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;
|
time: string;
|
||||||
status: 0 | 1;
|
status: 0 | 1;
|
||||||
messageType?: number;
|
messageType?: number;
|
||||||
|
/** 待办跳转路径(可选) */
|
||||||
|
link?: string;
|
||||||
|
/** 待办数量(可选) */
|
||||||
|
count?: number;
|
||||||
}
|
}
|
||||||
export type MessageListType = MessageRecord[];
|
export type MessageListType = MessageRecord[];
|
||||||
|
|
||||||
@@ -24,15 +28,3 @@ interface MessageStatus {
|
|||||||
export function setMessageStatus(data: MessageStatus) {
|
export function setMessageStatus(data: MessageStatus) {
|
||||||
return axios.post<MessageListType>('/api/message/read', data);
|
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';
|
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 {
|
export interface BasicInfoModel {
|
||||||
email: string;
|
email: string;
|
||||||
nickname: 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">
|
<div class="container">
|
||||||
<Breadcrumb :items="['menu.user', 'menu.user.info']" />
|
<Breadcrumb :items="['menu.user', 'menu.user.info']" />
|
||||||
<UserInfoHeader />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import UserInfoHeader from './components/user-info-header.vue';
|
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>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -45,43 +19,4 @@
|
|||||||
.container {
|
.container {
|
||||||
padding: 0 20px 20px 20px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
<image class="play-svg" :src="isPlayingVoice ? ICONS.pause : ICONS.play" mode="aspectFit" />
|
<image class="play-svg" :src="isPlayingVoice ? ICONS.pause : ICONS.play" mode="aspectFit" />
|
||||||
</view>
|
</view>
|
||||||
<view class="voice-info">
|
<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="voice-wave" v-if="isPlayingVoice">
|
||||||
<view class="bar b1"></view>
|
<view class="bar b1"></view>
|
||||||
<view class="bar b2"></view>
|
<view class="bar b2"></view>
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
<view class="bar-actions">
|
<view class="bar-actions">
|
||||||
<view class="icon-action btn-active" @click="chatOnline">
|
<view class="icon-action btn-active" @click="chatOnline">
|
||||||
<image class="action-svg" :src="ICONS.chat" mode="aspectFit" />
|
<image class="action-svg" :src="ICONS.chat" mode="aspectFit" />
|
||||||
<text class="action-text">私聊</text>
|
<text class="action-text">联系</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="icon-action btn-active" @click="toggleFavorite">
|
<view class="icon-action btn-active" @click="toggleFavorite">
|
||||||
<image class="action-svg" :src="isFav ? ICONS.starFilled : ICONS.starOutline" mode="aspectFit" />
|
<image class="action-svg" :src="isFav ? ICONS.starFilled : ICONS.starOutline" mode="aspectFit" />
|
||||||
@@ -538,22 +538,41 @@ const toggleVoice = () => {
|
|||||||
const chatOnline = () => {
|
const chatOnline = () => {
|
||||||
uni.showActionSheet({
|
uni.showActionSheet({
|
||||||
itemList: [
|
itemList: [
|
||||||
`与【${expert.value.nickname}】开启即时连麦`,
|
`复制大神游戏开黑专属房号`,
|
||||||
`向【${expert.value.nickname}】发送打招呼私信`,
|
`联系 7x24h 官方在线客服`
|
||||||
`复制大神游戏开黑专属房号`
|
|
||||||
],
|
],
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.tapIndex === 0) {
|
if (res.tapIndex === 0) {
|
||||||
uni.showToast({ title: '已建立加密连麦通道', icon: 'success' });
|
|
||||||
} else if (res.tapIndex === 1) {
|
|
||||||
uni.showToast({ title: '打招呼私信已送达', icon: 'success' });
|
|
||||||
} else {
|
|
||||||
uni.setClipboardData({
|
uni.setClipboardData({
|
||||||
data: `PW-${expert.value.id}-${Math.floor(1000 + Math.random() * 9000)}`,
|
data: `PW-${expert.value.id}-${Math.floor(1000 + Math.random() * 9000)}`,
|
||||||
success: () => {
|
success: () => {
|
||||||
uni.showToast({ title: '专属房号已复制', icon: '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',
|
id: 'pool_101',
|
||||||
game: '王者荣耀',
|
game: '王者荣耀',
|
||||||
price: 35,
|
price: 35,
|
||||||
desc: 'QQ区/星耀1晋级赛冲王者/需连麦/射手或辅助',
|
desc: 'QQ区/星耀1晋级赛冲王者/需开黑配合/射手或辅助',
|
||||||
countdown: 48
|
countdown: 48
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -724,7 +724,7 @@ const activeOrders = ref([
|
|||||||
serviceTitle: '排位包胜率',
|
serviceTitle: '排位包胜率',
|
||||||
hours: 2,
|
hours: 2,
|
||||||
totalAmount: 70,
|
totalAmount: 70,
|
||||||
statusText: '正在开黑连麦对局中'
|
statusText: '正在开黑对局履约中'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ord_active_2',
|
id: 'ord_active_2',
|
||||||
@@ -975,22 +975,41 @@ const handleRejectOrder = (order) => {
|
|||||||
const chatWithBuyer = () => {
|
const chatWithBuyer = () => {
|
||||||
uni.showActionSheet({
|
uni.showActionSheet({
|
||||||
itemList: [
|
itemList: [
|
||||||
'呼叫进入买家专属开黑语音连麦',
|
'复制开黑联络码',
|
||||||
'发送微信快捷提醒(准备开局)',
|
'联系 7x24h 官方客服'
|
||||||
'复制开黑联络码'
|
|
||||||
],
|
],
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.tapIndex === 0) {
|
if (res.tapIndex === 0) {
|
||||||
uni.showToast({ title: '已建立加密语音通道', icon: 'success' });
|
|
||||||
} else if (res.tapIndex === 1) {
|
|
||||||
uni.showToast({ title: '已发送准备开局提醒', icon: 'success' });
|
|
||||||
} else {
|
|
||||||
uni.setClipboardData({
|
uni.setClipboardData({
|
||||||
data: `BUYER-${Math.floor(100000 + Math.random() * 900000)}`,
|
data: `BUYER-${Math.floor(100000 + Math.random() * 900000)}`,
|
||||||
success: () => {
|
success: () => {
|
||||||
uni.showToast({ title: '联络码已复制', icon: '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" />
|
<image class="tab-svg" :src="ICONS.bolt" mode="aspectFit" />
|
||||||
<text class="tab-t">即刻出发</text>
|
<text class="tab-t">即刻出发</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="tab-d">付款后立即连麦开黑</text>
|
<text class="tab-d">付款后立即开黑履约</text>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
class="time-tab btn-active"
|
class="time-tab btn-active"
|
||||||
|
|||||||
@@ -547,11 +547,9 @@ const payOrder = async (id) => {
|
|||||||
|
|
||||||
const contactExpert = (name) => {
|
const contactExpert = (name) => {
|
||||||
uni.showActionSheet({
|
uni.showActionSheet({
|
||||||
itemList: [`呼叫进入【${name}】专属开黑连麦`, `复制【${name}】陪玩开黑房间号`, '联系 7x24h 官方电竞客服'],
|
itemList: [`复制【${name}】陪玩开黑房间号`, '联系 7x24h 官方电竞客服'],
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.tapIndex === 0) {
|
if (res.tapIndex === 0) {
|
||||||
uni.showToast({ title: `已呼叫大神【${name}】连麦`, icon: 'success' });
|
|
||||||
} else if (res.tapIndex === 1) {
|
|
||||||
uni.setClipboardData({
|
uni.setClipboardData({
|
||||||
data: `PW-${Math.floor(100000 + Math.random() * 900000)}`,
|
data: `PW-${Math.floor(100000 + Math.random() * 900000)}`,
|
||||||
success: () => {
|
success: () => {
|
||||||
@@ -559,7 +557,29 @@ const contactExpert = (name) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} 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;
|
) 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(`
|
await conn.query(`
|
||||||
CREATE TABLE IF NOT EXISTS user_coupons (
|
CREATE TABLE IF NOT EXISTS user_coupons (
|
||||||
|
|||||||
@@ -3729,6 +3729,13 @@ export class MysqlRepository {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logServerError('getDashboardTodos.reports', 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);
|
const pendingWithdrawalAmount = wdRows.reduce((sum: number, w: any) => sum + Number(w.amount), 0);
|
||||||
|
|
||||||
@@ -3737,6 +3744,7 @@ export class MysqlRepository {
|
|||||||
pendingArbitrationsCount: arbRows.length,
|
pendingArbitrationsCount: arbRows.length,
|
||||||
pendingWithdrawalsCount: wdRows.length,
|
pendingWithdrawalsCount: wdRows.length,
|
||||||
pendingWithdrawalAmount,
|
pendingWithdrawalAmount,
|
||||||
|
pendingOrdersCount,
|
||||||
pendingReportsCount: rptRows.length,
|
pendingReportsCount: rptRows.length,
|
||||||
riskInterceptionsCount: secRows.length,
|
riskInterceptionsCount: secRows.length,
|
||||||
pendingAudits: auditRows.map((c: any) => ({
|
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[]> {
|
async getUsers(filter?: { keyword?: string; role?: string; status?: string }): Promise<any[]> {
|
||||||
let sql = `
|
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)
|
// 真实文件与图片对象存储上传接口 (支持单图上传并持久化至 public/uploads)
|
||||||
const handleFileUpload = (req: Request, res: Response) => {
|
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) => {
|
function buildAdminTodoItems(todos: any, readMap: Record<string, number>): any[] {
|
||||||
res.json({
|
const defs = [
|
||||||
code: 20000,
|
{ key: 'pending_orders', count: Number(todos?.pendingOrdersCount || 0), title: '待接单订单', content: '有订单等待大神接单', link: '/order/list' },
|
||||||
data: []
|
{ 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) => {
|
app.post('/api/message/read', requireAdmin, async (req: Request, res: Response) => {
|
||||||
res.json({ code: 20000, data: true });
|
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)) : [];
|
||||||
app.all('/api/chat/list', (_req: Request, res: Response) => {
|
const todos = await repo.getDashboardTodos();
|
||||||
res.json({ code: 20000, data: [] });
|
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 数据库就绪');
|
expect(indexCode).not.toContain('MySQL 数据库就绪');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('相关接口返回空数组(由前端展示空态)', () => {
|
it('相关空壳接口已移除(后台不再展示演示卡片)', () => {
|
||||||
for (const marker of [
|
for (const marker of [
|
||||||
"app.all('/api/user/my-team/list'",
|
"app.all('/api/user/my-team/list'",
|
||||||
"app.all('/api/user/latest-activity'",
|
"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(indexCode).not.toContain(marker);
|
||||||
expect(start).toBeGreaterThan(-1);
|
|
||||||
const block = indexCode.slice(start, start + 220);
|
|
||||||
expect(block).toContain('data: []');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户