修改未完成
@@ -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 × 1280(360 × 640 CSS px,device 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 px,MuMu Android;360 × 640 CSS px,density 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/state:Android 模拟器 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
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "jiapuapp",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"yaml": "^2.8.1"
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,13 @@
|
||||
"disableScroll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/tree/t02-pedigree-overview",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"disableScroll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/tree/t03-member-profile",
|
||||
"style": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
@@ -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(
|
||||
|
||||
@@ -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 的唯一枚举拥有",
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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' }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||