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.
This commit is contained in:
@@ -1,41 +1,96 @@
|
||||
<template>
|
||||
<div class="service-program">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>专项公益服务项目</span><el-button type="primary" size="small" disabled>新建项目(待开放)</el-button></div></template>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>专项公益服务项目</span>
|
||||
<el-button type="primary" size="small" @click="openCreate">新建项目</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="programs" stripe v-loading="loading">
|
||||
<el-table-column prop="name" label="项目名称" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':row.status==='draft'?'info':'danger'">{{ row.status }}</el-tag></template>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="programStatusTag(row.status)">{{ programStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间范围" width="220">
|
||||
<template #default="{ row }">{{ formatDateTime(row.start_at) }} ~ {{ formatDateTime(row.end_at) }}</template>
|
||||
<template #default="{ row }">{{ formatDateTime(row.startAt) }} ~ {{ formatDateTime(row.endAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成员数" width="80">
|
||||
<template #default="{ row }">{{ row.memberCount || 0 }}</template>
|
||||
<el-table-column label="已通过" width="80">
|
||||
<template #default="{ row }">{{ row.memberCount ?? 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<el-table-column label="待审批" width="85">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='draft'" size="small" type="success" @click="activateProgram(row)">激活</el-button>
|
||||
<el-button size="small" @click="viewMembers(row)">查看成员</el-button>
|
||||
<el-tag v-if="row.pendingCount" type="warning" size="small">{{ row.pendingCount }}</el-tag>
|
||||
<span v-else>0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'draft'" size="small" type="success" @click="activateProgram(row)">激活</el-button>
|
||||
<el-button size="small" @click="viewMembers(row)">成员审批</el-button>
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 成员Dialog -->
|
||||
<el-dialog v-model="memberDialogVisible" title="项目成员" width="700px">
|
||||
<!-- 项目新建/编辑 Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑项目' : '新建项目'" width="620px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="项目名称" required><el-input v-model="form.name" maxlength="128" show-word-limit /></el-form-item>
|
||||
<el-form-item label="项目说明"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="开始时间"><el-date-picker v-model="form.startAt" type="datetime" placeholder="留空表示不限" /></el-form-item>
|
||||
<el-form-item label="结束时间"><el-date-picker v-model="form.endAt" type="datetime" placeholder="留空表示不限" /></el-form-item>
|
||||
<el-form-item v-if="editingId" label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="进行中" value="active" />
|
||||
<el-option label="已结束" value="ended" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
v-else
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="新建项目为草稿状态,保存后需在列表中「激活」才会对用户展示。"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveProgram">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 成员审批 Dialog -->
|
||||
<el-dialog v-model="memberDialogVisible" :title="`成员审批 · ${currentProgramName}`" width="820px">
|
||||
<el-descriptions v-if="stats" :column="5" border size="small" style="margin-bottom: 14px">
|
||||
<el-descriptions-item label="待审批">{{ stats.pending }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已通过">{{ stats.approved }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已拒绝">{{ stats.rejected }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已退出">{{ stats.withdrawn }}</el-descriptions-item>
|
||||
<el-descriptions-item label="合计">{{ stats.total }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-table :data="members" stripe v-loading="memberLoading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='approved'?'success':row.status==='pending'?'warning':'info'">{{ row.status }}</el-tag></template>
|
||||
<el-table-column prop="userId" label="用户ID" width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="memberStatusTag(row.status)">{{ memberStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="appliedAt" label="申请时间" width="160">
|
||||
<el-table-column prop="appliedAt" label="申请时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.appliedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<el-table-column prop="rejectedReason" label="原因" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.rejectedReason || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='pending'" size="small" type="success" @click="approveMember(row.id)">通过</el-button>
|
||||
<el-button v-if="row.status==='pending'" size="small" type="danger" @click="rejectMember(row.id)">拒绝</el-button>
|
||||
<el-button v-if="row.status === 'pending'" size="small" type="success" @click="approveMember(row.id)">通过</el-button>
|
||||
<el-button v-if="row.status === 'pending'" size="small" type="danger" @click="rejectMember(row.id)">拒绝</el-button>
|
||||
<el-button v-if="row.status === 'approved'" size="small" type="warning" @click="removeMember(row.id)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -45,48 +100,161 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { get, post, put } from '@/api/client'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const programs = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<string | null>(null)
|
||||
const form = ref({
|
||||
name: '', description: '', startAt: '', endAt: '', status: 'draft',
|
||||
})
|
||||
|
||||
const memberDialogVisible = ref(false)
|
||||
const currentProgramId = ref('')
|
||||
const currentProgramName = ref('')
|
||||
const members = ref<any[]>([])
|
||||
const memberLoading = ref(false)
|
||||
const stats = ref<any>(null)
|
||||
|
||||
const PROGRAM_STATUS_LABEL: Record<string, string> = { draft: '草稿', active: '进行中', ended: '已结束', cancelled: '已取消' }
|
||||
const PROGRAM_STATUS_TAG: Record<string, string> = { draft: 'info', active: 'success', ended: 'warning', cancelled: 'danger' }
|
||||
const MEMBER_STATUS_LABEL: Record<string, string> = { pending: '待审批', approved: '已通过', rejected: '已拒绝', withdrawn: '已退出' }
|
||||
const MEMBER_STATUS_TAG: Record<string, string> = { pending: 'warning', approved: 'success', rejected: 'danger', withdrawn: 'info' }
|
||||
|
||||
function programStatusLabel(s: string) { return PROGRAM_STATUS_LABEL[s] || s }
|
||||
function programStatusTag(s: string) { return PROGRAM_STATUS_TAG[s] || 'info' }
|
||||
function memberStatusLabel(s: string) { return MEMBER_STATUS_LABEL[s] || s }
|
||||
function memberStatusTag(s: string) { return MEMBER_STATUS_TAG[s] || 'info' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
// 错误提示统一由 api/client 的响应拦截器弹出,catch 里不再重复 toast
|
||||
async function loadPrograms() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await get('/service-programs')
|
||||
// operator 接口:含草稿/已结束项目。用户端接口只返回 active,草稿在后台会看不见。
|
||||
const res: any = await get('/service-programs/operator/list')
|
||||
programs.value = res.data || []
|
||||
} finally { loading.value = false }
|
||||
} catch (e) { console.error(e) } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
form.value = { name: '', description: '', startAt: '', endAt: '', status: 'draft' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
name: row.name || '', description: row.description || '',
|
||||
startAt: row.startAt || '', endAt: row.endAt || '',
|
||||
status: row.status || 'draft',
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
if (!form.value.name.trim()) { ElMessage.error('请填写项目名称'); return false }
|
||||
if (form.value.startAt && form.value.endAt && new Date(form.value.startAt) > new Date(form.value.endAt)) {
|
||||
ElMessage.error('开始时间不能晚于结束时间')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function saveProgram() {
|
||||
if (!validate()) return
|
||||
const payload: any = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
// 后端按 ISO-8601 Instant 解析;空字符串需转 null,否则 400
|
||||
startAt: form.value.startAt ? new Date(form.value.startAt).toISOString() : null,
|
||||
endAt: form.value.endAt ? new Date(form.value.endAt).toISOString() : null,
|
||||
}
|
||||
if (editingId.value) payload.status = form.value.status
|
||||
try {
|
||||
if (editingId.value) await put(`/service-programs/operator/${editingId.value}`, payload)
|
||||
else await post('/service-programs/operator', payload)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadPrograms()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function viewMembers(row: any) {
|
||||
currentProgramId.value = row.id
|
||||
currentProgramName.value = row.name
|
||||
memberDialogVisible.value = true
|
||||
await Promise.all([loadMembers(), loadStats()])
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
memberLoading.value = true
|
||||
try {
|
||||
const res: any = await get(`/service-programs/operator/${row.id}/members`)
|
||||
// operator 接口返回全部状态(含 pending);此前复用只返回 approved 的接口,
|
||||
// 导致待审批申请不显示、审批按钮永远不出现。
|
||||
const res: any = await get(`/service-programs/operator/${currentProgramId.value}/members`)
|
||||
members.value = res.data || []
|
||||
} finally { memberLoading.value = false }
|
||||
} catch (e) { console.error(e) } finally { memberLoading.value = false }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res: any = await get(`/service-programs/operator/${currentProgramId.value}/stats`)
|
||||
stats.value = res.data || null
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function refreshAfterAction() {
|
||||
await Promise.all([loadMembers(), loadStats()])
|
||||
loadPrograms()
|
||||
}
|
||||
|
||||
async function activateProgram(row: any) {
|
||||
try { await post(`/service-programs/operator/${row.id}/activate`, {}); ElMessage.success('激活成功'); loadPrograms() }
|
||||
catch (e) { ElMessage.error('操作失败') }
|
||||
}
|
||||
async function approveMember(memberId: string) {
|
||||
try { await post(`/service-programs/operator/members/${memberId}/approve`, {}); ElMessage.success('已通过'); viewMembers({ id: currentProgramId.value }); }
|
||||
catch (e) { ElMessage.error('操作失败') }
|
||||
}
|
||||
async function rejectMember(memberId: string) {
|
||||
try { await post(`/service-programs/operator/members/${memberId}/reject`, { reason: '不符合条件' }); ElMessage.success('已拒绝'); viewMembers({ id: currentProgramId.value }); }
|
||||
catch (e) { ElMessage.error('操作失败') }
|
||||
try {
|
||||
await post(`/service-programs/operator/${row.id}/activate`, {})
|
||||
ElMessage.success('激活成功')
|
||||
loadPrograms()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
async function approveMember(memberId: string) {
|
||||
try {
|
||||
await post(`/service-programs/operator/members/${memberId}/approve`, {})
|
||||
ElMessage.success('已通过')
|
||||
refreshAfterAction()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function rejectMember(memberId: string) {
|
||||
try {
|
||||
// 后端要求拒绝必须填写原因,这里不再硬编码"不符合条件"
|
||||
const { value } = await ElMessageBox.prompt('请填写拒绝原因(用户可见)', '拒绝申请', {
|
||||
confirmButtonText: '确认拒绝',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '例如:名额已满 / 不符合本期招募条件',
|
||||
inputValidator: (text: string) => (text && text.trim() ? true : '必须填写拒绝原因'),
|
||||
})
|
||||
await post(`/service-programs/operator/members/${memberId}/reject`, { reason: value })
|
||||
ElMessage.success('已拒绝')
|
||||
refreshAfterAction()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function removeMember(memberId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('移除后该成员状态变为「已退出」,记录保留可追溯。确认移除?', '移除成员', {
|
||||
confirmButtonText: '确认移除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await post(`/service-programs/operator/members/${memberId}/remove`, { reason: '运营移除' })
|
||||
ElMessage.success('已移除')
|
||||
refreshAfterAction()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
onMounted(loadPrograms)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user