feat(banner): support dynamic banner management in admin and sync with miniprogram
这个提交包含在:
@@ -46,6 +46,8 @@ export default {
|
|||||||
'menu.safety': 'Safety & Risk Control',
|
'menu.safety': 'Safety & Risk Control',
|
||||||
'menu.safety.places': 'Approved Venues',
|
'menu.safety.places': 'Approved Venues',
|
||||||
'menu.safety.auditLog': 'Risk Intercept Log',
|
'menu.safety.auditLog': 'Risk Intercept Log',
|
||||||
|
'menu.banner': 'Banner & Operations',
|
||||||
|
'menu.banner.manage': 'Banner Management',
|
||||||
'menu.server.dashboard': 'Dashboard-Server',
|
'menu.server.dashboard': 'Dashboard-Server',
|
||||||
'menu.server.workplace': 'Workplace-Server',
|
'menu.server.workplace': 'Workplace-Server',
|
||||||
'menu.server.monitor': 'Monitor-Server',
|
'menu.server.monitor': 'Monitor-Server',
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export default {
|
|||||||
'menu.safety': '履约安全与风控',
|
'menu.safety': '履约安全与风控',
|
||||||
'menu.safety.places': '线下商圈场所库',
|
'menu.safety.places': '线下商圈场所库',
|
||||||
'menu.safety.auditLog': '场所与内容拦截日志',
|
'menu.safety.auditLog': '场所与内容拦截日志',
|
||||||
|
'menu.banner': '首页轮播与运营',
|
||||||
|
'menu.banner.manage': '轮播图管理',
|
||||||
'menu.server.dashboard': '仪表盘-服务端',
|
'menu.server.dashboard': '仪表盘-服务端',
|
||||||
'menu.server.workplace': '工作台-服务端',
|
'menu.server.workplace': '工作台-服务端',
|
||||||
'menu.server.monitor': '实时监控-服务端',
|
'menu.server.monitor': '实时监控-服务端',
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { DEFAULT_LAYOUT } from '../base';
|
||||||
|
import { AppRouteRecordRaw } from '../types';
|
||||||
|
|
||||||
|
const BANNER: AppRouteRecordRaw = {
|
||||||
|
path: '/banner',
|
||||||
|
name: 'banner',
|
||||||
|
component: DEFAULT_LAYOUT,
|
||||||
|
meta: {
|
||||||
|
locale: 'menu.banner',
|
||||||
|
requiresAuth: true,
|
||||||
|
icon: 'icon-image',
|
||||||
|
order: 6,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'manage',
|
||||||
|
name: 'BannerManage',
|
||||||
|
component: () => import('@/views/banner/manage/index.vue'),
|
||||||
|
meta: {
|
||||||
|
locale: 'menu.banner.manage',
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ['*'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BANNER;
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<a-breadcrumb class="breadcrumb">
|
||||||
|
<a-breadcrumb-item>首页轮播与运营</a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item>轮播图管理</a-breadcrumb-item>
|
||||||
|
</a-breadcrumb>
|
||||||
|
|
||||||
|
<a-card class="general-card" title="首页轮播图配置与运营控制台">
|
||||||
|
<template #extra>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" @click="openCreateModal">
|
||||||
|
<template #icon><icon-plus /></template>
|
||||||
|
新增轮播图
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="fetchData">
|
||||||
|
<template #icon><icon-refresh /></template>
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 顶部提示 -->
|
||||||
|
<a-alert style="margin-bottom: 16px" type="info">
|
||||||
|
在此处配置的轮播图将实时同步至微信小程序端首页顶部横幅。支持配置活动跳转链接、标签角标、背景渐变色、排序优先级及一键上下线。
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<!-- 表格区域 -->
|
||||||
|
<a-table
|
||||||
|
:data="bannerList"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="{ pageSize: 10, showTotal: true }"
|
||||||
|
row-key="id"
|
||||||
|
>
|
||||||
|
<template #columns>
|
||||||
|
<a-table-column title="ID" data-index="id" :width="70" />
|
||||||
|
|
||||||
|
<a-table-column title="封面图片" :width="140">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div class="banner-preview-box" :style="{ background: record.bg }">
|
||||||
|
<a-image
|
||||||
|
v-if="record.image"
|
||||||
|
:src="record.image"
|
||||||
|
:width="110"
|
||||||
|
:height="56"
|
||||||
|
fit="cover"
|
||||||
|
style="border-radius: 4px; box-shadow: 0 2px 6px rgba(0,0,0,0.15);"
|
||||||
|
/>
|
||||||
|
<div v-else class="empty-img-text">无图卡片</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="标题 / 副标题" :width="280">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div class="banner-title-text">{{ record.title }}</div>
|
||||||
|
<div class="banner-subtitle-text">{{ record.subtitle || '(未设置副标题)' }}</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="标签 / 角标" :width="150">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div style="margin-bottom: 4px">
|
||||||
|
<a-tag color="arcoblue" size="small">{{ record.tag || '默认标签' }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a-tag color="orangered" size="small">{{ record.badge || 'HOT' }}</a-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="跳转链接" :width="200">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span class="link-text">{{ record.link }}</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="显示排序" data-index="sortOrder" :width="100">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-badge :count="record.sortOrder" :max-count="999" color="#165dff" />
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="状态" :width="110">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-switch
|
||||||
|
:model-value="record.status === 1"
|
||||||
|
:loading="statusLoadingId === record.id"
|
||||||
|
@change="(val) => handleToggleStatus(record, val)"
|
||||||
|
>
|
||||||
|
<template #checked>上架</template>
|
||||||
|
<template #unchecked>下线</template>
|
||||||
|
</a-switch>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="操作" :width="160" fixed="right">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="text" size="small" @click="openEditModal(record)">
|
||||||
|
<template #icon><icon-edit /></template>
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
content="确定彻底删除此轮播图吗?删除后小程序将不再呈现。"
|
||||||
|
@ok="handleDelete(record.id)"
|
||||||
|
>
|
||||||
|
<a-button type="text" status="danger" size="small">
|
||||||
|
<template #icon><icon-delete /></template>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- 新增 / 编辑 弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:visible="modalVisible"
|
||||||
|
:title="isEdit ? '编辑首页轮播图' : '新增首页轮播图'"
|
||||||
|
:width="640"
|
||||||
|
@ok="handleModalSubmit"
|
||||||
|
@cancel="modalVisible = false"
|
||||||
|
>
|
||||||
|
<a-form :model="formData" layout="vertical">
|
||||||
|
<a-form-item label="主标题" required>
|
||||||
|
<a-input
|
||||||
|
v-model="formData.title"
|
||||||
|
placeholder="如:同城聚会狂欢 | 网红火锅探店与量贩KTV嗨唱"
|
||||||
|
maxlength="100"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="副标题">
|
||||||
|
<a-input
|
||||||
|
v-model="formData.subtitle"
|
||||||
|
placeholder="如:AA制铜锅涮肉 · 8人热烈局"
|
||||||
|
maxlength="150"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="主分类标签">
|
||||||
|
<a-input v-model="formData.tag" placeholder="如:热门陪伴 / K歌对唱" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="角标高亮文本">
|
||||||
|
<a-input v-model="formData.badge" placeholder="如:HOT 热门组局 / 限时立减" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item label="轮播图封面图片">
|
||||||
|
<div style="display: flex; gap: 12px; align-items: flex-start; width: 100%;">
|
||||||
|
<a-input
|
||||||
|
v-model="formData.image"
|
||||||
|
placeholder="可直接输入图片URL或点击右侧上传"
|
||||||
|
style="flex: 1;"
|
||||||
|
/>
|
||||||
|
<a-upload
|
||||||
|
action="/api/upload"
|
||||||
|
name="file"
|
||||||
|
:show-file-list="false"
|
||||||
|
@success="handleUploadSuccess"
|
||||||
|
@error="handleUploadError"
|
||||||
|
>
|
||||||
|
<template #upload-button>
|
||||||
|
<a-button type="outline">
|
||||||
|
<template #icon><icon-upload /></template>
|
||||||
|
上传图片
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-upload>
|
||||||
|
</div>
|
||||||
|
<div v-if="formData.image" style="margin-top: 10px">
|
||||||
|
<a-image
|
||||||
|
:src="formData.image"
|
||||||
|
:height="90"
|
||||||
|
fit="cover"
|
||||||
|
style="border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="14">
|
||||||
|
<a-form-item label="跳转页面路径">
|
||||||
|
<a-input
|
||||||
|
v-model="formData.link"
|
||||||
|
placeholder="如:/pages/activity/detail?id=act_101"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="10">
|
||||||
|
<a-form-item label="排序优先级 (越小越靠前)">
|
||||||
|
<a-input-number v-model="formData.sortOrder" :min="0" :max="999" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item label="背景渐变色样式">
|
||||||
|
<a-input
|
||||||
|
v-model="formData.bg"
|
||||||
|
placeholder="如:linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)"
|
||||||
|
/>
|
||||||
|
<div style="margin-top: 6px; display: flex; gap: 8px;">
|
||||||
|
<a-button
|
||||||
|
size="mini"
|
||||||
|
@click="formData.bg = 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)'"
|
||||||
|
>
|
||||||
|
绯红活力
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
size="mini"
|
||||||
|
@click="formData.bg = 'linear-gradient(135deg, #7C3AED 0%, #6366F1 100%)'"
|
||||||
|
>
|
||||||
|
紫蓝魅夜
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
size="mini"
|
||||||
|
@click="formData.bg = 'linear-gradient(135deg, #059669 0%, #10B981 100%)'"
|
||||||
|
>
|
||||||
|
清新薄荷
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
size="mini"
|
||||||
|
@click="formData.bg = 'linear-gradient(135deg, #D97706 0%, #F59E0B 100%)'"
|
||||||
|
>
|
||||||
|
暖阳金秋
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="初始发布状态">
|
||||||
|
<a-radio-group v-model="formData.status">
|
||||||
|
<a-radio :value="1">立即上架(小程序端立即可见)</a-radio>
|
||||||
|
<a-radio :value="0">暂存草稿(下线隐藏)</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Message } from '@arco-design/web-vue';
|
||||||
|
|
||||||
|
interface BannerItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
tag: string;
|
||||||
|
badge: string;
|
||||||
|
badgeType: string;
|
||||||
|
bg: string;
|
||||||
|
image: string;
|
||||||
|
link: string;
|
||||||
|
sortOrder: number;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const bannerList = ref<BannerItem[]>([]);
|
||||||
|
const statusLoadingId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const modalVisible = ref(false);
|
||||||
|
const isEdit = ref(false);
|
||||||
|
const editingId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const defaultFormData = {
|
||||||
|
title: '',
|
||||||
|
subtitle: '',
|
||||||
|
tag: '热门活动',
|
||||||
|
badge: 'HOT 热门组局',
|
||||||
|
badgeType: 'badge-hot',
|
||||||
|
bg: 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)',
|
||||||
|
image: '',
|
||||||
|
link: '/pages/activity/list',
|
||||||
|
sortOrder: 1,
|
||||||
|
status: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formData = ref({ ...defaultFormData });
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await axios.get('/api/admin/banners');
|
||||||
|
if (res && res.data && res.data.code === 20000) {
|
||||||
|
bannerList.value = res.data.data;
|
||||||
|
} else if (Array.isArray(res.data)) {
|
||||||
|
bannerList.value = res.data;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error(err.response?.data?.message || '获取轮播图列表失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
isEdit.value = false;
|
||||||
|
editingId.value = null;
|
||||||
|
formData.value = { ...defaultFormData };
|
||||||
|
modalVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (record: BannerItem) => {
|
||||||
|
isEdit.value = true;
|
||||||
|
editingId.value = record.id;
|
||||||
|
formData.value = {
|
||||||
|
title: record.title,
|
||||||
|
subtitle: record.subtitle || '',
|
||||||
|
tag: record.tag || '热门活动',
|
||||||
|
badge: record.badge || 'HOT',
|
||||||
|
badgeType: record.badgeType || 'badge-hot',
|
||||||
|
bg: record.bg || 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)',
|
||||||
|
image: record.image || '',
|
||||||
|
link: record.link || '/pages/activity/list',
|
||||||
|
sortOrder: record.sortOrder || 0,
|
||||||
|
status: record.status,
|
||||||
|
};
|
||||||
|
modalVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadSuccess = (fileItem: any) => {
|
||||||
|
const response = fileItem.response;
|
||||||
|
if (response && response.code === 20000 && response.data?.url) {
|
||||||
|
formData.value.image = response.data.url;
|
||||||
|
Message.success('轮播图片上传成功并已持久化');
|
||||||
|
} else {
|
||||||
|
Message.warning('上传完成,但响应格式异常');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadError = () => {
|
||||||
|
Message.error('图片上传失败,请检查文件大小及网络');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalSubmit = async () => {
|
||||||
|
if (!formData.value.title || !formData.value.title.trim()) {
|
||||||
|
Message.warning('轮播图主标题不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit.value && editingId.value) {
|
||||||
|
const res: any = await axios.put(`/api/admin/banners/${editingId.value}`, formData.value);
|
||||||
|
if (res && (res.data.code === 20000 || res.status === 200)) {
|
||||||
|
Message.success('轮播图修改成功');
|
||||||
|
modalVisible.value = false;
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res: any = await axios.post('/api/admin/banners', formData.value);
|
||||||
|
if (res && (res.data.code === 20000 || res.status === 200)) {
|
||||||
|
Message.success('轮播图创建成功,已同步至小程序端');
|
||||||
|
modalVisible.value = false;
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error(err.response?.data?.message || '保存失败,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async (record: BannerItem, checked: any) => {
|
||||||
|
const newStatus = checked ? 1 : 0;
|
||||||
|
statusLoadingId.value = record.id;
|
||||||
|
try {
|
||||||
|
const res: any = await axios.post(`/api/admin/banners/${record.id}/status`, {
|
||||||
|
status: newStatus,
|
||||||
|
});
|
||||||
|
if (res && (res.data.code === 20000 || res.status === 200)) {
|
||||||
|
record.status = newStatus;
|
||||||
|
Message.success(newStatus === 1 ? '已上架至小程序端' : '已下线隐藏');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error(err.response?.data?.message || '切换状态失败');
|
||||||
|
} finally {
|
||||||
|
statusLoadingId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const res: any = await axios.delete(`/api/admin/banners/${id}`);
|
||||||
|
if (res && (res.data.code === 20000 || res.status === 200)) {
|
||||||
|
Message.success('轮播图已删除');
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error(err.response?.data?.message || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
.breadcrumb {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.banner-preview-box {
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.empty-img-text {
|
||||||
|
width: 110px;
|
||||||
|
height: 56px;
|
||||||
|
line-height: 56px;
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.banner-title-text {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.banner-subtitle-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
.link-text {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #165dff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// 已读取AGENTS.md
|
||||||
|
import { ContentSecurityService } from '../security/content-security.service';
|
||||||
|
|
||||||
|
export interface BannerItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
tag?: string;
|
||||||
|
badge?: string;
|
||||||
|
badgeType?: string;
|
||||||
|
bg?: string;
|
||||||
|
image?: string;
|
||||||
|
link?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
status: number; // 1: 启用上架, 0: 停用下架
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BannerService {
|
||||||
|
private banners: BannerItem[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '同城聚会狂欢 | 网红火锅探店与量贩KTV嗨唱',
|
||||||
|
subtitle: 'AA制铜锅涮肉 · 8人热烈局',
|
||||||
|
tag: '热门陪伴',
|
||||||
|
badge: 'HOT 热门组局',
|
||||||
|
badgeType: 'badge-hot',
|
||||||
|
bg: 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)',
|
||||||
|
image: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800',
|
||||||
|
link: '/pages/activity/detail?id=act_101',
|
||||||
|
sortOrder: 1,
|
||||||
|
status: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '量贩KTV麦霸沉浸欢唱 | 音乐达人歌声试听',
|
||||||
|
subtitle: '工体纯K 888豪华包厢',
|
||||||
|
tag: 'K歌对唱',
|
||||||
|
badge: '同城陪唱',
|
||||||
|
badgeType: 'badge-ktv',
|
||||||
|
bg: 'linear-gradient(135deg, #7C3AED 0%, #6366F1 100%)',
|
||||||
|
image: 'https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?w=800',
|
||||||
|
link: '/pages/activity/detail?id=act_102',
|
||||||
|
sortOrder: 2,
|
||||||
|
status: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '精酿清吧微醺畅聊 | 线下陪吃陪喝立减30元',
|
||||||
|
subtitle: '望京跳海酒馆 · 慢调交友',
|
||||||
|
tag: '同城陪喝',
|
||||||
|
badge: '同城陪喝',
|
||||||
|
badgeType: 'badge-bar',
|
||||||
|
bg: 'linear-gradient(135deg, #059669 0%, #10B981 100%)',
|
||||||
|
image: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800',
|
||||||
|
link: '/pages/activity/detail?id=act_103',
|
||||||
|
sortOrder: 3,
|
||||||
|
status: 1
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
private nextId = 4;
|
||||||
|
private secService = new ContentSecurityService();
|
||||||
|
|
||||||
|
// 获取移动端展示的轮播图 (仅 status = 1,按 sortOrder 升序)
|
||||||
|
getActiveBanners(): BannerItem[] {
|
||||||
|
return this.banners
|
||||||
|
.filter((b) => b.status === 1)
|
||||||
|
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取后台管理的全部轮播图
|
||||||
|
getAllBanners(): BannerItem[] {
|
||||||
|
return [...this.banners].sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新轮播图
|
||||||
|
createBanner(payload: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
tag?: string;
|
||||||
|
badge?: string;
|
||||||
|
badgeType?: string;
|
||||||
|
bg?: string;
|
||||||
|
image?: string;
|
||||||
|
link?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
status?: number;
|
||||||
|
}): BannerItem {
|
||||||
|
if (!payload.title || !payload.title.trim()) {
|
||||||
|
throw new Error('轮播图标题不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信内容安全检测
|
||||||
|
const secResult = this.secService.checkText(payload.title + ' ' + (payload.subtitle || ''));
|
||||||
|
if (!secResult.pass) {
|
||||||
|
const err = new Error(secResult.message);
|
||||||
|
(err as any).code = secResult.code;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBanner: BannerItem = {
|
||||||
|
id: this.nextId++,
|
||||||
|
title: payload.title.trim(),
|
||||||
|
subtitle: payload.subtitle ? payload.subtitle.trim() : '',
|
||||||
|
tag: payload.tag || '热门活动',
|
||||||
|
badge: payload.badge || 'HOT 热门组局',
|
||||||
|
badgeType: payload.badgeType || 'badge-hot',
|
||||||
|
bg: payload.bg || 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)',
|
||||||
|
image: payload.image || '',
|
||||||
|
link: payload.link || '/pages/activity/list',
|
||||||
|
sortOrder: payload.sortOrder !== undefined ? Number(payload.sortOrder) : 0,
|
||||||
|
status: payload.status !== undefined ? Number(payload.status) : 1,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.banners.push(newBanner);
|
||||||
|
return newBanner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改轮播图
|
||||||
|
updateBanner(id: number, payload: Partial<Omit<BannerItem, 'id'>>): BannerItem {
|
||||||
|
const index = this.banners.findIndex((b) => b.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('轮播图不存在或已被删除');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.title) {
|
||||||
|
const secResult = this.secService.checkText(payload.title + ' ' + (payload.subtitle || ''));
|
||||||
|
if (!secResult.pass) {
|
||||||
|
const err = new Error(secResult.message);
|
||||||
|
(err as any).code = secResult.code;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.banners[index];
|
||||||
|
const updated: BannerItem = {
|
||||||
|
...current,
|
||||||
|
...payload,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
this.banners[index] = updated;
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快速上下线切换
|
||||||
|
toggleStatus(id: number, status: number): BannerItem {
|
||||||
|
if (status !== 0 && status !== 1) {
|
||||||
|
throw new Error('状态参数必须为 0 或 1');
|
||||||
|
}
|
||||||
|
return this.updateBanner(id, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除轮播图
|
||||||
|
deleteBanner(id: number): boolean {
|
||||||
|
const initialLength = this.banners.length;
|
||||||
|
this.banners = this.banners.filter((b) => b.id !== id);
|
||||||
|
if (this.banners.length === initialLength) {
|
||||||
|
throw new Error('轮播图不存在或已被删除');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -296,6 +296,27 @@ export async function initDatabase(): Promise<void> {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// 16. 首页轮播图运营配置表
|
||||||
|
await conn.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS banners (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
title VARCHAR(128) NOT NULL,
|
||||||
|
subtitle VARCHAR(255) DEFAULT '',
|
||||||
|
tag VARCHAR(64) DEFAULT '热门活动',
|
||||||
|
badge VARCHAR(64) DEFAULT 'HOT 热门组局',
|
||||||
|
badge_type VARCHAR(64) DEFAULT 'badge-hot',
|
||||||
|
bg VARCHAR(255) DEFAULT 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)',
|
||||||
|
image TEXT,
|
||||||
|
link VARCHAR(255) DEFAULT '/pages/activity/list',
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
status TINYINT DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_sort (sort_order),
|
||||||
|
INDEX idx_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
`);
|
||||||
|
|
||||||
// 初始化配置单条记录
|
// 初始化配置单条记录
|
||||||
const [cfgRows]: any = await conn.query('SELECT COUNT(*) as count FROM finance_config WHERE id = 1');
|
const [cfgRows]: any = await conn.query('SELECT COUNT(*) as count FROM finance_config WHERE id = 1');
|
||||||
if (cfgRows[0].count === 0) {
|
if (cfgRows[0].count === 0) {
|
||||||
@@ -492,4 +513,16 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
|
|||||||
('cmt_3', 'post_2', 2001, '涮肉小行家', 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', '国服李白太强了!主玩大乔和张飞,晚上可以一起开黑吗?', NOW() - INTERVAL 25 MINUTE);
|
('cmt_3', 'post_2', 2001, '涮肉小行家', 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', '国服李白太强了!主玩大乔和张飞,晚上可以一起开黑吗?', NOW() - INTERVAL 25 MINUTE);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 13. 种子轮播图
|
||||||
|
const [bnrRows]: any = await conn.query('SELECT COUNT(*) as count FROM banners');
|
||||||
|
if (bnrRows[0].count === 0) {
|
||||||
|
await conn.query(`
|
||||||
|
INSERT INTO banners (id, title, subtitle, tag, badge, badge_type, bg, image, link, sort_order, status)
|
||||||
|
VALUES
|
||||||
|
(1, '同城聚会狂欢 | 网红火锅探店与量贩KTV嗨唱', 'AA制铜锅涮肉 · 8人热烈局', '热门陪伴', 'HOT 热门组局', 'badge-hot', 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)', 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800', '/pages/activity/detail?id=act_101', 1, 1),
|
||||||
|
(2, '量贩KTV麦霸沉浸欢唱 | 音乐达人歌声试听', '工体纯K 888豪华包厢', 'K歌对唱', '同城陪唱', 'badge-ktv', 'linear-gradient(135deg, #7C3AED 0%, #6366F1 100%)', 'https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?w=800', '/pages/activity/detail?id=act_102', 2, 1),
|
||||||
|
(3, '精酿清吧微醺畅聊 | 线下陪吃陪喝立减30元', '望京跳海酒馆 · 慢调交友', '同城陪喝', '同城陪喝', 'badge-bar', 'linear-gradient(135deg, #059669 0%, #10B981 100%)', 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800', '/pages/activity/detail?id=act_103', 3, 1);
|
||||||
|
`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -697,41 +697,158 @@ export class MysqlRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getBanners(): Promise<any[]> {
|
async getBanners(): Promise<any[]> {
|
||||||
return [
|
const [rows]: any = await pool.query(`
|
||||||
{
|
SELECT
|
||||||
id: 1,
|
id, title, subtitle, tag, badge, badge_type as badgeType,
|
||||||
title: '同城聚会狂欢 | 网红火锅探店与量贩KTV嗨唱',
|
bg, image, link, sort_order as sortOrder, status,
|
||||||
subtitle: 'AA制铜锅涮肉 · 8人热烈局',
|
created_at as createdAt, updated_at as updatedAt
|
||||||
tag: '热门陪伴',
|
FROM banners
|
||||||
badge: 'HOT 热门组局',
|
WHERE status = 1
|
||||||
badgeType: 'badge-hot',
|
ORDER BY sort_order ASC, id DESC
|
||||||
bg: 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)',
|
`);
|
||||||
image: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800',
|
return rows;
|
||||||
link: '/pages/activity/detail?id=act_101'
|
}
|
||||||
},
|
|
||||||
{
|
async getAdminBanners(): Promise<any[]> {
|
||||||
id: 2,
|
const [rows]: any = await pool.query(`
|
||||||
title: '量贩KTV麦霸沉浸欢唱 | 音乐达人歌声试听',
|
SELECT
|
||||||
subtitle: '工体纯K 888豪华包厢',
|
id, title, subtitle, tag, badge, badge_type as badgeType,
|
||||||
tag: 'K歌对唱',
|
bg, image, link, sort_order as sortOrder, status,
|
||||||
badge: '同城陪唱',
|
created_at as createdAt, updated_at as updatedAt
|
||||||
badgeType: 'badge-ktv',
|
FROM banners
|
||||||
bg: 'linear-gradient(135deg, #7C3AED 0%, #6366F1 100%)',
|
ORDER BY sort_order ASC, id DESC
|
||||||
image: 'https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?w=800',
|
`);
|
||||||
link: '/pages/activity/detail?id=act_102'
|
return rows;
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: 3,
|
async createBanner(data: {
|
||||||
title: '精酿清吧微醺畅聊 | 线下陪吃陪喝立减30元',
|
title: string;
|
||||||
subtitle: '望京跳海酒馆 · 慢调交友',
|
subtitle?: string;
|
||||||
tag: '同城陪喝',
|
tag?: string;
|
||||||
badge: '同城陪喝',
|
badge?: string;
|
||||||
badgeType: 'badge-bar',
|
badgeType?: string;
|
||||||
bg: 'linear-gradient(135deg, #059669 0%, #10B981 100%)',
|
bg?: string;
|
||||||
image: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800',
|
image?: string;
|
||||||
link: '/pages/activity/detail?id=act_103'
|
link?: string;
|
||||||
}
|
sortOrder?: number;
|
||||||
];
|
status?: number;
|
||||||
|
}): Promise<any> {
|
||||||
|
const title = data.title;
|
||||||
|
const subtitle = data.subtitle || '';
|
||||||
|
const tag = data.tag || '热门活动';
|
||||||
|
const badge = data.badge || 'HOT 热门组局';
|
||||||
|
const badgeType = data.badgeType || 'badge-hot';
|
||||||
|
const bg = data.bg || 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)';
|
||||||
|
const image = data.image || '';
|
||||||
|
const link = data.link || '/pages/activity/list';
|
||||||
|
const sortOrder = Number(data.sortOrder) || 0;
|
||||||
|
const status = data.status !== undefined ? Number(data.status) : 1;
|
||||||
|
|
||||||
|
const [res]: any = await pool.query(`
|
||||||
|
INSERT INTO banners (title, subtitle, tag, badge, badge_type, bg, image, link, sort_order, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, [title, subtitle, tag, badge, badgeType, bg, image, link, sortOrder, status]);
|
||||||
|
|
||||||
|
const newId = res.insertId;
|
||||||
|
const [rows]: any = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
id, title, subtitle, tag, badge, badge_type as badgeType,
|
||||||
|
bg, image, link, sort_order as sortOrder, status,
|
||||||
|
created_at as createdAt, updated_at as updatedAt
|
||||||
|
FROM banners
|
||||||
|
WHERE id = ?
|
||||||
|
`, [newId]);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBanner(id: number | string, data: Partial<{
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
tag: string;
|
||||||
|
badge: string;
|
||||||
|
badgeType: string;
|
||||||
|
bg: string;
|
||||||
|
image: string;
|
||||||
|
link: string;
|
||||||
|
sortOrder: number;
|
||||||
|
status: number;
|
||||||
|
}>): Promise<any> {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
if (data.title !== undefined) {
|
||||||
|
fields.push('title = ?');
|
||||||
|
values.push(data.title);
|
||||||
|
}
|
||||||
|
if (data.subtitle !== undefined) {
|
||||||
|
fields.push('subtitle = ?');
|
||||||
|
values.push(data.subtitle);
|
||||||
|
}
|
||||||
|
if (data.tag !== undefined) {
|
||||||
|
fields.push('tag = ?');
|
||||||
|
values.push(data.tag);
|
||||||
|
}
|
||||||
|
if (data.badge !== undefined) {
|
||||||
|
fields.push('badge = ?');
|
||||||
|
values.push(data.badge);
|
||||||
|
}
|
||||||
|
if (data.badgeType !== undefined) {
|
||||||
|
fields.push('badge_type = ?');
|
||||||
|
values.push(data.badgeType);
|
||||||
|
}
|
||||||
|
if (data.bg !== undefined) {
|
||||||
|
fields.push('bg = ?');
|
||||||
|
values.push(data.bg);
|
||||||
|
}
|
||||||
|
if (data.image !== undefined) {
|
||||||
|
fields.push('image = ?');
|
||||||
|
values.push(data.image);
|
||||||
|
}
|
||||||
|
if (data.link !== undefined) {
|
||||||
|
fields.push('link = ?');
|
||||||
|
values.push(data.link);
|
||||||
|
}
|
||||||
|
if (data.sortOrder !== undefined) {
|
||||||
|
fields.push('sort_order = ?');
|
||||||
|
values.push(Number(data.sortOrder));
|
||||||
|
}
|
||||||
|
if (data.status !== undefined) {
|
||||||
|
fields.push('status = ?');
|
||||||
|
values.push(Number(data.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length > 0) {
|
||||||
|
values.push(id);
|
||||||
|
await pool.query(`UPDATE banners SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows]: any = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
id, title, subtitle, tag, badge, badge_type as badgeType,
|
||||||
|
bg, image, link, sort_order as sortOrder, status,
|
||||||
|
created_at as createdAt, updated_at as updatedAt
|
||||||
|
FROM banners
|
||||||
|
WHERE id = ?
|
||||||
|
`, [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBannerStatus(id: number | string, status: number): Promise<any> {
|
||||||
|
await pool.query('UPDATE banners SET status = ? WHERE id = ?', [Number(status), id]);
|
||||||
|
const [rows]: any = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
id, title, subtitle, tag, badge, badge_type as badgeType,
|
||||||
|
bg, image, link, sort_order as sortOrder, status,
|
||||||
|
created_at as createdAt, updated_at as updatedAt
|
||||||
|
FROM banners
|
||||||
|
WHERE id = ?
|
||||||
|
`, [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBanner(id: number | string): Promise<boolean> {
|
||||||
|
const [res]: any = await pool.query('DELETE FROM banners WHERE id = ?', [id]);
|
||||||
|
return res.affectedRows > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -615,7 +615,7 @@ app.get('/api/expert/stats/:id', async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// 首页运营 Banner 列表 (配置化接口)
|
// 首页运营 Banner 列表 (小程序端读取启用状态)
|
||||||
app.get('/api/banners', async (_req: Request, res: Response) => {
|
app.get('/api/banners', async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const banners = await repo.getBanners();
|
const banners = await repo.getBanners();
|
||||||
@@ -625,6 +625,120 @@ app.get('/api/banners', async (_req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 管理后台: 获取全部 Banner 列表 (含停用状态)
|
||||||
|
app.get('/api/admin/banners', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const banners = await repo.getAdminBanners();
|
||||||
|
res.json({ code: 20000, data: banners });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ code: 50000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 管理后台: 新增 Banner
|
||||||
|
app.post('/api/admin/banners', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { title, subtitle, tag, badge, badgeType, bg, image, link, sortOrder, status } = req.body;
|
||||||
|
if (!title || !title.trim()) {
|
||||||
|
return res.status(400).json({ code: 40000, message: 'Banner 标题不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信内容安全检测
|
||||||
|
const secCheck = contentSecurityService.checkText(title + ' ' + (subtitle || ''));
|
||||||
|
if (!secCheck.pass) {
|
||||||
|
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner = await repo.createBanner({
|
||||||
|
title: title.trim(),
|
||||||
|
subtitle: subtitle ? subtitle.trim() : '',
|
||||||
|
tag,
|
||||||
|
badge,
|
||||||
|
badgeType,
|
||||||
|
bg,
|
||||||
|
image,
|
||||||
|
link,
|
||||||
|
sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0,
|
||||||
|
status: status !== undefined ? Number(status) : 1
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ code: 20000, data: banner, message: '轮播图创建成功' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ code: 40000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 管理后台: 修改 Banner
|
||||||
|
app.put('/api/admin/banners/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const bannerId = req.params.id;
|
||||||
|
const { title, subtitle, tag, badge, badgeType, bg, image, link, sortOrder, status } = req.body;
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
const secCheck = contentSecurityService.checkText(title + ' ' + (subtitle || ''));
|
||||||
|
if (!secCheck.pass) {
|
||||||
|
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await repo.updateBanner(bannerId, {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
tag,
|
||||||
|
badge,
|
||||||
|
badgeType,
|
||||||
|
bg,
|
||||||
|
image,
|
||||||
|
link,
|
||||||
|
sortOrder: sortOrder !== undefined ? Number(sortOrder) : undefined,
|
||||||
|
status: status !== undefined ? Number(status) : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '轮播图不存在或已被删除' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ code: 20000, data: updated, message: '轮播图修改成功' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ code: 40000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 管理后台: 切换 Banner 上架/下线状态
|
||||||
|
app.post('/api/admin/banners/:id/status', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const bannerId = req.params.id;
|
||||||
|
const { status } = req.body;
|
||||||
|
if (status === undefined || (status !== 0 && status !== 1)) {
|
||||||
|
return res.status(400).json({ code: 40000, message: '状态参数必须为 0 或 1' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await repo.updateBannerStatus(bannerId, Number(status));
|
||||||
|
if (!updated) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '轮播图不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ code: 20000, data: updated, message: status === 1 ? '轮播图已上架生效' : '轮播图已下线隐藏' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ code: 40000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 管理后台: 删除 Banner
|
||||||
|
app.delete('/api/admin/banners/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const bannerId = req.params.id;
|
||||||
|
const ok = await repo.deleteBanner(bannerId);
|
||||||
|
if (!ok) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '轮播图不存在或已被删除' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ code: 20000, data: null, message: '轮播图已删除' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ code: 40000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 创建新订单 (落盘 MySQL)
|
// 创建新订单 (落盘 MySQL)
|
||||||
app.post('/api/orders', async (req: Request, res: Response) => {
|
app.post('/api/orders', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
176
server/tests/banner-manage.spec.ts
普通文件
176
server/tests/banner-manage.spec.ts
普通文件
@@ -0,0 +1,176 @@
|
|||||||
|
// 已读取AGENTS.md
|
||||||
|
import { BannerService } from '../src/core/banner/banner.service';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
describe('Banner Management System (TDD 轮播图后台运维与小程序动态联动套件)', () => {
|
||||||
|
let bannerService: BannerService;
|
||||||
|
const rootDir = path.resolve(__dirname, '../../');
|
||||||
|
const serverIndexPath = path.join(rootDir, 'server/src/index.ts');
|
||||||
|
const mysqlPath = path.join(rootDir, 'server/src/db/mysql.ts');
|
||||||
|
const repoPath = path.join(rootDir, 'server/src/db/repository.ts');
|
||||||
|
const mobileIndexPath = path.join(rootDir, 'pwuniapp/pages/index/index.vue');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bannerService = new BannerService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('1. 核心业务服务 BannerService 单测', () => {
|
||||||
|
it('默认初始加载 3 条上架状态的种子轮播图,且按 sortOrder 正序排序', () => {
|
||||||
|
const activeBanners = bannerService.getActiveBanners();
|
||||||
|
expect(activeBanners.length).toBe(3);
|
||||||
|
expect(activeBanners[0].id).toBe(1);
|
||||||
|
expect(activeBanners[0].sortOrder).toBe(1);
|
||||||
|
expect(activeBanners[1].id).toBe(2);
|
||||||
|
expect(activeBanners[2].id).toBe(3);
|
||||||
|
expect(activeBanners.every((b) => b.status === 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成功创建新运营轮播图并支持排序与分类标签', () => {
|
||||||
|
const created = bannerService.createBanner({
|
||||||
|
title: '万圣节南瓜派对狂欢夜',
|
||||||
|
subtitle: '全场酒水半价 · 专属大神带队',
|
||||||
|
tag: '节日特惠',
|
||||||
|
badge: '节日爆款',
|
||||||
|
badgeType: 'badge-hot',
|
||||||
|
bg: 'linear-gradient(135deg, #FF7D00 0%, #F53F3F 100%)',
|
||||||
|
image: 'https://images.unsplash.com/photo-1509198397868-475647b2a1e5?w=800',
|
||||||
|
link: '/pages/activity/list?category=PARTY',
|
||||||
|
sortOrder: 0, // 置顶
|
||||||
|
status: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.id).toBe(4);
|
||||||
|
expect(created.title).toBe('万圣节南瓜派对狂欢夜');
|
||||||
|
expect(created.sortOrder).toBe(0);
|
||||||
|
|
||||||
|
// 验证置顶生效:getActiveBanners 第一项即为新轮播图
|
||||||
|
const activeList = bannerService.getActiveBanners();
|
||||||
|
expect(activeList.length).toBe(4);
|
||||||
|
expect(activeList[0].id).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('创建或编辑轮播图时,空标题应被拒绝拦截', () => {
|
||||||
|
expect(() => {
|
||||||
|
bannerService.createBanner({
|
||||||
|
title: ' ',
|
||||||
|
subtitle: '空标题测试'
|
||||||
|
});
|
||||||
|
}).toThrow('轮播图标题不能为空');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('创建或编辑轮播图时,涉嫌违规敏感词应触发微信内容安全拦截', () => {
|
||||||
|
expect(() => {
|
||||||
|
bannerService.createBanner({
|
||||||
|
title: '私下转账免手续费活动返利',
|
||||||
|
subtitle: '欺诈引流测试'
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
bannerService.createBanner({
|
||||||
|
title: '美女上门特殊服务招嫖',
|
||||||
|
subtitle: '违规低俗'
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('支持快速切换上下线状态并在移动端实时动态显隐', () => {
|
||||||
|
// 下线 ID 为 1 的轮播图
|
||||||
|
const offlineItem = bannerService.toggleStatus(1, 0);
|
||||||
|
expect(offlineItem.status).toBe(0);
|
||||||
|
|
||||||
|
// 移动端列表中已不再包含 ID 为 1
|
||||||
|
const activeList = bannerService.getActiveBanners();
|
||||||
|
expect(activeList.some((b) => b.id === 1)).toBe(false);
|
||||||
|
|
||||||
|
// 后台全量列表中仍保留
|
||||||
|
const allList = bannerService.getAllBanners();
|
||||||
|
expect(allList.some((b) => b.id === 1)).toBe(true);
|
||||||
|
|
||||||
|
// 重新上线
|
||||||
|
const onlineItem = bannerService.toggleStatus(1, 1);
|
||||||
|
expect(onlineItem.status).toBe(1);
|
||||||
|
expect(bannerService.getActiveBanners().some((b) => b.id === 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非法状态值切换应被拦截', () => {
|
||||||
|
expect(() => {
|
||||||
|
bannerService.toggleStatus(1, 99 as any);
|
||||||
|
}).toThrow('状态参数必须为 0 或 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('支持修改轮播图配置与排序', () => {
|
||||||
|
const updated = bannerService.updateBanner(2, {
|
||||||
|
title: '工体纯KTV超人气嗨唱局',
|
||||||
|
subtitle: '豪华大包 · 专属金牌麦霸',
|
||||||
|
sortOrder: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated.title).toBe('工体纯KTV超人气嗨唱局');
|
||||||
|
expect(updated.sortOrder).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('支持删除轮播图,删除不存在项时抛出异常', () => {
|
||||||
|
const ok = bannerService.deleteBanner(3);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(bannerService.getAllBanners().some((b) => b.id === 3)).toBe(false);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
bannerService.deleteBanner(999);
|
||||||
|
}).toThrow('轮播图不存在或已被删除');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('2. 服务端数据库与 API 架构持久化门禁校验', () => {
|
||||||
|
it('MySQL 数据库 schema 必须包含 banners 表定义与默认种子数据', () => {
|
||||||
|
const mysqlContent = fs.readFileSync(mysqlPath, 'utf-8');
|
||||||
|
expect(mysqlContent).toContain('CREATE TABLE IF NOT EXISTS banners');
|
||||||
|
expect(mysqlContent).toContain('sort_order INT DEFAULT 0');
|
||||||
|
expect(mysqlContent).toContain('status TINYINT DEFAULT 1');
|
||||||
|
expect(mysqlContent).toContain('INSERT INTO banners');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Repository 数据仓储层必须实现完整的 Banner CRUD 接口', () => {
|
||||||
|
const repoContent = fs.readFileSync(repoPath, 'utf-8');
|
||||||
|
expect(repoContent).toContain('async getBanners(): Promise<any[]>');
|
||||||
|
expect(repoContent).toContain('async getAdminBanners(): Promise<any[]>');
|
||||||
|
expect(repoContent).toContain('async createBanner(');
|
||||||
|
expect(repoContent).toContain('async updateBanner(');
|
||||||
|
expect(repoContent).toContain('async updateBannerStatus(');
|
||||||
|
expect(repoContent).toContain('async deleteBanner(');
|
||||||
|
// 必须带状态过滤与排序
|
||||||
|
expect(repoContent).toContain('WHERE status = 1');
|
||||||
|
expect(repoContent).toContain('ORDER BY sort_order ASC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('server/src/index.ts 必须注册完整的 Banner 运维 API 端点并配置微信内容安全检查', () => {
|
||||||
|
const indexContent = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||||
|
expect(indexContent).toContain("app.get('/api/banners'");
|
||||||
|
expect(indexContent).toContain("app.get('/api/admin/banners'");
|
||||||
|
expect(indexContent).toContain("app.post('/api/admin/banners'");
|
||||||
|
expect(indexContent).toContain("app.put('/api/admin/banners/:id'");
|
||||||
|
expect(indexContent).toContain("app.post('/api/admin/banners/:id/status'");
|
||||||
|
expect(indexContent).toContain("app.delete('/api/admin/banners/:id'");
|
||||||
|
expect(indexContent).toContain('contentSecurityService.checkText(');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('小程序前端 index.vue 必须通过 getBanners 动态接入服务端轮播图数据', () => {
|
||||||
|
const mobileContent = fs.readFileSync(mobileIndexPath, 'utf-8');
|
||||||
|
expect(mobileContent).toContain('v-for="banner in banners"');
|
||||||
|
expect(mobileContent).toContain('fetchLiveBanners()');
|
||||||
|
expect(mobileContent).toContain('getBanners()');
|
||||||
|
expect(mobileContent).toContain('goToActivity(banner)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('3. 规范门禁 (AGENTS.md & 零 Emoji 规则)', () => {
|
||||||
|
it('服务文件与测试套件必须严格满足零系统 Emoji 规范', () => {
|
||||||
|
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
|
||||||
|
const serviceContent = fs.readFileSync(path.join(rootDir, 'server/src/core/banner/banner.service.ts'), 'utf-8');
|
||||||
|
const testContent = fs.readFileSync(__filename, 'utf-8');
|
||||||
|
expect(emojiRegex.test(serviceContent)).toBe(false);
|
||||||
|
expect(emojiRegex.test(testContent)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户