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 用例全绿)
这个提交包含在:
xcbyy
2026-10-19 15:51:23 +08:00
父节点 a1e3a137b6
当前提交 d5c50aabe6
共有 3 个文件被更改,包括 97 次插入 和 11 次删除

查看文件

@@ -74,6 +74,12 @@
<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"
@@ -439,7 +445,9 @@ onLoad(async (query) => {
photos: snapPhotos,
services: buildDefaultServices(snapGame, snapPrice)
};
selectedServiceId.value = expert.value.services[0].id;
if (expert.value.services && expert.value.services.length > 0) {
selectedServiceId.value = expert.value.services[0].id;
}
}
try {
@@ -897,6 +905,20 @@ const submitReportTicket = async () => {
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;

查看文件

@@ -9,10 +9,10 @@
<text class="expert-name">{{ expert.nickname }}</text>
<text class="game-tag">{{ expert.game }}</text>
</view>
<text class="service-title">{{ selectedService.title }}</text>
<text class="service-title">{{ serviceView.title }}</text>
</view>
<view class="price-single">
<text class="price-unit">¥{{ selectedService.price }}/{{ selectedService.unit }}</text>
<text class="price-unit">¥{{ serviceView.price }}/{{ serviceView.unit }}</text>
</view>
</view>
</view>
@@ -22,11 +22,11 @@
<view class="stepper-row">
<view class="stepper-label">
<text class="label-main">购买时长</text>
<text class="label-sub">以{{ selectedService.unit }}为计费单位</text>
<text class="label-sub">以{{ serviceView.unit }}为计费单位</text>
</view>
<view class="stepper-box">
<button class="step-btn btn-active" :disabled="hours <= 1" @click="changeHours(-1)">-</button>
<text class="step-val">{{ hours }} {{ selectedService.unit }}</text>
<text class="step-val">{{ hours }} {{ serviceView.unit }}</text>
<button class="step-btn btn-active" :disabled="hours >= 10" @click="changeHours(1)">+</button>
</view>
</view>
@@ -314,6 +314,13 @@ const expert = ref({
services: []
});
const selectedService = ref(null);
// 模板安全视图:即便服务包尚未加载完成,也不会出现 null.title 崩溃
const serviceView = computed(() => selectedService.value || {
id: 'base',
title: '同城陪伴基础服务',
price: Number(expert.value && expert.value.price) || 35,
unit: '小时'
});
const hours = ref(2);
const timeType = ref('NOW');
const discount = ref(0);
@@ -340,21 +347,43 @@ onLoad(async (query) => {
const liveExpert = await getCompanionDetail(targetId);
if (liveExpert) {
expert.value = liveExpert;
if (query && query.serviceId && liveExpert.services) {
const s = liveExpert.services.find(srv => String(srv.id) === String(query.serviceId));
const services = Array.isArray(liveExpert.services) ? liveExpert.services : [];
if (query && query.serviceId) {
const s = services.find(srv => String(srv.id) === String(query.serviceId));
if (s) {
selectedService.value = s;
} else if (liveExpert.services.length > 0) {
selectedService.value = liveExpert.services[0];
}
} else if (liveExpert.services && liveExpert.services.length > 0) {
selectedService.value = liveExpert.services[0];
}
if (!selectedService.value && services.length > 0) {
selectedService.value = services[0];
}
// 兜底:大神尚未配置服务包时,按其基础时薪提供"基础陪伴服务",保证仍可下单
// (价格仍以服务端大神档案价为准,createOrder 会二次校验)
if (!selectedService.value) {
selectedService.value = {
id: 'base',
title: `${liveExpert.game || '同城陪伴'}基础陪伴服务`,
price: Number(liveExpert.price) || 35,
unit: '小时',
desc: '大神暂未配置服务包,按基础时薪计费'
};
}
}
} catch (err) {
console.warn('[OrderCreate] 获取线上大神详情异常:', err);
}
// 极端兜底:详情获取失败时也要保证不会因空服务崩溃
if (!selectedService.value) {
selectedService.value = {
id: 'base',
title: '同城陪伴基础服务',
price: Number(expert.value.price) || 35,
unit: '小时',
desc: ''
};
}
// 加载后台真实可用优惠券
await loadLiveCoupons();
});

查看文件

@@ -0,0 +1,35 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第八十二批:服务包为空时的空指针防护。
* 背景:移除"系统自动生成演示服务包"后,大神可能没有任何服务包,
* 下单页模板直接取 selectedService.title 会崩(Cannot read properties of null)。
*/
describe('第八十二批服务包空值防护闸门', () => {
const root = path.resolve(__dirname, '../../');
const createPage = fs.readFileSync(path.join(root, 'pwuniapp/pages/order/create.vue'), 'utf-8');
const detailPage = fs.readFileSync(path.join(root, 'pwuniapp/pages/expert/detail.vue'), 'utf-8');
it('下单页提供 serviceView 安全视图,模板不再直接解引用 selectedService', () => {
expect(createPage).toContain('const serviceView = computed(');
expect(createPage).toContain('{{ serviceView.title }}');
expect(createPage).toContain('{{ serviceView.price }}');
expect(createPage).toContain('{{ serviceView.unit }}');
expect(createPage).not.toContain('selectedService.title');
expect(createPage).not.toContain('selectedService.price');
expect(createPage).not.toContain('selectedService.unit');
});
it('下单页在无服务包时按大神基础时薪兜底,保证仍可下单', () => {
expect(createPage).toContain("id: 'base'");
expect(createPage).toContain('基础陪伴服务');
expect(createPage).toContain('Array.isArray(liveExpert.services) ? liveExpert.services : []');
});
it('详情页取 services[0] 前先判空,并提供空态提示', () => {
expect(detailPage).toContain('expert.value.services && expert.value.services.length > 0');
expect(detailPage).toContain('该大神暂未配置服务项目');
});
});