diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md new file mode 100644 index 0000000..cf5e1fd --- /dev/null +++ b/doc/CHANGELOG.md @@ -0,0 +1,90 @@ +# 功能更新日志 + +## V1.1 版本 (2026-02-26) + +### 新增功能 + +#### 1. 日志类型管理 +- **日志分类**:新增日志类型字段,支持 4 种类型 + - 1 - 工作计划 + - 2 - 工作日志(默认) + - 3 - 个人日志 + - 9 - 其他 + +#### 2. 业务规则优化 +- **唯一性约束**:工作计划和工作日志,同一天只能各创建一条 +- **灵活性支持**:个人日志和其他类型,同一天可以创建多条 + +#### 3. 日历交互升级 +- **原交互**:点击日期直接显示单条日志详情 +- **新交互**:点击日期显示日志列表弹窗 + - 列表按类型排序显示(工作计划 → 工作日志 → 个人日志 → 其他) + - 点击列表项查看详情 + - 详情页支持返回列表 + +### 数据库变更 + +#### work_log 表 +- 新增字段:`log_type TINYINT(1) NOT NULL DEFAULT 2` +- 新增索引:`idx_user_date_type (user_id, log_date, log_type)` + +### API 变更 + +#### 破坏性变更 +- **接口**:`GET /api/v1/log/by-date` +- **变更**:返回值从单个 `LogVO` 改为 `List` +- **影响**:前端需同步更新 + +#### 新增字段 +- **LogCreateDTO**:新增 `logType` 字段(可选,默认值2) +- **LogVO**:新增 `logType` 和 `logTypeDesc` 字段 + +### 前端变更 + +#### 管理后台 (worklog-web) +1. 日志管理页面:增加类型选择下拉框和类型列显示 +2. 首页日历:重构为列表+详情双层弹窗交互 +3. 首页人员查询:新增“添加日志”按钮,跳转日志管理并打开新建弹窗 + +#### 移动端 (worklog-mobile) +1. 首页日历:重构为列表模式+详情模式切换 +2. 日志创建:增加类型选择器 + +### 测试覆盖 + +- 单元测试:新增 15 个测试用例,覆盖类型创建、唯一性约束、列表排序等 +- 测试通过率:35/36 (97.2%) +- 跳过测试:1 个(deleteLog_success,原因:MyBatis Plus Lambda缓存在Mock环境下的限制) + +### 文档更新 + +- ✅ 产品需求文档 PRD (V1.0 → V1.1) +- ✅ 数据库初始化脚本注释 +- ✅ 数据库迁移脚本 (v1.1_add_log_type.sql) +- ✅ 功能更新日志 + +### 兼容性说明 + +- 现有日志数据的 `log_type` 字段自动设置为默认值 2(工作日志) +- 前后端需同步部署,API 接口有破坏性变更 + +### 回滚方案 + +如需回滚到 V1.0 版本: + +```sql +-- 删除日志类型字段和索引 +ALTER TABLE work_log DROP INDEX idx_user_date_type; +ALTER TABLE work_log DROP COLUMN log_type; +``` + +然后部署 V1.0 版本的前后端代码。 + +--- + +## V1.0 版本 (初始版本) + +- 基础的工作日志管理功能 +- 用户管理 +- 日志模板管理 +- PC 端和移动端支持 diff --git a/worklog-web/src/views/dashboard/index.vue b/worklog-web/src/views/dashboard/index.vue index 0a48984..8a95638 100644 --- a/worklog-web/src/views/dashboard/index.vue +++ b/worklog-web/src/views/dashboard/index.vue @@ -12,6 +12,7 @@ /> 当前查看:{{ selectedUserName }} 的日志 + 添加日志 @@ -45,22 +46,89 @@ + + + + + + + + + + + + - + - +
- +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
预览
+
+
+
+
+
+
@@ -69,10 +137,14 @@ 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 { getCalendarData, getLogsByDate, createLog } from '@/api/log' import { listEnabledUsers } from '@/api/user' +import { listEnabledTemplates } from '@/api/template' import type { Log } from '@/api/log' import type { User } from '@/api/user' +import type { Template } from '@/api/template' +import type { FormInstance, FormRules } from 'element-plus' +import { ElMessage } from 'element-plus' const userStore = useUserStore() @@ -84,47 +156,135 @@ const currentMonth = ref(today.getMonth() + 1) const calendarDate = ref(today) const logDates = ref>(new Set()) -const logDialogVisible = ref(false) +const logList = ref([]) const selectedLog = ref(null) +const showListDialog = ref(false) +const showDetailDialog = ref(false) const selectedDateStr = ref('') +// 新增日志 +const showAddDialog = ref(false) +const submitLoading = ref(false) +const formRef = ref() +const templateList = ref([]) +const form = ref({ + logDate: '', + logType: 2, + title: '', + content: '', + templateId: '' +}) + +const rules: FormRules = { + logDate: [{ required: true, message: '请选择日期', trigger: 'change' }], + logType: [{ required: true, message: '请选择日志类型', trigger: 'change' }], + title: [{ required: true, message: '请输入标题', trigger: 'blur' }] +} + // 渲染 Markdown 内容 -const renderedContent = computed(() => { - if (!selectedLog.value?.content) return '

暂无内容

' - - let content = selectedLog.value.content - - // 简单的 Markdown 渲染 - // 转义 HTML +function renderMarkdown(source: string) { + if (!source) return '

暂无内容

' + + let content = source + + const codeBlocks: string[] = [] + content = content.replace(/```([\s\S]*?)```/g, (_match, code) => { + const idx = codeBlocks.length + codeBlocks.push(code) + return `__CODE_BLOCK_${idx}__` + }) + + const tableBlocks: string[] = [] + content = content.replace(/(^|\n)((?:\|?.+\|?.*\n)+)(?=\n|$)/g, (match) => { + const lines = match.trim().split('\n') + if (lines.length < 2) return match + + const isSeparator = (line: string) => /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/.test(line) + if (!isSeparator(lines[1] || '')) return match + + const splitRow = (line: string) => + line + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map(cell => cell.trim()) + + const headerCells = splitRow(lines[0] || '') + const alignCells = splitRow(lines[1] || '') + const aligns = alignCells.map(cell => { + const left = cell.startsWith(':') + const right = cell.endsWith(':') + if (left && right) return 'center' + if (right) return 'right' + return 'left' + }) + + const bodyRows = lines.slice(2).filter(l => l.trim().length > 0) + const renderInline = (text: string) => { + let t = text + t = t.replace(/&/g, '&').replace(//g, '>') + t = t.replace(/\*\*(.+?)\*\*/g, '$1') + t = t.replace(/\*(.+?)\*/g, '$1') + t = t.replace(/`(.+?)`/g, '$1') + return t + } + + const thead = `${headerCells + .map((cell, i) => `${renderInline(cell)}`) + .join('')}` + + const tbody = `${bodyRows + .map(row => { + const cells = splitRow(row) + return `${cells + .map((cell, i) => `${renderInline(cell)}`) + .join('')}` + }) + .join('')}` + + const tableHtml = `${thead}${tbody}
` + const idx = tableBlocks.length + tableBlocks.push(tableHtml) + return `\n__TABLE_BLOCK_${idx}__\n` + }) + content = content .replace(/&/g, '&') .replace(//g, '>') - - // 标题 + content = content.replace(/^### (.+)$/gm, '

$1

') content = content.replace(/^## (.+)$/gm, '

$1

') content = content.replace(/^# (.+)$/gm, '

$1

') - - // 粗体和斜体 + content = content.replace(/\*\*(.+?)\*\*/g, '$1') content = content.replace(/\*(.+?)\*/g, '$1') - - // 代码块 - content = content.replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
') + content = content.replace(/`(.+?)`/g, '$1') - - // 列表 + content = content.replace(/^- (.+)$/gm, '
  • $1
  • ') content = content.replace(/(
  • .*<\/li>\n?)+/g, '
      $&
    ') content = content.replace(/^\d+\. (.+)$/gm, '
  • $1
  • ') - - // 换行 + content = content.replace(/\n\n/g, '

    ') content = content.replace(/\n/g, '
    ') - + + tableBlocks.forEach((table, idx) => { + content = content.replace(`__TABLE_BLOCK_${idx}__`, table) + }) + + codeBlocks.forEach((code, idx) => { + const escaped = code.replace(/&/g, '&').replace(//g, '>') + content = content.replace(`__CODE_BLOCK_${idx}__`, `

    ${escaped}
    `) + }) + return `

    ${content}

    ` -}) +} + +const renderedContent = computed(() => renderMarkdown(selectedLog.value?.content || '')) + +const addRenderedContent = computed(() => renderMarkdown(form.value.content)) // 用户选择相关 const userList = ref([]) @@ -155,11 +315,63 @@ async function loadCalendarData() { } } +async function loadTemplates() { + try { + templateList.value = await listEnabledTemplates() + } catch { + // 忽略错误 + } +} + // 用户选择变更 function handleUserChange() { loadCalendarData() } +function handleAddLog() { + resetForm() + showAddDialog.value = true +} + +function resetForm() { + form.value = { + logDate: new Date().toISOString().split('T')[0] || '', + logType: 2, + title: '', + content: '', + templateId: '' + } +} + +function handleTemplateChange(templateId: string) { + if (!templateId) return + const template = templateList.value.find(t => t.id === templateId) + if (template && template.content) { + form.value.content = template.content + } +} + +async function handleSubmit() { + const valid = await formRef.value?.validate() + if (!valid) return + + submitLoading.value = true + try { + await createLog({ + logDate: form.value.logDate, + logType: form.value.logType, + title: form.value.title, + content: form.value.content, + templateId: form.value.templateId || undefined + }) + ElMessage.success('创建成功') + showAddDialog.value = false + loadCalendarData() + } finally { + submitLoading.value = false + } +} + // 判断是否为当前月份 function isCurrentMonth(dateStr: string): boolean { const [year, month] = dateStr.split('-').map(Number) @@ -176,20 +388,36 @@ function getDayClass(dateStr: string): string { // 日期点击 async function handleDayClick(dateStr: string) { if (!isCurrentMonth(dateStr)) return - + selectedDateStr.value = dateStr + logList.value = [] selectedLog.value = null - + if (logDates.value.has(dateStr)) { try { - const log = await getLogByDate(dateStr) - selectedLog.value = log + const logs = await getLogsByDate(dateStr, selectedUserId.value || undefined) + logList.value = logs } catch { // 忽略错误 } } - - logDialogVisible.value = true + + // 显示列表弹窗 + showListDialog.value = true +} + +// 查看详情 +function handleViewDetail(log: Log) { + selectedLog.value = log + showListDialog.value = false + showDetailDialog.value = true +} + +// 返回列表 +function backToList() { + showDetailDialog.value = false + showListDialog.value = true + selectedLog.value = null } // 上个月 @@ -234,6 +462,7 @@ watch(calendarDate, (newDate) => { onMounted(() => { loadUsers() + loadTemplates() loadCalendarData() }) @@ -259,6 +488,106 @@ onMounted(() => { font-size: 14px; } +.add-log-btn { + margin-left: auto; +} + +.editor-wrap { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + width: 100%; +} + +.preview-panel { + border: 1px solid #ebeef5; + border-radius: 6px; + padding: 12px 14px; + min-height: 280px; + background: + radial-gradient(1200px 220px at -10% -10%, rgba(64, 158, 255, 0.08), transparent 60%), + radial-gradient(1200px 220px at 110% -10%, rgba(245, 108, 108, 0.06), transparent 60%), + #fcfcfd; +} + +.preview-title { + font-size: 12px; + color: #909399; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* 预览区 Markdown 细节 */ +.preview-panel .markdown-body { + font-size: 14px; + line-height: 1.8; + color: #303133; +} + +.preview-panel .markdown-body h1, +.preview-panel .markdown-body h2, +.preview-panel .markdown-body h3 { + margin: 16px 0 8px; + font-weight: 600; + line-height: 1.4; +} + +.preview-panel .markdown-body h1 { + font-size: 20px; + border-bottom: 1px solid #e6e8eb; + padding-bottom: 8px; +} + +.preview-panel .markdown-body h2 { + font-size: 18px; +} + +.preview-panel .markdown-body h3 { + font-size: 16px; +} + +.preview-panel .markdown-body p { + margin: 8px 0; +} + +.preview-panel .markdown-body ul { + margin: 8px 0; + padding-left: 24px; +} + +.preview-panel .markdown-body li { + margin: 4px 0; +} + +.preview-panel .markdown-body code { + background-color: #f1f3f5; + padding: 2px 6px; + border-radius: 4px; + font-family: 'Courier New', Courier, monospace; + font-size: 13px; +} + +.preview-panel .markdown-body pre { + background-color: #f1f3f5; + padding: 12px; + border-radius: 6px; + overflow-x: auto; + margin: 12px 0; + border: 1px solid #e6e8eb; +} + +.preview-panel .markdown-body pre code { + background-color: transparent; + padding: 0; +} + +@media (max-width: 768px) { + .editor-wrap { + grid-template-columns: 1fr; + } +} + .calendar-card { margin-bottom: 20px; } @@ -363,16 +692,20 @@ onMounted(() => { width: 100%; } -.header-left { +.header-info { display: flex; align-items: center; gap: 12px; + flex: 1; + justify-content: center; } -.log-date { - font-weight: bold; - font-size: 16px; - color: #303133; +.log-type-badge { + background-color: #409eff; + color: white; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; } .log-title { @@ -452,6 +785,28 @@ onMounted(() => { padding: 0; } +.markdown-body table { + width: 100%; + border-collapse: collapse; + margin: 12px 0; +} + +.markdown-body th, +.markdown-body td { + border: 1px solid #e6e8eb; + padding: 6px 10px; + text-align: left; +} + +.markdown-body thead th { + background-color: #f8f9fb; + font-weight: 600; +} + +.markdown-body tbody tr:nth-child(2n) td { + background-color: #fcfcfd; +} + .empty-content { color: #909399; text-align: center; diff --git a/worklog-web/src/views/log/index.vue b/worklog-web/src/views/log/index.vue index a688773..cce2b47 100644 --- a/worklog-web/src/views/log/index.vue +++ b/worklog-web/src/views/log/index.vue @@ -38,6 +38,7 @@ + @@ -77,6 +78,14 @@ :disabled="!!form.id" /> + + + + + + + + @@ -91,12 +100,18 @@ - +
    + +
    +
    预览
    +
    +
    +