Files
danyang-match/admin-web/src/modules/service-station/ServiceStationView.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

354 lines
12 KiB
Vue

<template>
<div class="service-station">
<el-card>
<template #header>
<div class="card-header">
<span>善缘服务站管理</span>
<el-button v-if="canManage" type="primary" size="small" @click="openCreate">新建服务站</el-button>
</div>
</template>
<el-table :data="stations" stripe v-loading="loading">
<el-table-column prop="name" label="名称" />
<el-table-column prop="shortName" label="简称" width="120" />
<el-table-column prop="address" label="地址" show-overflow-tooltip />
<el-table-column prop="contactPhoneDisplay" label="联系电话" width="130" />
<el-table-column prop="openingHoursText" label="营业时间" width="180">
<template #default="{ row }">{{ row.openingHoursText || '-' }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : row.status === 'closed' ? 'danger' : 'info'">
{{ STATUS_LABELS[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="210">
<template #default="{ row }">
<el-button size="small" v-if="canManage" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="primary" plain @click="openAnnouncements(row)">公告管理</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && !stations.length" description="暂无服务站" />
</el-card>
<!-- 服务站信息 -->
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑服务站' : '新建服务站'" width="600px">
<el-form :model="form" label-width="100px">
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
<el-form-item label="简称"><el-input v-model="form.shortName" /></el-form-item>
<el-form-item label="地址" required><el-input v-model="form.address" /></el-form-item>
<el-form-item label="电话显示">
<el-input v-model="form.contactPhoneDisplay" placeholder="展示给用户的脱敏号码,如 0511-****8888" />
</el-form-item>
<el-form-item label="营业时间"><el-input v-model="form.openingHoursText" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="服务说明">
<el-input v-model="form.serviceDescription" type="textarea" :rows="2" placeholder="服务站提供的公益服务内容" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="营业中" value="active" />
<el-option label="休息中" value="inactive" />
<el-option label="关闭" value="closed" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveStation">保存</el-button>
</template>
</el-dialog>
<!-- 临时公告管理 -->
<el-drawer v-model="drawerVisible" :title="`临时公告 · ${currentStation?.name || ''}`" size="70%">
<div class="drawer-toolbar">
<el-alert
type="info"
:closable="false"
show-icon
title="公告仅「已发布」且未过期时对小程序用户可见;过期后自动隐藏,无需手工下架。"
/>
<el-button v-if="canManage" type="primary" size="small" @click="openAnnouncementCreate">新建公告</el-button>
</div>
<el-table :data="announcements" stripe v-loading="announcementLoading">
<el-table-column prop="title" label="标题" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="ANNOUNCEMENT_STATUS_TYPE[row.status] || 'info'">
{{ ANNOUNCEMENT_STATUS_LABELS[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column label="发布时间" width="170">
<template #default="{ row }">{{ formatTime(row.publishedAt) }}</template>
</el-table-column>
<el-table-column label="过期时间" width="170">
<template #default="{ row }">{{ row.expiresAt ? formatTime(row.expiresAt) : '长期有效' }}</template>
</el-table-column>
<el-table-column label="操作" width="230">
<template #default="{ row }">
<el-button v-if="canManage" size="small" @click="openAnnouncementEdit(row)">编辑</el-button>
<el-button
v-if="canManage && row.status !== 'published'"
size="small"
type="success"
@click="publishAnnouncement(row)"
>发布</el-button>
<el-button
v-if="canManage && row.status === 'published'"
size="small"
type="warning"
@click="archiveAnnouncement(row)"
>下架</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!announcementLoading && !announcements.length" description="暂无公告" />
</el-drawer>
<!-- 公告编辑 -->
<el-dialog
v-model="announcementDialogVisible"
:title="announcementForm.id ? '编辑公告' : '新建公告'"
width="600px"
append-to-body
>
<el-form :model="announcementForm" label-width="100px">
<el-form-item label="标题" required>
<el-input v-model="announcementForm.title" maxlength="200" show-word-limit />
</el-form-item>
<el-form-item label="正文">
<el-input v-model="announcementForm.content" type="textarea" :rows="4" />
</el-form-item>
<el-form-item label="过期时间">
<el-date-picker
v-model="announcementForm.expiresAt"
type="datetime"
placeholder="留空表示长期有效"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="announcementForm.sortOrder" :min="0" />
<span class="form-hint">数值越小越靠前</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="announcementDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveAnnouncement">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const STATUS_LABELS: Record<string, string> = { active: '营业中', inactive: '休息中', closed: '关闭' }
const ANNOUNCEMENT_STATUS_LABELS: Record<string, string> = {
draft: '草稿',
published: '已发布',
archived: '已下架',
}
const ANNOUNCEMENT_STATUS_TYPE: Record<string, 'info' | 'success' | 'warning'> = {
draft: 'info',
published: 'success',
archived: 'warning',
}
const auth = useAuthStore()
// 后端写端点由 service_location.manage 保护;此处按同一权限显隐,避免点了才 403。
const canManage = computed(() => auth.permissions.includes('service_location.manage'))
const loading = ref(false)
const stations = ref<any[]>([])
const dialogVisible = ref(false)
const form = ref<any>({})
const drawerVisible = ref(false)
const currentStation = ref<any>(null)
const announcements = ref<any[]>([])
const announcementLoading = ref(false)
const announcementDialogVisible = ref(false)
const announcementForm = ref<any>({})
function formatTime(value: string | null | undefined) {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN', { hour12: false })
}
async function loadStations() {
loading.value = true
try {
const res: any = await get('/operator/service-locations')
stations.value = res.data || []
} finally {
loading.value = false
}
}
function openEdit(row: any) {
form.value = { ...row }
dialogVisible.value = true
}
function openCreate() {
form.value = {
id: undefined,
name: '',
shortName: '',
address: '',
contactPhoneDisplay: '',
openingHoursText: '',
serviceDescription: '',
status: 'active',
}
dialogVisible.value = true
}
async function saveStation() {
const payload = { ...form.value }
try {
// 失败提示由响应拦截器统一处理(避免重复 toast)
if (payload.id) await put(`/operator/service-locations/${payload.id}`, payload)
else await post('/operator/service-locations', payload)
ElMessage.success('保存成功')
dialogVisible.value = false
loadStations()
} catch {
/* 已由拦截器提示 */
}
}
// ==================== 公告 ====================
async function openAnnouncements(row: any) {
currentStation.value = row
drawerVisible.value = true
await loadAnnouncements()
}
async function loadAnnouncements() {
if (!currentStation.value) return
announcementLoading.value = true
try {
const res: any = await get(
`/operator/service-locations/${currentStation.value.id}/announcements`
)
announcements.value = res.data || []
} finally {
announcementLoading.value = false
}
}
function openAnnouncementCreate() {
announcementForm.value = { id: undefined, title: '', content: '', expiresAt: null, sortOrder: 0 }
announcementDialogVisible.value = true
}
function openAnnouncementEdit(row: any) {
announcementForm.value = {
id: row.id,
title: row.title,
content: row.content || '',
// el-date-picker 需要 Date 对象;后端为 ISO-8601 瞬时
expiresAt: row.expiresAt ? new Date(row.expiresAt) : null,
sortOrder: row.sortOrder ?? 0,
}
announcementDialogVisible.value = true
}
async function saveAnnouncement() {
const payload: any = {
title: announcementForm.value.title,
content: announcementForm.value.content,
sortOrder: announcementForm.value.sortOrder,
expiresAt: announcementForm.value.expiresAt
? new Date(announcementForm.value.expiresAt).toISOString()
: null,
}
try {
if (announcementForm.value.id) {
await put(`/operator/service-locations/announcements/${announcementForm.value.id}`, payload)
} else {
await post(
`/operator/service-locations/${currentStation.value.id}/announcements`,
payload
)
}
ElMessage.success('保存成功')
announcementDialogVisible.value = false
loadAnnouncements()
} catch {
/* 已由拦截器提示 */
}
}
async function publishAnnouncement(row: any) {
try {
await ElMessageBox.confirm(
`发布后小程序用户将立即可见「${row.title}」。确认发布?`,
'发布公告',
{ type: 'warning' }
)
} catch {
return
}
try {
await post(`/operator/service-locations/announcements/${row.id}/publish`)
ElMessage.success('已发布')
loadAnnouncements()
} catch {
/* 已由拦截器提示 */
}
}
async function archiveAnnouncement(row: any) {
try {
await ElMessageBox.confirm(
`下架后用户端立即不可见,记录保留可追溯。确认下架「${row.title}」?`,
'下架公告',
{ type: 'warning' }
)
} catch {
return
}
try {
await post(`/operator/service-locations/announcements/${row.id}/archive`)
ElMessage.success('已下架')
loadAnnouncements()
} catch {
/* 已由拦截器提示 */
}
}
onMounted(loadStations)
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.drawer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.drawer-toolbar :deep(.el-alert) {
flex: 1;
}
.form-hint {
margin-left: 10px;
color: var(--el-text-color-secondary);
font-size: var(--font-size-xs);
}
</style>