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.
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: 12px;
|
|
}
|
|
</style>
|