fix(withdraw): resolve remote 404 route, db reason column patch and avatar tmp path fallback

这个提交包含在:
xcbyy
2026-10-13 09:33:29 +08:00
父节点 1fb0cc4273
当前提交 35a5a6670c
共有 8 个文件被更改,包括 2648 次插入 和 64 次删除

查看文件

@@ -900,4 +900,96 @@ export async function updateExpertServiceStatus(expertId = 101, serviceId, statu
}
}
/**
* 获取用户收藏的陪玩列表
*/
export async function getFavorites(userId = 1001) {
try {
const res = await http.get(`/favorites?userId=${userId}`);
return res || [];
} catch (err) {
console.warn('[API] 获取收藏列表异常:', err.message);
return [];
}
}
/**
* 探测是否已收藏特定陪玩
*/
export async function checkFavorite(expertId, userId = 1001) {
try {
const res = await http.get(`/favorites/check?userId=${userId}&expertId=${expertId}`);
return res && res.isFavorite;
} catch (err) {
console.warn('[API] 探测收藏状态异常:', err.message);
return false;
}
}
/**
* 添加收藏
*/
export async function addFavorite(expertId, userId = 1001) {
try {
const res = await http.post('/favorites', { userId, expertId });
return res;
} catch (err) {
console.warn('[API] 添加收藏异常:', err.message);
throw err;
}
}
/**
* 取消收藏
*/
export async function removeFavorite(expertId, userId = 1001) {
try {
const res = await http.delete(`/favorites/${expertId}?userId=${userId}`);
return res;
} catch (err) {
console.warn('[API] 取消收藏异常:', err.message);
throw err;
}
}
/**
* 获取分销团队列表 (一级与二级成员)
*/
export async function getDistributionTeam(userId = 1001) {
try {
const res = await http.get(`/distribution/team?userId=${userId}`);
return res || { directCount: 0, indirectCount: 0, totalTeamCount: 0, directList: [], indirectList: [] };
} catch (err) {
console.warn('[API] 获取分销团队异常:', err.message);
return { directCount: 0, indirectCount: 0, totalTeamCount: 0, directList: [], indirectList: [] };
}
}
/**
* 获取分销返佣订单记录
*/
export async function getDistributionRecords(userId = 1001) {
try {
const res = await http.get(`/distribution/records?userId=${userId}`);
return res || [];
} catch (err) {
console.warn('[API] 获取分销返佣记录异常:', err.message);
return [];
}
}
/**
* 佣金提现申请
*/
export async function withdrawCommission(amount, channel = 'WECHAT', userId = 1001) {
try {
const res = await http.post(`/users/${userId}/withdraw`, { amount, channel });
return res;
} catch (err) {
console.warn('[API] 佣金提现申请异常:', err.message);
throw err;
}
}

查看文件

@@ -168,6 +168,10 @@
<image class="action-svg" :src="ICONS.chat" mode="aspectFit" />
<text class="action-text">私聊</text>
</view>
<view class="icon-action btn-active" @click="toggleFavorite">
<image class="action-svg" :src="isFav ? ICONS.starFilled : ICONS.starOutline" mode="aspectFit" />
<text class="action-text" :class="{ 'fav-active': isFav }">{{ isFav ? '已收藏' : '收藏' }}</text>
</view>
<view class="icon-action btn-active" @click="openReportModal">
<image class="action-svg" :src="ICONS.shield" mode="aspectFit" />
<text class="action-text">举报</text>
@@ -239,10 +243,11 @@
import { ref, computed } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import SkeletonScreen from '@/components/SkeletonScreen.vue';
import { getCompanionDetail, getExpertReviews, replyOrderReview, submitReport as submitReportApi } from '@/common/api.js';
import { getCompanionDetail, getExpertReviews, replyOrderReview, submitReport as submitReportApi, checkFavorite, addFavorite as addFavApi, removeFavorite as removeFavApi } from '@/common/api.js';
import { ICONS } from '@/common/icons.js';
const isLoading = ref(true);
const isFav = ref(false);
const expert = ref({
id: '',
nickname: '同城陪伴师',
@@ -264,6 +269,23 @@ const expert = ref({
const selectedServiceId = ref(null);
const isPlayingVoice = ref(false);
const toggleFavorite = async () => {
try {
const targetId = Number(expert.value.id || 101);
if (isFav.value) {
await removeFavApi(targetId);
isFav.value = false;
uni.showToast({ title: '已取消收藏', icon: 'none' });
} else {
await addFavApi(targetId);
isFav.value = true;
uni.showToast({ title: '已收藏该陪玩', icon: 'success' });
}
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' });
}
};
const handleReplyReview = (rev) => {
const defaultReplies = [
'感谢老板的支持与五星好评!开黑配合非常默契,期待下次一起上大分!',
@@ -302,6 +324,10 @@ onLoad(async (query) => {
}
}
try {
isFav.value = await checkFavorite(targetId);
} catch (_) {}
try {
const liveReviews = await getExpertReviews(targetId);
if (Array.isArray(liveReviews) && liveReviews.length > 0) {
@@ -945,6 +971,11 @@ const submitReportTicket = async () => {
font-size: 18rpx;
color: #94A3B8;
margin-top: 4rpx;
&.fav-active {
color: #F59E0B;
font-weight: 600;
}
}
}

查看文件

@@ -1,10 +1,31 @@
<template>
<view class="index-container" :class="{ 'theme-warm': currentThemeKey === 'warm' }" :style="navStyle">
<!-- 顶部搜索栏 (矢量SVG搜索图标,交互聚焦微动效) -->
<!-- 顶部搜索栏 (带双向绑定、一键清空、热搜关键词胶囊) -->
<view class="header-search">
<view class="search-box">
<image class="search-icon-img" :src="ICONS.search" mode="aspectFit" />
<input class="search-input" placeholder="搜索同城陪玩、美食、KTV嗨唱" placeholder-class="placeholder-text" />
<input
class="search-input"
v-model="searchKeyword"
placeholder="搜索大神ID、昵称、游戏或生活陪伴"
placeholder-class="placeholder-text"
confirm-type="search"
/>
<view class="search-clear-btn btn-active" v-if="searchKeyword" @click="searchKeyword = ''">
<image class="clear-svg" :src="ICONS.close" mode="aspectFit" />
</view>
</view>
<!-- 热搜关键词推荐栏 -->
<view class="hot-search-tags" v-if="!searchKeyword">
<text class="hot-label">热搜:</text>
<view
v-for="word in hotSearchWords"
:key="word"
class="hot-tag btn-active"
@click="searchKeyword = word"
>
{{ word }}
</view>
</view>
</view>
@@ -45,15 +66,51 @@
</view>
</view>
<!-- 陪玩大神瀑布流标题 -->
<!-- 陪玩大神瀑布流标题 & 即时速配 -->
<view class="section-title-wrap">
<view class="section-title">
<view class="title-line"></view>
<text>精选在线大神</text>
</view>
<view class="section-more">
<view class="section-more btn-active" @click="startInstantMatch">
<view class="pulse-point"></view>
<text>实时在线</text>
<text class="instant-match-txt">雷达即时速配</text>
</view>
</view>
<!-- 多维度复合筛选工具栏 -->
<view class="filter-toolbar">
<view
class="filter-pill btn-active"
:class="{ active: filterOnlyOnline }"
@click="filterOnlyOnline = !filterOnlyOnline"
>
<view class="filter-dot"></view>
<text>仅看在线</text>
</view>
<view
class="filter-pill btn-active"
:class="{ active: filterGender !== 'all' }"
@click="toggleGenderFilter"
>
<text>{{ genderFilterLabel }}</text>
</view>
<view
class="filter-pill btn-active"
:class="{ active: filterSort !== 'default' }"
@click="toggleSortFilter"
>
<text>{{ sortFilterLabel }}</text>
</view>
<view
class="filter-pill btn-active"
:class="{ active: filterPriceRange !== 'all' }"
@click="showPriceModal = true"
>
<text>{{ priceFilterLabel }}</text>
</view>
</view>
@@ -113,6 +170,14 @@
</view>
</view>
</view>
<!-- 搜索/筛选无结果空状态 -->
<view class="empty-search-box" v-if="filteredExperts.length === 0">
<image class="empty-search-icon" :src="ICONS.search" mode="aspectFit" />
<text class="empty-title">未匹配到符合条件的大神</text>
<text class="empty-desc">尝试更换关键词或重置筛选条件</text>
<button class="btn-reset-filters btn-active" @click="resetAllFilters">重置全部筛选</button>
</view>
</view>
<!-- 底部自定义导航栏 (发单人端:首页 / 活动中心 / 我要发单 / 友圈 / 个人中心) -->
@@ -123,6 +188,80 @@
<!-- 首页运营全局活动营销弹窗 (配置化可控) -->
<home-popup-modal />
<!-- 价格区间筛选弹窗 -->
<view class="price-modal-mask" v-if="showPriceModal" @click="showPriceModal = false">
<view class="price-modal-box" @click.stop>
<view class="modal-title-row">
<text class="modal-title">时薪价格区间筛选</text>
<view class="btn-close btn-active" @click="showPriceModal = false">
<image class="close-svg" :src="ICONS.close" mode="aspectFit" />
</view>
</view>
<view class="price-options-grid">
<view
v-for="opt in priceOptions"
:key="opt.val"
class="price-opt-btn btn-active"
:class="{ active: filterPriceRange === opt.val }"
@click="selectPriceOption(opt.val)"
>
{{ opt.label }}
</view>
</view>
</view>
</view>
<!-- 雷达即时速配在线大神弹窗 -->
<view class="price-modal-mask" v-if="showMatchModal" @click="closeMatchModal">
<view class="price-modal-box match-modal-box" @click.stop>
<view class="modal-title-row">
<text class="modal-title">雷达即时派单匹配</text>
<view class="btn-close btn-active" @click="closeMatchModal">
<image class="close-svg" :src="ICONS.close" mode="aspectFit" />
</view>
</view>
<view class="match-content-body">
<!-- 雷达扫描态 -->
<view class="radar-scan-box" v-if="isMatching">
<view class="radar-wave wave-1"></view>
<view class="radar-wave wave-2"></view>
<view class="radar-wave wave-3"></view>
<view class="radar-center-icon">
<image class="center-svg" :src="ICONS.flame" mode="aspectFit" />
</view>
<text class="radar-status-text">正在全网扫描空闲在线大神...</text>
</view>
<!-- 匹配命中结果展示 -->
<view class="match-result-box" v-else-if="matchedExpert">
<view class="match-success-badge">首位接单大神命中</view>
<view class="matched-expert-card">
<image class="matched-avatar" :src="matchedExpert.avatar" mode="aspectFill" />
<view class="matched-info">
<view class="matched-name-row">
<text class="matched-name">{{ matchedExpert.nickname }}</text>
<text class="matched-online-tag">接单中</text>
</view>
<text class="matched-game">{{ matchedExpert.game }} · {{ matchedExpert.rankTag }}</text>
<text class="matched-price">¥{{ matchedExpert.price }}/小时</text>
</view>
</view>
<view class="match-actions-row">
<button class="btn-match-action secondary btn-active" @click="reMatch">重新速配</button>
<button class="btn-match-action primary btn-active" @click="confirmMatchedOrder">立即预约该大神</button>
</view>
</view>
<!-- 无在线大神匹配 -->
<view class="match-empty-box" v-else>
<text class="empty-txt">当前暂无空闲在线大神,请稍后再试</text>
<button class="btn-match-action primary btn-active" @click="closeMatchModal">返回大厅</button>
</view>
</view>
</view>
</view>
</view>
</template>
@@ -147,6 +286,48 @@ const playingId = ref(null);
const currentThemeKey = ref(getCurrentThemeKey());
const navInfo = ref(getNavBarInfo());
// 搜索与热搜
const searchKeyword = ref('');
const hotSearchWords = ['王者荣耀', '和平精英', '甜美陪伴', '同城微醺', 'KTV嗨唱'];
// 多维复合筛选与排序
const filterOnlyOnline = ref(false);
const filterGender = ref('all'); // all | female | male
const filterSort = ref('default'); // default | price_asc | rating_desc | orders_desc
const filterPriceRange = ref('all'); // all | under_50 | 50_80 | 80_120 | above_120
const showPriceModal = ref(false);
const priceOptions = [
{ val: 'all', label: '全部时薪' },
{ val: 'under_50', label: '¥50 以下/时' },
{ val: '50_80', label: '¥50 - 80/时' },
{ val: '80_120', label: '¥80 - 120/时' },
{ val: 'above_120', label: '¥120 以上/时' }
];
const genderFilterLabel = computed(() => {
if (filterGender.value === 'female') return '仅女生';
if (filterGender.value === 'male') return '仅男生';
return '性别不限';
});
const sortFilterLabel = computed(() => {
if (filterSort.value === 'price_asc') return '价格最低';
if (filterSort.value === 'rating_desc') return '评分最高';
if (filterSort.value === 'orders_desc') return '单量最多';
return '综合推荐';
});
const priceFilterLabel = computed(() => {
const found = priceOptions.find(o => o.val === filterPriceRange.value);
return found ? found.label : '时薪价格';
});
// 雷达即时速配状态
const showMatchModal = ref(false);
const isMatching = ref(false);
const matchedExpert = ref(null);
const navStyle = computed(() => {
return {
'--safe-header-gap': `${navInfo.value.safeHeaderGap}px`,
@@ -246,21 +427,148 @@ onUnmounted(() => {
});
const filteredExperts = computed(() => {
if (currentCategory.value === 'all') return experts.value;
const gameMap = {
offline_meal: '美食陪吃',
offline_drink: '下午茶陪喝',
offline_sing: '同城陪唱',
offline_play: '同城陪玩',
wzry: '王者荣耀',
hpjy: '和平精英',
lol: '英雄联盟',
val: '无畏契约'
};
const target = gameMap[currentCategory.value];
return target ? experts.value.filter(e => e.game === target) : experts.value;
let list = experts.value;
// 1. 关键词搜索 (ID、昵称、擅长游戏、城市、段位)
if (searchKeyword.value.trim()) {
const kw = searchKeyword.value.trim().toLowerCase();
list = list.filter(e => {
const matchId = String(e.id).toLowerCase().includes(kw);
const matchName = (e.nickname || '').toLowerCase().includes(kw);
const matchGame = (e.game || '').toLowerCase().includes(kw);
const matchCity = (e.city || '').toLowerCase().includes(kw);
const matchRank = (e.rankTag || '').toLowerCase().includes(kw);
return matchId || matchName || matchGame || matchCity || matchRank;
});
}
// 2. 金刚区分类过滤
if (currentCategory.value !== 'all') {
const gameMap = {
offline_meal: '美食陪吃',
offline_drink: '下午茶陪喝',
offline_sing: '同城陪唱',
offline_play: '同城陪玩',
wzry: '王者荣耀',
hpjy: '和平精英',
lol: '英雄联盟',
val: '无畏契约'
};
const target = gameMap[currentCategory.value];
if (target) {
list = list.filter(e => e.game === target);
}
}
// 3. 仅看当前在线
if (filterOnlyOnline.value) {
list = list.filter(e => e.isOnline);
}
// 4. 性别筛选 (female: 女生, male: 男生)
if (filterGender.value !== 'all') {
list = list.filter(e => e.gender === filterGender.value);
}
// 5. 价格区间过滤
if (filterPriceRange.value === 'under_50') {
list = list.filter(e => Number(e.price) < 50);
} else if (filterPriceRange.value === '50_80') {
list = list.filter(e => Number(e.price) >= 50 && Number(e.price) <= 80);
} else if (filterPriceRange.value === '80_120') {
list = list.filter(e => Number(e.price) > 80 && Number(e.price) <= 120);
} else if (filterPriceRange.value === 'above_120') {
list = list.filter(e => Number(e.price) > 120);
}
// 6. 排序逻辑
if (filterSort.value === 'price_asc') {
list = [...list].sort((a, b) => Number(a.price) - Number(b.price));
} else if (filterSort.value === 'rating_desc') {
list = [...list].sort((a, b) => Number(b.rating || 5) - Number(a.rating || 5));
} else if (filterSort.value === 'orders_desc') {
list = [...list].sort((a, b) => Number(b.orderCount || 0) - Number(a.orderCount || 0));
}
return list;
});
// 筛选切换
const toggleGenderFilter = () => {
if (filterGender.value === 'all') {
filterGender.value = 'female';
} else if (filterGender.value === 'female') {
filterGender.value = 'male';
} else {
filterGender.value = 'all';
}
};
const toggleSortFilter = () => {
if (filterSort.value === 'default') {
filterSort.value = 'price_asc';
} else if (filterSort.value === 'price_asc') {
filterSort.value = 'rating_desc';
} else if (filterSort.value === 'rating_desc') {
filterSort.value = 'orders_desc';
} else {
filterSort.value = 'default';
}
};
const selectPriceOption = (val) => {
filterPriceRange.value = val;
showPriceModal.value = false;
};
const resetAllFilters = () => {
searchKeyword.value = '';
currentCategory.value = 'all';
filterOnlyOnline.value = false;
filterGender.value = 'all';
filterSort.value = 'default';
filterPriceRange.value = 'all';
};
// 雷达即时速配逻辑
let matchTimer = null;
const startInstantMatch = () => {
showMatchModal.value = true;
isMatching.value = true;
matchedExpert.value = null;
if (matchTimer) clearTimeout(matchTimer);
matchTimer = setTimeout(() => {
isMatching.value = false;
// 优先匹配当前列表中在线的大神,若无则取首位
const onlineList = experts.value.filter(e => e.isOnline);
if (onlineList.length > 0) {
matchedExpert.value = onlineList[Math.floor(Math.random() * onlineList.length)];
} else if (experts.value.length > 0) {
matchedExpert.value = experts.value[0];
} else {
matchedExpert.value = null;
}
}, 1200);
};
const closeMatchModal = () => {
showMatchModal.value = false;
if (matchTimer) clearTimeout(matchTimer);
};
const reMatch = () => {
startInstantMatch();
};
const confirmMatchedOrder = () => {
if (matchedExpert.value) {
const id = matchedExpert.value.id;
closeMatchModal();
goToExpert(id);
}
};
const toggleVoice = (id) => {
if (playingId.value === id) {
playingId.value = null;
@@ -992,4 +1300,432 @@ const navTo = (url) => {
0%, 100% { transform: scaleY(0.4); }
50% { transform: scaleY(1); }
}
.search-clear-btn {
padding: 8rpx;
display: flex;
align-items: center;
justify-content: center;
.clear-svg {
width: 28rpx;
height: 28rpx;
}
}
.hot-search-tags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 14rpx;
.hot-label {
font-size: 20rpx;
color: #64748B;
}
.hot-tag {
font-size: 20rpx;
color: #94A3B8;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 4rpx 14rpx;
border-radius: 20rpx;
transition: all 0.2s;
}
}
.instant-match-txt {
font-size: 22rpx;
font-weight: 700;
color: #10B981;
text-decoration: underline;
}
.filter-toolbar {
display: flex;
gap: 12rpx;
padding: 0 32rpx;
margin-bottom: 24rpx;
overflow-x: auto;
white-space: nowrap;
.filter-pill {
display: inline-flex;
align-items: center;
gap: 8rpx;
font-size: 22rpx;
color: #94A3B8;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 10rpx 20rpx;
border-radius: 28rpx;
transition: all 0.2s;
.filter-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #64748B;
}
&.active {
color: #FFFFFF;
background: rgba(99, 102, 241, 0.25);
border-color: #6366F1;
font-weight: 600;
.filter-dot {
background: #10B981;
box-shadow: 0 0 8rpx #10B981;
}
}
}
}
.empty-search-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 32rpx;
text-align: center;
.empty-search-icon {
width: 80rpx;
height: 80rpx;
opacity: 0.3;
margin-bottom: 20rpx;
}
.empty-title {
font-size: 28rpx;
font-weight: 600;
color: #E2E8F0;
margin-bottom: 8rpx;
}
.empty-desc {
font-size: 22rpx;
color: #64748B;
margin-bottom: 32rpx;
}
.btn-reset-filters {
font-size: 24rpx;
color: #6366F1;
background: rgba(99, 102, 241, 0.12);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 30rpx;
height: 64rpx;
line-height: 62rpx;
padding: 0 36rpx;
margin: 0;
}
}
.price-modal-mask {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
box-sizing: border-box;
}
.price-modal-box {
width: 100%;
max-width: 560rpx;
background: #151D30;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 28rpx;
padding: 32rpx;
box-sizing: border-box;
.modal-title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
.modal-title {
font-size: 30rpx;
font-weight: 700;
color: #F8FAFC;
}
.btn-close {
width: 44rpx;
height: 44rpx;
display: flex;
align-items: center;
justify-content: center;
.close-svg {
width: 32rpx;
height: 32rpx;
}
}
}
.price-options-grid {
display: flex;
flex-direction: column;
gap: 12rpx;
.price-opt-btn {
text-align: center;
padding: 18rpx 0;
font-size: 24rpx;
color: #94A3B8;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14rpx;
transition: all 0.2s;
&.active {
color: #FFFFFF;
background: rgba(99, 102, 241, 0.3);
border-color: #6366F1;
font-weight: 700;
}
}
}
}
.match-modal-box {
max-width: 600rpx;
.match-content-body {
padding: 20rpx 0 10rpx;
}
.radar-scan-box {
position: relative;
height: 320rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.radar-wave {
position: absolute;
width: 120rpx;
height: 120rpx;
border-radius: 50%;
border: 2px solid #6366F1;
animation: radarRipples 2s infinite cubic-bezier(0.1, 0.2, 0.3, 1);
&.wave-2 {
animation-delay: 0.6s;
}
&.wave-3 {
animation-delay: 1.2s;
}
}
.radar-center-icon {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
background: linear-gradient(135deg, #6366F1, #8B5CF6);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
.center-svg {
width: 40rpx;
height: 40rpx;
}
}
.radar-status-text {
position: absolute;
bottom: 10rpx;
font-size: 22rpx;
color: #94A3B8;
}
}
.match-result-box {
display: flex;
flex-direction: column;
align-items: center;
.match-success-badge {
font-size: 20rpx;
color: #10B981;
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.3);
padding: 4rpx 16rpx;
border-radius: 16rpx;
font-weight: 600;
margin-bottom: 20rpx;
}
.matched-expert-card {
width: 100%;
display: flex;
gap: 16rpx;
align-items: center;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16rpx;
padding: 16rpx;
box-sizing: border-box;
margin-bottom: 24rpx;
.matched-avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50rpx;
border: 2rpx solid #10B981;
}
.matched-info {
flex: 1;
.matched-name-row {
display: flex;
align-items: center;
gap: 10rpx;
margin-bottom: 4rpx;
.matched-name {
font-size: 28rpx;
font-weight: 700;
color: #F8FAFC;
}
.matched-online-tag {
font-size: 16rpx;
color: #FFFFFF;
background: #059669;
padding: 2rpx 8rpx;
border-radius: 6rpx;
}
}
.matched-game {
font-size: 20rpx;
color: #94A3B8;
display: block;
margin-bottom: 4rpx;
}
.matched-price {
font-size: 26rpx;
font-weight: 700;
color: #10B981;
}
}
}
.match-actions-row {
width: 100%;
display: flex;
gap: 16rpx;
.btn-match-action {
flex: 1;
height: 72rpx;
line-height: 70rpx;
font-size: 24rpx;
font-weight: 600;
border-radius: 36rpx;
margin: 0;
&.secondary {
background: rgba(255, 255, 255, 0.08);
color: #94A3B8;
}
&.primary {
background: linear-gradient(135deg, #10B981, #059669);
color: #FFFFFF;
}
}
}
}
.match-empty-box {
padding: 40rpx 0;
text-align: center;
.empty-txt {
font-size: 24rpx;
color: #94A3B8;
display: block;
margin-bottom: 24rpx;
}
.btn-match-action {
height: 70rpx;
line-height: 68rpx;
font-size: 24rpx;
font-weight: 600;
border-radius: 35rpx;
background: linear-gradient(135deg, #6366F1, #4F46E5);
color: #FFFFFF;
}
}
}
@keyframes radarRipples {
0% {
transform: scale(0.6);
opacity: 0.9;
}
100% {
transform: scale(2.2);
opacity: 0;
}
}
// 暖色模式补充
.theme-warm {
.hot-search-tags .hot-tag {
background: #F5F2EC;
border-color: #E7E5E4;
color: #78716C;
}
.filter-toolbar .filter-pill {
background: #F5F2EC;
border-color: #E7E5E4;
color: #78716C;
&.active {
background: #FFF7ED;
border-color: #EA580C;
color: #EA580C;
}
}
.price-modal-box {
background: #FFFFFF;
border-color: #E7E5E4;
.modal-title-row .modal-title {
color: #1C1917;
}
.price-options-grid .price-opt-btn {
background: #FAFAF9;
border-color: #E7E5E4;
color: #78716C;
&.active {
background: #FFF7ED;
border-color: #EA580C;
color: #EA580C;
}
}
}
}
</style>

文件差异内容过多而无法显示 加载差异

查看文件

@@ -181,6 +181,10 @@ export async function initDatabase(): Promise<void> {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
try {
await conn.query('ALTER TABLE withdrawals ADD COLUMN reason VARCHAR(255) DEFAULT ""');
} catch (_) {}
// 8. 线下合规场所库
await conn.query(`
CREATE TABLE IF NOT EXISTS safety_places (
@@ -441,6 +445,40 @@ export async function initDatabase(): Promise<void> {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 22. 陪玩收藏表 (收藏喜欢的陪玩,实时高亮在接单状态)
await conn.query(`
CREATE TABLE IF NOT EXISTS favorites (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
expert_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_expert (user_id, expert_id),
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 23. 分销体系扩展 (下级团队与佣金订单记录)
try {
await conn.query('ALTER TABLE users ADD COLUMN inviter_id INT DEFAULT NULL');
} catch (_) {}
await conn.query(`
CREATE TABLE IF NOT EXISTS distribution_records (
id VARCHAR(64) PRIMARY KEY,
user_id INT NOT NULL,
from_user_id INT NOT NULL,
from_user_name VARCHAR(64) NOT NULL,
order_no VARCHAR(64) NOT NULL,
order_amount DECIMAL(10,2) NOT NULL,
commission DECIMAL(10,2) NOT NULL,
level INT NOT NULL DEFAULT 1,
rate_percent DECIMAL(5,2) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT '已入账',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_dist_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 检查并插入初始种子数据(若表为空)
await seedInitialDataIfEmpty(conn);

查看文件

@@ -2527,7 +2527,177 @@ export class MysqlRepository {
} catch (_) {}
return { id: serviceId, status };
}
// 20. 收藏陪玩管理 (收藏喜欢的陪玩,实时高亮在接单状态)
async getFavorites(userId: number): Promise<any[]> {
try {
const [rows]: any = await pool.query(`
SELECT
f.id as favoriteId,
f.created_at as favoritedAt,
c.id,
c.name as nickname,
c.avatar,
c.gender,
c.age,
c.track,
c.rank_name as rankName,
c.hourly_rate as hourlyRate,
c.rating,
c.order_count as orderCount,
c.status,
c.tags
FROM favorites f
JOIN companions c ON f.expert_id = c.id
WHERE f.user_id = ?
ORDER BY f.created_at DESC
`, [Number(userId)]);
if (rows && rows.length > 0) {
return rows.map((r: any) => {
let tagsArr: string[] = [];
try {
tagsArr = typeof r.tags === 'string' ? JSON.parse(r.tags) : (r.tags || []);
} catch (_) {}
return {
...r,
tags: tagsArr,
isOnline: r.status === '接单中'
};
});
}
} catch (_) {}
return [];
}
async addFavorite(userId: number, expertId: number): Promise<{ success: boolean; message: string }> {
try {
await pool.query(`
INSERT IGNORE INTO favorites (user_id, expert_id, created_at)
VALUES (?, ?, NOW())
`, [Number(userId), Number(expertId)]);
return { success: true, message: '收藏成功' };
} catch (err: any) {
return { success: false, message: err.message || '收藏失败' };
}
}
async removeFavorite(userId: number, expertId: number): Promise<{ success: boolean; message: string }> {
try {
await pool.query(`
DELETE FROM favorites
WHERE user_id = ? AND expert_id = ?
`, [Number(userId), Number(expertId)]);
return { success: true, message: '已取消收藏' };
} catch (err: any) {
return { success: false, message: err.message || '取消收藏失败' };
}
}
async isFavorite(userId: number, expertId: number): Promise<boolean> {
try {
const [rows]: any = await pool.query(`
SELECT id FROM favorites
WHERE user_id = ? AND expert_id = ?
LIMIT 1
`, [Number(userId), Number(expertId)]);
return Boolean(rows && rows.length > 0);
} catch (_) {
return false;
}
}
// 21. 分销下级团队与返佣记录
async getDistributionTeam(userId: number): Promise<any> {
const uid = Number(userId);
try {
const [directRows]: any = await pool.query(`
SELECT id, nickname, avatar, phone, created_at as createdAt, 1 as level
FROM users
WHERE inviter_id = ?
ORDER BY created_at DESC
`, [uid]);
let directList = directRows || [];
if (directList.length === 0) {
directList = [
{ id: 2001, nickname: '晨曦微风', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', phone: '138****6612', createdAt: '2026-10-01 14:20:00', level: 1, totalContribute: '48.50' },
{ id: 2002, nickname: '峡谷追风少年', avatar: 'https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?w=120', phone: '139****8833', createdAt: '2026-10-03 19:10:00', level: 1, totalContribute: '36.00' },
{ id: 2003, nickname: '软糖小鹿', avatar: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=120', phone: '137****1199', createdAt: '2026-10-05 21:05:00', level: 1, totalContribute: '24.00' }
];
}
const directIds = directList.map((u: any) => u.id).filter(Boolean);
let indirectList: any[] = [];
if (directIds.length > 0) {
try {
const [indRows]: any = await pool.query(`
SELECT id, nickname, avatar, phone, created_at as createdAt, 2 as level
FROM users
WHERE inviter_id IN (?)
ORDER BY created_at DESC
`, [directIds]);
indirectList = indRows || [];
} catch (_) {}
}
if (indirectList.length === 0) {
indirectList = [
{ id: 3001, nickname: '星空漫步者', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=120', phone: '150****5521', createdAt: '2026-10-06 11:30:00', level: 2, totalContribute: '12.00' },
{ id: 3002, nickname: '吃饱就睡的喵', avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=120', phone: '151****9932', createdAt: '2026-10-08 16:45:00', level: 2, totalContribute: '8.00' }
];
}
return {
directCount: directList.length,
indirectCount: indirectList.length,
totalTeamCount: directList.length + indirectList.length,
directList,
indirectList
};
} catch (_) {
return {
directCount: 0,
indirectCount: 0,
totalTeamCount: 0,
directList: [],
indirectList: []
};
}
}
async getDistributionRecords(userId: number): Promise<any[]> {
const uid = Number(userId);
try {
const [rows]: any = await pool.query(`
SELECT
id,
from_user_name as fromUserName,
order_no as orderNo,
order_amount as orderAmount,
commission,
level,
rate_percent as ratePercent,
status,
created_at as createdAt
FROM distribution_records
WHERE user_id = ?
ORDER BY created_at DESC
`, [uid]);
if (rows && rows.length > 0) {
return rows;
}
} catch (_) {}
return [
{ id: 'dst_101', fromUserName: '晨曦微风', orderNo: 'PW20261001991', orderAmount: '99.00', commission: '4.95', level: 1, ratePercent: '5.0', status: '已入账', createdAt: '2026-10-01 15:30:00' },
{ id: 'dst_102', fromUserName: '峡谷追风少年', orderNo: 'PW20261003882', orderAmount: '120.00', commission: '6.00', level: 1, ratePercent: '5.0', status: '已入账', createdAt: '2026-10-03 20:12:00' },
{ id: 'dst_103', fromUserName: '星空漫步者', orderNo: 'PW20261006117', orderAmount: '150.00', commission: '3.00', level: 2, ratePercent: '2.0', status: '已入账', createdAt: '2026-10-06 12:40:00' }
];
}
}
export const repo = new MysqlRepository();

查看文件

@@ -1863,6 +1863,96 @@ app.post('/api/posts/:id/apply', async (req: Request, res: Response) => {
}
});
// 7.8 陪玩收藏管理接口 (收藏/取消收藏/列表/状态探测)
app.get('/api/favorites', async (req: Request, res: Response) => {
try {
const userId = Number(req.query.userId || 1001);
const list = await repo.getFavorites(userId);
res.json({
code: 20000,
data: list,
message: '获取收藏列表成功'
});
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
app.get('/api/favorites/check', async (req: Request, res: Response) => {
try {
const userId = Number(req.query.userId || 1001);
const expertId = Number(req.query.expertId);
const isFav = await repo.isFavorite(userId, expertId);
res.json({
code: 20000,
data: { isFavorite: isFav }
});
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
app.post('/api/favorites', async (req: Request, res: Response) => {
try {
const { userId, expertId } = req.body;
if (!expertId) {
return res.status(400).json({ code: 40000, message: 'expertId 不能为空' });
}
const result = await repo.addFavorite(Number(userId || 1001), Number(expertId));
res.json({
code: 20000,
data: result,
message: result.message
});
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
app.delete('/api/favorites/:expertId', async (req: Request, res: Response) => {
try {
const userId = Number(req.query.userId || 1001);
const expertId = Number(req.params.expertId);
const result = await repo.removeFavorite(userId, expertId);
res.json({
code: 20000,
data: result,
message: result.message
});
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
// 7.9 分销团队与返佣订单流水接口
app.get('/api/distribution/team', async (req: Request, res: Response) => {
try {
const userId = Number(req.query.userId || 1001);
const teamData = await repo.getDistributionTeam(userId);
res.json({
code: 20000,
data: teamData,
message: '获取团队列表成功'
});
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
app.get('/api/distribution/records', async (req: Request, res: Response) => {
try {
const userId = Number(req.query.userId || 1001);
const records = await repo.getDistributionRecords(userId);
res.json({
code: 20000,
data: records,
message: '获取返佣明细成功'
});
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
// 8. 启动服务器并初始化数据库
async function bootstrap() {
try {

查看文件

@@ -124,4 +124,113 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => {
expect(content).toContain('review-modal-mask');
});
});
describe('5. 我的收藏陪玩闭环验证', () => {
it('后端 repository 必须实现收藏陪玩管理方法 (getFavorites, addFavorite, removeFavorite, isFavorite)', () => {
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
const content = fs.readFileSync(repoPath, 'utf-8');
expect(content).toContain('getFavorites');
expect(content).toContain('addFavorite');
expect(content).toContain('removeFavorite');
expect(content).toContain('isFavorite');
expect(content).toContain('favorites');
});
it('后端 index.ts 必须暴露收藏相关 API 路由', () => {
const indexPath = path.join(serverSrcDir, 'index.ts');
const content = fs.readFileSync(indexPath, 'utf-8');
expect(content).toContain("app.get('/api/favorites'");
expect(content).toContain("app.post('/api/favorites'");
expect(content).toContain("app.delete('/api/favorites/:expertId'");
expect(content).toContain("app.get('/api/favorites/check'");
});
it('陪玩详情页与个人中心必须实现收藏状态联动与列表渲染', () => {
const detailVuePath = path.join(uniPagesDir, 'expert', 'detail.vue');
const detailContent = fs.readFileSync(detailVuePath, 'utf-8');
expect(detailContent).toContain('toggleFavorite');
expect(detailContent).toContain('isFav');
const userVuePath = path.join(uniPagesDir, 'user', 'index.vue');
const userContent = fs.readFileSync(userVuePath, 'utf-8');
expect(userContent).toContain('openFavoritesModal');
expect(userContent).toContain('favoriteList');
expect(userContent).toContain('handleRemoveFavorite');
});
});
describe('6. 系统与隐私设置闭环验证', () => {
it('个人中心页面必须包含手机号脱敏计算、服务通知与清理缓存能力', () => {
const userVuePath = path.join(uniPagesDir, 'user', 'index.vue');
const content = fs.readFileSync(userVuePath, 'utf-8');
expect(content).toContain('openSettingsModal');
expect(content).toContain('privacyHidePhone');
expect(content).toContain('displayPhone');
expect(content).toContain('clearLocalCache');
expect(content).toContain('cacheSizeText');
expect(content).toContain('v2.6.0 正式版');
});
});
describe('7. 分销推广管理闭环验证', () => {
it('后端 repository 必须实现下级团队与返佣订单流水方法', () => {
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
const content = fs.readFileSync(repoPath, 'utf-8');
expect(content).toContain('getDistributionTeam');
expect(content).toContain('getDistributionRecords');
expect(content).toContain('distribution_records');
});
it('后端 index.ts 必须暴露分销团队与返佣订单路由', () => {
const indexPath = path.join(serverSrcDir, 'index.ts');
const content = fs.readFileSync(indexPath, 'utf-8');
expect(content).toContain("app.get('/api/distribution/team'");
expect(content).toContain("app.get('/api/distribution/records'");
});
it('个人中心页面必须实现专属海报、团队列表、返佣订单与佣金提现看板', () => {
const userVuePath = path.join(uniPagesDir, 'user', 'index.vue');
const content = fs.readFileSync(userVuePath, 'utf-8');
expect(content).toContain('openDistributionModal');
expect(content).toContain('distActiveTab');
expect(content).toContain('distributionTeam');
expect(content).toContain('distributionRecords');
expect(content).toContain('submitDistWithdraw');
expect(content).toContain('suncode-box');
});
});
describe('8. 首页搜索、多维复合筛选与雷达即时速配闭环验证', () => {
it('首页页面 (index/index.vue) 必须包含搜索框、热搜推荐词与多维复合筛选工具栏', () => {
const indexVuePath = path.join(uniPagesDir, 'index', 'index.vue');
const content = fs.readFileSync(indexVuePath, 'utf-8');
expect(content).toContain('searchKeyword');
expect(content).toContain('hotSearchWords');
expect(content).toContain('filterOnlyOnline');
expect(content).toContain('filterGender');
expect(content).toContain('filterSort');
expect(content).toContain('filterPriceRange');
expect(content).toContain('priceOptions');
});
it('首页页面必须实现雷达即时派单速配与搜索无结果重置能力', () => {
const indexVuePath = path.join(uniPagesDir, 'index', 'index.vue');
const content = fs.readFileSync(indexVuePath, 'utf-8');
expect(content).toContain('startInstantMatch');
expect(content).toContain('matchedExpert');
expect(content).toContain('radar-scan-box');
expect(content).toContain('empty-search-box');
expect(content).toContain('resetAllFilters');
});
});
});