zhangjf 8c16de26ad feat: 管理后台首页日历及日志详情Markdown支持
后端:
- 日历接口支持userId参数(管理员可查他人)
- 新增用户列表接口listEnabledUsers

管理后台:
- 新增首页日历视图展示日志录入情况
- 管理员可选择人员查看日志日历
- 日历支持月份切换
- 日志详情弹窗支持Markdown渲染
- 工作日志列表新增人员筛选条件
- 列表查看详情支持Markdown渲染

移动端:
- 登录页面样式调整(居中、输入框宽度)
- 日历日期样式调整(红色数字无边框)
- 日志列表UI修复(标题遮挡、按钮分布)
- 首页管理员人员选择功能
- 日志详情支持Markdown渲染
2026-02-25 01:25:36 +08:00

460 lines
9.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 标题
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>