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:
@@ -136,20 +136,25 @@ import {
|
||||
Bell,
|
||||
Calendar,
|
||||
ChatDotRound,
|
||||
Clock,
|
||||
Connection,
|
||||
DataAnalysis,
|
||||
DataBoard,
|
||||
DocumentChecked,
|
||||
Expand,
|
||||
Fold,
|
||||
House,
|
||||
Location,
|
||||
LocationFilled,
|
||||
Lock,
|
||||
Memo,
|
||||
Opportunity,
|
||||
Picture,
|
||||
Reading,
|
||||
Search,
|
||||
Setting,
|
||||
StarFilled,
|
||||
Tickets,
|
||||
User,
|
||||
UserFilled,
|
||||
Warning,
|
||||
@@ -169,14 +174,20 @@ const navigation: NavigationItem[] = [
|
||||
{ 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: '/matchmaker', permissions: ['matchmaker.service.view'], 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: 'Banner管理', path: '/banner', permissions: ['banner.view'], icon: Picture },
|
||||
{ label: '内容管理', path: '/content', permissions: ['content.view'], icon: Reading },
|
||||
{ label: '善缘服务站', path: '/service-station', permissions: ['service_location.view'], icon: Location },
|
||||
{ label: '服务预约', path: '/service-appointment', permissions: ['service_appointment.view'], icon: House },
|
||||
{ label: '专项服务', path: '/service-program', permissions: ['service_program.manage'], icon: Tickets },
|
||||
{ label: '举报风控', path: '/risk/reports', permissions: ['risk.report.view'], icon: Warning },
|
||||
{ label: '申诉管理', path: '/appeals', permissions: ['risk.report.process'], icon: Memo },
|
||||
{ label: '数据统计', path: '/analytics', permissions: ['analytics.view'], icon: DataAnalysis },
|
||||
{ label: '组织人员', path: '/organizations', permissions: ['organization.manage'], icon: UserFilled },
|
||||
{ label: '操作日志', path: '/audit', permissions: ['user.view'], icon: Clock },
|
||||
{ label: '系统设置', path: '/operators', permissions: ['permission.manage'], icon: Setting },
|
||||
]
|
||||
|
||||
@@ -195,10 +206,17 @@ 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 '资料审核人员'
|
||||
// 按权限从高到低判定,覆盖七类后台角色(平台管理员 / 咨询管理员 / 内容编辑员 /
|
||||
// 风控运营 / 公益咨询 / 服务站运营 / 资料审核 / 公益红娘)。
|
||||
const perms = permissions.value
|
||||
if (perms.includes('permission.manage')) return '平台管理员'
|
||||
if (perms.includes('counseling.service.assign') || perms.includes('counseling.note.view')) return '咨询管理员'
|
||||
if (perms.includes('content.publish')) return '内容编辑员'
|
||||
if (perms.includes('risk.report.process')) return '风控运营人员'
|
||||
if (perms.includes('counseling.service.view')) return '公益咨询人员'
|
||||
if (perms.includes('service_appointment.view')) return '服务站运营人员'
|
||||
if (perms.includes('review.view')) return '资料审核人员'
|
||||
if (perms.includes('matchmaker.service.view')) return '公益红娘'
|
||||
return '运营人员'
|
||||
})
|
||||
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
|
||||
|
||||
@@ -7,31 +7,42 @@
|
||||
<el-button type="primary" @click="openCreate">新建 Banner</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="Banner 只允许跳转站内。目标值填写站内标识(如活动ID、文章ID、页面标识 home),填写 http(s):// 等外链会被拒绝。"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<el-table :data="banners" v-loading="loading" stripe>
|
||||
<el-table-column prop="title" label="标题" width="150" />
|
||||
<el-table-column prop="targetType" label="跳转类型" width="120">
|
||||
<el-table-column prop="buttonText" label="按钮文案" width="110">
|
||||
<template #default="{ row }">{{ row.buttonText || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="targetType" label="跳转类型" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ bannerTargetTypeLabel(row.targetType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="audienceScope" label="展示范围" width="120">
|
||||
<el-table-column prop="audienceScope" label="展示范围" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="audienceScopeTag(row.audienceScope)">{{ audienceScopeLabel(row.audienceScope) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<el-table-column prop="status" label="状态" width="95">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sortOrder" label="排序" width="80" />
|
||||
<el-table-column prop="sortOrder" label="排序" width="70" />
|
||||
<el-table-column label="时间范围" width="200">
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatDateTime(row.startAt) }} ~ {{ formatDateTime(row.endAt) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<el-table-column label="操作" width="230">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openPreview(row)">预览</el-button>
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="warning" @click="toggleStatus(row)">
|
||||
{{ row.status === 'active' ? '下架' : '上架' }}
|
||||
@@ -44,8 +55,9 @@
|
||||
<!-- 新建/编辑 Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑 Banner' : '新建 Banner'" width="600px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="副标题"><el-input v-model="form.subtitle" /></el-form-item>
|
||||
<el-form-item label="标题" required><el-input v-model="form.title" maxlength="200" show-word-limit /></el-form-item>
|
||||
<el-form-item label="副标题"><el-input v-model="form.subtitle" maxlength="300" show-word-limit /></el-form-item>
|
||||
<el-form-item label="按钮文案"><el-input v-model="form.buttonText" maxlength="64" show-word-limit placeholder="如:立即体验(留空则不显示按钮)" /></el-form-item>
|
||||
<el-form-item label="跳转类型">
|
||||
<el-select v-model="form.targetType">
|
||||
<el-option label="公益活动" value="activity" />
|
||||
@@ -58,7 +70,10 @@
|
||||
<el-option label="无" value="none" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标值"><el-input v-model="form.targetValue" placeholder="活动ID/文章ID等" /></el-form-item>
|
||||
<el-form-item label="目标值" :required="needsExplicitTarget">
|
||||
<el-input v-model="form.targetValue" maxlength="200" :placeholder="targetValuePlaceholder" />
|
||||
<div class="field-hint">仅填站内标识,禁止 http(s):// 等站外地址</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="展示范围">
|
||||
<el-select v-model="form.audienceScope">
|
||||
<el-option label="全部用户" value="all" />
|
||||
@@ -68,40 +83,118 @@
|
||||
<el-option label="成功牵手" value="successful" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
|
||||
<el-form-item label="展示开始"><el-date-picker v-model="form.startAt" type="datetime" /></el-form-item>
|
||||
<el-form-item label="展示结束"><el-date-picker v-model="form.endAt" type="datetime" /></el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="生效中" value="active" />
|
||||
<el-option label="已下架" value="inactive" />
|
||||
</el-select>
|
||||
<div class="field-hint">「定时」「已过期」由排期自动流转,无需手工选择</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sortOrder" :min="0" />
|
||||
<div class="field-hint">数值越小越靠前</div>
|
||||
</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>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button @click="previewFromForm">预览</el-button>
|
||||
<el-button type="primary" @click="saveBanner">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 预览 Dialog:模拟小程序首页 Banner 卡片 -->
|
||||
<el-dialog v-model="previewVisible" title="Banner 预览(模拟小程序首页)" width="420px">
|
||||
<div class="preview-stage">
|
||||
<div class="preview-card">
|
||||
<div class="preview-tag">{{ audienceScopeLabel(previewBanner.audienceScope) }}</div>
|
||||
<div class="preview-title">{{ previewBanner.title || '(未填写标题)' }}</div>
|
||||
<div class="preview-subtitle">{{ previewBanner.subtitle || '' }}</div>
|
||||
<div v-if="previewBanner.buttonText" class="preview-button">{{ previewBanner.buttonText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="1" border size="small" style="margin-top: 16px">
|
||||
<el-descriptions-item label="跳转去向">{{ previewTargetText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="展示状态">{{ statusLabel(previewBanner.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="展示时间">
|
||||
{{ formatDateTime(previewBanner.startAt) }} ~ {{ formatDateTime(previewBanner.endAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="排序">{{ previewBanner.sortOrder ?? 0 }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="previewVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get, post, put } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const banners = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const previewVisible = ref(false)
|
||||
const editingId = ref<string | null>(null)
|
||||
const form = ref({ title: '', subtitle: '', targetType: 'none', targetValue: '', audienceScope: 'all', sortOrder: 0, startAt: '', endAt: '' })
|
||||
const previewBanner = ref<any>({})
|
||||
|
||||
const EMPTY_FORM = {
|
||||
title: '', subtitle: '', buttonText: '', targetType: 'none', targetValue: '',
|
||||
audienceScope: 'all', status: 'active', sortOrder: 0, startAt: '', endAt: '',
|
||||
}
|
||||
const form = ref({ ...EMPTY_FORM })
|
||||
|
||||
const TARGET_TYPE_LABEL: Record<string, string> = { activity: '活动', matchmaker: '红娘', counseling: '咨询', premarital: '婚前辅导', service_location: '服务站', article: '文章', internal_page: '内部页', none: '无' }
|
||||
const TARGET_TYPE_DEST: Record<string, string> = {
|
||||
activity: '公益活动列表', matchmaker: '公益红娘', counseling: '婚恋咨询',
|
||||
premarital: '婚前辅导', service_location: '善缘服务站',
|
||||
article: '文章详情', internal_page: '站内页面', none: '无跳转(仅展示)',
|
||||
}
|
||||
const NEEDS_EXPLICIT_TARGET = ['internal_page', 'article']
|
||||
const AUDIENCE_SCOPE_LABEL: Record<string, string> = { all: '全部', onboarding: '未注册', searching: '寻找中', getting_to_know: '初步了解', successful: '成功牵手' }
|
||||
const STATUS_LABEL: Record<string, string> = { draft: '草稿', scheduled: '定时', active: '生效中', inactive: '已下架', expired: '已过期' }
|
||||
const STATUS_TAG: Record<string, string> = { draft: 'info', scheduled: 'warning', active: 'success', inactive: '', expired: 'danger' }
|
||||
/** 与后端 assertInternalTarget 保持一致的禁外链前缀 */
|
||||
const EXTERNAL_LINK_PREFIXES = ['http://', 'https://', '//', 'javascript:', 'data:text/html']
|
||||
|
||||
const needsExplicitTarget = computed(() => NEEDS_EXPLICIT_TARGET.includes(form.value.targetType))
|
||||
const targetValuePlaceholder = computed(() => (form.value.targetType === 'article'
|
||||
? '填写文章ID'
|
||||
: form.value.targetType === 'internal_page' ? '填写站内页面标识,如 home' : '可留空,默认进入对应列表页'))
|
||||
|
||||
function bannerTargetTypeLabel(t: string) { return TARGET_TYPE_LABEL[t] || t }
|
||||
function audienceScopeLabel(s: string) { return AUDIENCE_SCOPE_LABEL[s] || s }
|
||||
function audienceScopeTag(s: string) { return s === 'all' ? 'success' : 'info' }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s || '-' }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
function isExternalLink(value: string) {
|
||||
const normalized = (value || '').trim().toLowerCase()
|
||||
return EXTERNAL_LINK_PREFIXES.some((prefix) => normalized.startsWith(prefix))
|
||||
}
|
||||
function previewTargetText(banner: any) {
|
||||
const dest = TARGET_TYPE_DEST[banner.targetType] || banner.targetType || '无'
|
||||
if (!banner.targetValue) return dest
|
||||
return `${dest} · ${banner.targetValue}`
|
||||
}
|
||||
function assignForm(source: any) {
|
||||
form.value = {
|
||||
title: source.title || '',
|
||||
subtitle: source.subtitle || '',
|
||||
buttonText: source.buttonText || '',
|
||||
targetType: source.targetType || 'none',
|
||||
targetValue: source.targetValue || '',
|
||||
audienceScope: source.audienceScope || 'all',
|
||||
status: source.status || 'active',
|
||||
sortOrder: source.sortOrder ?? 0,
|
||||
startAt: source.startAt || '',
|
||||
endAt: source.endAt || '',
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBanners() {
|
||||
loading.value = true
|
||||
@@ -111,10 +204,60 @@ async function loadBanners() {
|
||||
} catch (e) { console.error(e) } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() { editingId.value = null; Object.assign(form.value, { title: '', subtitle: '', targetType: 'none', targetValue: '', audienceScope: 'all', sortOrder: 0, startAt: '', endAt: '' }); dialogVisible.value = true }
|
||||
function openEdit(row: any) { editingId.value = row.id; Object.assign(form.value, { title: row.title, subtitle: row.subtitle, targetType: row.targetType, targetValue: row.targetValue, audienceScope: row.audienceScope, sortOrder: row.sortOrder, startAt: row.startAt, endAt: row.endAt }); dialogVisible.value = true }
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
assignForm({})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
function openEdit(row: any) {
|
||||
editingId.value = row.id
|
||||
assignForm(row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 与后端同源的校验。前端拦一道是为了让运营即时看到原因,
|
||||
* 不作为唯一防线(后端 HomeBannerOperatorController.validate 仍是权威)。
|
||||
* @returns 校验通过返回 true
|
||||
*/
|
||||
function validateForm(): boolean {
|
||||
const value = form.value
|
||||
if (!value.title.trim()) { ElMessage.error('请填写 Banner 标题'); return false }
|
||||
if (isExternalLink(value.targetValue)) {
|
||||
ElMessage.error('Banner 不允许跳转站外地址,请填写站内标识')
|
||||
return false
|
||||
}
|
||||
if (NEEDS_EXPLICIT_TARGET.includes(value.targetType) && !value.targetValue.trim()) {
|
||||
ElMessage.error(`跳转类型为「${bannerTargetTypeLabel(value.targetType)}」时必须填写站内目标值`)
|
||||
return false
|
||||
}
|
||||
if (value.startAt && value.endAt && new Date(value.startAt) > new Date(value.endAt)) {
|
||||
ElMessage.error('展示开始时间不能晚于结束时间')
|
||||
return false
|
||||
}
|
||||
if (value.sortOrder != null && value.sortOrder < 0) {
|
||||
ElMessage.error('排序值不能为负数')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function openPreview(row: any) {
|
||||
if (isExternalLink(row.targetValue)) {
|
||||
ElMessage.error('该 Banner 目标值疑似站外地址,已阻止预览')
|
||||
return
|
||||
}
|
||||
previewBanner.value = row
|
||||
previewVisible.value = true
|
||||
}
|
||||
function previewFromForm() {
|
||||
if (!validateForm()) return
|
||||
previewBanner.value = { ...form.value }
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
async function saveBanner() {
|
||||
if (!validateForm()) return
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await put(`/operator/banners/${editingId.value}`, form.value)
|
||||
@@ -125,15 +268,32 @@ async function saveBanner() {
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadBanners()
|
||||
} catch (e) { ElMessage.error('操作失败') }
|
||||
} catch (e) {
|
||||
// 错误提示由 api/client 的响应拦截器统一弹出,此处不重复 toast
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row: any) {
|
||||
try {
|
||||
const newStatus = row.status === 'active' ? 'inactive' : 'active'
|
||||
await put(`/operator/banners/${row.id}`, { ...row, status: newStatus })
|
||||
// 只回传可编辑字段:id/createdBy/updatedAt 等由服务端维护,避免把服务端字段带回写坏
|
||||
await put(`/operator/banners/${row.id}`, {
|
||||
title: row.title,
|
||||
subtitle: row.subtitle,
|
||||
buttonText: row.buttonText,
|
||||
targetType: row.targetType,
|
||||
targetValue: row.targetValue,
|
||||
audienceScope: row.audienceScope,
|
||||
status: newStatus,
|
||||
sortOrder: row.sortOrder,
|
||||
startAt: row.startAt,
|
||||
endAt: row.endAt,
|
||||
})
|
||||
await loadBanners()
|
||||
} catch (e) { ElMessage.error('操作失败') }
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadBanners)
|
||||
@@ -141,4 +301,22 @@ onMounted(loadBanners)
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.field-hint { width: 100%; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; }
|
||||
.preview-stage { padding: 20px; background: #f5f6f8; border-radius: 12px; }
|
||||
.preview-card {
|
||||
padding: 20px 18px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #ffe9df, #fff7f2);
|
||||
box-shadow: 0 6px 16px rgba(200, 120, 100, 0.16);
|
||||
}
|
||||
.preview-tag {
|
||||
display: inline-block; padding: 2px 10px; border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.85); color: #c8553d; font-size: 12px;
|
||||
}
|
||||
.preview-title { margin-top: 10px; font-size: 17px; font-weight: 700; color: #2c2c34; }
|
||||
.preview-subtitle { margin-top: 6px; font-size: 12px; line-height: 1.6; color: #6b6b78; }
|
||||
.preview-button {
|
||||
display: inline-block; margin-top: 14px; padding: 5px 16px; border-radius: 999px;
|
||||
background: linear-gradient(135deg, #ff7d6e, #ff5c82); color: #fff; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,18 +3,38 @@
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="内容分类" name="categories">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>内容分类</span><el-button type="primary" size="small" @click="openCategoryDialog">新建分类</el-button></div></template>
|
||||
<el-table :data="categories" stripe>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>内容分类</span>
|
||||
<el-button type="primary" size="small" @click="openCategoryDialog">新建分类</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="分类不做删除:文章通过分类ID引用它,删除会让历史文章分类悬空。不再使用的分类请「停用」,停用后小程序端不再下发,但后台仍可见并可重新启用。"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<el-table :data="categories" stripe v-loading="categoryLoading">
|
||||
<el-table-column prop="name" label="分类名称" />
|
||||
<el-table-column prop="code" label="编码" width="150" />
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column prop="sortOrder" label="排序" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':''">{{ row.status }}</el-tag></template>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ categoryStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editCategory(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCategory(row)">删除</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="row.status === 'active' ? 'warning' : 'success'"
|
||||
@click="toggleCategoryStatus(row)"
|
||||
>
|
||||
{{ row.status === 'active' ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -22,21 +42,26 @@
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="文章内容" name="articles">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>文章内容</span><el-button type="primary" size="small" @click="openArticleDialog">新建文章</el-button></div></template>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>文章内容</span>
|
||||
<el-button type="primary" size="small" @click="openArticleDialog">新建文章</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="articles" stripe v-loading="loading">
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column label="分类" width="120">
|
||||
<template #default="{ row }">{{ categoryLabel(row.category_id) }}</template>
|
||||
<template #default="{ row }">{{ categoryLabel(row.categoryId) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="view_count" label="阅读量" width="80" />
|
||||
<el-table-column prop="viewCount" label="阅读量" width="80" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editArticle(row)">编辑</el-button>
|
||||
<el-button v-if="row.status==='draft'" size="small" type="success" @click="publishArticle(row)">发布</el-button>
|
||||
<el-button v-if="row.status==='published'" size="small" type="warning" @click="offlineArticle(row)">下架</el-button>
|
||||
<el-button v-if="row.status !== 'published'" size="small" type="success" @click="publishArticle(row)">发布</el-button>
|
||||
<el-button v-if="row.status === 'published'" size="small" type="warning" @click="offlineArticle(row)">下架</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -47,30 +72,35 @@
|
||||
<!-- 分类Dialog -->
|
||||
<el-dialog v-model="categoryDialogVisible" :title="editingCategoryId ? '编辑分类' : '新建分类'" width="500px">
|
||||
<el-form :model="categoryForm" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="categoryForm.name" /></el-form-item>
|
||||
<el-form-item label="编码"><el-input v-model="categoryForm.code" /></el-form-item>
|
||||
<el-form-item label="名称" required><el-input v-model="categoryForm.name" maxlength="128" show-word-limit /></el-form-item>
|
||||
<el-form-item label="编码" required>
|
||||
<el-input v-model="categoryForm.code" maxlength="64" show-word-limit placeholder="唯一标识,如 classroom" />
|
||||
<div class="field-hint">编码全局唯一,重复会被拒绝</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="categoryForm.sortOrder" :min="0" /></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="categoryForm.description" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="categoryDialogVisible=false">取消</el-button>
|
||||
<el-button @click="categoryDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCategory">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 文章Dialog -->
|
||||
<el-dialog v-model="articleDialogVisible" :title="editingArticleId ? '编辑文章' : '新建文章'" width="700px">
|
||||
<el-form :model="articleForm" label-width="80px">
|
||||
<el-form-item label="标题"><el-input v-model="articleForm.title" /></el-form-item>
|
||||
<el-form :model="articleForm" label-width="80px" v-loading="articleDetailLoading">
|
||||
<el-form-item label="标题" required><el-input v-model="articleForm.title" maxlength="200" show-word-limit /></el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="articleForm.categoryId">
|
||||
<el-select v-model="articleForm.categoryId" clearable placeholder="请选择分类">
|
||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要"><el-input v-model="articleForm.summary" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="内容"><el-input v-model="articleForm.content" type="textarea" :rows="8" /></el-form-item>
|
||||
<el-form-item label="摘要"><el-input v-model="articleForm.summary" type="textarea" :rows="2" maxlength="500" show-word-limit /></el-form-item>
|
||||
<el-form-item label="正文"><el-input v-model="articleForm.content" type="textarea" :rows="10" /></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="articleForm.sortOrder" :min="0" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="articleDialogVisible=false">取消</el-button>
|
||||
<el-button @click="articleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveArticle">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -79,76 +109,149 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post, put, del } from '@/api/client'
|
||||
import { get, post, put } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const activeTab = ref('categories')
|
||||
const loading = ref(false)
|
||||
const categoryLoading = ref(false)
|
||||
const articleDetailLoading = ref(false)
|
||||
const categories = ref<any[]>([])
|
||||
const articles = ref<any[]>([])
|
||||
|
||||
const categoryDialogVisible = ref(false)
|
||||
const editingCategoryId = ref<string | null>(null)
|
||||
const categoryForm = ref({ name: '', code: '', sortOrder: 0 })
|
||||
const categoryForm = ref({ name: '', code: '', description: '', sortOrder: 0 })
|
||||
|
||||
const articleDialogVisible = ref(false)
|
||||
const editingArticleId = ref<string | null>(null)
|
||||
const articleForm = ref({ title: '', categoryId: '', summary: '', content: '' })
|
||||
const articleForm = ref({ title: '', categoryId: '', summary: '', content: '', sortOrder: 0 })
|
||||
|
||||
const CATEGORY_STATUS_LABEL: Record<string, string> = { active: '启用中', inactive: '已停用' }
|
||||
const STATUS_LABEL: Record<string, string> = { draft: '草稿', scheduled: '定时', published: '已发布', offline: '已下架' }
|
||||
const STATUS_TAG: Record<string, string> = { draft: 'info', scheduled: 'warning', published: 'success', offline: 'danger' }
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = { draft: '草稿', published: '已发布', offline: '已下架' }
|
||||
const STATUS_TAG: Record<string, string> = { draft: 'info', published: 'success', offline: 'danger' }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
function categoryStatusLabel(s: string) { return CATEGORY_STATUS_LABEL[s] || s }
|
||||
function categoryLabel(id: string) { const c = categories.value.find(x => x.id === id); return c ? c.name : '-' }
|
||||
|
||||
// 错误提示统一由 api/client 的响应拦截器弹出,catch 里不再重复 toast
|
||||
async function loadCategories() {
|
||||
const res: any = await get('/content/categories')
|
||||
categories.value = res.data || []
|
||||
categoryLoading.value = true
|
||||
try {
|
||||
// operator 接口:含已停用分类,否则停用后无法在后台重新启用
|
||||
const res: any = await get('/content/operator/categories')
|
||||
categories.value = res.data || []
|
||||
} catch (e) { console.error(e) } finally { categoryLoading.value = false }
|
||||
}
|
||||
|
||||
async function loadArticles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await get('/content/articles')
|
||||
// operator 接口:含全部状态。公开接口只给 published,草稿不出现在列表里 → 无法点「发布」
|
||||
const res: any = await get('/content/operator/articles')
|
||||
articles.value = res.data || []
|
||||
} finally { loading.value = false }
|
||||
} catch (e) { console.error(e) } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function validateCategory(): boolean {
|
||||
if (!categoryForm.value.name.trim()) { ElMessage.error('请填写分类名称'); return false }
|
||||
if (!categoryForm.value.code.trim()) { ElMessage.error('请填写分类编码'); return false }
|
||||
return true
|
||||
}
|
||||
|
||||
async function saveCategory() {
|
||||
if (!validateCategory()) return
|
||||
try {
|
||||
if (editingCategoryId.value) await put(`/content/operator/categories/${editingCategoryId.value}`, categoryForm.value)
|
||||
else await post('/content/operator/categories', categoryForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
categoryDialogVisible.value = false
|
||||
loadCategories()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
async function editCategory(row: any) { editingCategoryId.value = row.id; Object.assign(categoryForm.value, row); categoryDialogVisible.value = true }
|
||||
async function deleteCategory(row: any) {
|
||||
try { await del(`/content/operator/categories/${row.id}`); ElMessage.success('删除成功'); loadCategories() }
|
||||
catch (e) { ElMessage.error('删除失败') }
|
||||
|
||||
function editCategory(row: any) {
|
||||
editingCategoryId.value = row.id
|
||||
categoryForm.value = {
|
||||
name: row.name || '', code: row.code || '',
|
||||
description: row.description || '', sortOrder: row.sortOrder ?? 0,
|
||||
}
|
||||
categoryDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function toggleCategoryStatus(row: any) {
|
||||
const next = row.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
await put(`/content/operator/categories/${row.id}/status`, { status: next })
|
||||
ElMessage.success(next === 'active' ? '已启用' : '已停用')
|
||||
loadCategories()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function validateArticle(): boolean {
|
||||
if (!articleForm.value.title.trim()) { ElMessage.error('请填写文章标题'); return false }
|
||||
return true
|
||||
}
|
||||
|
||||
async function saveArticle() {
|
||||
if (!validateArticle()) return
|
||||
try {
|
||||
if (editingArticleId.value) await put(`/content/operator/articles/${editingArticleId.value}`, articleForm.value)
|
||||
else await post('/content/operator/articles', articleForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
articleDialogVisible.value = false
|
||||
loadArticles()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
|
||||
}
|
||||
async function editArticle(row: any) { editingArticleId.value = row.id; Object.assign(articleForm.value, { title: row.title, categoryId: row.category_id, summary: row.summary, content: row.content }); articleDialogVisible.value = true }
|
||||
async function publishArticle(row: any) {
|
||||
try { await post(`/content/operator/articles/${row.id}/publish`, {}); ElMessage.success('发布成功'); loadArticles() }
|
||||
catch (e) { ElMessage.error('发布失败') }
|
||||
}
|
||||
async function offlineArticle(row: any) {
|
||||
try { await post(`/content/operator/articles/${row.id}/offline`, {}); ElMessage.success('下架成功'); loadArticles() }
|
||||
catch (e) { ElMessage.error('下架失败') }
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function openCategoryDialog() { editingCategoryId.value = null; Object.assign(categoryForm.value, { name: '', code: '', sortOrder: 0 }); categoryDialogVisible.value = true }
|
||||
function openArticleDialog() { editingArticleId.value = null; Object.assign(articleForm.value, { title: '', categoryId: '', summary: '', content: '' }); articleDialogVisible.value = true }
|
||||
async function editArticle(row: any) {
|
||||
editingArticleId.value = row.id
|
||||
articleForm.value = {
|
||||
title: row.title || '', categoryId: row.categoryId || '',
|
||||
summary: row.summary || '', content: '', sortOrder: row.sortOrder ?? 0,
|
||||
}
|
||||
articleDialogVisible.value = true
|
||||
// 列表 DTO 不含正文(有意保持轻量),必须拉详情才能回填正文
|
||||
articleDetailLoading.value = true
|
||||
try {
|
||||
const res: any = await get(`/content/operator/articles/${row.id}`)
|
||||
const detail = res.data || {}
|
||||
articleForm.value = {
|
||||
title: detail.title || '', categoryId: detail.categoryId || '',
|
||||
summary: detail.summary || '', content: detail.content || '',
|
||||
sortOrder: detail.sortOrder ?? 0,
|
||||
}
|
||||
} catch (e) { console.error(e) } finally { articleDetailLoading.value = false }
|
||||
}
|
||||
|
||||
async function publishArticle(row: any) {
|
||||
try { await post(`/content/operator/articles/${row.id}/publish`, {}); ElMessage.success('发布成功'); loadArticles() }
|
||||
catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function offlineArticle(row: any) {
|
||||
try { await post(`/content/operator/articles/${row.id}/offline`, {}); ElMessage.success('下架成功'); loadArticles() }
|
||||
catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function openCategoryDialog() {
|
||||
editingCategoryId.value = null
|
||||
categoryForm.value = { name: '', code: '', description: '', sortOrder: 0 }
|
||||
categoryDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openArticleDialog() {
|
||||
editingArticleId.value = null
|
||||
articleForm.value = { title: '', categoryId: '', summary: '', content: '', sortOrder: 0 }
|
||||
articleDialogVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => { loadCategories(); loadArticles() })
|
||||
</script>
|
||||
<style
|
||||
scoped></style>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.field-hint { width: 100%; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; }
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<div class="service-station">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>善缘服务站管理</span><el-button type="primary" size="small" @click="openCreate">新建服务站</el-button></div></template>
|
||||
<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" />
|
||||
@@ -11,23 +16,35 @@
|
||||
<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'">{{ row.status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<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="编辑服务站" width="600px">
|
||||
<!-- 服务站信息 -->
|
||||
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑服务站' : '新建服务站'" width="600px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<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="地址"><el-input v-model="form.address" /></el-form-item>
|
||||
<el-form-item label="电话显示"><el-input v-model="form.contactPhoneDisplay" /></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" />
|
||||
@@ -37,44 +54,300 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible=false">取消</el-button>
|
||||
<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, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get, post, put } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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 }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(row: any) { Object.assign(form.value, row); dialogVisible.value = true }
|
||||
function openCreate() { Object.assign(form.value, { id: undefined, name: '', shortName: '', address: '', contactPhoneDisplay: '', openingHoursText: '', status: 'active' }); dialogVisible.value = true }
|
||||
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 {
|
||||
if (form.value.id) await put(`/operator/service-locations/${form.value.id}`, form.value)
|
||||
else await post('/operator/service-locations', form.value)
|
||||
// 失败提示由响应拦截器统一处理(避免重复 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 (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
|
||||
} 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}</style>
|
||||
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user