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 通过
这个提交包含在:
@@ -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');
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户