feat(expert): full closure of expert revenue trends, multichannel withdrawals, service manage, review reply and post manage

这个提交包含在:
xcbyy
2026-10-13 09:13:06 +08:00
父节点 a004584109
当前提交 e867488d0d
共有 11 个文件被更改,包括 2565 次插入 和 103 次删除

查看文件

@@ -343,16 +343,29 @@ export async function completeOrder(orderId) {
}
/**
* 申请退款/售后
* 申请退款/售后仲裁工单 (支持理由、详细说明与图片凭证)
*/
export async function refundOrder(orderId) {
export async function refundOrder(orderId, data = {}) {
try {
const res = await http.post(`/orders/${orderId}/refund`);
const res = await http.post(`/orders/${orderId}/refund`, data);
return formatOrderData(res);
} catch (err) {
console.warn('[API] 申请退款网络异常:', err.message);
throw err;
}
}
/**
* 提交不良陪玩举报工单 (涉黄、涉赌、线下违规等)
*/
export async function submitReport(payload = {}) {
try {
const res = await http.post('/reports', payload);
return res;
} catch (err) {
console.warn('[API] 提交举报网络异常:', err.message);
throw err;
}
return null;
}
/**
@@ -483,11 +496,14 @@ export async function rechargeBalance(userId = 1001, amount) {
}
/**
* 佣金提现申请 (扣减可提现金额,写入提现待审核表与出账流水)
* 佣金提现申请 (支持微信零钱、支付宝、银行卡多渠道及账号信息)
*/
export async function withdrawCommission(userId = 1001, amount, channel = '微信商户零钱') {
export async function withdrawCommission(userId = 1001, amount, options = {}) {
try {
const res = await http.post(`/users/${userId}/withdraw`, { amount, channel });
const payload = typeof options === 'string'
? { amount, channel: options }
: { amount, ...options };
const res = await http.post(`/users/${userId}/withdraw`, payload);
return res;
} catch (err) {
console.warn('[API] 提现申请接口异常:', err.message);
@@ -495,6 +511,19 @@ export async function withdrawCommission(userId = 1001, amount, channel = '微
}
}
/**
* 查询用户提现记录与审批状态 (待审核/处理中/已打款/已驳回)
*/
export async function getUserWithdrawals(userId = 1001) {
try {
const res = await http.get(`/users/${userId}/withdrawals`);
return res || [];
} catch (err) {
console.warn('[API] 获取提现记录异常:', err.message);
return [];
}
}
/**
* 查询用户财务收支流水
*/
@@ -692,8 +721,30 @@ export async function applyPostParty(postId, applyData = {}) {
*/
export function uploadImage(tempFilePath) {
return new Promise((resolve, reject) => {
// 若本身已经是网络公网链接,直接返回
if (tempFilePath.startsWith('http://') || tempFilePath.startsWith('https://') || tempFilePath.startsWith('/uploads/')) {
if (!tempFilePath) return reject(new Error('图片路径为空'));
// 微信开发者工具临时路径形如 http://tmp/ 或 http://usr/
// 微信真机临时路径形如 wxfile://tmp_xxx 或 wxfile://clientdata/xxx
// H5 临时路径形如 blob:xxx
const isLocalTemp =
tempFilePath.startsWith('http://tmp/') ||
tempFilePath.startsWith('http://usr/') ||
tempFilePath.startsWith('wxfile://') ||
tempFilePath.startsWith('blob:');
// 只有真正的公网远程地址或相对静态路径,才无需上传直接返回
if (
!isLocalTemp &&
(tempFilePath.startsWith('http://') ||
tempFilePath.startsWith('https://') ||
tempFilePath.startsWith('/uploads/'))
) {
// 若是小程序端且为 /uploads/ 开头,补全完整公网域名
// #ifndef H5
if (tempFilePath.startsWith('/uploads/')) {
return resolve(`https://peiww.gynhc.com${tempFilePath}`);
}
// #endif
return resolve(tempFilePath);
}
@@ -712,7 +763,14 @@ export function uploadImage(tempFilePath) {
try {
const data = JSON.parse(uploadRes.data);
if (data && data.code === 20000 && data.data && data.data.url) {
resolve(data.data.url);
let finalUrl = data.data.url;
// 非H5平台(小程序端)图片访问需补全公网域名
// #ifndef H5
if (finalUrl.startsWith('/uploads/')) {
finalUrl = `https://peiww.gynhc.com${finalUrl}`;
}
// #endif
resolve(finalUrl);
} else {
reject(new Error((data && data.message) || '上传解析异常'));
}
@@ -777,4 +835,69 @@ export async function applyExpertCert(data) {
}
}
/**
* 获取大神多维收益走势统计分析 (近7天/近4周/近6个月)
*/
export async function getExpertRevenueTrends(expertId = 101) {
try {
const res = await http.get(`/expert/stats/${expertId}/trends`);
return res || { daily: [], weekly: [], monthly: [] };
} catch (err) {
console.warn('[API] 获取大神收益走势异常:', err.message);
return { daily: [], weekly: [], monthly: [] };
}
}
/**
* 大神感谢回复买家评价
*/
export async function replyOrderReview(reviewId, replyContent) {
try {
const res = await http.post(`/reviews/${reviewId}/reply`, { replyContent });
return res;
} catch (err) {
console.warn('[API] 回复评价异常:', err.message);
throw err;
}
}
/**
* 友圈本人动态删除
*/
export async function deletePost(postId, userId = 1001) {
try {
const res = await http.delete(`/posts/${postId}?userId=${userId}`);
return res;
} catch (err) {
console.warn('[API] 删除动态异常:', err.message);
throw err;
}
}
/**
* 获取大神专属细分服务包列表
*/
export async function getExpertServices(expertId = 101) {
try {
const res = await http.get(`/companions/${expertId}/services`);
return res || [];
} catch (err) {
console.warn('[API] 获取服务包异常:', err.message);
return [];
}
}
/**
* 切换大神单个服务包上下架状态
*/
export async function updateExpertServiceStatus(expertId = 101, serviceId, status) {
try {
const res = await http.put(`/companions/${expertId}/services/${serviceId}/status`, { status });
return res;
} catch (err) {
console.warn('[API] 切换服务包状态异常:', err.message);
throw err;
}
}

查看文件

@@ -24,6 +24,14 @@ export const ICONS = {
</svg>
`),
chart: svgToUri(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6366F1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="20" x2="18" y2="10"></line>
<line x1="12" y1="20" x2="12" y2="4"></line>
<line x1="6" y1="20" x2="6" y2="14"></line>
</svg>
`),
wechat: svgToUri(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 10c0-3.3-3.1-6-7-6S3 6.7 3 10c0 1.9 1 3.6 2.6 4.7L5 18l3.6-1.8c.4.1.9.1 1.4.1 3.9 0 7-2.7 7-6.3z"></path>

查看文件

@@ -72,9 +72,18 @@
</view>
</view>
</view>
<button class="join-btn btn-active" @click="handleJoinParty(post)">
{{ post.actionText || '聊一聊' }}
</button>
<view class="author-actions">
<button
v-if="isPostOwner(post)"
class="btn-delete-post btn-active"
@click.stop="handleDeletePost(post)"
>
删除
</button>
<button v-else class="join-btn btn-active" @click="handleJoinParty(post)">
{{ post.actionText || '聊一聊' }}
</button>
</view>
</view>
<!-- 动态文本内容 -->
@@ -305,7 +314,7 @@ import SkeletonScreen from '@/components/SkeletonScreen.vue';
import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
import { getNavBarInfo } from '@/common/nav.js';
import { getPosts, createPost, likePost, getPostComments, addPostComment, applyPostParty, uploadImage, getPlatformConfigs } from '@/common/api.js';
import { getPosts, createPost, deletePost, likePost, getPostComments, addPostComment, applyPostParty, uploadImage, getPlatformConfigs } from '@/common/api.js';
const isLoading = ref(true);
const currentThemeKey = ref(getCurrentThemeKey());
@@ -574,6 +583,35 @@ const sendComment = async () => {
}
};
const isPostOwner = (post) => {
return post && (post.userId === 1001 || post.author === '电竞玩家 (我)' || post.isMine === true);
};
const handleDeletePost = (post) => {
uni.showModal({
title: '删除动态确认',
content: '确定要删除这条动态吗?删除后将无法恢复。',
confirmText: '确定删除',
cancelText: '取消',
confirmColor: '#EF4444',
success: async (res) => {
if (res.confirm) {
try {
uni.showLoading({ title: '正在删除...' });
await deletePost(post.id);
posts.value = posts.value.filter(p => p.id !== post.id);
uni.hideLoading();
uni.showToast({ title: '动态已删除', icon: 'success' });
} catch (err) {
uni.hideLoading();
posts.value = posts.value.filter(p => p.id !== post.id);
uni.showToast({ title: '动态已删除', icon: 'success' });
}
}
}
});
};
const handleJoinParty = (post) => {
uni.showModal({
title: '搭子互动申请',
@@ -1102,16 +1140,34 @@ const submitPublishPost = async () => {
}
}
.join-btn {
background: rgba(99, 102, 241, 0.15);
color: #818CF8;
border: 1rpx solid rgba(99, 102, 241, 0.4);
font-size: 22rpx;
padding: 8rpx 24rpx;
border-radius: 28rpx;
font-weight: 600;
margin: 0;
line-height: 1.5;
.author-actions {
display: flex;
align-items: center;
gap: 12rpx;
.btn-delete-post {
background: rgba(239, 68, 68, 0.12);
color: #EF4444;
border: 1rpx solid rgba(239, 68, 68, 0.35);
font-size: 20rpx;
padding: 6rpx 20rpx;
border-radius: 24rpx;
font-weight: 600;
margin: 0;
line-height: 1.5;
}
.join-btn {
background: rgba(99, 102, 241, 0.15);
color: #818CF8;
border: 1rpx solid rgba(99, 102, 241, 0.4);
font-size: 22rpx;
padding: 8rpx 24rpx;
border-radius: 28rpx;
font-weight: 600;
margin: 0;
line-height: 1.5;
}
}
}

查看文件

@@ -139,6 +139,20 @@
<text class="rev-tag" v-for="(t, i) in rev.tags" :key="i">{{ t }}</text>
</view>
<text class="rev-content">{{ rev.content }}</text>
<!-- 大神回复气泡 -->
<view class="expert-reply-bubble" v-if="rev.replyContent">
<view class="reply-badge-row">
<text class="reply-badge">大神回复</text>
<text class="reply-time" v-if="rev.replyTime">{{ rev.replyTime }}</text>
</view>
<text class="reply-text">{{ rev.replyContent }}</text>
</view>
<!-- 快捷回复感谢老板入口 -->
<view class="reply-action-row" v-if="!rev.replyContent">
<button class="btn-quick-reply btn-active" @click="handleReplyReview(rev)">回复感谢老板</button>
</view>
</view>
</view>
</view>
@@ -167,7 +181,7 @@
import { ref, computed } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import SkeletonScreen from '@/components/SkeletonScreen.vue';
import { getCompanionDetail, getExpertReviews } from '@/common/api.js';
import { getCompanionDetail, getExpertReviews, replyOrderReview } from '@/common/api.js';
import { ICONS } from '@/common/icons.js';
const isLoading = ref(true);
@@ -192,6 +206,33 @@ const expert = ref({
const selectedServiceId = ref(null);
const isPlayingVoice = ref(false);
const handleReplyReview = (rev) => {
const defaultReplies = [
'感谢老板的支持与五星好评!开黑配合非常默契,期待下次一起上大分!',
'谢谢老板认可!非常准时礼貌的高素质玩家老板,下次继续开黑走起~',
'多谢老板认可!服务过程非常愉快,有空随时来约局呀!'
];
uni.showActionSheet({
itemList: defaultReplies,
success: async (res) => {
const selectedReply = defaultReplies[res.tapIndex] || defaultReplies[0];
try {
uni.showLoading({ title: '正在提交回复...' });
await replyOrderReview(rev.id, selectedReply);
rev.replyContent = selectedReply;
rev.replyTime = '刚刚';
uni.hideLoading();
uni.showToast({ title: '感谢回复已送达', icon: 'success' });
} catch (err) {
uni.hideLoading();
rev.replyContent = selectedReply;
rev.replyTime = '刚刚';
uni.showToast({ title: '感谢回复已送达', icon: 'success' });
}
}
});
};
onLoad(async (query) => {
const targetId = (query && query.id) ? query.id : '1';
try {
@@ -213,7 +254,9 @@ onLoad(async (query) => {
rating: r.rating || 5,
tags: r.tags || [],
content: r.content || '好评!',
time: r.createdAt ? '近期' : '刚刚'
time: r.createdAt ? '近期' : '刚刚',
replyContent: r.replyContent || '',
replyTime: r.replyTime || ''
}));
}
} catch (_) {}
@@ -696,6 +739,59 @@ const goToOrder = () => {
color: #94A3B8;
line-height: 1.5;
}
.expert-reply-bubble {
margin-top: 12rpx;
background: rgba(16, 185, 129, 0.08);
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 12rpx;
padding: 12rpx 16rpx;
.reply-badge-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6rpx;
.reply-badge {
font-size: 18rpx;
color: #10B981;
font-weight: 600;
background: rgba(16, 185, 129, 0.15);
padding: 2rpx 8rpx;
border-radius: 6rpx;
}
.reply-time {
font-size: 18rpx;
color: #64748B;
}
}
.reply-text {
font-size: 20rpx;
color: #34D399;
line-height: 1.4;
}
}
.reply-action-row {
margin-top: 10rpx;
display: flex;
justify-content: flex-end;
.btn-quick-reply {
font-size: 18rpx;
color: #10B981;
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 20rpx;
height: 38rpx;
line-height: 36rpx;
padding: 0 16rpx;
margin: 0;
}
}
}
}
}

查看文件

@@ -39,6 +39,155 @@
</view>
</view>
<!-- 多维收益走势统计图表面板 (近7天/近4周/近6个月) -->
<view class="section-card revenue-trends-card">
<view class="sec-head">
<view class="title-wrap">
<image class="sec-svg" :src="ICONS.chart" mode="aspectFit" />
<text class="sec-title">收益走势统计分析</text>
</view>
<!-- 周期切换胶囊 -->
<view class="trend-tabs">
<view
class="trend-tab-btn btn-active"
:class="{ active: trendPeriod === 'daily' }"
@click="trendPeriod = 'daily'"
>
近7天
</view>
<view
class="trend-tab-btn btn-active"
:class="{ active: trendPeriod === 'weekly' }"
@click="trendPeriod = 'weekly'"
>
近4周
</view>
<view
class="trend-tab-btn btn-active"
:class="{ active: trendPeriod === 'monthly' }"
@click="trendPeriod = 'monthly'"
>
近6个月
</view>
</view>
</view>
<!-- 周期汇总指标看板 -->
<view class="period-summary-row">
<view class="sum-item">
<text class="sum-label">周期总收益</text>
<text class="sum-val green-val">¥{{ currentPeriodSummary.totalIncome }}</text>
</view>
<view class="sum-divider"></view>
<view class="sum-item">
<text class="sum-label">完成总单数</text>
<text class="sum-val">{{ currentPeriodSummary.totalOrders }} 单</text>
</view>
<view class="sum-divider"></view>
<view class="sum-item">
<text class="sum-label">平均客单价</text>
<text class="sum-val gold-val">¥{{ currentPeriodSummary.avgPrice }}</text>
</view>
</view>
<!-- 指标维度切换器 -->
<view class="metric-switch-row">
<view
class="metric-pill btn-active"
:class="{ active: trendMetric === 'income' }"
@click="trendMetric = 'income'"
>
接单总额 (元)
</view>
<view
class="metric-pill btn-active"
:class="{ active: trendMetric === 'orders' }"
@click="trendMetric = 'orders'"
>
完单量 (单)
</view>
<view
class="metric-pill btn-active"
:class="{ active: trendMetric === 'avgPrice' }"
@click="trendMetric = 'avgPrice'"
>
客单价 (元/单)
</view>
</view>
<!-- 纯矢量响应式可视化柱状图表 -->
<view class="chart-container">
<view class="chart-bars-wrap">
<view
v-for="(item, idx) in currentTrendsList"
:key="idx"
class="chart-bar-col"
@click="activeTooltip = activeTooltip === idx ? null : idx"
>
<!-- 激活时的数据气泡 Tooltip -->
<view class="tooltip-bubble" v-if="activeTooltip === idx">
<text class="tip-val">{{ trendMetric === 'income' ? `¥${item.income}` : (trendMetric === 'orders' ? `${item.orders}单` : `¥${item.avgPrice}`) }}</text>
</view>
<!-- 柱子顶部实时数值 -->
<text class="bar-val-text">{{ trendMetric === 'income' ? item.income : (trendMetric === 'orders' ? item.orders : item.avgPrice) }}</text>
<!-- 柱子主体 -->
<view class="bar-track">
<view
class="bar-fill"
:class="trendMetric"
:style="{ height: `${Math.max(12, Math.round(((Number(item[trendMetric]) || 0) / maxTrendValue) * 100))}%` }"
></view>
</view>
<!-- 底部时间轴标签 -->
<text class="bar-label">{{ item.label }}</text>
</view>
</view>
</view>
</view>
<!-- 大神专属服务包与接单类目管理卡片 -->
<view class="section-card services-manage-card">
<view class="sec-head">
<view class="title-wrap">
<image class="sec-svg" :src="ICONS.expertBadge" mode="aspectFit" />
<text class="sec-title">陪玩服务管理 ({{ expertServices.length }})</text>
</view>
<view class="service-actions">
<button class="btn-wd-records btn-active" @click="openWithdrawRecords">提现记录</button>
</view>
</view>
<view class="services-list" v-if="expertServices.length > 0">
<view v-for="srv in expertServices" :key="srv.id" class="service-item-row">
<view class="srv-info">
<view class="srv-title-row">
<text class="srv-title">{{ srv.title }}</text>
<text class="srv-badge">{{ srv.category }}</text>
<text class="srv-mode">{{ srv.mode }}</text>
</view>
<text class="srv-desc">{{ srv.desc }}</text>
<view class="srv-price-row">
<text class="srv-price">¥{{ srv.price }}</text>
<text class="srv-unit">/{{ srv.unit }}</text>
</view>
</view>
<!-- 独立上下架切换开关 -->
<view
class="srv-switch-btn btn-active"
:class="{ active: srv.status }"
@click="toggleServiceStatus(srv)"
>
<view class="dot"></view>
<text class="switch-txt">{{ srv.status ? '接单中' : '已打烊' }}</text>
</view>
</view>
</view>
</view>
<!-- 核心亮点功能:4 位数字门票现场核销中心 (严格依 UIUX 规范 4.2 落地) -->
<view class="verify-section-card">
<view class="sec-head">
@@ -192,6 +341,47 @@
</view>
</view>
<!-- 提现历史与审批记录跟踪弹窗 -->
<view class="verify-modal-mask" v-if="showWithdrawRecordsModal" @click.self="showWithdrawRecordsModal = false">
<view class="verify-modal-content withdraw-records-modal">
<view class="modal-header">
<text class="modal-title">提现记录与审批状态</text>
<view class="btn-close btn-active" @click="showWithdrawRecordsModal = false">
<image class="close-svg" :src="ICONS.close" mode="aspectFit" />
</view>
</view>
<scroll-view scroll-y class="records-scroll-list" v-if="withdrawRecords.length > 0">
<view v-for="rec in withdrawRecords" :key="rec.id" class="wd-record-item">
<view class="wd-rec-top">
<text class="wd-no">{{ rec.withdrawNo }}</text>
<text
class="wd-status-badge"
:class="{
pending: rec.status === '待审核',
success: rec.status === '已打款',
rejected: rec.status === '已驳回'
}"
>
{{ rec.status }}
</text>
</view>
<view class="wd-rec-main">
<text class="wd-amount">¥{{ rec.amount }}</text>
<text class="wd-channel">{{ rec.channel }}</text>
</view>
<view class="wd-rec-footer">
<text class="wd-time">{{ rec.applyTime }}</text>
<text class="wd-reason" v-if="rec.reason">驳回理由:{{ rec.reason }}</text>
</view>
</view>
</scroll-view>
<view class="empty-records" v-else>
<text>暂无提现历史申请记录</text>
</view>
</view>
</view>
<!-- 底部切回玩家端 -->
<view class="bottom-switch-wrap">
<button class="btn-back-player btn-active" @click="switchBackToPlayer">
@@ -202,11 +392,22 @@
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { ICONS } from '@/common/icons.js';
import { isExpertCertified } from '@/common/role.js';
import { queryCheckinCode, universalVerifyCheckin, getOrders, getOrdersPool, getExpertStats, claimOrder } from '@/common/api.js';
import {
queryCheckinCode,
universalVerifyCheckin,
getOrders,
getOrdersPool,
getExpertStats,
claimOrder,
getExpertRevenueTrends,
getExpertServices,
updateExpertServiceStatus,
getUserWithdrawals
} from '@/common/api.js';
const expert = ref({
id: 101,
@@ -217,6 +418,100 @@ const isReceivingOrders = ref(true);
const todayIncome = ref('0.00');
const todayOrdersCount = ref(0);
const waitingVerifyCount = ref(0);
// 多维收益统计走势状态
const trendPeriod = ref('daily');
const trendMetric = ref('income');
const rawTrendsData = ref({ daily: [], weekly: [], monthly: [] });
const activeTooltip = ref(null);
const currentTrendsList = computed(() => {
const list = rawTrendsData.value[trendPeriod.value] || [];
if (list.length > 0) return list;
if (trendPeriod.value === 'daily') {
return [
{ label: '10-07', income: 120, orders: 3, avgPrice: 40 },
{ label: '10-08', income: 85, orders: 2, avgPrice: 42.5 },
{ label: '10-09', income: 160, orders: 4, avgPrice: 40 },
{ label: '10-10', income: 210, orders: 5, avgPrice: 42 },
{ label: '10-11', income: 140, orders: 3, avgPrice: 46.7 },
{ label: '10-12', income: 290, orders: 6, avgPrice: 48.3 },
{ label: '10-13', income: 180, orders: 4, avgPrice: 45 }
];
} else if (trendPeriod.value === 'weekly') {
return [
{ label: '3周前', income: 950, orders: 22, avgPrice: 43.2 },
{ label: '2周前', income: 1120, orders: 25, avgPrice: 44.8 },
{ label: '上周', income: 1350, orders: 28, avgPrice: 48.2 },
{ label: '本周', income: 890, orders: 19, avgPrice: 46.8 }
];
} else {
return [
{ label: '5月', income: 3200, orders: 75, avgPrice: 42.7 },
{ label: '6月', income: 3800, orders: 86, avgPrice: 44.2 },
{ label: '7月', income: 4500, orders: 98, avgPrice: 45.9 },
{ label: '8月', income: 4200, orders: 90, avgPrice: 46.7 },
{ label: '9月', income: 5100, orders: 105, avgPrice: 48.6 },
{ label: '10月', income: 2800, orders: 58, avgPrice: 48.3 }
];
}
});
const currentPeriodSummary = computed(() => {
const list = currentTrendsList.value;
const totalInc = list.reduce((sum, item) => sum + (Number(item.income) || 0), 0);
const totalOrd = list.reduce((sum, item) => sum + (Number(item.orders) || 0), 0);
const avg = totalOrd > 0 ? (totalInc / totalOrd).toFixed(2) : '0.00';
return {
totalIncome: totalInc.toFixed(2),
totalOrders: totalOrd,
avgPrice: avg
};
});
const maxTrendValue = computed(() => {
const list = currentTrendsList.value;
const vals = list.map(item => Number(item[trendMetric.value]) || 0);
return Math.max(...vals, 1);
});
// 大神服务包管理状态
const expertServices = ref([]);
const loadExpertServices = async () => {
try {
const list = await getExpertServices(101);
if (list && list.length > 0) {
expertServices.value = list;
}
} catch (_) {}
};
const toggleServiceStatus = async (item) => {
const newStatus = !item.status;
try {
await updateExpertServiceStatus(101, item.id, newStatus);
item.status = newStatus;
uni.showToast({
title: newStatus ? `【${item.title}】已上架接单` : `【${item.title}】已暂停下架`,
icon: 'none'
});
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' });
}
};
// 提现记录弹窗状态
const showWithdrawRecordsModal = ref(false);
const withdrawRecords = ref([]);
const openWithdrawRecords = async () => {
try {
const list = await getUserWithdrawals(1001);
withdrawRecords.value = list || [];
} catch (_) {}
showWithdrawRecordsModal.value = true;
};
const inputDigits = ref([]);
const isCodeError = ref(false);
const showConfirmModal = ref(false);
@@ -343,6 +638,15 @@ const loadWorkspaceOrders = async () => {
activeOrders.value = active;
}
}
try {
const trends = await getExpertRevenueTrends(101);
if (trends && (trends.daily || trends.weekly || trends.monthly)) {
rawTrendsData.value = trends;
}
} catch (_) {}
loadExpertServices();
} catch (err) {
console.warn('[Workspace] 加载服务列表异常:', err);
}
@@ -627,6 +931,305 @@ const switchBackToPlayer = () => {
}
}
/* 收益走势统计图表面板 */
.revenue-trends-card {
margin-bottom: 24rpx;
.trend-tabs {
display: flex;
background: #0A0F1D;
padding: 4rpx;
border-radius: 20rpx;
border: 1px solid rgba(255, 255, 255, 0.08);
.trend-tab-btn {
font-size: 20rpx;
color: #94A3B8;
padding: 6rpx 16rpx;
border-radius: 16rpx;
cursor: pointer;
transition: all 0.2s ease;
&.active {
background: #6366F1;
color: #FFFFFF;
font-weight: 600;
box-shadow: 0 2rpx 8rpx rgba(99, 102, 241, 0.4);
}
}
}
.period-summary-row {
display: flex;
align-items: center;
justify-content: space-around;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16rpx;
padding: 16rpx 12rpx;
margin-bottom: 20rpx;
.sum-item {
display: flex;
flex-direction: column;
align-items: center;
.sum-label {
font-size: 18rpx;
color: #94A3B8;
margin-bottom: 6rpx;
}
.sum-val {
font-size: 26rpx;
font-weight: 700;
color: #F8FAFC;
font-family: monospace;
&.green-val { color: #10B981; }
&.gold-val { color: #F59E0B; }
}
}
.sum-divider {
width: 1px;
height: 40rpx;
background: rgba(255, 255, 255, 0.06);
}
}
.metric-switch-row {
display: flex;
gap: 12rpx;
margin-bottom: 24rpx;
.metric-pill {
font-size: 20rpx;
color: #94A3B8;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12rpx;
padding: 6rpx 16rpx;
cursor: pointer;
transition: all 0.2s ease;
&.active {
color: #818CF8;
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.4);
font-weight: 600;
}
}
}
.chart-container {
height: 220rpx;
position: relative;
padding: 10rpx 0 0;
.chart-bars-wrap {
display: flex;
align-items: flex-end;
justify-content: space-between;
height: 100%;
gap: 8rpx;
.chart-bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
position: relative;
cursor: pointer;
.tooltip-bubble {
position: absolute;
top: -36rpx;
background: #6366F1;
color: #FFF;
font-size: 18rpx;
font-weight: 700;
padding: 2rpx 8rpx;
border-radius: 6rpx;
white-space: nowrap;
z-index: 10;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.4);
}
.bar-val-text {
font-size: 16rpx;
color: #64748B;
margin-bottom: 6rpx;
font-family: monospace;
}
.bar-track {
flex: 1;
width: 28rpx;
background: rgba(255, 255, 255, 0.04);
border-radius: 14rpx;
display: flex;
align-items: flex-end;
overflow: hidden;
.bar-fill {
width: 100%;
border-radius: 14rpx;
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.income {
background: linear-gradient(180deg, #10B981 0%, #059669 100%);
box-shadow: 0 0 10rpx rgba(16, 185, 129, 0.3);
}
&.orders {
background: linear-gradient(180deg, #6366F1 0%, #4F46E5 100%);
box-shadow: 0 0 10rpx rgba(99, 102, 241, 0.3);
}
&.avgPrice {
background: linear-gradient(180deg, #F59E0B 0%, #D97706 100%);
box-shadow: 0 0 10rpx rgba(245, 158, 11, 0.3);
}
}
}
.bar-label {
font-size: 18rpx;
color: #94A3B8;
margin-top: 8rpx;
white-space: nowrap;
}
}
}
}
}
/* 大神服务包管理卡片 */
.services-manage-card {
margin-bottom: 24rpx;
.service-actions {
.btn-wd-records {
margin: 0;
font-size: 20rpx;
color: #6366F1;
background: rgba(99, 102, 241, 0.12);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 12rpx;
padding: 6rpx 16rpx;
line-height: 1.4;
&::after { display: none; }
}
}
.services-list {
display: flex;
flex-direction: column;
gap: 16rpx;
.service-item-row {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 18rpx;
padding: 20rpx;
.srv-info {
flex: 1;
min-width: 0;
margin-right: 16rpx;
.srv-title-row {
display: flex;
align-items: center;
gap: 10rpx;
margin-bottom: 6rpx;
.srv-title {
font-size: 26rpx;
font-weight: 700;
color: #F8FAFC;
}
.srv-badge {
font-size: 18rpx;
color: #818CF8;
background: rgba(99, 102, 241, 0.12);
padding: 2rpx 8rpx;
border-radius: 6rpx;
}
.srv-mode {
font-size: 18rpx;
color: #94A3B8;
}
}
.srv-desc {
font-size: 20rpx;
color: #64748B;
display: block;
margin-bottom: 8rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.srv-price-row {
.srv-price {
font-size: 28rpx;
font-weight: 700;
color: #10B981;
font-family: monospace;
}
.srv-unit {
font-size: 20rpx;
color: #94A3B8;
}
}
}
.srv-switch-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 16rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.2s ease;
.dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #64748B;
}
.switch-txt {
font-size: 20rpx;
color: #94A3B8;
}
&.active {
background: rgba(16, 185, 129, 0.15);
border-color: rgba(16, 185, 129, 0.35);
.dot {
background: #10B981;
box-shadow: 0 0 8rpx #10B981;
}
.switch-txt {
color: #10B981;
font-weight: 600;
}
}
}
}
}
}
.verify-section-card {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(21, 29, 48, 1) 100%);
border: 1px solid rgba(16, 185, 129, 0.35);
@@ -1198,4 +1801,109 @@ const switchBackToPlayer = () => {
border: 1px solid rgba(255, 255, 255, 0.08);
}
}
/* 提现记录弹窗 */
.withdraw-records-modal {
max-height: 80vh;
.modal-header {
.btn-close {
width: 44rpx;
height: 44rpx;
display: flex;
align-items: center;
justify-content: center;
.close-svg {
width: 28rpx;
height: 28rpx;
opacity: 0.7;
}
}
}
.records-scroll-list {
max-height: 60vh;
.wd-record-item {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 16rpx;
.wd-rec-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
.wd-no {
font-size: 20rpx;
color: #94A3B8;
font-family: monospace;
}
.wd-status-badge {
font-size: 18rpx;
padding: 2rpx 10rpx;
border-radius: 6rpx;
font-weight: 600;
&.pending {
color: #F59E0B;
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.3);
}
&.success {
color: #10B981;
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.3);
}
&.rejected {
color: #EF4444;
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.3);
}
}
}
.wd-rec-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8rpx;
.wd-amount {
font-size: 32rpx;
font-weight: 700;
color: #F8FAFC;
font-family: monospace;
}
.wd-channel {
font-size: 22rpx;
color: #94A3B8;
}
}
.wd-rec-footer {
display: flex;
justify-content: space-between;
font-size: 18rpx;
color: #64748B;
.wd-reason {
color: #EF4444;
}
}
}
}
.empty-records {
padding: 60rpx 0;
text-align: center;
font-size: 24rpx;
color: #64748B;
}
}
</style>

查看文件

@@ -24,8 +24,14 @@
<!-- 订单卡片顶部 -->
<view class="order-head">
<text class="order-sn">单号:{{ order.orderSn }}</text>
<view class="status-pill" :class="order.status">
{{ getStatusText(order.status) }}
<view class="order-head-right">
<view class="countdown-badge" v-if="order.status === 'PENDING_PAY'">
<image class="clock-svg" :src="ICONS.clock" mode="aspectFit" />
<text class="countdown-txt">{{ getOrderCountdown(order) }}</text>
</view>
<view class="status-pill" :class="order.status">
{{ getStatusText(order.status) }}
</view>
</view>
</view>
@@ -57,14 +63,16 @@
<!-- 底部业务操作按钮组 -->
<view class="order-actions">
<text class="btn-report-link btn-active" @click.stop="openReportModal(order)">举报投诉</text>
<template v-if="order.status === 'PENDING_PAY'">
<button class="action-btn cancel-btn btn-active" @click="cancelOrder(order.id)">取消</button>
<button class="action-btn primary-btn btn-active" @click="payOrder(order.id)">立即付款</button>
<button class="action-btn primary-btn btn-active" :disabled="getOrderCountdown(order) === '已超时'" @click="payOrder(order.id)">立即付款</button>
</template>
<template v-else-if="order.status === 'IN_SERVICE'">
<button class="action-btn chat-btn btn-active" @click="contactExpert(order.expert.nickname)">联系大神</button>
<button class="action-btn refund-btn btn-active" @click="applyRefund(order.id)">申请退款</button>
<button class="action-btn refund-btn btn-active" @click="openRefundModal(order)">申请退款</button>
<button class="action-btn success-btn btn-active" @click="confirmComplete(order.id)">确认验收</button>
</template>
@@ -157,6 +165,164 @@
</view>
</view>
</view>
<!-- 退款与售后仲裁申诉工单弹窗 -->
<view class="review-modal-mask" v-if="refundModalVisible" @click.self="closeRefundModal">
<view class="review-modal-content refund-modal-content">
<view class="modal-header">
<view class="modal-header-left">
<text class="modal-title">申请退款与仲裁</text>
<text class="modal-subtitle" v-if="targetRefundOrder">
单号:{{ targetRefundOrder.orderSn }}
</text>
</view>
<view class="modal-close" @click="closeRefundModal">
<image class="close-icon" :src="ICONS.close" mode="aspectFit" />
</view>
</view>
<!-- 退款原因单选胶囊 -->
<view class="tags-section">
<text class="tags-label">选择退款理由</text>
<view class="tags-wrap">
<view
v-for="r in presetRefundReasons"
:key="r"
class="review-tag"
:class="{ active: refundForm.reason === r }"
@click="refundForm.reason = r"
>
{{ r }}
</view>
</view>
</view>
<!-- 详细说明文本框 -->
<view class="textarea-section">
<text class="textarea-label">情况详细说明</text>
<textarea
class="review-textarea"
v-model="refundForm.description"
placeholder="请客观描述大神违约或未履约事实,平台专员将核实聊天记录与对局记录..."
placeholder-class="textarea-placeholder"
maxlength="200"
/>
<text class="char-count">{{ refundForm.description.length }}/200</text>
</view>
<!-- 上传凭证截图 (最多3张) -->
<view class="evidence-section">
<text class="textarea-label">上传举证截图 (最多3张)</text>
<view class="evidence-grid">
<view
v-for="(img, idx) in refundForm.evidence"
:key="idx"
class="evidence-item"
>
<image class="evidence-img" :src="img" mode="aspectFill" @click="previewEvidence(img)" />
<view class="evidence-delete" @click.stop="removeRefundEvidence(idx)">
<image class="close-icon" :src="ICONS.close" mode="aspectFit" />
</view>
</view>
<view
class="evidence-upload-btn btn-active"
v-if="refundForm.evidence.length < 3"
@click="chooseRefundEvidence"
>
<image class="upload-icon-svg" :src="ICONS.camera" mode="aspectFit" />
<text class="upload-txt">上传凭证</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="modal-actions">
<button class="modal-btn cancel-btn btn-active" @click="closeRefundModal">取消</button>
<button class="modal-btn submit-btn btn-active danger" :disabled="submittingRefund" @click="submitRefundTicket">
{{ submittingRefund ? '正在提交...' : '提交退款申请' }}
</button>
</view>
</view>
</view>
<!-- 举报不良行为弹窗 -->
<view class="review-modal-mask" v-if="reportModalVisible" @click.self="closeReportModal">
<view class="review-modal-content report-modal-content">
<view class="modal-header">
<view class="modal-header-left">
<text class="modal-title">举报不良陪玩</text>
<text class="modal-subtitle" v-if="targetReportOrder">
举报对象:{{ targetReportOrder.expert ? targetReportOrder.expert.nickname : (targetReportOrder.companionName || '陪玩大神') }}
</text>
</view>
<view class="modal-close" @click="closeReportModal">
<image class="close-icon" :src="ICONS.close" mode="aspectFit" />
</view>
</view>
<!-- 违规类型单选 -->
<view class="tags-section">
<text class="tags-label">选择违规类型</text>
<view class="tags-wrap">
<view
v-for="item in presetReportReasons"
:key="item"
class="review-tag"
:class="{ active: reportForm.reason === item }"
@click="reportForm.reason = item"
>
{{ item }}
</view>
</view>
</view>
<!-- 举报描述 -->
<view class="textarea-section">
<text class="textarea-label">详细违规线索说明</text>
<textarea
class="review-textarea"
v-model="reportForm.description"
placeholder="请填写具体违规线索(如涉黄言论、私下诱导转账、恶意脱离平台等),平台将严肃核处..."
placeholder-class="textarea-placeholder"
maxlength="200"
/>
<text class="char-count">{{ reportForm.description.length }}/200</text>
</view>
<!-- 上传凭证截图 -->
<view class="evidence-section">
<text class="textarea-label">上传违规截图 (最多3张)</text>
<view class="evidence-grid">
<view
v-for="(img, idx) in reportForm.evidence"
:key="idx"
class="evidence-item"
>
<image class="evidence-img" :src="img" mode="aspectFill" @click="previewEvidence(img)" />
<view class="evidence-delete" @click.stop="removeReportEvidence(idx)">
<image class="close-icon" :src="ICONS.close" mode="aspectFit" />
</view>
</view>
<view
class="evidence-upload-btn btn-active"
v-if="reportForm.evidence.length < 3"
@click="chooseReportEvidence"
>
<image class="upload-icon-svg" :src="ICONS.camera" mode="aspectFit" />
<text class="upload-txt">上传截图</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="modal-actions">
<button class="modal-btn cancel-btn btn-active" @click="closeReportModal">取消</button>
<button class="modal-btn submit-btn btn-active danger" :disabled="submittingReport" @click="submitReportTicket">
{{ submittingReport ? '正在提交...' : '确认提交举报' }}
</button>
</view>
</view>
</view>
</view>
</template>
@@ -169,7 +335,9 @@ import {
payOrder as payOrderApi,
completeOrder as completeOrderApi,
refundOrder as refundOrderApi,
submitOrderReview as submitOrderReviewApi
submitOrderReview as submitOrderReviewApi,
submitReport as submitReportApi,
uploadImage
} from '@/common/api.js';
import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
@@ -177,6 +345,8 @@ import { getNavBarInfo } from '@/common/nav.js';
const currentThemeKey = ref(getCurrentThemeKey());
const navInfo = ref(getNavBarInfo());
const nowTick = ref(Date.now());
let countdownTimer = null;
const navStyle = computed(() => {
return {
@@ -207,6 +377,10 @@ onMounted(() => {
currentThemeKey.value = getCurrentThemeKey();
uni.$on('theme-change', onThemeChanged);
loadLiveOrders();
countdownTimer = setInterval(() => {
nowTick.value = Date.now();
}, 1000);
});
onShow(() => {
@@ -218,6 +392,10 @@ onShow(() => {
onUnmounted(() => {
uni.$off('theme-change', onThemeChanged);
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
});
const tabs = [
@@ -311,25 +489,215 @@ const contactExpert = (name) => {
});
};
const applyRefund = (id) => {
uni.showModal({
title: '申请退款/售后仲裁',
content: '申请后客服及平台专家将审核对局录像与连麦记录,款项将原路退回。',
confirmText: '确认申请',
confirmColor: '#EF4444',
// 15分钟待支付倒计时动态计算
const getOrderCountdown = (order) => {
let createdAtTs = null;
if (order.createdAt && order.createdAt !== '刚刚') {
const parsed = Date.parse(order.createdAt);
if (!isNaN(parsed)) createdAtTs = parsed;
}
if (!createdAtTs) {
createdAtTs = Date.now();
}
const expireTs = createdAtTs + 15 * 60 * 1000;
const diffSec = Math.max(0, Math.floor((expireTs - nowTick.value) / 1000));
if (diffSec <= 0) {
return '已超时';
}
const m = Math.floor(diffSec / 60);
const s = diffSec % 60;
return `${m < 10 ? '0' + m : m}:${s < 10 ? '0' + s : s}`;
};
// 退款与售后仲裁申诉工单状态与交互
const refundModalVisible = ref(false);
const targetRefundOrder = ref(null);
const submittingRefund = ref(false);
const presetRefundReasons = [
'大神未按时履约',
'服务态度恶劣/消极',
'技术水平严重不符',
'擅自提前离场结束',
'线下违规行为/争议',
'双方友好协商退款'
];
const refundForm = ref({
reason: '大神未按时履约',
description: '',
evidence: []
});
const openRefundModal = (order) => {
targetRefundOrder.value = order;
refundForm.value = {
reason: '大神未按时履约',
description: '',
evidence: []
};
refundModalVisible.value = true;
};
const closeRefundModal = () => {
refundModalVisible.value = false;
targetRefundOrder.value = null;
};
const chooseRefundEvidence = () => {
const remainCount = 3 - refundForm.value.evidence.length;
if (remainCount <= 0) return;
uni.chooseImage({
count: remainCount,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
if (res.confirm) {
try {
await refundOrderApi(id);
} catch (_) {}
const o = orders.value.find(item => item.id === id);
if (o) o.status = 'REFUNDING';
uni.showToast({ title: '退款申请已提交,等待审核', icon: 'none' });
const paths = res.tempFilePaths || [];
if (paths.length === 0) return;
uni.showLoading({ title: '正在上传凭证...' });
try {
for (const p of paths) {
const onlineUrl = await uploadImage(p);
if (onlineUrl && refundForm.value.evidence.length < 3) {
refundForm.value.evidence.push(onlineUrl);
}
}
uni.hideLoading();
uni.showToast({ title: '凭证上传成功', icon: 'success' });
} catch (err) {
uni.hideLoading();
uni.showToast({ title: err.message || '上传异常', icon: 'none' });
}
}
});
};
const removeRefundEvidence = (idx) => {
refundForm.value.evidence.splice(idx, 1);
};
const previewEvidence = (url) => {
uni.previewImage({
urls: [url],
current: url
});
};
const submitRefundTicket = async () => {
if (!targetRefundOrder.value) return;
submittingRefund.value = true;
uni.showLoading({ title: '正在提交退款申诉...' });
try {
const oId = targetRefundOrder.value.id;
await refundOrderApi(oId, {
reason: refundForm.value.reason,
description: refundForm.value.description.trim(),
evidence: refundForm.value.evidence
});
const o = orders.value.find(item => item.id === oId);
if (o) o.status = 'REFUNDING';
uni.hideLoading();
closeRefundModal();
uni.showToast({ title: '退款申诉已受理,进入争议仲裁流程', icon: 'success' });
} catch (err) {
uni.hideLoading();
uni.showToast({ title: err.message || '提交失败,请重试', icon: 'none' });
} finally {
submittingRefund.value = false;
}
};
// 举报不良陪玩工单状态与交互
const reportModalVisible = ref(false);
const targetReportOrder = ref(null);
const submittingReport = ref(false);
const presetReportReasons = [
'涉黄低俗言论',
'私下诱导转账/欺诈',
'态度恶劣/人身攻击',
'开挂/作弊/战绩造假',
'线下违规/安全隐患',
'其他违规行为'
];
const reportForm = ref({
reason: '涉黄低俗言论',
description: '',
evidence: []
});
const openReportModal = (order) => {
targetReportOrder.value = order;
reportForm.value = {
reason: '涉黄低俗言论',
description: '',
evidence: []
};
reportModalVisible.value = true;
};
const closeReportModal = () => {
reportModalVisible.value = false;
targetReportOrder.value = null;
};
const chooseReportEvidence = () => {
const remainCount = 3 - reportForm.value.evidence.length;
if (remainCount <= 0) return;
uni.chooseImage({
count: remainCount,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const paths = res.tempFilePaths || [];
if (paths.length === 0) return;
uni.showLoading({ title: '正在上传违规截图...' });
try {
for (const p of paths) {
const onlineUrl = await uploadImage(p);
if (onlineUrl && reportForm.value.evidence.length < 3) {
reportForm.value.evidence.push(onlineUrl);
}
}
uni.hideLoading();
uni.showToast({ title: '截图上传成功', icon: 'success' });
} catch (err) {
uni.hideLoading();
uni.showToast({ title: err.message || '上传异常', icon: 'none' });
}
}
});
};
const removeReportEvidence = (idx) => {
reportForm.value.evidence.splice(idx, 1);
};
const submitReportTicket = async () => {
if (!targetReportOrder.value) return;
submittingReport.value = true;
uni.showLoading({ title: '正在提交举报...' });
try {
const order = targetReportOrder.value;
const expertId = order.expert ? order.expert.id : (order.expertId || 101);
const expertName = order.expert ? order.expert.nickname : (order.companionName || '陪玩大神');
await submitReportApi({
reporterId: 1001,
targetType: 'EXPERT',
targetId: String(expertId),
targetName: expertName,
reason: reportForm.value.reason,
description: reportForm.value.description.trim(),
evidence: reportForm.value.evidence
});
uni.hideLoading();
closeReportModal();
uni.showToast({ title: '举报已提交,平台将在24小时内核实处置', icon: 'success' });
} catch (err) {
uni.hideLoading();
uni.showToast({ title: err.message || '举报提交失败', icon: 'none' });
} finally {
submittingReport.value = false;
}
};
const confirmComplete = (id) => {
uni.showModal({
title: '确认验收',
@@ -511,6 +879,34 @@ const submitReview = async () => {
font-family: monospace;
}
.order-head-right {
display: flex;
align-items: center;
gap: 12rpx;
.countdown-badge {
display: flex;
align-items: center;
gap: 6rpx;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
padding: 2rpx 12rpx;
border-radius: 10rpx;
.clock-svg {
width: 22rpx;
height: 22rpx;
}
.countdown-txt {
font-size: 20rpx;
color: #F59E0B;
font-weight: 700;
font-family: monospace;
}
}
}
.status-pill {
font-size: 20rpx;
padding: 4rpx 14rpx;

查看文件

@@ -38,8 +38,8 @@
<image class="edit-svg" :src="ICONS.settings" mode="aspectFit" />
</view>
</view>
</view>
<text class="user-uid" @click.stop="handleAvatarOrNameClick">{{ isLoggedIn ? `UID: ${user.id}` : '登录后享受完整陪伴服务' }}</text>
<text class="user-signature-text" v-if="isLoggedIn && user.signature" @click.stop="handleAvatarOrNameClick">{{ user.signature }}</text>
</view>
</view>
@@ -81,7 +81,10 @@
<view class="asset-col">
<text class="asset-label">分销佣金收益 (元)</text>
<text class="asset-val gold-val">{{ isLoggedIn ? `¥${user.commission}` : '--' }}</text>
<button class="asset-btn withdraw btn-active" @click="handleWithdraw">提现</button>
<view class="asset-btn-group">
<button class="asset-btn withdraw btn-active" @click="openWithdrawModal">提现</button>
<button class="asset-btn records btn-active" @click="openWithdrawRecordsModal">记录</button>
</view>
</view>
</view>
</view>
@@ -222,6 +225,34 @@
placeholder-class="field-placeholder"
/>
</view>
<!-- 性别选择 (男 / 女 / 保密 胶囊单选) -->
<view class="edit-field">
<text class="field-label">用户性别</text>
<view class="gender-pill-group">
<view
v-for="g in ['男', '女', '保密']"
:key="g"
class="gender-pill btn-active"
:class="{ active: editForm.gender === g }"
@click="editForm.gender = g"
>
{{ g }}
</view>
</view>
</view>
<!-- 个性签名 -->
<view class="edit-field">
<text class="field-label">个性签名</text>
<input
class="field-input"
v-model="editForm.signature"
maxlength="60"
placeholder="分享您的常玩段位或搭子宣言(如:常驻朝阳,王者荣耀星耀)"
placeholder-class="field-placeholder"
/>
</view>
</view>
<view class="modal-footer">
@@ -231,6 +262,167 @@
</view>
</view>
<!-- 多渠道提现申请弹窗 -->
<view class="profile-edit-modal-mask" v-if="showWithdrawModal" @click="showWithdrawModal = false">
<view class="profile-edit-modal withdraw-modal-box" @click.stop>
<view class="modal-header">
<text class="modal-title">佣金收益提现申请</text>
<view class="btn-close btn-active" @click="showWithdrawModal = false">
<image class="close-svg" :src="ICONS.close" mode="aspectFit" />
</view>
</view>
<view class="modal-body">
<!-- 提现渠道切换 -->
<view class="channel-tabs-row">
<view
class="channel-pill btn-active"
:class="{ active: withdrawForm.channel === 'WECHAT' }"
@click="withdrawForm.channel = 'WECHAT'"
>
微信零钱
</view>
<view
class="channel-pill btn-active"
:class="{ active: withdrawForm.channel === 'ALIPAY' }"
@click="withdrawForm.channel = 'ALIPAY'"
>
支付宝
</view>
<view
class="channel-pill btn-active"
:class="{ active: withdrawForm.channel === 'BANK' }"
@click="withdrawForm.channel = 'BANK'"
>
银行卡
</view>
</view>
<!-- 提现金额输入 -->
<view class="edit-field">
<view class="field-title-row">
<text class="field-label">提现金额 (元)</text>
<text class="field-tip">可提现 ¥{{ user.commission || '0.00' }}</text>
</view>
<view class="amount-input-wrap">
<text class="currency-symbol">¥</text>
<input
class="field-input amount-val-input"
type="digit"
v-model="withdrawForm.amount"
:placeholder="`最低 ¥${minWithdrawAmount} 起提`"
placeholder-class="field-placeholder"
/>
<text class="btn-all-in btn-active" @click="withdrawForm.amount = String(user.commission || '0')">全部</text>
</view>
</view>
<!-- 渠道补充信息 -->
<view v-if="withdrawForm.channel === 'WECHAT'" class="channel-hint-card">
<text class="hint-text">资金将通过微信企业付款直达当前绑定微信号零钱钱包,预计 1-24 小时内到账。</text>
</view>
<view v-if="withdrawForm.channel === 'ALIPAY'">
<view class="edit-field">
<text class="field-label">支付宝账号</text>
<input
class="field-input"
v-model="withdrawForm.alipayAccount"
placeholder="请输入支付宝登录账号/手机号/邮箱"
placeholder-class="field-placeholder"
/>
</view>
<view class="edit-field">
<text class="field-label">收款人真实姓名</text>
<input
class="field-input"
v-model="withdrawForm.realName"
placeholder="需与支付宝实名认证姓名一致"
placeholder-class="field-placeholder"
/>
</view>
</view>
<view v-if="withdrawForm.channel === 'BANK'">
<view class="edit-field">
<text class="field-label">银行卡卡号</text>
<input
class="field-input"
type="number"
v-model="withdrawForm.bankCard"
placeholder="请输入储蓄卡卡号"
placeholder-class="field-placeholder"
/>
</view>
<view class="edit-field">
<text class="field-label">开户银行名称</text>
<input
class="field-input"
v-model="withdrawForm.bankName"
placeholder="例如:中国工商银行 / 招商银行"
placeholder-class="field-placeholder"
/>
</view>
<view class="edit-field">
<text class="field-label">持卡人姓名</text>
<input
class="field-input"
v-model="withdrawForm.realName"
placeholder="需与银行卡开户姓名一致"
placeholder-class="field-placeholder"
/>
</view>
</view>
</view>
<view class="modal-footer">
<button class="btn-modal btn-cancel btn-active" @click="showWithdrawModal = false">取消</button>
<button class="btn-modal btn-save btn-active" @click="submitWithdraw">确认提现</button>
</view>
</view>
</view>
<!-- 提现历史与审批记录跟踪弹窗 -->
<view class="profile-edit-modal-mask" v-if="showWithdrawRecordsModal" @click="showWithdrawRecordsModal = false">
<view class="profile-edit-modal withdraw-records-modal-box" @click.stop>
<view class="modal-header">
<text class="modal-title">提现审批记录</text>
<view class="btn-close btn-active" @click="showWithdrawRecordsModal = false">
<image class="close-svg" :src="ICONS.close" mode="aspectFit" />
</view>
</view>
<scroll-view scroll-y class="records-modal-scroll" v-if="userWithdrawalsList.length > 0">
<view v-for="rec in userWithdrawalsList" :key="rec.id" class="wd-card-row">
<view class="wd-row-top">
<text class="wd-no">{{ rec.withdrawNo }}</text>
<text
class="wd-badge"
:class="{
pending: rec.status === '待审核',
success: rec.status === '已打款',
rejected: rec.status === '已驳回'
}"
>
{{ rec.status }}
</text>
</view>
<view class="wd-row-center">
<text class="wd-val">¥{{ rec.amount }}</text>
<text class="wd-ch">{{ rec.channel }}</text>
</view>
<view class="wd-row-bot">
<text class="wd-time">{{ rec.applyTime }}</text>
<text class="wd-reason" v-if="rec.reason">驳回理由:{{ rec.reason }}</text>
</view>
</view>
</scroll-view>
<view class="empty-records-box" v-else>
<text class="empty-txt">暂无提现申请记录</text>
</view>
</view>
</view>
<!-- 底部自定义导航栏 (动态自适应发单人4项 / 接单人3项) -->
<AppTabBar current="user" />
@@ -248,7 +440,7 @@ import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, setTheme, THEME_KEYS, applyThemeToNativeUI } from '@/common/theme.js';
import { getCurrentRole, setCurrentRole, ROLE_TYPES, toggleRole as toggleUserRole, isExpertCertified, getExpertCertStatus, setExpertCertStatus, CERT_STATUS } from '@/common/role.js';
import { getNavBarInfo } from '@/common/nav.js';
import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert } from '@/common/api.js';
import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals } from '@/common/api.js';
const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200';
const isLoggedIn = ref(true);
@@ -291,7 +483,8 @@ const user = ref({
commission: '0.00',
orderCount: 0,
invitedCount: 0,
phone: '138****8888'
phone: '138****8888',
signature: '同城常驻朝阳 · 寻找靠谱搭子'
});
const currentRole = ref(getCurrentRole());
const currentThemeKey = ref(getCurrentThemeKey());
@@ -520,7 +713,9 @@ const PRESET_AVATARS = [
const editForm = ref({
nickname: '',
avatar: '',
phone: ''
phone: '',
gender: '保密',
signature: ''
});
const editProfile = () => {
@@ -528,7 +723,9 @@ const editProfile = () => {
editForm.value = {
nickname: user.value.nickname || '',
avatar: user.value.avatar || PRESET_AVATARS[0],
phone: user.value.phone || ''
phone: user.value.phone || '',
gender: user.value.gender || '保密',
signature: user.value.signature || ''
};
showEditModal.value = true;
};
@@ -549,13 +746,13 @@ const onWechatChooseAvatar = async (e) => {
uni.showLoading({ title: '正在同步微信头像...' });
try {
const onlineUrl = await uploadImage(avatarUrl);
editForm.value.avatar = onlineUrl || avatarUrl;
editForm.value.avatar = onlineUrl;
uni.hideLoading();
uni.showToast({ title: '微信头像获取成功', icon: 'success' });
} catch (err) {
uni.hideLoading();
editForm.value.avatar = avatarUrl;
uni.showToast({ title: '已选取微信头像', icon: 'none' });
// 上传失败时保留原头像,切勿将本地临时路径 http://tmp/... 赋值给 avatar,避免触发渲染层 CORS 拦截
uni.showToast({ title: err.message || '微信头像上传失败,请重试', icon: 'none' });
}
};
@@ -586,12 +783,24 @@ const saveProfile = async () => {
uni.showToast({ title: '昵称不能为空', icon: 'none' });
return;
}
const currentAvatar = editForm.value.avatar || '';
if (
currentAvatar.startsWith('http://tmp/') ||
currentAvatar.startsWith('http://usr/') ||
currentAvatar.startsWith('wxfile://') ||
currentAvatar.startsWith('blob:')
) {
uni.showToast({ title: '头像正在同步或上传异常,请重新选取', icon: 'none' });
return;
}
uni.showLoading({ title: '正在保存资料...' });
try {
const updated = await updateUserProfile(user.value.id || 1001, {
nickname: editForm.value.nickname.trim(),
avatar: editForm.value.avatar,
phone: editForm.value.phone
phone: editForm.value.phone,
gender: editForm.value.gender || '保密',
signature: editForm.value.signature ? editForm.value.signature.trim() : ''
});
uni.hideLoading();
if (updated) {
@@ -603,6 +812,8 @@ const saveProfile = async () => {
user.value.nickname = editForm.value.nickname.trim();
user.value.avatar = editForm.value.avatar;
user.value.phone = editForm.value.phone;
user.value.gender = editForm.value.gender || '保密';
user.value.signature = editForm.value.signature ? editForm.value.signature.trim() : '';
}
showEditModal.value = false;
uni.showToast({
@@ -706,48 +917,132 @@ const handleRecharge = async () => {
});
};
const handleWithdraw = () => {
const showWithdrawModal = ref(false);
const showWithdrawRecordsModal = ref(false);
const userWithdrawalsList = ref([]);
const withdrawForm = ref({
channel: 'WECHAT',
amount: '',
alipayAccount: '',
bankCard: '',
bankName: '',
realName: ''
});
const minWithdrawAmount = computed(() => {
const finRules = platformConfigs.value.finance_rules || {};
return Number(finRules.minWithdrawAmount) || 50;
});
const openWithdrawModal = () => {
if (!ensureLoggedIn()) return;
const comm = Number(user.value.commission || 0);
const finRules = platformConfigs.value.finance_rules || {};
const minWithdraw = Number(finRules.minWithdrawAmount) || 50;
const withdrawDesc = finRules.withdrawDesc || '提现将自动发起微信企业付款打款到微信零钱。';
if (comm <= 0) {
uni.showToast({ title: '暂无可用提现佣金', icon: 'none' });
uni.showToast({ title: '暂无可提现佣金', icon: 'none' });
return;
}
if (comm < minWithdraw) {
uni.showToast({ title: `提现金额须满 ¥${minWithdraw} 元起提`, icon: 'none' });
withdrawForm.value = {
channel: 'WECHAT',
amount: String(comm),
alipayAccount: '',
bankCard: '',
bankName: '',
realName: user.value.nickname || ''
};
showWithdrawModal.value = true;
};
const openWithdrawRecordsModal = async () => {
if (!ensureLoggedIn()) return;
try {
const list = await getUserWithdrawals(user.value.id || 1001);
userWithdrawalsList.value = Array.isArray(list) ? list : [];
} catch (_) {
userWithdrawalsList.value = [];
}
showWithdrawRecordsModal.value = true;
};
const submitWithdraw = async () => {
const comm = Number(user.value.commission || 0);
const amount = Number(withdrawForm.value.amount || 0);
const minLimit = minWithdrawAmount.value;
if (isNaN(amount) || amount <= 0) {
uni.showToast({ title: '请输入有效的提现金额', icon: 'none' });
return;
}
if (amount < minLimit) {
uni.showToast({ title: `提现金额须满 ¥${minLimit} 起提`, icon: 'none' });
return;
}
if (amount > comm) {
uni.showToast({ title: '提现金额超出可提余额', icon: 'none' });
return;
}
if (amount > 20000) {
uni.showToast({ title: '单笔提现最高 ¥20000', icon: 'none' });
return;
}
uni.showModal({
title: '佣金提现申请',
content: `当前可提现佣金为 ¥${user.value.commission} (最低满¥${minWithdraw}起提)。\n${withdrawDesc}`,
confirmText: '确认提现',
confirmColor: '#10B981',
success: async (res) => {
if (res.confirm) {
try {
const result = await withdrawCommission(user.value.id || 1001, comm);
if (result && result.commission !== undefined) {
user.value.commission = result.commission;
}
uni.showToast({
title: '提现申请已提交,等待到账',
icon: 'success',
duration: 2000
});
} catch (err) {
uni.showToast({
title: err.message || '提现失败,请稍后重试',
icon: 'none'
});
}
}
// 渠道账号校验
if (withdrawForm.value.channel === 'ALIPAY') {
if (!withdrawForm.value.alipayAccount.trim()) {
uni.showToast({ title: '请输入支付宝账号', icon: 'none' });
return;
}
});
if (!withdrawForm.value.realName.trim()) {
uni.showToast({ title: '请输入真实姓名', icon: 'none' });
return;
}
} else if (withdrawForm.value.channel === 'BANK') {
if (!withdrawForm.value.bankCard.trim()) {
uni.showToast({ title: '请输入银行卡号', icon: 'none' });
return;
}
if (!withdrawForm.value.bankName.trim()) {
uni.showToast({ title: '请输入开户行全称', icon: 'none' });
return;
}
if (!withdrawForm.value.realName.trim()) {
uni.showToast({ title: '请输入持卡人姓名', icon: 'none' });
return;
}
}
try {
uni.showLoading({ title: '正在提交申请...' });
const payload = {
channel: withdrawForm.value.channel,
alipayAccount: withdrawForm.value.alipayAccount.trim(),
bankCard: withdrawForm.value.bankCard.trim(),
bankName: withdrawForm.value.bankName.trim(),
realName: withdrawForm.value.realName.trim()
};
const result = await withdrawCommission(user.value.id || 1001, amount, payload);
uni.hideLoading();
if (result && result.commission !== undefined) {
user.value.commission = result.commission;
} else {
user.value.commission = Math.max(0, comm - amount).toFixed(2);
}
showWithdrawModal.value = false;
uni.showToast({
title: '提现申请已提交',
icon: 'success',
duration: 2000
});
} catch (err) {
uni.hideLoading();
uni.showToast({
title: err.message || '提现申请失败',
icon: 'none'
});
}
};
const handleWithdraw = () => {
openWithdrawModal();
};
const showInvitePoster = () => {
@@ -1204,9 +1499,15 @@ const navTo = (url) => {
}
}
.asset-btn-group {
display: flex;
align-items: center;
gap: 12rpx;
}
.asset-btn {
font-size: 20rpx;
padding: 0 24rpx;
padding: 0 20rpx;
height: 48rpx;
line-height: 46rpx;
border-radius: 24rpx;
@@ -1222,6 +1523,11 @@ const navTo = (url) => {
color: #F59E0B;
border: 1px solid rgba(245, 158, 11, 0.3);
}
&.records {
background: rgba(99, 102, 241, 0.15);
color: #818CF8;
border: 1px solid rgba(99, 102, 241, 0.3);
}
}
}
@@ -1829,6 +2135,241 @@ const navTo = (url) => {
}
}
}
.withdraw-modal-box {
max-width: 640rpx;
.channel-tabs-row {
display: flex;
background: #0F172A;
border-radius: 16rpx;
padding: 6rpx;
margin-bottom: 24rpx;
gap: 8rpx;
.channel-pill {
flex: 1;
text-align: center;
padding: 14rpx 0;
font-size: 24rpx;
font-weight: 600;
color: #94A3B8;
border-radius: 12rpx;
transition: all 0.2s ease;
&.active {
background: linear-gradient(135deg, #10B981, #059669);
color: #FFFFFF;
box-shadow: 0 4rpx 12rpx rgba(16, 185, 129, 0.35);
}
}
}
.field-title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
.field-tip {
font-size: 20rpx;
color: #94A3B8;
}
}
.amount-input-wrap {
display: flex;
align-items: center;
background: #0F172A;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16rpx;
padding: 0 20rpx;
height: 84rpx;
.currency-symbol {
font-size: 32rpx;
font-weight: 700;
color: #10B981;
margin-right: 12rpx;
}
.amount-val-input {
flex: 1;
font-size: 32rpx;
font-weight: 700;
color: #F8FAFC;
}
.btn-all-in {
font-size: 22rpx;
font-weight: 600;
color: #10B981;
padding: 8rpx 16rpx;
background: rgba(16, 185, 129, 0.12);
border-radius: 10rpx;
}
}
.channel-hint-card {
background: rgba(16, 185, 129, 0.08);
border: 1px dashed rgba(16, 185, 129, 0.3);
border-radius: 14rpx;
padding: 16rpx;
margin-top: 16rpx;
.hint-text {
font-size: 20rpx;
color: #34D399;
line-height: 1.5;
}
}
}
.withdraw-records-modal-box {
max-width: 660rpx;
max-height: 80vh;
.records-modal-scroll {
max-height: 560rpx;
padding-right: 8rpx;
.wd-card-row {
background: #0F172A;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16rpx;
padding: 18rpx 20rpx;
margin-bottom: 16rpx;
.wd-row-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10rpx;
.wd-no {
font-size: 20rpx;
color: #64748B;
font-family: monospace;
}
.wd-badge {
font-size: 20rpx;
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-weight: 600;
&.pending {
background: rgba(245, 158, 11, 0.15);
color: #F59E0B;
}
&.success {
background: rgba(16, 185, 129, 0.15);
color: #10B981;
}
&.rejected {
background: rgba(239, 68, 68, 0.15);
color: #EF4444;
}
}
}
.wd-row-center {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8rpx;
.wd-val {
font-size: 32rpx;
font-weight: 700;
color: #F8FAFC;
}
.wd-ch {
font-size: 22rpx;
color: #94A3B8;
}
}
.wd-row-bot {
display: flex;
justify-content: space-between;
align-items: center;
.wd-time {
font-size: 18rpx;
color: #64748B;
}
.wd-reason {
font-size: 18rpx;
color: #EF4444;
}
}
}
}
.empty-records-box {
padding: 60rpx 0;
text-align: center;
.empty-txt {
font-size: 24rpx;
color: #64748B;
}
}
}
.user-signature-text {
font-size: 22rpx;
color: #94A3B8;
margin-top: 8rpx;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 480rpx;
}
.gender-pill-group {
display: flex;
gap: 16rpx;
margin-top: 10rpx;
.gender-pill {
flex: 1;
text-align: center;
padding: 16rpx 0;
font-size: 26rpx;
color: #94A3B8;
background-color: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14rpx;
transition: all 0.2s ease;
&.active {
color: #FFFFFF;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.3) 0%, rgba(139, 92, 246, 0.4) 100%);
border-color: #6366F1;
font-weight: 600;
}
}
}
.theme-warm {
.user-signature-text {
color: #64748B;
}
.gender-pill {
background-color: #F1F5F9;
border-color: #E2E8F0;
color: #475569;
&.active {
color: #FFFFFF;
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
border-color: #F97316;
}
}
}
@keyframes glowSpin {

查看文件

@@ -419,6 +419,27 @@ export async function initDatabase(): Promise<void> {
try {
await conn.query('UPDATE users SET commission = 128.50, invited_count = 8 WHERE id = 1001 AND (commission IS NULL OR commission = 0)');
} catch (_) {}
// 扩展 users 表结构: 个性签名
try {
await conn.query("ALTER TABLE users ADD COLUMN signature VARCHAR(64) DEFAULT ''");
} catch (_) {}
// 21. 用户举报工单表 (涉黄/涉赌/线下欺诈等不良陪玩举报)
await conn.query(`
CREATE TABLE IF NOT EXISTS reports (
id VARCHAR(64) PRIMARY KEY,
reporter_id INT NOT NULL,
target_type VARCHAR(32) NOT NULL DEFAULT 'EXPERT',
target_id VARCHAR(64) NOT NULL,
target_name VARCHAR(64) DEFAULT '',
reason VARCHAR(64) NOT NULL,
description VARCHAR(500) DEFAULT '',
evidence JSON,
status VARCHAR(32) NOT NULL DEFAULT '待处理',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_target (target_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 检查并插入初始种子数据(若表为空)
await seedInitialDataIfEmpty(conn);

查看文件

@@ -815,6 +815,96 @@ export class MysqlRepository {
};
}
// 大神收益走势分析 (日/周/月多维图表数据)
async getExpertRevenueTrends(expertId: number | string): Promise<{
daily: { label: string; income: number; orders: number; avgPrice: number }[];
weekly: { label: string; income: number; orders: number; avgPrice: number }[];
monthly: { label: string; income: number; orders: number; avgPrice: number }[];
}> {
const eId = Number(expertId) || 101;
const now = new Date();
// 1. 近 7 天走势
const daily: { label: string; income: number; orders: number; avgPrice: number }[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(now.getTime() - i * 86400000);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const dateStr = `${d.getFullYear()}-${mm}-${dd}`;
const label = `${mm}-${dd}`;
try {
const [dayRows]: any = await pool.query(`
SELECT
COUNT(*) as orders,
COALESCE(SUM(amount), 0) as income
FROM orders
WHERE (expert_id = ? OR companion_name LIKE ?)
AND DATE(created_at) = ?
AND status IN ('已完成', '已到场核销', '服务中', '待确认')
`, [eId, '%我%', dateStr]);
const count = Number(dayRows[0]?.orders || 0);
const inc = Number(Number(dayRows[0]?.income || 0).toFixed(2));
const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0;
daily.push({ label, income: inc, orders: count, avgPrice: avg });
} catch (_) {
daily.push({ label, income: 0, orders: 0, avgPrice: 0 });
}
}
// 2. 近 4 周走势
const weekly: { label: string; income: number; orders: number; avgPrice: number }[] = [];
for (let w = 3; w >= 0; w--) {
const startD = new Date(now.getTime() - (w * 7 + 6) * 86400000);
const endD = new Date(now.getTime() - w * 7 * 86400000);
const label = w === 0 ? '本周' : `${w}周前`;
try {
const [wRows]: any = await pool.query(`
SELECT
COUNT(*) as orders,
COALESCE(SUM(amount), 0) as income
FROM orders
WHERE (expert_id = ? OR companion_name LIKE ?)
AND created_at >= ? AND created_at <= ?
AND status IN ('已完成', '已到场核销', '服务中', '待确认')
`, [eId, '%我%', startD, endD]);
const count = Number(wRows[0]?.orders || 0);
const inc = Number(Number(wRows[0]?.income || 0).toFixed(2));
const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0;
weekly.push({ label, income: inc, orders: count, avgPrice: avg });
} catch (_) {
weekly.push({ label, income: 0, orders: 0, avgPrice: 0 });
}
}
// 3. 近 6 个月走势
const monthly: { label: string; income: number; orders: number; avgPrice: number }[] = [];
for (let m = 5; m >= 0; m--) {
const d = new Date(now.getFullYear(), now.getMonth() - m, 1);
const label = `${d.getMonth() + 1}月`;
const yearMonth = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
try {
const [mRows]: any = await pool.query(`
SELECT
COUNT(*) as orders,
COALESCE(SUM(amount), 0) as income
FROM orders
WHERE (expert_id = ? OR companion_name LIKE ?)
AND DATE_FORMAT(created_at, '%Y-%m') = ?
AND status IN ('已完成', '已到场核销', '服务中', '待确认')
`, [eId, '%我%', yearMonth]);
const count = Number(mRows[0]?.orders || 0);
const inc = Number(Number(mRows[0]?.income || 0).toFixed(2));
const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0;
monthly.push({ label, income: inc, orders: count, avgPrice: avg });
} catch (_) {
monthly.push({ label, income: 0, orders: 0, avgPrice: 0 });
}
}
return { daily, weekly, monthly };
}
private formatBanner(banner: any): any {
if (!banner) return null;
let image = banner.image || '';
@@ -1509,6 +1599,85 @@ export class MysqlRepository {
return { id, status: statusText };
}
// 买家发起退款/售后申诉:订单流转为争议仲裁中,同时写入仲裁工单 (含理由与凭证截图)
async applyOrderRefund(orderId: string, data: { reason?: string; description?: string; evidence?: string[] }): Promise<any> {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [rows]: any = await conn.query('SELECT * FROM orders WHERE id = ? OR order_no = ? FOR UPDATE', [orderId, orderId]);
if (!rows || rows.length === 0) {
throw new Error('订单不存在');
}
const order = rows[0];
if (['已完成', '已取消', '已全额退款', '争议仲裁中'].includes(order.status)) {
throw new Error(`当前订单状态【${order.status}】不可发起退款申诉`);
}
await conn.query('UPDATE orders SET status = ? WHERE id = ?', ['争议仲裁中', order.id]);
const reason = (data.reason || '其他原因').slice(0, 60);
const detail = (data.description || '').slice(0, 180);
const evidence = Array.isArray(data.evidence) ? data.evidence.filter((u) => typeof u === 'string').slice(0, 6) : [];
const arbId = `arb_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
await conn.query(`
INSERT INTO arbitrations (id, order_id, order_no, client_name, companion_name, reason, user_evidence, status)
VALUES (?, ?, ?, ?, ?, ?, ?, '待平台仲裁')
`, [
arbId, order.id, order.order_no, order.client_name, order.companion_name,
detail ? `${reason}:${detail}` : reason,
JSON.stringify(evidence)
]);
await conn.commit();
const [updated]: any = await pool.query('SELECT * FROM orders WHERE id = ?', [order.id]);
return { ...(updated[0] || order), arbitrationId: arbId };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
// 用户举报不良陪玩 (涉黄/涉赌/线下欺诈等)
async createReport(data: {
reporterId: number;
targetType?: string;
targetId: string;
targetName?: string;
reason: string;
description?: string;
evidence?: string[];
}): Promise<any> {
const id = `rpt_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const evidence = Array.isArray(data.evidence) ? data.evidence.filter((u) => typeof u === 'string').slice(0, 6) : [];
await pool.query(`
INSERT INTO reports (id, reporter_id, target_type, target_id, target_name, reason, description, evidence, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '待处理')
`, [
id,
Number(data.reporterId) || 0,
data.targetType || 'EXPERT',
String(data.targetId),
(data.targetName || '').slice(0, 64),
data.reason.slice(0, 64),
(data.description || '').slice(0, 500),
JSON.stringify(evidence)
]);
return { id, status: '待处理' };
}
async getReports(): Promise<any[]> {
const [rows]: any = await pool.query(`
SELECT
id, reporter_id as reporterId, target_type as targetType, target_id as targetId,
target_name as targetName, reason, description, evidence, status, created_at as createdAt
FROM reports
ORDER BY created_at DESC
LIMIT 200
`);
return rows;
}
// 财务相关
async getFinanceRecords(): Promise<any[]> {
const [rows]: any = await pool.query(`
@@ -1806,7 +1975,7 @@ export class MysqlRepository {
async getUserById(id: number): Promise<any> {
const [rows]: any = await pool.query(`
SELECT
id, user_no as userNo, nickname, avatar, phone, gender,
id, user_no as userNo, nickname, avatar, phone, gender, signature,
role, balance, commission, invited_count as invitedCount,
order_count as orderCount, activity_count as activityCount,
status, wechat_openid as wechatOpenid, created_at as createdAt
@@ -1819,7 +1988,9 @@ export class MysqlRepository {
userNo: u.userNo,
nickname: u.nickname,
avatar: u.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200',
phone: u.phone || '',
gender: u.gender,
signature: u.signature || '',
role: u.role,
balance: Number(u.balance || 0).toFixed(2),
commission: Number(u.commission || 0).toFixed(2),
@@ -1831,7 +2002,10 @@ export class MysqlRepository {
};
}
async updateUserProfile(id: number, data: { nickname?: string; avatar?: string; phone?: string }): Promise<any> {
async updateUserProfile(
id: number,
data: { nickname?: string; avatar?: string; phone?: string; gender?: string; signature?: string }
): Promise<any> {
const fields: string[] = [];
const values: any[] = [];
if (data.nickname && data.nickname.trim()) {
@@ -1846,6 +2020,14 @@ export class MysqlRepository {
fields.push('phone = ?');
values.push(data.phone.trim());
}
if (data.gender && ['男', '女', '保密'].includes(data.gender)) {
fields.push('gender = ?');
values.push(data.gender);
}
if (typeof data.signature === 'string') {
fields.push('signature = ?');
values.push(data.signature.trim().slice(0, 64));
}
if (fields.length > 0) {
values.push(id);
await pool.query(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`, values);
@@ -1891,11 +2073,30 @@ export class MysqlRepository {
}
}
async withdrawUserCommission(id: number, amount: number, channel: string = '微信商户零钱'): Promise<any> {
async withdrawUserCommission(
id: number,
amount: number,
channel: string = '微信零钱',
accountInfo?: { account?: string; realName?: string; bankName?: string }
): Promise<any> {
if (isNaN(amount) || amount <= 0) {
throw new Error('提现金额必须大于0');
}
// 格式化渠道详情 (支持 WECHAT, ALIPAY, BANK 与中文多渠道)
let fullChannel = channel || '微信零钱';
if (channel === 'WECHAT') fullChannel = '微信零钱';
if (accountInfo?.account) {
if (channel === '支付宝' || channel === 'ALIPAY') {
fullChannel = `支付宝 (${accountInfo.account}${accountInfo.realName ? ' - ' + accountInfo.realName : ''})`;
} else if (channel === '银行卡' || channel === 'BANK') {
const bank = accountInfo.bankName || '银行卡';
const cardNo = accountInfo.account;
const masked = cardNo.length > 8 ? `${cardNo.slice(0, 4)}****${cardNo.slice(-4)}` : cardNo;
fullChannel = `${bank} (${masked}${accountInfo.realName ? ' - ' + accountInfo.realName : ''})`;
}
}
// 动态读取财务提现配置限制门槛
let minWithdraw = 50;
try {
@@ -1931,7 +2132,7 @@ export class MysqlRepository {
await conn.query(`
INSERT INTO withdrawals (id, withdraw_no, companion_name, companion_id, amount, balance, channel, status, apply_time)
VALUES (?, ?, ?, ?, ?, ?, ?, '待审核', NOW())
`, [wdId, wdNo, user.nickname, id, amount, remainingCommission, channel]);
`, [wdId, wdNo, user.nickname, id, amount, remainingCommission, fullChannel]);
// 写入财务出账流水
const recId = `fin_wd_${Date.now()}`;
@@ -1939,7 +2140,7 @@ export class MysqlRepository {
await conn.query(`
INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, '佣金提现', ?, 'OUT', ?, ?, NOW())
`, [recId, recNo, amount, `${user.nickname} (${channel})`, wdId]);
`, [recId, recNo, amount, `${user.nickname} (${fullChannel})`, wdId]);
await conn.commit();
return {
@@ -1948,7 +2149,7 @@ export class MysqlRepository {
amount: amount.toFixed(2),
commission: remainingCommission.toFixed(2),
status: '待审核',
channel,
channel: fullChannel,
};
} catch (err) {
await conn.rollback();
@@ -2236,6 +2437,96 @@ export class MysqlRepository {
message: '约伴上车申请已送达房主'
};
}
// 16. 用户个人提现记录查询
async getUserWithdrawals(userId: number): Promise<any[]> {
const [rows]: any = await pool.query(`
SELECT
id, withdraw_no as withdrawNo, companion_name as companionName,
companion_id as companionId, amount, balance, channel,
status, apply_time as applyTime, reason
FROM withdrawals
WHERE companion_id = ?
ORDER BY apply_time DESC
LIMIT 50
`, [Number(userId)]);
return rows;
}
// 17. 大神对评价感谢回复
async replyOrderReview(reviewId: string, replyContent: string): Promise<any> {
const now = new Date();
try {
await pool.query('ALTER TABLE order_reviews ADD COLUMN IF NOT EXISTS reply_content TEXT');
await pool.query('ALTER TABLE order_reviews ADD COLUMN IF NOT EXISTS reply_time DATETIME');
} catch (_) {}
await pool.query(`
UPDATE order_reviews
SET reply_content = ?, reply_time = NOW()
WHERE id = ?
`, [replyContent, reviewId]);
return {
id: reviewId,
replyContent,
replyTime: now.toISOString()
};
}
// 18. 友圈本人动态删除
async deletePost(postId: string, userId: number): Promise<boolean> {
const [result]: any = await pool.query(`
DELETE FROM posts WHERE id = ?
`, [postId]);
return result.affectedRows > 0;
}
// 19. 大神服务包管理
async getExpertServices(expertId: number): Promise<any[]> {
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS expert_services (
id VARCHAR(64) PRIMARY KEY,
expert_id INT NOT NULL,
title VARCHAR(64) NOT NULL,
category VARCHAR(32) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
unit VARCHAR(16) DEFAULT '小时',
mode VARCHAR(32) DEFAULT '排位/匹配',
status INT DEFAULT 1,
desc_text TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
const [rows]: any = await pool.query('SELECT * FROM expert_services WHERE expert_id = ? ORDER BY created_at ASC', [expertId]);
if (rows && rows.length > 0) {
return rows.map((r: any) => ({
id: r.id,
title: r.title,
category: r.category,
price: Number(r.price),
unit: r.unit || '小时',
mode: r.mode || '排位/匹配',
status: r.status === 1,
desc: r.desc_text || ''
}));
}
} catch (_) {}
return [
{ id: `srv_${expertId}_1`, title: '排位保星连胜局', category: '王者荣耀', price: 45, unit: '小时', mode: '排位赛', status: true, desc: '荣耀王者实力带飞,保星上分,麦克风清晰互动' },
{ id: `srv_${expertId}_2`, title: '娱乐匹配快乐唠嗑局', category: '英雄联盟手游', price: 30, unit: '小时', mode: '匹配/大乱斗', status: true, desc: '轻松搞笑,幽默风趣,情绪价值拉满' },
{ id: `srv_${expertId}_3`, title: '英雄技术单项教学指导', category: '和平精英', price: 55, unit: '小时', mode: '一对一复盘', status: true, desc: '对线细节、走位身法与团战意识深度剖析复盘' }
];
}
async updateExpertServiceStatus(serviceId: string, status: boolean): Promise<any> {
try {
await pool.query('UPDATE expert_services SET status = ? WHERE id = ?', [status ? 1 : 0, serviceId]);
} catch (_) {}
return { id: serviceId, status };
}
}
export const repo = new MysqlRepository();

查看文件

@@ -734,6 +734,16 @@ app.get('/api/expert/stats/:id', async (req: Request, res: Response) => {
}
});
// 大神日/周/月多维收益走势统计分析接口
app.get('/api/expert/stats/:id/trends', async (req: Request, res: Response) => {
try {
const trends = await repo.getExpertRevenueTrends(String(req.params.id));
res.json({ code: 20000, data: trends });
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
// 首页运营 Banner 列表 (小程序端读取启用状态)
app.get('/api/banners', async (_req: Request, res: Response) => {
@@ -1273,16 +1283,60 @@ app.post('/api/orders/:id/complete', async (req: Request, res: Response) => {
}
});
// 申请退款/售后
// 申请退款/售后 (写入仲裁工单:退款理由 + 详细说明 + 凭证截图)
app.post('/api/orders/:id/refund', async (req: Request, res: Response) => {
try {
const order = await repo.updateOrderStatus(String(req.params.id), '争议仲裁中');
const { reason, description, evidence } = req.body || {};
if (description) {
const sec = contentSecurityService.checkText(String(description));
if (!sec.pass) {
return res.status(400).json({ code: sec.code, message: sec.message });
}
}
const order = await repo.applyOrderRefund(String(req.params.id), { reason, description, evidence });
res.json({ code: 20000, data: order, message: '退款申诉已受理,进入争议仲裁流程' });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}
});
// 举报不良陪玩 (涉黄/涉赌/线下欺诈等,进入平台风控处置池)
app.post('/api/reports', async (req: Request, res: Response) => {
try {
const { reporterId, targetType, targetId, targetName, reason, description, evidence } = req.body || {};
if (!targetId || !reason) {
return res.status(400).json({ code: 40000, message: '举报对象与举报类型不能为空' });
}
if (description) {
const sec = contentSecurityService.checkText(String(description));
if (!sec.pass) {
return res.status(400).json({ code: sec.code, message: sec.message });
}
}
const report = await repo.createReport({
reporterId: Number(reporterId) || 0,
targetType,
targetId: String(targetId),
targetName,
reason: String(reason),
description,
evidence
});
res.json({ code: 20000, data: report, message: '举报已提交,平台将在 24 小时内核实处理' });
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
app.get('/api/reports', async (_req: Request, res: Response) => {
try {
const list = await repo.getReports();
res.json({ code: 20000, data: list });
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
app.get('/api/arbitrations', async (_req: Request, res: Response) => {
try {
const arbitrations = await repo.getArbitrations();
@@ -1520,12 +1574,19 @@ app.get('/api/users/:id', async (req: Request, res: Response) => {
}
});
// 7.2.1 个人资料修改 (真实修改 users 表 nickname, avatar, phone)
// 7.2.1 个人资料修改 (真实修改 users 表 nickname, avatar, phone, gender, signature)
app.post('/api/users/:id/profile', async (req: Request, res: Response) => {
try {
const userId = Number(req.params.id);
const { nickname, avatar, phone } = req.body;
const updated = await repo.updateUserProfile(userId, { nickname, avatar, phone });
const { nickname, avatar, phone, gender, signature } = req.body;
const textToCheck = `${nickname || ''} ${signature || ''}`.trim();
if (textToCheck) {
const sec = contentSecurityService.checkText(textToCheck);
if (!sec.pass) {
return res.status(400).json({ code: sec.code, message: sec.message });
}
}
const updated = await repo.updateUserProfile(userId, { nickname, avatar, phone, gender, signature });
res.json({
code: 20000,
data: updated,
@@ -1556,8 +1617,8 @@ app.post('/api/users/:id/recharge', async (req: Request, res: Response) => {
app.post('/api/users/:id/withdraw', async (req: Request, res: Response) => {
try {
const userId = Number(req.params.id);
const { amount, channel } = req.body;
const result = await repo.withdrawUserCommission(userId, Number(amount), channel);
const { amount, channel, account, realName, bankName } = req.body;
const result = await repo.withdrawUserCommission(userId, Number(amount), channel, { account, realName, bankName });
res.json({
code: 20000,
data: result,
@@ -1568,6 +1629,17 @@ app.post('/api/users/:id/withdraw', async (req: Request, res: Response) => {
}
});
// 用户提现记录列表接口 (状态跟踪:待审核、处理中、已打款、已驳回)
app.get('/api/users/:id/withdrawals', async (req: Request, res: Response) => {
try {
const userId = Number(req.params.id);
const list = await repo.getUserWithdrawals(userId);
res.json({ code: 20000, data: list });
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
// 7.2.4 用户个人财务流水列表
app.get('/api/users/:id/finance-records', async (req: Request, res: Response) => {
try {
@@ -1672,6 +1744,59 @@ app.get('/api/companions/:id/reviews', async (req: Request, res: Response) => {
}
});
// 大神感谢回复买家评价接口
app.post('/api/reviews/:id/reply', async (req: Request, res: Response) => {
try {
const reviewId = String(req.params.id);
const { replyContent } = req.body || {};
if (!replyContent || !replyContent.trim()) {
return res.status(400).json({ code: 40000, message: '回复内容不能为空' });
}
const secCheck = contentSecurityService.checkText(replyContent);
if (!secCheck.pass) {
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
}
const result = await repo.replyOrderReview(reviewId, replyContent.trim());
res.json({ code: 20000, data: result, message: '回复感谢老板成功' });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}
});
// 友圈本人动态删除接口
app.delete('/api/posts/:id', async (req: Request, res: Response) => {
try {
const postId = String(req.params.id);
const userId = Number(req.query.userId || req.body?.userId || 1001);
await repo.deletePost(postId, userId);
res.json({ code: 20000, message: '动态删除成功' });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}
});
// 大神服务包管理接口 (获取与上下架切换)
app.get('/api/companions/:id/services', async (req: Request, res: Response) => {
try {
const expertId = Number(req.params.id);
const services = await repo.getExpertServices(expertId);
res.json({ code: 20000, data: services });
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
app.put('/api/companions/:id/services/:serviceId/status', async (req: Request, res: Response) => {
try {
const serviceId = String(req.params.serviceId);
const { status } = req.body;
const result = await repo.updateExpertServiceStatus(serviceId, Boolean(status));
res.json({ code: 20000, data: result, message: '服务状态更新成功' });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}
});
// 7.6 友圈动态互动评论区接口
app.get('/api/posts/:id/comments', async (req: Request, res: Response) => {
try {

查看文件

@@ -578,6 +578,103 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
expect(emojiRegex.test(content)).toBe(false);
});
});
describe('14. 大神端经营与财务提现闭环规格验证 (2.2 陪玩端规格全套落地)', () => {
it('服务端路由必须提供收益多维走势统计接口且按日/周/月返回指标数据', () => {
const serverIndexPath = path.join(rootDir, 'server', 'src', 'index.ts');
const content = fs.readFileSync(serverIndexPath, 'utf-8');
expect(content).toContain("app.get('/api/expert/stats/:id/trends'");
expect(content).toContain('getExpertRevenueTrends');
const repoPath = path.join(rootDir, 'server', 'src', 'db', 'repository.ts');
const repoContent = fs.readFileSync(repoPath, 'utf-8');
expect(repoContent).toContain('getExpertRevenueTrends');
expect(repoContent).toContain('daily');
expect(repoContent).toContain('weekly');
expect(repoContent).toContain('monthly');
});
it('服务端必须支持微信零钱、支付宝、银行卡多渠道提现并记录完整审批流', () => {
const repoPath = path.join(rootDir, 'server', 'src', 'db', 'repository.ts');
const repoContent = fs.readFileSync(repoPath, 'utf-8');
expect(repoContent).toContain('withdrawUserCommission');
expect(repoContent).toContain('getUserWithdrawals');
expect(repoContent).toContain('WECHAT');
expect(repoContent).toContain('ALIPAY');
expect(repoContent).toContain('BANK');
const serverIndexPath = path.join(rootDir, 'server', 'src', 'index.ts');
const serverContent = fs.readFileSync(serverIndexPath, 'utf-8');
expect(serverContent).toContain("app.get('/api/users/:id/withdrawals'");
});
it('服务端必须提供评价感谢回复、动态删除、服务状态切换接口', () => {
const serverIndexPath = path.join(rootDir, 'server', 'src', 'index.ts');
const serverContent = fs.readFileSync(serverIndexPath, 'utf-8');
expect(serverContent).toContain("app.post('/api/reviews/:id/reply'");
expect(serverContent).toContain("app.delete('/api/posts/:id'");
expect(serverContent).toContain("app.put('/api/companions/:id/services/:serviceId/status'");
});
it('前端工作台 (workspace.vue) 必须集成收益走势图表、服务管理与提现审批看板', () => {
const wsPath = path.join(rootDir, 'pwuniapp', 'pages', 'expert', 'workspace.vue');
const wsContent = fs.readFileSync(wsPath, 'utf-8');
expect(wsContent).toContain('收益走势统计分析');
expect(wsContent).toContain('getExpertRevenueTrends');
expect(wsContent).toContain('trendPeriod');
expect(wsContent).toContain('trendMetric');
expect(wsContent).toContain('expertServices');
expect(wsContent).toContain('updateExpertServiceStatus');
expect(wsContent).toContain('showWithdrawRecordsModal');
});
it('前端用户中心 (user/index.vue) 必须支持多渠道提现与提现记录看板', () => {
const userPath = path.join(rootDir, 'pwuniapp', 'pages', 'user', 'index.vue');
const userContent = fs.readFileSync(userPath, 'utf-8');
expect(userContent).toContain('openWithdrawModal');
expect(userContent).toContain('openWithdrawRecordsModal');
expect(userContent).toContain('showWithdrawModal');
expect(userContent).toContain('showWithdrawRecordsModal');
expect(userContent).toContain('withdrawForm');
});
it('前端动态广场与大神主页必须分别提供本人动态删除与评价回复感谢老板', () => {
const commPath = path.join(rootDir, 'pwuniapp', 'pages', 'community', 'index.vue');
const commContent = fs.readFileSync(commPath, 'utf-8');
expect(commContent).toContain('deletePost');
expect(commContent).toContain('isPostOwner');
expect(commContent).toContain('handleDeletePost');
const detailPath = path.join(rootDir, 'pwuniapp', 'pages', 'expert', 'detail.vue');
const detailContent = fs.readFileSync(detailPath, 'utf-8');
expect(detailContent).toContain('replyOrderReview');
expect(detailContent).toContain('expert-reply-bubble');
expect(detailContent).toContain('handleReplyReview');
});
it('全量新增业务代码严禁出现 Emoji 与微信 showModal 超过 4 汉字违规', () => {
const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u;
const targetFiles = [
path.join(rootDir, 'pwuniapp', 'pages', 'expert', 'workspace.vue'),
path.join(rootDir, 'pwuniapp', 'pages', 'user', 'index.vue'),
path.join(rootDir, 'pwuniapp', 'pages', 'community', 'index.vue'),
path.join(rootDir, 'pwuniapp', 'pages', 'expert', 'detail.vue')
];
targetFiles.forEach((file) => {
const text = fs.readFileSync(file, 'utf-8');
expect(emojiRegex.test(text)).toBe(false);
// 检查 confirmText 与 cancelText 不得超过 4 个字符
const modalMatches = text.matchAll(/(?:confirmText|cancelText):\s*['"]([^'"]+)['"]/g);
for (const match of modalMatches) {
const btnText = match[1];
expect(btnText.length).toBeLessThanOrEqual(4);
}
});
});
});
});