Files
danyang-match/admin-web/src/modules/banner/BannerManagementView.vue
T
WorkBuddy 3c2b967274
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
release: freeze 2026-10-15 validated release baseline
2026-10-15 09:47:31 +08:00

323 lines
15 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="banner-management">
<el-card>
<template #header>
<div class="card-header">
<span>首页 Banner 管理</span>
<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="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="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="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="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="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' ? '下架' : '上架' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新建/编辑 Dialog -->
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑 Banner' : '新建 Banner'" width="600px">
<el-form :model="form" label-width="100px">
<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" />
<el-option label="公益红娘" value="matchmaker" />
<el-option label="婚恋咨询" value="counseling" />
<el-option label="婚前辅导" value="premarital" />
<el-option label="善缘服务站" value="service_location" />
<el-option label="文章内容" value="article" />
<el-option label="内部页面" value="internal_page" />
<el-option label="无" value="none" />
</el-select>
</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" />
<el-option label="未注册" value="onboarding" />
<el-option label="正在寻找" value="searching" />
<el-option label="初步了解" value="getting_to_know" />
<el-option label="成功牵手" value="successful" />
</el-select>
</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, 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 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 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} · 已配置站内目标`
}
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
try {
const res: any = await get('/operator/banners')
banners.value = res.data || []
} catch (e) { console.error(e) } finally { loading.value = false }
}
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)
ElMessage.success('更新成功')
} else {
await post('/operator/banners', form.value)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadBanners()
} catch (e) {
// 错误提示由 api/client 的响应拦截器统一弹出,此处不重复 toast
console.error(e)
}
}
async function toggleStatus(row: any) {
try {
const newStatus = row.status === 'active' ? 'inactive' : 'active'
// 只回传可编辑字段: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) {
console.error(e)
}
}
onMounted(loadBanners)
</script>
<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: var(--color-bg-subtle); border-radius: 12px; }
.preview-card {
padding: 20px 18px;
border-radius: 16px;
background: var(--gradient-brand-soft); /* 还原消费端 Banner 预览底色 */
box-shadow: 0 6px 16px rgba(200, 120, 100, 0.16);
}
.preview-tag {
display: inline-block; padding: 2px 10px; border-radius: 999px;
background: var(--color-bg-overlay); color: var(--admin-coral-strong); font-size: var(--font-size-xs);
}
.preview-title { margin-top: 10px; font-size: var(--font-size-xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
.preview-subtitle { margin-top: 6px; font-size: var(--font-size-xs); line-height: 1.6; color: var(--color-text-tertiary); }
.preview-button {
display: inline-block; margin-top: 14px; padding: 5px 16px; border-radius: 999px;
background: var(--gradient-brand-vivid); color: var(--color-text-inverse); font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); min-height: var(--tap-control); /* 还原消费端 Banner 按钮外观预览 */
}
</style>