## 一、设计系统收敛 - theme/index.css 重写为 token 底座:品牌三级、语义五组、文字四级、 边框/背景/圆角/阴影/动效/热区/排版刻度 - 修复 Element Plus 派生色阶残留:--el-color-*-light-N 是 SCSS 编译期静态生成, 只覆盖基色不会带动它们 —— 数据统计页实测 133 处元素仍在用 EP 默认调色板 (其中主色蓝 8 处)。已按 mix(#fff,base,N*10%) / mix(#000,base,20%) 补全全部档位 - 新增 src/theme/tokens.ts:模板里 :style 需拼 8 位 hex(color + '15'),用不了 var(), 故以 TS 常量镜像色值;守卫会双向校验它与 index.css 同名取值一致 - 24 个 .vue 收敛:硬编码色值 97 处/67 种 → 0;越界字号 88 处(最低 9px)→ 0; 非法字重 650×6 → 0;触摸目标 17 处 <44px → 全部 ≥44px - 对比度:text-tertiary 3.54:1、warning 2.33:1、coral 作文字 3.14:1 → 全部 ≥4.5:1 - 新增守卫 ui-tests/admin-design-tokens.mjs(含 WCAG 对比度实算) ## 二、修复既有功能性缺陷:数据统计页漏斗卡与安全卡恒显示 0 - 现象:同一张卡片里,上方漏斗数字全是 0,下方汇总表却是 12846/9217/8462/3812, 界面自相矛盾 - 根因:funnelStages / matchStages / safetyCards 是普通数组(const x = [...]), loadFunnel() 直接改元素属性,模板读的却是同一个数组 —— 非响应式写入不触发重渲染。 同页凡走 ref 的值(汇总表、V11 区块)全部正常,规律清晰 - 修法(按根因):三个集合改为 computed() 从既有 ref 派生,卡片与汇总表因此同源, 结构上不可能再出现「同一份数据的两处展示对不上」;另把 loadFunnel() 内遮蔽 颜色助手的局部变量 const token 改名 authToken - 该缺陷无 TS/lint/构建报错,前 18 个守卫全绿也抓不到 —— 只有真跑起来看渲染结果才暴露 - 新增守卫 ui-tests/admin-reactivity-contracts.mjs: ①非响应式状态(普通声明 + 被当状态写 + 出现在模板里;未被写过的静态常量清单合法) ②数据统计页同源契约 ③颜色助手遮蔽 ## 验证 - UI 契约门禁 20/20 PASS(MIN_GUARDS 18→20) - 负向注入 3 例全部 FAIL(exit 1),还原后复跑 exit 0(证明守卫有判别力) - vue-tsc --noEmit exit 0 - vite build 成功(17.74s) - 渲染态实测(Edge 无头 + CDP 读渲染后 DOM,同一 mock、同一路由,只差代码): 修复前漏斗卡/安全卡全 0、比率全 '-' → 修复后 12846/9217/8462/3812/ 48210/51236/7420/4380/1926/1284,安全卡 17/214/23/9 交付物:artifacts/admin-design-v2-preview.html
258 lines
12 KiB
Vue
258 lines
12 KiB
Vue
<template>
|
||
<div class="content-management">
|
||
<el-tabs v-model="activeTab">
|
||
<el-tab-pane label="内容分类" name="categories">
|
||
<el-card>
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>内容分类</span>
|
||
<el-button type="primary" size="small" @click="openCategoryDialog">新建分类</el-button>
|
||
</div>
|
||
</template>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
title="分类不做删除:文章通过分类ID引用它,删除会让历史文章分类悬空。不再使用的分类请「停用」,停用后小程序端不再下发,但后台仍可见并可重新启用。"
|
||
style="margin-bottom: 12px"
|
||
/>
|
||
<el-table :data="categories" stripe v-loading="categoryLoading">
|
||
<el-table-column prop="name" label="分类名称" />
|
||
<el-table-column prop="code" label="编码" width="150" />
|
||
<el-table-column prop="sortOrder" label="排序" width="80" />
|
||
<el-table-column prop="status" label="状态" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ categoryStatusLabel(row.status) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="180">
|
||
<template #default="{ row }">
|
||
<el-button size="small" @click="editCategory(row)">编辑</el-button>
|
||
<el-button
|
||
size="small"
|
||
:type="row.status === 'active' ? 'warning' : 'success'"
|
||
@click="toggleCategoryStatus(row)"
|
||
>
|
||
{{ row.status === 'active' ? '停用' : '启用' }}
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-card>
|
||
</el-tab-pane>
|
||
<el-tab-pane label="文章内容" name="articles">
|
||
<el-card>
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>文章内容</span>
|
||
<el-button type="primary" size="small" @click="openArticleDialog">新建文章</el-button>
|
||
</div>
|
||
</template>
|
||
<el-table :data="articles" stripe v-loading="loading">
|
||
<el-table-column prop="title" label="标题" />
|
||
<el-table-column label="分类" width="120">
|
||
<template #default="{ row }">{{ categoryLabel(row.categoryId) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="status" label="状态" width="100">
|
||
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column prop="viewCount" label="阅读量" width="80" />
|
||
<el-table-column label="操作" width="200">
|
||
<template #default="{ row }">
|
||
<el-button size="small" @click="editArticle(row)">编辑</el-button>
|
||
<el-button v-if="row.status !== 'published'" size="small" type="success" @click="publishArticle(row)">发布</el-button>
|
||
<el-button v-if="row.status === 'published'" size="small" type="warning" @click="offlineArticle(row)">下架</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-card>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
|
||
<!-- 分类Dialog -->
|
||
<el-dialog v-model="categoryDialogVisible" :title="editingCategoryId ? '编辑分类' : '新建分类'" width="500px">
|
||
<el-form :model="categoryForm" label-width="80px">
|
||
<el-form-item label="名称" required><el-input v-model="categoryForm.name" maxlength="128" show-word-limit /></el-form-item>
|
||
<el-form-item label="编码" required>
|
||
<el-input v-model="categoryForm.code" maxlength="64" show-word-limit placeholder="唯一标识,如 classroom" />
|
||
<div class="field-hint">编码全局唯一,重复会被拒绝</div>
|
||
</el-form-item>
|
||
<el-form-item label="排序"><el-input-number v-model="categoryForm.sortOrder" :min="0" /></el-form-item>
|
||
<el-form-item label="说明"><el-input v-model="categoryForm.description" type="textarea" :rows="2" /></el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="categoryDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="saveCategory">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 文章Dialog -->
|
||
<el-dialog v-model="articleDialogVisible" :title="editingArticleId ? '编辑文章' : '新建文章'" width="700px">
|
||
<el-form :model="articleForm" label-width="80px" v-loading="articleDetailLoading">
|
||
<el-form-item label="标题" required><el-input v-model="articleForm.title" maxlength="200" show-word-limit /></el-form-item>
|
||
<el-form-item label="分类">
|
||
<el-select v-model="articleForm.categoryId" clearable placeholder="请选择分类">
|
||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="摘要"><el-input v-model="articleForm.summary" type="textarea" :rows="2" maxlength="500" show-word-limit /></el-form-item>
|
||
<el-form-item label="正文"><el-input v-model="articleForm.content" type="textarea" :rows="10" /></el-form-item>
|
||
<el-form-item label="排序"><el-input-number v-model="articleForm.sortOrder" :min="0" /></el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="articleDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="saveArticle">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { get, post, put } from '@/api/client'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const activeTab = ref('categories')
|
||
const loading = ref(false)
|
||
const categoryLoading = ref(false)
|
||
const articleDetailLoading = ref(false)
|
||
const categories = ref<any[]>([])
|
||
const articles = ref<any[]>([])
|
||
|
||
const categoryDialogVisible = ref(false)
|
||
const editingCategoryId = ref<string | null>(null)
|
||
const categoryForm = ref({ name: '', code: '', description: '', sortOrder: 0 })
|
||
|
||
const articleDialogVisible = ref(false)
|
||
const editingArticleId = ref<string | null>(null)
|
||
const articleForm = ref({ title: '', categoryId: '', summary: '', content: '', sortOrder: 0 })
|
||
|
||
const CATEGORY_STATUS_LABEL: Record<string, string> = { active: '启用中', inactive: '已停用' }
|
||
const STATUS_LABEL: Record<string, string> = { draft: '草稿', scheduled: '定时', published: '已发布', offline: '已下架' }
|
||
const STATUS_TAG: Record<string, string> = { draft: 'info', scheduled: 'warning', published: 'success', offline: 'danger' }
|
||
|
||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||
function categoryStatusLabel(s: string) { return CATEGORY_STATUS_LABEL[s] || s }
|
||
function categoryLabel(id: string) { const c = categories.value.find(x => x.id === id); return c ? c.name : '-' }
|
||
|
||
// 错误提示统一由 api/client 的响应拦截器弹出,catch 里不再重复 toast
|
||
async function loadCategories() {
|
||
categoryLoading.value = true
|
||
try {
|
||
// operator 接口:含已停用分类,否则停用后无法在后台重新启用
|
||
const res: any = await get('/content/operator/categories')
|
||
categories.value = res.data || []
|
||
} catch (e) { console.error(e) } finally { categoryLoading.value = false }
|
||
}
|
||
|
||
async function loadArticles() {
|
||
loading.value = true
|
||
try {
|
||
// operator 接口:含全部状态。公开接口只给 published,草稿不出现在列表里 → 无法点「发布」
|
||
const res: any = await get('/content/operator/articles')
|
||
articles.value = res.data || []
|
||
} catch (e) { console.error(e) } finally { loading.value = false }
|
||
}
|
||
|
||
function validateCategory(): boolean {
|
||
if (!categoryForm.value.name.trim()) { ElMessage.error('请填写分类名称'); return false }
|
||
if (!categoryForm.value.code.trim()) { ElMessage.error('请填写分类编码'); return false }
|
||
return true
|
||
}
|
||
|
||
async function saveCategory() {
|
||
if (!validateCategory()) return
|
||
try {
|
||
if (editingCategoryId.value) await put(`/content/operator/categories/${editingCategoryId.value}`, categoryForm.value)
|
||
else await post('/content/operator/categories', categoryForm.value)
|
||
ElMessage.success('保存成功')
|
||
categoryDialogVisible.value = false
|
||
loadCategories()
|
||
} catch (e) { console.error(e) }
|
||
}
|
||
|
||
function editCategory(row: any) {
|
||
editingCategoryId.value = row.id
|
||
categoryForm.value = {
|
||
name: row.name || '', code: row.code || '',
|
||
description: row.description || '', sortOrder: row.sortOrder ?? 0,
|
||
}
|
||
categoryDialogVisible.value = true
|
||
}
|
||
|
||
async function toggleCategoryStatus(row: any) {
|
||
const next = row.status === 'active' ? 'inactive' : 'active'
|
||
try {
|
||
await put(`/content/operator/categories/${row.id}/status`, { status: next })
|
||
ElMessage.success(next === 'active' ? '已启用' : '已停用')
|
||
loadCategories()
|
||
} catch (e) { console.error(e) }
|
||
}
|
||
|
||
function validateArticle(): boolean {
|
||
if (!articleForm.value.title.trim()) { ElMessage.error('请填写文章标题'); return false }
|
||
return true
|
||
}
|
||
|
||
async function saveArticle() {
|
||
if (!validateArticle()) return
|
||
try {
|
||
if (editingArticleId.value) await put(`/content/operator/articles/${editingArticleId.value}`, articleForm.value)
|
||
else await post('/content/operator/articles', articleForm.value)
|
||
ElMessage.success('保存成功')
|
||
articleDialogVisible.value = false
|
||
loadArticles()
|
||
} catch (e) { console.error(e) }
|
||
}
|
||
|
||
async function editArticle(row: any) {
|
||
editingArticleId.value = row.id
|
||
articleForm.value = {
|
||
title: row.title || '', categoryId: row.categoryId || '',
|
||
summary: row.summary || '', content: '', sortOrder: row.sortOrder ?? 0,
|
||
}
|
||
articleDialogVisible.value = true
|
||
// 列表 DTO 不含正文(有意保持轻量),必须拉详情才能回填正文
|
||
articleDetailLoading.value = true
|
||
try {
|
||
const res: any = await get(`/content/operator/articles/${row.id}`)
|
||
const detail = res.data || {}
|
||
articleForm.value = {
|
||
title: detail.title || '', categoryId: detail.categoryId || '',
|
||
summary: detail.summary || '', content: detail.content || '',
|
||
sortOrder: detail.sortOrder ?? 0,
|
||
}
|
||
} catch (e) { console.error(e) } finally { articleDetailLoading.value = false }
|
||
}
|
||
|
||
async function publishArticle(row: any) {
|
||
try { await post(`/content/operator/articles/${row.id}/publish`, {}); ElMessage.success('发布成功'); loadArticles() }
|
||
catch (e) { console.error(e) }
|
||
}
|
||
|
||
async function offlineArticle(row: any) {
|
||
try { await post(`/content/operator/articles/${row.id}/offline`, {}); ElMessage.success('下架成功'); loadArticles() }
|
||
catch (e) { console.error(e) }
|
||
}
|
||
|
||
function openCategoryDialog() {
|
||
editingCategoryId.value = null
|
||
categoryForm.value = { name: '', code: '', description: '', sortOrder: 0 }
|
||
categoryDialogVisible.value = true
|
||
}
|
||
|
||
function openArticleDialog() {
|
||
editingArticleId.value = null
|
||
articleForm.value = { title: '', categoryId: '', summary: '', content: '', sortOrder: 0 }
|
||
articleDialogVisible.value = true
|
||
}
|
||
|
||
onMounted(() => { loadCategories(); loadArticles() })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||
.field-hint { width: 100%; font-size: var(--font-size-xs); color: var(--el-text-color-secondary); line-height: 1.5; }
|
||
</style>
|