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
323 lines
15 KiB
Vue
323 lines
15 KiB
Vue
<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>
|