修改未完成

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
+68 -36
View File
@@ -1,40 +1,72 @@
{
"name": "家谱",
"appid": "__UNI__1997FFD",
"description": "家祠卷轴风格的家谱共建应用",
"versionName": "1.0.0",
"versionCode": 100,
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"statusbar": {
"immersed": "supportedDevice",
"style": "light",
"background": "#B52E22"
"name" : "家谱",
"appid" : "__UNI__2E0520B",
"description" : "家祠卷轴风格的家谱共建应用",
"versionName" : "1.0.0",
"versionCode" : 100,
"transformPx" : false,
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"statusbar" : {
"immersed" : "supportedDevice",
"style" : "light",
"background" : "#B52E22"
},
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
"distribute" : {
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
"<uses-permission android:name=\"android.permission.READ_MEDIA_IMAGES\"/>"
]
},
"ios" : {
"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"
}
}
}
}
},
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
"mp-weixin" : {
"usingComponents" : true
},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
"<uses-permission android:name=\"android.permission.READ_MEDIA_IMAGES\"/>"
]
},
"ios": {
"dSYMs": false
}
}
},
"mp-weixin": {
"usingComponents": true
},
"vueVersion": "3"
"vueVersion" : "3"
}
+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
}
},
{
"path": "pages/tree/t02-pedigree-overview",
"style": {
"navigationStyle": "custom",
"disableScroll": true
}
},
{
"path": "pages/tree/t03-member-profile",
"style": {
+238 -80
View File
@@ -18,18 +18,32 @@
<view
class="overview-surface"
:class="{ 'overview-surface--state': overviewState !== 'ready' }"
:class="{
'overview-surface--state': overviewState !== 'ready',
'overview-surface--member':
overviewState === 'ready' && viewMode === 'member',
}"
>
<template
v-if="overviewState === 'ready' && genealogy && viewMode === 'member'"
>
<view class="overview-ready">
<view class="overview-hero">
<text class="overview-hero__kicker"
>{{ genealogy.hall || "堂号待补" }} ·
{{ genealogy.location || "所在地待补" }}</text
>
<text class="overview-hero__title">{{ genealogy.name }}</text>
<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"
>{{ genealogy.hall || "堂号待补" }} ·
{{ genealogy.location || "所在地待补" }}</text
>
</view>
</view>
<text class="overview-hero__motto">{{
genealogy.intro || "简介待补充"
}}</text>
@@ -41,53 +55,97 @@
<text>{{
getGenealogyAccessPresetLabel(genealogy.accessPreset)
}}</text>
</view>
<view class="overview-hero__stats">
<text v-if="genealogy.joinTime"
>加入于 {{ formatGenealogyTime(genealogy.joinTime) }}</text
>
</view>
</view>
<view
class="overview-actions"
:class="{ 'overview-actions--member': accessRole === 'member' }"
>
<view class="overview-actions">
<view class="overview-action overview-action--tree" @click="toTree">
<text class="overview-action__title">世系树</text
><text>查看家脉关系</text>
</view>
<view
class="overview-action overview-action--poem"
@click="toGenerationPoems"
>
<text class="overview-action__title">字辈谱</text
><text>传承行辈次序</text>
<view class="overview-action__icon overview-action__icon--primary">
<image
src="/static/assets/modules/genealogy/transparent/shortcut-tree.png"
mode="aspectFit"
/>
</view>
<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
v-if="accessRole === 'owner'"
class="overview-action overview-action--review"
@click="toApplications"
>
<text class="overview-action__title">入谱审核</text
><text>处理加入申请</text>
<image
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
v-if="accessRole === 'owner'"
class="overview-action overview-action--settings"
@click="toSettings"
>
<text class="overview-action__title">家谱设置</text
><text>维护公开范围与基础资料</text>
<image
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>
<template v-else>
<view class="overview-summary">
<text class="overview-action__title">成员身份</text
><text>已加入 · 普通成员</text>
<text class="overview-action__title">成员身份</text>
<text>已加入 · 普通成员</text>
</view>
<view class="overview-summary">
<text class="overview-action__title">访问范围</text
><text>{{
<text class="overview-action__title">访问范围</text>
<text>{{
getGenealogyAccessPresetLabel(genealogy.accessPreset)
}}</text>
</view>
@@ -95,8 +153,14 @@
</view>
<view class="overview-family" @click="toFamily">
<text>家族近况</text><text>进入家族圈查看动态</text
><text>查看</text>
<view>
<text>家族近况</text>
<text>进入家族圈查看动态</text>
</view>
<image
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view>
<view class="overview-note">
<text class="overview-note__title">成员资料按家谱访问规则保护</text>
@@ -388,6 +452,12 @@ const applyToJoin = () => {
min-height: min(483px, calc(100vw * 1.3422));
flex: 0 0 auto;
}
.overview-surface--member {
min-height: 100%;
border: 0;
border-image: none;
background: #fbf4e7;
}
.overview-surface--state {
display: flex;
height: calc(100vh - 112rpx);
@@ -398,35 +468,64 @@ const applyToJoin = () => {
}
.overview-ready {
display: flex;
min-height: min(540px, calc(100vw * 1.5));
flex-direction: column;
gap: 34rpx;
padding: 10rpx 0 42rpx;
min-height: 0;
padding: 0 0 calc(34rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.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;
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 {
display: block;
color: #dec483;
font-size: 22rpx;
letter-spacing: 3rpx;
font-size: 20rpx;
line-height: 1.5;
}
.overview-hero__title {
display: block;
margin-top: 12rpx;
margin-bottom: 6rpx;
font-family: "STKaiti", "KaiTi", serif;
font-size: 46rpx;
font-size: 42rpx;
font-weight: 700;
letter-spacing: 4rpx;
letter-spacing: 2rpx;
}
.overview-hero__motto {
display: block;
margin-top: 6rpx;
height: 66rpx;
margin-top: 22rpx;
color: #e9dcc1;
font-size: 23rpx;
font-size: 21rpx;
line-height: 1.55;
overflow: hidden;
display: -webkit-box;
@@ -434,79 +533,138 @@ const applyToJoin = () => {
-webkit-line-clamp: 2;
}
.overview-hero__stats {
display: inline-flex;
gap: 18rpx;
margin-top: 12rpx;
display: flex;
flex-wrap: wrap;
gap: 8rpx 0;
margin-top: 18rpx;
color: #dbc990;
font-size: 22rpx;
transform: translateY(-18rpx);
font-size: 19rpx;
line-height: 1.4;
}
.overview-hero__stats + .overview-hero__stats {
margin-left: 18rpx;
.overview-hero__stats text {
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 {
display: grid;
min-height: 386rpx;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
gap: 3.4% 3%;
margin: 0 7.2%;
}
.overview-actions--member {
min-height: 386rpx;
display: flex;
flex-direction: column;
padding: 28rpx 42rpx 0;
background: #fbf4e7;
box-sizing: border-box;
}
.overview-action,
.overview-summary {
display: flex;
flex-direction: column;
justify-content: center;
display: grid;
grid-template-columns: 72rpx minmax(0, 1fr) 28rpx;
min-height: 126rpx;
align-items: center;
gap: 24rpx;
min-width: 0;
padding: 24rpx 22rpx;
padding: 18rpx 0;
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
box-sizing: border-box;
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 {
margin-bottom: 9rpx;
margin-bottom: 5rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 30rpx;
font-weight: 700;
}
.overview-action--tree .overview-action__title {
font-size: 40rpx;
}
.overview-summary {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.overview-family {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-columns: minmax(0, 1fr) 26rpx;
align-items: center;
align-self: start;
gap: 16rpx;
margin: 0 7.5%;
gap: 20rpx;
margin: 14rpx 42rpx 0;
padding: 20rpx 0;
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
color: $ink-muted;
font-size: 22rpx;
font-size: 20rpx;
line-height: 1.5;
}
.overview-family text {
display: block;
}
.overview-family text:first-child {
margin-bottom: 3rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 28rpx;
font-size: 27rpx;
font-weight: 700;
}
.overview-family text:last-child {
color: $brand-red;
}
.overview-note {
display: flex;
flex-direction: column;
justify-content: center;
margin: 0 8.5%;
margin: 18rpx 42rpx 0;
padding: 18rpx 0 0;
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
color: $ink-muted;
font-size: 22rpx;
line-height: 1.6;
margin-top: 100rpx;
font-size: 19rpx;
line-height: 1.55;
}
.overview-note__title {
margin-bottom: 8rpx;
margin-bottom: 5rpx;
color: $brand-red;
font-family: "STKaiti", "KaiTi", serif;
font-size: 28rpx;
font-size: 24rpx;
font-weight: 700;
}
.overview-state {
+503 -151
View File
@@ -1,70 +1,152 @@
<!-- 页面编号M-01用途个人中心显示已核实的当前用户资料 -->
<!-- 页面编号M-01用途个人中心显示当前用户资料与服务入口 -->
<template>
<view class="profile-page">
<ModulePageBackground module="profile" />
<view class="page-header"><PageHeader root title="我的" /></view>
<view class="page-content">
<view v-if="loading" class="state-card"
><text>正在读取个人资料</text></view
>
<view v-else-if="loadError" class="state-card">
<text>个人资料暂时无法读取</text>
<text>{{ loadError }}</text>
<AppButton block label="重新读取" @click="loadProfile" />
</view>
<template v-else>
<view class="profile-summary">
<view class="profile-summary__avatar"
><image
src="/static/assets/foundation/transparent/auth-login-outline.png"
mode="aspectFit"
/></view>
<view class="profile-summary__identity">
<text>{{ profile.nickName || "未设置昵称" }}</text>
<text>{{ profile.realName || "未设置真实姓名" }}</text>
<text>{{ profile.phone || "未提供手机号" }}</text>
</view>
<AppButton
class="profile-summary__edit"
label="编辑资料"
@click="openEdit"
/>
</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 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>
</template>
<view
class="profile-page"
:class="{
'profile-state--ready': !loading && !loadError,
'profile-state--error': Boolean(loadError),
}"
>
<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>{{ loadError }}</text>
<AppButton block label="重新读取" @click="loadProfile" />
</view>
<template v-else>
<view class="profile-hero">
<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
class="profile-hero__setting-icon"
src="/static/assets/modules/profile/transparent/m01-icon-settings.png"
mode="aspectFit"
aria-hidden="true"
/></view>
</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.realName || "未设置真实姓名" }}</text>
<text>{{ profile.phone || "未提供手机号" }}</text>
</view>
</view>
<button
class="profile-hero__edit"
@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>
</view>
</template>
<AppTabbar active="profile" />
</view>
</template>
<script setup>
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 AppTabbar from "@/components/AppTabbar.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
appApi,
createRequestController,
@@ -83,11 +165,59 @@ const profile = reactive({
});
const loading = ref(false);
const loadError = ref("");
const compactHeader = ref(false);
const requestController = createRequestController();
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(() =>
/^\d{4}-\d{2}-\d{2}/.test(profile.birthday)
? profile.birthday.slice(0, 10)
@@ -111,143 +241,365 @@ const loadProfile = async () => {
}
};
const open = (route) => openPage(route, {}, "M01");
const openEdit = () => open("M02");
const openService = (item) => openPage(item.routeKey, {}, "M01");
const openEdit = () => openPage("M02", {}, "M01");
const openSettings = () => openPage("M10", {}, "M01");
onShow(loadProfile);
onPageScroll(({ scrollTop = 0 }) => {
compactHeader.value = scrollTop > 44;
});
onUnload(() => {
pageActive = false;
compactHeader.value = false;
requestController.abort();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.profile-page {
display: flex;
width: 100%;
min-height: 100vh;
flex-direction: column;
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 {
padding-bottom: 166rpx;
box-sizing: border-box;
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
overflow-x: hidden;
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;
}
.state-card text {
.profile-state-card text {
display: block;
font-size: 26rpx;
line-height: 1.6;
}
.state-card text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 35rpx;
font-weight: 700;
.profile-state-card text + text {
margin-top: 12rpx;
color: #806e5d;
font-size: 22rpx;
}
.state-card text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
.profile-state-card .app-button {
@include adaptive.adaptive-scroll-button(primary);
margin-top: 28rpx;
}
.profile-summary {
.profile-hero {
display: grid;
grid-template-columns: 96rpx minmax(0, 1fr);
align-items: center;
gap: 18rpx;
padding: 30rpx;
width: 100%;
height: 503rpx;
}
.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;
width: 96rpx;
height: 96rpx;
flex-direction: column;
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;
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;
height: 64rpx;
opacity: 0.8;
.profile-hero__setting-icon {
width: 42rpx;
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;
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;
overflow-wrap: anywhere;
}
.profile-summary__identity text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
.profile-hero__copy text:first-child {
color: #fff2da;
font-family: STSong, SimSun, serif;
font-size: 32rpx;
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 {
margin-top: 5rpx;
color: $ink-muted;
font-size: 21rpx;
.profile-hero__copy text:nth-child(2) {
margin-top: 14rpx;
color: #ecd2a4;
font-size: 27rpx;
line-height: 1.35;
}
.profile-summary__edit {
grid-column: 1 / -1;
margin-top: 6rpx;
}
.profile-fields {
display: flex;
flex-direction: column;
.profile-hero__copy text:last-child {
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;
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;
justify-content: space-between;
gap: 20rpx;
border-bottom: 1rpx solid rgba(181, 137, 63, 0.3);
gap: 24rpx;
border-bottom: 1rpx solid rgba(112, 85, 61, 0.2);
color: #45362d;
}
.profile-field:last-child {
border-bottom: 0;
}
.profile-field text:first-child {
flex: 0 0 auto;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.profile-field text:last-child {
.profile-service-row__leading {
display: flex;
min-width: 0;
color: $ink-muted;
font-size: 22rpx;
overflow-wrap: anywhere;
text-align: right;
align-items: center;
gap: 30rpx;
padding-left: 7rpx;
}
.profile-menu {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 24rpx;
.profile-service-row__icon {
width: 44rpx;
height: 44rpx;
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>
+395 -35
View File
@@ -1,28 +1,324 @@
<!-- 页面编号M-05用途换绑手机号缺专用发码/资料 DTO 且需人工 TAC/短信时保持关闭 -->
<!-- 页面编号M-05用途换绑已登录账号的手机号 -->
<template>
<view class="phone-page"
><ModulePageBackground module="profile" /><view class="page-header"
><PageHeader
title="换绑手机号"
custom-back
@back="backToSecurity" /></view
><view class="page-content"
><view class="state-card"
><text>暂不能在线换绑手机号</text
><text>为保护账户安全请联系管理员协助办理手机号换绑</text
><AppButton
block
label="返回账号与安全"
@click="backToSecurity" /></view></view
></view>
<view
class="phone-page"
:class="{
'phone-state--sending': phoneState === 'sending',
'phone-state--submitting': phoneState === 'submitting',
}"
>
<ModulePageBackground module="profile" />
<view class="page-layer"
><PageHeader title="换绑手机号" custom-back @back="requestBack"
/></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
:disabled="phoneState !== 'ready'"
:label="phoneState === 'submitting' ? '正在换绑' : '确认换绑手机号'"
@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>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
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 PageHeader from "@/components/PageHeader.vue";
import { returnTo } from "@/utils/navigation.js";
const backToSecurity = () => returnTo("M03");
import TacVerification from "@/components/TacVerification.vue";
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>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.phone-page {
@@ -31,36 +327,100 @@ const backToSecurity = () => returnTo("M03");
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
.page-layer {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
flex: 1;
padding: 28rpx 30rpx 72rpx;
}
.state-card {
box-sizing: border-box;
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
.security-tip,
.form-panel {
@include adaptive.adaptive-profile-content;
}
.security-tip {
min-height: 170rpx;
padding: 38rpx 44rpx;
text-align: center;
@include adaptive.adaptive-family-content;
}
.state-card text {
.security-tip text {
display: block;
}
.state-card text:first-child {
.security-tip text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 35rpx;
font-size: 32rpx;
font-weight: 700;
}
.state-card text:nth-child(2) {
margin-top: 18rpx;
.security-tip text:last-child {
margin-top: 10rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
font-size: 22rpx;
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;
}
@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>
+422 -54
View File
@@ -2,13 +2,26 @@
<view class="orders-page">
<ModulePageBackground module="profile" />
<view class="page-layer"
><PageHeader title="VIP 与订单" custom-back @back="requestBack"
><PageHeader
class="orders-header"
title="VIP 与订单"
custom-back
@back="requestBack"
/></view>
<view class="page-content page-layer">
<view class="service-card"
><text>服务套餐</text
><text>套餐和订单均从服务端读取本页不发起支付或创建订单</text></view
>
<view class="vip-intro">
<image
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"
><AppLoading text="正在读取服务套餐"
/></view>
@@ -18,12 +31,33 @@
/></view>
<template v-else>
<view v-if="packages.length" class="package-list"
><view v-for="item in packages" :key="item.id" class="package-card"
><view class="package-heading"
><text>{{ item.name }}</text
><text v-if="item.price">¥{{ item.price }}</text></view
><text v-if="item.description">{{ item.description }}</text
><text v-if="item.duration">{{ item.duration }}</text></view
><view
v-for="item in packages"
:key="item.id"
class="package-card"
><image
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 v-else class="state-card"
@@ -32,22 +66,46 @@
<view class="section-heading"
><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-for="item in orders" :key="item.id" class="order-card"
><text>{{ item.title }}</text
><text>{{ item.status }}</text></view
><view><text>{{ item.title }}</text><text>订单编号已由服务端生成</text></view
><text>{{ item.status || "状态待服务端确认" }}</text></view
></view
>
<view v-else class="order-empty"><text>暂无订单记录</text></view>
</template>
</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>
</template>
<script setup>
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 AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
@@ -61,8 +119,13 @@ import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const packages = ref([]);
const orders = ref([]);
const state = ref("loading");
const selectedPackage = ref(null);
const creating = ref(false);
const purchaseError = ref("");
const orderNotice = ref("");
const controller = createRequestController();
let active = true;
let statusBarTimer = null;
const formatPrice = (value) =>
Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "";
const durationUnits = Object.freeze({ year: "年", month: "个月", day: "天" });
@@ -107,14 +170,67 @@ const loadVip = async () => {
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({});
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(() => {
setVipStatusBar("dark", "#f4ebde");
if (state.value !== "loading") loadVip();
});
onUnload(() => {
active = false;
controller.abort();
if (statusBarTimer) clearTimeout(statusBarTimer);
const navigator = globalThis.plus?.navigator;
if (navigator) {
navigator.setStatusBarStyle("light");
navigator.setStatusBarBackground("#b52e22");
}
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
@@ -125,40 +241,61 @@ onBackPress((event) => handleBackPress(event, requestBack));
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
background: #f7f2e9;
}
.page-layer {
z-index: 1;
}
.page-content {
flex: 1;
padding: 28rpx 30rpx 72rpx;
padding: 20rpx 30rpx 72rpx;
}
.service-card,
.state-card,
.package-card,
.order-card,
.order-empty {
@include adaptive.adaptive-profile-content;
}
.service-card {
min-height: 170rpx;
padding: 38rpx 44rpx;
.vip-intro {
position: relative;
min-height: 284rpx;
overflow: hidden;
margin: -20rpx -30rpx 0;
padding: 36rpx 48rpx 30rpx;
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;
}
.service-card text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 34rpx;
.vip-intro__eyebrow {
color: $brand-red;
font-size: 20rpx;
font-weight: 700;
letter-spacing: 5rpx;
}
.service-card text:last-child {
margin-top: 12rpx;
.vip-intro__title {
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;
font-size: 21rpx;
font-size: 23rpx;
line-height: 1.55;
}
.state-card {
@@ -181,55 +318,138 @@ onBackPress((event) => handleBackPress(event, requestBack));
.order-list {
display: flex;
flex-direction: column;
gap: 14rpx;
margin-top: 18rpx;
gap: 18rpx;
}
.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;
align-items: center;
justify-content: space-between;
gap: 16rpx;
color: $ink;
font-size: 27rpx;
gap: 20rpx;
margin-top: 30rpx;
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;
letter-spacing: 1rpx;
line-height: normal;
}
.package-heading text:last-child {
flex: 0 0 auto;
color: $brand-red;
.purchase-button::after {
border: 0;
}
.package-card > text {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: 21rpx;
line-height: 1.5;
.purchase-button[disabled] {
opacity: 0.5;
}
.section-heading {
display: flex;
justify-content: space-between;
margin: 28rpx 6rpx 12rpx;
color: $ink;
font-size: 27rpx;
margin: 44rpx 6rpx 16rpx;
color: #38281d;
font-family: STKaiti, KaiTi, serif;
font-size: 32rpx;
font-weight: 700;
}
.section-heading text:last-child {
color: $ink-muted;
color: #8a796b;
font-size: 21rpx;
font-weight: 400;
}
.order-notice {
display: block;
margin: 0 6rpx 14rpx;
color: #786556;
font-size: 21rpx;
line-height: 1.5;
}
.order-card {
display: flex;
justify-content: space-between;
gap: 18rpx;
padding: 26rpx 32rpx;
color: $ink;
padding: 26rpx 4rpx;
border-bottom: 1rpx solid rgba(117, 84, 52, 0.14);
color: #48372a;
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 {
color: $ink-muted;
flex: 0 0 auto;
color: #7f6959;
font-size: 21rpx;
}
.order-empty {
padding: 38rpx;
@@ -237,10 +457,158 @@ onBackPress((event) => handleBackPress(event, requestBack));
color: $ink-muted;
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) {
.page-content {
padding-right: 22rpx;
padding-left: 22rpx;
}
.package-card {
padding-right: 28rpx;
padding-left: 28rpx;
}
.purchase-button {
min-width: 166rpx;
padding-right: 20rpx;
padding-left: 20rpx;
}
}
</style>
+3 -1
View File
@@ -11,7 +11,7 @@
>
<ModulePageBackground module="tree" />
<view class="tree-page__header">
<PageHeader title="世系树" action="成员目录" @action="toDirectory" />
<PageHeader title="树状图" action="世系谱" @action="toPedigree" />
</view>
<view v-if="treeState !== 'loading'" class="tree-toolbar">
@@ -782,6 +782,8 @@ const toMember = () => {
};
const toDirectory = () =>
openPage("T07", { genealogyId: genealogyId.value }, "T01");
const toPedigree = () =>
openPage("T02", { genealogyId: genealogyId.value }, "T01");
const toRank = () =>
selected.value
? 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>
+61 -63
View File
@@ -158,69 +158,59 @@ const trailIndex = ref(-1);
const memberRequestController = createRequestController();
let loadSequence = 0;
const details = computed(() =>
member.value
? [
{ label: "别名", value: member.value.aliasName },
{ label: "字辈", value: member.value.generationName },
{ label: "性别(字典值)", value: member.value.sex },
{ label: "人物状态(字典值)", value: member.value.personStatus },
{
label: "出生日期",
value:
member.value.status === "forbidden"
? "按权限隐藏"
: member.value.birthDate,
},
{
label: "出生农历",
value:
member.value.status === "forbidden"
? "按权限隐藏"
: member.value.birthLunar,
},
{ label: "生卒信息", value: member.value.years },
{
label: "出生地",
value:
member.value.status === "forbidden"
? "按权限隐藏"
: member.value.birthplace,
},
{
label: "逝世农历",
value:
member.value.status === "forbidden"
? "按权限隐藏"
: member.value.deathLunar,
},
{
label: "逝世地",
value:
member.value.status === "forbidden"
? "按权限隐藏"
: member.value.deathPlace,
},
{
label: "安葬地",
value:
member.value.status === "forbidden"
? "按权限隐藏"
: member.value.burialPlace,
},
{ label: "配偶", value: member.value.spouseNames },
{ label: "所属支系", value: member.value.branch },
{
label: "生平",
value:
member.value.status === "forbidden"
? "按权限隐藏"
: member.value.biography,
},
{ label: "备注", value: member.value.remark },
]
: [],
);
const dictionaryLabel = (value, labels) => labels[String(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.generationName },
{
label: "性别",
value: dictionaryLabel(member.value.sex, {
0: "男",
1: "女",
2: "未知",
}),
},
{
label: "人物状态",
value: dictionaryLabel(member.value.personStatus, {
0: "健在",
1: "已故",
2: "未知",
}),
},
{ label: "出生日期", value: protectedValue(member.value.birthDate) },
{
label: "出生农历",
value: protectedValue(
dictionaryLabel(member.value.birthLunar, { 0: "否", 1: "是" }),
),
},
{ label: "生卒信息", value: member.value.years },
{ label: "出生地", value: protectedValue(member.value.birthplace) },
...(isDeceased.value
? [
{ label: "逝世日期", value: protectedValue(member.value.deathDate) },
{
label: "逝世农历",
value: protectedValue(
dictionaryLabel(member.value.deathLunar, { 0: "否", 1: "是" }),
),
},
{ label: "逝世地", value: protectedValue(member.value.deathPlace) },
{ label: "安葬地", value: protectedValue(member.value.burialPlace) },
]
: []),
{ label: "配偶", value: member.value.spouseNames },
{ label: "所属支系", value: member.value.branch },
{ label: "生平", value: protectedValue(member.value.biography) },
{ label: "备注", value: member.value.remark },
];
});
const canPreviewEdit = computed(() => memberState.value === "detail");
const restrictedCopy = computed(() =>
member.value?.status === "forbidden"
@@ -377,6 +367,14 @@ onShow(() => {
const result = consumeNavigationResult("T03");
if (result?.operation === "member-open-requested" && 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
}}</text>
<view class="form-field">
<text>人物编号</text>
<input
v-model="addForm.personNo"
maxlength="40"
placeholder="不填则由服务端生成"
placeholder-class="form-placeholder"
/>
</view>
<picker
:range="sexOptions.map((item) => item.label)"
:value="optionIndex(sexOptions, addForm.sex)"
@@ -154,27 +145,6 @@
/>
<text v-else>首位成员固定为 1</text>
</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
mode="date"
:value="addForm.birthDate"
@@ -208,6 +178,7 @@
</view>
<picker
v-if="isDeceased"
mode="date"
:value="addForm.deathDate"
@change="selectDeathDate"
@@ -218,6 +189,7 @@
</view>
</picker>
<picker
v-if="isDeceased"
:range="lunarOptions.map((item) => item.label)"
:value="optionIndex(lunarOptions, addForm.deathLunar)"
@change="selectOption('deathLunar', lunarOptions, $event)"
@@ -229,7 +201,7 @@
}}</text>
</view>
</picker>
<view class="form-field">
<view v-if="isDeceased" class="form-field">
<text>逝世地</text>
<input
v-model="addForm.deathPlace"
@@ -238,7 +210,7 @@
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field">
<view v-if="isDeceased" class="form-field">
<text>安葬地</text>
<input
v-model="addForm.burialPlace"
@@ -268,18 +240,6 @@
placeholder-class="form-placeholder"
/>
</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">
<text>人物简介</text>
<textarea
@@ -372,7 +332,6 @@ const discardDialogVisible = ref(false);
const currentMember = ref(null);
const errorMessage = ref("");
const addRequestController = createRequestController();
const personOptionsRequestController = createRequestController();
let loadSequence = 0;
const relationIntents = Object.freeze({
@@ -408,23 +367,15 @@ const personStatusOptions = Object.freeze([
const bindingModeOptions = Object.freeze([
{ label: "不绑定账号", value: "NONE" },
{ label: "绑定当前账号", value: "SELF" },
{ label: "绑定指定用户", value: "SPECIFIED" },
]);
const personOptions = ref([{ label: "不选择", value: "" }]);
const personOptionLabels = computed(() =>
personOptions.value.map((item) => item.label),
);
const addForm = reactive({
bindingMode: "NONE",
personNo: "",
name: "",
relation: "",
aliasName: "",
sex: "",
generation: "",
generationName: "",
fatherId: "",
motherId: "",
avatarOssId: "",
birthDate: "",
birthLunar: "",
@@ -437,11 +388,11 @@ const addForm = reactive({
biography: "",
remark: "",
sortOrder: "",
relationName: "",
});
const fieldErrors = reactive({ name: "", relation: "", bindingMode: "" });
const isFirstMember = computed(() => mode.value === "first");
const isDeceased = computed(() => addForm.personStatus === "1");
const activeRelationIntent = computed(
() => relationIntents[relationType.value] || null,
);
@@ -524,27 +475,6 @@ const loadCurrentMember = async () => {
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) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
@@ -567,7 +497,6 @@ onLoad((query) => {
if (activeRelationIntent.value) {
addForm.relation = activeRelationIntent.value.label;
}
void loadPersonOptions();
if (isFirstMember.value) {
addState.value = "form";
return;
@@ -577,7 +506,6 @@ onLoad((query) => {
onUnload(() => {
loadSequence += 1;
addRequestController.abort();
personOptionsRequestController.abort();
discardConfirmation.dispose();
});
@@ -609,22 +537,20 @@ const optionLabel = (options, value) =>
options.find((item) => item.value === value)?.label || "";
const selectOption = (field, options, event) => {
addForm[field] = options[Number(event.detail.value)]?.value || "";
if (field === "personStatus" && addForm.personStatus !== "1") {
Object.assign(addForm, {
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
});
}
};
const selectBindingMode = (event) => {
addForm.bindingMode =
bindingModeOptions[Number(event.detail.value)]?.value || "NONE";
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 () => {
if (isAvatarUploading.value || isSubmitting.value) return;
isAvatarUploading.value = true;
@@ -665,13 +591,10 @@ const submitAdd = async () => {
try {
const payload = {
bindingMode: addForm.bindingMode,
personNo: addForm.personNo,
name: addForm.name,
aliasName: addForm.aliasName,
sex: addForm.sex,
generationName: addForm.generationName,
fatherId: addForm.fatherId,
motherId: addForm.motherId,
avatarOssId: addForm.avatarOssId,
birthDate: addForm.birthDate,
birthLunar: addForm.birthLunar,
@@ -685,12 +608,14 @@ const submitAdd = async () => {
remark: addForm.remark,
sortOrder: addForm.sortOrder,
...(isFirstMember.value
? { generation: 1, relationName: addForm.relationName }
? { generation: 1 }
: {
...(addForm.generation
? { generation: Number(addForm.generation) }
: {}),
relationName: relationLabel.value,
...(relationType.value === "SPOUSE"
? { relationName: relationLabel.value }
: {}),
}),
};
if (isFirstMember.value) {
@@ -711,15 +636,12 @@ const submitAdd = async () => {
}
Object.assign(addForm, {
bindingMode: "NONE",
personNo: "",
name: "",
relation: "",
aliasName: "",
sex: "",
generation: "",
generationName: "",
fatherId: "",
motherId: "",
avatarOssId: "",
birthDate: "",
birthLunar: "",
@@ -732,7 +654,6 @@ const submitAdd = async () => {
biography: "",
remark: "",
sortOrder: "",
relationName: "",
});
avatarFileName.value = "";
await returnTo("T01", { genealogyId: genealogyId.value });
+52 -34
View File
@@ -49,12 +49,7 @@
<view class="form-field">
<text>人物编号</text>
<input
v-model="editForm.personNo"
maxlength="40"
placeholder="不填则由服务端生成"
placeholder-class="form-placeholder"
/>
<text>{{ originalMember.personNo || "服务端生成" }}</text>
</view>
<picker
:range="sexOptions.map((item) => item.label)"
@@ -68,7 +63,14 @@
}}</text>
</view>
</picker>
<view
v-if="editForm.bindingMode === 'SPECIFIED'"
class="form-field form-field--picker"
>
<text>身份认领</text><text>已绑定指定用户</text>
</view>
<picker
v-else
:range="bindingModeOptions.map((item) => item.label)"
:value="optionIndex(bindingModeOptions, editForm.bindingMode)"
@change="selectBindingMode"
@@ -183,6 +185,7 @@
/>
</view>
<picker
v-if="isDeceased"
mode="date"
:value="editForm.deathDate"
@change="selectDate('deathDate', $event)"
@@ -193,6 +196,7 @@
>
</picker>
<picker
v-if="isDeceased"
:range="lunarOptions.map((item) => item.label)"
:value="optionIndex(lunarOptions, editForm.deathLunar)"
@change="selectOption('deathLunar', lunarOptions, $event)"
@@ -204,7 +208,7 @@
}}</text>
</view>
</picker>
<view class="form-field">
<view v-if="isDeceased" class="form-field">
<text>逝世地</text>
<input
v-model="editForm.deathPlace"
@@ -212,7 +216,7 @@
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field">
<view v-if="isDeceased" class="form-field">
<text>安葬地</text>
<input
v-model="editForm.burialPlace"
@@ -242,15 +246,6 @@
placeholder-class="form-placeholder"
/>
</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">
<text>人物简介</text>
<textarea
@@ -332,7 +327,12 @@ import {
pickAndUploadImage,
} from "@/utils/resumable-image-upload.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 genealogyId = ref("");
@@ -353,7 +353,6 @@ const baseline = ref("");
const editForm = reactive({
appUserId: "",
bindingMode: "NONE",
personNo: "",
name: "",
aliasName: "",
sex: "",
@@ -373,7 +372,6 @@ const editForm = reactive({
summary: "",
remark: "",
sortOrder: "",
relationName: "",
});
const fieldErrors = reactive({ name: "", dates: "", bindingMode: "" });
const sexOptions = Object.freeze([
@@ -393,7 +391,6 @@ const personStatusOptions = Object.freeze([
const bindingModeOptions = Object.freeze([
{ label: "不绑定账号", value: "NONE" },
{ label: "绑定当前账号", value: "SELF" },
{ label: "绑定指定用户", value: "SPECIFIED" },
]);
const personOptions = ref([{ label: "不选择", value: "" }]);
const personOptionLabels = computed(() =>
@@ -408,6 +405,7 @@ const appUserBindingHint = computed(() => {
? "保留当前已认领的指定业务用户"
: "需先选择指定业务用户;当前没有可用候选接口";
});
const isDeceased = computed(() => editForm.personStatus === "1");
const formSnapshot = computed(() => JSON.stringify(editForm));
const hasValidContext = computed(() =>
@@ -463,11 +461,11 @@ const loadMember = async () => {
});
if (activeLoad !== loadSequence) return;
originalMember.value = member;
const deceased = member.personStatus === "1";
Object.assign(editForm, {
appUserId: member.appUserId || "",
bindingMode:
member.bindingMode || (member.appUserId ? "SPECIFIED" : "NONE"),
personNo: member.personNo || "",
name: member.name,
aliasName: member.aliasName || "",
sex: member.sex || "",
@@ -481,15 +479,14 @@ const loadMember = async () => {
birthDate: datePart(member.birthDate),
birthLunar: member.birthLunar || "",
birthPlace: member.birthplace || "",
deathDate: datePart(member.deathDate),
deathLunar: member.deathLunar || "",
deathPlace: member.deathPlace || "",
burialPlace: member.burialPlace || "",
deathDate: deceased ? datePart(member.deathDate) : "",
deathLunar: deceased ? member.deathLunar || "" : "",
deathPlace: deceased ? member.deathPlace || "" : "",
burialPlace: deceased ? member.burialPlace || "" : "",
personStatus: member.personStatus || "",
summary: member.biography || "",
remark: member.remark || "",
sortOrder: member.sortOrder ?? "",
relationName: member.relationName || "",
});
baseline.value = formSnapshot.value;
errorMessage.value = "";
@@ -567,6 +564,14 @@ const optionLabel = (options, value) =>
options.find((item) => item.value === value)?.label || "";
const selectOption = (field, options, event) => {
editForm[field] = options[Number(event.detail.value)]?.value || "";
if (field === "personStatus" && editForm.personStatus !== "1") {
Object.assign(editForm, {
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
});
}
};
const selectBindingMode = (event) => {
editForm.bindingMode =
@@ -632,7 +637,6 @@ const saveMember = async () => {
...(editForm.bindingMode === "SPECIFIED"
? { appUserId: editForm.appUserId }
: {}),
personNo: editForm.personNo,
name: editForm.name,
aliasName: editForm.aliasName,
sex: editForm.sex,
@@ -646,20 +650,34 @@ const saveMember = async () => {
birthDate: editForm.birthDate,
birthLunar: editForm.birthLunar,
birthPlace: editForm.birthPlace,
deathDate: editForm.deathDate,
deathLunar: editForm.deathLunar,
deathPlace: editForm.deathPlace,
burialPlace: editForm.burialPlace,
...(isDeceased.value
? {
deathDate: editForm.deathDate,
deathLunar: editForm.deathLunar,
deathPlace: editForm.deathPlace,
burialPlace: editForm.burialPlace,
}
: {}),
personStatus: editForm.personStatus,
biography: editForm.summary,
remark: editForm.remark,
sortOrder: editForm.sortOrder,
relationName: editForm.relationName,
},
{ requestController: editRequestController },
);
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) {
if (isRequestCancelled(error)) return;
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 session = {
getToken: () => "",
getToken: () => "session-1",
saveToken: (token) => globalThis.__savedTokens.push(token),
clear: () => globalThis.__clearedSessions.push("cleared"),
};
@@ -136,6 +136,17 @@ const run = async () => {
phone: "13800138000",
}, "策略关闭时发码请求不得伪造 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 时,只清理失效的本地会话;请求仍向调用方失败返回。
respond({ statusCode: 200, data: { code: 401, msg: "认证失败", data: null } });
await assert.rejects(
@@ -164,25 +175,12 @@ const run = async () => {
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, {
tenantId: "000000",
grantType: "sms",
grantType: "password",
phone: "13800138000",
validToken: "ticket-legacy",
newPassword: "a".repeat(32),
smsCode: "1234",
});
respond({
@@ -191,6 +189,13 @@ const run = async () => {
});
const login = await appApi.loginWithSms({ phone: "13800138000", smsCode: "1234" });
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"]);
respond({
@@ -241,7 +246,6 @@ const run = async () => {
});
assert.strictEqual(registration.access_token, "token-register");
assert.deepStrictEqual(requests.at(-1).data, {
clientId: "client-1",
tenantId: "000000",
grantType: "password",
phone: "13800138000",
@@ -262,6 +266,12 @@ const run = async () => {
});
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;
const requestController = createRequestController();
const cancelled = appApi.sendSmsCode(
+2
View File
@@ -29,6 +29,8 @@ const run = async () => {
SMS_LOGIN: "sms-login",
REGISTER: "register",
FORGOT_PASSWORD: "forgot-password",
PHONE_CHANGE: "phone-change",
ACCOUNT_DEACTIVATE: "account-deactivate",
},
"认证动作必须由受保护 OpenAPI 的唯一枚举拥有",
);
+86 -39
View File
@@ -1,57 +1,104 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$documents = @(Get-ChildItem -LiteralPath $root -File -Filter '*.openapi.json')
if ($documents.Count -ne 1) { throw 'Exactly one current OpenAPI JSON export is required at the repository root' }
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $documents[0].FullName | ConvertFrom-Json
$openApiPath = Join-Path $root 'genealogy-app-openapi.yaml'
if (-not (Test-Path -LiteralPath $openApiPath)) {
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 }
}
}
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $openApiPath
$operationCount = [regex]::Matches($document, '(?m)^ (?:get|post|put|delete|patch):\s*$').Count
if ($operationCount -ne 137) {
throw "Current backend OpenAPI operation count drifted: $operationCount"
}
function Get-PathBlock {
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
}
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"
}
)
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'
if ($videoList.requestBody.content.'application/json'.schema.'$ref' -ne '#/components/schemas/VideoBody') {
throw 'Video create must consume VideoBody'
$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"
}
}
$videoBody = $document.components.schemas.VideoBody
if ((@($videoBody.required) -join ',') -ne 'videoTitle,videoOssId') {
throw 'VideoBody required fields drifted'
$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"
}
if ($videoList.responses.'200'.content.'application/json'.schema) {
throw 'Video create unexpectedly gained a response DTO; review F10 integration'
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')
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"')) {
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'
+5 -4
View File
@@ -78,7 +78,7 @@ const run = async () => {
toDataModuleUrl(`${prelude}\n${moduleBody}`),
);
const submit = (payload = {
feedbackType: "功能问题",
feedbackType: "bug",
feedbackContent: "上传照片时出现异常",
contactInfo: "user@example.com",
}) => appApi.submitFeedback(payload);
@@ -90,11 +90,12 @@ const run = async () => {
{ feedbackContent: " " },
{ feedbackContent: 123 },
{ feedbackContent: "内容", feedbackType: 1 },
{ feedbackContent: "内容", feedbackType: "feature" },
{ feedbackContent: "内容", contactInfo: false },
{ feedbackContent: "内容", type: "旧字段" },
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 } } };
@@ -103,11 +104,11 @@ const run = async () => {
assert.deepStrictEqual(requests.at(-1).data, { feedbackContent: "仅反馈内容" });
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(requests.at(-1).data, {
feedbackContent: "有效反馈",
feedbackType: "使用建议",
feedbackType: "advice",
});
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/feedback");
assert.strictEqual(requests.at(-1).method, "POST");
+46 -60
View File
@@ -43,30 +43,11 @@ const run = async () => {
},
};
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) => {
singleUploads.push(options);
queueMicrotask(() => options.success({
statusCode: 200,
data: JSON.stringify({ code: 200, data: { ossId: "900002", url: "https://oss.example/single.png", thumbnailUrl: "", fileName: "single.png" } }),
}));
return { abort() {} };
const task = { abort() {} };
uploads.push({ options, task });
queueMicrotask(() => options.success({ statusCode: 200, data: JSON.stringify({ code: 200, data: null }) }));
return task;
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
const basePayload = {
@@ -110,9 +91,46 @@ const run = async () => {
filePath: "/storage/emulated/0/avatar.png",
});
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.deepStrictEqual(uploads[0].upload.headers, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" });
assert.deepStrictEqual(uploads[0].upload.files, [{ filePath: "/storage/emulated/0/avatar.png", fileOptions: { key: "file" } }]);
assert.strictEqual(uploads[0].options.url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/chunk");
assert.strictEqual(uploads[0].options.filePath, "/storage/emulated/0/avatar.png");
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" } } };
assert.deepStrictEqual(await appApi.completeResumableUpload(completePayload), {
@@ -122,39 +140,6 @@ const run = async () => {
fileName: "avatar.png",
});
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(
appApi.initializeResumableUpload({ ...basePayload, totalSize: 0 }),
/totalSize/,
@@ -164,8 +149,9 @@ const run = async () => {
/chunkIndex/,
);
delete globalThis.plus;
delete globalThis.uni;
delete globalThis.fetch;
delete globalThis.FormData;
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 @(
'v-model="addForm.personNo"',
'v-model="addForm.aliasName"',
'v-model="addForm.generation"',
'v-model="addForm.generationName"',
'v-model="addForm.birthPlace"',
'v-model="addForm.deathPlace"',
'v-model="addForm.burialPlace"',
'v-model="addForm.biography"',
'v-model="addForm.remark"',
'v-model="addForm.sortOrder"',
'v-model="addForm.relationName"',
'personNo: addForm.personNo',
'sex: addForm.sex',
'avatarOssId: addForm.avatarOssId',
'personStatus: addForm.personStatus',
@@ -53,28 +48,24 @@ foreach ($required in @(
'generationName: addForm.generationName',
'biography: addForm.biography',
'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 lunarOptions = Object.freeze([',
'const personStatusOptions = Object.freeze([',
'personOptionLabels',
'appApi.getLineagePersonOptions(',
'const personOptionsRequestController = createRequestController();',
'requestController: personOptionsRequestController',
'const bindingModeOptions = Object.freeze([',
'bindingMode: addForm.bindingMode',
'addForm.bindingMode === "SPECIFIED"',
'pickAndUploadImage('
)) {
if (-not $t04.Contains($required)) { throw "T04 safe optional field missing: $required" }
}
foreach ($required in @(
'v-model="editForm.personNo"',
'originalMember.personNo ||',
'v-model="editForm.generation"',
'v-model="editForm.sortOrder"',
'v-model="editForm.relationName"',
'personNo: editForm.personNo',
'sex: editForm.sex',
'avatarOssId: editForm.avatarOssId',
'personStatus: editForm.personStatus',
@@ -89,11 +80,29 @@ foreach ($required in @(
'const bindingModeOptions = Object.freeze([',
'bindingMode: editForm.bindingMode',
'editForm.bindingMode === "SPECIFIED"',
'const isDeceased = computed(() => editForm.personStatus === "1")',
'v-if="isDeceased"',
'pickAndUploadImage('
)) {
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 @(
'v-model="addForm.appUserId"',
'v-model="addForm.fatherId"',
+3 -2
View File
@@ -2,9 +2,10 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$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 ($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'
+42 -47
View File
@@ -1,67 +1,62 @@
$ErrorActionPreference = 'Stop'
$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--error',
'@include adaptive.adaptive-profile-summary;',
'@include adaptive.adaptive-scroll-button(primary);',
'brand-seal.png',
'auth-divider-knot.png',
'chevron-right.png',
'root-header-hall.png',
'a01-icon-lock-v1.png',
'notice.png',
'm01-profile-archive-hero-v2.png',
'm01-profile-tree-medallion.png',
'class="profile-hero__art"',
'class="profile-hero__content"',
'class="profile-metadata"',
'class="profile-service-row"',
'grid-area: 1 / 1',
'height: 503rpx',
'height: 480rpx',
'white-space: nowrap',
'pointer-events: none',
'AppButton',
'import { openPage } from "@/utils/navigation.js";',
'AppTabbar',
'routeKey: "M03"',
'routeKey: "N01"',
'routeKey: "M06"',
'routeKey: "M10"',
'routeKey: "M08"',
'routeKey: "M09"',
'routeKey: "M10"',
'openPage("M02", {}, "M01")',
'openPage("N01", {}, "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') {
throw 'The adaptive profile owner must retain the M01 summary-card artwork'
foreach ($forbidden in @(
'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 -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 '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 -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'
+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 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(routePaths, registeredPaths, "路由路径集合和顺序必须与 pages.json 精确一致");
assert(Object.isFrozen(ROUTES), "ROUTES 必须冻结");
@@ -78,8 +78,8 @@ const fs = require("fs");
}
assert.deepStrictEqual(
Array.from(ROUTES.T03.resultOperations),
["member-open-requested"],
"T03 当前只能保留 single 页面激活实际使用的页内打开结果",
["member-open-requested", "member-updated"],
"T03 当前必须保留成员打开与编辑完成后的返回结果",
);
const expectedFamilyParams = {
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: "家谱用户",
realName: "王小明",
avatar: 900001,
sex: "",
sex: "0",
birthday: "1990-01-02",
email: "ming@example.com",
registerSource: "APP",
@@ -79,7 +79,7 @@ const run = async () => {
nickName: " 新昵称 ",
realName: "王小明",
avatar: 900001,
sex: "",
sex: "1",
birthday: "1991-02-03",
email: "new@example.com",
});
@@ -87,7 +87,7 @@ const run = async () => {
nickName: "新昵称",
realName: "王小明",
avatar: 900001,
sex: "",
sex: "1",
birthday: "1991-02-03",
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() {} }; } };
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
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.deepStrictEqual(requests.at(-1).data, { parentCode: "0" });
await assert.rejects(appApi.getRegionChildren(" "), /地区父级/);
responseData = [{ regionCode: "110000", label: "Beijing" }];
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(" "), /行政区划编码/);
assert.deepStrictEqual(
await appApi.searchRegions({ keyword: " Beijing ", level: 2, limit: 20 }),
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 });
await assert.rejects(appApi.searchRegions({ keyword: "" }), /行政区划搜索关键词/);
await assert.rejects(appApi.searchRegions({ keyword: "Beijing", level: 6 }), /行政区划级别/);
responseData = { regionCode: "110000", label: "Beijing" };
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;
process.stdout.write("REGION-API-RUNTIME-SMOKE PASS\n");
};
+5 -14
View File
@@ -31,20 +31,10 @@ const run = async () => {
success("/tmp/image.jpg");
},
},
io: {
resolveLocalFileSystemURL(_path, success) {
success({
file(done) {
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 } });
}
},
};
globalThis.uni = {
getFileInfo(options) {
options.success({ size: 4, digest: "a".repeat(32) });
},
};
assert.deepStrictEqual(await pickAndUploadImage(), {
@@ -54,6 +44,7 @@ const run = async () => {
fileName: "image.jpg",
});
delete globalThis.plus;
delete globalThis.uni;
delete globalThis.__appApi;
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);
});
+37 -44
View File
@@ -1,58 +1,51 @@
$ErrorActionPreference = 'Stop'
$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 @(
'import AppButton from "@/components/AppButton.vue"',
'<AppButton',
'type="secondary"',
'@click="toMember"',
't01-member-node-standard.png',
't01-member-node-selected.png',
't01-state-panel.png',
'openMemberPanel(member)',
'member-action-profile',
'v-for="connector in lineageConnectors"',
'lineage-pan-cue',
'const treeScrollLeft = ref(90)',
':scroll-left="treeState === ''tree'' ? treeScrollLeft : 0"',
':style="treeState === ''tree'' ? treeMetricsStyle : undefined"'
'@action="toTree"',
'class="pedigree-page"',
'class="pedigree-layout"',
'class="pedigree-scroll"',
'class="pedigree-sheet"',
'class="pedigree-column pedigree-column--legend"',
'class="member-node__relation"',
'class="member-node__name"',
'class="member-node__copy"',
'class="generation-band__arrow"',
'v-for="(page, pageIndex) in pedigreePages"',
'v-for="(member, memberIndex) in page.members"',
'class="pedigree-swiper"',
':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" }
}
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" }
if ($page -notmatch [regex]::Escape($required)) {
throw "T02 pedigree visual contract missing: $required"
}
}
foreach ($rule in @(
'(?s)\.tree-toolbar__title text:last-child\s*\{[^}]*font-size:\s*22rpx;',
'(?s)\.tree-toolbar__actions\s*\{[^}]*font-size:\s*23rpx;',
'(?s)\.generation-band\s*\{[^}]*font-size:\s*22rpx;',
'(?s)\.node-relation\s*\{[^}]*font-size:\s*21rpx;',
'(?s)\.node-years\s*\{[^}]*font-size:\s*20rpx;',
'(?s)\.tree-state-card__copy\s*\{[^}]*font-size:\s*24rpx;',
'(?s)\.member-action-profile__copy text:nth-child\(2\)\s*\{[^}]*font-size:\s*21rpx;',
'(?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;'
'(?s)\.pedigree-layout\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 92rpx;',
'(?s)\.pedigree-swiper\s*\{[^}]*width:\s*100%;[^}]*height:\s*100%;',
'(?s)\.pedigree-sheet\s*\{[^}]*display:\s*grid;[^}]*width:\s*100%;[^}]*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);',
'(?s)\.member-node\s*\{[^}]*grid-template-rows:\s*144rpx 274rpx minmax\(0, 1fr\);',
'(?s)\.member-node__name\s*\{[^}]*font-size:\s*34rpx;',
'(?s)\.member-node__copy\s*\{[^}]*writing-mode:\s*vertical-rl;',
'(?s)\.generation-band__copy\s*\{[^}]*writing-mode:\s*vertical-rl;'
)) {
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">') {
throw 'T01 horizontal reading cue must stay in the visible lineage viewport, not inside the wide canvas'
if ($page -match 'scroll-x|treeScrollLeft|openMemberPanel') {
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'
+24 -18
View File
@@ -1,11 +1,12 @@
$ErrorActionPreference = 'Stop'
$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
foreach ($required in @(
'class="member-node__avatar"',
'class="member-node__surface"',
'const memberActionPanelVisible = ref(false)',
'const memberActions = Object.freeze([',
'const openMemberAction = (action) =>',
@@ -14,35 +15,40 @@ foreach ($required in @(
'unavailableActionVisible',
'BIND_INVITE'
)) {
if (-not $page.Contains($required)) {
throw "T01 member action panel missing: $required"
if (-not $tree.Contains($required)) {
throw "T01 tree member action panel missing: $required"
}
}
foreach ($actionKey in @(
'VIEW_PROFILE',
'ADD_FATHER',
'ADD_MOTHER',
'ADD_SPOUSE',
'ADD_SIBLING',
'ADJUST_RANK',
'ADD_SON',
'ADD_DAUGHTER',
'BIND_INVITE',
'EDIT_PROFILE'
'VIEW_PROFILE', 'ADD_FATHER', 'ADD_MOTHER', 'ADD_SPOUSE', 'ADD_SIBLING',
'ADJUST_RANK', 'ADD_SON', 'ADD_DAUGHTER', 'BIND_INVITE', 'EDIT_PROFILE'
)) {
if (-not $page.Contains(('key: "' + $actionKey + '"'))) {
throw "T01 member action panel missing action: $actionKey"
if (-not $tree.Contains(('key: "' + $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 @(
'optionalParams: ["personId", "mode", "relationType"]',
'allowedSources: ["T01", "T03"]',
'allowedSources: ["T01", "T02", "T03"]',
'optionalParams: ["mode"]'
)) {
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'
$root = Split-Path -Parent $PSScriptRoot
$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 ($page -match 'maxX\s*=.*members\.value.*\.x|maxY\s*=.*members\.value.*\.y') { throw 'T01 canvas must not depend on API pixel coordinates' }
+54 -97
View File
@@ -5,79 +5,72 @@ function Assert-Contains {
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
$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
$pedigree = Get-Content -LiteralPath $t02Path -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 ($pages -match 'pages/tree/t02-tree-states') { throw 'T02 duplicate route must be removed from pages.json' }
if (-not (Test-Path -LiteralPath $t02Path)) { throw 'T02 pedigree route file must exist' }
if ($pages -notmatch 'pages/tree/t02-pedigree-overview') { throw 'T02 pedigree route must be registered in pages.json' }
foreach ($required in @(
'appApi.getTree',
'createRequestController',
'isRequestCancelled',
'treeRequestController.abort()',
'onShow(() =>',
'requestController: treeRequestController'
'requestController: treeRequestController',
'genealogyContext.isCurrentGenealogyInvalidated()',
'genealogyContext.setCurrentGenealogyId(genealogyId.value)'
)) { Assert-Contains $page $required "Missing T01 remote data contract: $required" }
foreach ($required in @(
'@action="toPedigree"',
'const treeState = ref("loading")',
'tree-state--tree',
'tree-state--landscape',
'tree-state--empty',
'tree-state--error',
'tree-canvas--state',
'lineage-connector',
'const generationRows = computed(',
'v-for="row in generationRows"',
':style="generationBandStyle(row)"',
'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}`"',
'v-for="member in layoutMembers"',
'class="member-node__portrait"',
'class="lineage-pan-cue"',
'scroll-x',
':scroll-left="treeState === ''tree'' ? treeScrollLeft : 0"',
'@scroll="handleTreeScroll"',
'const currentTreeScrollLeft = ref(90)',
'uni.createSelectorQuery()',
'nodeRect.left + nodeRect.width / 2 - (scrollRect.left + scrollRect.width / 2)',
'const treeHasDrifted = ref(false)',
':style="nodeGridStyle(member)"',
'const nodeGridStyle = (member)',
'class="generation-rail"',
'class="generation-band"',
'v-for="row in generationRows"',
':style="generationBandStyle(row)"',
'const treeMetrics = computed(',
':style="treeState === ''tree'' ? treeMetricsStyle : undefined"',
'const recenterSelectedMember = async () =>',
'class="tree-recenter"'
)) { Assert-Contains $page $required "Missing T01 contract: $required" }
'class="tree-recenter"',
'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')) {
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'
}
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"') {
throw 'T01 outer vertical tree owner must preserve the stage grid instead of wrapping its grid children'
}
foreach ($fixedCapacity in @(
'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)\.tree-stage--lineage\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*144rpx minmax\(0, 1fr\);') {
throw 'T01 generation rail must stay beside the tree canvas'
}
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'
+34 -15
View File
@@ -49,13 +49,13 @@ const waitFor = async (send, expression, message) => {
throw new Error(message)
}
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 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')
await send('Page.reload')
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `T01 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, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `T02 reload did not create a fresh document: ${query}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `T02 state did not render: ${selector}`)
}
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.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.nodeCount, 6, `T01 member nodes are incomplete at ${size.width}x${size.height}`)
assert(metrics.firstNodeRect.width > 80 && metrics.firstNodeRect.height > 30, `T01 member node collapsed at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
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 > 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.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 = []
for (let generation = 1; generation <= 10; generation += 1) {
@@ -122,7 +122,7 @@ const run = async () => {
return true
})()`)
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 canvas = document.querySelector('.tree-canvas')
const rail = document.querySelector('.generation-rail')
@@ -135,18 +135,37 @@ const run = async () => {
documentWidth: document.documentElement.scrollWidth
}
})()`)
assert(stressMetrics.canvasWidth > 1400, 'T01 canvas width did not grow from 12 members per generation')
assert(stressMetrics.canvasHeight > 1000, 'T01 canvas height did not grow from 10 generations')
assert(stressMetrics.canvasWidth > 250, 'T01 swipe sheet did not fill the available viewport')
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.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.documentWidth <= 413, 'T01 stress tree escaped its scroll owner')
await open(send, '?genealogyId=1001', '.tree-state--tree .member-node')
await valueOf(send, "document.querySelector('.member-node')?.click()")
await valueOf(send, "document.querySelector('.member-action-profile')?.click()")
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')
const swiperRect = await valueOf(send, `(() => {
const rect = document.querySelector('.pedigree-swiper').getBoundingClientRect()
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
})()`)
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 {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
+10 -2
View File
@@ -8,6 +8,7 @@ function Assert-Contains {
$root = Split-Path -Parent $PSScriptRoot
$pages = @{
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
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
@@ -29,13 +30,20 @@ foreach ($required in @(
'appApi.getTree',
'const memberActions = Object.freeze([',
'const openMemberAction = (action) =>',
'openPage("T03"',
'"T03",',
'routeKey: "T04"',
'routeKey: "T05"',
'routeKey: "T06"',
'return openPage(action.routeKey, params, "T01")'
)) { 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 @(
'appApi.getPerson(',
'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'
$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'
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)
})
const authPayload = (payload) => ({
clientId: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
...payload
})
// 已核对接口使用这一严格边界:只接受 HTTP 200 与完整 JSON envelope
// 并统一限制弱网等待时间。尚未逐页治理的旧业务读取仍沿用上方通用 request。
const requestStrict = (options, {
@@ -241,7 +235,7 @@ const parseStrictUploadResponse = (responseText, requireData) => {
}
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'))
return
}
@@ -254,6 +248,7 @@ const requestNativeFileChunk = ({ uploadId, chunkIndex, chunkMd5, filePath }, {
}
let settled = false
let task = null
let cancelled = false
const release = () => requestController?.release(abort)
const resolveOnce = (value) => {
if (settled) return
@@ -269,34 +264,56 @@ const requestNativeFileChunk = ({ uploadId, chunkIndex, chunkMd5, filePath }, {
}
const abort = () => {
if (settled) return
cancelled = true
task?.abort?.()
rejectOnce(createRequestCancelledError())
}
const query = `uploadId=${encodeURIComponent(uploadId)}&chunkIndex=${encodeURIComponent(chunkIndex)}&chunkMd5=${encodeURIComponent(chunkMd5)}`
const token = session.getToken()
try {
task = globalThis.plus.uploader.createUpload(
`${runtimeConfig.baseUrl}/genealogy/app/files/resumable/chunk?${query}`,
{ method: 'POST' },
(upload, status) => {
if (status !== 200) {
rejectOnce(createRequestError(`文件分片上传失败(HTTP ${status}`, 'HTTP_ERROR', { httpStatus: status }))
task = globalThis.uni.uploadFile({
url: `${runtimeConfig.baseUrl}/genealogy/app/files/resumable/chunk`,
filePath,
name: 'file',
formData: {
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
}
try {
parseStrictUploadResponse(upload.responseText, false)
parseStrictUploadResponse(upload.data, false)
resolveOnce(null)
} catch (error) {
rejectOnce(error)
}
},
fail: (error) => {
if (settled) return
if (cancelled) {
rejectOnce(createRequestCancelledError())
return
}
const message = typeof error?.errMsg === 'string' && error.errMsg
? error.errMsg
: '文件分片上传失败'
const timeout = /timeout/i.test(message)
rejectOnce(createRequestError(
timeout ? '文件分片上传超时' : message,
timeout ? 'REQUEST_TIMEOUT' : 'UPLOAD_FAILED'
))
}
)
task.setRequestHeader('clientid', runtimeConfig.clientId)
task.setRequestHeader('tenantId', runtimeConfig.tenantId)
const token = session.getToken()
if (token) task.setRequestHeader('Authorization', `Bearer ${token}`)
task.addFile(filePath, { key: 'file' })
})
if (requestController) requestController.bind(abort)
task.start()
} catch (error) {
rejectOnce(error)
}
@@ -315,17 +332,25 @@ const requestBrowserFileChunk = ({ uploadId, chunkIndex, chunkMd5, file }, { req
return
}
let settled = false
let timeoutId = null
const abortController = new AbortController()
const release = () => requestController?.release(abort)
const clearRequestTimeout = () => {
if (timeoutId === null) return
clearTimeout(timeoutId)
timeoutId = null
}
const resolveOnce = (value) => {
if (settled) return
settled = true
clearRequestTimeout()
release()
resolve(value)
}
const rejectOnce = (error) => {
if (settled) return
settled = true
clearRequestTimeout()
release()
reject(error)
}
@@ -334,12 +359,18 @@ const requestBrowserFileChunk = ({ uploadId, chunkIndex, chunkMd5, file }, { req
abortController.abort()
rejectOnce(createRequestCancelledError())
}
const query = `uploadId=${encodeURIComponent(uploadId)}&chunkIndex=${encodeURIComponent(chunkIndex)}&chunkMd5=${encodeURIComponent(chunkMd5)}`
const token = session.getToken()
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')
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',
headers: {
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 = () => {
if (resolveRuntimeMode() !== 'remote') {
const error = new Error('当前为本地预览模式,真实认证服务未启用')
@@ -1095,21 +1065,18 @@ const normalizeLineageText = (value, label, { required = false } = {}) => {
const lineageDatePart = (value, label) => {
const normalized = normalizeLineageText(value, label)
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
const instant = new Date(normalized)
if (Number.isNaN(instant.getTime())) throw lineageTreeError(`世系树${label}无效`)
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(instant)
.filter((part) => part.type !== 'literal')
.map((part) => [part.type, part.value])
)
return `${parts.year}-${parts.month}-${parts.day}`
const chinaTime = new Date(instant.getTime() + 8 * 60 * 60 * 1000)
const year = chinaTime.getUTCFullYear()
const month = String(chinaTime.getUTCMonth() + 1).padStart(2, '0')
const day = String(chinaTime.getUTCDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const normalizeLineageTree = (value) => {
@@ -1870,7 +1837,7 @@ const normalizePhoneChangePayload = (payload) => {
if (typeof payload.phone !== 'string' || !/^1\d{10}$/.test(payload.phone.trim())) {
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) => {
@@ -1898,36 +1865,23 @@ const normalizeVipOrderPayload = (payload) => {
return data
}
const normalizeFileReferencePayload = (payload) => {
assertPlainPayload(payload, new Set(['bizType', 'bizName', 'bizTable', 'bizId', 'bizField', 'ossId', 'ossIds', 'usageScene', 'usageName']), '文件引用请求')
const normalizeSiteArticleQuery = (payload = {}) => {
assertPlainPayload(payload, new Set(['articleType', 'limit']), '官网文章查询')
const data = {}
for (const field of ['bizType', 'bizName', 'bizTable', 'bizField', 'usageScene', 'usageName']) {
const value = normalizeOptionalAuthText(payload[field], field)
if (value) data[field] = value
const articleType = normalizeOptionalAuthText(payload.articleType, '官网文章类型')
if (articleType) data.articleType = articleType
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
}
const normalizeFileReferenceReleasePayload = (payload) => {
assertPlainPayload(payload, new Set(['bizTable', 'bizId', 'bizField']), '文件引用释放请求')
const bizTable = normalizeOptionalAuthText(payload.bizTable, 'bizTable')
const bizField = normalizeOptionalAuthText(payload.bizField, 'bizField')
if (!bizTable || !bizField) throw new TypeError('文件引用释放缺少 bizTable 或 bizField')
return {
bizTable,
bizId: normalizeResourcePathId(payload.bizId, '业务标识'),
bizField
}
const normalizeSitePageKey = (pageKey) => {
const normalized = normalizeOptionalAuthText(pageKey, '官网页面标识')
if (!normalized) throw new TypeError('官网页面标识不能为空')
return normalized
}
const normalizeJoinApplicationPayload = (payload) => {
@@ -2252,20 +2206,6 @@ export const appApi = {
}
}, 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 = {}) {
requireRemoteAuth()
const normalizedValidToken = normalizeOptionalValidToken(validToken)
@@ -2287,7 +2227,7 @@ export const appApi = {
const result = await requestAuth({
url: '/genealogy/app/auth/login/sms',
method: 'POST',
data: authPayload({ grantType: 'sms', phone, smsCode: assertSmsCode(smsCode) })
data: { tenantId: runtimeConfig.tenantId, grantType: 'sms', phone, smsCode: assertSmsCode(smsCode) }
}, requestOptions)
return saveLogin(result)
},
@@ -2297,13 +2237,14 @@ export const appApi = {
const result = await requestAuth({
url: '/genealogy/app/auth/register',
method: 'POST',
data: authPayload({
data: {
tenantId: runtimeConfig.tenantId,
grantType: 'password',
phone,
password: assertPasswordHash(passwordHash),
smsCode: assertSmsCode(smsCode),
...(normalizedNickName ? { nickName: normalizedNickName } : {})
})
}
}, requestOptions)
return saveLogin(result)
},
@@ -2312,12 +2253,13 @@ export const appApi = {
return requestAuthVoid({
url: '/genealogy/app/auth/password/reset',
method: 'PUT',
data: authPayload({
data: {
tenantId: runtimeConfig.tenantId,
grantType: 'password',
phone,
newPassword: assertPasswordHash(passwordHash),
smsCode: assertSmsCode(smsCode)
})
}
}, requestOptions)
},
async changePassword({ oldPasswordHash, newPasswordHash }, requestOptions = {}) {
@@ -2340,7 +2282,7 @@ export const appApi = {
await requestStrict({
url: '/genealogy/app/auth/account/deactivate',
method: 'POST',
data: { clientId: runtimeConfig.clientId, smsCode: assertSmsCode(smsCode) }
data: { smsCode: assertSmsCode(smsCode) }
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
@@ -2477,27 +2419,6 @@ export const appApi = {
})
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 = {}) {
if (!hasRemoteConfig()) {
const error = new Error('地区选择需要真实读取服务,当前本地预览不会伪造地区列表')
@@ -2505,7 +2426,7 @@ export const appApi = {
throw error
}
const result = await requestStrict({
url: '/genealogy/region/children',
url: '/genealogy/app/region/children',
method: 'GET',
data: { parentCode: normalizeRegionParentCode(parentCode) }
}, {
@@ -2516,7 +2437,7 @@ export const appApi = {
async getRegionPath(regionCode, requestOptions = {}) {
requireRemoteResource('行政区划路径', '读取')
const result = await requestStrict({
url: `/genealogy/region/path/${encodeURIComponent(normalizeRegionCode(regionCode))}`,
url: `/genealogy/app/region/path/${encodeURIComponent(normalizeRegionCode(regionCode))}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
@@ -2529,7 +2450,7 @@ export const appApi = {
async searchRegions(payload, requestOptions = {}) {
requireRemoteResource('行政区划搜索', '读取')
const result = await requestStrict({
url: '/genealogy/region/search',
url: '/genealogy/app/region/search',
method: 'GET',
data: normalizeRegionSearchPayload(payload)
}, {
@@ -2543,7 +2464,7 @@ export const appApi = {
async getRegion(regionCode, requestOptions = {}) {
requireRemoteResource('行政区划详情', '读取')
const result = await requestStrict({
url: `/genealogy/region/${encodeURIComponent(normalizeRegionCode(regionCode))}`,
url: `/genealogy/app/region/${encodeURIComponent(normalizeRegionCode(regionCode))}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
@@ -3444,11 +3365,15 @@ export const appApi = {
},
async changePhone(payload, requestOptions = {}) {
requireRemoteAuth()
return requestAuthVoid({
await requestStrict({
url: '/genealogy/app/auth/phone',
method: 'PUT',
data: normalizePhoneChangePayload(payload)
}, requestOptions)
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async getHelpArticles(requestOptions = {}) {
return readRemoteList('/genealogy/app/help-articles', '帮助文章', requestOptions)
@@ -3504,10 +3429,32 @@ export const appApi = {
const id = normalizeGenealogyPathId(genealogyId)
return readRemoteList(`/genealogy/app/genealogies/${id}/lineage/persons/options`, '世系人物选项', requestOptions)
},
async getContent(type, genealogyId, requestOptions = {}) {
const loaders = { article: this.getArticles, album: this.getAlbums, ceremony: this.getCeremonies, record: this.getGrowthRecords }
if (!loaders[type]) throw new Error('未知内容类型')
return loaders[type].call(this, genealogyId, requestOptions)
async getSiteArticles(payload = {}, requestOptions = {}) {
const result = await requestStrict({
url: '/genealogy/app/site/articles',
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 = {}) {
if (!hasRemoteConfig()) return listNotificationFixtures()
+2
View File
@@ -3,6 +3,8 @@ export const AUTH_VERIFICATION_OPERATION = Object.freeze({
SMS_LOGIN: "sms-login",
REGISTER: "register",
FORGOT_PASSWORD: "forgot-password",
PHONE_CHANGE: "phone-change",
ACCOUNT_DEACTIVATE: "account-deactivate",
});
const SUPPORTED_TAC_TYPES = new Set([
+15 -7
View File
@@ -106,7 +106,15 @@ export const ROUTES = Object.freeze({
parent: "G05",
requiredParams: ["genealogyId"],
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({
path: "/pages/tree/t03-member-profile",
@@ -114,8 +122,8 @@ export const ROUTES = Object.freeze({
parent: "T01",
parentParamMap: { selectedId: "personId" },
requiredParams: ["genealogyId", "personId"],
allowedSources: ["T01", "T07", "R02"],
resultOperations: ["member-open-requested"],
allowedSources: ["T01", "T02", "T07", "R02"],
resultOperations: ["member-open-requested", "member-updated"],
}),
T04: defineRoute({
path: "/pages/tree/t04-add-relative",
@@ -124,14 +132,14 @@ export const ROUTES = Object.freeze({
parentParamMap: { selectedId: "personId" },
requiredParams: ["genealogyId"],
optionalParams: ["personId", "mode", "relationType"],
allowedSources: ["T01"],
allowedSources: ["T01", "T02"],
}),
T05: defineRoute({
path: "/pages/tree/t05-edit-member",
kind: "flow",
parent: "T03",
requiredParams: ["genealogyId", "personId"],
allowedSources: ["T01", "T03"],
allowedSources: ["T01", "T02", "T03"],
}),
T06: defineRoute({
path: "/pages/tree/t06-edit-relationship",
@@ -140,14 +148,14 @@ export const ROUTES = Object.freeze({
parentParamMap: { selectedId: "personId" },
requiredParams: ["genealogyId", "personId"],
optionalParams: ["mode"],
allowedSources: ["T01"],
allowedSources: ["T01", "T02"],
}),
T07: defineRoute({
path: "/pages/tree/t07-member-directory",
kind: "page",
parent: "T01",
requiredParams: ["genealogyId"],
allowedSources: ["T01"],
allowedSources: ["T01", "T02"],
}),
T08: defineRoute({
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) => {
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) {
reject(createUploadError("当前运行环境不支持读取图片", "IMAGE_READ_UNAVAILABLE"));
return;
@@ -202,7 +223,7 @@ export const pickAndUploadImage = async ({ requestController = null } = {}) => {
if (!Number.isSafeInteger(file.size) || file.size <= 0) {
throw createUploadError("所选图片大小无效", "IMAGE_SIZE_INVALID");
}
const fileMd5 = calcMD5Bytes(file.data);
const fileMd5 = file.md5 || calcMD5Bytes(file.data);
const initPayload = {
uploadId: createUploadId(),
fileName: file.fileName,
@@ -245,7 +266,7 @@ export const pickAndUploadVideo = async ({ requestController = null } = {}) => {
if (!Number.isSafeInteger(file.size) || file.size <= 0) {
throw createUploadError("所选视频大小无效", "VIDEO_SIZE_INVALID");
}
const fileMd5 = calcMD5Bytes(file.data);
const fileMd5 = file.md5 || calcMD5Bytes(file.data);
const initPayload = {
uploadId: createUploadId(),
fileName,