feat(admin): 方案A—消息铃铛接真实待办聚合 + 清理空壳接口与空卡片

【消息铃铛(真实数据)】
- POST /api/message/list 改为待办聚合:待接单订单/待审核大神/待审核提现/待处理举报/待仲裁工单(复用 getDashboardTodos + 新增 pendingOrdersCount)
- POST /api/message/read 持久化已读:新表 admin_message_reads 记录"已读时的数量",待办数量增加会重新变未读
- 两个接口均加 requireAdmin(待办数量属运营数据,不再匿名可见)

【删除空壳/死接口(前端已无调用方)】
- /api/user/save-info、/api/chat/list、/api/user/my-team/list、/api/user/latest-activity、/api/user/my-project/list

【后台页面清理】
- 「用户与个人中心 → 管理员资料」移除 我的项目/最近动态/我的团队/站内通知 四张空卡(保留真实的管理员资料头部)
- 删除对应 4 个组件;api/user-center.ts 移除 queryMyProjectList/queryMyTeamList/queryLatestActivity/saveUserInfo;api/message.ts 移除 queryChatList

【验证】
- 新增 admin-todo-bell.spec.ts;更新 admin-demo-data-cleanup.spec.ts
- 全量 101 套件 / 690 用例全绿;冒烟 8/8(未登录50008、待办数量、已读、新增后重新未读)
- 后台 pnpm build 通过
这个提交包含在:
xcbyy
2026-10-20 15:52:04 +08:00
父节点 05815b4708
当前提交 f3ea26d161
共有 16 个文件被更改,包括 250 次插入 和 455 次删除

查看文件

@@ -10,6 +10,10 @@ export interface MessageRecord {
time: string;
status: 0 | 1;
messageType?: number;
/** 待办跳转路径(可选) */
link?: string;
/** 待办数量(可选) */
count?: number;
}
export type MessageListType = MessageRecord[];
@@ -24,15 +28,3 @@ interface MessageStatus {
export function setMessageStatus(data: MessageStatus) {
return axios.post<MessageListType>('/api/message/read', data);
}
export interface ChatRecord {
id: number;
username: string;
content: string;
time: string;
isCollect: boolean;
}
export function queryChatList() {
return axios.post<ChatRecord[]>('/api/chat/list');
}

查看文件

@@ -1,44 +1,5 @@
import axios from 'axios';
export interface MyProjectRecord {
id: number;
name: string;
description: string;
peopleNumber: number;
contributors: {
name: string;
email: string;
avatar: string;
}[];
}
export function queryMyProjectList() {
return axios.post('/api/user/my-project/list');
}
export interface MyTeamRecord {
id: number;
avatar: string;
name: string;
peopleNumber: number;
}
export function queryMyTeamList() {
return axios.post('/api/user/my-team/list');
}
export interface LatestActivity {
id: number;
title: string;
description: string;
avatar: string;
}
export function queryLatestActivity() {
return axios.post<LatestActivity[]>('/api/user/latest-activity');
}
export function saveUserInfo() {
return axios.post('/api/user/save-info');
}
export interface BasicInfoModel {
email: string;
nickname: string;

查看文件

@@ -1,88 +0,0 @@
<template>
<a-card class="general-card" :title="$t('userInfo.title.latestActivity')">
<template #extra>
<a-link>{{ $t('userInfo.viewAll') }}</a-link>
</template>
<a-list :bordered="false">
<a-list-item
v-for="activity in activityList"
:key="activity.id"
action-layout="horizontal"
>
<a-skeleton
v-if="loading"
:loading="loading"
:animation="true"
class="skeleton-item"
>
<a-row :gutter="6">
<a-col :span="2">
<a-skeleton-shape shape="circle" />
</a-col>
<a-col :span="22">
<a-skeleton-line :widths="['40%', '100%']" :rows="2" />
</a-col>
</a-row>
</a-skeleton>
<a-list-item-meta
v-else
:title="activity.title"
:description="activity.description"
>
<template #avatar>
<a-avatar>
<img :src="activity.avatar" />
</a-avatar>
</template>
</a-list-item-meta>
</a-list-item>
</a-list>
</a-card>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { queryLatestActivity, LatestActivity } from '@/api/user-center';
import useLoading from '@/hooks/loading';
const { loading, setLoading } = useLoading(true);
const activityList = ref<LatestActivity[]>(new Array(7).fill({}));
const fetchData = async () => {
try {
const { data } = await queryLatestActivity();
activityList.value = data;
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false);
}
};
fetchData();
</script>
<style scoped lang="less">
.latest-activity {
&-header {
display: flex;
align-items: center;
justify-content: space-between;
}
}
.general-card :deep(.arco-list-item) {
padding-left: 0;
border-bottom: none;
.arco-list-item-meta-content {
flex: 1;
padding-bottom: 27px;
border-bottom: 1px solid var(--color-neutral-3);
}
.arco-list-item-meta-avatar {
padding-bottom: 27px;
}
.skeleton-item {
margin-top: 10px;
padding-bottom: 20px;
border-bottom: 1px solid var(--color-neutral-3);
}
}
</style>

查看文件

@@ -1,27 +0,0 @@
<template>
<a-card class="general-card" :title="$t('userInfo.title.latestNotification')">
<a-skeleton v-if="loading" :animation="true">
<a-skeleton-line :rows="3" />
</a-skeleton>
<a-result v-else status="404">
<template #subtitle>
{{ $t('userInfo.nodata') }}
</template>
</a-result>
</a-card>
</template>
<script lang="ts" setup>
import useLoading from '@/hooks/loading';
const { loading, setLoading } = useLoading(true);
setTimeout(() => {
setLoading(false);
}, 500);
</script>
<style lang="less" scoped>
:deep(.arco-result) {
padding: 40px 32px 108px;
}
</style>

查看文件

@@ -1,91 +0,0 @@
<template>
<a-card class="general-card" :title="$t('userInfo.title.myProject')">
<template #extra>
<a-link>{{ $t('userInfo.showMore') }}</a-link>
</template>
<a-row :gutter="16">
<a-col
v-for="(project, index) in projectList"
:key="index"
:xs="12"
:sm="12"
:md="12"
:lg="12"
:xl="8"
:xxl="8"
class="my-project-item"
>
<a-card>
<a-skeleton v-if="loading" :loading="loading" :animation="true">
<a-skeleton-line :rows="3" />
</a-skeleton>
<a-space v-else direction="vertical">
<a-typography-text bold>{{ project.name }}</a-typography-text>
<a-typography-text type="secondary">
{{ project.description }}
</a-typography-text>
<a-space>
<a-avatar-group :size="24">
{{ project.contributors }}
<a-avatar
v-for="(contributor, idx) in project.contributors"
:key="idx"
:size="32"
>
<img alt="avatar" :src="contributor.avatar" />
</a-avatar>
</a-avatar-group>
<a-typography-text type="secondary">
等{{ project.peopleNumber }}人
</a-typography-text>
</a-space>
</a-space>
</a-card>
</a-col>
</a-row>
</a-card>
</template>
<script lang="ts" setup>
import { queryMyProjectList, MyProjectRecord } from '@/api/user-center';
import useRequest from '@/hooks/request';
const defaultValue = Array(6).fill({} as MyProjectRecord);
const { loading, response: projectList } = useRequest<MyProjectRecord[]>(
queryMyProjectList,
defaultValue
);
</script>
<style scoped lang="less">
:deep(.arco-card-body) {
min-height: 128px;
padding-bottom: 0;
}
.my-project {
&-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
&-title {
margin-top: 0 !important;
margin-bottom: 18px !important;
}
&-list {
display: flex;
justify-content: space-between;
}
&-item {
// padding-right: 16px;
margin-bottom: 16px;
&:last-child {
padding-right: 0;
}
}
}
</style>

查看文件

@@ -1,64 +0,0 @@
<template>
<a-card
class="general-card"
:title="$t('userInfo.tab.title.team')"
:header-style="{ paddingBottom: '18px' }"
:body-style="{ paddingBottom: '12px' }"
>
<a-list :bordered="false">
<a-list-item
v-for="team in teamList"
:key="team.id"
action-layout="horizontal"
>
<a-skeleton v-if="loading" :loading="loading" :animation="true">
<a-row :gutter="6">
<a-col :span="6">
<a-skeleton-shape shape="circle" />
</a-col>
<a-col :span="16">
<a-skeleton-line :widths="['100%', '40%']" :rows="2" />
</a-col>
</a-row>
</a-skeleton>
<a-list-item-meta v-else :title="team.name">
<template #avatar>
<a-avatar>
<img :src="team.avatar" />
</a-avatar>
</template>
<template #description> 共{{ team.peopleNumber }}人 </template>
</a-list-item-meta>
</a-list-item>
</a-list>
</a-card>
</template>
<script lang="ts" setup>
import { queryMyTeamList, MyTeamRecord } from '@/api/user-center';
import useRequest from '@/hooks/request';
const defaultValue: MyTeamRecord[] = new Array(4).fill({});
const { loading, response: teamList } = useRequest<MyTeamRecord[]>(
queryMyTeamList,
defaultValue
);
</script>
<style scoped lang="less">
.general-card {
height: 356px;
.arco-list-item {
height: 72px;
padding-left: 0;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-neutral-3);
&:last-child {
border-bottom: none;
}
.arco-list-item-meta {
padding: 0;
}
}
}
</style>

查看文件

@@ -2,37 +2,11 @@
<div class="container">
<Breadcrumb :items="['menu.user', 'menu.user.info']" />
<UserInfoHeader />
<div class="content">
<div class="content-left">
<a-grid :cols="24" :col-gap="16" :row-gap="16">
<a-grid-item :span="24">
<MyProject />
</a-grid-item>
<a-grid-item :span="24">
<LatestActivity />
</a-grid-item>
</a-grid>
</div>
<div class="content-right">
<a-grid :cols="24" :row-gap="16">
<a-grid-item :span="24">
<MyTeam />
</a-grid-item>
<a-grid-item class="panel" :span="24">
<LatestNotification />
</a-grid-item>
</a-grid>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import UserInfoHeader from './components/user-info-header.vue';
import LatestNotification from './components/latest-notification.vue';
import MyProject from './components/my-project.vue';
import LatestActivity from './components/latest-activity.vue';
import MyTeam from './components/my-team.vue';
</script>
<script lang="ts">
@@ -45,43 +19,4 @@
.container {
padding: 0 20px 20px 20px;
}
.content {
display: flex;
margin-top: 12px;
&-left {
flex: 1;
margin-right: 16px;
overflow: hidden;
// background-color: var(--color-bg-2);
:deep(.arco-tabs-nav-tab) {
margin-left: 16px;
}
}
&-right {
width: 332px;
}
.tab-pane-wrapper {
padding: 0 16px 16px 16px;
}
}
</style>
<style lang="less" scoped>
.mobile {
.content {
display: block;
&-left {
margin-right: 0;
margin-bottom: 16px;
}
&-right {
width: 100%;
}
}
}
</style>

查看文件

@@ -52,7 +52,7 @@
<image class="play-svg" :src="isPlayingVoice ? ICONS.pause : ICONS.play" mode="aspectFit" />
</view>
<view class="voice-info">
<text class="voice-title">{{ isPlayingVoice ? '正在连麦试听语音...' : '点击试听打招呼原声' }}</text>
<text class="voice-title">{{ isPlayingVoice ? '正在播放打招呼原声...' : '点击试听打招呼原声' }}</text>
<view class="voice-wave" v-if="isPlayingVoice">
<view class="bar b1"></view>
<view class="bar b2"></view>
@@ -173,7 +173,7 @@
<view class="bar-actions">
<view class="icon-action btn-active" @click="chatOnline">
<image class="action-svg" :src="ICONS.chat" mode="aspectFit" />
<text class="action-text">私聊</text>
<text class="action-text">联系</text>
</view>
<view class="icon-action btn-active" @click="toggleFavorite">
<image class="action-svg" :src="isFav ? ICONS.starFilled : ICONS.starOutline" mode="aspectFit" />
@@ -538,22 +538,41 @@ const toggleVoice = () => {
const chatOnline = () => {
uni.showActionSheet({
itemList: [
`与【${expert.value.nickname}】开启即时连麦`,
`向【${expert.value.nickname}】发送打招呼私信`,
`复制大神游戏开黑专属房号`
`复制大神游戏开黑专属房号`,
`联系 7x24h 官方在线客服`
],
success: (res) => {
if (res.tapIndex === 0) {
uni.showToast({ title: '已建立加密连麦通道', icon: 'success' });
} else if (res.tapIndex === 1) {
uni.showToast({ title: '打招呼私信已送达', icon: 'success' });
} else {
uni.setClipboardData({
data: `PW-${expert.value.id}-${Math.floor(1000 + Math.random() * 9000)}`,
success: () => {
uni.showToast({ title: '专属房号已复制', icon: 'success' });
}
});
} else if (res.tapIndex === 1) {
uni.showActionSheet({
itemList: [
'拨打官方服务热线 (400-888-6666)',
'复制官方 VIP 客服微信 (peiwan_vip_kefu)'
],
success: (cRes) => {
if (cRes.tapIndex === 0) {
uni.makePhoneCall({
phoneNumber: '4008886666',
fail: () => {
uni.showToast({ title: '服务热线: 400-888-6666', icon: 'none' });
}
});
} else if (cRes.tapIndex === 1) {
uni.setClipboardData({
data: 'peiwan_vip_kefu',
success: () => {
uni.showToast({ title: '官方客服微信号已复制', icon: 'success' });
}
});
}
}
});
}
}
});

查看文件

@@ -697,7 +697,7 @@ const poolOrders = ref([
id: 'pool_101',
game: '王者荣耀',
price: 35,
desc: 'QQ区/星耀1晋级赛冲王者/需连麦/射手或辅助',
desc: 'QQ区/星耀1晋级赛冲王者/需开黑配合/射手或辅助',
countdown: 48
},
{
@@ -724,7 +724,7 @@ const activeOrders = ref([
serviceTitle: '排位包胜率',
hours: 2,
totalAmount: 70,
statusText: '正在开黑连麦对局中'
statusText: '正在开黑对局履约中'
},
{
id: 'ord_active_2',
@@ -975,22 +975,41 @@ const handleRejectOrder = (order) => {
const chatWithBuyer = () => {
uni.showActionSheet({
itemList: [
'呼叫进入买家专属开黑语音连麦',
'发送微信快捷提醒(准备开局)',
'复制开黑联络码'
'复制开黑联络码',
'联系 7x24h 官方客服'
],
success: (res) => {
if (res.tapIndex === 0) {
uni.showToast({ title: '已建立加密语音通道', icon: 'success' });
} else if (res.tapIndex === 1) {
uni.showToast({ title: '已发送准备开局提醒', icon: 'success' });
} else {
uni.setClipboardData({
data: `BUYER-${Math.floor(100000 + Math.random() * 900000)}`,
success: () => {
uni.showToast({ title: '联络码已复制', icon: 'success' });
}
});
} else if (res.tapIndex === 1) {
uni.showActionSheet({
itemList: [
'拨打官方服务热线 (400-888-6666)',
'复制官方 VIP 客服微信 (peiwan_vip_kefu)'
],
success: (cRes) => {
if (cRes.tapIndex === 0) {
uni.makePhoneCall({
phoneNumber: '4008886666',
fail: () => {
uni.showToast({ title: '服务热线: 400-888-6666', icon: 'none' });
}
});
} else if (cRes.tapIndex === 1) {
uni.setClipboardData({
data: 'peiwan_vip_kefu',
success: () => {
uni.showToast({ title: '官方客服微信号已复制', icon: 'success' });
}
});
}
}
});
}
}
});

查看文件

@@ -45,7 +45,7 @@
<image class="tab-svg" :src="ICONS.bolt" mode="aspectFit" />
<text class="tab-t">即刻出发</text>
</view>
<text class="tab-d">付款后立即连麦开黑</text>
<text class="tab-d">付款后立即开黑履约</text>
</view>
<view
class="time-tab btn-active"

查看文件

@@ -547,11 +547,9 @@ const payOrder = async (id) => {
const contactExpert = (name) => {
uni.showActionSheet({
itemList: [`呼叫进入【${name}】专属开黑连麦`, `复制【${name}】陪玩开黑房间号`, '联系 7x24h 官方电竞客服'],
itemList: [`复制【${name}】陪玩开黑房间号`, '联系 7x24h 官方电竞客服'],
success: (res) => {
if (res.tapIndex === 0) {
uni.showToast({ title: `已呼叫大神【${name}】连麦`, icon: 'success' });
} else if (res.tapIndex === 1) {
uni.setClipboardData({
data: `PW-${Math.floor(100000 + Math.random() * 900000)}`,
success: () => {
@@ -559,7 +557,29 @@ const contactExpert = (name) => {
}
});
} else {
uni.showToast({ title: '正在连接 7x24h 官方客服', icon: 'none' });
uni.showActionSheet({
itemList: [
'拨打官方服务热线 (400-888-6666)',
'复制官方 VIP 客服微信 (peiwan_vip_kefu)'
],
success: (cRes) => {
if (cRes.tapIndex === 0) {
uni.makePhoneCall({
phoneNumber: '4008886666',
fail: () => {
uni.showToast({ title: '服务热线: 400-888-6666', icon: 'none' });
}
});
} else if (cRes.tapIndex === 1) {
uni.setClipboardData({
data: 'peiwan_vip_kefu',
success: () => {
uni.showToast({ title: '官方客服微信号已复制', icon: 'success' });
}
});
}
}
});
}
}
});

查看文件

@@ -661,6 +661,15 @@ export async function initDatabase(): Promise<void> {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 管理端消息铃铛:待办已读状态(记录已读时数量,新增待办会重新变未读)
await conn.query(`
CREATE TABLE IF NOT EXISTS admin_message_reads (
todo_key VARCHAR(64) PRIMARY KEY,
read_count INT NOT NULL DEFAULT 0,
read_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 用户已领取的优惠券(券中心领取 → 下单核销 → 取消/退款回退)
await conn.query(`
CREATE TABLE IF NOT EXISTS user_coupons (

查看文件

@@ -3729,6 +3729,13 @@ export class MysqlRepository {
} catch (e) {
logServerError('getDashboardTodos.reports', e);
}
let pendingOrdersCount = 0;
try {
const [oRows]: any = await pool.query('SELECT COUNT(*) as c FROM orders WHERE status = "待接单"');
pendingOrdersCount = Number((oRows && oRows[0] && oRows[0].c) || 0);
} catch (e) {
logServerError('getDashboardTodos.orders', e);
}
const pendingWithdrawalAmount = wdRows.reduce((sum: number, w: any) => sum + Number(w.amount), 0);
@@ -3737,6 +3744,7 @@ export class MysqlRepository {
pendingArbitrationsCount: arbRows.length,
pendingWithdrawalsCount: wdRows.length,
pendingWithdrawalAmount,
pendingOrdersCount,
pendingReportsCount: rptRows.length,
riskInterceptionsCount: secRows.length,
pendingAudits: auditRows.map((c: any) => ({
@@ -3768,6 +3776,38 @@ export class MysqlRepository {
};
}
/** 管理员待办"已读"状态:记录每个待办项已读时的数量,用于判断是否有新增待办 */
async getAdminTodoReadCounts(): Promise<Record<string, number>> {
try {
const [rows]: any = await pool.query('SELECT todo_key, read_count FROM admin_message_reads');
const map: Record<string, number> = {};
for (const r of rows || []) map[String(r.todo_key)] = Number(r.read_count) || 0;
return map;
} catch (e) {
logServerError('getAdminTodoReadCounts', e);
return {};
}
}
async markAdminTodosRead(items: Array<{ key: string; count: number }>): Promise<number> {
let affected = 0;
for (const it of items || []) {
if (!it || !it.key) continue;
try {
await pool.query(
`INSERT INTO admin_message_reads (todo_key, read_count, read_at)
VALUES (?, ?, NOW())
ON DUPLICATE KEY UPDATE read_count = VALUES(read_count), read_at = NOW()`,
[String(it.key), Number(it.count) || 0]
);
affected += 1;
} catch (e) {
logServerError('markAdminTodosRead', e);
}
}
return affected;
}
// 全平台用户管理相关
async getUsers(filter?: { keyword?: string; role?: string; status?: string }): Promise<any[]> {
let sql = `

查看文件

@@ -586,34 +586,6 @@ app.all('/api/user/certification', (_req: Request, res: Response) => {
});
});
// 平台未接入多项目管理:返回空数组,不再返回伪造项目与成员
app.all('/api/user/my-project/list', (_req: Request, res: Response) => {
res.json({
code: 20000,
data: []
});
});
// 后台个人中心:我的团队(真实团队表尚未建立,返回空数组,不再返回静态演示成员)
app.all('/api/user/my-team/list', (_req: Request, res: Response) => {
res.json({
code: 20000,
data: []
});
});
// 后台个人中心:最新动态(暂无审计动态数据源,返回空数组,不再返回静态演示内容)
app.all('/api/user/latest-activity', (_req: Request, res: Response) => {
res.json({
code: 20000,
data: []
});
});
app.all('/api/user/save-info', (_req: Request, res: Response) => {
res.json({ code: 20000, data: null, message: '个人设置保存成功' });
});
// 真实文件与图片对象存储上传接口 (支持单图上传并持久化至 public/uploads)
const handleFileUpload = (req: Request, res: Response) => {
// 生产环境要求登录后上传,避免匿名上传被滥用(刷盘/托管非法内容)
@@ -734,20 +706,59 @@ app.get('/api/message/subscribe-templates', async (_req: Request, res: Response)
}
});
// 通知中心列表(暂无真实站内消息数据源,返回空数组,不再返回静态演示消息)
app.all('/api/message/list', (_req: Request, res: Response) => {
res.json({
code: 20000,
data: []
});
// 管理端消息铃铛:真实待办聚合(待接单 / 待审核大神 / 待审核提现 / 待处理举报 / 待仲裁)
function buildAdminTodoItems(todos: any, readMap: Record<string, number>): any[] {
const defs = [
{ key: 'pending_orders', count: Number(todos?.pendingOrdersCount || 0), title: '待接单订单', content: '有订单等待大神接单', link: '/order/list' },
{ key: 'pending_audits', count: Number(todos?.pendingAuditsCount || 0), title: '待审核大神', content: '有大神入驻申请待审核', link: '/companion/audit' },
{ key: 'pending_withdrawals', count: Number(todos?.pendingWithdrawalsCount || 0), title: '待审核提现', content: `待审核提现合计 ¥${Number(todos?.pendingWithdrawalAmount || 0).toFixed(2)}`, link: '/finance/withdraw' },
{ key: 'pending_reports', count: Number(todos?.pendingReportsCount || 0), title: '待处理举报', content: '有违规举报工单待处置', link: '/safety/reports' },
{ key: 'pending_arbitrations', count: Number(todos?.pendingArbitrationsCount || 0), title: '待仲裁工单', content: '有争议仲裁待平台介入', link: '/order/arbitration' },
];
return defs
.filter((d) => d.count > 0)
.map((d, i) => {
const readCount = Number(readMap[d.key] || 0);
return {
id: i + 1,
key: d.key,
type: 'todo',
title: d.title,
subTitle: `待处理 ${d.count} 条`,
content: d.content,
time: formatCnTime(),
status: d.count > readCount ? 0 : 1,
messageType: 1,
count: d.count,
link: d.link,
};
});
}
app.post('/api/message/list', requireAdmin, async (_req: Request, res: Response) => {
try {
const todos = await repo.getDashboardTodos();
const readMap = await repo.getAdminTodoReadCounts();
res.json({ code: 20000, data: buildAdminTodoItems(todos, readMap) });
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
app.all('/api/message/read', (_req: Request, res: Response) => {
res.json({ code: 20000, data: true });
});
app.all('/api/chat/list', (_req: Request, res: Response) => {
res.json({ code: 20000, data: [] });
app.post('/api/message/read', requireAdmin, async (req: Request, res: Response) => {
try {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map((x: any) => Number(x)) : [];
const keys = Array.isArray(req.body?.keys) ? req.body.keys.map((x: any) => String(x)) : [];
const todos = await repo.getDashboardTodos();
const readMap = await repo.getAdminTodoReadCounts();
const items = buildAdminTodoItems(todos, readMap);
const targets = items.filter((it: any) => ids.includes(it.id) || keys.includes(it.key));
const toMark = (targets.length ? targets : items).map((it: any) => ({ key: it.key, count: it.count }));
const affected = await repo.markAdminTodosRead(toMark);
res.json({ code: 20000, data: { affected }, message: '已标记为已读' });
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
// ===== 站内消息通知中心(用户端真实数据源)=====

查看文件

@@ -18,16 +18,21 @@ describe('第六十五批后台演示数据清理闸门', () => {
expect(indexCode).not.toContain('MySQL 数据库就绪');
});
it('相关接口返回空数组(由前端展示空态)', () => {
it('相关空壳接口已移除(后台不再展示演示卡片)', () => {
for (const marker of [
"app.all('/api/user/my-team/list'",
"app.all('/api/user/latest-activity'",
"app.all('/api/message/list'",
"app.all('/api/user/my-project/list'",
"app.all('/api/user/save-info'",
"app.all('/api/chat/list'",
]) {
const start = indexCode.indexOf(marker);
expect(start).toBeGreaterThan(-1);
const block = indexCode.slice(start, start + 220);
expect(block).toContain('data: []');
expect(indexCode).not.toContain(marker);
}
});
it('消息中心改为真实待办聚合', () => {
expect(indexCode).toContain("app.post('/api/message/list'");
expect(indexCode).toContain('buildAdminTodoItems');
expect(indexCode).toContain('pending_orders');
});
});

查看文件

@@ -0,0 +1,54 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第八十五批:后台消息铃铛接真实待办聚合 + 清理空壳接口与空卡片。
*/
describe('第八十五批后台待办消息闸门', () => {
const root = path.resolve(__dirname, '../../');
const read = (p: string) => fs.readFileSync(path.join(root, p), 'utf-8');
const exists = (p: string) => fs.existsSync(path.join(root, p));
const indexCode = read('server/src/index.ts');
const repoCode = read('server/src/db/repository.ts');
const mysqlCode = read('server/src/db/mysql.ts');
const admin = 'admin/arco-design-pro-vite/src';
it('空壳接口已删除(无调用方)', () => {
for (const m of [
"app.all('/api/user/save-info'",
"app.all('/api/chat/list'",
"app.all('/api/user/my-team/list'",
"app.all('/api/user/latest-activity'",
"app.all('/api/user/my-project/list'",
]) {
expect(indexCode).not.toContain(m);
}
});
it('消息铃铛改为真实待办聚合,且需管理员鉴权', () => {
expect(indexCode).toContain("app.post('/api/message/list', requireAdmin");
expect(indexCode).toContain("app.post('/api/message/read', requireAdmin");
expect(indexCode).toContain('buildAdminTodoItems');
for (const k of ['pending_orders', 'pending_audits', 'pending_withdrawals', 'pending_reports', 'pending_arbitrations']) {
expect(indexCode).toContain(k);
}
});
it('待办已读状态持久化(数量增加会重新变未读)', () => {
expect(mysqlCode).toContain('admin_message_reads');
expect(repoCode).toContain('async getAdminTodoReadCounts(');
expect(repoCode).toContain('async markAdminTodosRead(');
expect(repoCode).toContain('pendingOrdersCount');
});
it('后台移除空卡片与死函数', () => {
for (const f of ['my-team.vue', 'latest-activity.vue', 'my-project.vue', 'latest-notification.vue']) {
expect(exists(`${admin}/views/user/info/components/${f}`)).toBe(false);
}
expect(read(`${admin}/api/user-center.ts`)).not.toContain('queryMyTeamList');
expect(read(`${admin}/api/user-center.ts`)).not.toContain('queryLatestActivity');
expect(read(`${admin}/api/message.ts`)).not.toContain('queryChatList');
expect(read(`${admin}/views/user/info/index.vue`)).toContain('UserInfoHeader');
});
});