Files
danyang-match/admin-web/src/modules/content/ContentManagementView.vue
T
WorkBuddy 631e3000d3 管理端:设计系统 v2 收敛 + 修复漏斗卡恒为 0 的响应式缺陷
## 一、设计系统收敛

- 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
2026-09-23 01:06:57 +08:00

258 lines
12 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="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>