修改未完成

This commit is contained in:
rain
2026-07-29 18:15:59 +08:00
parent 6fbcf21024
commit 8c2355a52c
59 changed files with 7995 additions and 1118 deletions
+103
View File
@@ -0,0 +1,103 @@
# M09 VIP 与订单:设计核验
## 对比对象
- Source visual truth: `C:\Users\Administrator\.codex\generated_images\019fa647-03b4-7901-9b6a-175d07d56512\exec-f3e0331f-59c3-4f01-a48f-d069bdab2d58.png`
- 同状态真机截图:`tmp/vip-redesign-audit/20-uniform-footer-cards.png`
- 下单面板真机截图:`tmp/vip-redesign-audit/21-uniform-footer-sheet.png`
- 并排对比:`tmp/vip-redesign-audit/22-uniform-main-reference-and-implementation.png`
- 设备:MuMu Android 基座,720 × 1280360 × 640 CSS pxdevice scale factor 2)。参考图裁去底部已露出的面板后,按同一高度归一化进行并排检查。
## 视觉验收
- 顶部:水墨山景、竹影、留白与居中的标题层级已落地;新增背景资产为 `static/assets/modules/profile/opaque/m09-vip-hero-landscape.png`
- 套餐:基础版、宗族版、永久纪念版使用同一主推卡片规格,均有同样的朱砂描边、庭院水墨资产、底部价格/期限栏与圆角“立即开通”按钮。
- 卡片资产:使用 `static/assets/modules/profile/opaque/m09-featured-courtyard.png`,在三张卡上保持统一的透明度和裁切方式,文字与按钮在真实窄屏上保持可读。
- 下单:原居中装饰弹窗替换为底部确认单;套餐、有效期、应付金额、微信支付方式和取消入口均清晰可见。
## 交互验收
- MuMu 真机已点击“立即开通”,成功打开“确认下单”底部面板。
- 已点击“暂不购买”安全关闭面板;关闭后确认页面仍保留 3 个“立即开通”入口,未由这次视觉验收新增订单。
- 套餐和订单数据仍由现有接口读取;本次只改页面结构、样式与图片资产,不改接口字段或订单创建逻辑。
## 已知合同边界
- 当前 OpenAPI 未提供第三方支付调起参数或支付结果回调,因此主操作保持“创建订单”,不虚假宣称已完成微信支付。
## 结果
final result: passed
---
# T01 世系谱:设计核验
## 对比对象
- Source visual truth:用户在本次会话提供的 `Jiapu-App` 世系谱截图(408 × 936 px),并已核对参考源码 `C:\Users\Administrator\Desktop\job\Jiapu-App\pages\index\tree\index.vue`
- Implementation screenshot`tmp/t01-pedigree-paged-order-final2.png`720 × 1280 pxMuMu Android360 × 640 CSS pxdensity 2)。
- 当前真实数据状态:已授权的“真机联调10159371”仅有 1 位成员;页面已显示一列始祖资料与右侧世代切换,不能据此核验多人横向滚动。
## Findings
- [P1] 当前授权家谱只有一位成员,无法以真实数据复核参考图的“多位同世成员横向展开”状态。
- 影响:实际单列、世代切换和纵向文字已验证,但多人列宽、横向滚动和同代排序仍缺少真机证据。
- 修复:使用至少 5 位同世成员的测试家谱再次打开 T01;不应以本地伪造数据替代真实家谱数据。
## 已完成的源码核验
- T01 使用现有 `appApi.getTree` 单一数据 owner,按 `generation` 分组后每 5 人补齐空列并使用 `swiper` 横向分页;不引入参考项目的旧 `getGenealogyUserByParentsId` 接口。
- 列结构为关系、姓名、资料三段纵向阅读;同世人员位于横向滚动容器,右侧切换上一世/下一世。
- 静态合同、关系布局合同、人物操作合同和树接口运行时 smoke 均已通过。
final result: blocked
---
# T01 树状图与 T02 世系谱:真机回归
## 对比对象
- Source visual truth:本次会话中用户提供的参考项目“世系谱”截图(408 × 936 px)及 `C:\Users\Administrator\Desktop\job\Jiapu-App\pages\index\tree\index.vue` 的交互实现。
- Implementation captures`C:\Users\Administrator\AppData\Local\Temp\t01-entry-verify.png``C:\Users\Administrator\AppData\Local\Temp\t02-entry-verify.png``C:\Users\Administrator\AppData\Local\Temp\t02-swipe-verify.png``C:\Users\Administrator\AppData\Local\Temp\t02-return-after-fix.png`
- Device/stateAndroid 模拟器 720 × 1280 px;真实家谱“界面大数据回归谱0726”;T01 树状图、T02 第 1/2 世。
## 真机交互验收
- T01 右上角“世系谱”可进入独立 T02 表格页;T02 右上角“树状图”可返回 T01 图谱页。
- T02 从第 1 世直接左滑可切换到第 2 世,未使用点击箭头替代横向手势。
- 点击姓名打开“成员档案”;点击下方生平文字读取人物详情并打开“人物生平”弹窗,两个命中区域和结果不同。
- 返回失败的路由根因已修复:可选 `selectedId` 为空时不再传入 `undefined`,以符合导航参数必须为非空字符串的校验。
## 视觉核验状态
- 字体与排版、列格、纵向文字、朱红页头、右侧世代标识和背景纹理已在真机截图中检查。
- 当前会话里的参考截图没有可供本地重新打开的原始文件路径,因此无法按同一画布把参考图与实现图合成后做逐像素并排对比;不对像素级一致性作虚假通过结论。
## Implementation Checklist
- [x] 树状图与世系谱拆分为两个路由。
- [x] 双向页头跳转与原生横向滑动。
- [x] 姓名资料页与生平弹窗的分离点击行为。
- [x] HBuilderX 页面诊断、静态路由合约和 Android 真机回归。
final result: blocked
---
# T01 树状图与 T02 世系谱:设计核验
## 对比对象
- Source visual truth:用户本次会话提供的参考项目“世系谱”截图,以及 `C:\Users\Administrator\Desktop\job\Jiapu-App\pages\index\tree\index.vue``swiper` 实现。
- Implementation capture:未生成。当前 HBuilderX CLI 未识别已连接模拟器作为可部署设备,无法取得包含新 T01/T02 路由的截图。
- 目标状态:从“世系图”进入 T01 树状图;T01 顶部进入 T02 世系谱;T02 顶部返回 T01。
## 阻塞项
- [P0] 无法取得新版本的真机或浏览器实现截图,因此不能与参考截图进行同视口视觉比较,也不能把直接左右滑动标为已验收。
- 已完成代码核验:T01 恢复横向树状图;T02 使用原生 `swiper` 和页内纵向 `scroll-view`,每页五位成员。
- 需要:让 HBuilderX 识别任一已连接模拟器,或提供可运行的 H5 预览,以捕获两页入口和横滑状态。
final result: blocked
File diff suppressed because it is too large Load Diff
+33 -1
View File
@@ -1,6 +1,6 @@
{ {
"name" : "家谱", "name" : "家谱",
"appid": "__UNI__1997FFD", "appid" : "__UNI__2E0520B",
"description" : "家祠卷轴风格的家谱共建应用", "description" : "家祠卷轴风格的家谱共建应用",
"versionName" : "1.0.0", "versionName" : "1.0.0",
"versionCode" : 100, "versionCode" : 100,
@@ -30,6 +30,38 @@
}, },
"ios" : { "ios" : {
"dSYMs" : false "dSYMs" : false
},
"icons" : {
"android" : {
"hdpi" : "unpackage/res/icons/72x72.png",
"xhdpi" : "unpackage/res/icons/96x96.png",
"xxhdpi" : "unpackage/res/icons/144x144.png",
"xxxhdpi" : "unpackage/res/icons/192x192.png"
},
"ios" : {
"appstore" : "unpackage/res/icons/1024x1024.png",
"ipad" : {
"app" : "unpackage/res/icons/76x76.png",
"app@2x" : "unpackage/res/icons/152x152.png",
"notification" : "unpackage/res/icons/20x20.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"proapp@2x" : "unpackage/res/icons/167x167.png",
"settings" : "unpackage/res/icons/29x29.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"spotlight" : "unpackage/res/icons/40x40.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png"
},
"iphone" : {
"app@2x" : "unpackage/res/icons/120x120.png",
"app@3x" : "unpackage/res/icons/180x180.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"notification@3x" : "unpackage/res/icons/60x60.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"settings@3x" : "unpackage/res/icons/87x87.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png",
"spotlight@3x" : "unpackage/res/icons/120x120.png"
}
}
} }
} }
}, },
+26
View File
@@ -0,0 +1,26 @@
{
"name": "jiapuapp",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jiapuapp",
"devDependencies": {
"yaml": "^2.8.1"
}
},
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "jiapuapp",
"private": true,
"devDependencies": {
"yaml": "^2.8.1"
}
}
+7
View File
@@ -81,6 +81,13 @@
"disableScroll": true "disableScroll": true
} }
}, },
{
"path": "pages/tree/t02-pedigree-overview",
"style": {
"navigationStyle": "custom",
"disableScroll": true
}
},
{ {
"path": "pages/tree/t03-member-profile", "path": "pages/tree/t03-member-profile",
"style": { "style": {
+233 -75
View File
@@ -18,18 +18,32 @@
<view <view
class="overview-surface" class="overview-surface"
:class="{ 'overview-surface--state': overviewState !== 'ready' }" :class="{
'overview-surface--state': overviewState !== 'ready',
'overview-surface--member':
overviewState === 'ready' && viewMode === 'member',
}"
> >
<template <template
v-if="overviewState === 'ready' && genealogy && viewMode === 'member'" v-if="overviewState === 'ready' && genealogy && viewMode === 'member'"
> >
<view class="overview-ready"> <view class="overview-ready">
<view class="overview-hero"> <view class="overview-hero">
<view class="overview-hero__identity">
<view class="overview-hero__emblem">
<image
src="/static/assets/modules/genealogy/transparent/shortcut-tree.png"
mode="aspectFit"
/>
</view>
<view class="overview-hero__headings">
<text class="overview-hero__title">{{ genealogy.name }}</text>
<text class="overview-hero__kicker" <text class="overview-hero__kicker"
>{{ genealogy.hall || "堂号待补" }} · >{{ genealogy.hall || "堂号待补" }} ·
{{ genealogy.location || "所在地待补" }}</text {{ genealogy.location || "所在地待补" }}</text
> >
<text class="overview-hero__title">{{ genealogy.name }}</text> </view>
</view>
<text class="overview-hero__motto">{{ <text class="overview-hero__motto">{{
genealogy.intro || "简介待补充" genealogy.intro || "简介待补充"
}}</text> }}</text>
@@ -41,53 +55,97 @@
<text>{{ <text>{{
getGenealogyAccessPresetLabel(genealogy.accessPreset) getGenealogyAccessPresetLabel(genealogy.accessPreset)
}}</text> }}</text>
</view>
<view class="overview-hero__stats">
<text v-if="genealogy.joinTime" <text v-if="genealogy.joinTime"
>加入于 {{ formatGenealogyTime(genealogy.joinTime) }}</text >加入于 {{ formatGenealogyTime(genealogy.joinTime) }}</text
> >
</view> </view>
</view> </view>
<view <view class="overview-actions">
class="overview-actions"
:class="{ 'overview-actions--member': accessRole === 'member' }"
>
<view class="overview-action overview-action--tree" @click="toTree"> <view class="overview-action overview-action--tree" @click="toTree">
<text class="overview-action__title">世系树</text <view class="overview-action__icon overview-action__icon--primary">
><text>查看家脉关系</text> <image
src="/static/assets/modules/genealogy/transparent/shortcut-tree.png"
mode="aspectFit"
/>
</view> </view>
<view <view class="overview-action__copy">
class="overview-action overview-action--poem" <text class="overview-action__title">世系树</text>
@click="toGenerationPoems" <text>查看家脉关系</text>
> </view>
<text class="overview-action__title">字辈谱</text <image
><text>传承行辈次序</text> class="overview-action__chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view> </view>
<view <view
v-if="accessRole === 'owner'" v-if="accessRole === 'owner'"
class="overview-action overview-action--review" class="overview-action overview-action--review"
@click="toApplications" @click="toApplications"
> >
<text class="overview-action__title">入谱审核</text <image
><text>处理加入申请</text> class="overview-action__icon"
src="/static/assets/modules/genealogy/transparent/shortcut-application.png"
mode="aspectFit"
/>
<view class="overview-action__copy">
<text class="overview-action__title">入谱审核</text>
<text>处理加入申请</text>
</view>
<image
class="overview-action__chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view>
<view
class="overview-action overview-action--poem"
@click="toGenerationPoems"
>
<image
class="overview-action__icon"
src="/static/assets/foundation/transparent/tab-genealogy.png"
mode="aspectFit"
/>
<view class="overview-action__copy">
<text class="overview-action__title">字辈谱</text>
<text>传承行辈次序</text>
</view>
<image
class="overview-action__chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view> </view>
<view <view
v-if="accessRole === 'owner'" v-if="accessRole === 'owner'"
class="overview-action overview-action--settings" class="overview-action overview-action--settings"
@click="toSettings" @click="toSettings"
> >
<text class="overview-action__title">家谱设置</text <image
><text>维护公开范围与基础资料</text> class="overview-action__icon"
src="/static/assets/modules/genealogy/transparent/g05-settings-gear.png"
mode="aspectFit"
/>
<view class="overview-action__copy">
<text class="overview-action__title">家谱设置</text>
<text>维护公开范围与基础资料</text>
</view>
<image
class="overview-action__chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view> </view>
<template v-else> <template v-else>
<view class="overview-summary"> <view class="overview-summary">
<text class="overview-action__title">成员身份</text <text class="overview-action__title">成员身份</text>
><text>已加入 · 普通成员</text> <text>已加入 · 普通成员</text>
</view> </view>
<view class="overview-summary"> <view class="overview-summary">
<text class="overview-action__title">访问范围</text <text class="overview-action__title">访问范围</text>
><text>{{ <text>{{
getGenealogyAccessPresetLabel(genealogy.accessPreset) getGenealogyAccessPresetLabel(genealogy.accessPreset)
}}</text> }}</text>
</view> </view>
@@ -95,8 +153,14 @@
</view> </view>
<view class="overview-family" @click="toFamily"> <view class="overview-family" @click="toFamily">
<text>家族近况</text><text>进入家族圈查看动态</text <view>
><text>查看</text> <text>家族近况</text>
<text>进入家族圈查看动态</text>
</view>
<image
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view> </view>
<view class="overview-note"> <view class="overview-note">
<text class="overview-note__title">成员资料按家谱访问规则保护</text> <text class="overview-note__title">成员资料按家谱访问规则保护</text>
@@ -388,6 +452,12 @@ const applyToJoin = () => {
min-height: min(483px, calc(100vw * 1.3422)); min-height: min(483px, calc(100vw * 1.3422));
flex: 0 0 auto; flex: 0 0 auto;
} }
.overview-surface--member {
min-height: 100%;
border: 0;
border-image: none;
background: #fbf4e7;
}
.overview-surface--state { .overview-surface--state {
display: flex; display: flex;
height: calc(100vh - 112rpx); height: calc(100vh - 112rpx);
@@ -398,35 +468,64 @@ const applyToJoin = () => {
} }
.overview-ready { .overview-ready {
display: flex; display: flex;
min-height: min(540px, calc(100vw * 1.5));
flex-direction: column; flex-direction: column;
gap: 34rpx; min-height: 0;
padding: 10rpx 0 42rpx; padding: 0 0 calc(34rpx + env(safe-area-inset-bottom));
box-sizing: border-box; box-sizing: border-box;
} }
.overview-hero { .overview-hero {
margin: 0 8%; display: flex;
min-height: 286rpx;
flex-direction: column;
justify-content: flex-end;
margin: 0;
padding: 34rpx 42rpx 28rpx;
background: #10293b
url("/static/assets/modules/genealogy/opaque/g05-overview-surface.png")
center top / auto 1220rpx no-repeat;
color: #fff8ec; color: #fff8ec;
box-sizing: border-box;
}
.overview-hero__identity {
display: grid;
grid-template-columns: 108rpx minmax(0, 1fr);
align-items: center;
gap: 22rpx;
}
.overview-hero__emblem {
display: grid;
width: 108rpx;
height: 108rpx;
place-items: center;
border: 2rpx solid rgba(225, 190, 111, 0.86);
border-radius: 50%;
background: rgba(16, 41, 59, 0.56);
box-sizing: border-box;
}
.overview-hero__emblem image {
width: 64rpx;
height: 64rpx;
} }
.overview-hero__kicker { .overview-hero__kicker {
display: block; display: block;
color: #dec483; color: #dec483;
font-size: 22rpx; font-size: 20rpx;
letter-spacing: 3rpx; line-height: 1.5;
} }
.overview-hero__title { .overview-hero__title {
display: block; display: block;
margin-top: 12rpx; margin-bottom: 6rpx;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 46rpx; font-size: 42rpx;
font-weight: 700; font-weight: 700;
letter-spacing: 4rpx; letter-spacing: 2rpx;
} }
.overview-hero__motto { .overview-hero__motto {
display: block; display: block;
margin-top: 6rpx; height: 66rpx;
margin-top: 22rpx;
color: #e9dcc1; color: #e9dcc1;
font-size: 23rpx; font-size: 21rpx;
line-height: 1.55; line-height: 1.55;
overflow: hidden; overflow: hidden;
display: -webkit-box; display: -webkit-box;
@@ -434,79 +533,138 @@ const applyToJoin = () => {
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
} }
.overview-hero__stats { .overview-hero__stats {
display: inline-flex; display: flex;
gap: 18rpx; flex-wrap: wrap;
margin-top: 12rpx; gap: 8rpx 0;
margin-top: 18rpx;
color: #dbc990; color: #dbc990;
font-size: 22rpx; font-size: 19rpx;
transform: translateY(-18rpx); line-height: 1.4;
} }
.overview-hero__stats + .overview-hero__stats { .overview-hero__stats text {
margin-left: 18rpx; display: flex;
align-items: center;
}
.overview-hero__stats text + text::before {
width: 1rpx;
height: 20rpx;
margin: 0 14rpx;
background: rgba(220, 201, 144, 0.55);
content: "";
} }
.overview-actions { .overview-actions {
display: grid; display: flex;
min-height: 386rpx; flex-direction: column;
grid-template-columns: 1fr 1fr; padding: 28rpx 42rpx 0;
grid-template-rows: auto auto; background: #fbf4e7;
gap: 3.4% 3%; box-sizing: border-box;
margin: 0 7.2%;
}
.overview-actions--member {
min-height: 386rpx;
} }
.overview-action, .overview-action,
.overview-summary { .overview-summary {
display: flex; display: grid;
flex-direction: column; grid-template-columns: 72rpx minmax(0, 1fr) 28rpx;
justify-content: center; min-height: 126rpx;
align-items: center;
gap: 24rpx;
min-width: 0; min-width: 0;
padding: 24rpx 22rpx; padding: 18rpx 0;
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
box-sizing: border-box; box-sizing: border-box;
color: $ink-muted; color: $ink-muted;
font-size: 22rpx; font-size: 21rpx;
line-height: 1.5;
}
.overview-action--tree {
min-height: 176rpx;
grid-template-columns: 128rpx minmax(0, 1fr) 28rpx;
gap: 28rpx;
border-top: 0;
}
.overview-action__icon {
width: 64rpx;
height: 64rpx;
justify-self: center;
object-fit: contain;
}
.overview-action__icon--primary {
display: grid;
width: 118rpx;
height: 118rpx;
place-items: center;
border: 2rpx solid rgba(160, 37, 30, 0.88);
border-radius: 50%;
background: #aa3129;
box-sizing: border-box;
}
.overview-action__icon--primary image {
width: 68rpx;
height: 68rpx;
filter: brightness(0) invert(1);
}
.overview-action__copy {
min-width: 0;
}
.overview-action__copy text,
.overview-summary text {
display: block;
}
.overview-action__chevron,
.overview-family image {
width: 26rpx;
height: 26rpx;
opacity: 0.68;
} }
.overview-action__title { .overview-action__title {
margin-bottom: 9rpx; margin-bottom: 5rpx;
color: $ink; color: $ink;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 30rpx; font-size: 30rpx;
font-weight: 700; font-weight: 700;
} }
.overview-action--tree .overview-action__title {
font-size: 40rpx;
}
.overview-summary {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.overview-family { .overview-family {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: minmax(0, 1fr) 26rpx;
align-items: center; align-items: center;
align-self: start; gap: 20rpx;
gap: 16rpx; margin: 14rpx 42rpx 0;
margin: 0 7.5%; padding: 20rpx 0;
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
color: $ink-muted; color: $ink-muted;
font-size: 22rpx; font-size: 20rpx;
line-height: 1.5;
}
.overview-family text {
display: block;
} }
.overview-family text:first-child { .overview-family text:first-child {
margin-bottom: 3rpx;
color: $ink; color: $ink;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 28rpx; font-size: 27rpx;
font-weight: 700; font-weight: 700;
} }
.overview-family text:last-child {
color: $brand-red;
}
.overview-note { .overview-note {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; margin: 18rpx 42rpx 0;
margin: 0 8.5%; padding: 18rpx 0 0;
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
color: $ink-muted; color: $ink-muted;
font-size: 22rpx; font-size: 19rpx;
line-height: 1.6; line-height: 1.55;
margin-top: 100rpx;
} }
.overview-note__title { .overview-note__title {
margin-bottom: 8rpx; margin-bottom: 5rpx;
color: $brand-red; color: $brand-red;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 28rpx; font-size: 24rpx;
font-weight: 700; font-weight: 700;
} }
.overview-state { .overview-state {
+485 -133
View File
@@ -1,70 +1,152 @@
<!-- 页面编号M-01用途个人中心显示已核实的当前用户资料 --> <!-- 页面编号M-01用途个人中心显示当前用户资料与服务入口 -->
<template> <template>
<view class="profile-page"> <view
<ModulePageBackground module="profile" /> class="profile-page"
<view class="page-header"><PageHeader root title="我的" /></view> :class="{
<view class="page-content"> 'profile-state--ready': !loading && !loadError,
<view v-if="loading" class="state-card" 'profile-state--error': Boolean(loadError),
><text>正在读取个人资料</text></view }"
> >
<view v-else-if="loadError" class="state-card"> <view v-if="compactHeader" class="profile-compact-header" aria-hidden="true">
<text>我的</text>
</view>
<view v-if="loading" class="profile-state-card">
<text>正在读取个人资料</text>
</view>
<view v-else-if="loadError" class="profile-state-card">
<text>个人资料暂时无法读取</text> <text>个人资料暂时无法读取</text>
<text>{{ loadError }}</text> <text>{{ loadError }}</text>
<AppButton block label="重新读取" @click="loadProfile" /> <AppButton block label="重新读取" @click="loadProfile" />
</view> </view>
<template v-else> <template v-else>
<view class="profile-summary"> <view class="profile-hero">
<view class="profile-summary__avatar" <image
class="profile-hero__art"
src="/static/assets/modules/profile/opaque/m01-profile-archive-hero-v2.png"
mode="scaleToFill"
aria-hidden="true"
/>
<view class="profile-hero__content">
<view class="profile-hero__label">
<text>我的</text>
<view
class="profile-hero__setting"
role="button"
aria-label="关于与设置"
@click="openSettings"
><image ><image
src="/static/assets/foundation/transparent/auth-login-outline.png" class="profile-hero__setting-icon"
src="/static/assets/modules/profile/transparent/m01-icon-settings.png"
mode="aspectFit" mode="aspectFit"
aria-hidden="true"
/></view> /></view>
<view class="profile-summary__identity"> </view>
<view class="profile-hero__identity">
<image
class="profile-hero__emblem"
src="/static/assets/modules/profile/transparent/m01-profile-tree-medallion.png"
mode="aspectFit"
aria-hidden="true"
/>
<view class="profile-hero__copy">
<text>{{ profile.nickName || "未设置昵称" }}</text> <text>{{ profile.nickName || "未设置昵称" }}</text>
<text>{{ profile.realName || "未设置真实姓名" }}</text> <text>{{ profile.realName || "未设置真实姓名" }}</text>
<text>{{ profile.phone || "未提供手机号" }}</text> <text>{{ profile.phone || "未提供手机号" }}</text>
</view> </view>
<AppButton </view>
class="profile-summary__edit" <button
label="编辑资料" class="profile-hero__edit"
@click="openEdit" @click="openEdit"
>编辑资料</button>
</view>
</view>
<view class="profile-metadata" aria-label="个人资料摘要">
<view class="profile-metadata__item">
<image
class="profile-metadata__icon"
src="/static/assets/modules/profile/transparent/m01-icon-person.png"
mode="aspectFit"
aria-hidden="true"
/>
<view><text>性别</text><text>{{ sexText }}</text></view>
</view>
<view class="profile-metadata__item">
<image
class="profile-metadata__icon"
src="/static/assets/modules/profile/transparent/m01-icon-calendar.png"
mode="aspectFit"
aria-hidden="true"
/>
<view><text>生日</text><text>{{ birthdayText }}</text></view>
</view>
<view class="profile-metadata__item profile-metadata__item--email">
<image
class="profile-metadata__icon"
src="/static/assets/modules/profile/transparent/m01-icon-envelope.png"
mode="aspectFit"
aria-hidden="true"
/>
<view><text>邮箱</text><text>{{ profile.email || "未设置" }}</text></view>
</view>
</view>
<view class="profile-services">
<view
v-for="group in serviceGroups"
:key="group.title"
class="profile-service-group"
>
<view class="profile-section-title">
<view class="profile-section-title__mark" aria-hidden="true"></view>
<text>{{ group.title }}</text>
</view>
<view class="profile-service-list">
<view
v-for="item in group.items"
:key="item.routeKey"
class="profile-service-row"
role="button"
:aria-label="item.label"
@click="openService(item)"
>
<view class="profile-service-row__leading">
<image
:class="[
'profile-service-row__icon',
`profile-service-row__icon--${item.routeKey.toLowerCase()}`,
]"
:src="item.icon"
mode="aspectFit"
aria-hidden="true"
/>
<text>{{ item.label }}</text>
</view>
<image
class="profile-service-row__chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
aria-hidden="true"
/> />
</view> </view>
<view class="profile-fields">
<view class="profile-field"
><text>性别</text><text>{{ sexText }}</text></view
>
<view class="profile-field"
><text>生日</text><text>{{ birthdayText }}</text></view
>
<view class="profile-field"
><text>邮箱</text><text>{{ profile.email || "未设置" }}</text></view
>
</view> </view>
</view>
<view class="profile-menu">
<AppButton block label="账号与安全" @click="open('M03')" />
<AppButton block label="消息中心" @click="open('N01')" />
<AppButton block label="帮助中心" @click="open('M06')" />
<AppButton block label="意见反馈" @click="open('M07')" />
<AppButton block label="应用推广" @click="open('M08')" />
<AppButton block label="VIP 服务" @click="open('M09')" />
<AppButton block label="关于与设置" @click="open('M10')" />
</view> </view>
</template> </template>
</view>
<AppTabbar active="profile" /> <AppTabbar active="profile" />
</view> </view>
</template> </template>
<script setup> <script setup>
import { computed, reactive, ref } from "vue"; import { computed, reactive, ref } from "vue";
import { onShow, onUnload } from "@dcloudio/uni-app"; import { onPageScroll, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue"; import AppButton from "@/components/AppButton.vue";
import AppTabbar from "@/components/AppTabbar.vue"; import AppTabbar from "@/components/AppTabbar.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { import {
appApi, appApi,
createRequestController, createRequestController,
@@ -83,11 +165,59 @@ const profile = reactive({
}); });
const loading = ref(false); const loading = ref(false);
const loadError = ref(""); const loadError = ref("");
const compactHeader = ref(false);
const requestController = createRequestController(); const requestController = createRequestController();
let pageActive = true; let pageActive = true;
const sexText = computed(() => (profile.sex ? "待确认" : "未设置")); const serviceGroups = [
{
title: "账户与服务",
items: [
{
routeKey: "M03",
label: "账号与安全",
icon: "/static/assets/modules/profile/transparent/m01-icon-security.png",
},
{
routeKey: "N01",
label: "消息中心",
icon: "/static/assets/modules/profile/transparent/m01-icon-message.png",
},
{
routeKey: "M09",
label: "VIP 服务",
icon: "/static/assets/modules/profile/transparent/m01-icon-vip.png",
},
],
},
{
title: "支持与其他",
items: [
{
routeKey: "M06",
label: "帮助中心",
icon: "/static/assets/modules/profile/transparent/m01-icon-help.png",
},
{
routeKey: "M07",
label: "意见反馈",
icon: "/static/assets/modules/profile/transparent/m01-icon-feedback.png",
},
{
routeKey: "M08",
label: "应用推广",
icon: "/static/assets/modules/profile/transparent/m01-icon-promotion.png",
},
{
routeKey: "M10",
label: "关于与设置",
icon: "/static/assets/modules/profile/transparent/m01-icon-settings.png",
},
],
},
];
const sexText = computed(() => (profile.sex ? "待确认" : "未设置"));
const birthdayText = computed(() => const birthdayText = computed(() =>
/^\d{4}-\d{2}-\d{2}/.test(profile.birthday) /^\d{4}-\d{2}-\d{2}/.test(profile.birthday)
? profile.birthday.slice(0, 10) ? profile.birthday.slice(0, 10)
@@ -111,143 +241,365 @@ const loadProfile = async () => {
} }
}; };
const open = (route) => openPage(route, {}, "M01"); const openService = (item) => openPage(item.routeKey, {}, "M01");
const openEdit = () => open("M02"); const openEdit = () => openPage("M02", {}, "M01");
const openSettings = () => openPage("M10", {}, "M01");
onShow(loadProfile); onShow(loadProfile);
onPageScroll(({ scrollTop = 0 }) => {
compactHeader.value = scrollTop > 44;
});
onUnload(() => { onUnload(() => {
pageActive = false; pageActive = false;
compactHeader.value = false;
requestController.abort(); requestController.abort();
}); });
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive; @use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.profile-page { .profile-page {
display: flex; width: 100%;
min-height: 100vh; min-height: 100vh;
flex-direction: column; padding-bottom: 166rpx;
background: $paper;
}
.page-header,
.page-content {
z-index: 1;
}
.page-content {
flex: 1;
padding: 18rpx 24rpx 190rpx;
}
.state-card,
.profile-summary,
.profile-fields {
@include adaptive.adaptive-family-content;
}
.state-card {
box-sizing: border-box; box-sizing: border-box;
min-height: 340rpx; overflow-x: hidden;
padding: 78rpx 52rpx 50rpx; background: #f3eee5 url("/static/assets/modules/profile/opaque/m01-profile-paper-mountains-v1.png") center 360rpx / 100% auto no-repeat;
}
.profile-compact-header {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 30;
height: 88rpx;
box-sizing: border-box;
padding: 38rpx 48rpx 0;
background: linear-gradient(90deg, #6a1d17, #4e120f);
box-shadow: 0 2rpx 12rpx rgba(58, 16, 12, 0.22);
color: #f2d9a8;
font-family: STKaiti, KaiTi, serif;
font-size: 28rpx;
font-weight: 700;
letter-spacing: 3rpx;
line-height: 1;
pointer-events: none;
}
.profile-state-card {
@include adaptive.adaptive-profile-summary;
margin: 48rpx 30rpx;
padding: 64rpx 44rpx;
color: #382c25;
text-align: center; text-align: center;
} }
.state-card text {
.profile-state-card text {
display: block; display: block;
font-size: 26rpx;
line-height: 1.6;
} }
.state-card text:first-child {
color: $ink; .profile-state-card text + text {
font-family: STKaiti, KaiTi, serif; margin-top: 12rpx;
font-size: 35rpx; color: #806e5d;
font-weight: 700; font-size: 22rpx;
} }
.state-card text:nth-child(2) {
margin-top: 18rpx; .profile-state-card .app-button {
color: $ink-muted; @include adaptive.adaptive-scroll-button(primary);
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx; margin-top: 28rpx;
} }
.profile-summary {
.profile-hero {
display: grid; display: grid;
grid-template-columns: 96rpx minmax(0, 1fr); width: 100%;
align-items: center; height: 503rpx;
gap: 18rpx;
padding: 30rpx;
} }
.profile-summary__avatar {
.profile-hero__art,
.profile-hero__content {
grid-area: 1 / 1;
}
.profile-hero__art {
width: 100%;
height: 480rpx;
pointer-events: none;
}
.profile-hero__content {
display: flex; display: flex;
width: 96rpx; flex-direction: column;
height: 96rpx; z-index: 1;
padding: 100rpx 56rpx 50rpx 54rpx;
box-sizing: border-box;
}
.profile-hero__label {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
color: #f0d6a4;
font-family: STKaiti, KaiTi, serif;
font-size: 54rpx;
font-weight: 700;
letter-spacing: 5rpx;
line-height: 1;
}
.profile-hero__setting {
display: flex;
width: 48rpx;
height: 48rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1rpx solid rgba(159, 23, 15, 0.32);
border-radius: 50%;
background: rgba(159, 23, 15, 0.06);
} }
.profile-summary__avatar image {
width: 64rpx; .profile-hero__setting-icon {
height: 64rpx; width: 42rpx;
opacity: 0.8; height: 42rpx;
filter: grayscale(1) sepia(0.9) saturate(1.6) hue-rotate(348deg) brightness(1.35);
transform: scale(1.25);
} }
.profile-summary__identity {
.profile-hero__identity {
display: flex;
min-width: 0; min-width: 0;
align-items: flex-start;
position: relative;
margin-top: 43rpx;
margin-left: -30rpx;
} }
.profile-summary__identity text {
.profile-hero__emblem {
width: 220rpx;
height: 220rpx;
flex: 0 0 auto;
}
.profile-hero__copy {
min-width: 0;
flex: 1;
margin: 46rpx 0 0 -5rpx;
}
.profile-hero__copy text {
display: block; display: block;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.profile-summary__identity text:first-child {
color: $ink; .profile-hero__copy text:first-child {
font-family: STKaiti, KaiTi, serif; color: #fff2da;
font-family: STSong, SimSun, serif;
font-size: 32rpx; font-size: 32rpx;
font-weight: 700; font-weight: 700;
letter-spacing: 1rpx;
line-height: 1.25;
white-space: nowrap;
} }
.profile-summary__identity text:nth-child(2),
.profile-summary__identity text:last-child { .profile-hero__copy text:nth-child(2) {
margin-top: 5rpx; margin-top: 14rpx;
color: $ink-muted; color: #ecd2a4;
font-size: 21rpx; font-size: 27rpx;
line-height: 1.35;
} }
.profile-summary__edit {
grid-column: 1 / -1; .profile-hero__copy text:last-child {
margin-top: 6rpx;
}
.profile-fields {
display: flex;
flex-direction: column;
margin-top: 18rpx; margin-top: 18rpx;
padding: 8rpx 30rpx; color: #e8bd78;
font-size: 28rpx;
letter-spacing: 1rpx;
} }
.profile-field {
.profile-hero__edit {
position: absolute;
top: 337rpx;
right: 61rpx;
width: 154rpx;
min-height: 52rpx;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent url("/static/assets/modules/profile/transparent/m01-profile-edit-button-v2.png") center / 100% 100% no-repeat;
box-shadow: none;
color: #fff6e5;
font-size: 22rpx;
font-weight: 700;
letter-spacing: 1rpx;
}
.profile-hero__edit::after {
border: 0;
}
.profile-metadata {
display: flex; display: flex;
min-height: 82rpx; min-height: 146rpx;
z-index: 1;
margin: -36rpx 34rpx 0;
padding: 5rpx 0;
border: 1rpx solid rgba(117, 83, 52, 0.09);
border-radius: 18rpx;
background: rgba(239, 234, 224, 0.92);
}
.profile-metadata__item {
display: flex;
min-width: 0;
align-items: center;
flex: 1;
gap: 10rpx;
padding: 0 10rpx;
border-right: 1rpx solid rgba(117, 83, 52, 0.16);
}
.profile-metadata__item:last-child {
border-right: 0;
}
.profile-metadata__item--email {
flex: 2.3;
}
.profile-metadata__icon {
width: 42rpx;
height: 42rpx;
filter: sepia(0.7) saturate(0.8) hue-rotate(330deg) brightness(0.82);
transform: scale(2.1);
}
.profile-metadata__item view {
min-width: 0;
transform: translateY(14rpx);
}
.profile-metadata__item text {
display: block;
}
.profile-metadata__item text:first-child {
color: #857263;
font-size: 20rpx;
}
.profile-metadata__item text:last-child {
margin-top: 0;
color: #382c25;
font-size: 24rpx;
line-height: 1.25;
overflow-wrap: anywhere;
}
.profile-metadata__item--email text:last-child {
font-size: 13rpx;
white-space: nowrap;
}
.profile-services {
margin: 49rpx 58rpx 0 48rpx;
}
.profile-service-group + .profile-service-group {
margin-top: 51rpx;
}
.profile-section-title {
display: flex;
align-items: center;
color: #382c25;
font-family: STSong, SimSun, serif;
font-size: 34rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.profile-section-title__mark {
width: 6rpx;
height: 24rpx;
border-radius: 6rpx;
background: #8d2722;
pointer-events: none;
flex: 0 0 auto;
margin-right: 24rpx;
}
.profile-service-list {
margin-top: 2rpx;
}
.profile-service-row {
display: flex;
min-height: 96rpx;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 20rpx; gap: 24rpx;
border-bottom: 1rpx solid rgba(181, 137, 63, 0.3); border-bottom: 1rpx solid rgba(112, 85, 61, 0.2);
color: #45362d;
} }
.profile-field:last-child {
border-bottom: 0; .profile-service-row__leading {
} display: flex;
.profile-field text:first-child {
flex: 0 0 auto;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.profile-field text:last-child {
min-width: 0; min-width: 0;
color: $ink-muted; align-items: center;
font-size: 22rpx; gap: 30rpx;
overflow-wrap: anywhere; padding-left: 7rpx;
text-align: right;
} }
.profile-menu {
display: grid; .profile-service-row__icon {
grid-template-columns: repeat(2, minmax(0, 1fr)); width: 44rpx;
gap: 14rpx; height: 44rpx;
margin-top: 24rpx; flex: 0 0 auto;
transform: translate(7rpx, 0) scale(1.8);
} }
.profile-menu .app-button {
margin: 0; .profile-service-row__icon--m03 {
transform: translate(9rpx, -9rpx) scale(1.8);
} }
.profile-service-row__icon--n01 {
transform: translate(10rpx, -10rpx) scale(1.8);
}
.profile-service-row__icon--m09 {
transform: translate(-5rpx, 5rpx) scale(1.8);
}
.profile-service-row__icon--m06 {
transform: translate(-3rpx, 7rpx) scale(1.8);
}
.profile-service-row__icon--m07 {
transform: translate(4rpx, 8rpx) scale(1.8);
}
.profile-service-row__icon--m08 {
transform: translate(11rpx, 7rpx) scale(1.8);
}
.profile-service-row__icon--m10 {
transform: translate(9rpx, 7rpx) scale(1.8);
}
.profile-service-row text {
font-size: 32rpx;
line-height: 1.3;
}
.profile-service-row__chevron {
width: 24rpx;
height: 24rpx;
flex: 0 0 auto;
opacity: 0.86;
}
.profile-service-row:active {
opacity: 0.68;
}
</style> </style>
+394 -34
View File
@@ -1,28 +1,324 @@
<!-- 页面编号M-05用途换绑手机号缺专用发码/资料 DTO 且需人工 TAC/短信时保持关闭 --> <!-- 页面编号M-05用途换绑已登录账号的手机号 -->
<template> <template>
<view class="phone-page" <view
><ModulePageBackground module="profile" /><view class="page-header" class="phone-page"
><PageHeader :class="{
title="换绑手机号" 'phone-state--sending': phoneState === 'sending',
custom-back 'phone-state--submitting': phoneState === 'submitting',
@back="backToSecurity" /></view }"
><view class="page-content" >
><view class="state-card" <ModulePageBackground module="profile" />
><text>暂不能在线换绑手机号</text <view class="page-layer"
><text>为保护账户安全请联系管理员协助办理手机号换绑</text ><PageHeader title="换绑手机号" custom-back @back="requestBack"
><AppButton /></view>
<view class="page-content page-layer">
<view class="security-tip"
><text>验证新手机号</text
><text>完成行为验证和短信校验后立即更新当前账号的登录手机号</text></view
>
<view class="form-panel">
<view class="field-block">
<view class="form-row">
<text>新手机号</text>
<input
v-model.trim="phone"
type="number"
maxlength="11"
aria-label="新手机号"
placeholder="请输入新手机号"
@input="handlePhoneInput"
/>
</view>
<text v-if="errors.phone" class="field-error">{{ errors.phone }}</text>
</view>
<view class="field-block">
<view class="form-row form-row--code">
<text>短信验证码</text>
<input
v-model.trim="smsCode"
type="number"
maxlength="4"
aria-label="短信验证码"
placeholder="4 位验证码"
@input="errors.smsCode = ''"
/>
<AppButton
compact
type="secondary"
:disabled="phoneState !== 'ready' || cooldownSeconds > 0"
:label="cooldownSeconds > 0 ? `${cooldownSeconds}s 后重试` : '获取验证码'"
@click="prepareGetCode"
/>
</view>
<text v-if="errors.smsCode" class="field-error">{{ errors.smsCode }}</text>
</view>
</view>
<AppButton
block block
label="返回账号与安全" :disabled="phoneState !== 'ready'"
@click="backToSecurity" /></view></view :label="phoneState === 'submitting' ? '正在换绑' : '确认换绑手机号'"
></view> @click="submitPhoneChange"
/>
</view>
<TacVerification
:visible="tacVisible"
:context="tacContext"
@success="completeTac"
@failure="handleTacFailure"
@error="handleTacError"
@cancel="closeTac"
/>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃手机号换绑?"
message="新手机号和验证码尚未提交服务器。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template> </template>
<script setup> <script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue"; import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { returnTo } from "@/utils/navigation.js"; import TacVerification from "@/components/TacVerification.vue";
const backToSecurity = () => returnTo("M03"); import {
appApi,
createRequestController,
isRequestCancelled,
} from "@/utils/api.js";
import {
AUTH_VERIFICATION_OPERATION,
createTacRenderContext,
isAuthPhone,
isSmsDeliveryOutcomeUnknown,
normalizeCaptchaRequirement,
normalizeTacSuccess,
} from "@/utils/auth-verification.js";
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
import { runtimeConfig } from "@/utils/config.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const phone = ref("");
const smsCode = ref("");
const sentPhone = ref("");
const errors = reactive({ phone: "", smsCode: "" });
const phoneState = ref("ready");
const cooldownSeconds = ref(0);
const tacVisible = ref(false);
const tacContext = ref(null);
const discardVisible = ref(false);
const toastVisible = ref(false);
const toastMessage = ref("");
const formSnapshot = computed(() => JSON.stringify({ phone: phone.value, smsCode: smsCode.value }));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const requestController = createRequestController();
const smsCooldown = createAuthSmsCooldown({
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
onChange: (seconds) => {
cooldownSeconds.value = seconds;
},
});
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
let toastTimer = null;
let tacSequence = 0;
let pageActive = true;
const showToast = (message) => {
toastMessage.value = message;
toastVisible.value = true;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
}, 2200);
};
const closeTac = () => {
tacVisible.value = false;
tacContext.value = null;
};
const handlePhoneInput = () => {
errors.phone = "";
if (phone.value === sentPhone.value) return;
smsCode.value = "";
sentPhone.value = "";
errors.smsCode = "";
};
const sendSmsCode = async (requestedPhone, validToken) => {
await appApi.sendSmsCode(
{
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
phone: requestedPhone,
...(validToken ? { validToken } : {}),
},
{ requestController },
);
if (!pageActive) return;
sentPhone.value = requestedPhone;
smsCooldown.start();
showToast("验证码已发送");
};
const prepareGetCode = async () => {
if (phoneState.value !== "ready" || cooldownSeconds.value > 0) return;
if (!isAuthPhone(phone.value)) {
errors.phone = "请输入正确手机号";
return;
}
errors.phone = "";
phoneState.value = "sending";
try {
const requestedPhone = phone.value;
const response = await appApi.getCaptchaRequirement(
{
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
subject: requestedPhone,
},
{ requestController },
);
if (!pageActive) return;
if (phone.value !== requestedPhone) {
throw new Error("手机号已变化,请重新获取验证码");
}
const requirement = normalizeCaptchaRequirement(response);
if (!requirement.required) {
await sendSmsCode(requestedPhone);
return;
}
tacSequence += 1;
tacContext.value = createTacRenderContext({
requestId: `m05-phone-change-${tacSequence}`,
baseUrl: runtimeConfig.baseUrl,
clientId: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
subject: requestedPhone,
requirement,
});
tacVisible.value = true;
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showToast(error?.message || "安全验证暂不可用");
}
} finally {
if (pageActive) phoneState.value = "ready";
}
};
const completeTac = async (result) => {
const context = tacContext.value;
if (!context || phoneState.value !== "ready") return;
phoneState.value = "sending";
try {
const ticket = normalizeTacSuccess(result, context.requestId);
if (phone.value !== context.subject) {
throw new Error("手机号已变化,请重新验证");
}
closeTac();
await sendSmsCode(context.subject, ticket.validToken);
} catch (error) {
closeTac();
if (pageActive) {
if (isSmsDeliveryOutcomeUnknown(error)) {
sentPhone.value = context.subject;
smsCooldown.start();
showToast("发送结果未知,如收到短信可直接填写;60 秒后可重试");
} else if (!isRequestCancelled(error)) {
showToast(error?.message || "验证码发送失败");
}
}
} finally {
if (pageActive) phoneState.value = "ready";
}
};
const handleTacFailure = ({ message } = {}) => {
showToast(message || "行为验证未通过,请重试");
};
const handleTacError = ({ message } = {}) => {
closeTac();
showToast(message || "安全验证暂不可用");
};
const validateForm = () => {
errors.phone = isAuthPhone(phone.value) ? "" : "请输入正确手机号";
errors.smsCode =
sentPhone.value !== phone.value
? "请先获取当前手机号的验证码"
: /^\d{4}$/.test(smsCode.value)
? ""
: "请输入 4 位验证码";
return !errors.phone && !errors.smsCode;
};
const submitPhoneChange = async () => {
if (phoneState.value !== "ready" || !validateForm()) return;
phoneState.value = "submitting";
try {
await appApi.changePhone(
{ phone: phone.value, smsCode: smsCode.value },
{ requestController },
);
if (!pageActive) return;
phone.value = "";
smsCode.value = "";
sentPhone.value = "";
baseline.value = formSnapshot.value;
showToast("手机号换绑成功");
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showToast(error?.message || "换绑结果未知,请重新登录确认手机号");
}
} finally {
if (pageActive) phoneState.value = "ready";
}
};
const requestBack = () =>
runBackGuard({
transientOpen: tacVisible.value || discardVisible.value,
dirty: isDirty.value,
submitting: phoneState.value !== "ready",
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
"block-submitting": () => {
requestController.abort();
return true;
},
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onShow(() => smsCooldown.sync());
onUnload(() => {
pageActive = false;
requestController.abort();
smsCooldown.dispose();
discardConfirmation.dispose();
clearTimeout(toastTimer);
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive; @use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.phone-page { .phone-page {
@@ -31,36 +327,100 @@ const backToSecurity = () => returnTo("M03");
flex-direction: column; flex-direction: column;
background: $paper; background: $paper;
} }
.page-header, .page-layer {
.page-content {
z-index: 1; z-index: 1;
} }
.page-content { .page-content {
padding: 18rpx 24rpx 72rpx; flex: 1;
padding: 28rpx 30rpx 72rpx;
} }
.state-card { .security-tip,
box-sizing: border-box; .form-panel {
min-height: 340rpx; @include adaptive.adaptive-profile-content;
padding: 78rpx 52rpx 50rpx; }
.security-tip {
min-height: 170rpx;
padding: 38rpx 44rpx;
text-align: center; text-align: center;
@include adaptive.adaptive-family-content;
} }
.state-card text { .security-tip text {
display: block; display: block;
} }
.state-card text:first-child { .security-tip text:first-child {
color: $ink; color: $ink;
font-family: STKaiti, KaiTi, serif; font-size: 32rpx;
font-size: 35rpx;
font-weight: 700; font-weight: 700;
} }
.state-card text:nth-child(2) { .security-tip text:last-child {
margin-top: 18rpx; margin-top: 10rpx;
color: $ink-muted; color: $ink-muted;
font-size: 24rpx; font-size: 22rpx;
line-height: 1.65; line-height: 1.55;
} }
.state-card .app-button { .form-panel {
margin-top: 20rpx;
padding: 22rpx 34rpx 30rpx;
}
.field-block + .field-block {
margin-top: 6rpx;
}
.form-row {
display: grid;
grid-template-columns: 142rpx minmax(0, 1fr);
min-height: 92rpx;
align-items: center;
gap: 12rpx;
border-bottom: 1px solid rgba(181, 137, 63, 0.42);
}
.form-row--code {
grid-template-columns: 142rpx minmax(0, 1fr) 170rpx;
}
.form-row > text {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.form-row input {
width: auto;
min-width: 0;
min-height: 68rpx;
color: $ink;
font-size: 23rpx;
}
.form-row--code .app-button {
justify-self: end;
width: 170rpx;
min-height: 68rpx;
}
.field-error {
display: block;
padding-top: 7rpx;
color: #b42318;
font-size: 20rpx;
line-height: 1.4;
text-align: right;
}
.page-content > .app-button {
margin-top: 28rpx; margin-top: 28rpx;
} }
@media (max-width: 340px) {
.page-content {
padding-right: 22rpx;
padding-left: 22rpx;
}
.form-panel {
padding-right: 26rpx;
padding-left: 26rpx;
}
.form-row {
grid-template-columns: 120rpx minmax(0, 1fr);
gap: 8rpx;
}
.form-row--code {
grid-template-columns: 120rpx minmax(0, 1fr) 150rpx;
}
.form-row--code .app-button {
width: 150rpx;
}
}
</style> </style>
+422 -54
View File
@@ -2,13 +2,26 @@
<view class="orders-page"> <view class="orders-page">
<ModulePageBackground module="profile" /> <ModulePageBackground module="profile" />
<view class="page-layer" <view class="page-layer"
><PageHeader title="VIP 与订单" custom-back @back="requestBack" ><PageHeader
class="orders-header"
title="VIP 与订单"
custom-back
@back="requestBack"
/></view> /></view>
<view class="page-content page-layer"> <view class="page-content page-layer">
<view class="service-card" <view class="vip-intro">
><text>服务套餐</text <image
><text>套餐和订单均从服务端读取本页不发起支付或创建订单</text></view class="vip-intro__background"
> src="/static/assets/modules/profile/opaque/m09-vip-hero-landscape.png"
mode="aspectFill"
aria-hidden="true"
/>
<view class="vip-intro__content">
<text class="vip-intro__eyebrow">家谱传承服务</text>
<text class="vip-intro__title">开通 VIP守护家谱长久传承</text>
<text class="vip-intro__copy">选择适合家谱规模的服务套餐</text>
</view>
</view>
<view v-if="state === 'loading'" class="state-card" <view v-if="state === 'loading'" class="state-card"
><AppLoading text="正在读取服务套餐" ><AppLoading text="正在读取服务套餐"
/></view> /></view>
@@ -18,12 +31,33 @@
/></view> /></view>
<template v-else> <template v-else>
<view v-if="packages.length" class="package-list" <view v-if="packages.length" class="package-list"
><view v-for="item in packages" :key="item.id" class="package-card" ><view
><view class="package-heading" v-for="item in packages"
><text>{{ item.name }}</text :key="item.id"
><text v-if="item.price">¥{{ item.price }}</text></view class="package-card"
><text v-if="item.description">{{ item.description }}</text ><image
><text v-if="item.duration">{{ item.duration }}</text></view class="package-courtyard"
src="/static/assets/modules/profile/opaque/m09-featured-courtyard.png"
mode="aspectFill"
aria-hidden="true"
/>
<view class="package-heading">
<view>
<text>{{ item.name }}</text>
<text v-if="item.description">{{ item.description }}</text>
</view>
</view>
<view class="package-meta"
><text v-if="item.price" class="package-price">¥{{ item.price }}</text
><text class="package-duration">{{ item.duration || "服务期限以订单为准" }}</text
><button
class="purchase-button"
:disabled="creating"
@click="openPurchase(item)"
>立即开通</button
></view
>
</view
></view ></view
> >
<view v-else class="state-card" <view v-else class="state-card"
@@ -32,22 +66,46 @@
<view class="section-heading" <view class="section-heading"
><text>订单记录</text><text>{{ orders.length }} </text></view ><text>订单记录</text><text>{{ orders.length }} </text></view
> >
<text v-if="orderNotice" class="order-notice">{{ orderNotice }}</text>
<view v-if="orders.length" class="order-list" <view v-if="orders.length" class="order-list"
><view v-for="item in orders" :key="item.id" class="order-card" ><view v-for="item in orders" :key="item.id" class="order-card"
><text>{{ item.title }}</text ><view><text>{{ item.title }}</text><text>订单编号已由服务端生成</text></view
><text>{{ item.status }}</text></view ><text>{{ item.status || "状态待服务端确认" }}</text></view
></view ></view
> >
<view v-else class="order-empty"><text>暂无订单记录</text></view> <view v-else class="order-empty"><text>暂无订单记录</text></view>
</template> </template>
</view> </view>
<AppDialog
class="payment-sheet"
:visible="Boolean(selectedPackage)"
title="确认下单"
:confirm-text="creating ? '正在创建订单' : '创建订单'"
cancel-text="暂不购买"
show-cancel
:close-on-mask="!creating"
@confirm="createOrder"
@cancel="closePurchase"
>
<view class="sheet-order-summary"
><view
><text>套餐</text><text>{{ selectedPackage?.name }}</text></view
><view v-if="selectedPackage?.duration"
><text>有效期</text><text>{{ selectedPackage.duration }}</text></view
><view><text>应付金额</text><text>¥{{ selectedPackage?.price }}</text></view></view
>
<view class="payment-method"><text>支付方式</text><text>微信支付</text></view>
<text class="payment-copy">将创建微信支付订单支付完成后请以订单状态为准</text>
<text v-if="purchaseError" class="purchase-error">{{ purchaseError }}</text>
</AppDialog>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { ref } from "vue";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app"; import { onBackPress, onLoad, onReady, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue"; import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue"; import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
@@ -61,8 +119,13 @@ import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const packages = ref([]); const packages = ref([]);
const orders = ref([]); const orders = ref([]);
const state = ref("loading"); const state = ref("loading");
const selectedPackage = ref(null);
const creating = ref(false);
const purchaseError = ref("");
const orderNotice = ref("");
const controller = createRequestController(); const controller = createRequestController();
let active = true; let active = true;
let statusBarTimer = null;
const formatPrice = (value) => const formatPrice = (value) =>
Number.isFinite(Number(value)) ? Number(value).toFixed(2) : ""; Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "";
const durationUnits = Object.freeze({ year: "年", month: "个月", day: "天" }); const durationUnits = Object.freeze({ year: "年", month: "个月", day: "天" });
@@ -107,14 +170,67 @@ const loadVip = async () => {
state.value = "error"; state.value = "error";
} }
}; };
const openPurchase = (item) => {
if (creating.value || !item?.id) return;
purchaseError.value = "";
selectedPackage.value = item;
};
const closePurchase = () => {
if (creating.value) return;
selectedPackage.value = null;
purchaseError.value = "";
};
const createOrder = async () => {
const item = selectedPackage.value;
if (!item?.id || creating.value) return;
creating.value = true;
purchaseError.value = "";
try {
await appApi.createVipOrder(
{ packageId: item.id, payType: "wechat" },
{ requestController: controller },
);
if (!active) return;
selectedPackage.value = null;
orderNotice.value = `${item.name}”订单已创建,并已从服务端重新读取订单状态。`;
await loadVip();
} catch (error) {
if (!active || isRequestCancelled(error)) return;
purchaseError.value = error?.message || "订单创建失败,请稍后重试。";
} finally {
creating.value = false;
}
};
const requestBack = () => runBackGuard({}); const requestBack = () => runBackGuard({});
onLoad(loadVip); const setVipStatusBar = (style, color) => {
const navigator = globalThis.plus?.navigator;
if (!navigator) return;
const apply = () => {
navigator.setStatusBarStyle(style);
navigator.setStatusBarBackground(color);
};
apply();
if (statusBarTimer) clearTimeout(statusBarTimer);
statusBarTimer = setTimeout(apply, 80);
};
onLoad(() => {
setVipStatusBar("dark", "#f4ebde");
loadVip();
});
onReady(() => setVipStatusBar("dark", "#f4ebde"));
onShow(() => { onShow(() => {
setVipStatusBar("dark", "#f4ebde");
if (state.value !== "loading") loadVip(); if (state.value !== "loading") loadVip();
}); });
onUnload(() => { onUnload(() => {
active = false; active = false;
controller.abort(); controller.abort();
if (statusBarTimer) clearTimeout(statusBarTimer);
const navigator = globalThis.plus?.navigator;
if (navigator) {
navigator.setStatusBarStyle("light");
navigator.setStatusBarBackground("#b52e22");
}
}); });
onBackPress((event) => handleBackPress(event, requestBack)); onBackPress((event) => handleBackPress(event, requestBack));
</script> </script>
@@ -125,40 +241,61 @@ onBackPress((event) => handleBackPress(event, requestBack));
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
flex-direction: column; flex-direction: column;
background: $paper; background: #f7f2e9;
} }
.page-layer { .page-layer {
z-index: 1; z-index: 1;
} }
.page-content { .page-content {
flex: 1; flex: 1;
padding: 28rpx 30rpx 72rpx; padding: 20rpx 30rpx 72rpx;
} }
.service-card,
.state-card, .state-card,
.package-card,
.order-card,
.order-empty { .order-empty {
@include adaptive.adaptive-profile-content; @include adaptive.adaptive-profile-content;
} }
.service-card { .vip-intro {
min-height: 170rpx; position: relative;
padding: 38rpx 44rpx; min-height: 284rpx;
overflow: hidden;
margin: -20rpx -30rpx 0;
padding: 36rpx 48rpx 30rpx;
text-align: center; text-align: center;
} }
.service-card text { .vip-intro__background {
position: absolute;
z-index: 0;
inset: 0;
width: 100%;
height: 100%;
opacity: 0.78;
pointer-events: none;
}
.vip-intro__content {
position: relative;
z-index: 1;
}
.vip-intro text {
display: block; display: block;
} }
.service-card text:first-child { .vip-intro__eyebrow {
color: $ink; color: $brand-red;
font-family: STKaiti, KaiTi, serif; font-size: 20rpx;
font-size: 34rpx;
font-weight: 700; font-weight: 700;
letter-spacing: 5rpx;
} }
.service-card text:last-child { .vip-intro__title {
margin-top: 12rpx; margin-top: 16rpx;
color: #33261d;
font-family: STKaiti, KaiTi, serif;
font-size: 38rpx;
font-weight: 700;
letter-spacing: 1rpx;
}
.vip-intro__copy {
margin-top: 14rpx;
color: $ink-muted; color: $ink-muted;
font-size: 21rpx; font-size: 23rpx;
line-height: 1.55; line-height: 1.55;
} }
.state-card { .state-card {
@@ -181,55 +318,138 @@ onBackPress((event) => handleBackPress(event, requestBack));
.order-list { .order-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14rpx; gap: 18rpx;
margin-top: 18rpx;
} }
.package-card { .package-card {
padding: 28rpx 32rpx; position: relative;
overflow: hidden;
padding: 38rpx 36rpx 32rpx;
border: 1rpx solid rgba(159, 23, 15, 0.52);
border-radius: 26rpx;
background: rgba(255, 253, 248, 0.95);
box-shadow: 0 16rpx 32rpx rgba(102, 48, 33, 0.12);
} }
.package-heading { .package-courtyard {
position: absolute;
right: 0;
bottom: 0;
z-index: 0;
width: 100%;
height: 100%;
opacity: 0.38;
pointer-events: none;
}
.package-card > :not(.package-courtyard) {
position: relative;
z-index: 1;
}
.package-heading > view {
min-width: 0;
}
.package-heading > view text {
display: block;
}
.package-heading > view text:first-child {
color: #302319;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
font-weight: 700;
}
.package-heading > view text:last-child {
margin-top: 10rpx;
color: #77675a;
font-size: 23rpx;
line-height: 1.55;
}
.package-price {
color: $brand-red;
font-family: Georgia, STKaiti, serif;
font-size: 38rpx;
font-weight: 700;
line-height: 1.1;
}
.package-meta {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16rpx; gap: 20rpx;
color: $ink; margin-top: 30rpx;
font-size: 27rpx; padding-top: 22rpx;
border-top: 1rpx solid rgba(117, 84, 52, 0.14);
}
.package-duration {
color: #837164;
font-size: 23rpx;
}
.purchase-button {
display: inline-flex;
min-width: 184rpx;
min-height: 70rpx;
align-items: center;
justify-content: center;
box-sizing: border-box;
margin: 0;
padding: 0 28rpx;
border: 0;
border-radius: 999rpx;
background: $brand-red;
color: #fffaf1;
font-size: 24rpx;
font-weight: 700; font-weight: 700;
letter-spacing: 1rpx;
line-height: normal;
} }
.package-heading text:last-child { .purchase-button::after {
flex: 0 0 auto; border: 0;
color: $brand-red;
} }
.package-card > text { .purchase-button[disabled] {
display: block; opacity: 0.5;
margin-top: 10rpx;
color: $ink-muted;
font-size: 21rpx;
line-height: 1.5;
} }
.section-heading { .section-heading {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin: 28rpx 6rpx 12rpx; margin: 44rpx 6rpx 16rpx;
color: $ink; color: #38281d;
font-size: 27rpx; font-family: STKaiti, KaiTi, serif;
font-size: 32rpx;
font-weight: 700; font-weight: 700;
} }
.section-heading text:last-child { .section-heading text:last-child {
color: $ink-muted; color: #8a796b;
font-size: 21rpx; font-size: 21rpx;
font-weight: 400; font-weight: 400;
} }
.order-notice {
display: block;
margin: 0 6rpx 14rpx;
color: #786556;
font-size: 21rpx;
line-height: 1.5;
}
.order-card { .order-card {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 18rpx; gap: 18rpx;
padding: 26rpx 32rpx; padding: 26rpx 4rpx;
color: $ink; border-bottom: 1rpx solid rgba(117, 84, 52, 0.14);
color: #48372a;
font-size: 24rpx; font-size: 24rpx;
} }
.order-card > view text {
display: block;
}
.order-card > view text:first-child {
font-weight: 700;
}
.order-card > view text:last-child {
margin-top: 8rpx;
color: #8a796b;
font-size: 20rpx;
}
.order-card text:last-child { .order-card text:last-child {
color: $ink-muted; flex: 0 0 auto;
color: #7f6959;
font-size: 21rpx;
} }
.order-empty { .order-empty {
padding: 38rpx; padding: 38rpx;
@@ -237,10 +457,158 @@ onBackPress((event) => handleBackPress(event, requestBack));
color: $ink-muted; color: $ink-muted;
font-size: 23rpx; font-size: 23rpx;
} }
:deep(.orders-header .page-header) {
height: calc(104rpx + var(--status-bar-height, 0px));
border-bottom: 1rpx solid rgba(117, 84, 52, 0.08);
background: #f4ebde;
}
:deep(.orders-header.page-header-slot) {
height: calc(104rpx + var(--status-bar-height, 0px));
}
:deep(.orders-header .header-title) {
color: #302319;
font-family: STKaiti, KaiTi, serif;
font-size: 37rpx;
}
:deep(.orders-header .header-back__icon) {
filter: none;
opacity: 0.78;
}
:deep(.payment-sheet.app-dialog-layer) {
align-items: flex-end;
padding: 0;
background: rgba(37, 26, 18, 0.38);
}
:deep(.payment-sheet .app-dialog) {
width: 100%;
max-width: none;
max-height: 76vh;
border: 0;
border-radius: 38rpx 38rpx 0 0;
background: #fffdf9;
box-shadow: 0 -12rpx 38rpx rgba(50, 33, 20, 0.16);
}
:deep(.payment-sheet .app-dialog__content) {
align-items: stretch;
padding: 34rpx 42rpx calc(30rpx + env(safe-area-inset-bottom));
text-align: left;
}
:deep(.payment-sheet .app-dialog__content::before) {
display: block;
width: 64rpx;
height: 8rpx;
align-self: center;
margin-bottom: 24rpx;
border-radius: 999rpx;
background: rgba(76, 58, 45, 0.22);
content: "";
}
:deep(.payment-sheet .app-dialog__copy) {
align-items: flex-start;
}
:deep(.payment-sheet .app-dialog__title) {
color: #302319;
font-family: STKaiti, KaiTi, serif;
font-size: 42rpx;
letter-spacing: 1rpx;
text-align: left;
}
:deep(.payment-sheet .app-dialog__actions) {
display: flex;
flex-direction: column-reverse;
gap: 4rpx;
margin-top: 28rpx;
}
:deep(.payment-sheet .app-button) {
width: 100%;
min-height: 88rpx;
}
:deep(.payment-sheet .app-button__skin) {
display: none;
}
:deep(.payment-sheet .app-button--primary) {
border-radius: 999rpx;
background: $brand-red;
}
:deep(.payment-sheet .app-button--secondary) {
min-height: 60rpx;
background: transparent;
}
:deep(.payment-sheet .app-button--secondary .app-button__label) {
color: #8a796b;
font-size: 24rpx;
font-weight: 400;
}
.sheet-order-summary {
margin-top: 24rpx;
overflow: hidden;
border: 1rpx solid rgba(117, 84, 52, 0.12);
border-radius: 20rpx;
background: #fffaf4;
}
.sheet-order-summary > view,
.payment-method {
display: flex;
min-height: 82rpx;
align-items: center;
justify-content: space-between;
gap: 24rpx;
padding: 0 24rpx;
border-bottom: 1rpx solid rgba(117, 84, 52, 0.1);
color: #5a4432;
font-size: 24rpx;
}
.sheet-order-summary > view:last-child {
border-bottom: 0;
}
.sheet-order-summary > view text:last-child,
.payment-method text:last-child {
color: #302319;
font-weight: 700;
text-align: right;
}
.sheet-order-summary > view:last-child text:last-child {
color: $brand-red;
font-family: Georgia, STKaiti, serif;
font-size: 34rpx;
}
.payment-method {
width: 100%;
min-height: 88rpx;
box-sizing: border-box;
margin-top: 16rpx;
border: 1rpx solid rgba(117, 84, 52, 0.12);
border-radius: 20rpx;
background: #fffaf4;
}
.payment-method text:last-child {
color: $brand-red;
}
.payment-copy,
.purchase-error {
display: block;
width: 100%;
margin-top: 16rpx;
color: #8a796b;
font-size: 20rpx;
line-height: 1.55;
}
.purchase-error {
color: $brand-red;
}
@media (max-width: 340px) { @media (max-width: 340px) {
.page-content { .page-content {
padding-right: 22rpx; padding-right: 22rpx;
padding-left: 22rpx; padding-left: 22rpx;
} }
.package-card {
padding-right: 28rpx;
padding-left: 28rpx;
}
.purchase-button {
min-width: 166rpx;
padding-right: 20rpx;
padding-left: 20rpx;
}
} }
</style> </style>
+3 -1
View File
@@ -11,7 +11,7 @@
> >
<ModulePageBackground module="tree" /> <ModulePageBackground module="tree" />
<view class="tree-page__header"> <view class="tree-page__header">
<PageHeader title="世系树" action="成员目录" @action="toDirectory" /> <PageHeader title="树状图" action="世系谱" @action="toPedigree" />
</view> </view>
<view v-if="treeState !== 'loading'" class="tree-toolbar"> <view v-if="treeState !== 'loading'" class="tree-toolbar">
@@ -782,6 +782,8 @@ const toMember = () => {
}; };
const toDirectory = () => const toDirectory = () =>
openPage("T07", { genealogyId: genealogyId.value }, "T01"); openPage("T07", { genealogyId: genealogyId.value }, "T01");
const toPedigree = () =>
openPage("T02", { genealogyId: genealogyId.value }, "T01");
const toRank = () => const toRank = () =>
selected.value selected.value
? openPage( ? openPage(
+586
View File
@@ -0,0 +1,586 @@
<!-- 页面编号T-02用途世系谱表阅读同世成员横向滑页 -->
<template>
<view class="pedigree-page">
<ModulePageBackground module="tree" />
<view class="pedigree-page__header">
<PageHeader title="世系谱" action="树状图" @action="toTree" />
</view>
<view class="pedigree-stage">
<AppLoading
v-if="treeState === 'loading'"
text="正在整理世系谱"
description="请稍候,正在准备家谱成员关系。"
/>
<template v-else-if="treeState === 'tree'">
<view class="pedigree-layout">
<swiper
class="pedigree-swiper"
:current="pedigreePageIndex"
:duration="260"
:disable-touch="false"
@change="handlePedigreePageChange"
>
<swiper-item
v-for="(page, pageIndex) in pedigreePages"
:key="`generation-${page.generation}-page-${pageIndex}`"
>
<scroll-view
class="pedigree-scroll"
scroll-y
:show-scrollbar="false"
:style="pedigreeScrollStyle"
>
<view class="pedigree-sheet" :style="pedigreeSheetStyle">
<view class="pedigree-column pedigree-column--legend">
<view class="pedigree-column__relation"><text>身份</text></view>
<view class="pedigree-column__name"><text>{{ page.label }}</text></view>
<view class="pedigree-column__detail"><text>{{ page.summary }}</text></view>
</view>
<view
v-for="(member, memberIndex) in page.members"
:key="member?.id || `empty-${pageIndex}-${memberIndex}`"
class="member-node"
:class="{
'member-node--blank': !member,
'member-node--selected': selectedId === String(member?.id || ''),
'member-node--spouse': Boolean(member?.spouseOf),
}"
>
<view v-if="member" class="member-node__surface">
<view class="member-node__relation"><text>{{ memberRelation(member) }}</text></view>
<view
class="member-node__name"
role="button"
:aria-label="`查看${member.name}的资料`"
@click.stop="openMemberProfile(member)"
><text>{{ member.name }}</text></view>
<view
class="member-node__copy"
role="button"
:aria-label="`查看${member.name}的生平详情`"
@click.stop="openMemberDetail(member)"
>
<text class="node-relation">{{ member.branch }}</text>
<text class="node-years">{{ memberDetail(member) }}</text>
</view>
</view>
<view v-else class="member-node__surface">
<view class="member-node__relation"></view>
<view class="member-node__name"></view>
<view class="member-node__copy"></view>
</view>
</view>
</view>
</scroll-view>
</swiper-item>
</swiper>
<view class="generation-rail" :style="pedigreeSheetStyle">
<view class="generation-band">
<image
class="generation-band__arrow generation-band__arrow--previous"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
aria-hidden="true"
/>
<view class="generation-band__copy">
<text>{{ activePage?.label || '未定世代' }}</text>
<text>{{ activePage?.memberCount || 0 }} 位成员</text>
</view>
<image
class="generation-band__arrow"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
aria-hidden="true"
/>
</view>
</view>
</view>
</template>
<view v-else class="pedigree-state-card">
<image
class="pedigree-state-card__skin"
src="/static/assets/modules/tree/transparent/t01-state-panel.png"
mode="scaleToFill"
/>
<view class="pedigree-state-card__content">
<text class="pedigree-state-card__eyebrow">{{ stateCopy.eyebrow }}</text>
<text class="pedigree-state-card__title">{{ stateCopy.title }}</text>
<text class="pedigree-state-card__copy">{{ stateCopy.copy }}</text>
<view class="pedigree-state-card__action" @click="handleStateAction">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>{{ stateCopy.action }}</text>
</view>
</view>
</view>
</view>
<AppDialog
:visible="detailVisible"
eyebrow="人物生平"
:title="detailMember?.name || '人物详情'"
:message="detailMessage"
confirm-text="关闭"
@confirm="closeMemberDetail"
@cancel="closeMemberDetail"
/>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
appApi,
createRequestController,
isRequestCancelled,
} from "@/utils/api.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { openPage } from "@/utils/navigation.js";
const PEDIGREE_PAGE_SIZE = 5;
const genealogyId = ref("");
const treeState = ref("loading");
const treeLoadError = ref("");
const members = ref([]);
const selectedId = ref("");
const pedigreePageIndex = ref(0);
const detailVisible = ref(false);
const detailMember = ref(null);
const detailMessage = ref("");
const pedigreeRequestController = createRequestController();
const detailRequestController = createRequestController();
let loadGeneration = 0;
let detailLoadGeneration = 0;
let pageActive = true;
let skipNextShowRefresh = true;
const compareMembers = (left, right) =>
Number(left.generation) - Number(right.generation) ||
String(left.id).localeCompare(String(right.id));
const generationRows = computed(() => {
const groups = new Map();
members.value.forEach((member) => {
const generation = Number(member.generation) || 0;
const group = groups.get(generation) || [];
group.push(member);
groups.set(generation, group);
});
return Array.from(groups.entries())
.sort(([left], [right]) => left - right)
.map(([generation, group]) => ({
generation,
label: generation ? `${generation}` : "未定世代",
members: group.sort(compareMembers),
}));
});
const pedigreePages = computed(() => {
const pages = [];
generationRows.value.forEach((row) => {
const branch = row.members.find((member) => member.branch)?.branch;
for (let index = 0; index < row.members.length; index += PEDIGREE_PAGE_SIZE) {
const pageMembers = row.members.slice(index, index + PEDIGREE_PAGE_SIZE);
while (pageMembers.length < PEDIGREE_PAGE_SIZE) pageMembers.push(null);
pages.push({
generation: row.generation,
label: row.label,
memberCount: row.members.length,
summary: [branch, `${row.members.length} 位成员`]
.filter(Boolean)
.join(" · "),
members: pageMembers,
});
}
});
return pages;
});
const activePage = computed(
() => pedigreePages.value[pedigreePageIndex.value] || null,
);
const detailLength = computed(() =>
Math.max(
0,
...members.value.map((member) =>
Array.from(`${member.branch || ""}${memberDetail(member)}`).length,
),
),
);
const sheetHeight = computed(() => Math.max(1100, 720 + detailLength.value * 20));
const pedigreeSheetStyle = computed(() => ({
height: `${sheetHeight.value}rpx`,
}));
const pedigreeScrollStyle = computed(() => ({
height: `${sheetHeight.value}rpx`,
}));
const stateCopy = computed(
() =>
({
landscape: {
eyebrow: "世系谱阅读提示",
title: "左右滑动查看成员",
copy: "同一世代每页展示五位成员,继续向左滑动即可查看下一页。",
action: "返回树状图",
},
empty: {
eyebrow: "尚未建立世系",
title: "从第一位先祖开始记录",
copy: "录入首代成员后,即可在世系树与世系谱中查看关系。",
action: "添加首位成员",
},
error: {
eyebrow: "世系谱读取失败",
title: "暂时无法展示家谱成员",
copy: treeLoadError.value || "请稍后重试。",
action: "重新查看",
},
})[treeState.value] || {},
);
const handlePedigreePageChange = (event) => {
pedigreePageIndex.value = Number(event?.detail?.current || 0);
};
const memberRelation = (member) =>
member.spouseOf ? "配偶" : member.relation || "家谱成员";
const memberDetail = (member) =>
[member.years, member.sex, member.personStatus]
.filter((item) => item && item !== "生卒待补")
.join(" · ") || "生卒待补";
const openMemberProfile = (member) =>
openPage(
"T03",
{ genealogyId: genealogyId.value, personId: String(member.id) },
"T02",
);
const closeMemberDetail = () => {
detailVisible.value = false;
detailRequestController.abort();
};
const openMemberDetail = async (member) => {
if (!member) return;
const generation = ++detailLoadGeneration;
detailRequestController.abort();
detailMember.value = member;
detailMessage.value = "正在读取人物生平…";
detailVisible.value = true;
try {
const detail = await appApi.getPerson(genealogyId.value, member.id, {
requestController: detailRequestController,
});
if (generation !== detailLoadGeneration || !detailVisible.value) return;
detailMember.value = detail;
detailMessage.value =
[detail.biography, detail.remark].filter(Boolean).join("\n\n") ||
"暂未填写生平说明。";
} catch (error) {
if (isRequestCancelled(error) || generation !== detailLoadGeneration) return;
detailMessage.value = error?.message || "人物生平读取失败,请稍后重试。";
}
};
const toTree = () => {
const params = { genealogyId: genealogyId.value };
if (selectedId.value) params.selectedId = selectedId.value;
return openPage("T01", params, "T02");
};
const handleStateAction = () => {
if (treeState.value === "landscape") return toTree();
if (treeState.value === "empty") {
return openPage(
"T04",
{ genealogyId: genealogyId.value, mode: "first" },
"T02",
);
}
return loadPedigree({ genealogyId: genealogyId.value });
};
const loadPedigree = async (query = {}) => {
const generation = ++loadGeneration;
pedigreeRequestController.abort();
treeLoadError.value = "";
genealogyId.value = String(
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "",
);
selectedId.value = String(query.selectedId || "");
if (!genealogyId.value) {
members.value = [];
treeState.value = "empty";
return;
}
if (["landscape", "empty", "error"].includes(query.state)) {
treeState.value = query.state;
return;
}
treeState.value = "loading";
try {
const result = await appApi.getTree(genealogyId.value, {
requestController: pedigreeRequestController,
});
if (!pageActive || generation !== loadGeneration) return;
members.value = result;
genealogyContext.setCurrentGenealogyId(genealogyId.value);
const selectedPage = pedigreePages.value.findIndex((page) =>
page.members.some((member) => String(member?.id) === selectedId.value),
);
pedigreePageIndex.value = selectedPage < 0 ? 0 : selectedPage;
treeState.value = members.value.length ? "tree" : "empty";
} catch (error) {
if (!pageActive || generation !== loadGeneration || isRequestCancelled(error)) {
return;
}
members.value = [];
treeLoadError.value = error?.message || "世系谱读取失败,请稍后重试。";
treeState.value = "error";
}
};
onLoad(loadPedigree);
onShow(() => {
if (skipNextShowRefresh) {
skipNextShowRefresh = false;
return;
}
if (genealogyId.value) loadPedigree({ genealogyId: genealogyId.value });
});
onUnload(() => {
pageActive = false;
loadGeneration += 1;
detailLoadGeneration += 1;
pedigreeRequestController.abort();
detailRequestController.abort();
});
</script>
<style scoped lang="scss">
.pedigree-page {
display: grid;
min-height: 100vh;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
background: $paper;
}
.pedigree-page__header,
.pedigree-stage {
z-index: 1;
}
.pedigree-stage {
min-height: 0;
}
.pedigree-layout {
display: grid;
height: 100%;
grid-template-columns: minmax(0, 1fr) 92rpx;
background: #fbfbfb;
}
.pedigree-swiper {
width: 100%;
height: 100%;
}
.pedigree-scroll {
width: 100%;
}
.pedigree-sheet {
display: grid;
width: 100%;
min-height: 100%;
grid-template-columns: repeat(6, minmax(0, 1fr));
align-items: stretch;
border-top: 1rpx solid #e2e2e2;
border-bottom: 1rpx solid #e2e2e2;
background: #fbfbfb;
box-sizing: border-box;
}
.pedigree-column,
.member-node {
display: grid;
min-width: 0;
min-height: 100%;
grid-template-rows: 144rpx 274rpx minmax(0, 1fr);
border-right: 1rpx solid #e2e2e2;
box-sizing: border-box;
color: #38312d;
}
.pedigree-column--legend {
border-left: 1rpx solid #e2e2e2;
background: #f6f6f6;
color: #665d56;
}
.pedigree-column__relation,
.pedigree-column__name,
.pedigree-column__detail,
.member-node__relation,
.member-node__name,
.member-node__copy {
display: flex;
justify-content: center;
box-sizing: border-box;
writing-mode: vertical-rl;
text-orientation: upright;
}
.pedigree-column__relation,
.member-node__relation {
align-items: center;
border-bottom: 1rpx solid #e2e2e2;
font-size: 22rpx;
}
.pedigree-column__name,
.member-node__name {
align-items: center;
border-bottom: 1rpx solid #e2e2e2;
color: #2f2925;
font-family: "STKaiti", "KaiTi", serif;
font-size: 34rpx;
font-weight: 700;
}
.pedigree-column__detail,
.member-node__copy {
align-items: flex-start;
gap: 18rpx;
padding: 28rpx 20rpx;
font-size: 21rpx;
line-height: 1.7;
white-space: normal;
}
.pedigree-column__detail {
font-size: 20rpx;
}
.member-node__surface {
display: contents;
}
.member-node--blank {
pointer-events: none;
}
.member-node--selected .member-node__relation,
.member-node--selected .member-node__name,
.member-node--selected .member-node__copy {
background: rgba(181, 46, 34, 0.06);
}
.member-node--selected .member-node__name {
color: #a7241d;
}
.member-node--spouse .member-node__relation {
color: #7a4d43;
}
.node-relation {
color: #675f57;
}
.node-years {
color: #3f3934;
}
.generation-rail {
width: 92rpx;
border-left: 1rpx solid #e2e2e2;
background: rgba(252, 252, 252, 0.96);
box-sizing: border-box;
}
.generation-band {
display: flex;
width: 72rpx;
min-height: 250rpx;
margin: 250rpx auto 0;
padding: 12rpx 0;
flex-direction: column;
align-items: center;
justify-content: space-between;
border: 1rpx solid #b52e22;
background: rgba(255, 255, 255, 0.88);
box-sizing: border-box;
}
.generation-band__arrow {
width: 26rpx;
height: 26rpx;
filter: sepia(1) saturate(3) hue-rotate(318deg);
transform: rotate(90deg);
}
.generation-band__arrow--previous {
transform: rotate(-90deg);
}
.generation-band__copy {
display: flex;
gap: 12rpx;
flex-direction: column;
align-items: center;
color: #a7241d;
font-size: 21rpx;
writing-mode: vertical-rl;
text-orientation: upright;
}
.generation-band__copy text:last-child {
font-size: 18rpx;
}
.pedigree-state-card {
display: grid;
width: calc(100% - 48rpx);
min-height: 360rpx;
margin: 150rpx 24rpx 0;
text-align: center;
}
.pedigree-state-card__skin {
width: 100%;
height: 100%;
}
.pedigree-state-card__skin,
.pedigree-state-card__content {
grid-area: 1 / 1;
}
.pedigree-state-card__content {
z-index: 1;
padding: 62rpx 58rpx 42rpx;
}
.pedigree-state-card__eyebrow,
.pedigree-state-card__title,
.pedigree-state-card__copy {
display: block;
}
.pedigree-state-card__eyebrow {
color: $brand-red;
font-size: 22rpx;
letter-spacing: 3rpx;
}
.pedigree-state-card__title {
margin-top: 12rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 34rpx;
font-weight: 700;
}
.pedigree-state-card__copy {
margin-top: 16rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.pedigree-state-card__action {
display: grid;
width: 360rpx;
min-height: 70rpx;
margin: 22rpx auto 0;
}
.pedigree-state-card__action image,
.pedigree-state-card__action text {
grid-area: 1 / 1;
}
.pedigree-state-card__action image {
width: 100%;
height: 100%;
}
.pedigree-state-card__action text {
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
color: #fff9ed;
font-size: 24rpx;
font-weight: 700;
}
</style>
+47 -49
View File
@@ -158,69 +158,59 @@ const trailIndex = ref(-1);
const memberRequestController = createRequestController(); const memberRequestController = createRequestController();
let loadSequence = 0; let loadSequence = 0;
const details = computed(() => const dictionaryLabel = (value, labels) => labels[String(value || "")] || "";
member.value const isDeceased = computed(() => member.value?.personStatus === "1");
? [ const details = computed(() => {
if (!member.value) return [];
const protectedValue = (value) =>
member.value.status === "forbidden" ? "按权限隐藏" : value;
return [
{ label: "别名", value: member.value.aliasName }, { label: "别名", value: member.value.aliasName },
{ label: "字辈", value: member.value.generationName }, { label: "字辈", value: member.value.generationName },
{ label: "性别(字典值)", value: member.value.sex },
{ label: "人物状态(字典值)", value: member.value.personStatus },
{ {
label: "出生日期", label: "性别",
value: value: dictionaryLabel(member.value.sex, {
member.value.status === "forbidden" 0: "男",
? "按权限隐藏" 1: "女",
: member.value.birthDate, 2: "未知",
}),
}, },
{
label: "人物状态",
value: dictionaryLabel(member.value.personStatus, {
0: "健在",
1: "已故",
2: "未知",
}),
},
{ label: "出生日期", value: protectedValue(member.value.birthDate) },
{ {
label: "出生农历", label: "出生农历",
value: value: protectedValue(
member.value.status === "forbidden" dictionaryLabel(member.value.birthLunar, { 0: "否", 1: "是" }),
? "按权限隐藏" ),
: member.value.birthLunar,
}, },
{ label: "生卒信息", value: member.value.years }, { label: "生卒信息", value: member.value.years },
{ { label: "出生地", value: protectedValue(member.value.birthplace) },
label: "出生地", ...(isDeceased.value
value: ? [
member.value.status === "forbidden" { label: "逝世日期", value: protectedValue(member.value.deathDate) },
? "按权限隐藏"
: member.value.birthplace,
},
{ {
label: "逝世农历", label: "逝世农历",
value: value: protectedValue(
member.value.status === "forbidden" dictionaryLabel(member.value.deathLunar, { 0: "否", 1: "是" }),
? "按权限隐藏" ),
: member.value.deathLunar,
},
{
label: "逝世地",
value:
member.value.status === "forbidden"
? "按权限隐藏"
: member.value.deathPlace,
},
{
label: "安葬地",
value:
member.value.status === "forbidden"
? "按权限隐藏"
: member.value.burialPlace,
}, },
{ label: "逝世地", value: protectedValue(member.value.deathPlace) },
{ label: "安葬地", value: protectedValue(member.value.burialPlace) },
]
: []),
{ label: "配偶", value: member.value.spouseNames }, { label: "配偶", value: member.value.spouseNames },
{ label: "所属支系", value: member.value.branch }, { label: "所属支系", value: member.value.branch },
{ { label: "生平", value: protectedValue(member.value.biography) },
label: "生平",
value:
member.value.status === "forbidden"
? "按权限隐藏"
: member.value.biography,
},
{ label: "备注", value: member.value.remark }, { label: "备注", value: member.value.remark },
] ];
: [], });
);
const canPreviewEdit = computed(() => memberState.value === "detail"); const canPreviewEdit = computed(() => memberState.value === "detail");
const restrictedCopy = computed(() => const restrictedCopy = computed(() =>
member.value?.status === "forbidden" member.value?.status === "forbidden"
@@ -377,6 +367,14 @@ onShow(() => {
const result = consumeNavigationResult("T03"); const result = consumeNavigationResult("T03");
if (result?.operation === "member-open-requested" && result.entityId) { if (result?.operation === "member-open-requested" && result.entityId) {
void openRelative(result.entityId); void openRelative(result.entityId);
return;
}
if (
result?.operation === "member-updated" &&
result.refresh &&
result.entityId === String(personId.value)
) {
void loadMember(result.entityId, { preserveCurrent: true });
} }
}); });
+17 -96
View File
@@ -53,15 +53,6 @@
fieldErrors.name fieldErrors.name
}}</text> }}</text>
<view class="form-field">
<text>人物编号</text>
<input
v-model="addForm.personNo"
maxlength="40"
placeholder="不填则由服务端生成"
placeholder-class="form-placeholder"
/>
</view>
<picker <picker
:range="sexOptions.map((item) => item.label)" :range="sexOptions.map((item) => item.label)"
:value="optionIndex(sexOptions, addForm.sex)" :value="optionIndex(sexOptions, addForm.sex)"
@@ -154,27 +145,6 @@
/> />
<text v-else>首位成员固定为 1</text> <text v-else>首位成员固定为 1</text>
</view> </view>
<picker
:range="personOptionLabels"
:value="personOptionIndex(addForm.fatherId)"
@change="selectPerson('fatherId', $event)"
>
<view class="form-field form-field--picker"
><text>父亲</text
><text>{{ personOptionLabel(addForm.fatherId) }}</text></view
>
</picker>
<picker
:range="personOptionLabels"
:value="personOptionIndex(addForm.motherId)"
@change="selectPerson('motherId', $event)"
>
<view class="form-field form-field--picker"
><text>母亲</text
><text>{{ personOptionLabel(addForm.motherId) }}</text></view
>
</picker>
<picker <picker
mode="date" mode="date"
:value="addForm.birthDate" :value="addForm.birthDate"
@@ -208,6 +178,7 @@
</view> </view>
<picker <picker
v-if="isDeceased"
mode="date" mode="date"
:value="addForm.deathDate" :value="addForm.deathDate"
@change="selectDeathDate" @change="selectDeathDate"
@@ -218,6 +189,7 @@
</view> </view>
</picker> </picker>
<picker <picker
v-if="isDeceased"
:range="lunarOptions.map((item) => item.label)" :range="lunarOptions.map((item) => item.label)"
:value="optionIndex(lunarOptions, addForm.deathLunar)" :value="optionIndex(lunarOptions, addForm.deathLunar)"
@change="selectOption('deathLunar', lunarOptions, $event)" @change="selectOption('deathLunar', lunarOptions, $event)"
@@ -229,7 +201,7 @@
}}</text> }}</text>
</view> </view>
</picker> </picker>
<view class="form-field"> <view v-if="isDeceased" class="form-field">
<text>逝世地</text> <text>逝世地</text>
<input <input
v-model="addForm.deathPlace" v-model="addForm.deathPlace"
@@ -238,7 +210,7 @@
placeholder-class="form-placeholder" placeholder-class="form-placeholder"
/> />
</view> </view>
<view class="form-field"> <view v-if="isDeceased" class="form-field">
<text>安葬地</text> <text>安葬地</text>
<input <input
v-model="addForm.burialPlace" v-model="addForm.burialPlace"
@@ -268,18 +240,6 @@
placeholder-class="form-placeholder" placeholder-class="form-placeholder"
/> />
</view> </view>
<view class="form-field">
<text>关系名称</text>
<input
v-if="isFirstMember"
v-model="addForm.relationName"
maxlength="30"
placeholder="可选关系显示名称"
placeholder-class="form-placeholder"
/>
<text v-else>{{ relationLabel }}</text>
</view>
<view class="form-field form-field--summary"> <view class="form-field form-field--summary">
<text>人物简介</text> <text>人物简介</text>
<textarea <textarea
@@ -372,7 +332,6 @@ const discardDialogVisible = ref(false);
const currentMember = ref(null); const currentMember = ref(null);
const errorMessage = ref(""); const errorMessage = ref("");
const addRequestController = createRequestController(); const addRequestController = createRequestController();
const personOptionsRequestController = createRequestController();
let loadSequence = 0; let loadSequence = 0;
const relationIntents = Object.freeze({ const relationIntents = Object.freeze({
@@ -408,23 +367,15 @@ const personStatusOptions = Object.freeze([
const bindingModeOptions = Object.freeze([ const bindingModeOptions = Object.freeze([
{ label: "不绑定账号", value: "NONE" }, { label: "不绑定账号", value: "NONE" },
{ label: "绑定当前账号", value: "SELF" }, { label: "绑定当前账号", value: "SELF" },
{ label: "绑定指定用户", value: "SPECIFIED" },
]); ]);
const personOptions = ref([{ label: "不选择", value: "" }]);
const personOptionLabels = computed(() =>
personOptions.value.map((item) => item.label),
);
const addForm = reactive({ const addForm = reactive({
bindingMode: "NONE", bindingMode: "NONE",
personNo: "",
name: "", name: "",
relation: "", relation: "",
aliasName: "", aliasName: "",
sex: "", sex: "",
generation: "", generation: "",
generationName: "", generationName: "",
fatherId: "",
motherId: "",
avatarOssId: "", avatarOssId: "",
birthDate: "", birthDate: "",
birthLunar: "", birthLunar: "",
@@ -437,11 +388,11 @@ const addForm = reactive({
biography: "", biography: "",
remark: "", remark: "",
sortOrder: "", sortOrder: "",
relationName: "",
}); });
const fieldErrors = reactive({ name: "", relation: "", bindingMode: "" }); const fieldErrors = reactive({ name: "", relation: "", bindingMode: "" });
const isFirstMember = computed(() => mode.value === "first"); const isFirstMember = computed(() => mode.value === "first");
const isDeceased = computed(() => addForm.personStatus === "1");
const activeRelationIntent = computed( const activeRelationIntent = computed(
() => relationIntents[relationType.value] || null, () => relationIntents[relationType.value] || null,
); );
@@ -524,27 +475,6 @@ const loadCurrentMember = async () => {
errorMessage.value = error?.message || "当前成员暂不可用。"; errorMessage.value = error?.message || "当前成员暂不可用。";
} }
}; };
const loadPersonOptions = async () => {
try {
const rows = await appApi.getLineagePersonOptions(genealogyId.value, {
requestController: personOptionsRequestController,
});
const options = rows
.map((item) => {
const value = String(item?.personId ?? item?.id ?? "");
if (!/^[1-9]\d*$/.test(value)) return null;
const name = String(
item?.name || item?.personName || item?.personNo || "未命名人物",
);
return { value, label: `${name}${value}` };
})
.filter(Boolean);
personOptions.value = [{ label: "不选择", value: "" }, ...options];
} catch (error) {
if (!isRequestCancelled(error))
personOptions.value = [{ label: "暂无可选人物", value: "" }];
}
};
onLoad((query) => { onLoad((query) => {
genealogyId.value = String(query.genealogyId || ""); genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || ""); personId.value = String(query.personId || "");
@@ -567,7 +497,6 @@ onLoad((query) => {
if (activeRelationIntent.value) { if (activeRelationIntent.value) {
addForm.relation = activeRelationIntent.value.label; addForm.relation = activeRelationIntent.value.label;
} }
void loadPersonOptions();
if (isFirstMember.value) { if (isFirstMember.value) {
addState.value = "form"; addState.value = "form";
return; return;
@@ -577,7 +506,6 @@ onLoad((query) => {
onUnload(() => { onUnload(() => {
loadSequence += 1; loadSequence += 1;
addRequestController.abort(); addRequestController.abort();
personOptionsRequestController.abort();
discardConfirmation.dispose(); discardConfirmation.dispose();
}); });
@@ -609,22 +537,20 @@ const optionLabel = (options, value) =>
options.find((item) => item.value === value)?.label || ""; options.find((item) => item.value === value)?.label || "";
const selectOption = (field, options, event) => { const selectOption = (field, options, event) => {
addForm[field] = options[Number(event.detail.value)]?.value || ""; addForm[field] = options[Number(event.detail.value)]?.value || "";
if (field === "personStatus" && addForm.personStatus !== "1") {
Object.assign(addForm, {
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
});
}
}; };
const selectBindingMode = (event) => { const selectBindingMode = (event) => {
addForm.bindingMode = addForm.bindingMode =
bindingModeOptions[Number(event.detail.value)]?.value || "NONE"; bindingModeOptions[Number(event.detail.value)]?.value || "NONE";
fieldErrors.bindingMode = ""; fieldErrors.bindingMode = "";
}; };
const personOptionIndex = (value) =>
Math.max(
0,
personOptions.value.findIndex((item) => item.value === value),
);
const personOptionLabel = (value) =>
personOptions.value.find((item) => item.value === value)?.label || "不选择";
const selectPerson = (field, event) => {
addForm[field] = personOptions.value[Number(event.detail.value)]?.value || "";
};
const uploadAvatar = async () => { const uploadAvatar = async () => {
if (isAvatarUploading.value || isSubmitting.value) return; if (isAvatarUploading.value || isSubmitting.value) return;
isAvatarUploading.value = true; isAvatarUploading.value = true;
@@ -665,13 +591,10 @@ const submitAdd = async () => {
try { try {
const payload = { const payload = {
bindingMode: addForm.bindingMode, bindingMode: addForm.bindingMode,
personNo: addForm.personNo,
name: addForm.name, name: addForm.name,
aliasName: addForm.aliasName, aliasName: addForm.aliasName,
sex: addForm.sex, sex: addForm.sex,
generationName: addForm.generationName, generationName: addForm.generationName,
fatherId: addForm.fatherId,
motherId: addForm.motherId,
avatarOssId: addForm.avatarOssId, avatarOssId: addForm.avatarOssId,
birthDate: addForm.birthDate, birthDate: addForm.birthDate,
birthLunar: addForm.birthLunar, birthLunar: addForm.birthLunar,
@@ -685,12 +608,14 @@ const submitAdd = async () => {
remark: addForm.remark, remark: addForm.remark,
sortOrder: addForm.sortOrder, sortOrder: addForm.sortOrder,
...(isFirstMember.value ...(isFirstMember.value
? { generation: 1, relationName: addForm.relationName } ? { generation: 1 }
: { : {
...(addForm.generation ...(addForm.generation
? { generation: Number(addForm.generation) } ? { generation: Number(addForm.generation) }
: {}), : {}),
relationName: relationLabel.value, ...(relationType.value === "SPOUSE"
? { relationName: relationLabel.value }
: {}),
}), }),
}; };
if (isFirstMember.value) { if (isFirstMember.value) {
@@ -711,15 +636,12 @@ const submitAdd = async () => {
} }
Object.assign(addForm, { Object.assign(addForm, {
bindingMode: "NONE", bindingMode: "NONE",
personNo: "",
name: "", name: "",
relation: "", relation: "",
aliasName: "", aliasName: "",
sex: "", sex: "",
generation: "", generation: "",
generationName: "", generationName: "",
fatherId: "",
motherId: "",
avatarOssId: "", avatarOssId: "",
birthDate: "", birthDate: "",
birthLunar: "", birthLunar: "",
@@ -732,7 +654,6 @@ const submitAdd = async () => {
biography: "", biography: "",
remark: "", remark: "",
sortOrder: "", sortOrder: "",
relationName: "",
}); });
avatarFileName.value = ""; avatarFileName.value = "";
await returnTo("T01", { genealogyId: genealogyId.value }); await returnTo("T01", { genealogyId: genealogyId.value });
+48 -30
View File
@@ -49,12 +49,7 @@
<view class="form-field"> <view class="form-field">
<text>人物编号</text> <text>人物编号</text>
<input <text>{{ originalMember.personNo || "服务端生成" }}</text>
v-model="editForm.personNo"
maxlength="40"
placeholder="不填则由服务端生成"
placeholder-class="form-placeholder"
/>
</view> </view>
<picker <picker
:range="sexOptions.map((item) => item.label)" :range="sexOptions.map((item) => item.label)"
@@ -68,7 +63,14 @@
}}</text> }}</text>
</view> </view>
</picker> </picker>
<view
v-if="editForm.bindingMode === 'SPECIFIED'"
class="form-field form-field--picker"
>
<text>身份认领</text><text>已绑定指定用户</text>
</view>
<picker <picker
v-else
:range="bindingModeOptions.map((item) => item.label)" :range="bindingModeOptions.map((item) => item.label)"
:value="optionIndex(bindingModeOptions, editForm.bindingMode)" :value="optionIndex(bindingModeOptions, editForm.bindingMode)"
@change="selectBindingMode" @change="selectBindingMode"
@@ -183,6 +185,7 @@
/> />
</view> </view>
<picker <picker
v-if="isDeceased"
mode="date" mode="date"
:value="editForm.deathDate" :value="editForm.deathDate"
@change="selectDate('deathDate', $event)" @change="selectDate('deathDate', $event)"
@@ -193,6 +196,7 @@
> >
</picker> </picker>
<picker <picker
v-if="isDeceased"
:range="lunarOptions.map((item) => item.label)" :range="lunarOptions.map((item) => item.label)"
:value="optionIndex(lunarOptions, editForm.deathLunar)" :value="optionIndex(lunarOptions, editForm.deathLunar)"
@change="selectOption('deathLunar', lunarOptions, $event)" @change="selectOption('deathLunar', lunarOptions, $event)"
@@ -204,7 +208,7 @@
}}</text> }}</text>
</view> </view>
</picker> </picker>
<view class="form-field"> <view v-if="isDeceased" class="form-field">
<text>逝世地</text> <text>逝世地</text>
<input <input
v-model="editForm.deathPlace" v-model="editForm.deathPlace"
@@ -212,7 +216,7 @@
placeholder-class="form-placeholder" placeholder-class="form-placeholder"
/> />
</view> </view>
<view class="form-field"> <view v-if="isDeceased" class="form-field">
<text>安葬地</text> <text>安葬地</text>
<input <input
v-model="editForm.burialPlace" v-model="editForm.burialPlace"
@@ -242,15 +246,6 @@
placeholder-class="form-placeholder" placeholder-class="form-placeholder"
/> />
</view> </view>
<view class="form-field">
<text>关系名称</text>
<input
v-model="editForm.relationName"
maxlength="30"
placeholder="可选关系显示名称"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field form-field--summary"> <view class="form-field form-field--summary">
<text>人物简介</text> <text>人物简介</text>
<textarea <textarea
@@ -332,7 +327,12 @@ import {
pickAndUploadImage, pickAndUploadImage,
} from "@/utils/resumable-image-upload.js"; } from "@/utils/resumable-image-upload.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js"; import {
finishPage,
goBack,
handleBackPress,
runBackGuard,
} from "@/utils/navigation.js";
const editState = ref("loading"); const editState = ref("loading");
const genealogyId = ref(""); const genealogyId = ref("");
@@ -353,7 +353,6 @@ const baseline = ref("");
const editForm = reactive({ const editForm = reactive({
appUserId: "", appUserId: "",
bindingMode: "NONE", bindingMode: "NONE",
personNo: "",
name: "", name: "",
aliasName: "", aliasName: "",
sex: "", sex: "",
@@ -373,7 +372,6 @@ const editForm = reactive({
summary: "", summary: "",
remark: "", remark: "",
sortOrder: "", sortOrder: "",
relationName: "",
}); });
const fieldErrors = reactive({ name: "", dates: "", bindingMode: "" }); const fieldErrors = reactive({ name: "", dates: "", bindingMode: "" });
const sexOptions = Object.freeze([ const sexOptions = Object.freeze([
@@ -393,7 +391,6 @@ const personStatusOptions = Object.freeze([
const bindingModeOptions = Object.freeze([ const bindingModeOptions = Object.freeze([
{ label: "不绑定账号", value: "NONE" }, { label: "不绑定账号", value: "NONE" },
{ label: "绑定当前账号", value: "SELF" }, { label: "绑定当前账号", value: "SELF" },
{ label: "绑定指定用户", value: "SPECIFIED" },
]); ]);
const personOptions = ref([{ label: "不选择", value: "" }]); const personOptions = ref([{ label: "不选择", value: "" }]);
const personOptionLabels = computed(() => const personOptionLabels = computed(() =>
@@ -408,6 +405,7 @@ const appUserBindingHint = computed(() => {
? "保留当前已认领的指定业务用户" ? "保留当前已认领的指定业务用户"
: "需先选择指定业务用户;当前没有可用候选接口"; : "需先选择指定业务用户;当前没有可用候选接口";
}); });
const isDeceased = computed(() => editForm.personStatus === "1");
const formSnapshot = computed(() => JSON.stringify(editForm)); const formSnapshot = computed(() => JSON.stringify(editForm));
const hasValidContext = computed(() => const hasValidContext = computed(() =>
@@ -463,11 +461,11 @@ const loadMember = async () => {
}); });
if (activeLoad !== loadSequence) return; if (activeLoad !== loadSequence) return;
originalMember.value = member; originalMember.value = member;
const deceased = member.personStatus === "1";
Object.assign(editForm, { Object.assign(editForm, {
appUserId: member.appUserId || "", appUserId: member.appUserId || "",
bindingMode: bindingMode:
member.bindingMode || (member.appUserId ? "SPECIFIED" : "NONE"), member.bindingMode || (member.appUserId ? "SPECIFIED" : "NONE"),
personNo: member.personNo || "",
name: member.name, name: member.name,
aliasName: member.aliasName || "", aliasName: member.aliasName || "",
sex: member.sex || "", sex: member.sex || "",
@@ -481,15 +479,14 @@ const loadMember = async () => {
birthDate: datePart(member.birthDate), birthDate: datePart(member.birthDate),
birthLunar: member.birthLunar || "", birthLunar: member.birthLunar || "",
birthPlace: member.birthplace || "", birthPlace: member.birthplace || "",
deathDate: datePart(member.deathDate), deathDate: deceased ? datePart(member.deathDate) : "",
deathLunar: member.deathLunar || "", deathLunar: deceased ? member.deathLunar || "" : "",
deathPlace: member.deathPlace || "", deathPlace: deceased ? member.deathPlace || "" : "",
burialPlace: member.burialPlace || "", burialPlace: deceased ? member.burialPlace || "" : "",
personStatus: member.personStatus || "", personStatus: member.personStatus || "",
summary: member.biography || "", summary: member.biography || "",
remark: member.remark || "", remark: member.remark || "",
sortOrder: member.sortOrder ?? "", sortOrder: member.sortOrder ?? "",
relationName: member.relationName || "",
}); });
baseline.value = formSnapshot.value; baseline.value = formSnapshot.value;
errorMessage.value = ""; errorMessage.value = "";
@@ -567,6 +564,14 @@ const optionLabel = (options, value) =>
options.find((item) => item.value === value)?.label || ""; options.find((item) => item.value === value)?.label || "";
const selectOption = (field, options, event) => { const selectOption = (field, options, event) => {
editForm[field] = options[Number(event.detail.value)]?.value || ""; editForm[field] = options[Number(event.detail.value)]?.value || "";
if (field === "personStatus" && editForm.personStatus !== "1") {
Object.assign(editForm, {
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
});
}
}; };
const selectBindingMode = (event) => { const selectBindingMode = (event) => {
editForm.bindingMode = editForm.bindingMode =
@@ -632,7 +637,6 @@ const saveMember = async () => {
...(editForm.bindingMode === "SPECIFIED" ...(editForm.bindingMode === "SPECIFIED"
? { appUserId: editForm.appUserId } ? { appUserId: editForm.appUserId }
: {}), : {}),
personNo: editForm.personNo,
name: editForm.name, name: editForm.name,
aliasName: editForm.aliasName, aliasName: editForm.aliasName,
sex: editForm.sex, sex: editForm.sex,
@@ -646,20 +650,34 @@ const saveMember = async () => {
birthDate: editForm.birthDate, birthDate: editForm.birthDate,
birthLunar: editForm.birthLunar, birthLunar: editForm.birthLunar,
birthPlace: editForm.birthPlace, birthPlace: editForm.birthPlace,
...(isDeceased.value
? {
deathDate: editForm.deathDate, deathDate: editForm.deathDate,
deathLunar: editForm.deathLunar, deathLunar: editForm.deathLunar,
deathPlace: editForm.deathPlace, deathPlace: editForm.deathPlace,
burialPlace: editForm.burialPlace, burialPlace: editForm.burialPlace,
}
: {}),
personStatus: editForm.personStatus, personStatus: editForm.personStatus,
biography: editForm.summary, biography: editForm.summary,
remark: editForm.remark, remark: editForm.remark,
sortOrder: editForm.sortOrder, sortOrder: editForm.sortOrder,
relationName: editForm.relationName,
}, },
{ requestController: editRequestController }, { requestController: editRequestController },
); );
baseline.value = formSnapshot.value; baseline.value = formSnapshot.value;
await goBack(); await finishPage(
"T03",
{
genealogyId: genealogyId.value,
personId: personId.value,
},
{
operation: "member-updated",
entityId: personId.value,
refresh: true,
},
);
} catch (error) { } catch (error) {
if (isRequestCancelled(error)) return; if (isRequestCancelled(error)) return;
failedAction.value = "save"; failedAction.value = "save";
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

+28 -18
View File
@@ -46,7 +46,7 @@ const run = async () => {
}; };
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" }); const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const session = { const session = {
getToken: () => "", getToken: () => "session-1",
saveToken: (token) => globalThis.__savedTokens.push(token), saveToken: (token) => globalThis.__savedTokens.push(token),
clear: () => globalThis.__clearedSessions.push("cleared"), clear: () => globalThis.__clearedSessions.push("cleared"),
}; };
@@ -136,6 +136,17 @@ const run = async () => {
phone: "13800138000", phone: "13800138000",
}, "策略关闭时发码请求不得伪造 validToken"); }, "策略关闭时发码请求不得伪造 validToken");
respond({ statusCode: 200, data: { code: 200 } });
assert.strictEqual(
await appApi.changePhone({ phone: "13900139000", smsCode: "1234" }),
null,
"手机号换绑的 RVoid 响应必须解析为 null",
);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/auth/phone");
assert.strictEqual(requests.at(-1).method, "PUT");
assert.strictEqual(requests.at(-1).header.Authorization, "Bearer session-1");
assert.deepStrictEqual(requests.at(-1).data, { phone: "13900139000", smsCode: "1234" });
// 已鉴权读取收到业务 401 时,只清理失效的本地会话;请求仍向调用方失败返回。 // 已鉴权读取收到业务 401 时,只清理失效的本地会话;请求仍向调用方失败返回。
respond({ statusCode: 200, data: { code: 401, msg: "认证失败", data: null } }); respond({ statusCode: 200, data: { code: 401, msg: "认证失败", data: null } });
await assert.rejects( await assert.rejects(
@@ -164,25 +175,12 @@ const run = async () => {
null, null,
"找回密码的 RVoid 省略 data 时仍必须解析为 null", "找回密码的 RVoid 省略 data 时仍必须解析为 null",
); );
respond({
statusCode: 200,
data: { code: 200, msg: "操作成功", data: { access_token: "token-1" } },
});
respond({ statusCode: 200, data: { code: 200 } });
await appApi.sendLegacySmsCode({
phone: "13800138000",
validToken: "ticket-legacy",
});
assert.strictEqual(
requests.at(-1).url,
"https://backend-api.ddxcjp.cn/genealogy/app/auth/sms/code",
);
assert.deepStrictEqual(requests.at(-1).data, { assert.deepStrictEqual(requests.at(-1).data, {
tenantId: "000000", tenantId: "000000",
grantType: "sms", grantType: "password",
phone: "13800138000", phone: "13800138000",
validToken: "ticket-legacy", newPassword: "a".repeat(32),
smsCode: "1234",
}); });
respond({ respond({
@@ -191,6 +189,13 @@ const run = async () => {
}); });
const login = await appApi.loginWithSms({ phone: "13800138000", smsCode: "1234" }); const login = await appApi.loginWithSms({ phone: "13800138000", smsCode: "1234" });
assert.strictEqual(login.access_token, "token-1"); assert.strictEqual(login.access_token, "token-1");
assert.deepStrictEqual(requests.at(-1).data, {
tenantId: "000000",
grantType: "sms",
phone: "13800138000",
smsCode: "1234",
});
assert.strictEqual(requests.at(-1).header.Authorization, undefined);
assert.deepStrictEqual(savedTokens, ["token-1"]); assert.deepStrictEqual(savedTokens, ["token-1"]);
respond({ respond({
@@ -241,7 +246,6 @@ const run = async () => {
}); });
assert.strictEqual(registration.access_token, "token-register"); assert.strictEqual(registration.access_token, "token-register");
assert.deepStrictEqual(requests.at(-1).data, { assert.deepStrictEqual(requests.at(-1).data, {
clientId: "client-1",
tenantId: "000000", tenantId: "000000",
grantType: "password", grantType: "password",
phone: "13800138000", phone: "13800138000",
@@ -262,6 +266,12 @@ const run = async () => {
}); });
assert.strictEqual(Object.hasOwn(requests.at(-1).data, "nickName"), false); assert.strictEqual(Object.hasOwn(requests.at(-1).data, "nickName"), false);
respond({ statusCode: 200, data: { code: 200 } });
assert.strictEqual(await appApi.deactivateAccount({ smsCode: "1234" }), null);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/auth/account/deactivate");
assert.strictEqual(requests.at(-1).header.Authorization, "Bearer session-1");
assert.deepStrictEqual(requests.at(-1).data, { smsCode: "1234" });
holdResponse = true; holdResponse = true;
const requestController = createRequestController(); const requestController = createRequestController();
const cancelled = appApi.sendSmsCode( const cancelled = appApi.sendSmsCode(
+2
View File
@@ -29,6 +29,8 @@ const run = async () => {
SMS_LOGIN: "sms-login", SMS_LOGIN: "sms-login",
REGISTER: "register", REGISTER: "register",
FORGOT_PASSWORD: "forgot-password", FORGOT_PASSWORD: "forgot-password",
PHONE_CHANGE: "phone-change",
ACCOUNT_DEACTIVATE: "account-deactivate",
}, },
"认证动作必须由受保护 OpenAPI 的唯一枚举拥有", "认证动作必须由受保护 OpenAPI 的唯一枚举拥有",
); );
+88 -41
View File
@@ -1,57 +1,104 @@
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
$documents = @(Get-ChildItem -LiteralPath $root -File -Filter '*.openapi.json') $openApiPath = Join-Path $root 'genealogy-app-openapi.yaml'
if ($documents.Count -ne 1) { throw 'Exactly one current OpenAPI JSON export is required at the repository root' } if (-not (Test-Path -LiteralPath $openApiPath)) {
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $documents[0].FullName | ConvertFrom-Json throw 'Current backend OpenAPI export genealogy-app-openapi.yaml is missing at the repository root'
$operations = @(
foreach ($pathProperty in $document.paths.PSObject.Properties) {
foreach ($methodProperty in $pathProperty.Value.PSObject.Properties) {
if ($methodProperty.Name -in @('get', 'post', 'put', 'delete', 'patch')) {
[PSCustomObject]@{ Path = $pathProperty.Name; Method = $methodProperty.Name }
}
}
}
)
if ($operations.Count -ne 149) { throw "Current OpenAPI operation count drifted: $($operations.Count)" }
function Get-Operation {
param([string]$Path, [string]$Method)
$pathProperty = $document.paths.PSObject.Properties[$Path]
if (-not $pathProperty) { throw "Missing path: $Path" }
$operation = $pathProperty.Value.PSObject.Properties[$Method]
if (-not $operation) { throw "Missing operation: $Method $Path" }
return $operation.Value
} }
$videoList = Get-Operation -Path '/genealogy/app/genealogies/{genealogyId}/videos' -Method 'post' $document = Get-Content -Raw -Encoding UTF8 -LiteralPath $openApiPath
if ($videoList.requestBody.content.'application/json'.schema.'$ref' -ne '#/components/schemas/VideoBody') { $operationCount = [regex]::Matches($document, '(?m)^ (?:get|post|put|delete|patch):\s*$').Count
throw 'Video create must consume VideoBody' if ($operationCount -ne 137) {
throw "Current backend OpenAPI operation count drifted: $operationCount"
} }
$videoBody = $document.components.schemas.VideoBody
if ((@($videoBody.required) -join ',') -ne 'videoTitle,videoOssId') { function Get-PathBlock {
throw 'VideoBody required fields drifted' param([string]$Path)
$escapedPath = [regex]::Escape($Path)
$match = [regex]::Match($document, "(?ms)^ ${escapedPath}:`r?`n(.*?)(?=^ /|\z)")
if (-not $match.Success) { throw "Missing path: $Path" }
return $match.Groups[1].Value
} }
if ($videoList.responses.'200'.content.'application/json'.schema) {
throw 'Video create unexpectedly gained a response DTO; review F10 integration' function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
} }
foreach ($path in @(
'/genealogy/app/auth/sms/{operationCode}/code',
'/genealogy/app/files/resumable/init',
'/genealogy/app/files/resumable/chunk',
'/genealogy/app/files/resumable/complete',
'/genealogy/app/region/children',
'/genealogy/app/region/path/{regionCode}',
'/genealogy/app/region/search',
'/genealogy/app/region/{regionCode}'
)) {
[void](Get-PathBlock $path)
}
foreach ($retiredPath in @(
'/captcha/require',
'/captcha/challenge',
'/captcha/verify',
'/auth/code',
'/genealogy/app/auth/sms/code',
'/genealogy/app/files/upload',
'/genealogy/app/files/reference',
'/genealogy/region/children',
'/genealogy/region/path/{regionCode}',
'/genealogy/region/search',
'/genealogy/region/{regionCode}'
)) {
if ($document -match "(?m)^ $([regex]::Escape($retiredPath)):") {
throw "Retired path remains in current backend OpenAPI: $retiredPath"
}
}
$videoPath = Get-PathBlock '/genealogy/app/genealogies/{genealogyId}/videos'
Assert-Contains $videoPath "`$ref: '#/components/requestBodies/Video'" 'Video create must consume VideoBody'
Assert-Contains $videoPath "`$ref: '#/components/responses/ListResult'" 'Video list remains a generic ListResult until the backend publishes a VideoView DTO'
$videoSchema = [regex]::Match($document, "(?ms)^ VideoBody:`r?`n(.*?)(?=^ [A-Za-z][A-Za-z0-9_-]*:|\z)").Value
Assert-Contains $videoSchema 'required: [videoTitle, videoOssId]' 'VideoBody required fields drifted'
foreach ($path in @('/genealogy/app/site/articles', '/genealogy/app/site/pages/{pageKey}')) {
$sitePath = Get-PathBlock $path
if ($sitePath -match 'VideoView|SiteArticleView|SitePageView') {
throw "Site content response DTO changed for $path; review M10 integration"
}
}
$phoneChangePath = Get-PathBlock '/genealogy/app/auth/phone'
Assert-Contains $phoneChangePath "`$ref: '#/components/requestBodies/PhoneChange'" 'Phone change must consume PhoneChangeBody'
$phoneChangeSchema = [regex]::Match($document, "(?ms)^ PhoneChangeBody:`r?`n(.*?)(?=^ [A-Za-z][A-Za-z0-9_-]*:|\z)").Value
Assert-Contains $phoneChangeSchema 'additionalProperties: false' 'PhoneChangeBody must reject legacy auth payload fields'
Assert-Contains $phoneChangeSchema 'required: [phone, smsCode]' 'PhoneChangeBody required fields drifted'
$apiSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/api.js')
foreach ($expected in @(
'/genealogy/app/auth/sms/${encodeURIComponent(assertAuthVerificationOperation(operationCode))}/code',
'/genealogy/app/region/children',
'/genealogy/app/region/path/${encodeURIComponent(normalizeRegionCode(regionCode))}',
'/genealogy/app/region/search',
'/genealogy/app/region/${encodeURIComponent(normalizeRegionCode(regionCode))}'
)) {
Assert-Contains $apiSource $expected "Current API adapter missing: $expected"
}
foreach ($retiredPath in @('/genealogy/app/auth/sms/code', '/genealogy/app/files/upload', '/genealogy/app/files/reference', '/genealogy/region/')) {
if ($apiSource.Contains($retiredPath)) { throw "API adapter retains retired backend path: $retiredPath" }
}
$phonePayload = [regex]::Match($apiSource, "(?ms)const normalizePhoneChangePayload = \(payload\) => \{(.*?)^\}").Value
Assert-Contains $phonePayload 'return { phone: payload.phone.trim(), smsCode: assertSmsCode(payload.smsCode) }' 'Phone change payload must only contain the documented fields'
if ($phonePayload.Contains('authPayload(')) { throw 'Phone change payload must not add clientId or tenantId to PhoneChangeBody' }
$videoPage = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'pages/family/f10-video-list.vue') $videoPage = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'pages/family/f10-video-list.vue')
foreach ($token in @('pickAndUploadVideo', 'appApi.createVideo', 'videoTitle', 'videoOssId: receipt.value.ossId')) { foreach ($token in @('pickAndUploadVideo', 'appApi.createVideo', 'videoTitle', 'videoOssId: receipt.value.ossId')) {
if (-not $videoPage.Contains($token)) { throw "F10 video publish contract missing: $token" } Assert-Contains $videoPage $token "F10 video publish contract missing: $token"
} }
foreach ($forbidden in @('v-model="form.videoOssId"', 'v-model="form.status"', 'v-model="form.durationSeconds"')) { foreach ($forbidden in @('v-model="form.videoOssId"', 'v-model="form.status"', 'v-model="form.durationSeconds"')) {
if ($videoPage.Contains($forbidden)) { throw "F10 must not expose auto or management field: $forbidden" } if ($videoPage.Contains($forbidden)) { throw "F10 must not expose auto or management field: $forbidden" }
} }
foreach ($path in @('/genealogy/app/site/articles', '/genealogy/app/site/pages/{pageKey}')) {
$operation = Get-Operation -Path $path -Method 'get'
if ($operation.responses.'200'.content.'application/json'.schema) {
throw "Site content response DTO changed for $path; review M10 integration"
}
}
$auditDocs = @(Get-ChildItem -LiteralPath (Join-Path $root 'docs') -File -Filter '*149*.md')
if ($auditDocs.Count -lt 1) { throw 'Current 149 operation audit document is missing' }
Write-Output 'CURRENT-OPENAPI-INVENTORY-CONTRACT PASS' Write-Output 'CURRENT-OPENAPI-INVENTORY-CONTRACT PASS'
+5 -4
View File
@@ -78,7 +78,7 @@ const run = async () => {
toDataModuleUrl(`${prelude}\n${moduleBody}`), toDataModuleUrl(`${prelude}\n${moduleBody}`),
); );
const submit = (payload = { const submit = (payload = {
feedbackType: "功能问题", feedbackType: "bug",
feedbackContent: "上传照片时出现异常", feedbackContent: "上传照片时出现异常",
contactInfo: "user@example.com", contactInfo: "user@example.com",
}) => appApi.submitFeedback(payload); }) => appApi.submitFeedback(payload);
@@ -90,11 +90,12 @@ const run = async () => {
{ feedbackContent: " " }, { feedbackContent: " " },
{ feedbackContent: 123 }, { feedbackContent: 123 },
{ feedbackContent: "内容", feedbackType: 1 }, { feedbackContent: "内容", feedbackType: 1 },
{ feedbackContent: "内容", feedbackType: "feature" },
{ feedbackContent: "内容", contactInfo: false }, { feedbackContent: "内容", contactInfo: false },
{ feedbackContent: "内容", type: "旧字段" }, { feedbackContent: "内容", type: "旧字段" },
Object.assign(Object.create({ feedbackType: "继承字段" }), { feedbackContent: "内容" }), Object.assign(Object.create({ feedbackType: "继承字段" }), { feedbackContent: "内容" }),
]) { ]) {
await assert.rejects(submit(invalid), /反馈|字段|对象|字符串/); await assert.rejects(submit(invalid), /反馈|字段|对象|字符串|feedbackType/);
} }
nextResponse = { statusCode: 200, data: { code: 200, msg: "成功", data: { accepted: true } } }; nextResponse = { statusCode: 200, data: { code: 200, msg: "成功", data: { accepted: true } } };
@@ -103,11 +104,11 @@ const run = async () => {
assert.deepStrictEqual(requests.at(-1).data, { feedbackContent: "仅反馈内容" }); assert.deepStrictEqual(requests.at(-1).data, { feedbackContent: "仅反馈内容" });
nextResponse = { statusCode: 200, data: { code: 200, msg: "成功", data: { feedbackId: 1 } } }; nextResponse = { statusCode: 200, data: { code: 200, msg: "成功", data: { feedbackId: 1 } } };
const result = await submit({ feedbackContent: " 有效反馈 ", feedbackType: " 使用建议 ", contactInfo: " " }); const result = await submit({ feedbackContent: " 有效反馈 ", feedbackType: " advice ", contactInfo: " " });
assert.deepStrictEqual(result, { feedbackId: 1 }); assert.deepStrictEqual(result, { feedbackId: 1 });
assert.deepStrictEqual(requests.at(-1).data, { assert.deepStrictEqual(requests.at(-1).data, {
feedbackContent: "有效反馈", feedbackContent: "有效反馈",
feedbackType: "使用建议", feedbackType: "advice",
}); });
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/feedback"); assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/feedback");
assert.strictEqual(requests.at(-1).method, "POST"); assert.strictEqual(requests.at(-1).method, "POST");
+46 -60
View File
@@ -43,30 +43,11 @@ const run = async () => {
}, },
}; };
const uploads = []; const uploads = [];
const singleUploads = [];
globalThis.plus = {
uploader: {
createUpload(url, options, callback) {
const upload = {
headers: {},
files: [],
setRequestHeader(key, value) { this.headers[key] = value; },
addFile(filePath, fileOptions) { this.files.push({ filePath, fileOptions }); },
start() { queueMicrotask(() => callback({ responseText: JSON.stringify({ code: 200, data: null }) }, 200)); },
abort() {},
};
uploads.push({ url, options, upload });
return upload;
},
},
};
globalThis.uni.uploadFile = (options) => { globalThis.uni.uploadFile = (options) => {
singleUploads.push(options); const task = { abort() {} };
queueMicrotask(() => options.success({ uploads.push({ options, task });
statusCode: 200, queueMicrotask(() => options.success({ statusCode: 200, data: JSON.stringify({ code: 200, data: null }) }));
data: JSON.stringify({ code: 200, data: { ossId: "900002", url: "https://oss.example/single.png", thumbnailUrl: "", fileName: "single.png" } }), return task;
}));
return { abort() {} };
}; };
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`)); const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
const basePayload = { const basePayload = {
@@ -110,9 +91,46 @@ const run = async () => {
filePath: "/storage/emulated/0/avatar.png", filePath: "/storage/emulated/0/avatar.png",
}); });
assert.strictEqual(uploads.length, 1); assert.strictEqual(uploads.length, 1);
assert.strictEqual(uploads[0].url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/chunk?uploadId=upload-1&chunkIndex=0&chunkMd5=" + "b".repeat(32)); assert.strictEqual(uploads[0].options.url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/chunk");
assert.deepStrictEqual(uploads[0].upload.headers, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" }); assert.strictEqual(uploads[0].options.filePath, "/storage/emulated/0/avatar.png");
assert.deepStrictEqual(uploads[0].upload.files, [{ filePath: "/storage/emulated/0/avatar.png", fileOptions: { key: "file" } }]); assert.strictEqual(uploads[0].options.name, "file");
assert.strictEqual(uploads[0].options.timeout, 15000);
assert.deepStrictEqual(uploads[0].options.header, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" });
assert.deepStrictEqual(uploads[0].options.formData, { uploadId: "upload-1", chunkIndex: "0", chunkMd5: "b".repeat(32) });
globalThis.uni.uploadFile = (options) => {
queueMicrotask(() => options.fail({ errMsg: "uploadFile:fail timeout" }));
return { abort() {} };
};
await assert.rejects(appApi.uploadResumableChunk({
uploadId: "upload-1",
chunkIndex: 0,
chunkMd5: "b".repeat(32),
filePath: "/storage/emulated/0/avatar.png",
}), (error) => error.code === "REQUEST_TIMEOUT");
const browserRequests = [];
class TestFormData {
entries = [];
append(name, value, fileName) { this.entries.push({ name, value, fileName }); }
}
globalThis.FormData = TestFormData;
globalThis.fetch = async (url, options) => {
browserRequests.push({ url, options });
return { status: 200, text: async () => JSON.stringify({ code: 200, data: null }) };
};
await appApi.uploadBrowserResumableChunk({
uploadId: "upload-1",
chunkIndex: 0,
chunkMd5: "b".repeat(32),
}, { name: "avatar.png" });
assert.strictEqual(browserRequests[0].url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/chunk");
assert.deepStrictEqual(browserRequests[0].options.body.entries, [
{ name: "uploadId", value: "upload-1", fileName: undefined },
{ name: "chunkIndex", value: "0", fileName: undefined },
{ name: "chunkMd5", value: "b".repeat(32), fileName: undefined },
{ name: "file", value: { name: "avatar.png" }, fileName: "avatar.png" },
]);
response = { statusCode: 200, data: { code: 200, data: { ossId: "900001", url: "https://oss.example/avatar.png", thumbnailUrl: "", fileName: "avatar.png" } } }; response = { statusCode: 200, data: { code: 200, data: { ossId: "900001", url: "https://oss.example/avatar.png", thumbnailUrl: "", fileName: "avatar.png" } } };
assert.deepStrictEqual(await appApi.completeResumableUpload(completePayload), { assert.deepStrictEqual(await appApi.completeResumableUpload(completePayload), {
@@ -122,39 +140,6 @@ const run = async () => {
fileName: "avatar.png", fileName: "avatar.png",
}); });
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/complete"); assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/complete");
assert.deepStrictEqual(await appApi.uploadSingleFile({ filePath: "/storage/emulated/0/single.png" }), {
ossId: "900002",
url: "https://oss.example/single.png",
thumbnailUrl: "",
fileName: "single.png",
});
assert.strictEqual(singleUploads.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/upload");
assert.strictEqual(singleUploads.at(-1).name, "file");
assert.strictEqual(singleUploads.at(-1).filePath, "/storage/emulated/0/single.png");
assert.deepStrictEqual(singleUploads.at(-1).header, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" });
response = { statusCode: 200, data: { code: 200, data: null } };
await appApi.createFileReference({
bizType: "family_feed",
bizName: "family feed image",
bizTable: "gen_family_feed",
bizId: "900013001",
bizField: "media_oss_ids",
ossId: "900002",
usageScene: "feed_image",
});
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/reference");
assert.deepStrictEqual(requests.at(-1).data, {
bizType: "family_feed",
bizName: "family feed image",
bizTable: "gen_family_feed",
bizId: 900013001,
bizField: "media_oss_ids",
ossId: 900002,
usageScene: "feed_image",
});
await appApi.deleteFileReference({ bizTable: "gen_family_feed", bizId: "900013001", bizField: "media_oss_ids" });
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/reference?bizTable=gen_family_feed&bizId=900013001&bizField=media_oss_ids");
await assert.rejects( await assert.rejects(
appApi.initializeResumableUpload({ ...basePayload, totalSize: 0 }), appApi.initializeResumableUpload({ ...basePayload, totalSize: 0 }),
/totalSize/, /totalSize/,
@@ -164,8 +149,9 @@ const run = async () => {
/chunkIndex/, /chunkIndex/,
); );
delete globalThis.plus;
delete globalThis.uni; delete globalThis.uni;
delete globalThis.fetch;
delete globalThis.FormData;
process.stdout.write("FILE-UPLOAD-API-RUNTIME-SMOKE PASS\n"); process.stdout.write("FILE-UPLOAD-API-RUNTIME-SMOKE PASS\n");
}; };
+23 -14
View File
@@ -33,18 +33,13 @@ foreach ($source in @($t04, $t05)) {
} }
foreach ($required in @( foreach ($required in @(
'v-model="addForm.personNo"',
'v-model="addForm.aliasName"', 'v-model="addForm.aliasName"',
'v-model="addForm.generation"', 'v-model="addForm.generation"',
'v-model="addForm.generationName"', 'v-model="addForm.generationName"',
'v-model="addForm.birthPlace"', 'v-model="addForm.birthPlace"',
'v-model="addForm.deathPlace"',
'v-model="addForm.burialPlace"',
'v-model="addForm.biography"', 'v-model="addForm.biography"',
'v-model="addForm.remark"', 'v-model="addForm.remark"',
'v-model="addForm.sortOrder"', 'v-model="addForm.sortOrder"',
'v-model="addForm.relationName"',
'personNo: addForm.personNo',
'sex: addForm.sex', 'sex: addForm.sex',
'avatarOssId: addForm.avatarOssId', 'avatarOssId: addForm.avatarOssId',
'personStatus: addForm.personStatus', 'personStatus: addForm.personStatus',
@@ -53,28 +48,24 @@ foreach ($required in @(
'generationName: addForm.generationName', 'generationName: addForm.generationName',
'biography: addForm.biography', 'biography: addForm.biography',
'remark: addForm.remark', 'remark: addForm.remark',
'? { generation: 1, relationName: addForm.relationName }', '? { generation: 1 }',
'relationType.value === "SPOUSE"',
'v-if="isDeceased"',
'const isDeceased = computed(() => addForm.personStatus === "1")',
'const sexOptions = Object.freeze([', 'const sexOptions = Object.freeze([',
'const lunarOptions = Object.freeze([', 'const lunarOptions = Object.freeze([',
'const personStatusOptions = Object.freeze([', 'const personStatusOptions = Object.freeze([',
'personOptionLabels',
'appApi.getLineagePersonOptions(',
'const personOptionsRequestController = createRequestController();',
'requestController: personOptionsRequestController',
'const bindingModeOptions = Object.freeze([', 'const bindingModeOptions = Object.freeze([',
'bindingMode: addForm.bindingMode', 'bindingMode: addForm.bindingMode',
'addForm.bindingMode === "SPECIFIED"',
'pickAndUploadImage(' 'pickAndUploadImage('
)) { )) {
if (-not $t04.Contains($required)) { throw "T04 safe optional field missing: $required" } if (-not $t04.Contains($required)) { throw "T04 safe optional field missing: $required" }
} }
foreach ($required in @( foreach ($required in @(
'v-model="editForm.personNo"', 'originalMember.personNo ||',
'v-model="editForm.generation"', 'v-model="editForm.generation"',
'v-model="editForm.sortOrder"', 'v-model="editForm.sortOrder"',
'v-model="editForm.relationName"',
'personNo: editForm.personNo',
'sex: editForm.sex', 'sex: editForm.sex',
'avatarOssId: editForm.avatarOssId', 'avatarOssId: editForm.avatarOssId',
'personStatus: editForm.personStatus', 'personStatus: editForm.personStatus',
@@ -89,11 +80,29 @@ foreach ($required in @(
'const bindingModeOptions = Object.freeze([', 'const bindingModeOptions = Object.freeze([',
'bindingMode: editForm.bindingMode', 'bindingMode: editForm.bindingMode',
'editForm.bindingMode === "SPECIFIED"', 'editForm.bindingMode === "SPECIFIED"',
'const isDeceased = computed(() => editForm.personStatus === "1")',
'v-if="isDeceased"',
'pickAndUploadImage(' 'pickAndUploadImage('
)) { )) {
if (-not $t05.Contains($required)) { throw "T05 LineagePersonBody field missing: $required" } if (-not $t05.Contains($required)) { throw "T05 LineagePersonBody field missing: $required" }
} }
foreach ($forbidden in @(
'v-model="addForm.personNo"',
'personNo: addForm.personNo',
'v-model="addForm.fatherId"',
'v-model="addForm.motherId"',
'v-model="addForm.relationName"',
'v-model="editForm.personNo"',
'personNo: editForm.personNo',
'v-model="editForm.relationName"',
'relationName: editForm.relationName'
)) {
if ($t04.Contains($forbidden) -or $t05.Contains($forbidden)) {
throw "Lineage system-managed or relation-context field must not be editable: $forbidden"
}
}
foreach ($forbidden in @( foreach ($forbidden in @(
'v-model="addForm.appUserId"', 'v-model="addForm.appUserId"',
'v-model="addForm.fatherId"', 'v-model="addForm.fatherId"',
+3 -2
View File
@@ -2,9 +2,10 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent $root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/profile/m01-profile-home.vue'), [System.Text.Encoding]::UTF8) $page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/profile/m01-profile-home.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @('grid-template-columns: 42rpx minmax(0, 1fr)','pointer-events: none')) { foreach ($expected in @('grid-area: 1 / 1','display: flex','pointer-events: none')) {
if (-not $page.Contains($expected)) { throw "M01 document-flow contract missing: $expected" } if (-not $page.Contains($expected)) { throw "M01 document-flow contract missing: $expected" }
} }
if ($page -match '(?s)\.profile-hero__label\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;') { throw 'M01 profile hero label must use the hero grid flow' } if ($page -notmatch '(?s)\.profile-hero__identity\s*\{[^}]*position\s*:\s*relative\s*;') { throw 'M01 hero identity must provide the edit-action anchor' }
if ($page -notmatch '(?s)\.profile-hero__edit\s*\{[^}]*position\s*:\s*absolute\s*;') { throw 'M01 edit action must be positioned within the hero identity block' }
Write-Output 'M01-DOCUMENT-FLOW-CONTRACT PASS' Write-Output 'M01-DOCUMENT-FLOW-CONTRACT PASS'
+42 -47
View File
@@ -1,67 +1,62 @@
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$source = Get-Content 'pages/profile/m01-profile-home.vue' -Raw -Encoding UTF8 $source = Get-Content 'pages/profile/m01-profile-home.vue' -Raw -Encoding UTF8
$profiles = Get-Content 'styles/adaptive-frame-profiles.scss' -Raw -Encoding UTF8
$required = @( foreach ($required in @(
'profile-state--ready', 'profile-state--ready',
'profile-state--error', 'profile-state--error',
'@include adaptive.adaptive-profile-summary;', 'm01-profile-archive-hero-v2.png',
'@include adaptive.adaptive-scroll-button(primary);', 'm01-profile-tree-medallion.png',
'brand-seal.png', 'class="profile-hero__art"',
'auth-divider-knot.png', 'class="profile-hero__content"',
'chevron-right.png', 'class="profile-metadata"',
'root-header-hall.png', 'class="profile-service-row"',
'a01-icon-lock-v1.png', 'grid-area: 1 / 1',
'notice.png', 'height: 503rpx',
'height: 480rpx',
'white-space: nowrap',
'pointer-events: none',
'AppButton', 'AppButton',
'import { openPage } from "@/utils/navigation.js";', 'AppTabbar',
'routeKey: "M03"', 'routeKey: "M03"',
'routeKey: "N01"',
'routeKey: "M06"', 'routeKey: "M06"',
'routeKey: "M10"',
'routeKey: "M08"', 'routeKey: "M08"',
'routeKey: "M09"', 'routeKey: "M09"',
'routeKey: "M10"',
'openPage("M02", {}, "M01")', 'openPage("M02", {}, "M01")',
'openPage("N01", {}, "M01")',
'openPage(item.routeKey, {}, "M01")' 'openPage(item.routeKey, {}, "M01")'
)
foreach ($token in $required) {
if (-not $source.Contains($token)) {
throw "M01 missing contract token: $token"
}
}
foreach ($forbiddenNavigation in @('uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', 'getCurrentPages(', '<navigator', '/pages/')) {
if ($source.Contains($forbiddenNavigation)) {
throw "M01 must use the registered navigation gateway: $forbiddenNavigation"
}
}
foreach ($forbidden in @('m01-profile-notice-card.png', 'm01-profile-menu-frame.png', 'g03-create-flow-panel.png', 'application-status-card.png', 'g06-search-input-wide.png')) {
if ($source.Contains($forbidden)) {
throw "M01 must not reuse opaque business asset: $forbidden"
}
}
if ($source -match '<image\s+(?:[^>]*\s)?class="(?:profile-hero__frame|profile-scroll-notice__skin|profile-error__skin)"') {
throw 'M01 decorative frames must be container backgrounds instead of positioned image layers'
}
if ($source -match 'position\s*:') { throw 'M01 must keep content and local decoration in flex/grid document flow' }
foreach ($token in @(
'class="profile-hero__content"',
'grid-area: 1 / 1'
)) { )) {
if (-not $source.Contains($token)) { throw "M01 missing document-flow token: $token" } if (-not $source.Contains($required)) {
throw "M01 missing archive redesign contract: $required"
}
} }
if ($profiles -notmatch '(?s)@mixin\s+adaptive-profile-summary\s*\{.*?m01-profile-summary-card\.png') { foreach ($forbidden in @(
throw 'The adaptive profile owner must retain the M01 summary-card artwork' 'auth-divider-knot.png',
'root-header-hall.png',
'm01-profile-notice-card.png',
'm01-profile-menu-frame.png',
'uni.navigateTo',
'uni.navigateBack',
'uni.redirectTo',
'uni.reLaunch',
'getCurrentPages(',
'<navigator',
'/pages/'
)) {
if ($source.Contains($forbidden)) {
throw "M01 must not retain obsolete navigation or separate-decoration contract: $forbidden"
} }
if ($profiles -notmatch '(?s)@mixin\s+adaptive-scroll-button\s*\(\$type\).*?a01-scroll-primary-v3\.png') {
throw 'The adaptive profile owner must retain the primary scroll-button artwork'
} }
if ($source -match '100%\s+100%\s+no-repeat|border-image-slice\s*:') {
throw 'M01 must consume adaptive frame geometry without page-local stretching or slicing' if ($source -notmatch '(?s)\.profile-hero__identity\s*\{[^}]*position\s*:\s*relative\s*;' -or
$source -notmatch '(?s)\.profile-hero__edit\s*\{[^}]*position\s*:\s*absolute\s*;') {
throw 'M01 archive redesign must anchor the edit action to the hero identity block'
}
if ($source -match '\.profile-page\s*:deep\(\.app-tabbar\)|\.profile-page\s*:deep\(\.tab-label\)') {
throw 'M01 must preserve the shared AppTabbar appearance'
} }
Write-Output 'M01-MODULE-BASELINE-CONTRACT PASS' Write-Output 'M01-MODULE-BASELINE-CONTRACT PASS'
+134
View File
@@ -0,0 +1,134 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const cdpPort = process.env.CDP_PORT || "9222";
const requestedRoute = process.env.MUMU_ROUTE || "";
const getTargets = async () => (await fetch(`http://127.0.0.1:${cdpPort}/json/list`)).json();
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const latestRouteTarget = (targets, routePrefix = "pages/") => targets
.filter((candidate) => candidate.type === "page" && candidate.title.startsWith(routePrefix))
.sort((left, right) => {
const leftIndex = Number(left.title.match(/\[(\d+)\]$/)?.[1] || 0);
const rightIndex = Number(right.title.match(/\[(\d+)\]$/)?.[1] || 0);
return rightIndex - leftIndex;
})[0];
const connect = async (target) => {
const socket = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener("error", reject, { once: true });
});
let id = 0;
const pending = new Map();
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result);
});
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1;
const requestId = id;
pending.set(requestId, { resolve, reject });
socket.send(JSON.stringify({ id: requestId, method, params }));
});
return { socket, send };
};
const run = async () => {
const configuredRoutes = JSON.parse(fs.readFileSync("pages.json", "utf8")).pages.map((page) => page.path);
const routes = requestedRoute ? [requestedRoute] : configuredRoutes;
assert.ok(routes.every((route) => configuredRoutes.includes(route)), "requested route is not configured in pages.json");
const failures = [];
let previousRoute = "";
for (const route of routes) {
await sleep(previousRoute === "pages/auth/a01-entry" ? 1600 : 350);
const activeTarget = latestRouteTarget(await getTargets());
if (!activeTarget) {
failures.push(`${route}: no active Uni page WebView`);
continue;
}
const active = await connect(activeTarget);
try {
void active.send("Runtime.evaluate", {
expression: `uni.reLaunch({ url: ${JSON.stringify(`/${route}`)} })`,
returnByValue: true,
}).catch(() => {});
} finally {
active.socket.close();
}
const deadline = Date.now() + 5000;
let routedTarget = null;
while (!routedTarget && Date.now() < deadline) {
routedTarget = latestRouteTarget(await getTargets(), `${route}[`);
if (!routedTarget) await sleep(100);
}
if (!routedTarget) {
await sleep(500);
const retryTarget = latestRouteTarget(await getTargets());
if (retryTarget) {
const retry = await connect(retryTarget);
try {
void retry.send("Runtime.evaluate", {
expression: `uni.reLaunch({ url: ${JSON.stringify(`/${route}`)} })`,
returnByValue: true,
}).catch(() => {});
} finally {
retry.socket.close();
}
const retryDeadline = Date.now() + 5000;
while (!routedTarget && Date.now() < retryDeadline) {
routedTarget = latestRouteTarget(await getTargets(), `${route}[`);
if (!routedTarget) await sleep(100);
}
}
}
if (!routedTarget) {
failures.push(`${route}: native route did not open`);
continue;
}
const routed = await connect(routedTarget);
const exceptions = [];
routed.socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.method === "Runtime.exceptionThrown") exceptions.push(message.params.exceptionDetails.text);
});
try {
await routed.send("Runtime.enable");
let page = null;
while (Date.now() < deadline) {
const result = await routed.send("Runtime.evaluate", {
expression: "JSON.stringify({ textLength: document.body?.innerText?.trim().length || 0, loading: [...document.querySelectorAll('.app-loading, .uni-loading, .loading-spinner')].some((element) => { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || 1) > 0 && rect.width > 0 && rect.height > 0; }) })",
returnByValue: true,
});
page = JSON.parse(result.result?.value || "{}");
if (page.textLength > 0 && !page.loading) break;
await sleep(100);
}
if (!page?.textLength || page.loading) {
failures.push(`${route}: page did not reach a stable rendered state`);
} else if (exceptions.length) {
failures.push(`${route}: ${exceptions.join("; ")}`);
} else {
process.stdout.write(`PASS ${route}\n`);
}
} finally {
routed.socket.close();
}
previousRoute = route;
}
if (failures.length) throw new Error(`MuMu native route audit failures:\n${failures.map((failure) => `- ${failure}`).join("\n")}`);
process.stdout.write(`MUMU-NATIVE-ROUTE-RUNTIME-SMOKE PASS routes=${routes.length}\n`);
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+3 -3
View File
@@ -31,7 +31,7 @@ const fs = require("fs");
const routeKeys = Object.keys(ROUTES); const routeKeys = Object.keys(ROUTES);
const routePaths = routeKeys.map((routeKey) => ROUTES[routeKey].path); const routePaths = routeKeys.map((routeKey) => ROUTES[routeKey].path);
assert.strictEqual(routeKeys.length, 52, "路由注册表必须恰好包含 52 条活动路由"); assert.strictEqual(routeKeys.length, 53, "路由注册表必须恰好包含 53 条活动路由");
assert.deepStrictEqual(routeKeys, expectedRouteKeys, "路由键集合和顺序必须来自 pages.json 的页面编号"); assert.deepStrictEqual(routeKeys, expectedRouteKeys, "路由键集合和顺序必须来自 pages.json 的页面编号");
assert.deepStrictEqual(routePaths, registeredPaths, "路由路径集合和顺序必须与 pages.json 精确一致"); assert.deepStrictEqual(routePaths, registeredPaths, "路由路径集合和顺序必须与 pages.json 精确一致");
assert(Object.isFrozen(ROUTES), "ROUTES 必须冻结"); assert(Object.isFrozen(ROUTES), "ROUTES 必须冻结");
@@ -78,8 +78,8 @@ const fs = require("fs");
} }
assert.deepStrictEqual( assert.deepStrictEqual(
Array.from(ROUTES.T03.resultOperations), Array.from(ROUTES.T03.resultOperations),
["member-open-requested"], ["member-open-requested", "member-updated"],
"T03 当前只能保留 single 页面激活实际使用的页内打开结果", "T03 当前必须保留成员打开与编辑完成后的返回结果",
); );
const expectedFamilyParams = { const expectedFamilyParams = {
F01: { required: [], optional: ["genealogyId"] }, F01: { required: [], optional: ["genealogyId"] },
+82
View File
@@ -0,0 +1,82 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const walk = (directory) => fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory() ? walk(path.join(directory, entry.name)) : [path.join(directory, entry.name)],
);
const apiSource = fs.readFileSync("utils/api.js", "utf8");
const apiOwners = new Set([...apiSource.matchAll(/^ async ([A-Za-z0-9_]+)\(/gm)].map((match) => match[1]));
const pageSources = new Map(
walk("pages")
.filter((file) => file.endsWith(".vue"))
.map((file) => [file.replace(/\\/g, "/"), fs.readFileSync(file, "utf8")]),
);
const routedPages = new Set(
JSON.parse(fs.readFileSync("pages.json", "utf8")).pages.map((page) => `${page.path}.vue`),
);
assert.deepStrictEqual(
[...routedPages].filter((file) => !pageSources.has(file)),
[],
"every configured route must have its Vue page source",
);
const calls = [...pageSources].flatMap(([file, source]) =>
[...source.matchAll(/appApi\.([A-Za-z0-9_]+)/g)].map((match) => ({ file, method: match[1] })),
);
assert.ok(calls.length > 0, "page API ownership scan found no calls");
assert.deepStrictEqual(
calls.filter((call) => !apiOwners.has(call.method)),
[],
"every page appApi call must have one adapter owner",
);
assert.deepStrictEqual(
[...routedPages].filter((file) => !calls.some((call) => call.file === file)),
["pages/records/r09-life-events.vue"],
"only the explicitly unavailable life-events page may have no backend call",
);
const expectedCallers = {
sendSmsCode: [
"pages/auth/a01-entry.vue",
"pages/auth/a04-register.vue",
"pages/auth/a05-reset-password.vue",
"pages/profile/m05-change-phone.vue",
],
changePhone: ["pages/profile/m05-change-phone.vue"],
getRegionChildren: [
"pages/genealogy/g03-create-genealogy.vue",
"pages/genealogy/g11-genealogy-settings.vue",
],
};
for (const [method, files] of Object.entries(expectedCallers)) {
const actual = [...new Set(calls.filter((call) => call.method === method).map((call) => call.file))].sort();
assert.deepStrictEqual(actual, files, `${method} page ownership drifted`);
}
const changePhonePage = pageSources.get("pages/profile/m05-change-phone.vue");
for (const requiredToken of [
"appApi.getCaptchaRequirement",
"appApi.sendSmsCode",
"appApi.changePhone",
"AUTH_VERIFICATION_OPERATION.PHONE_CHANGE",
"<TacVerification",
]) {
assert.ok(changePhonePage.includes(requiredToken), `M05 phone-change flow is missing: ${requiredToken}`);
}
for (const source of pageSources.values()) {
for (const retiredPath of [
"/genealogy/app/auth/sms/code",
"/genealogy/app/files/upload",
"/genealogy/app/files/reference",
"/genealogy/region/",
]) {
assert.strictEqual(source.includes(retiredPath), false, `page retains retired API path: ${retiredPath}`);
}
}
process.stdout.write(`PAGE-API-OWNERSHIP-RUNTIME-SMOKE PASS calls=${calls.length}\n`);
+104
View File
@@ -0,0 +1,104 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const YAML = require("yaml");
const walk = (directory) => fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory() ? walk(path.join(directory, entry.name)) : [path.join(directory, entry.name)],
);
const document = YAML.parse(fs.readFileSync("genealogy-app-openapi.yaml", "utf8"));
assert.strictEqual(document.openapi, "3.0.3", "backend contract must be OpenAPI 3.0.3");
const operations = new Set(
Object.entries(document.paths).flatMap(([endpoint, pathItem]) =>
Object.keys(pathItem)
.filter((method) => ["get", "post", "put", "delete", "patch"].includes(method))
.map((method) => `${method.toUpperCase()} ${endpoint}`),
),
);
const apiSource = fs.readFileSync("utils/api.js", "utf8");
const appApiStart = apiSource.indexOf("export const appApi = {");
assert.ok(appApiStart >= 0, "appApi owner is missing");
const appApiSource = apiSource.slice(appApiStart);
const methodMatches = [...appApiSource.matchAll(/^ async ([A-Za-z0-9_]+)\(/gm)];
const methodSources = new Map(
methodMatches.map((match, index) => [
match[1],
appApiSource.slice(match.index, methodMatches[index + 1]?.index),
]),
);
const normalizeEndpoint = (endpoint) => endpoint
.replace(/\$\{[^}]+\}/g, "{parameter}")
.replace(/\{[^/]+\}/g, "{parameter}");
const resolveOpenApiEndpoint = (endpoint) => Object.keys(document.paths).find(
(candidate) => normalizeEndpoint(candidate) === normalizeEndpoint(endpoint),
);
const collectLiteralOperations = (source) => {
const entries = [];
const add = (method, endpoint) => {
if (endpoint.includes("${relationPath}")) {
for (const relationPath of ["parents", "spouses", "siblings", "children"]) {
entries.push({ method, endpoint: endpoint.replace("${relationPath}", relationPath) });
}
return;
}
entries.push({ method, endpoint });
};
const literal = "([`'])";
const endpoint = "(/genealogy[^`']*)";
const readPattern = new RegExp(`readRemote(?:List|Object)\\(\\s*${literal}${endpoint}\\1`, "g");
for (const match of source.matchAll(readPattern)) add("GET", match[2]);
const writePattern = new RegExp(`writeRemote(?:Void|Object)\\(\\s*${literal}${endpoint}\\1\\s*,\\s*['\"](POST|PUT|DELETE|PATCH)['\"]`, "g");
for (const match of source.matchAll(writePattern)) add(match[3], match[2]);
const directPattern = new RegExp(`url:\\s*${literal}${endpoint}\\1\\s*,\\s*method:\\s*['\"](GET|POST|PUT|DELETE|PATCH)['\"]`, "g");
for (const match of source.matchAll(directPattern)) add(match[3], match[2]);
const conditionalMethodPattern = new RegExp(`url:\\s*${literal}${endpoint}\\1\\s*,\\s*method:\\s*[^?]+\\?\\s*['\"](POST|PUT|DELETE|PATCH)['\"]\\s*:\\s*['\"](POST|PUT|DELETE|PATCH)['\"]`, "g");
for (const match of source.matchAll(conditionalMethodPattern)) {
add(match[3], match[2]);
add(match[4], match[2]);
}
return entries;
};
const pageCalls = walk("pages")
.filter((file) => file.endsWith(".vue"))
.flatMap((file) => {
const source = fs.readFileSync(file, "utf8");
return [...source.matchAll(/appApi\.([A-Za-z0-9_]+)/g)].map((match) => ({
file: file.replace(/\\/g, "/"),
method: match[1],
}));
});
const routedPages = new Set(
JSON.parse(fs.readFileSync("pages.json", "utf8")).pages.map((page) => `${page.path}.vue`),
);
const unresolvedMethods = [];
const unmappedOperations = [];
for (const method of [...new Set(pageCalls.map((call) => call.method))]) {
const source = methodSources.get(method);
if (!source) {
unresolvedMethods.push(method);
continue;
}
const references = collectLiteralOperations(source);
if (!references.length) {
unresolvedMethods.push(method);
continue;
}
for (const reference of references) {
const endpoint = resolveOpenApiEndpoint(reference.endpoint);
if (!endpoint || !operations.has(`${reference.method} ${endpoint}`)) {
unmappedOperations.push(`${method}: ${reference.method} ${reference.endpoint}`);
}
}
}
assert.deepStrictEqual(unresolvedMethods, [], "page call has no inspectable adapter endpoint owner");
assert.deepStrictEqual(unmappedOperations, [], "page adapter endpoint diverges from current backend OpenAPI");
process.stdout.write(`PAGE-OPENAPI-CONTRACT PASS routed=${routedPages.size} apiPages=${new Set(pageCalls.map((call) => call.file)).size} calls=${pageCalls.length}\n`);
+3 -3
View File
@@ -51,7 +51,7 @@ const run = async () => {
nickName: "家谱用户", nickName: "家谱用户",
realName: "王小明", realName: "王小明",
avatar: 900001, avatar: 900001,
sex: "", sex: "0",
birthday: "1990-01-02", birthday: "1990-01-02",
email: "ming@example.com", email: "ming@example.com",
registerSource: "APP", registerSource: "APP",
@@ -79,7 +79,7 @@ const run = async () => {
nickName: " 新昵称 ", nickName: " 新昵称 ",
realName: "王小明", realName: "王小明",
avatar: 900001, avatar: 900001,
sex: "", sex: "1",
birthday: "1991-02-03", birthday: "1991-02-03",
email: "new@example.com", email: "new@example.com",
}); });
@@ -87,7 +87,7 @@ const run = async () => {
nickName: "新昵称", nickName: "新昵称",
realName: "王小明", realName: "王小明",
avatar: 900001, avatar: 900001,
sex: "", sex: "1",
birthday: "1991-02-03", birthday: "1991-02-03",
email: "new@example.com", email: "new@example.com",
}); });
+4 -4
View File
@@ -21,27 +21,27 @@ const run = async () => {
globalThis.uni = { request(options) { requests.push(options); queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: responseData } })); return { abort() {} }; } }; globalThis.uni = { request(options) { requests.push(options); queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: responseData } })); return { abort() {} }; } };
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`)); const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
assert.deepStrictEqual(await appApi.getRegionChildren(), [{ regionCode: "11", label: "Beijing", parentCode: "0", leaf: false, regionLevel: 1 }]); assert.deepStrictEqual(await appApi.getRegionChildren(), [{ regionCode: "11", label: "Beijing", parentCode: "0", leaf: false, regionLevel: 1 }]);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/children"); assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/region/children");
assert.strictEqual(requests.at(-1).method, "GET"); assert.strictEqual(requests.at(-1).method, "GET");
assert.deepStrictEqual(requests.at(-1).data, { parentCode: "0" }); assert.deepStrictEqual(requests.at(-1).data, { parentCode: "0" });
await assert.rejects(appApi.getRegionChildren(" "), /地区父级/); await assert.rejects(appApi.getRegionChildren(" "), /地区父级/);
responseData = [{ regionCode: "110000", label: "Beijing" }]; responseData = [{ regionCode: "110000", label: "Beijing" }];
assert.deepStrictEqual(await appApi.getRegionPath("110000"), responseData); assert.deepStrictEqual(await appApi.getRegionPath("110000"), responseData);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/path/110000"); assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/region/path/110000");
await assert.rejects(appApi.getRegionPath(" "), /行政区划编码/); await assert.rejects(appApi.getRegionPath(" "), /行政区划编码/);
assert.deepStrictEqual( assert.deepStrictEqual(
await appApi.searchRegions({ keyword: " Beijing ", level: 2, limit: 20 }), await appApi.searchRegions({ keyword: " Beijing ", level: 2, limit: 20 }),
responseData, responseData,
); );
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/search"); assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/region/search");
assert.deepStrictEqual(requests.at(-1).data, { keyword: "Beijing", level: 2, limit: 20 }); assert.deepStrictEqual(requests.at(-1).data, { keyword: "Beijing", level: 2, limit: 20 });
await assert.rejects(appApi.searchRegions({ keyword: "" }), /行政区划搜索关键词/); await assert.rejects(appApi.searchRegions({ keyword: "" }), /行政区划搜索关键词/);
await assert.rejects(appApi.searchRegions({ keyword: "Beijing", level: 6 }), /行政区划级别/); await assert.rejects(appApi.searchRegions({ keyword: "Beijing", level: 6 }), /行政区划级别/);
responseData = { regionCode: "110000", label: "Beijing" }; responseData = { regionCode: "110000", label: "Beijing" };
assert.deepStrictEqual(await appApi.getRegion("110000"), responseData); assert.deepStrictEqual(await appApi.getRegion("110000"), responseData);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/110000"); assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/region/110000");
delete globalThis.uni; delete globalThis.uni;
process.stdout.write("REGION-API-RUNTIME-SMOKE PASS\n"); process.stdout.write("REGION-API-RUNTIME-SMOKE PASS\n");
}; };
+5 -14
View File
@@ -31,20 +31,10 @@ const run = async () => {
success("/tmp/image.jpg"); success("/tmp/image.jpg");
}, },
}, },
io: { };
resolveLocalFileSystemURL(_path, success) { globalThis.uni = {
success({ getFileInfo(options) {
file(done) { options.success({ size: 4, digest: "a".repeat(32) });
done({ name: "image.jpg", size: 4, type: "image/jpeg" });
},
});
},
FileReader: class {
readAsArrayBuffer() {
this.result = new Uint8Array([1, 2, 3, 4]).buffer;
this.onloadend({ target: { result: this.result } });
}
},
}, },
}; };
assert.deepStrictEqual(await pickAndUploadImage(), { assert.deepStrictEqual(await pickAndUploadImage(), {
@@ -54,6 +44,7 @@ const run = async () => {
fileName: "image.jpg", fileName: "image.jpg",
}); });
delete globalThis.plus; delete globalThis.plus;
delete globalThis.uni;
delete globalThis.__appApi; delete globalThis.__appApi;
process.stdout.write("RESUMABLE-IMAGE-UPLOAD-RUNTIME-SMOKE PASS\n"); process.stdout.write("RESUMABLE-IMAGE-UPLOAD-RUNTIME-SMOKE PASS\n");
}; };
+51
View File
@@ -0,0 +1,51 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {}; const genealogies = []; const publicGenealogies = []; const treeMembers = []; const notifications = []; const joinApplications = [];
const listFamilyFeedFixtures = () => []; const listFamilyArticleFixtures = () => []; const listFamilyAlbumFixtures = () => []; const listCeremonyFixtures = () => []; const listGrowthRecordFixtures = () => []; const listNotificationFixtures = () => [];
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
const hasRemoteConfig = () => true; const resolveRuntimeMode = () => "remote"; const AUTH_VERIFICATION_OPERATION = Object.freeze({}); const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" }); const fromApiGenealogyAccess = () => null; const session = { getToken: () => "session-1", saveToken() {} };
`;
const requests = [];
let responseData = [];
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: responseData } }));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
responseData = [{ id: "help-1" }];
assert.deepStrictEqual(await appApi.getSiteArticles({ articleType: " help ", limit: "5" }), responseData);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/site/articles");
assert.deepStrictEqual(requests.at(-1).data, { articleType: "help", limit: 5 });
assert.strictEqual(requests.at(-1).header.Authorization, undefined);
await assert.rejects(appApi.getSiteArticles({ limit: 0 }), /数量上限/);
responseData = { pageKey: "about" };
assert.deepStrictEqual(await appApi.getSitePage("about us"), responseData);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/site/pages/about%20us");
assert.strictEqual(requests.at(-1).header.Authorization, undefined);
await assert.rejects(appApi.getSitePage(" "), /页面标识/);
delete globalThis.uni;
process.stdout.write("SITE-CONTENT-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+36 -43
View File
@@ -1,58 +1,51 @@
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding utf8 $tree = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding utf8
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t02-pedigree-overview.vue') -Raw -Encoding utf8
foreach ($required in @( foreach ($required in @(
'import AppButton from "@/components/AppButton.vue"', '@action="toTree"',
'<AppButton', 'class="pedigree-page"',
'type="secondary"', 'class="pedigree-layout"',
'@click="toMember"', 'class="pedigree-scroll"',
't01-member-node-standard.png', 'class="pedigree-sheet"',
't01-member-node-selected.png', 'class="pedigree-column pedigree-column--legend"',
't01-state-panel.png', 'class="member-node__relation"',
'openMemberPanel(member)', 'class="member-node__name"',
'member-action-profile', 'class="member-node__copy"',
'v-for="connector in lineageConnectors"', 'class="generation-band__arrow"',
'lineage-pan-cue', 'v-for="(page, pageIndex) in pedigreePages"',
'const treeScrollLeft = ref(90)', 'v-for="(member, memberIndex) in page.members"',
':scroll-left="treeState === ''tree'' ? treeScrollLeft : 0"', 'class="pedigree-swiper"',
':style="treeState === ''tree'' ? treeMetricsStyle : undefined"' ':disable-touch="false"',
'scroll-y',
'const openMemberProfile = (member) =>',
'const openMemberDetail = async (member) =>',
'detailVisible',
'<AppDialog'
)) { )) {
if ($page -notmatch [regex]::Escape($required)) { throw "T01 member action asset missing: $required" } if ($page -notmatch [regex]::Escape($required)) {
throw "T02 pedigree visual contract missing: $required"
} }
foreach ($forbidden in @(
'a01-primary-button-v2.png',
'a01-secondary-button-v2.png'
)) {
if ($page -match [regex]::Escape($forbidden)) { throw "T01 must consume AppButton instead of legacy action asset: $forbidden" }
} }
foreach ($rule in @( foreach ($rule in @(
'(?s)\.tree-toolbar__title text:last-child\s*\{[^}]*font-size:\s*22rpx;', '(?s)\.pedigree-layout\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 92rpx;',
'(?s)\.tree-toolbar__actions\s*\{[^}]*font-size:\s*23rpx;', '(?s)\.pedigree-swiper\s*\{[^}]*width:\s*100%;[^}]*height:\s*100%;',
'(?s)\.generation-band\s*\{[^}]*font-size:\s*22rpx;', '(?s)\.pedigree-sheet\s*\{[^}]*display:\s*grid;[^}]*width:\s*100%;[^}]*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);',
'(?s)\.node-relation\s*\{[^}]*font-size:\s*21rpx;', '(?s)\.member-node\s*\{[^}]*grid-template-rows:\s*144rpx 274rpx minmax\(0, 1fr\);',
'(?s)\.node-years\s*\{[^}]*font-size:\s*20rpx;', '(?s)\.member-node__name\s*\{[^}]*font-size:\s*34rpx;',
'(?s)\.tree-state-card__copy\s*\{[^}]*font-size:\s*24rpx;', '(?s)\.member-node__copy\s*\{[^}]*writing-mode:\s*vertical-rl;',
'(?s)\.member-action-profile__copy text:nth-child\(2\)\s*\{[^}]*font-size:\s*21rpx;', '(?s)\.generation-band__copy\s*\{[^}]*writing-mode:\s*vertical-rl;'
'(?s)\.tree-stage--lineage\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*190rpx minmax\(0, 1fr\);',
'(?s)\.generation-rail\s*\{[^}]*display:\s*grid;[^}]*width:\s*190rpx;',
'(?s)\.tree-scroll--lineage\s*\{[^}]*grid-area:\s*1 / 2;[^}]*min-width:\s*0;',
'(?s)\.lineage-pan-cue\s*\{[^}]*grid-area:\s*1 / 2;',
'const treeMetricsStyle = computed',
'(?s)\.generation-band\s*\{[^}]*display:\s*grid;',
'(?s)\.generation-band image,\s*\.generation-band__copy\s*\{[^}]*grid-area:\s*1 / 1;',
'(?s)\.member-node\s*\{[^}]*display:\s*grid;',
'(?s)\.member-node__skin,\s*\.member-node__copy\s*\{[^}]*grid-area:\s*1 / 1;',
'(?s)\.member-action-profile\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;',
'(?s)\.member-action-profile__avatar\s*\{[^}]*width:\s*82rpx;[^}]*aspect-ratio:\s*1;[^}]*flex:\s*0 0 82rpx;'
)) { )) {
if ($page -notmatch $rule) { throw "T01 readable visual rule missing: $rule" } if ($page -notmatch $rule) { throw "T02 pedigree visual rule missing: $rule" }
} }
if ($page -match '(?s)<view\s+class="tree-canvas".*?<view class="lineage-pan-cue">') { if ($page -match 'scroll-x|treeScrollLeft|openMemberPanel') {
throw 'T01 horizontal reading cue must stay in the visible lineage viewport, not inside the wide canvas' throw 'T02 must use direct swiper gestures and separate its name/detail tap targets'
}
if ($tree -notmatch 'scroll-x|treeScrollLeft|class="lineage-pan-cue"|@action="toPedigree"') {
throw 'T01 must retain the horizontal tree-diagram reader and its pedigree entry'
} }
Write-Output 'T01-ALL-STATES-VISUAL-CONTRACT PASS' Write-Output 'T01-ALL-STATES-VISUAL-CONTRACT PASS'
+24 -18
View File
@@ -1,11 +1,12 @@
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8 $tree = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8
$pedigree = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t02-pedigree-overview.vue') -Raw -Encoding UTF8
$routes = Get-Content -LiteralPath (Join-Path $root 'utils/navigation-routes.js') -Raw -Encoding UTF8 $routes = Get-Content -LiteralPath (Join-Path $root 'utils/navigation-routes.js') -Raw -Encoding UTF8
foreach ($required in @( foreach ($required in @(
'class="member-node__avatar"', 'class="member-node__surface"',
'const memberActionPanelVisible = ref(false)', 'const memberActionPanelVisible = ref(false)',
'const memberActions = Object.freeze([', 'const memberActions = Object.freeze([',
'const openMemberAction = (action) =>', 'const openMemberAction = (action) =>',
@@ -14,35 +15,40 @@ foreach ($required in @(
'unavailableActionVisible', 'unavailableActionVisible',
'BIND_INVITE' 'BIND_INVITE'
)) { )) {
if (-not $page.Contains($required)) { if (-not $tree.Contains($required)) {
throw "T01 member action panel missing: $required" throw "T01 tree member action panel missing: $required"
} }
} }
foreach ($actionKey in @( foreach ($actionKey in @(
'VIEW_PROFILE', 'VIEW_PROFILE', 'ADD_FATHER', 'ADD_MOTHER', 'ADD_SPOUSE', 'ADD_SIBLING',
'ADD_FATHER', 'ADJUST_RANK', 'ADD_SON', 'ADD_DAUGHTER', 'BIND_INVITE', 'EDIT_PROFILE'
'ADD_MOTHER',
'ADD_SPOUSE',
'ADD_SIBLING',
'ADJUST_RANK',
'ADD_SON',
'ADD_DAUGHTER',
'BIND_INVITE',
'EDIT_PROFILE'
)) { )) {
if (-not $page.Contains(('key: "' + $actionKey + '"'))) { if (-not $tree.Contains(('key: "' + $actionKey + '"'))) {
throw "T01 member action panel missing action: $actionKey" throw "T01 tree member action missing: $actionKey"
}
}
foreach ($required in @(
'const openMemberProfile = (member) =>',
'const openMemberDetail = async (member) =>',
'appApi.getPerson(genealogyId.value, member.id',
'detailVisible',
'class="member-node__name"',
'class="member-node__copy"'
)) {
if (-not $pedigree.Contains($required)) {
throw "T02 pedigree member interaction missing: $required"
} }
} }
foreach ($required in @( foreach ($required in @(
'optionalParams: ["personId", "mode", "relationType"]', 'optionalParams: ["personId", "mode", "relationType"]',
'allowedSources: ["T01", "T03"]', 'allowedSources: ["T01", "T02", "T03"]',
'optionalParams: ["mode"]' 'optionalParams: ["mode"]'
)) { )) {
if (-not $routes.Contains($required)) { if (-not $routes.Contains($required)) {
throw "T01 member action route contract missing: $required" throw "Tree route contract missing: $required"
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8 $page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8
foreach ($token in @('const layoutMembers = computed', 'childrenByParent', 'spouseByPersonId', 'parentAnchorX', 'FAMILY_LINK_OFFSET', 'kind: "family"', 'getFamilyPartner', 'spouseRect', 'layoutMembers.value', 'GENERATION_GAP', 'MEMBER_GAP')) { foreach ($token in @('const layoutMembers = computed', 'childrenByParent', 'spouseByPersonId', 'parentAnchorX', 'FAMILY_LINK_OFFSET', 'kind: "family"', 'layoutMembers.value', 'GENERATION_GAP', 'MEMBER_GAP')) {
if (-not $page.Contains($token)) { throw "T01 relation layout contract missing: $token" } if (-not $page.Contains($token)) { throw "T01 relation layout contract missing: $token" }
} }
if ($page -match 'maxX\s*=.*members\.value.*\.x|maxY\s*=.*members\.value.*\.y') { throw 'T01 canvas must not depend on API pixel coordinates' } if ($page -match 'maxX\s*=.*members\.value.*\.x|maxY\s*=.*members\.value.*\.y') { throw 'T01 canvas must not depend on API pixel coordinates' }
+53 -96
View File
@@ -5,79 +5,72 @@ function Assert-Contains {
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message } if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
} }
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius', 'box-shadow')) {
if ($Content -match "(?s)\.$ClassName\s*\{[^}]*\b$property\s*:") {
throw "T01 must not construct .$ClassName with CSS $property"
}
}
}
function Assert-NoPosition {
param([string]$Content, [string]$Selector)
$escaped = [regex]::Escape($Selector)
if ($Content -match "(?s)$escaped\s*\{[^}]*\bposition\s*:") {
throw "T01 ordinary layout selector must stay in document flow: $Selector"
}
}
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
$pagePath = Join-Path $root 'pages/tree/t01-tree-overview.vue' $pagePath = Join-Path $root 'pages/tree/t01-tree-overview.vue'
$t02Path = Join-Path $root 'pages/tree/t02-tree-states.vue' $t02Path = Join-Path $root 'pages/tree/t02-pedigree-overview.vue'
$page = Get-Content -LiteralPath $pagePath -Raw -Encoding utf8 $page = Get-Content -LiteralPath $pagePath -Raw -Encoding utf8
$pedigree = Get-Content -LiteralPath $t02Path -Raw -Encoding utf8
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 $pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8
if (Test-Path -LiteralPath $t02Path) { throw 'T02 duplicate route file must be removed' } if (-not (Test-Path -LiteralPath $t02Path)) { throw 'T02 pedigree route file must exist' }
if ($pages -match 'pages/tree/t02-tree-states') { throw 'T02 duplicate route must be removed from pages.json' } if ($pages -notmatch 'pages/tree/t02-pedigree-overview') { throw 'T02 pedigree route must be registered in pages.json' }
foreach ($required in @( foreach ($required in @(
'appApi.getTree', 'appApi.getTree',
'createRequestController', 'createRequestController',
'isRequestCancelled', 'isRequestCancelled',
'treeRequestController.abort()', 'treeRequestController.abort()',
'onShow(() =>', 'onShow(() =>',
'requestController: treeRequestController' 'requestController: treeRequestController',
'genealogyContext.isCurrentGenealogyInvalidated()',
'genealogyContext.setCurrentGenealogyId(genealogyId.value)'
)) { Assert-Contains $page $required "Missing T01 remote data contract: $required" } )) { Assert-Contains $page $required "Missing T01 remote data contract: $required" }
foreach ($required in @( foreach ($required in @(
'@action="toPedigree"',
'const treeState = ref("loading")', 'const treeState = ref("loading")',
'tree-state--tree', 'tree-state--tree',
'tree-state--landscape', 'tree-state--landscape',
'tree-state--empty', 'tree-state--empty',
'tree-state--error', 'tree-state--error',
'tree-canvas--state', 'tree-canvas--state',
'lineage-connector', 'v-for="member in layoutMembers"',
'const generationRows = computed(', 'class="member-node__portrait"',
'v-for="row in generationRows"', 'class="lineage-pan-cue"',
':style="generationBandStyle(row)"', 'scroll-x',
'class="tree-stage"',
'tree-stage--lineage',
'class="generation-rail"',
'tree-scroll--lineage',
':style="nodeGridStyle(member)"',
'const nodeGridStyle = (member)',
'class="member-node__copy"',
't01-member-node-standard.png',
't01-member-node-selected.png',
't01-state-panel.png',
'class="member-action-profile"',
'const openMemberPanel = (member) =>',
'openPage("T03"',
'routeKey: "T04"',
'openPage("T06"',
'openPage("T07"',
'query.genealogyId',
'genealogyContext.isCurrentGenealogyInvalidated()',
':id="`tree-member-${member.id}`"',
':scroll-left="treeState === ''tree'' ? treeScrollLeft : 0"', ':scroll-left="treeState === ''tree'' ? treeScrollLeft : 0"',
'@scroll="handleTreeScroll"', '@scroll="handleTreeScroll"',
'const currentTreeScrollLeft = ref(90)', ':style="nodeGridStyle(member)"',
'uni.createSelectorQuery()', 'const nodeGridStyle = (member)',
'nodeRect.left + nodeRect.width / 2 - (scrollRect.left + scrollRect.width / 2)', 'class="generation-rail"',
'const treeHasDrifted = ref(false)', 'class="generation-band"',
'v-for="row in generationRows"',
':style="generationBandStyle(row)"',
'const treeMetrics = computed(',
':style="treeState === ''tree'' ? treeMetricsStyle : undefined"',
'const recenterSelectedMember = async () =>', 'const recenterSelectedMember = async () =>',
'class="tree-recenter"' 'class="tree-recenter"',
)) { Assert-Contains $page $required "Missing T01 contract: $required" } 'class="member-action-profile"',
'const openMemberPanel = (member) =>',
'"T03",',
'routeKey: "T04"',
'"T06",',
'openPage("T07"'
)) { Assert-Contains $page $required "Missing T01 tree contract: $required" }
foreach ($required in @(
'@action="toTree"',
'const pedigreePages = computed(() =>',
'class="pedigree-sheet"',
'class="pedigree-column pedigree-column--legend"',
'v-for="(page, pageIndex) in pedigreePages"',
'v-for="(member, memberIndex) in page.members"',
'class="pedigree-swiper"',
':disable-touch="false"',
'scroll-y',
'const handlePedigreePageChange = (event)',
'const toTree = () =>'
)) { Assert-Contains $pedigree $required "Missing T02 pedigree contract: $required" }
foreach ($fixtureToken in @('getGenealogyFixtureAccess', 'treeMembers', 'mockTree')) { foreach ($fixtureToken in @('getGenealogyFixtureAccess', 'treeMembers', 'mockTree')) {
if ($page -match [regex]::Escape($fixtureToken)) { if ($page -match [regex]::Escape($fixtureToken)) {
@@ -91,56 +84,20 @@ if ($invalidatedGuardIndex -lt 0 -or $contextWriteIndex -le $invalidatedGuardInd
throw 'T01 must reject an invalidated genealogy before any route can rewrite the current context' throw 'T01 must reject an invalidated genealogy before any route can rewrite the current context'
} }
foreach ($forbidden in @(
'g03-create-flow-panel.png',
'g06-search-input-wide.png',
'application-status-card.png',
'generation-band--twelve',
'generation-band--thirteen',
'generation-band--fourteen'
)) {
if ($page -match [regex]::Escape($forbidden)) { throw "T01 must not keep opaque surface asset: $forbidden" }
}
foreach ($className in @('tree-canvas', 'member-node', 'member-action-profile', 'tree-state-card')) {
Assert-NoCssSurface $page $className
}
foreach ($selector in @('.generation-rail', '.generation-band', '.member-node')) {
Assert-NoPosition $page $selector
}
if ($page -match '(?s)\.node-name,\s*\.node-relation,\s*\.node-years\s*\{[^}]*\bposition\s*:') {
throw 'T01 member node text must stay in document flow'
}
if ($page -notmatch '(?s)\.tree-page\s*\{[^}]*display:\s*grid;[^}]*grid-template-rows:\s*auto auto minmax\(0, 1fr\);') {
throw 'T01 page content must own a grid stacking context above the fixed module background'
}
foreach ($obsolete in @('const nodeStyle = (member)', 'const generationRowStyle = (row)', 'left: `${member.x}rpx`', 'top: `${member.y}rpx`')) {
if ($page -match [regex]::Escape($obsolete)) { throw "T01 obsolete positioned layout remains: $obsolete" }
}
foreach ($required in @(
'const treeMetrics = computed(',
':style="treeState === ''tree'' ? treeMetricsStyle : undefined"',
':style="generationBandStyle(row)"',
'overflow-y: auto;'
)) { Assert-Contains $page $required "T01 data-driven tree boundary missing: $required" }
if ($page -match '<scroll-view\s+class="tree-stage"') { if ($page -match '<scroll-view\s+class="tree-stage"') {
throw 'T01 outer vertical tree owner must preserve the stage grid instead of wrapping its grid children' throw 'T01 outer vertical tree owner must preserve the stage grid instead of wrapping its grid children'
} }
if ($page -notmatch '(?s)\.tree-stage--lineage\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*144rpx minmax\(0, 1fr\);') {
foreach ($fixedCapacity in @( throw 'T01 generation rail must stay beside the tree canvas'
'grid-template-columns: repeat(180, 5rpx)',
'grid-template-rows: repeat(180, 5rpx)',
'width: 900rpx',
'height: 900rpx',
'grid-template-rows: 47rpx 58rpx 157rpx 58rpx 242rpx 58rpx 1fr'
)) {
if ($page -match [regex]::Escape($fixedCapacity)) {
throw "T01 must derive tree capacity from member data: $fixedCapacity"
} }
if ($page -notmatch '(?s)\.member-node\s*\{[^}]*position:\s*relative;') {
throw 'T01 graph node must retain its positioned tree layout'
}
if ($pedigree -match 'scroll-x|treeScrollLeft|changeGeneration =') {
throw 'T02 must leave horizontal gestures to the native swiper, not an outer scroll or click pager'
}
if ($page -notmatch 'openPage\(\s*"T02"') {
throw 'T01 must expose the pedigree page from the tree header'
} }
Write-Output 'T01-TREE-STATE-CONTRACT PASS' Write-Output 'T01-TREE-STATE-CONTRACT PASS'
+34 -15
View File
@@ -49,13 +49,13 @@ const waitFor = async (send, expression, message) => {
throw new Error(message) throw new Error(message)
} }
const open = async (send, query, selector) => { const open = async (send, query, selector) => {
const url = `${origin}/#/pages/tree/t01-tree-overview${query}` const url = `${origin}/#/pages/tree/t02-pedigree-overview${query}`
await send('Page.navigate', { url }) await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `T01 navigation failed: ${query}`) await waitFor(send, `location.href === ${JSON.stringify(url)}`, `T02 navigation failed: ${query}`)
const previousTimeOrigin = await valueOf(send, 'performance.timeOrigin') const previousTimeOrigin = await valueOf(send, 'performance.timeOrigin')
await send('Page.reload') await send('Page.reload')
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `T01 reload did not create a fresh document: ${query}`) await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `T02 reload did not create a fresh document: ${query}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `T01 state did not render: ${selector}`) await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `T02 state did not render: ${selector}`)
} }
const run = async () => { const run = async () => {
@@ -88,11 +88,11 @@ const run = async () => {
assert.strictEqual(metrics.stageDisplay, 'grid', `T01 stage is not a grid at ${size.width}x${size.height}`) assert.strictEqual(metrics.stageDisplay, 'grid', `T01 stage is not a grid at ${size.width}x${size.height}`)
assert.strictEqual(metrics.railPosition, 'static', `T01 generation rail is positioned at ${size.width}x${size.height}`) assert.strictEqual(metrics.railPosition, 'static', `T01 generation rail is positioned at ${size.width}x${size.height}`)
assert.strictEqual(metrics.nodePosition, 'static', `T01 member node is positioned at ${size.width}x${size.height}`) assert.strictEqual(metrics.nodePosition, 'static', `T01 member node is positioned at ${size.width}x${size.height}`)
assert.strictEqual(metrics.nodeCount, 6, `T01 member nodes are incomplete at ${size.width}x${size.height}`) assert.strictEqual(metrics.nodeCount, 5, `T01 pager must render one five-member swipe page at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
assert(metrics.firstNodeRect.width > 80 && metrics.firstNodeRect.height > 30, `T01 member node collapsed at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`) assert(metrics.firstNodeRect.width > 30 && metrics.firstNodeRect.height > 180, `T01 pedigree column collapsed at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
assert(metrics.firstNodeRect.top >= metrics.canvasRect.top && metrics.firstNodeRect.top < metrics.canvasRect.top + metrics.canvasRect.height, `T01 member node escaped the data-driven canvas at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`) assert(metrics.firstNodeRect.top >= metrics.canvasRect.top && metrics.firstNodeRect.top < metrics.canvasRect.top + metrics.canvasRect.height, `T01 member node escaped the data-driven canvas at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
assert(metrics.firstNodeRect.left + metrics.firstNodeRect.width > 0 && metrics.firstNodeRect.left < size.width, `T01 initial member node is outside the visible horizontal tree viewport at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`) assert(metrics.firstNodeRect.left + metrics.firstNodeRect.width > 0 && metrics.firstNodeRect.left < size.width, `T01 initial member node is outside the visible horizontal tree viewport at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
assert(metrics.firstNodeRect.top < size.height * 0.5, `T01 generation rail and canvas were stacked instead of sharing the tree-stage row at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`) assert(metrics.firstNodeRect.top < size.height * 0.5, `T01 generation rail and pedigree sheet were stacked instead of sharing the tree-stage row at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
} }
const stressMembers = [] const stressMembers = []
for (let generation = 1; generation <= 10; generation += 1) { for (let generation = 1; generation <= 10; generation += 1) {
@@ -122,7 +122,7 @@ const run = async () => {
return true return true
})()`) })()`)
assert.strictEqual(injected, true, 'T01 stress data could not be injected into the active component') assert.strictEqual(injected, true, 'T01 stress data could not be injected into the active component')
await waitFor(send, "document.querySelectorAll('.member-node').length === 120", 'T01 did not render 10 generations with 12 members each') await waitFor(send, "document.querySelectorAll('.member-node').length === 5", 'T01 did not render one five-member swipe page')
const stressMetrics = await valueOf(send, `(() => { const stressMetrics = await valueOf(send, `(() => {
const canvas = document.querySelector('.tree-canvas') const canvas = document.querySelector('.tree-canvas')
const rail = document.querySelector('.generation-rail') const rail = document.querySelector('.generation-rail')
@@ -135,18 +135,37 @@ const run = async () => {
documentWidth: document.documentElement.scrollWidth documentWidth: document.documentElement.scrollWidth
} }
})()`) })()`)
assert(stressMetrics.canvasWidth > 1400, 'T01 canvas width did not grow from 12 members per generation') assert(stressMetrics.canvasWidth > 250, 'T01 swipe sheet did not fill the available viewport')
assert(stressMetrics.canvasHeight > 1000, 'T01 canvas height did not grow from 10 generations') assert(stressMetrics.canvasHeight > 500, 'T01 pedigree sheet height did not reserve space for vertical details')
assert(Math.abs(stressMetrics.canvasHeight - stressMetrics.railHeight) <= 1, 'T01 generation rail did not follow the data-driven canvas height') assert(Math.abs(stressMetrics.canvasHeight - stressMetrics.railHeight) <= 1, 'T01 generation rail did not follow the data-driven canvas height')
assert.strictEqual(stressMetrics.generationCount, 10, 'T01 generation rail did not derive all generations from data') assert.strictEqual(stressMetrics.generationCount, 10, 'T01 generation rail did not derive all generations from data')
assert(stressMetrics.connectorCount > 30, 'T01 relationship connectors did not derive from parent data') assert(stressMetrics.connectorCount > 30, 'T01 relationship connectors did not derive from parent data')
assert(stressMetrics.documentWidth <= 413, 'T01 stress tree escaped its scroll owner') assert(stressMetrics.documentWidth <= 413, 'T01 stress tree escaped its scroll owner')
await open(send, '?genealogyId=1001', '.tree-state--tree .member-node') const swiperRect = await valueOf(send, `(() => {
await valueOf(send, "document.querySelector('.member-node')?.click()") const rect = document.querySelector('.pedigree-swiper').getBoundingClientRect()
await valueOf(send, "document.querySelector('.member-action-profile')?.click()") return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
await waitFor(send, "location.href.includes('/pages/tree/t03-member-profile?genealogyId=1001&personId=')", 'T01 selected member did not open T03') })()`)
process.stdout.write('T01-TREE-STATE-RUNTIME-SMOKE PASS\n') await send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: swiperRect.left + swiperRect.width * 0.76, y: swiperRect.top + Math.min(180, swiperRect.height * 0.35), radiusX: 1, radiusY: 1, force: 1, id: 1 }]
})
await send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: swiperRect.left + swiperRect.width * 0.24, y: swiperRect.top + Math.min(180, swiperRect.height * 0.35), radiusX: 1, radiusY: 1, force: 1, id: 1 }]
})
await send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await waitFor(send, `(() => {
let instance = document.querySelector('.tree-page')?.__vueParentComponent
while (instance && !Object.prototype.hasOwnProperty.call(instance.setupState || {}, 'pedigreePageIndex')) instance = instance.parent
const value = instance?.setupState?.pedigreePageIndex
return typeof value === 'number' ? value > 0 : value?.value > 0
})()`, 'T01 did not advance to the next page after a direct left swipe')
await open(send, '?genealogyId=1001', '.tree-state--tree .member-node__name')
await valueOf(send, "document.querySelector('.member-node__name')?.click()")
await waitFor(send, "location.href.includes('/pages/tree/t03-member-profile?genealogyId=1001&personId=')", 'T02 selected member did not open T03')
process.stdout.write('T02-PEDIGREE-RUNTIME-SMOKE PASS\n')
} finally { } finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {} try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close() socket.close()
+10 -2
View File
@@ -8,6 +8,7 @@ function Assert-Contains {
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
$pages = @{ $pages = @{
T01 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8 T01 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8
T02 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t02-pedigree-overview.vue') -Raw -Encoding UTF8
T03 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t03-member-profile.vue') -Raw -Encoding UTF8 T03 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t03-member-profile.vue') -Raw -Encoding UTF8
T04 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t04-add-relative.vue') -Raw -Encoding UTF8 T04 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t04-add-relative.vue') -Raw -Encoding UTF8
T05 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t05-edit-member.vue') -Raw -Encoding UTF8 T05 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t05-edit-member.vue') -Raw -Encoding UTF8
@@ -29,13 +30,20 @@ foreach ($required in @(
'appApi.getTree', 'appApi.getTree',
'const memberActions = Object.freeze([', 'const memberActions = Object.freeze([',
'const openMemberAction = (action) =>', 'const openMemberAction = (action) =>',
'openPage("T03"', '"T03",',
'routeKey: "T04"', 'routeKey: "T04"',
'routeKey: "T05"', 'routeKey: "T05"',
'routeKey: "T06"', 'routeKey: "T06"',
'return openPage(action.routeKey, params, "T01")' 'return openPage(action.routeKey, params, "T01")'
)) { Assert-Contains $pages.T01 $required "T01 missing member flow contract: $required" } )) { Assert-Contains $pages.T01 $required "T01 missing member flow contract: $required" }
foreach ($required in @(
'appApi.getTree',
'const pedigreePages = computed(() =>',
'const openMemberProfile = (member) =>',
'"T02",'
)) { Assert-Contains $pages.T02 $required "T02 missing pedigree member flow contract: $required" }
foreach ($required in @( foreach ($required in @(
'appApi.getPerson(', 'appApi.getPerson(',
'const memberTrail = reactive([]);', 'const memberTrail = reactive([]);',
@@ -57,7 +65,7 @@ foreach ($key in @('T04', 'T05', 'T06')) {
Assert-Contains $pages.T04 'relationType.value' 'T04 must accept the validated relation intent from T01' Assert-Contains $pages.T04 'relationType.value' 'T04 must accept the validated relation intent from T01'
$routes = Get-Content -LiteralPath (Join-Path $root 'utils/navigation-routes.js') -Raw -Encoding UTF8 $routes = Get-Content -LiteralPath (Join-Path $root 'utils/navigation-routes.js') -Raw -Encoding UTF8
Assert-Contains $routes 'allowedSources: ["T01", "T03"]' 'T05 route source contract drifted' Assert-Contains $routes 'allowedSources: ["T01", "T02", "T03"]' 'T05 route source contract drifted'
Assert-Contains $pages.T06 'query.mode !== "rank"' 'T06 must reject non-rank entry modes' Assert-Contains $pages.T06 'query.mode !== "rank"' 'T06 must reject non-rank entry modes'
Write-Output 'T03-T08-MEMBER-FLOW-CONTRACT PASS REMOTE-WRITE' Write-Output 'T03-T08-MEMBER-FLOW-CONTRACT PASS REMOTE-WRITE'
+121 -174
View File
@@ -181,12 +181,6 @@ const request = (options, {
if (!settled && requestController) requestController.bind(abortRequest) if (!settled && requestController) requestController.bind(abortRequest)
}) })
const authPayload = (payload) => ({
clientId: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
...payload
})
// 已核对接口使用这一严格边界:只接受 HTTP 200 与完整 JSON envelope // 已核对接口使用这一严格边界:只接受 HTTP 200 与完整 JSON envelope
// 并统一限制弱网等待时间。尚未逐页治理的旧业务读取仍沿用上方通用 request。 // 并统一限制弱网等待时间。尚未逐页治理的旧业务读取仍沿用上方通用 request。
const requestStrict = (options, { const requestStrict = (options, {
@@ -241,7 +235,7 @@ const parseStrictUploadResponse = (responseText, requireData) => {
} }
const requestNativeFileChunk = ({ uploadId, chunkIndex, chunkMd5, filePath }, { requestController = null } = {}) => new Promise((resolve, reject) => { const requestNativeFileChunk = ({ uploadId, chunkIndex, chunkMd5, filePath }, { requestController = null } = {}) => new Promise((resolve, reject) => {
if (!globalThis.plus?.uploader?.createUpload) { if (typeof globalThis.uni?.uploadFile !== 'function') {
reject(createRequestError('当前运行环境不支持文件上传', 'FILE_UPLOAD_UNAVAILABLE')) reject(createRequestError('当前运行环境不支持文件上传', 'FILE_UPLOAD_UNAVAILABLE'))
return return
} }
@@ -254,6 +248,7 @@ const requestNativeFileChunk = ({ uploadId, chunkIndex, chunkMd5, filePath }, {
} }
let settled = false let settled = false
let task = null let task = null
let cancelled = false
const release = () => requestController?.release(abort) const release = () => requestController?.release(abort)
const resolveOnce = (value) => { const resolveOnce = (value) => {
if (settled) return if (settled) return
@@ -269,34 +264,56 @@ const requestNativeFileChunk = ({ uploadId, chunkIndex, chunkMd5, filePath }, {
} }
const abort = () => { const abort = () => {
if (settled) return if (settled) return
cancelled = true
task?.abort?.() task?.abort?.()
rejectOnce(createRequestCancelledError()) rejectOnce(createRequestCancelledError())
} }
const query = `uploadId=${encodeURIComponent(uploadId)}&chunkIndex=${encodeURIComponent(chunkIndex)}&chunkMd5=${encodeURIComponent(chunkMd5)}` const token = session.getToken()
try { try {
task = globalThis.plus.uploader.createUpload( task = globalThis.uni.uploadFile({
`${runtimeConfig.baseUrl}/genealogy/app/files/resumable/chunk?${query}`, url: `${runtimeConfig.baseUrl}/genealogy/app/files/resumable/chunk`,
{ method: 'POST' }, filePath,
(upload, status) => { name: 'file',
if (status !== 200) { formData: {
rejectOnce(createRequestError(`文件分片上传失败(HTTP ${status}`, 'HTTP_ERROR', { httpStatus: status })) uploadId,
chunkIndex: String(chunkIndex),
chunkMd5
},
header: {
clientid: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
...(token ? { Authorization: `Bearer ${token}` } : {})
},
timeout: REQUEST_TIMEOUT_MS,
success: (upload) => {
if (upload.statusCode !== 200) {
rejectOnce(createRequestError(`文件分片上传失败(HTTP ${upload.statusCode}`, 'HTTP_ERROR', { httpStatus: upload.statusCode }))
return return
} }
try { try {
parseStrictUploadResponse(upload.responseText, false) parseStrictUploadResponse(upload.data, false)
resolveOnce(null) resolveOnce(null)
} catch (error) { } catch (error) {
rejectOnce(error) rejectOnce(error)
} }
},
fail: (error) => {
if (settled) return
if (cancelled) {
rejectOnce(createRequestCancelledError())
return
} }
) const message = typeof error?.errMsg === 'string' && error.errMsg
task.setRequestHeader('clientid', runtimeConfig.clientId) ? error.errMsg
task.setRequestHeader('tenantId', runtimeConfig.tenantId) : '文件分片上传失败'
const token = session.getToken() const timeout = /timeout/i.test(message)
if (token) task.setRequestHeader('Authorization', `Bearer ${token}`) rejectOnce(createRequestError(
task.addFile(filePath, { key: 'file' }) timeout ? '文件分片上传超时' : message,
timeout ? 'REQUEST_TIMEOUT' : 'UPLOAD_FAILED'
))
}
})
if (requestController) requestController.bind(abort) if (requestController) requestController.bind(abort)
task.start()
} catch (error) { } catch (error) {
rejectOnce(error) rejectOnce(error)
} }
@@ -315,17 +332,25 @@ const requestBrowserFileChunk = ({ uploadId, chunkIndex, chunkMd5, file }, { req
return return
} }
let settled = false let settled = false
let timeoutId = null
const abortController = new AbortController() const abortController = new AbortController()
const release = () => requestController?.release(abort) const release = () => requestController?.release(abort)
const clearRequestTimeout = () => {
if (timeoutId === null) return
clearTimeout(timeoutId)
timeoutId = null
}
const resolveOnce = (value) => { const resolveOnce = (value) => {
if (settled) return if (settled) return
settled = true settled = true
clearRequestTimeout()
release() release()
resolve(value) resolve(value)
} }
const rejectOnce = (error) => { const rejectOnce = (error) => {
if (settled) return if (settled) return
settled = true settled = true
clearRequestTimeout()
release() release()
reject(error) reject(error)
} }
@@ -334,12 +359,18 @@ const requestBrowserFileChunk = ({ uploadId, chunkIndex, chunkMd5, file }, { req
abortController.abort() abortController.abort()
rejectOnce(createRequestCancelledError()) rejectOnce(createRequestCancelledError())
} }
const query = `uploadId=${encodeURIComponent(uploadId)}&chunkIndex=${encodeURIComponent(chunkIndex)}&chunkMd5=${encodeURIComponent(chunkMd5)}`
const token = session.getToken() const token = session.getToken()
const formData = new globalThis.FormData() const formData = new globalThis.FormData()
formData.append('uploadId', uploadId)
formData.append('chunkIndex', String(chunkIndex))
formData.append('chunkMd5', chunkMd5)
formData.append('file', file, file.name || 'image') formData.append('file', file, file.name || 'image')
if (requestController) requestController.bind(abort) if (requestController) requestController.bind(abort)
globalThis.fetch(`${runtimeConfig.baseUrl}/genealogy/app/files/resumable/chunk?${query}`, { timeoutId = setTimeout(() => {
abortController.abort()
rejectOnce(createRequestError('文件分片上传超时', 'REQUEST_TIMEOUT'))
}, REQUEST_TIMEOUT_MS)
globalThis.fetch(`${runtimeConfig.baseUrl}/genealogy/app/files/resumable/chunk`, {
method: 'POST', method: 'POST',
headers: { headers: {
clientid: runtimeConfig.clientId, clientid: runtimeConfig.clientId,
@@ -360,67 +391,6 @@ const requestBrowserFileChunk = ({ uploadId, chunkIndex, chunkMd5, file }, { req
}) })
}) })
const requestNativeSingleFileUpload = ({ filePath }, { requestController = null } = {}) => new Promise((resolve, reject) => {
if (requestController !== null && (
typeof requestController.bind !== 'function' ||
typeof requestController.release !== 'function'
)) {
reject(new TypeError('文件上传控制器格式无效'))
return
}
let settled = false
let task = null
const release = () => requestController?.release(abort)
const resolveOnce = (value) => {
if (settled) return
settled = true
release()
resolve(value)
}
const rejectOnce = (error) => {
if (settled) return
settled = true
release()
reject(error)
}
const abort = () => {
if (settled) return
task?.abort?.()
rejectOnce(createRequestCancelledError())
}
try {
const token = session.getToken()
task = uni.uploadFile({
url: `${runtimeConfig.baseUrl}/genealogy/app/files/upload`,
filePath,
name: 'file',
header: {
clientid: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
...(token ? { Authorization: `Bearer ${token}` } : {})
},
success: ({ data, statusCode }) => {
if (statusCode !== 200) {
rejectOnce(createRequestError(`文件上传失败(HTTP ${statusCode}`, 'HTTP_ERROR', { httpStatus: statusCode }))
return
}
try {
resolveOnce(normalizeResumableCompleteResult(parseStrictUploadResponse(data, true)))
} catch (error) {
rejectOnce(error)
}
},
fail: (error) => {
const message = error?.errMsg || '文件上传网络连接失败'
rejectOnce(createRequestError(message, /timeout/i.test(message) ? 'REQUEST_TIMEOUT' : 'NETWORK_ERROR'))
}
})
if (requestController) requestController.bind(abort)
} catch (error) {
rejectOnce(error)
}
})
const requireRemoteAuth = () => { const requireRemoteAuth = () => {
if (resolveRuntimeMode() !== 'remote') { if (resolveRuntimeMode() !== 'remote') {
const error = new Error('当前为本地预览模式,真实认证服务未启用') const error = new Error('当前为本地预览模式,真实认证服务未启用')
@@ -1095,21 +1065,18 @@ const normalizeLineageText = (value, label, { required = false } = {}) => {
const lineageDatePart = (value, label) => { const lineageDatePart = (value, label) => {
const normalized = normalizeLineageText(value, label) const normalized = normalizeLineageText(value, label)
if (!normalized) return '' if (!normalized) return ''
if (!/^\d{4}-\d{2}-\d{2}/.test(normalized)) throw lineageTreeError(`世系树${label}无效`) if (!/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
throw lineageTreeError(`世系树${label}无效`)
}
if (normalized.length === 10) return normalized if (normalized.length === 10) return normalized
const instant = new Date(normalized) const instant = new Date(normalized)
if (Number.isNaN(instant.getTime())) throw lineageTreeError(`世系树${label}无效`) if (Number.isNaN(instant.getTime())) throw lineageTreeError(`世系树${label}无效`)
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', { const chinaTime = new Date(instant.getTime() + 8 * 60 * 60 * 1000)
timeZone: 'Asia/Shanghai', const year = chinaTime.getUTCFullYear()
year: 'numeric', const month = String(chinaTime.getUTCMonth() + 1).padStart(2, '0')
month: '2-digit', const day = String(chinaTime.getUTCDate()).padStart(2, '0')
day: '2-digit' return `${year}-${month}-${day}`
}).formatToParts(instant)
.filter((part) => part.type !== 'literal')
.map((part) => [part.type, part.value])
)
return `${parts.year}-${parts.month}-${parts.day}`
} }
const normalizeLineageTree = (value) => { const normalizeLineageTree = (value) => {
@@ -1870,7 +1837,7 @@ const normalizePhoneChangePayload = (payload) => {
if (typeof payload.phone !== 'string' || !/^1\d{10}$/.test(payload.phone.trim())) { if (typeof payload.phone !== 'string' || !/^1\d{10}$/.test(payload.phone.trim())) {
throw new TypeError('新手机号格式无效') throw new TypeError('新手机号格式无效')
} }
return authPayload({ phone: payload.phone.trim(), smsCode: assertSmsCode(payload.smsCode) }) return { phone: payload.phone.trim(), smsCode: assertSmsCode(payload.smsCode) }
} }
const normalizeMemberUpdatePayload = (payload) => { const normalizeMemberUpdatePayload = (payload) => {
@@ -1898,36 +1865,23 @@ const normalizeVipOrderPayload = (payload) => {
return data return data
} }
const normalizeFileReferencePayload = (payload) => { const normalizeSiteArticleQuery = (payload = {}) => {
assertPlainPayload(payload, new Set(['bizType', 'bizName', 'bizTable', 'bizId', 'bizField', 'ossId', 'ossIds', 'usageScene', 'usageName']), '文件引用请求') assertPlainPayload(payload, new Set(['articleType', 'limit']), '官网文章查询')
const data = {} const data = {}
for (const field of ['bizType', 'bizName', 'bizTable', 'bizField', 'usageScene', 'usageName']) { const articleType = normalizeOptionalAuthText(payload.articleType, '官网文章类型')
const value = normalizeOptionalAuthText(payload[field], field) if (articleType) data.articleType = articleType
if (value) data[field] = value if (payload.limit !== undefined && payload.limit !== null && payload.limit !== '') {
const limit = typeof payload.limit === 'number' ? payload.limit : Number(payload.limit)
if (!Number.isSafeInteger(limit) || limit < 1) throw new TypeError('官网文章数量上限必须是正整数')
data.limit = limit
} }
for (const field of ['bizType', 'bizTable', 'bizField']) {
if (!data[field]) throw new TypeError(`文件引用缺少 ${field}`)
}
data.bizId = Number(normalizeResourcePathId(payload.bizId, '业务标识'))
if (payload.ossId !== undefined && payload.ossId !== null && payload.ossId !== '') {
data.ossId = Number(normalizeResourcePathId(payload.ossId, '文件标识'))
}
const ossIds = normalizeOptionalOssIdList(payload.ossIds)
if (ossIds) data.ossIds = ossIds
if (!data.ossId && !data.ossIds) throw new TypeError('文件引用需要 ossId 或 ossIds')
return data return data
} }
const normalizeFileReferenceReleasePayload = (payload) => { const normalizeSitePageKey = (pageKey) => {
assertPlainPayload(payload, new Set(['bizTable', 'bizId', 'bizField']), '文件引用释放请求') const normalized = normalizeOptionalAuthText(pageKey, '官网页面标识')
const bizTable = normalizeOptionalAuthText(payload.bizTable, 'bizTable') if (!normalized) throw new TypeError('官网页面标识不能为空')
const bizField = normalizeOptionalAuthText(payload.bizField, 'bizField') return normalized
if (!bizTable || !bizField) throw new TypeError('文件引用释放缺少 bizTable 或 bizField')
return {
bizTable,
bizId: normalizeResourcePathId(payload.bizId, '业务标识'),
bizField
}
} }
const normalizeJoinApplicationPayload = (payload) => { const normalizeJoinApplicationPayload = (payload) => {
@@ -2252,20 +2206,6 @@ export const appApi = {
} }
}, requestOptions) }, requestOptions)
}, },
async sendLegacySmsCode({ phone, validToken }, requestOptions = {}) {
requireRemoteAuth()
const normalizedValidToken = normalizeOptionalValidToken(validToken)
return requestAuthVoid({
url: '/genealogy/app/auth/sms/code',
method: 'POST',
data: {
tenantId: runtimeConfig.tenantId,
grantType: 'sms',
phone,
...(normalizedValidToken ? { validToken: normalizedValidToken } : {})
}
}, requestOptions)
},
async loginWithPassword({ phone, passwordHash, validToken }, requestOptions = {}) { async loginWithPassword({ phone, passwordHash, validToken }, requestOptions = {}) {
requireRemoteAuth() requireRemoteAuth()
const normalizedValidToken = normalizeOptionalValidToken(validToken) const normalizedValidToken = normalizeOptionalValidToken(validToken)
@@ -2287,7 +2227,7 @@ export const appApi = {
const result = await requestAuth({ const result = await requestAuth({
url: '/genealogy/app/auth/login/sms', url: '/genealogy/app/auth/login/sms',
method: 'POST', method: 'POST',
data: authPayload({ grantType: 'sms', phone, smsCode: assertSmsCode(smsCode) }) data: { tenantId: runtimeConfig.tenantId, grantType: 'sms', phone, smsCode: assertSmsCode(smsCode) }
}, requestOptions) }, requestOptions)
return saveLogin(result) return saveLogin(result)
}, },
@@ -2297,13 +2237,14 @@ export const appApi = {
const result = await requestAuth({ const result = await requestAuth({
url: '/genealogy/app/auth/register', url: '/genealogy/app/auth/register',
method: 'POST', method: 'POST',
data: authPayload({ data: {
tenantId: runtimeConfig.tenantId,
grantType: 'password', grantType: 'password',
phone, phone,
password: assertPasswordHash(passwordHash), password: assertPasswordHash(passwordHash),
smsCode: assertSmsCode(smsCode), smsCode: assertSmsCode(smsCode),
...(normalizedNickName ? { nickName: normalizedNickName } : {}) ...(normalizedNickName ? { nickName: normalizedNickName } : {})
}) }
}, requestOptions) }, requestOptions)
return saveLogin(result) return saveLogin(result)
}, },
@@ -2312,12 +2253,13 @@ export const appApi = {
return requestAuthVoid({ return requestAuthVoid({
url: '/genealogy/app/auth/password/reset', url: '/genealogy/app/auth/password/reset',
method: 'PUT', method: 'PUT',
data: authPayload({ data: {
tenantId: runtimeConfig.tenantId,
grantType: 'password', grantType: 'password',
phone, phone,
newPassword: assertPasswordHash(passwordHash), newPassword: assertPasswordHash(passwordHash),
smsCode: assertSmsCode(smsCode) smsCode: assertSmsCode(smsCode)
}) }
}, requestOptions) }, requestOptions)
}, },
async changePassword({ oldPasswordHash, newPasswordHash }, requestOptions = {}) { async changePassword({ oldPasswordHash, newPasswordHash }, requestOptions = {}) {
@@ -2340,7 +2282,7 @@ export const appApi = {
await requestStrict({ await requestStrict({
url: '/genealogy/app/auth/account/deactivate', url: '/genealogy/app/auth/account/deactivate',
method: 'POST', method: 'POST',
data: { clientId: runtimeConfig.clientId, smsCode: assertSmsCode(smsCode) } data: { smsCode: assertSmsCode(smsCode) }
}, { }, {
requireData: false, requireData: false,
requestController: requestOptions.requestController ?? null requestController: requestOptions.requestController ?? null
@@ -2477,27 +2419,6 @@ export const appApi = {
}) })
return normalizeResumableCompleteResult(result) return normalizeResumableCompleteResult(result)
}, },
async uploadSingleFile({ filePath }, requestOptions = {}) {
if (typeof filePath !== 'string' || !filePath.trim()) {
throw new TypeError('filePath必须是非空字符串')
}
requireRemoteResource('文件上传', '写入')
return requestNativeSingleFileUpload({ filePath: filePath.trim() }, requestOptions)
},
async createFileReference(payload, requestOptions = {}) {
return writeRemoteVoid(
'/genealogy/app/files/reference',
'POST',
normalizeFileReferencePayload(payload),
'文件引用',
requestOptions
)
},
async deleteFileReference(payload, requestOptions = {}) {
const data = normalizeFileReferenceReleasePayload(payload)
const query = `bizTable=${encodeURIComponent(data.bizTable)}&bizId=${encodeURIComponent(data.bizId)}&bizField=${encodeURIComponent(data.bizField)}`
return writeRemoteVoid(`/genealogy/app/files/reference?${query}`, 'DELETE', undefined, '文件引用', requestOptions)
},
async getRegionChildren(parentCode, requestOptions = {}) { async getRegionChildren(parentCode, requestOptions = {}) {
if (!hasRemoteConfig()) { if (!hasRemoteConfig()) {
const error = new Error('地区选择需要真实读取服务,当前本地预览不会伪造地区列表') const error = new Error('地区选择需要真实读取服务,当前本地预览不会伪造地区列表')
@@ -2505,7 +2426,7 @@ export const appApi = {
throw error throw error
} }
const result = await requestStrict({ const result = await requestStrict({
url: '/genealogy/region/children', url: '/genealogy/app/region/children',
method: 'GET', method: 'GET',
data: { parentCode: normalizeRegionParentCode(parentCode) } data: { parentCode: normalizeRegionParentCode(parentCode) }
}, { }, {
@@ -2516,7 +2437,7 @@ export const appApi = {
async getRegionPath(regionCode, requestOptions = {}) { async getRegionPath(regionCode, requestOptions = {}) {
requireRemoteResource('行政区划路径', '读取') requireRemoteResource('行政区划路径', '读取')
const result = await requestStrict({ const result = await requestStrict({
url: `/genealogy/region/path/${encodeURIComponent(normalizeRegionCode(regionCode))}`, url: `/genealogy/app/region/path/${encodeURIComponent(normalizeRegionCode(regionCode))}`,
method: 'GET' method: 'GET'
}, { }, {
requestController: requestOptions.requestController ?? null requestController: requestOptions.requestController ?? null
@@ -2529,7 +2450,7 @@ export const appApi = {
async searchRegions(payload, requestOptions = {}) { async searchRegions(payload, requestOptions = {}) {
requireRemoteResource('行政区划搜索', '读取') requireRemoteResource('行政区划搜索', '读取')
const result = await requestStrict({ const result = await requestStrict({
url: '/genealogy/region/search', url: '/genealogy/app/region/search',
method: 'GET', method: 'GET',
data: normalizeRegionSearchPayload(payload) data: normalizeRegionSearchPayload(payload)
}, { }, {
@@ -2543,7 +2464,7 @@ export const appApi = {
async getRegion(regionCode, requestOptions = {}) { async getRegion(regionCode, requestOptions = {}) {
requireRemoteResource('行政区划详情', '读取') requireRemoteResource('行政区划详情', '读取')
const result = await requestStrict({ const result = await requestStrict({
url: `/genealogy/region/${encodeURIComponent(normalizeRegionCode(regionCode))}`, url: `/genealogy/app/region/${encodeURIComponent(normalizeRegionCode(regionCode))}`,
method: 'GET' method: 'GET'
}, { }, {
requestController: requestOptions.requestController ?? null requestController: requestOptions.requestController ?? null
@@ -3444,11 +3365,15 @@ export const appApi = {
}, },
async changePhone(payload, requestOptions = {}) { async changePhone(payload, requestOptions = {}) {
requireRemoteAuth() requireRemoteAuth()
return requestAuthVoid({ await requestStrict({
url: '/genealogy/app/auth/phone', url: '/genealogy/app/auth/phone',
method: 'PUT', method: 'PUT',
data: normalizePhoneChangePayload(payload) data: normalizePhoneChangePayload(payload)
}, requestOptions) }, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
}, },
async getHelpArticles(requestOptions = {}) { async getHelpArticles(requestOptions = {}) {
return readRemoteList('/genealogy/app/help-articles', '帮助文章', requestOptions) return readRemoteList('/genealogy/app/help-articles', '帮助文章', requestOptions)
@@ -3504,10 +3429,32 @@ export const appApi = {
const id = normalizeGenealogyPathId(genealogyId) const id = normalizeGenealogyPathId(genealogyId)
return readRemoteList(`/genealogy/app/genealogies/${id}/lineage/persons/options`, '世系人物选项', requestOptions) return readRemoteList(`/genealogy/app/genealogies/${id}/lineage/persons/options`, '世系人物选项', requestOptions)
}, },
async getContent(type, genealogyId, requestOptions = {}) { async getSiteArticles(payload = {}, requestOptions = {}) {
const loaders = { article: this.getArticles, album: this.getAlbums, ceremony: this.getCeremonies, record: this.getGrowthRecords } const result = await requestStrict({
if (!loaders[type]) throw new Error('未知内容类型') url: '/genealogy/app/site/articles',
return loaders[type].call(this, genealogyId, requestOptions) method: 'GET',
data: normalizeSiteArticleQuery(payload)
}, {
authenticated: false,
requestController: requestOptions.requestController ?? null
})
if (!Array.isArray(result)) {
throw createRequestError('官网文章列表响应不是数组', 'SITE_ARTICLE_LIST_RESPONSE_INVALID')
}
return result
},
async getSitePage(pageKey, requestOptions = {}) {
const result = await requestStrict({
url: `/genealogy/app/site/pages/${encodeURIComponent(normalizeSitePageKey(pageKey))}`,
method: 'GET'
}, {
authenticated: false,
requestController: requestOptions.requestController ?? null
})
if (!result || typeof result !== 'object' || Array.isArray(result)) {
throw createRequestError('官网单页响应不是对象', 'SITE_PAGE_RESPONSE_INVALID')
}
return result
}, },
async getNotifications(requestOptions = {}) { async getNotifications(requestOptions = {}) {
if (!hasRemoteConfig()) return listNotificationFixtures() if (!hasRemoteConfig()) return listNotificationFixtures()
+2
View File
@@ -3,6 +3,8 @@ export const AUTH_VERIFICATION_OPERATION = Object.freeze({
SMS_LOGIN: "sms-login", SMS_LOGIN: "sms-login",
REGISTER: "register", REGISTER: "register",
FORGOT_PASSWORD: "forgot-password", FORGOT_PASSWORD: "forgot-password",
PHONE_CHANGE: "phone-change",
ACCOUNT_DEACTIVATE: "account-deactivate",
}); });
const SUPPORTED_TAC_TYPES = new Set([ const SUPPORTED_TAC_TYPES = new Set([
+15 -7
View File
@@ -106,7 +106,15 @@ export const ROUTES = Object.freeze({
parent: "G05", parent: "G05",
requiredParams: ["genealogyId"], requiredParams: ["genealogyId"],
optionalParams: ["selectedId"], optionalParams: ["selectedId"],
allowedSources: ["G01", "G05", "T04", "T06", "T07"], allowedSources: ["G01", "G05", "T02", "T04", "T06", "T07"],
}),
T02: defineRoute({
path: "/pages/tree/t02-pedigree-overview",
kind: "page",
parent: "G05",
requiredParams: ["genealogyId"],
optionalParams: ["selectedId"],
allowedSources: ["G01", "G05", "T01"],
}), }),
T03: defineRoute({ T03: defineRoute({
path: "/pages/tree/t03-member-profile", path: "/pages/tree/t03-member-profile",
@@ -114,8 +122,8 @@ export const ROUTES = Object.freeze({
parent: "T01", parent: "T01",
parentParamMap: { selectedId: "personId" }, parentParamMap: { selectedId: "personId" },
requiredParams: ["genealogyId", "personId"], requiredParams: ["genealogyId", "personId"],
allowedSources: ["T01", "T07", "R02"], allowedSources: ["T01", "T02", "T07", "R02"],
resultOperations: ["member-open-requested"], resultOperations: ["member-open-requested", "member-updated"],
}), }),
T04: defineRoute({ T04: defineRoute({
path: "/pages/tree/t04-add-relative", path: "/pages/tree/t04-add-relative",
@@ -124,14 +132,14 @@ export const ROUTES = Object.freeze({
parentParamMap: { selectedId: "personId" }, parentParamMap: { selectedId: "personId" },
requiredParams: ["genealogyId"], requiredParams: ["genealogyId"],
optionalParams: ["personId", "mode", "relationType"], optionalParams: ["personId", "mode", "relationType"],
allowedSources: ["T01"], allowedSources: ["T01", "T02"],
}), }),
T05: defineRoute({ T05: defineRoute({
path: "/pages/tree/t05-edit-member", path: "/pages/tree/t05-edit-member",
kind: "flow", kind: "flow",
parent: "T03", parent: "T03",
requiredParams: ["genealogyId", "personId"], requiredParams: ["genealogyId", "personId"],
allowedSources: ["T01", "T03"], allowedSources: ["T01", "T02", "T03"],
}), }),
T06: defineRoute({ T06: defineRoute({
path: "/pages/tree/t06-edit-relationship", path: "/pages/tree/t06-edit-relationship",
@@ -140,14 +148,14 @@ export const ROUTES = Object.freeze({
parentParamMap: { selectedId: "personId" }, parentParamMap: { selectedId: "personId" },
requiredParams: ["genealogyId", "personId"], requiredParams: ["genealogyId", "personId"],
optionalParams: ["mode"], optionalParams: ["mode"],
allowedSources: ["T01"], allowedSources: ["T01", "T02"],
}), }),
T07: defineRoute({ T07: defineRoute({
path: "/pages/tree/t07-member-directory", path: "/pages/tree/t07-member-directory",
kind: "page", kind: "page",
parent: "T01", parent: "T01",
requiredParams: ["genealogyId"], requiredParams: ["genealogyId"],
allowedSources: ["T01"], allowedSources: ["T01", "T02"],
}), }),
T08: defineRoute({ T08: defineRoute({
path: "/pages/tree/t08-member-states", path: "/pages/tree/t08-member-states",
+23 -2
View File
@@ -134,6 +134,27 @@ const pickBrowserVideo = () => new Promise((resolve, reject) => {
}); });
const readNativeFile = (filePath) => new Promise((resolve, reject) => { const readNativeFile = (filePath) => new Promise((resolve, reject) => {
if (typeof globalThis.uni?.getFileInfo === 'function') {
const normalizedPath = String(filePath || '')
const fileName = normalizedPath.split(/[\\/]/).pop() || 'file'
try {
globalThis.uni.getFileInfo({
filePath,
digestAlgorithm: 'md5',
success: (file) => resolve({
filePath,
fileName,
size: file.size,
contentType: 'application/octet-stream',
md5: String(file.digest || '').toLowerCase()
}),
fail: () => reject(createUploadError('读取图片信息失败', 'IMAGE_READ_FAILED'))
})
} catch (error) {
reject(error)
}
return
}
if (!globalThis.plus?.io?.resolveLocalFileSystemURL || !globalThis.plus?.io?.FileReader) { if (!globalThis.plus?.io?.resolveLocalFileSystemURL || !globalThis.plus?.io?.FileReader) {
reject(createUploadError("当前运行环境不支持读取图片", "IMAGE_READ_UNAVAILABLE")); reject(createUploadError("当前运行环境不支持读取图片", "IMAGE_READ_UNAVAILABLE"));
return; return;
@@ -202,7 +223,7 @@ export const pickAndUploadImage = async ({ requestController = null } = {}) => {
if (!Number.isSafeInteger(file.size) || file.size <= 0) { if (!Number.isSafeInteger(file.size) || file.size <= 0) {
throw createUploadError("所选图片大小无效", "IMAGE_SIZE_INVALID"); throw createUploadError("所选图片大小无效", "IMAGE_SIZE_INVALID");
} }
const fileMd5 = calcMD5Bytes(file.data); const fileMd5 = file.md5 || calcMD5Bytes(file.data);
const initPayload = { const initPayload = {
uploadId: createUploadId(), uploadId: createUploadId(),
fileName: file.fileName, fileName: file.fileName,
@@ -245,7 +266,7 @@ export const pickAndUploadVideo = async ({ requestController = null } = {}) => {
if (!Number.isSafeInteger(file.size) || file.size <= 0) { if (!Number.isSafeInteger(file.size) || file.size <= 0) {
throw createUploadError("所选视频大小无效", "VIDEO_SIZE_INVALID"); throw createUploadError("所选视频大小无效", "VIDEO_SIZE_INVALID");
} }
const fileMd5 = calcMD5Bytes(file.data); const fileMd5 = file.md5 || calcMD5Bytes(file.data);
const initPayload = { const initPayload = {
uploadId: createUploadId(), uploadId: createUploadId(),
fileName, fileName,