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 次删除

查看文件

@@ -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');
});
});