后端: - 日历接口支持userId参数(管理员可查他人) - 新增用户列表接口listEnabledUsers 管理后台: - 新增首页日历视图展示日志录入情况 - 管理员可选择人员查看日志日历 - 日历支持月份切换 - 日志详情弹窗支持Markdown渲染 - 工作日志列表新增人员筛选条件 - 列表查看详情支持Markdown渲染 移动端: - 登录页面样式调整(居中、输入框宽度) - 日历日期样式调整(红色数字无边框) - 日志列表UI修复(标题遮挡、按钮分布) - 首页管理员人员选择功能 - 日志详情支持Markdown渲染
460 lines
9.8 KiB
Vue
460 lines
9.8 KiB
Vue
<template>
|
||
<div class="dashboard-page">
|
||
<!-- 管理员人员选择器 -->
|
||
<el-card v-if="isAdmin" class="user-selector-card">
|
||
<el-select v-model="selectedUserId" placeholder="选择人员查看日志" clearable style="width: 200px;" @change="handleUserChange">
|
||
<el-option label="自己" value="" />
|
||
<el-option
|
||
v-for="user in userList"
|
||
:key="user.id"
|
||
:label="user.name"
|
||
:value="user.id"
|
||
/>
|
||
</el-select>
|
||
<span class="selector-hint" v-if="selectedUserId">当前查看:{{ selectedUserName }} 的日志</span>
|
||
</el-card>
|
||
|
||
<!-- 日历卡片 -->
|
||
<el-card class="calendar-card">
|
||
<!-- 月份切换 -->
|
||
<div class="calendar-header">
|
||
<el-button @click="prevMonth" :icon="ArrowLeft" circle />
|
||
<span class="month-title">{{ currentYear }}年{{ currentMonth }}月</span>
|
||
<el-button @click="nextMonth" :icon="ArrowRight" circle />
|
||
</div>
|
||
|
||
<!-- 日历 -->
|
||
<el-calendar v-model="calendarDate">
|
||
<template #date-cell="{ data }">
|
||
<div
|
||
class="calendar-day"
|
||
:class="getDayClass(data.day)"
|
||
@click="handleDayClick(data.day)"
|
||
>
|
||
<span class="day-number">{{ data.day.split('-')[2] }}</span>
|
||
<span v-if="logDates.has(data.day)" class="log-indicator has-log">✓</span>
|
||
<span v-else-if="isCurrentMonth(data.day)" class="log-indicator no-log">○</span>
|
||
</div>
|
||
</template>
|
||
</el-calendar>
|
||
|
||
<!-- 图例 -->
|
||
<div class="calendar-legend">
|
||
<span class="legend-item"><span class="dot blue"></span>已记录</span>
|
||
<span class="legend-item"><span class="dot red"></span>未记录</span>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 日志详情弹窗 -->
|
||
<el-dialog v-model="logDialogVisible" width="700px" :show-close="true">
|
||
<template #header>
|
||
<div class="log-detail-header" v-if="selectedLog">
|
||
<div class="header-left">
|
||
<span class="log-date">{{ selectedDateStr }}</span>
|
||
<span class="log-title">{{ selectedLog.title }}</span>
|
||
</div>
|
||
<span class="log-user">{{ selectedLog.userName }}</span>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="log-detail-content" v-if="selectedLog">
|
||
<div class="markdown-body" v-html="renderedContent"></div>
|
||
</div>
|
||
<el-empty v-else description="暂无日志记录" />
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, watch } from 'vue'
|
||
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
|
||
import { useUserStore } from '@/store/user'
|
||
import { getCalendarData, getLogByDate } from '@/api/log'
|
||
import { listEnabledUsers } from '@/api/user'
|
||
import type { Log } from '@/api/log'
|
||
import type { User } from '@/api/user'
|
||
|
||
const userStore = useUserStore()
|
||
|
||
const isAdmin = computed(() => userStore.isAdmin())
|
||
|
||
const today = new Date()
|
||
const currentYear = ref(today.getFullYear())
|
||
const currentMonth = ref(today.getMonth() + 1)
|
||
const calendarDate = ref(today)
|
||
|
||
const logDates = ref<Set<string>>(new Set())
|
||
const logDialogVisible = ref(false)
|
||
const selectedLog = ref<Log | null>(null)
|
||
const selectedDateStr = ref('')
|
||
|
||
// 渲染 Markdown 内容
|
||
const renderedContent = computed(() => {
|
||
if (!selectedLog.value?.content) return '<p class="empty-content">暂无内容</p>'
|
||
|
||
let content = selectedLog.value.content
|
||
|
||
// 简单的 Markdown 渲染
|
||
// 转义 HTML
|
||
content = content
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
|
||
// 标题
|
||
content = content.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||
content = content.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||
content = content.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||
|
||
// 粗体和斜体
|
||
content = content.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||
content = content.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||
|
||
// 代码块
|
||
content = content.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
|
||
content = content.replace(/`(.+?)`/g, '<code>$1</code>')
|
||
|
||
// 列表
|
||
content = content.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||
content = content.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>')
|
||
content = content.replace(/^\d+\. (.+)$/gm, '<li>$1</li>')
|
||
|
||
// 换行
|
||
content = content.replace(/\n\n/g, '</p><p>')
|
||
content = content.replace(/\n/g, '<br>')
|
||
|
||
return `<p>${content}</p>`
|
||
})
|
||
|
||
// 用户选择相关
|
||
const userList = ref<User[]>([])
|
||
const selectedUserId = ref<string>('')
|
||
const selectedUserName = computed(() => {
|
||
if (!selectedUserId.value) return '自己'
|
||
const user = userList.value.find(u => u.id === selectedUserId.value)
|
||
return user?.name || ''
|
||
})
|
||
|
||
// 加载用户列表
|
||
async function loadUsers() {
|
||
if (!isAdmin.value) return
|
||
try {
|
||
userList.value = await listEnabledUsers()
|
||
} catch {
|
||
// 忽略错误
|
||
}
|
||
}
|
||
|
||
// 加载日历数据
|
||
async function loadCalendarData() {
|
||
try {
|
||
const dates = await getCalendarData(currentYear.value, currentMonth.value, selectedUserId.value || undefined)
|
||
logDates.value = new Set(dates)
|
||
} catch {
|
||
// 忽略错误
|
||
}
|
||
}
|
||
|
||
// 用户选择变更
|
||
function handleUserChange() {
|
||
loadCalendarData()
|
||
}
|
||
|
||
// 判断是否为当前月份
|
||
function isCurrentMonth(dateStr: string): boolean {
|
||
const [year, month] = dateStr.split('-').map(Number)
|
||
return year === currentYear.value && month === currentMonth.value
|
||
}
|
||
|
||
// 获取日期样式类
|
||
function getDayClass(dateStr: string): string {
|
||
if (!isCurrentMonth(dateStr)) return 'other-month'
|
||
if (logDates.value.has(dateStr)) return 'has-log'
|
||
return 'no-log'
|
||
}
|
||
|
||
// 日期点击
|
||
async function handleDayClick(dateStr: string) {
|
||
if (!isCurrentMonth(dateStr)) return
|
||
|
||
selectedDateStr.value = dateStr
|
||
selectedLog.value = null
|
||
|
||
if (logDates.value.has(dateStr)) {
|
||
try {
|
||
const log = await getLogByDate(dateStr)
|
||
selectedLog.value = log
|
||
} catch {
|
||
// 忽略错误
|
||
}
|
||
}
|
||
|
||
logDialogVisible.value = true
|
||
}
|
||
|
||
// 上个月
|
||
function prevMonth() {
|
||
if (currentMonth.value === 1) {
|
||
currentYear.value--
|
||
currentMonth.value = 12
|
||
} else {
|
||
currentMonth.value--
|
||
}
|
||
updateCalendarDate()
|
||
loadCalendarData()
|
||
}
|
||
|
||
// 下个月
|
||
function nextMonth() {
|
||
if (currentMonth.value === 12) {
|
||
currentYear.value++
|
||
currentMonth.value = 1
|
||
} else {
|
||
currentMonth.value++
|
||
}
|
||
updateCalendarDate()
|
||
loadCalendarData()
|
||
}
|
||
|
||
// 更新日历组件日期
|
||
function updateCalendarDate() {
|
||
calendarDate.value = new Date(currentYear.value, currentMonth.value - 1, 1)
|
||
}
|
||
|
||
// 监听日历组件月份变化
|
||
watch(calendarDate, (newDate) => {
|
||
const year = newDate.getFullYear()
|
||
const month = newDate.getMonth() + 1
|
||
if (year !== currentYear.value || month !== currentMonth.value) {
|
||
currentYear.value = year
|
||
currentMonth.value = month
|
||
loadCalendarData()
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
loadUsers()
|
||
loadCalendarData()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.dashboard-page {
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.user-selector-card {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.user-selector-card :deep(.el-card__body) {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.selector-hint {
|
||
color: #606266;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.calendar-card {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.calendar-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 20px;
|
||
padding-bottom: 16px;
|
||
border-bottom: 1px solid #ebeef5;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.month-title {
|
||
font-size: 18px;
|
||
font-weight: 500;
|
||
color: #303133;
|
||
min-width: 120px;
|
||
text-align: center;
|
||
}
|
||
|
||
.calendar-day {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.calendar-day:hover {
|
||
background-color: #f5f7fa;
|
||
}
|
||
|
||
.day-number {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.log-indicator {
|
||
font-size: 12px;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.has-log .day-number,
|
||
.has-log .log-indicator {
|
||
color: #409eff;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.no-log .day-number,
|
||
.no-log .log-indicator {
|
||
color: #f56c6c;
|
||
}
|
||
|
||
.other-month .day-number {
|
||
color: #c0c4cc;
|
||
}
|
||
|
||
.other-month .log-indicator {
|
||
display: none;
|
||
}
|
||
|
||
.calendar-legend {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 24px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid #ebeef5;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 14px;
|
||
color: #606266;
|
||
}
|
||
|
||
.dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.dot.blue {
|
||
background-color: #409eff;
|
||
}
|
||
|
||
.dot.red {
|
||
background-color: #f56c6c;
|
||
}
|
||
|
||
/* 日志详情弹窗样式 */
|
||
.log-detail-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
width: 100%;
|
||
}
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.log-date {
|
||
font-weight: bold;
|
||
font-size: 16px;
|
||
color: #303133;
|
||
}
|
||
|
||
.log-title {
|
||
font-weight: bold;
|
||
font-size: 16px;
|
||
color: #303133;
|
||
}
|
||
|
||
.log-user {
|
||
color: #909399;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.log-detail-content {
|
||
padding: 16px 0;
|
||
}
|
||
|
||
.markdown-body {
|
||
font-size: 14px;
|
||
line-height: 1.8;
|
||
color: #303133;
|
||
}
|
||
|
||
.markdown-body h1,
|
||
.markdown-body h2,
|
||
.markdown-body h3 {
|
||
margin: 16px 0 8px;
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.markdown-body h1 {
|
||
font-size: 20px;
|
||
border-bottom: 1px solid #ebeef5;
|
||
padding-bottom: 8px;
|
||
}
|
||
|
||
.markdown-body h2 {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.markdown-body h3 {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.markdown-body p {
|
||
margin: 8px 0;
|
||
}
|
||
|
||
.markdown-body ul {
|
||
margin: 8px 0;
|
||
padding-left: 24px;
|
||
}
|
||
|
||
.markdown-body li {
|
||
margin: 4px 0;
|
||
}
|
||
|
||
.markdown-body code {
|
||
background-color: #f5f7fa;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-family: 'Courier New', Courier, monospace;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.markdown-body pre {
|
||
background-color: #f5f7fa;
|
||
padding: 12px;
|
||
border-radius: 6px;
|
||
overflow-x: auto;
|
||
margin: 12px 0;
|
||
}
|
||
|
||
.markdown-body pre code {
|
||
background-color: transparent;
|
||
padding: 0;
|
||
}
|
||
|
||
.empty-content {
|
||
color: #909399;
|
||
text-align: center;
|
||
}
|
||
</style>
|