feat(admin): add dashboard log dialog and markdown preview

This commit is contained in:
zhangjf 2026-02-26 18:23:01 +08:00
parent 90a5c032c2
commit c18943911b
3 changed files with 727 additions and 68 deletions

90
doc/CHANGELOG.md Normal file
View File

@ -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<LogVO>`
- **影响**:前端需同步更新
#### 新增字段
- **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 端和移动端支持

View File

@ -12,6 +12,7 @@
/>
</el-select>
<span class="selector-hint" v-if="selectedUserId">当前查看{{ selectedUserName }} 的日志</span>
<el-button type="primary" class="add-log-btn" @click="handleAddLog">添加日志</el-button>
</el-card>
<!-- 日历卡片 -->
@ -45,22 +46,89 @@
</div>
</el-card>
<!-- 日志列表弹窗 -->
<el-dialog v-model="showListDialog" :title="`${selectedDateStr} 的日志`" width="600px">
<el-table :data="logList" stripe>
<el-table-column prop="logTypeDesc" label="类型" width="100" />
<el-table-column prop="title" label="标题" show-overflow-tooltip />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleViewDetail(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="logList.length === 0" description="暂无日志记录" />
</el-dialog>
<!-- 日志详情弹窗 -->
<el-dialog v-model="logDialogVisible" width="700px" :show-close="true">
<el-dialog v-model="showDetailDialog" 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 class="log-detail-header">
<el-button @click="backToList" :icon="ArrowLeft" text>返回列表</el-button>
<div class="header-info">
<span class="log-type-badge">{{ selectedLog?.logTypeDesc }}</span>
<span class="log-title">{{ selectedLog?.title }}</span>
</div>
<span class="log-user">{{ selectedLog.userName }}</span>
<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>
<!-- 新建日志弹窗 -->
<el-dialog v-model="showAddDialog" title="添加日志" width="700px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="日期" prop="logDate">
<el-date-picker
v-model="form.logDate"
type="date"
placeholder="请选择日期"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="日志类型" prop="logType">
<el-select v-model="form.logType" placeholder="请选择日志类型">
<el-option label="工作计划" :value="1" />
<el-option label="工作日志" :value="2" />
<el-option label="个人日志" :value="3" />
<el-option label="其他" :value="9" />
</el-select>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="选择模板">
<el-select v-model="form.templateId" placeholder="请选择模板" clearable @change="handleTemplateChange">
<el-option
v-for="item in templateList"
:key="item.id"
:label="item.templateName"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="内容">
<div class="editor-wrap">
<el-input
v-model="form.content"
type="textarea"
:rows="12"
placeholder="请输入日志内容支持Markdown格式"
/>
<div class="preview-panel">
<div class="preview-title">预览</div>
<div class="markdown-body" v-html="addRenderedContent"></div>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
@ -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<Set<string>>(new Set())
const logDialogVisible = ref(false)
const logList = ref<Log[]>([])
const selectedLog = ref<Log | null>(null)
const showListDialog = ref(false)
const showDetailDialog = ref(false)
const selectedDateStr = ref('')
//
const showAddDialog = ref(false)
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const templateList = ref<Template[]>([])
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 '<p class="empty-content">暂无内容</p>'
let content = selectedLog.value.content
// Markdown
// HTML
function renderMarkdown(source: string) {
if (!source) return '<p class="empty-content">暂无内容</p>'
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
t = t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
t = t.replace(/\*(.+?)\*/g, '<em>$1</em>')
t = t.replace(/`(.+?)`/g, '<code>$1</code>')
return t
}
const thead = `<thead><tr>${headerCells
.map((cell, i) => `<th style="text-align:${aligns[i] || 'left'}">${renderInline(cell)}</th>`)
.join('')}</tr></thead>`
const tbody = `<tbody>${bodyRows
.map(row => {
const cells = splitRow(row)
return `<tr>${cells
.map((cell, i) => `<td style="text-align:${aligns[i] || 'left'}">${renderInline(cell)}</td>`)
.join('')}</tr>`
})
.join('')}</tbody>`
const tableHtml = `<table>${thead}${tbody}</table>`
const idx = tableBlocks.length
tableBlocks.push(tableHtml)
return `\n__TABLE_BLOCK_${idx}__\n`
})
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>')
tableBlocks.forEach((table, idx) => {
content = content.replace(`__TABLE_BLOCK_${idx}__`, table)
})
codeBlocks.forEach((code, idx) => {
const escaped = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
content = content.replace(`__CODE_BLOCK_${idx}__`, `<pre><code>${escaped}</code></pre>`)
})
return `<p>${content}</p>`
})
}
const renderedContent = computed(() => renderMarkdown(selectedLog.value?.content || ''))
const addRenderedContent = computed(() => renderMarkdown(form.value.content))
//
const userList = ref<User[]>([])
@ -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()
})
</script>
@ -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;

View File

@ -38,6 +38,7 @@
<!-- 表格 -->
<el-table :data="tableData" v-loading="loading" stripe>
<el-table-column prop="logDate" label="日期" width="120" />
<el-table-column prop="logTypeDesc" label="类型" width="100" />
<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 />
@ -77,6 +78,14 @@
:disabled="!!form.id"
/>
</el-form-item>
<el-form-item label="日志类型" prop="logType">
<el-select v-model="form.logType" placeholder="请选择日志类型" :disabled="!!form.id">
<el-option label="工作计划" :value="1" />
<el-option label="工作日志" :value="2" />
<el-option label="个人日志" :value="3" />
<el-option label="其他" :value="9" />
</el-select>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
@ -91,12 +100,18 @@
</el-select>
</el-form-item>
<el-form-item label="内容">
<el-input
v-model="form.content"
type="textarea"
:rows="12"
placeholder="请输入日志内容支持Markdown格式"
/>
<div class="editor-wrap">
<el-input
v-model="form.content"
type="textarea"
:rows="12"
placeholder="请输入日志内容支持Markdown格式"
/>
<div class="preview-panel">
<div class="preview-title">预览</div>
<div class="markdown-body" v-html="editRenderedContent"></div>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
@ -125,7 +140,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { pageAllLogs, createLog, updateLog, deleteLog } from '@/api/log'
import type { Log } from '@/api/log'
@ -134,6 +149,7 @@ import type { Template } from '@/api/template'
import { listEnabledUsers } from '@/api/user'
import type { User } from '@/api/user'
import type { FormInstance, FormRules } from 'element-plus'
import { useRoute } from 'vue-router'
const loading = ref(false)
const submitLoading = ref(false)
@ -143,6 +159,7 @@ const userList = ref<User[]>([])
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(10)
const route = useRoute()
const searchForm = reactive({
userId: '',
@ -167,6 +184,7 @@ const formRef = ref<FormInstance>()
const form = reactive({
id: '',
logDate: '',
logType: 2, //
title: '',
content: '',
templateId: ''
@ -176,6 +194,7 @@ const dialogTitle = computed(() => form.id ? '编辑日志' : '新建日志')
const rules: FormRules = {
logDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
logType: [{ required: true, message: '请选择日志类型', trigger: 'change' }],
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
}
@ -189,42 +208,110 @@ const viewData = reactive({
})
// Markdown
const viewRenderedContent = computed(() => {
if (!viewData.content) return '<p class="empty-content">暂无内容</p>'
let content = viewData.content
// Markdown
// HTML
function renderMarkdown(source: string) {
if (!source) return '<p class="empty-content">暂无内容</p>'
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
t = t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
t = t.replace(/\*(.+?)\*/g, '<em>$1</em>')
t = t.replace(/`(.+?)`/g, '<code>$1</code>')
return t
}
const thead = `<thead><tr>${headerCells
.map((cell, i) => `<th style="text-align:${aligns[i] || 'left'}">${renderInline(cell)}</th>`)
.join('')}</tr></thead>`
const tbody = `<tbody>${bodyRows
.map(row => {
const cells = splitRow(row)
return `<tr>${cells
.map((cell, i) => `<td style="text-align:${aligns[i] || 'left'}">${renderInline(cell)}</td>`)
.join('')}</tr>`
})
.join('')}</tbody>`
const tableHtml = `<table>${thead}${tbody}</table>`
const idx = tableBlocks.length
tableBlocks.push(tableHtml)
return `\n__TABLE_BLOCK_${idx}__\n`
})
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>')
tableBlocks.forEach((table, idx) => {
content = content.replace(`__TABLE_BLOCK_${idx}__`, table)
})
codeBlocks.forEach((code, idx) => {
const escaped = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
content = content.replace(`__CODE_BLOCK_${idx}__`, `<pre><code>${escaped}</code></pre>`)
})
return `<p>${content}</p>`
})
}
const viewRenderedContent = computed(() => renderMarkdown(viewData.content))
// Markdown
const editRenderedContent = computed(() => renderMarkdown(form.content))
//
async function loadData() {
@ -273,6 +360,7 @@ function handleReset() {
function handleAdd() {
form.id = ''
form.logDate = new Date().toISOString().split('T')[0] || ''
form.logType = 2 //
form.title = ''
form.content = ''
form.templateId = ''
@ -283,6 +371,7 @@ function handleAdd() {
async function handleEdit(row: Log) {
form.id = row.id
form.logDate = row.logDate
form.logType = row.logType || 2
form.title = row.title
form.content = row.content || ''
form.templateId = row.templateId || ''
@ -324,6 +413,7 @@ async function handleSubmit() {
} else {
await createLog({
logDate: form.logDate,
logType: form.logType,
title: form.title,
content: form.content,
templateId: form.templateId || undefined
@ -349,6 +439,12 @@ async function handleDelete(row: Log) {
loadData()
loadTemplates()
loadUsers()
if (route.query.action === 'add') {
nextTick(() => {
handleAdd()
})
}
</script>
<style scoped>
@ -364,6 +460,102 @@ loadUsers()
margin-bottom: 16px;
}
.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;
}
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
@ -466,6 +658,28 @@ loadUsers()
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;