文件
shanghaipeiwan/pwuniapp/pages/expert/detail.vue
xcbyy d5c50aabe6 fix(order): 修复大神无服务包时下单页崩溃(Cannot read properties of null reading 'title')
- 起因:上一批移除"系统自动生成演示服务包"后,未配置服务包的大神返回空列表,下单页模板直接取 selectedService.title 触发空指针
- 下单页新增 serviceView 安全视图(computed 带兜底对象),模板统一改用 serviceView.title/price/unit,杜绝解引用 null
- 无服务包时按大神基础时薪兜底生成"<品类>基础陪伴服务",保证仍可下单(价格仍由服务端档案价二次校验)
- 详情页取 services[0] 前先判空,并新增"暂未配置服务项目"空态提示
- 新增回归护栏 null-service-guard.spec.ts(98 套件/677 用例全绿)
2026-10-19 15:51:23 +08:00

1662 行
42 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<view class="expert-detail-container" :class="{ 'theme-warm': currentThemeKey === 'warm' }">
<!-- 大神主页骨架屏 (首屏加载中流光占位,杜绝视觉空白) -->
<skeleton-screen v-if="isLoading" type="expert" />
<!-- 真实内容 (数据加载完成平滑展示) -->
<view v-else class="expert-body-wrapper">
<!-- 顶部封面与基本信息 -->
<view class="hero-header">
<image class="cover-bg" :src="expert.coverImage || expert.avatar" mode="aspectFill" />
<view class="cover-mask"></view>
<view class="profile-card">
<view class="avatar-wrap">
<image class="avatar-img" :src="expert.avatar" mode="aspectFill" />
<view class="status-badge" :class="{ online: expert.isOnline }">
<view class="pulse-dot" v-if="expert.isOnline"></view>
<text>{{ expert.isOnline ? '在线' : '离线' }}</text>
</view>
</view>
<view class="info-wrap">
<view class="name-row">
<text class="nickname">{{ expert.nickname }}</text>
<text class="level-tag">{{ expert.levelTag }}</text>
</view>
<view class="rank-row">
<text class="game-tag">{{ expert.game }}</text>
<text class="rank-tag">{{ expert.rankTag }}</text>
</view>
<view class="city-row" v-if="expert.city">
<image class="city-pin-svg" :src="ICONS.mapPin" mode="aspectFit" />
<text class="city-text">{{ expert.city }}</text>
</view>
<view class="data-row">
<view class="rating-box">
<image class="star-svg" :src="ICONS.star" mode="aspectFit" />
<text class="rating-val">{{ expert.rating }}</text>
</view>
<text class="order-val">已陪玩 {{ expert.orderCount }} 次</text>
</view>
</view>
</view>
</view>
<!-- 专属打招呼试听语音 (矢量SVG播放条) -->
<view class="voice-card btn-active" @click="toggleVoice">
<view class="voice-icon-wrap">
<image class="play-svg" :src="isPlayingVoice ? ICONS.pause : ICONS.play" mode="aspectFit" />
</view>
<view class="voice-info">
<text class="voice-title">{{ isPlayingVoice ? '正在连麦试听语音...' : '点击试听打招呼原声' }}</text>
<view class="voice-wave" v-if="isPlayingVoice">
<view class="bar b1"></view>
<view class="bar b2"></view>
<view class="bar b3"></view>
<view class="bar b4"></view>
</view>
<text class="voice-dur" v-else>{{ expert.voiceDuration }}s 真实原声</text>
</view>
<text class="listen-tag">试听</text>
</view>
<!-- 个人简介 -->
<view class="section-card">
<view class="sec-title">个人介绍</view>
<text class="bio-text">{{ expert.bio }}</text>
</view>
<!-- 服务项目选择(仅真实大神展示) -->
<view class="section-card" v-if="isCompanion">
<view class="sec-title">服务项目</view>
<view class="service-list">
<view
v-if="!expert.services || expert.services.length === 0"
class="service-empty"
>
<text class="service-empty-text">该大神暂未配置服务项目,可直接发起基础陪伴预约</text>
</view>
<view
v-for="srv in expert.services"
:key="srv.id"
class="service-item btn-active"
:class="{ selected: selectedServiceId === srv.id }"
@click="selectService(srv)"
>
<view class="srv-left">
<view class="srv-title">{{ srv.title }}</view>
<view class="srv-desc">{{ srv.desc }}</view>
</view>
<view class="srv-right">
<text class="price-sym">¥</text>
<text class="price-num">{{ srv.price }}</text>
<text class="price-unit">/{{ srv.unit }}</text>
</view>
</view>
</view>
<!-- 线下安全守则提示卡 -->
<view class="offline-safety-card" v-if="expert.city">
<image class="safety-icon-svg" :src="ICONS.shield" mode="aspectFit" />
<view class="safety-content">
<text class="safety-title">绿色合规陪伴服务承诺</text>
<text class="safety-desc">该大神已完成公安实名联网比对与健康认证。同城陪玩/陪吃/陪喝仅限在公开正规商业场所履约,到场出示4位核销码现场签到。</text>
</view>
</view>
</view>
<!-- 战绩与生活照片 -->
<view class="section-card" v-if="expert.photos && expert.photos.length > 0">
<view class="sec-title">战绩相册</view>
<scroll-view scroll-x class="gallery-scroll" :show-scrollbar="false">
<view class="gallery-list">
<image
v-for="(photo, index) in expert.photos"
:key="index"
class="gallery-item"
:src="photo"
mode="aspectFill"
@click="previewImage(index)"
/>
</view>
</scroll-view>
</view>
<!-- 真实买家评价 -->
<view class="section-card">
<view class="sec-header">
<text class="sec-title">服务好评 ({{ expert.reviews ? expert.reviews.length : 0 }})</text>
<text class="rate-percent">好评率 99.8%</text>
</view>
<view class="review-list" v-if="expert.reviews && expert.reviews.length > 0">
<view v-for="rev in expert.reviews" :key="rev.id" class="review-item">
<image class="rev-avatar" :src="rev.userAvatar" mode="aspectFill" />
<view class="rev-body">
<view class="rev-top">
<text class="rev-name">{{ rev.userNickname }}</text>
<text class="rev-time">{{ rev.time }}</text>
</view>
<view class="tag-row">
<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>
<view v-else class="empty-reviews">
<text>近期新增服务,暂无评价记录</text>
</view>
</view>
</view>
<!-- 底部固定栏 (限制居中与安全区) -->
<view class="bottom-bar">
<view class="icon-action btn-active" @click="chatOnline">
<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>
</view>
<template v-if="isCompanion">
<view class="price-summary">
<text class="summary-label">当前选定:</text>
<text class="summary-price">¥{{ currentSelectedService ? currentSelectedService.price : expert.price }}</text>
</view>
<button class="btn-book btn-active" @click="goToOrder">立即预约</button>
</template>
<view v-else class="price-summary">
<text class="summary-label">该用户尚未认证为大神,暂不可预约</text>
</view>
</view>
<!-- 举报不良行为弹窗 -->
<view class="review-modal-mask" v-if="reportModalVisible" @click="closeReportModal">
<view class="review-modal-content" @click.stop="stopPropagation">
<view class="modal-header">
<view class="modal-header-left">
<text class="modal-title">举报违规行为</text>
<text class="modal-subtitle">
举报对象:{{ expert.nickname }}
</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="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>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import SkeletonScreen from '@/components/SkeletonScreen.vue';
import { getCompanionDetail, getExpertReviews, replyOrderReview, submitReport as submitReportApi, checkFavorite, addFavorite as addFavApi, removeFavorite as removeFavApi } from '@/common/api.js';
import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
import { ensureLoginOrRedirect } from '@/common/role.js';
const currentThemeKey = ref(getCurrentThemeKey());
const onThemeChanged = (key) => {
currentThemeKey.value = key;
applyThemeToNativeUI(key);
};
onMounted(() => {
currentThemeKey.value = getCurrentThemeKey();
applyThemeToNativeUI(currentThemeKey.value);
uni.$on('theme-change', onThemeChanged);
});
onUnmounted(() => {
uni.$off('theme-change', onThemeChanged);
});
const stopPropagation = (e) => {
if (e && typeof e.stopPropagation === 'function') {
e.stopPropagation();
}
};
const isLoading = ref(true);
const isFav = ref(false);
const buildDefaultServices = (gameTitle = '同城陪伴', basePrice = 68) => [
{
id: 'srv_1',
name: `${gameTitle} · 基础陪伴 (2小时)`,
title: `${gameTitle} · 基础陪伴 (2小时)`,
price: basePrice,
unit: '小时',
desc: '正规公开商圈同城陪伴,AA制或协商消费'
},
{
id: 'srv_2',
name: `${gameTitle} · 半日畅享 (3小时)`,
title: `${gameTitle} · 半日畅享 (3小时)`,
price: Math.round(basePrice * 2.8),
unit: '次',
desc: '深度互动与陪伴交流,赠送专属点歌或语音祝福'
}
];
const expert = ref({
id: '101',
nickname: '同城陪伴师',
avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300',
gender: 'female',
levelTag: '',
game: '同城陪伴',
rankTag: '',
city: '',
rating: 0,
orderCount: 0,
isOnline: false,
bio: '',
voiceDuration: 0,
price: 0,
services: [],
photos: [
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=400',
'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400'
],
reviews: []
});
// 是否为真实大神档案(非合成的普通用户资料)
const isCompanion = computed(() => expert.value.isCompanion !== false);
const selectedServiceId = ref('srv_1');
const isPlayingVoice = ref(false);
let currentExpertTargetId = null;
const refreshFavoriteState = async () => {
if (!currentExpertTargetId) return;
try {
const favStatus = await checkFavorite(currentExpertTargetId);
isFav.value = Boolean(favStatus);
} catch (_) {}
};
onShow(() => {
currentThemeKey.value = getCurrentThemeKey();
applyThemeToNativeUI(currentThemeKey.value);
refreshFavoriteState();
});
const toggleFavorite = async () => {
const targetId = Number(expert.value.id || currentExpertTargetId || 101);
if (!ensureLoginOrRedirect(`/pages/expert/detail?id=${targetId}`)) {
return;
}
try {
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 targetId = Number(expert.value.id || currentExpertTargetId || 101);
if (!ensureLoginOrRedirect(`/pages/expert/detail?id=${targetId}`)) {
return;
}
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' });
}
}
});
};
const safeDecode = (val) => {
if (!val) return '';
try {
return decodeURIComponent(val);
} catch (_) {
return val;
}
};
onLoad(async (query) => {
const targetId = (query && query.id) ? safeDecode(query.id) : '1';
const queryName = query && query.name ? safeDecode(query.name) : '';
const queryAvatar = query && query.avatar ? safeDecode(query.avatar) : '';
const queryTag = query && query.tag ? safeDecode(query.tag) : '';
const queryCity = query && query.city ? safeDecode(query.city) : '';
currentExpertTargetId = targetId;
const cachedAuthor = uni.getStorageSync('ACTIVE_AUTHOR_PROFILE');
const matchedSnapshot = (cachedAuthor && (String(cachedAuthor.id) === String(targetId) || String(cachedAuthor.userId) === String(targetId) || (queryName && cachedAuthor.nickname === queryName)))
? cachedAuthor
: null;
if (matchedSnapshot || queryName) {
const snapName = queryName || (matchedSnapshot && matchedSnapshot.nickname) || expert.value.nickname;
const snapAvatar = queryAvatar || (matchedSnapshot && matchedSnapshot.avatar) || expert.value.avatar;
const snapTag = queryTag || (matchedSnapshot && matchedSnapshot.rankTag) || expert.value.rankTag;
const snapCity = queryCity || (matchedSnapshot && matchedSnapshot.city) || expert.value.city;
const snapGame = (matchedSnapshot && matchedSnapshot.game) || '同城搭子';
const snapBio = (matchedSnapshot && matchedSnapshot.bio) || expert.value.bio;
const snapPhotos = (matchedSnapshot && Array.isArray(matchedSnapshot.photos) && matchedSnapshot.photos.length > 0)
? matchedSnapshot.photos
: [snapAvatar];
const snapPrice = snapGame === '车队开黑' ? 88 : 128;
expert.value = {
...expert.value,
id: String(targetId),
nickname: snapName,
avatar: snapAvatar,
coverImage: snapPhotos[0] || snapAvatar,
rankTag: snapTag,
levelTag: '实名认证',
game: snapGame,
city: snapCity,
bio: snapBio,
price: snapPrice,
photos: snapPhotos,
services: buildDefaultServices(snapGame, snapPrice)
};
if (expert.value.services && expert.value.services.length > 0) {
selectedServiceId.value = expert.value.services[0].id;
}
}
try {
const liveData = await getCompanionDetail(targetId);
if (liveData) {
if (queryName || matchedSnapshot) {
const finalName = queryName || (matchedSnapshot && matchedSnapshot.nickname) || liveData.nickname;
const finalAvatar = queryAvatar || (matchedSnapshot && matchedSnapshot.avatar) || liveData.avatar;
const finalTag = queryTag || (matchedSnapshot && matchedSnapshot.rankTag) || liveData.rankTag;
const finalCity = queryCity || (matchedSnapshot && matchedSnapshot.city) || liveData.city;
const finalBio = (matchedSnapshot && matchedSnapshot.bio) || liveData.bio;
const finalPhotos = (matchedSnapshot && Array.isArray(matchedSnapshot.photos) && matchedSnapshot.photos.length > 0)
? matchedSnapshot.photos
: (liveData.photos || [finalAvatar]);
expert.value = {
...liveData,
nickname: finalName,
avatar: finalAvatar,
coverImage: finalPhotos[0] || finalAvatar,
rankTag: finalTag,
city: finalCity,
bio: finalBio,
photos: finalPhotos
};
} else {
expert.value = liveData;
}
if (expert.value.services && expert.value.services.length > 0) {
selectedServiceId.value = expert.value.services[0].id;
}
}
try {
isFav.value = await checkFavorite(targetId);
} catch (_) {}
try {
const liveReviews = await getExpertReviews(targetId);
if (Array.isArray(liveReviews) && liveReviews.length > 0) {
expert.value.reviews = liveReviews.map(r => ({
id: r.id,
userNickname: r.userNickname || '买家玩家',
userAvatar: r.userAvatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=100',
rating: r.rating || 5,
tags: r.tags || [],
content: r.content || '好评!',
time: r.createdAt ? '近期' : '刚刚',
replyContent: r.replyContent || '',
replyTime: r.replyTime || ''
}));
}
} catch (_) {}
} catch (err) {
console.warn('[ExpertDetail] 获取线上大神详情异常:', err);
} finally {
setTimeout(() => {
isLoading.value = false;
}, 200);
}
});
const currentSelectedService = computed(() => {
if (!expert.value.services) return null;
return expert.value.services.find(s => s.id === selectedServiceId.value);
});
const selectService = (srv) => {
selectedServiceId.value = srv.id;
};
const toggleVoice = () => {
isPlayingVoice.value = !isPlayingVoice.value;
if (isPlayingVoice.value) {
uni.showToast({ title: '正在播放大神打招呼原声', icon: 'none' });
setTimeout(() => {
isPlayingVoice.value = false;
}, 4000);
}
};
const chatOnline = () => {
uni.showActionSheet({
itemList: [
`与【${expert.value.nickname}】开启即时连麦`,
`向【${expert.value.nickname}】发送打招呼私信`,
`复制大神游戏开黑专属房号`
],
success: (res) => {
if (res.tapIndex === 0) {
uni.showToast({ title: '已建立加密连麦通道', icon: 'success' });
} else if (res.tapIndex === 1) {
uni.showToast({ title: '打招呼私信已送达', icon: 'success' });
} else {
uni.setClipboardData({
data: `PW-${expert.value.id}-${Math.floor(1000 + Math.random() * 9000)}`,
success: () => {
uni.showToast({ title: '专属房号已复制', icon: 'success' });
}
});
}
}
});
};
const previewImage = (index) => {
uni.previewImage({
current: index,
urls: expert.value.photos
});
};
const goToOrder = () => {
const srv = currentSelectedService.value || (expert.value.services && expert.value.services[0]);
const srvId = srv ? srv.id : 'srv_1';
const targetUrl = `/pages/order/create?expertId=${expert.value.id || currentExpertTargetId || 101}&serviceId=${srvId}`;
if (!ensureLoginOrRedirect(targetUrl)) {
return;
}
uni.navigateTo({
url: targetUrl
});
};
// 举报违规行为状态与提交
const reportModalVisible = ref(false);
const submittingReport = ref(false);
const presetReportReasons = [
'涉黄低俗言论',
'私下诱导转账/欺诈',
'态度恶劣/人身攻击',
'开挂/作弊/战绩造假',
'线下违规/安全隐患',
'其他违规行为'
];
const reportForm = ref({
reason: '涉黄低俗言论',
description: ''
});
const openReportModal = () => {
const targetId = Number(expert.value.id || currentExpertTargetId || 101);
if (!ensureLoginOrRedirect(`/pages/expert/detail?id=${targetId}`)) {
return;
}
reportForm.value = {
reason: '涉黄低俗言论',
description: ''
};
reportModalVisible.value = true;
};
const closeReportModal = () => {
reportModalVisible.value = false;
};
const submitReportTicket = async () => {
submittingReport.value = true;
uni.showLoading({ title: '正在提交举报...' });
try {
await submitReportApi({
targetType: 'EXPERT',
targetId: String(expert.value.id || '101'),
targetName: expert.value.nickname || '同城陪伴师',
reason: reportForm.value.reason,
description: reportForm.value.description.trim()
});
uni.hideLoading();
closeReportModal();
uni.showToast({ title: '举报提交成功', icon: 'success' });
setTimeout(() => {
uni.showModal({
title: '举报受理通知',
content: '感谢您的监督!平台安全团队将在 24 小时内核查聊天记录与履约凭据,违规属实将从严处罚。',
showCancel: false,
confirmText: '我知道了'
});
}, 400);
} catch (err) {
uni.hideLoading();
uni.showToast({ title: err.message || '举报提交失败', icon: 'none' });
} finally {
submittingReport.value = false;
}
};
</script>
<style lang="scss">
.expert-detail-container {
min-height: 100vh;
background-color: #0A0F1D;
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.hero-header {
position: relative;
height: 440rpx;
overflow: hidden;
.cover-bg {
width: 100%;
height: 100%;
filter: blur(8px);
transform: scale(1.1);
}
.cover-mask {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(10, 15, 29, 0.4) 0%, #0A0F1D 100%);
}
.profile-card {
position: absolute;
bottom: 20rpx;
left: 32rpx;
right: 32rpx;
display: flex;
align-items: flex-end;
gap: 24rpx;
.avatar-wrap {
position: relative;
width: 140rpx;
height: 140rpx;
flex-shrink: 0;
.avatar-img {
width: 100%;
height: 100%;
border-radius: 70rpx;
border: 4rpx solid #6366F1;
}
.status-badge {
position: absolute;
bottom: 0;
right: 0;
background: #64748B;
color: #FFF;
font-size: 18rpx;
padding: 2rpx 10rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
gap: 4rpx;
&.online {
background: #10B981;
box-shadow: 0 0 8rpx #10B981;
}
.pulse-dot {
width: 8rpx;
height: 8rpx;
background: #FFF;
border-radius: 50%;
}
}
}
.info-wrap {
flex: 1;
.name-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
.nickname {
font-size: 34rpx;
font-weight: 700;
color: #FFF;
}
.level-tag {
background: linear-gradient(135deg, #F59E0B, #D97706);
color: #FFF;
font-size: 18rpx;
padding: 2rpx 10rpx;
border-radius: 6rpx;
font-weight: 600;
}
}
.rank-row {
display: flex;
gap: 10rpx;
margin-bottom: 8rpx;
.game-tag {
background: rgba(255, 255, 255, 0.15);
color: #E2E8F0;
font-size: 20rpx;
padding: 2rpx 10rpx;
border-radius: 6rpx;
}
.rank-tag {
background: rgba(99, 102, 241, 0.2);
color: #818CF8;
font-size: 20rpx;
padding: 2rpx 10rpx;
border-radius: 6rpx;
}
}
.city-row {
display: flex;
align-items: center;
gap: 6rpx;
margin-bottom: 8rpx;
.city-pin-svg {
width: 22rpx;
height: 22rpx;
}
.city-text {
font-size: 20rpx;
color: #38BDF8;
font-weight: 500;
}
}
.data-row {
display: flex;
align-items: center;
gap: 20rpx;
font-size: 22rpx;
color: #94A3B8;
.rating-box {
display: flex;
align-items: center;
gap: 6rpx;
.star-svg {
width: 24rpx;
height: 24rpx;
}
.rating-val {
color: #F59E0B;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
}
}
}
}
}
.voice-card {
margin: 16rpx 32rpx 24rpx;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2) 0%, rgba(139, 92, 246, 0.1) 100%);
border: 1px solid rgba(99, 102, 241, 0.35);
border-radius: 20rpx;
padding: 20rpx 24rpx;
display: flex;
align-items: center;
.voice-icon-wrap {
width: 60rpx;
height: 60rpx;
border-radius: 30rpx;
background: #6366F1;
display: flex;
align-items: center;
justify-content: center;
margin-right: 18rpx;
.play-svg {
width: 24rpx;
height: 24rpx;
}
}
.voice-info {
flex: 1;
.voice-title {
font-size: 24rpx;
color: #F8FAFC;
font-weight: 600;
display: block;
margin-bottom: 4rpx;
}
.voice-dur {
font-size: 20rpx;
color: #818CF8;
}
.voice-wave {
display: flex;
align-items: flex-end;
gap: 6rpx;
height: 24rpx;
.bar {
width: 6rpx;
background-color: #818CF8;
border-radius: 3rpx;
&.b1 { height: 16rpx; }
&.b2 { height: 24rpx; }
&.b3 { height: 12rpx; }
&.b4 { height: 20rpx; }
}
}
}
.listen-tag {
font-size: 20rpx;
color: #818CF8;
border: 1px solid rgba(129, 140, 248, 0.4);
padding: 4rpx 14rpx;
border-radius: 20rpx;
}
}
.section-card {
margin: 0 32rpx 24rpx;
background-color: #151D30;
border-radius: 24rpx;
padding: 28rpx;
border: 1px solid rgba(255, 255, 255, 0.06);
.sec-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
.rate-percent {
font-size: 22rpx;
color: #10B981;
}
}
.sec-title {
font-size: 28rpx;
font-weight: 700;
color: #F8FAFC;
margin-bottom: 18rpx;
}
.bio-text {
font-size: 24rpx;
color: #94A3B8;
line-height: 1.6;
}
.service-list {
display: flex;
flex-direction: column;
gap: 16rpx;
.service-empty {
padding: 28rpx 24rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.12);
text-align: center;
.service-empty-text {
font-size: 24rpx;
color: #94A3B8;
line-height: 1.6;
}
}
.service-item {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16rpx;
padding: 20rpx 24rpx;
&.selected {
background: rgba(99, 102, 241, 0.16);
border-color: #6366F1;
box-shadow: 0 0 16rpx rgba(99, 102, 241, 0.35);
}
.srv-left {
.srv-title {
font-size: 26rpx;
color: #F8FAFC;
font-weight: 600;
margin-bottom: 4rpx;
}
.srv-desc {
font-size: 20rpx;
color: #64748B;
}
}
.srv-right {
color: #10B981;
font-variant-numeric: tabular-nums;
.price-sym { font-size: 22rpx; }
.price-num { font-size: 34rpx; font-weight: 700; }
.price-unit { font-size: 20rpx; color: #64748B; }
}
}
}
.offline-safety-card {
margin-top: 20rpx;
background: rgba(16, 185, 129, 0.08);
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 16rpx;
padding: 16rpx 20rpx;
display: flex;
align-items: flex-start;
gap: 12rpx;
.safety-icon-svg {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
margin-top: 4rpx;
}
.safety-content {
flex: 1;
.safety-title {
font-size: 22rpx;
font-weight: 600;
color: #10B981;
display: block;
margin-bottom: 4rpx;
}
.safety-desc {
font-size: 20rpx;
color: #94A3B8;
line-height: 1.4;
display: block;
}
}
}
.gallery-scroll {
white-space: nowrap;
.gallery-list {
display: flex;
gap: 16rpx;
}
.gallery-item {
width: 220rpx;
height: 160rpx;
border-radius: 16rpx;
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
.review-list {
display: flex;
flex-direction: column;
gap: 20rpx;
.review-item {
display: flex;
gap: 16rpx;
.rev-avatar {
width: 64rpx;
height: 64rpx;
border-radius: 32rpx;
}
.rev-body {
flex: 1;
.rev-top {
display: flex;
justify-content: space-between;
margin-bottom: 6rpx;
.rev-name {
font-size: 24rpx;
font-weight: 600;
color: #E2E8F0;
}
.rev-time {
font-size: 20rpx;
color: #64748B;
}
}
.tag-row {
display: flex;
gap: 10rpx;
margin-bottom: 8rpx;
.rev-tag {
font-size: 18rpx;
color: #818CF8;
background: rgba(99, 102, 241, 0.15);
padding: 2rpx 8rpx;
border-radius: 6rpx;
}
}
.rev-content {
font-size: 22rpx;
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;
}
}
}
}
}
.empty-reviews {
text-align: center;
color: #64748B;
font-size: 22rpx;
padding: 20rpx 0;
}
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: calc(120rpx + env(safe-area-inset-bottom));
padding: 0 32rpx env(safe-area-inset-bottom);
box-sizing: border-box;
background-color: rgba(21, 29, 48, 0.95);
backdrop-filter: blur(12px);
border-top: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
gap: 20rpx;
z-index: 100;
max-width: 450px;
margin: 0 auto;
.icon-action {
display: flex;
flex-direction: column;
align-items: center;
width: 80rpx;
.action-svg {
width: 36rpx;
height: 36rpx;
}
.action-text {
font-size: 18rpx;
color: #94A3B8;
margin-top: 4rpx;
&.fav-active {
color: #F59E0B;
font-weight: 600;
}
}
}
.price-summary {
flex: 1;
display: flex;
align-items: baseline;
.summary-label {
font-size: 22rpx;
color: #64748B;
}
.summary-price {
font-size: 36rpx;
font-weight: 700;
color: #10B981;
margin-left: 6rpx;
font-variant-numeric: tabular-nums;
}
}
.btn-book {
background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
color: #FFF;
font-size: 28rpx;
font-weight: 700;
border-radius: 44rpx;
height: 80rpx;
line-height: 80rpx;
padding: 0 48rpx;
box-shadow: 0 4rpx 20rpx rgba(99, 102, 241, 0.4);
margin: 0;
}
}
.review-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: flex-end;
justify-content: center;
}
.review-modal-content {
width: 100%;
max-width: 450px;
background: #151D30;
border-top-left-radius: 36rpx;
border-top-right-radius: 36rpx;
padding: 36rpx 36rpx calc(36rpx + env(safe-area-inset-bottom));
border: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: none;
box-shadow: 0 -8rpx 32rpx rgba(0, 0, 0, 0.5);
animation: slideUp 0.25s ease-out;
.modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32rpx;
.modal-header-left {
display: flex;
flex-direction: column;
gap: 6rpx;
.modal-title {
font-size: 34rpx;
font-weight: 700;
color: #F8FAFC;
}
.modal-subtitle {
font-size: 24rpx;
color: #94A3B8;
}
}
.modal-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
.close-icon {
width: 36rpx;
height: 36rpx;
}
}
}
.tags-section {
margin-bottom: 24rpx;
.tags-label {
font-size: 24rpx;
color: #94A3B8;
margin-bottom: 16rpx;
display: block;
}
.tags-wrap {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
.review-tag {
padding: 12rpx 24rpx;
font-size: 24rpx;
color: #94A3B8;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 30rpx;
transition: all 0.2s;
&.active {
color: #FFFFFF;
background: rgba(239, 68, 68, 0.25);
border-color: #EF4444;
font-weight: 600;
}
}
}
}
.textarea-section {
margin-bottom: 32rpx;
position: relative;
.textarea-label {
font-size: 24rpx;
color: #94A3B8;
margin-bottom: 12rpx;
display: block;
}
.review-textarea {
width: 100%;
height: 160rpx;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16rpx;
padding: 16rpx 20rpx;
font-size: 26rpx;
color: #F8FAFC;
}
.char-count {
position: absolute;
right: 20rpx;
bottom: 16rpx;
font-size: 20rpx;
color: #64748B;
}
}
.modal-actions {
display: flex;
gap: 20rpx;
.modal-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
border-radius: 40rpx;
font-size: 28rpx;
font-weight: 600;
margin: 0;
&.cancel-btn {
background: rgba(255, 255, 255, 0.08);
color: #94A3B8;
}
&.submit-btn {
background: linear-gradient(135deg, #EF4444 0%, #DC2626 100%);
color: #FFFFFF;
box-shadow: 0 4rpx 16rpx rgba(239, 68, 68, 0.4);
}
}
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* 暖阳晨曦浅色主题适配 (theme-warm) */
.expert-detail-container.theme-warm {
background-color: #FDFBF7;
.hero-header {
.cover-mask {
background: linear-gradient(180deg, rgba(253, 251, 247, 0.45) 0%, #FDFBF7 100%);
}
.profile-card {
.avatar-wrap {
.avatar-img {
border-color: #EA580C;
}
}
.info-wrap {
.name-row {
.nickname {
color: #1C1917;
}
}
.rank-row {
.game-tag {
background: #F5F5F4;
color: #57534E;
}
.rank-tag {
background: #FFF7ED;
color: #EA580C;
}
}
.city-row {
.city-text {
color: #EA580C;
}
}
.data-row {
color: #78716C;
.rating-box {
.rating-val {
color: #D97706;
}
}
.order-val {
color: #78716C;
}
}
}
}
}
.voice-card {
background: #FFFFFF;
border-color: #FED7AA;
box-shadow: 0 4rpx 16rpx rgba(234, 88, 12, 0.08);
.voice-icon-wrap {
background: linear-gradient(135deg, #F97316 0%, #EA580C 100%);
box-shadow: 0 4rpx 12rpx rgba(234, 88, 12, 0.3);
}
.voice-info {
.voice-title {
color: #1C1917;
}
.voice-dur {
color: #78716C;
}
.voice-wave .bar {
background: #EA580C;
}
}
.listen-tag {
background: #FFF7ED;
color: #EA580C;
border: 1px solid rgba(234, 88, 12, 0.3);
}
}
.section-card {
background: #FFFFFF;
border: 1px solid #F0ECE4;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.03);
.sec-title {
color: #1C1917;
}
.bio-text {
color: #57534E;
}
.rate-percent {
color: #EA580C;
}
.empty-reviews {
color: #A8A29E;
}
}
.service-list {
.service-item {
background: #FDFBF7;
border-color: #E7E5E4;
.srv-left {
.srv-title {
color: #1C1917;
}
.srv-desc {
color: #78716C;
}
}
.srv-right {
.price-sym, .price-num {
color: #EA580C;
}
.price-unit {
color: #A8A29E;
}
}
&.selected {
border-color: #EA580C;
background: #FFF7ED;
box-shadow: 0 4rpx 16rpx rgba(234, 88, 12, 0.12);
}
}
}
.offline-safety-card {
background: #FFFBEB;
border-color: #FDE68A;
.safety-content {
.safety-title {
color: #B45309;
}
.safety-desc {
color: #78716C;
}
}
}
.gallery-list {
.gallery-item {
border-color: #E7E5E4;
}
}
.review-list {
.review-item {
border-bottom-color: #F5F5F4;
.rev-body {
.rev-top {
.rev-name {
color: #1C1917;
}
.rev-time {
color: #A8A29E;
}
}
.tag-row {
.rev-tag {
background: #F5F5F4;
color: #57534E;
}
}
.rev-content {
color: #44403C;
}
.expert-reply-bubble {
background: #FDFBF7;
border-left-color: #EA580C;
.reply-badge-row {
.reply-badge {
background: #FFF7ED;
color: #EA580C;
}
.reply-time {
color: #A8A29E;
}
}
.reply-text {
color: #57534E;
}
}
.reply-action-row {
.btn-quick-reply {
background: #FFF7ED;
color: #EA580C;
border-color: rgba(234, 88, 12, 0.3);
}
}
}
}
}
.bottom-bar {
background: rgba(255, 255, 255, 0.96);
border-top-color: #F0ECE4;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
.icon-action {
.action-text {
color: #57534E;
&.fav-active {
color: #EA580C;
}
}
}
.price-summary {
.summary-label {
color: #78716C;
}
.summary-price {
color: #EA580C;
}
}
.btn-book {
background: linear-gradient(135deg, #EA580C 0%, #C2410C 100%);
box-shadow: 0 4rpx 20rpx rgba(234, 88, 12, 0.35);
color: #FFFFFF;
}
}
.review-modal-content {
background: #FFFFFF;
border-color: #E7E5E4;
box-shadow: 0 -8rpx 32rpx rgba(0, 0, 0, 0.1);
.modal-header {
.modal-title {
color: #1C1917;
}
.modal-subtitle {
color: #78716C;
}
}
.tags-section {
.tags-label {
color: #1C1917;
}
.tags-wrap {
.review-tag {
background: #F5F5F4;
color: #57534E;
border-color: #E7E5E4;
&.active {
color: #EF4444;
background: #FEF2F2;
border-color: #EF4444;
}
}
}
}
.textarea-section {
.textarea-label {
color: #1C1917;
}
.review-textarea {
background: #FDFBF7;
color: #1C1917;
border-color: #E7E5E4;
}
.char-count {
color: #A8A29E;
}
}
.modal-actions {
.modal-btn.cancel-btn {
background: #F5F5F4;
color: #57534E;
}
}
}
}
</style>