## 一、设计系统收敛 - 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
354 lines
12 KiB
Vue
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>
|