feat: 管理后台首页日历及日志详情Markdown支持

后端:
- 日历接口支持userId参数(管理员可查他人)
- 新增用户列表接口listEnabledUsers

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

移动端:
- 登录页面样式调整(居中、输入框宽度)
- 日历日期样式调整(红色数字无边框)
- 日志列表UI修复(标题遮挡、按钮分布)
- 首页管理员人员选择功能
- 日志详情支持Markdown渲染
This commit is contained in:
zhangjf 2026-02-25 01:25:36 +08:00
parent 4218128e38
commit 8c16de26ad
18 changed files with 954 additions and 39 deletions

View File

@ -127,8 +127,9 @@ public class LogController {
@GetMapping("/calendar")
public Result<List<String>> getCalendarData(
@Parameter(description = "年份") @RequestParam Integer year,
@Parameter(description = "月份1-12") @RequestParam Integer month) {
Set<LocalDate> dates = logService.getLogDatesByMonth(year, month);
@Parameter(description = "月份1-12") @RequestParam Integer month,
@Parameter(description = "用户ID管理员可用不传则查当前用户") @RequestParam(required = false) String userId) {
Set<LocalDate> dates = logService.getLogDatesByMonth(year, month, userId);
List<String> dateStrings = dates.stream()
.map(LocalDate::toString)
.toList();

View File

@ -15,6 +15,8 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 用户控制器
* 处理用户管理相关请求
@ -117,4 +119,14 @@ public class UserController {
userService.resetPassword(id, newPassword);
return Result.success();
}
/**
* 获取所有启用的用户列表
*/
@Operation(summary = "获取启用用户列表", description = "获取所有启用状态的用户列表")
@GetMapping("/list")
public Result<List<UserVO>> listEnabledUsers() {
List<UserVO> users = userService.listEnabledUsers();
return Result.success(users);
}
}

View File

@ -77,6 +77,16 @@ public interface LogService {
*/
Set<LocalDate> getLogDatesByMonth(int year, int month);
/**
* 获取指定用户指定月份有日志的日期列表管理员用
*
* @param year 年份
* @param month 月份1-12
* @param userId 用户ID可选为空则查当前用户
* @return 有日志的日期集合
*/
Set<LocalDate> getLogDatesByMonth(int year, int month, String userId);
/**
* 根据日期获取日志
*

View File

@ -5,6 +5,8 @@ import com.wjbl.worklog.dto.UserCreateDTO;
import com.wjbl.worklog.dto.UserUpdateDTO;
import com.wjbl.worklog.vo.UserVO;
import java.util.List;
/**
* 用户服务接口
* 处理用户相关的业务逻辑
@ -70,4 +72,11 @@ public interface UserService {
* @param newPassword 新密码
*/
void resetPassword(String id, String newPassword);
/**
* 获取所有启用的用户列表
*
* @return 用户列表
*/
List<UserVO> listEnabledUsers();
}

View File

@ -200,6 +200,17 @@ public class LogServiceImpl implements LogService {
return workLogDataService.getLogDatesByMonth(currentUserId, year, month);
}
@Override
public Set<LocalDate> getLogDatesByMonth(int year, int month, String userId) {
// 如果指定了用户ID则查询指定用户的日志管理员
// 否则查询当前用户的日志
String targetUserId = userId;
if (targetUserId == null || targetUserId.isEmpty()) {
targetUserId = UserContext.getUserId();
}
return workLogDataService.getLogDatesByMonth(targetUserId, year, month);
}
@Override
public LogVO getLogByDate(LocalDate date) {
String currentUserId = UserContext.getUserId();

View File

@ -18,6 +18,7 @@ import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
/**
* 用户服务实现类
@ -194,6 +195,18 @@ public class UserServiceImpl implements UserService {
log.info("重置用户密码成功:{}", user.getUsername());
}
@Override
public List<UserVO> listEnabledUsers() {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getStatus, 1)
.orderByAsc(User::getName);
List<User> users = userDataService.list(wrapper);
return users.stream()
.map(this::convertToVO)
.toList();
}
/**
* 转换为 VO
*/

View File

@ -70,8 +70,8 @@ export function deleteLog(id: string): Promise<void> {
}
// 获取日历数据(有日志的日期列表)
export function getCalendarData(year: number, month: number): Promise<string[]> {
return request.get('/log/calendar', { params: { year, month } })
export function getCalendarData(year: number, month: number, userId?: string): Promise<string[]> {
return request.get('/log/calendar', { params: { year, month, userId } })
}
// 获取指定日期的日志

View File

@ -0,0 +1,20 @@
// 用户相关 API
import { request } from '@/utils/request'
// 用户信息
export interface User {
id: string
username: string
name: string
phone: string
email: string
position: string
description: string
role: string
status: number
}
// 获取所有启用的用户列表
export function listEnabledUsers(): Promise<User[]> {
return request.get('/user/list')
}

View File

@ -3,6 +3,20 @@
<van-nav-bar title="工作日志" />
<div class="page-content">
<!-- 管理员人员选择器 -->
<van-cell-group v-if="isAdmin" inset class="user-selector">
<van-cell
is-link
:title="selectedUserName"
@click="showUserPicker = true"
>
<template #title>
<span class="selector-label">查看人员</span>
<span class="selector-value">{{ selectedUserName }}</span>
</template>
</van-cell>
</van-cell-group>
<!-- 日历 -->
<van-cell-group inset class="calendar-card">
<!-- 月份切换 -->
@ -59,13 +73,13 @@
</div>
</van-cell-group>
<div class="popup-actions">
<div class="popup-actions" v-if="!isAdminView">
<van-button type="primary" block @click="goEdit">编辑日志</van-button>
</div>
</template>
<!-- 新建日志表单 -->
<template v-if="isCreateMode">
<!-- 新建日志表单只有查看自己的日志时才能新建 -->
<template v-if="isCreateMode && !isAdminView">
<van-form @submit="handleCreateLog">
<van-cell-group inset>
<van-field
@ -102,6 +116,13 @@
</div>
</van-form>
</template>
<!-- 管理员查看模式无日志提示 -->
<template v-if="isCreateMode && isAdminView">
<div class="empty-log">
<p>该日期暂无日志记录</p>
</div>
</template>
</div>
</van-popup>
@ -115,6 +136,16 @@
/>
</van-popup>
<!-- 用户选择器 -->
<van-popup v-model:show="showUserPicker" position="bottom" round>
<van-picker
:columns="userColumns"
title="选择人员"
@confirm="onUserConfirm"
@cancel="showUserPicker = false"
/>
</van-popup>
<!-- 底部导航 -->
<van-tabbar v-model="active" route>
<van-tabbar-item icon="home-o" to="/">首页</van-tabbar-item>
@ -127,13 +158,17 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { useUserStore } from '@/store/user'
import { getCalendarData, getLogByDate, createLog } from '@/api/log'
import { listEnabledTemplates } from '@/api/template'
import { listEnabledUsers } from '@/api/user'
import type { Log } from '@/api/log'
import type { Template } from '@/api/template'
import type { User } from '@/api/user'
import type { CalendarDayItem } from 'vant'
const router = useRouter()
const userStore = useUserStore()
const active = ref(0)
const today = new Date()
@ -150,6 +185,17 @@ const selectedLog = ref<Log | null>(null)
const selectedDateStr = ref('')
const isCreateMode = ref(false)
//
const isAdmin = computed(() => userStore.userInfo?.role === 'ADMIN')
const showUserPicker = ref(false)
const users = ref<User[]>([])
const userColumns = ref<{ text: string; value: string }[]>([])
const selectedUserId = ref<string>('')
const selectedUserName = ref<string>('自己')
//
const isAdminView = computed(() => isAdmin.value && selectedUserId.value !== '')
//
const createLoading = ref(false)
const createForm = reactive({
@ -163,10 +209,28 @@ const showTemplatePicker = ref(false)
const templates = ref<Template[]>([])
const templateColumns = ref<{ text: string; value: string }[]>([])
//
async function loadUsers() {
if (!isAdmin.value) return
try {
users.value = await listEnabledUsers()
userColumns.value = [
{ text: '自己', value: '' },
...users.value.map(u => ({
text: u.name,
value: u.id
}))
]
} catch {
//
}
}
//
async function loadCalendarData() {
try {
const dates = await getCalendarData(currentYear.value, currentMonth.value)
const dates = await getCalendarData(currentYear.value, currentMonth.value, selectedUserId.value || undefined)
logDates.value = new Set(dates)
} catch {
//
@ -233,7 +297,7 @@ async function onDateSelect(date: Date) {
//
}
} else {
//
//
isCreateMode.value = true
resetCreateForm()
}
@ -263,6 +327,15 @@ function onTemplateConfirm({ selectedValues }: { selectedValues: string[] }) {
showTemplatePicker.value = false
}
//
function onUserConfirm({ selectedValues, selectedOptions }: { selectedValues: string[]; selectedOptions: { text: string; value: string }[] }) {
selectedUserId.value = selectedValues[0]
selectedUserName.value = selectedOptions[0].text
showUserPicker.value = false
//
loadCalendarData()
}
//
async function handleCreateLog() {
createLoading.value = true
@ -313,6 +386,7 @@ function goEdit() {
}
onMounted(() => {
loadUsers()
loadCalendarData()
loadTemplates()
})
@ -328,6 +402,19 @@ onMounted(() => {
padding-bottom: 60px;
}
.user-selector {
margin: 16px;
}
.selector-label {
color: #969799;
}
.selector-value {
color: #333;
font-weight: 500;
}
.calendar-card {
margin: 16px;
overflow: hidden;
@ -430,6 +517,16 @@ onMounted(() => {
.popup-actions {
margin: 16px;
}
.empty-log {
text-align: center;
padding: 40px 20px;
}
.empty-log p {
color: #969799;
margin-bottom: 20px;
}
</style>
<style>
@ -453,8 +550,6 @@ onMounted(() => {
.van-calendar__day.no-log {
color: #ee0a24;
border: 1px solid #ee0a24 !important;
border-radius: 4px;
}
.van-calendar__day.no-log .van-calendar__bottom-info {

View File

@ -14,7 +14,7 @@
<van-cell-group inset title="内容">
<div class="content-box">
<pre>{{ log.content || '暂无内容' }}</pre>
<div class="markdown-body" v-html="renderedContent"></div>
</div>
</van-cell-group>
@ -26,7 +26,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showSuccessToast, showConfirmDialog } from 'vant'
import { getLogById, deleteLog } from '@/api/log'
@ -46,6 +46,44 @@ const log = ref<Log>({
updatedTime: ''
})
// Markdown
const renderedContent = computed(() => {
if (!log.value.content) return '<p class="empty-content">暂无内容</p>'
let content = log.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>`
})
onMounted(async () => {
const id = route.params.id as string
try {
@ -83,13 +121,71 @@ async function handleDelete() {
padding: 12px 16px;
}
.content-box pre {
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
font-family: inherit;
.markdown-body {
font-size: 14px;
line-height: 1.6;
line-height: 1.8;
color: #333;
}
.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: 18px;
border-bottom: 1px solid #ebedf0;
padding-bottom: 8px;
}
.markdown-body h2 {
font-size: 16px;
}
.markdown-body h3 {
font-size: 15px;
}
.markdown-body p {
margin: 8px 0;
}
.markdown-body ul {
margin: 8px 0;
padding-left: 20px;
}
.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: #969799;
text-align: center;
}
.action-btn {

View File

@ -149,6 +149,7 @@ async function handleDelete(item: Log) {
.page-content {
padding: 16px;
padding-top: 62px;
padding-bottom: 80px;
}
@ -208,7 +209,7 @@ async function handleDelete(item: Log) {
.log-actions {
display: flex;
align-items: center;
justify-content: center;
justify-content: space-around;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}

View File

@ -166,9 +166,7 @@ async function handleLogin() {
border-radius: 12px;
border: 1px solid #e8e8e8;
overflow: hidden;
margin: 0 20px;
width: 100%;
max-width: 400px;
}
.form-card .van-field {

View File

@ -6,6 +6,7 @@ import type { PageResult } from './user'
export interface Log {
id: string
userId: string
userName?: string
logDate: string
title: string
content?: string
@ -68,3 +69,13 @@ export function updateLog(id: string, data: UpdateLogParams): Promise<Log> {
export function deleteLog(id: string): Promise<void> {
return request.delete(`/log/${id}`)
}
// 获取日历数据(有日志的日期列表)
export function getCalendarData(year: number, month: number, userId?: string): Promise<string[]> {
return request.get('/log/calendar', { params: { year, month, userId } })
}
// 获取指定日期的日志
export function getLogByDate(date: string): Promise<Log | null> {
return request.get('/log/by-date', { params: { date } })
}

View File

@ -85,3 +85,8 @@ export function deleteUser(id: string): Promise<void> {
export function resetPassword(id: string, newPassword: string): Promise<void> {
return request.put(`/user/${id}/password`, null, { params: { newPassword } })
}
// 获取所有启用的用户列表
export function listEnabledUsers(): Promise<User[]> {
return request.get('/user/list')
}

View File

@ -13,8 +13,14 @@ const routes: RouteRecordRaw[] = [
path: '/',
name: 'Layout',
component: () => import('@/views/layout/index.vue'),
redirect: '/log',
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '首页', icon: 'HomeFilled' }
},
{
path: 'user',
name: 'User',

View File

@ -0,0 +1,459 @@
<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>

View File

@ -12,6 +12,10 @@
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<el-menu-item index="/dashboard">
<el-icon><HomeFilled /></el-icon>
<span>首页</span>
</el-menu-item>
<el-menu-item index="/log">
<el-icon><Notebook /></el-icon>
<span>工作日志</span>
@ -57,7 +61,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Notebook, Document, User } from '@element-plus/icons-vue'
import { HomeFilled, Notebook, Document, User } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user'
const route = useRoute()

View File

@ -3,6 +3,16 @@
<el-card>
<!-- 搜索栏 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="人员">
<el-select v-model="searchForm.userId" placeholder="全部人员" clearable style="width: 150px;">
<el-option
v-for="user in userList"
:key="user.id"
:label="user.name"
:value="user.id"
/>
</el-select>
</el-form-item>
<el-form-item label="日期范围">
<el-date-picker
v-model="dateRange"
@ -28,6 +38,7 @@
<!-- 表格 -->
<el-table :data="tableData" v-loading="loading" stripe>
<el-table-column prop="logDate" label="日期" width="120" />
<el-table-column prop="userName" label="操作人" width="100" />
<el-table-column prop="title" label="标题" width="200" />
<el-table-column prop="content" label="内容" show-overflow-tooltip />
<el-table-column prop="createdTime" label="创建时间" width="180" />
@ -95,14 +106,20 @@
</el-dialog>
<!-- 查看详情对话框 -->
<el-dialog v-model="viewDialogVisible" title="日志详情" width="700px">
<el-descriptions :column="2" border>
<el-descriptions-item label="日期">{{ viewData.logDate }}</el-descriptions-item>
<el-descriptions-item label="标题">{{ viewData.title }}</el-descriptions-item>
<el-descriptions-item label="内容" :span="2">
<pre class="content-pre">{{ viewData.content }}</pre>
</el-descriptions-item>
</el-descriptions>
<el-dialog v-model="viewDialogVisible" width="700px" :show-close="true">
<template #header>
<div class="log-detail-header">
<div class="header-left">
<span class="log-date">{{ viewData.logDate }}</span>
<span class="log-title">{{ viewData.title }}</span>
</div>
<span class="log-user">{{ viewData.userName }}</span>
</div>
</template>
<div class="log-detail-content">
<div class="markdown-body" v-html="viewRenderedContent"></div>
</div>
</el-dialog>
</div>
</template>
@ -110,21 +127,25 @@
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { pageMyLogs, createLog, updateLog, deleteLog } from '@/api/log'
import { pageAllLogs, createLog, updateLog, deleteLog } from '@/api/log'
import type { Log } from '@/api/log'
import { listEnabledTemplates } from '@/api/template'
import type { Template } from '@/api/template'
import { listEnabledUsers } from '@/api/user'
import type { User } from '@/api/user'
import type { FormInstance, FormRules } from 'element-plus'
const loading = ref(false)
const submitLoading = ref(false)
const tableData = ref<Log[]>([])
const templateList = ref<Template[]>([])
const userList = ref<User[]>([])
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(10)
const searchForm = reactive({
userId: '',
startDate: '',
endDate: ''
})
@ -162,17 +183,57 @@ const rules: FormRules = {
const viewDialogVisible = ref(false)
const viewData = reactive({
logDate: '',
userName: '',
title: '',
content: ''
})
// Markdown
const viewRenderedContent = computed(() => {
if (!viewData.content) return '<p class="empty-content">暂无内容</p>'
let content = viewData.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>`
})
//
async function loadData() {
loading.value = true
try {
const result = await pageMyLogs({
const result = await pageAllLogs({
pageNum: pageNum.value,
pageSize: pageSize.value,
userId: searchForm.userId || undefined,
startDate: searchForm.startDate || undefined,
endDate: searchForm.endDate || undefined
})
@ -188,6 +249,11 @@ async function loadTemplates() {
templateList.value = await listEnabledTemplates()
}
//
async function loadUsers() {
userList.value = await listEnabledUsers()
}
//
function handleSearch() {
pageNum.value = 1
@ -197,6 +263,7 @@ function handleSearch() {
//
function handleReset() {
dateRange.value = []
searchForm.userId = ''
searchForm.startDate = ''
searchForm.endDate = ''
handleSearch()
@ -225,6 +292,7 @@ async function handleEdit(row: Log) {
//
function handleView(row: Log) {
viewData.logDate = row.logDate
viewData.userName = row.userName || ''
viewData.title = row.title
viewData.content = row.content || ''
viewDialogVisible.value = true
@ -280,6 +348,7 @@ async function handleDelete(row: Log) {
//
loadData()
loadTemplates()
loadUsers()
</script>
<style scoped>
@ -300,10 +369,105 @@ loadTemplates()
justify-content: flex-end;
}
.content-pre {
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
font-family: inherit;
/* 日志详情弹窗样式 */
.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>