Files
danyang-match/admin-web/src/layouts/MainLayout.vue
T
WorkBuddy 29112eed6e fix(activity): close Phase 15 KEEP-VERIFY/EXTEND — 2 critical + 14 other defects, 3 test-fidelity fixes
Phase 15 (activity registration / review / checkin) verification closure.

Two CRITICAL defects, both "production always had it, test schema never did":
- D1: generateCheckinToken wrote the token to Redis with value "used", while
  scanCheckin rejects any token marked "used" -> every freshly issued check-in
  code was dead on arrival, staff scanning failed 100% of the time.
  Issuing now writes "issued"; "used" is set only after a successful scan.
- D9: UNIQUE(activity_id, user_id) exists in production, but re-registering
  after cancel/reject went through INSERT -> users who cancelled once could
  never register for that activity again (duplicate key 500). The H2 test
  schema lacked the UNIQUE, so the path had zero coverage. Now the original
  row is reused via an atomic conditional UPDATE.

Checkin (D2-D8):
- D2: duplicate check-in was read-then-write; now atomic checkinIfNotCheckedIn
  and repeat scans are idempotent (alreadyCheckedIn) without duplicate audit.
- D3: no data scope at all -> added activity staff assignment (V040) and
  assertCheckinScope; INV-020 (org relation grants nothing).
- D4: manualCheckin did not validate activity existence/cancellation.
- D5: getCheckinStats NPE on missing activity; remainingCount was semantically
  "free seats" -> replaced with notCheckedInCount.
- D6: malformed timestamp caused 500 -> 400. D7: constant-time signature compare.

Registration / review / cancel (D10-D13):
- D10: backend registration list only queried status='approved' -> the review
  screen never saw pending rows (review unusable).
- D11: registration_start_at / registration_end_at were never enforced.
- D12: cancel had no audit log (the only write without one).
- D13: reject/cancel were read-then-full-update -> rejectIfPending/cancelIfActive.

Capacity groups (D14, plan item 2): groups were stored and read back but never
participated in capacity -> incrementIfAvailable / decrementIfPositive now make
approved_count real; activities without groups are unaffected.

Frontend contract (F1-F2):
- F1: admin-web had no check-in surface at all -> ActivityCheckinView.vue
  (paste/scan token, manual check-in, stats) + /activities/checkin route & nav.
- F2: miniapp had no cancel-registration wrapper although the endpoint existed.

Test fidelity (T1-T3):
- T1: test schema gained UNIQUE(activity_id,user_id) + chk_reg_status +
  chk_checkin_status; two new guards parse the real V010 DDL and compare.
- T2: 3 assertTrue(true) placeholders in CheckinServiceTest (covering duplicate
  check-in, expiry, no-sensitive-plaintext) replaced with 5 real guards that
  parse the real source and real production DDL.
- T3: activity_registrations + activity_staff_assignments moved into
  REQUIRED_IN_TEST_SCHEMA; MigrationValidationTest baseline 39 -> 40.

Note: ActivityStaffAssignment must NOT extend BaseEntity - the table is a
composite PK (activity_id, operator_id); BaseEntity would add id/updated_at/
deleted_at to every INSERT and break on PostgreSQL (caught by
EntityMigrationParityTest immediately).

Tests: ActivityPhase15Test (21), CheckinServiceTest (5), 2 new constraint
guards. Negative verification: 3 injected defects -> 8 assertions turned red,
all restored.

Regression: 474 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(baseline 450). admin-web vue-tsc 0 error, vite build OK.
2026-09-17 15:07:52 +08:00

725 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="admin-scene" :class="{ 'sidebar-collapsed': isCollapse }">
<header class="scene-header" aria-label="善缘丹阳公益服务品牌信息">
<div class="scene-brand">
<img src="/brand/logo-mark.png" alt="善缘丹阳" />
<div class="scene-brand-copy">
<strong>善缘丹阳</strong>
<span>公益婚恋服务平台</span>
</div>
<div class="scene-title">
<h1>管理端(运营后台)</h1>
<p>用专业的服务,帮助更多人遇见对的人</p>
</div>
</div>
<div class="scene-slogan" aria-hidden="true">
<span>善缘相牵</span>
<strong>爱在丹阳</strong>
</div>
<div class="scene-benefits" aria-label="平台服务原则">
<span><el-icon><StarFilled /></el-icon><b>公益为本</b><small>不以营利为目的</small></span>
<span><el-icon><Lock /></el-icon><b>真实可靠</b><small>严格审核 多重保障</small></span>
<span><el-icon><LocationFilled /></el-icon><b>本地服务</b><small>深耕丹阳 服务家乡</small></span>
<span><el-icon><Opportunity /></el-icon><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
</div>
</header>
<el-container class="admin-shell">
<el-aside :width="isCollapse ? '76px' : '208px'" class="admin-sidebar">
<div class="brand" :class="{ compact: isCollapse }">
<img class="brand-mark" src="/brand/logo-mark.png" alt="" />
<div v-if="!isCollapse" class="brand-copy">
<strong>善缘丹阳</strong>
<span>公益婚恋服务平台</span>
</div>
</div>
<el-scrollbar class="menu-scroll">
<nav aria-label="管理后台主导航">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:router="true"
class="sidebar-menu"
>
<el-menu-item
v-for="item in visibleNavigation"
:key="item.label"
:index="item.path"
>
<el-icon><component :is="item.icon" /></el-icon>
<template #title>{{ item.label }}</template>
</el-menu-item>
</el-menu>
</nav>
</el-scrollbar>
<div v-if="!isCollapse" class="sidebar-motto">
<el-icon><StarFilled /></el-icon>
<span>汇聚善意<br />成就良缘</span>
</div>
<button
class="collapse-control"
type="button"
:aria-label="isCollapse ? '展开导航' : '收起导航'"
@click="isCollapse = !isCollapse"
>
<el-icon><Fold v-if="!isCollapse" /><Expand v-else /></el-icon>
<span v-if="!isCollapse">收起导航</span>
</button>
</el-aside>
<el-container class="workspace">
<el-header class="admin-topbar">
<div class="page-context">
<button class="mobile-collapse" type="button" aria-label="切换导航" @click="isCollapse = !isCollapse">
<el-icon><Expand v-if="isCollapse" /><Fold v-else /></el-icon>
</button>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">工作台</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentTitle !== '工作台'">{{ currentTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-tools">
<el-input
v-model="globalSearch"
class="global-search"
clearable
aria-label="搜索后台页面"
placeholder="搜索用户、活动、内容…"
@keyup.enter="handleSearch"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<button class="notification-button" type="button" aria-label="查看系统通知" @click="showNotifications">
<el-icon><Bell /></el-icon>
<span class="notification-badge" aria-hidden="true">{{ notificationCount }}</span>
</button>
<el-dropdown trigger="click">
<span class="user-info">
<el-avatar :size="34">{{ (operator?.name || '工')[0] }}</el-avatar>
<strong>{{ operator?.name || '工作人员' }}</strong>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>{{ currentRoleName }}</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="admin-content">
<router-view v-slot="{ Component }">
<keep-alive><component :is="Component" /></keep-alive>
</router-view>
</el-main>
</el-container>
</el-container>
<footer class="scene-footer">
<strong>善缘丹阳</strong><span>公益婚恋服务平台</span>
<small>真实 · 真诚 · 公益 · 幸福</small>
</footer>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowDown,
Bell,
Calendar,
ChatDotRound,
Connection,
DataAnalysis,
DataBoard,
DocumentChecked,
Expand,
Fold,
Location,
LocationFilled,
Lock,
Opportunity,
Reading,
Search,
Setting,
StarFilled,
User,
UserFilled,
Warning,
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
interface NavigationItem {
label: string
path: string
permissions: string[]
icon: unknown
}
const navigation: NavigationItem[] = [
{ label: '工作台', path: '/dashboard', permissions: [], icon: DataBoard },
{ label: '用户管理', path: '/users', permissions: ['user.view'], icon: User },
{ label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked },
{ label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled },
{ label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection },
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar },
{ label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar },
{ label: '内容管理', path: '/content', permissions: ['content.view'], icon: Reading },
{ label: '善缘服务站', path: '/service-station', permissions: ['service_location.view'], icon: Location },
{ label: '举报风控', path: '/risk/reports', permissions: ['risk.report.view'], icon: Warning },
{ label: '数据统计', path: '/analytics', permissions: ['analytics.view'], icon: DataAnalysis },
{ label: '组织人员', path: '/organizations', permissions: ['organization.manage'], icon: UserFilled },
{ label: '系统设置', path: '/operators', permissions: ['permission.manage'], icon: Setting },
]
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const isCollapse = ref(false)
const globalSearch = ref('')
const notificationCount = ref(0)
const activeMenu = computed(() => {
if (route.path === '/users/detail') return '/users'
return route.path
})
const currentTitle = computed(() => (route.meta?.title as string) || '页面')
const operator = computed(() => authStore.operator)
const permissions = computed(() => authStore.permissions || [])
const currentRoleName = computed(() => {
if (permissions.value.includes('permission.manage')) return '平台管理员'
if (permissions.value.includes('counseling.service.view')) return '公益咨询人员'
if (permissions.value.includes('service_appointment.view')) return '服务站运营人员'
if (permissions.value.includes('review.view')) return '资料审核人员'
return '运营人员'
})
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
function hasPerm(permission: string) {
return permissions.value.includes(permission)
}
function handleSearch() {
const query = globalSearch.value.trim()
if (!query) {
ElMessage.info('请输入要查找的页面名称')
return
}
const destination = router.getRoutes().find((item) => {
const title = String(item.meta?.title || '')
const required = (item.meta?.permissions as string[] | undefined) || []
return item.path !== '/login' && title.includes(query) && required.every(hasPerm)
})
if (!destination) {
ElMessage.info('没有找到可访问的相关页面')
return
}
router.push(destination.path)
globalSearch.value = ''
}
function showNotifications() {
ElMessage.info(notificationCount.value ? `您有 ${notificationCount.value} 条待处理通知` : '当前没有新的系统通知')
}
function handleLogout() {
authStore.logout()
router.push('/login')
}
</script>
<style scoped>
.admin-scene {
min-width: 1100px;
min-height: 100vh;
padding: 18px 34px 14px;
overflow: hidden;
background: #fff7f7 url('/brand/admin-danyang-backdrop.jpg') center / cover fixed no-repeat;
}
.scene-header {
display: grid;
max-width: 1840px;
height: 188px;
margin: 0 auto;
grid-template-columns: minmax(640px, 1fr) auto;
grid-template-rows: 112px 76px;
align-items: center;
column-gap: 32px;
}
.scene-brand,
.scene-benefits,
.scene-benefits span,
.scene-brand-copy {
display: flex;
align-items: center;
}
.scene-brand > img {
width: 64px;
height: 64px;
object-fit: contain;
}
.scene-slogan {
display: flex;
padding-right: 24px;
align-items: flex-end;
flex-direction: column;
color: var(--admin-coral);
font-family: KaiTi, STKaiti, serif;
line-height: 1.05;
transform: rotate(-7deg);
}
.scene-slogan span {
font-size: 17px;
}
.scene-slogan strong {
margin-top: 4px;
font-size: 25px;
font-weight: 500;
}
.scene-brand-copy {
min-width: 172px;
margin-left: 10px;
align-items: flex-start;
flex-direction: column;
color: var(--admin-coral);
}
.scene-brand-copy strong {
font-size: 27px;
line-height: 1.15;
letter-spacing: 2px;
}
.scene-brand-copy span {
margin-top: 5px;
font-size: 12px;
letter-spacing: 4px;
}
.scene-title {
min-width: 370px;
margin-left: 28px;
padding-left: 28px;
border-left: 1px solid rgba(244, 95, 104, 0.22);
}
.scene-title h1 {
font-size: clamp(26px, 2.2vw, 38px);
line-height: 1.1;
letter-spacing: 1px;
}
.scene-title p {
margin-top: 9px;
color: #66717a;
}
.scene-benefits {
grid-column: 1 / -1;
align-self: start;
justify-content: flex-start;
gap: clamp(38px, 5vw, 92px);
padding: 4px 0 0 50px;
}
.scene-benefits span {
display: grid;
grid-template-columns: 36px auto;
column-gap: 8px;
color: var(--admin-coral);
}
.scene-benefits .el-icon {
grid-row: 1 / 3;
font-size: 30px;
}
.scene-benefits b {
font-size: 13px;
}
.scene-benefits small {
margin-top: 2px;
color: #7b7f86;
font-size: 10px;
white-space: nowrap;
}
.admin-shell {
width: min(1840px, 100%);
height: calc(100vh - 238px);
min-height: 560px;
margin: 0 auto;
overflow: hidden;
background: var(--admin-shell-bg);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: var(--admin-shell-radius);
box-shadow: var(--admin-shadow);
backdrop-filter: blur(14px);
}
.admin-sidebar {
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--admin-sidebar-text);
background: linear-gradient(180deg, #202d38 0%, #192733 100%);
border-right: 1px solid rgba(255, 255, 255, 0.06);
transition: width 0.2s ease;
}
.brand {
display: flex;
height: 82px;
padding: 17px 16px;
align-items: center;
gap: 11px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.brand.compact {
justify-content: center;
padding: 16px 8px;
}
.brand-mark {
width: 42px;
height: 42px;
flex: none;
object-fit: contain;
}
.brand-copy {
display: flex;
min-width: 0;
flex-direction: column;
white-space: nowrap;
}
.brand-copy strong {
color: #fff;
font-size: 17px;
letter-spacing: 1px;
}
.brand-copy span {
margin-top: 4px;
color: #b5c0c8;
font-size: 10px;
letter-spacing: 1.5px;
}
.menu-scroll {
flex: 1;
}
.sidebar-menu {
padding: 13px 9px;
border: 0;
background: transparent;
}
.sidebar-menu :deep(.el-menu-item) {
height: 41px;
margin: 2px 0;
color: var(--admin-sidebar-text);
border-radius: 9px;
}
.sidebar-menu :deep(.el-menu-item:hover) {
color: #fff;
background: var(--admin-sidebar-surface);
}
.sidebar-menu :deep(.el-menu-item.is-active) {
color: #fff;
font-weight: 650;
background: linear-gradient(135deg, #f45f68, #ee727b);
box-shadow: 0 8px 18px rgba(244, 95, 104, 0.26);
}
.sidebar-menu :deep(.el-icon) {
font-size: 17px;
}
.sidebar-motto {
display: flex;
margin: 8px 15px 2px;
padding: 14px 12px;
align-items: center;
gap: 11px;
color: #ff969c;
font-family: KaiTi, STKaiti, serif;
font-size: 15px;
line-height: 1.45;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.sidebar-motto .el-icon {
font-size: 31px;
}
.collapse-control {
display: flex;
height: 42px;
margin: 2px 10px 10px;
align-items: center;
justify-content: center;
gap: 7px;
color: var(--admin-sidebar-muted);
cursor: pointer;
background: transparent;
border: 0;
}
.collapse-control:hover {
color: #fff;
}
.workspace {
min-width: 0;
}
.admin-topbar {
display: flex;
height: 64px;
padding: 0 24px;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid #edf0f2;
}
.page-context,
.header-tools,
.user-info {
display: flex;
align-items: center;
}
.page-context {
min-width: 180px;
gap: 12px;
}
.mobile-collapse {
display: none;
color: #56616a;
cursor: pointer;
background: transparent;
border: 0;
}
.header-tools {
gap: 14px;
}
.global-search {
width: clamp(220px, 22vw, 320px);
}
.global-search :deep(.el-input__wrapper) {
background: #f6f7f8;
border-radius: 9px;
box-shadow: inset 0 0 0 1px #e8eaed;
}
.notification-button {
position: relative;
display: grid;
width: 36px;
height: 36px;
color: #4f5b65;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 9px;
place-items: center;
}
.notification-button:hover {
background: #f6f7f8;
}
.notification-badge {
position: absolute;
top: 2px;
right: 1px;
min-width: 15px;
height: 15px;
padding: 0 3px;
color: #fff;
font-size: 9px;
line-height: 15px;
text-align: center;
background: var(--admin-coral);
border-radius: 999px;
}
.user-info {
gap: 8px;
cursor: pointer;
}
.user-info .el-avatar {
color: #fff;
background: var(--admin-coral);
}
.user-info strong {
max-width: 92px;
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-content {
padding: 0;
overflow: auto;
background: var(--admin-page-bg);
}
.scene-footer {
display: flex;
max-width: 1840px;
height: 32px;
margin: 0 auto;
align-items: flex-end;
gap: 10px;
color: #8d8587;
font-size: 11px;
}
.scene-footer strong {
color: var(--admin-coral);
font-size: 14px;
}
.scene-footer small {
margin-left: auto;
letter-spacing: 4px;
}
@media (max-width: 1450px), (max-height: 820px) {
.admin-scene {
padding: 12px 24px 10px;
}
.scene-header {
display: grid;
height: 120px;
grid-template-columns: minmax(520px, 1fr) auto;
grid-template-rows: 66px 54px;
column-gap: 18px;
}
.scene-slogan {
padding-right: 12px;
}
.scene-slogan span {
font-size: 14px;
}
.scene-slogan strong {
font-size: 20px;
}
.scene-brand > img {
width: 52px;
height: 52px;
}
.scene-brand-copy strong {
font-size: 22px;
}
.scene-title {
min-width: 310px;
margin-left: 18px;
padding-left: 18px;
}
.scene-title h1 {
font-size: 27px;
}
.scene-benefits {
padding: 0 0 0 28px;
gap: clamp(22px, 4vw, 54px);
}
.admin-shell {
height: calc(100vh - 170px);
}
.scene-footer {
height: 28px;
}
}
@media (max-width: 1180px) {
.scene-title {
display: none;
}
.mobile-collapse {
display: inline-grid;
}
.admin-sidebar {
width: 76px !important;
}
.brand-copy,
.collapse-control span,
.sidebar-motto {
display: none;
}
.brand {
justify-content: center;
}
}
@media (max-width: 760px) {
.admin-scene {
min-width: 0;
padding: 0;
}
.scene-header,
.scene-footer,
.global-search,
.user-info strong {
display: none;
}
.admin-shell {
height: 100vh;
min-height: 0;
border-radius: 0;
}
.admin-topbar {
padding: 0 12px;
}
}
</style>