Files
danyang-match/admin-web/src/modules/service-station/ServiceStationView.vue
T
WorkBuddy 72b4e9b846 fix(ops-backend/service-station): close Phase 19 KEEP-VERIFY — 25 defects (unreachable announcements + station write privilege escalation; banner 500 + soft-delete leak; content publish dead-end; program approval dead-end/draft leak/zero counts/missing withdraw; zero-coverage test schema) ; add V042 announcement chain + 34 guard tests; full regression 602 green
Backend
- Service Station: expose the 「临时公告」 chain end-to-end (V042 table/service existed
  but had ZERO endpoints -> both admin and miniapp could never reach it): 5 operator
  endpoints (list/create/update/publish/archive) + 1 user-facing read endpoint.
- Fix privilege escalation: create/update were guarded only by the class-level
  service_location.view, making V015's service_location.manage a never-used dead
  permission -> read-only roles could edit stations. Permissions moved to method level.
- Add ServiceLocationOperatorController to OperatorEndpointPermissionCoverageTest
  (it was NOT scanned, which is why the escalation stayed invisible).
- Add create/update validation (address NOT NULL + status CHECK only blew up on PG).
- Banner: validation failures threw bare RuntimeException -> 500; now VALIDATION_ERROR
  -> 400. Add deleted_at IS NULL to both home queries (soft-deleted banners stayed live).
- Content: operator list/detail split (admin «publish» was a dead end because it reused
  the published-only public API); category deactivate/reactivate; article body on edit.
- Service Program: operator member list now includes pending (approval reachable);
  active-only public visibility (draft leak); memberCount/pendingCount aggregation;
  withdraw/removeMember; reuse row on re-apply (UNIQUE(program_id,user_id) crash).
- MeController: add the missing GET /me/service-history route.

Schema / guards
- schema-h2.sql: add service_location_announcements (+ chk_announcement_status) and
  content_categories (+ uk_content_category_code, chk_category_status); move both into
  REQUIRED_IN_TEST_SCHEMA. MigrationValidationTest baseline 41 -> 42.
- V11PermissionMatrixContractTest now scans ALL migrations instead of hardcoding V015+V029.
- New: ServiceLocationAnnouncementPhase19IntegrationTest (24),
  ServiceLocationControllerPermissionContractTest (5),
  ServiceLocationAnnouncementConstraintParityTest (5, prod DDL <-> test schema <->
  backend whitelist <-> admin labels).

Frontend
- admin-web: ServiceStationView gains announcement management (statuses, create/edit,
  publish/archive with confirm, expiry); write buttons gated by service_location.manage.
- miniapp: U50 renders the 临时公告 block via new serviceStationService; page no longer
  talks to utils/api directly; announcement failure degrades to [] instead of erroring.
- ui-tests: U37 four-group + U50 announcement contracts; /home assertion retargeted to
  the service layer.

Verification
- Negative verification: 3 injected defects (drop soft-delete filter / drop test-schema
  CHECK / downgrade write endpoint to view) -> 4 named tests fail precisely, no
  collateral failures; restored, NEG-VAL residue = 0.
- Backend full regression: 602 tests, 0 failures, 0 errors, 3 skipped (baseline 420).
- admin-web: vue-tsc --noEmit EXIT=0, vite build EXIT=0. Miniapp: tsc --noEmit EXIT=0.
- ui-tests 14/14 PASS.
2026-09-17 22:21:31 +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: 12px;
}
</style>