From d5c50aabe63bb2567b9403243435c917fbfdd2a1 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Mon, 19 Oct 2026 15:51:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(order):=20=E4=BF=AE=E5=A4=8D=E5=A4=A7?= =?UTF-8?q?=E7=A5=9E=E6=97=A0=E6=9C=8D=E5=8A=A1=E5=8C=85=E6=97=B6=E4=B8=8B?= =?UTF-8?q?=E5=8D=95=E9=A1=B5=E5=B4=A9=E6=BA=83=EF=BC=88Cannot=20read=20pr?= =?UTF-8?q?operties=20of=20null=20reading=20'title'=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 起因:上一批移除"系统自动生成演示服务包"后,未配置服务包的大神返回空列表,下单页模板直接取 selectedService.title 触发空指针 - 下单页新增 serviceView 安全视图(computed 带兜底对象),模板统一改用 serviceView.title/price/unit,杜绝解引用 null - 无服务包时按大神基础时薪兜底生成"<品类>基础陪伴服务",保证仍可下单(价格仍由服务端档案价二次校验) - 详情页取 services[0] 前先判空,并新增"暂未配置服务项目"空态提示 - 新增回归护栏 null-service-guard.spec.ts(98 套件/677 用例全绿) --- pwuniapp/pages/expert/detail.vue | 24 +++++++++++- pwuniapp/pages/order/create.vue | 49 ++++++++++++++++++++----- server/tests/null-service-guard.spec.ts | 35 ++++++++++++++++++ 3 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 server/tests/null-service-guard.spec.ts diff --git a/pwuniapp/pages/expert/detail.vue b/pwuniapp/pages/expert/detail.vue index 0b17c5e..a4cdd1d 100644 --- a/pwuniapp/pages/expert/detail.vue +++ b/pwuniapp/pages/expert/detail.vue @@ -74,6 +74,12 @@ 服务项目 + + 该大神暂未配置服务项目,可直接发起基础陪伴预约 + { 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; diff --git a/pwuniapp/pages/order/create.vue b/pwuniapp/pages/order/create.vue index 4afe7d6..7c1158e 100644 --- a/pwuniapp/pages/order/create.vue +++ b/pwuniapp/pages/order/create.vue @@ -9,10 +9,10 @@ {{ expert.nickname }} {{ expert.game }} - {{ selectedService.title }} + {{ serviceView.title }} - ¥{{ selectedService.price }}/{{ selectedService.unit }} + ¥{{ serviceView.price }}/{{ serviceView.unit }} @@ -22,11 +22,11 @@ 购买时长 - 以{{ selectedService.unit }}为计费单位 + 以{{ serviceView.unit }}为计费单位 - {{ hours }} {{ selectedService.unit }} + {{ hours }} {{ serviceView.unit }} @@ -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(); }); diff --git a/server/tests/null-service-guard.spec.ts b/server/tests/null-service-guard.spec.ts new file mode 100644 index 0000000..fc29aa5 --- /dev/null +++ b/server/tests/null-service-guard.spec.ts @@ -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('该大神暂未配置服务项目'); + }); +});