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 {