完成70%

This commit is contained in:
2026-07-24 07:56:22 +08:00
parent bb6431b319
commit c7f278fe79
92 changed files with 4741 additions and 14440 deletions
+8 -219
View File
@@ -2,59 +2,20 @@
<view
v-show="visible"
class="tac-layer"
:class="{ 'tac-layer--visible': visible }"
:aria-hidden="visible ? 'false' : 'true'"
@click.stop="requestCancel"
@click.stop
>
<view
id="jiapu-tac-dialog"
class="tac-panel"
role="dialog"
aria-modal="true"
tabindex="-1"
aria-labelledby="jiapu-tac-title"
aria-describedby="jiapu-tac-description"
@click.stop
@keydown.esc.stop.prevent="requestCancel"
>
<view class="tac-heading">
<view class="tac-heading__copy">
<text id="jiapu-tac-title" class="tac-title">安全验证</text>
<text id="jiapu-tac-description" class="tac-description"
>拖动滑块完成验证可刷新当前挑战或关闭返回</text
>
</view>
<view class="tac-tools" role="group" aria-label="安全验证操作">
<button
class="tac-tool tac-tool--refresh"
aria-label="刷新安全验证"
@click.stop="requestRefresh"
></button>
<button
class="tac-tool tac-tool--close"
aria-label="关闭安全验证"
@click.stop="requestCancel"
>×</button>
</view>
</view>
<view
id="jiapu-tac-host"
class="tac-host"
:prop="renderContext"
:change:prop="tacRenderer.onContextChange"
/>
</view>
id="jiapu-tac-host"
:prop="renderContext"
:change:prop="tacRenderer.onContextChange"
/>
</view>
</template>
<script>
export default {
name: "TacVerification",
data() {
return {
refreshSequence: 0,
};
},
props: {
visible: {
type: Boolean,
@@ -71,17 +32,10 @@ export default {
return {
...(this.context || {}),
visible: this.visible,
refreshSequence: this.refreshSequence,
};
},
},
methods: {
requestCancel() {
if (this.visible) this.$emit("cancel");
},
requestRefresh() {
if (this.visible) this.refreshSequence += 1;
},
handleTacSuccess(payload) {
this.$emit("success", payload);
},
@@ -186,19 +140,14 @@ export default {
activeXhr: null,
fatalSent: false,
completionSent: false,
previousFocus: null,
focusTrapTarget: null,
focusTrapHandler: null,
};
},
methods: {
async onContextChange(nextContext) {
const wasVisible = Boolean(this.context && this.context.visible === true);
const nextVisible = Boolean(nextContext && nextContext.visible === true);
this.generation += 1;
const generation = this.generation;
if (nextVisible && !wasVisible) this.capturePreviousFocus();
this.destroyTac(!nextVisible);
this.destroyTac();
this.context = nextContext || null;
this.challenge = null;
this.fatalSent = false;
@@ -283,19 +232,8 @@ export default {
preRequest: (type, request) => this.beforeRequest(type, request),
postRequest: (type, request, response) => this.afterRequest(type, response),
});
this.tac = new window.TAC(config, {
logoUrl: null,
i18n: {
tips_success: "验证成功",
tips_error: "验证失败,请重新尝试",
slider_title: "拖动滑块完成安全验证",
rotate_title: "拖动滑块完成安全验证",
concat_title: "拖动滑块完成拼图",
image_click_title: "请依次点击",
},
});
this.tac = new window.TAC(config);
this.tac.init();
this.activateFocusTrap();
},
beforeRequest(type, request) {
if (type === "requestCaptchaData") {
@@ -392,60 +330,12 @@ export default {
message: (error && error.message) || "安全验证暂不可用",
});
},
capturePreviousFocus() {
const previousFocus = document.activeElement;
this.previousFocus = previousFocus && typeof previousFocus.focus === "function"
? previousFocus
: null;
},
activateFocusTrap() {
this.deactivateFocusTrap(false);
const panel = document.querySelector("#jiapu-tac-dialog");
if (!panel) return;
const getFocusable = () => Array.from(panel.querySelectorAll(
'button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])',
)).filter((element) => element.offsetParent !== null);
this.focusTrapHandler = (event) => {
if (event.key !== "Tab") return;
const focusable = getFocusable();
if (focusable.length === 0) {
event.preventDefault();
panel.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
this.focusTrapTarget = panel;
panel.addEventListener("keydown", this.focusTrapHandler);
const initialFocus = panel.querySelector(".tac-tool--refresh") || panel;
setTimeout(() => initialFocus.focus?.(), 0);
},
deactivateFocusTrap(restoreFocus = false) {
if (this.focusTrapTarget && this.focusTrapHandler) {
this.focusTrapTarget.removeEventListener("keydown", this.focusTrapHandler);
}
this.focusTrapTarget = null;
this.focusTrapHandler = null;
if (!restoreFocus) return;
const previousFocus = this.previousFocus;
this.previousFocus = null;
setTimeout(() => previousFocus?.focus?.(), 0);
},
destroyTac(restoreFocus = true) {
destroyTac() {
this.abortActiveRequest();
if (this.tac) this.tac.destroyWindow();
this.tac = null;
const host = document.querySelector("#jiapu-tac-host");
if (host) host.innerHTML = "";
this.deactivateFocusTrap(restoreFocus);
},
},
};
@@ -459,106 +349,5 @@ export default {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 32rpx;
background: rgba(34, 19, 12, 0.62);
opacity: 0;
pointer-events: none;
transition: opacity 160ms ease;
}
.tac-layer--visible {
opacity: 1;
pointer-events: auto;
}
.tac-panel {
box-sizing: border-box;
width: 318px;
max-width: 100%;
max-height: calc(var(--app-viewport-height) - 2px);
min-height: 318px;
border: 2rpx solid rgba(117, 25, 19, 0.48);
border-radius: 12rpx;
background: #f7f0e5;
box-shadow: 0 16rpx 52rpx rgba(39, 18, 10, 0.28);
overflow-y: auto;
}
.tac-heading {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
padding: 6px 4px 6px 12px;
border-bottom: 1px solid rgba(117, 25, 19, 0.18);
}
.tac-heading__copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.tac-title {
color: #8f160f;
font-size: 16px;
font-weight: 700;
}
.tac-description {
margin-top: 2px;
color: #5c4330;
font-size: 12px;
line-height: 1.35;
}
.tac-tools {
display: flex;
flex: 0 0 auto;
gap: 2px;
}
.tac-tool {
display: inline-flex;
min-width: 48px;
min-height: 48px;
align-items: center;
justify-content: center;
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: #8f160f;
font-size: 25px;
line-height: 1;
}
.tac-tool::after {
border: 0;
}
:deep(.slider-bottom .close-btn),
:deep(.slider-bottom .refresh-btn) {
display: none !important;
}
.tac-host {
width: 318px;
max-width: 100%;
min-height: 318px;
}
@media (max-width: 340px) {
.tac-layer {
padding: 1px;
}
.tac-panel {
border: 0;
}
}
</style>
@@ -49,7 +49,6 @@
{ "id": "app-modules-records-transparent-module-content-frame", "output": "static/assets/modules/records/transparent/module-content-frame.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-records-transparent-module-field-frame", "output": "static/assets/modules/records/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-records-transparent-r01-person-name-card", "output": "static/assets/modules/records/transparent/r01-person-name-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-t01-member-drawer", "output": "static/assets/modules/tree/transparent/t01-member-drawer.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-t01-member-node-selected", "output": "static/assets/modules/tree/transparent/t01-member-node-selected.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-t01-member-node-standard", "output": "static/assets/modules/tree/transparent/t01-member-node-standard.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-t01-state-panel", "output": "static/assets/modules/tree/transparent/t01-state-panel.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
@@ -0,0 +1,221 @@
# Apifox 逐页业务接口与页面展示核对台账
> 权威取证顺序:用户已打开的 Apifox 桌面端文档页 → 同一部署的脱敏只读响应(仅在获准时)→ 导出文档交叉核验。不得以导出文档缺项否定 Apifox 中已存在的 operation,也不得以 operation 存在推定页面已经完成。
>
> 记录规则:每一页必须同时给出业务动作、请求合同、响应字段、页面展示字段和完成状态。`已发布`是 Apifox 文档状态,不是客户端完成状态;`DECLARED_UNVERIFIED` 不得写成完成。
## F 家族内容
### F01 家族动态列表
| 核对项 | Apifox 桌面端实读 | 当前页面实情 | 结论 |
| --- | --- | --- | --- |
| 读取列表 | `GET /genealogy/app/genealogies/{genealogyId}/feeds`;鉴权 `Authorization`;路径 `genealogyId:int64` 必填;Header `clientid:string` 必填 | 页面已删除 `listFamilyFeedFixtures`,不再调用缺 DTO 的列表响应来填充动态卡片 | 读取 owner 存在但展示未接线,等待可消费条目 DTO |
| 响应字段 | `200 ListResult` 仅实读到通用 `code``msg``data[]``data` 元素未声明动态 DTO 字段 | 页面不再展示 `id/tag/time/title/content/author` 等本地字段;只提示缺失的字段合同 | 不能建立真实字段映射;不得猜测字段名 |
| 页面状态 | 进入发布页与跨模块入口均保留 | 有效家谱下明确显示“动态列表待后端字段合同” | **未完成 / DECLARED_UNVERIFIED**:待 Apifox 补充动态条目 DTO,或在获准的登录只读窗口取得脱敏真实响应后再恢复列表与详情入口 |
### F02 发布家族动态
| 核对项 | Apifox 桌面端实读 | 当前页面实情 | 结论 |
| --- | --- | --- | --- |
| 发布动作 | `POST /genealogy/app/genealogies/{genealogyId}/feeds`;鉴权 `Authorization`;路径 `genealogyId:int64`、Header `clientid:string` 均必填 | `appApi.createFeed` 使用严格请求和离页取消;页面不再生成本地预览 | 已接线,等待真实写入响应核验 |
| 请求体 | `application/json``feedContent:string` 必填且不能为空;`feedType:string` 可选,未传默认 `text``mediaOssIds:string` 可选,多个 OSS ID 用英文逗号分隔;`sortOrder:int64` 可选,未传默认 `0``status:string` 可选,未传默认正常状态 `0` | 表单只采集并发送 `feedContent`;可选的媒体、排序、状态没有可用输入/owner,故不发送 | 页面只消费可明确映射的文本动态请求子集 |
| 响应字段 | `200 ObjectResult` 只声明通用 `code``msg``data:object`,未声明新动态 DTO | 仅在严格成功信封后显示“已提交服务端”;不会在 F01 生成本地列表项 | **未完成 / DECLARED_UNVERIFIED**:接线不等于已验证;真实写入保留人工可观察窗口 |
### F03 动态详情与评论
| 核对项 | Apifox 桌面端实读 | 当前页面实情 | 结论 |
| --- | --- | --- | --- |
| 动态详情 | `GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}`;鉴权 `Authorization``genealogyId:int64``feedId:int64``clientid:string` 必填 | 页面已删除 `findFamilyFeedFixture`;因详情响应仍未声明动态本体 DTO,不读取并展示猜测字段 | 正文展示仍未接线,等待可消费响应字段 |
| 详情响应 | `200 ObjectResult` 只有通用 `code``msg``data:object`,没有动态本体 DTO | 页面不展示 `tag/time/title/content/author` 等正文 fixture 字段,只显示字段合同缺口 | 动态本体字段仍不能映射,不能猜测 |
| 一级评论读取 | `GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments`;同样要求鉴权、两个路径 ID 和 `clientid`。接口说明:仅返回正常展示的一级评论,`replyCount` 为直属回复数 | `appApi.getFeedComments` 真实读取;无远端配置或响应不合同时显示错误,不回退 fixture | 已接线,等待真实响应核验 |
| 评论响应字段 | `data: FamilyFeedCommentView[]``commentId``genealogyId``feedId``parentCommentId``appUserId``appUserNickName``appUserAvatar``parentAppUserId``parentAppUserNickName``commentContent``userDeleted``replyCount``commentLevel``status``createTime` | 展示 `id ← commentId``author ← appUserNickName``time ← createTime``content ← commentContent``replyCount ← replyCount`;归属 ID 与重复 ID 在 API 边界校验 | 已接线,等待真实响应字段核验 |
| 发表评论 | `POST /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments`;请求体 `parentCommentId:int64|null` 可选(不传或 `null` 为一级评论)、`commentContent:string` 必填,最大 1000 字符 | 提交 `commentContent`,限制 1000 字;仅在服务端请求成功并刷新评论列表后提示提交成功 | **未完成 / DECLARED_UNVERIFIED**:接口调用已接线,但未在无人值守时发起写入,也没有真实成功响应证据;动态本体仍缺 DTO |
### F04—F06 谱文列表、详情与编辑
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
| --- | --- | --- | --- |
| F04 列表 | `GET /genealogy/app/genealogies/{genealogyId}/articles`;鉴权、`genealogyId:int64``clientid:string` 必填;`200 ListResult` 仅通用 `code/msg/data[]`,条目未声明 DTO | 已删除 fixture、分类和本地搜索;页面明确提示缺失文章 ID、分类、标题、摘要、作者和更新时间投影 | **未完成 / DECLARED_UNVERIFIED**:没有可审计的条目字段映射,不能接线或把本地筛选误称服务端能力 |
| F05 详情 | `GET /genealogy/app/genealogies/{genealogyId}/articles/{articleId}`;鉴权、`genealogyId:int64``articleId:int64``clientid:string` 必填;`200 ObjectResult` 仅通用对象 DTO | 已删除 fixture 正文与编辑跳转;只显示正文 DTO 缺口 | **未完成 / DECLARED_UNVERIFIED**:正文、作者、时间投影均未由详情响应声明 |
| F06 新建 | `POST /genealogy/app/genealogies/{genealogyId}/articles`;鉴权、`genealogyId:int64``clientid:string` 必填 | `appApi.createArticle` 严格提交 `articleTitle/articleContent`;成功仅表示服务端成功信封,不生成本地文章 | 已接线,等待真实写入响应核验 |
| F06 修改 | `PUT /genealogy/app/genealogies/{genealogyId}/articles/{articleId}`;鉴权、两个路径 ID、`clientid:string` 必填 | 已移除 fixture 编辑预填;没有可靠详情 DTO 和文章 ID 列表来源时,编辑入口关闭 | **未完成 / DECLARED_UNVERIFIED** |
| 新建/修改请求体 | `categoryId:int64` 可选;`articleTitle:string` 必填;`articleSummary:string` 可选;`coverOssId:int64` 可选;`articleContent:string` 必填;`authorName:string``sortOrder:int64``status:string` 均可选。返回均为通用 `ObjectResult` | 新建页只收集并发送 `articleTitle/articleContent``categoryId`、摘要、封面、作者、排序、状态没有来源,故不发送 | 已实现请求字段的安全子集;编辑仍需可靠 articleId/详情 owner,所有写入待人工真实响应核验 |
### F07—F09 相册、照片墙与上传
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
| --- | --- | --- | --- |
| F07 相册列表 | `GET /genealogy/app/genealogies/{genealogyId}/albums`;鉴权、`genealogyId:int64``clientid:string` 必填;`200 ListResult` 仅通用数组 DTO | 已删除 fixture 相册卡片和本地预览,页面明确提示缺失相册 ID、封面、名称、照片数、描述和更新时间字段 | **未完成 / DECLARED_UNVERIFIED**:相册条目、封面 URL、照片数和更新时间没有响应字段来源 |
| F07 新建相册 | `POST /genealogy/app/genealogies/{genealogyId}/albums`;请求体 `albumName:string` 必填,`albumDesc:string``coverOssId:int64``sortOrder:int64``status:string` 可选;返回通用 `ObjectResult` | `appApi.createAlbum` 严格提交 `albumName`;成功仅表示服务端成功信封,不生成本地相册卡片 | 已接线,等待真实写入响应核验;描述、封面、排序、状态无输入来源,故不发送 |
| F08 照片墙读取 | `GET /genealogy/app/genealogies/{genealogyId}/albums/{albumId}/photos`;鉴权、`genealogyId:int64``albumId:int64``clientid:string` 必填;`200 ListResult` 仅通用数组 DTO | 已删除 fixture 相册和照片墙,只显示缺失照片展示字段的状态 | **未完成 / DECLARED_UNVERIFIED**:缺相册与照片展示 DTO,不能猜 OSS URL、标题或说明字段 |
| F09 写入照片记录 | `POST /genealogy/app/genealogies/{genealogyId}/albums/{albumId}/photos`;路径两个 ID、鉴权、`clientid` 必填;`ossId:int64` 必填,`photoTitle/photoDesc/photographer/shootTime/sortOrder/status` 可选 | 已删除 mock 图片库、说明表单和本地预览 | **未完成 / BLOCKED_BY_MEDIA_OWNER**:接口接受的是既有 `ossId`,当前页面没有已核实的文件上传 owner 和真实 OSS 回执,不能把本地图片冒充上传成功 |
### F10 短视频
| 核对项 | Apifox 桌面端实读 | 当前页面/结论 |
| --- | --- | --- |
| 目录检索 | 以 `video` 检索,APP 目录仅返回“删除视频”;未返回视频列表、详情、发布、修改、播放地址、评论、点赞或分享 operation | F10 所需浏览和互动链路没有业务 owner,不能以参考项目或相册接口补造 |
| 唯一命中动作 | `DELETE /genealogy/app/genealogies/{genealogyId}/videos/{videoId}`;接口说明为逻辑删除并释放视频文件和封面文件引用;鉴权、`genealogyId:int64``videoId:int64``clientid:string` 必填,`200 VoidResult` | 单一删除动作不能证明视频页面能读取、播放或发布;**F10 未完成 / MISSING_OPERATION**。不发起删除请求 |
## G 家谱工作区
### G01、G03、G05—G11
| 页面/动作 | Apifox 桌面端实读 | 当前页面状态与结论 |
| --- | --- | --- |
| G01 我的家谱 | `GET /genealogy/app/genealogies/mine`;鉴权、`clientid:string` 必填;`200 ListResult` 仅通用 `code/msg/data[]` | 页面要展示当前家谱、可切换家谱、角色与快捷入口;当前 DTO 没有这些字段。**未完成 / DECLARED_UNVERIFIED** |
| G03 创建家谱 | `POST /genealogy/app/genealogies``genealogyName``surname``regionCode` 必填;`ancestralHall/originPlace/addressDetail/coverOssId/intro/visibility/joinMode` 可选。`visibility``0` 私密、`1` 公开、`2` 成员可见;`joinMode``0` 关闭、`1` 审核、`2` 邀请码 | 已删除本地家谱/首位人物预览。创建后必须从真实响应取得 `genealogyId` 再创建首位人物;当前没有可恢复查询 owner,不能从泛型 mine 列表按名称猜 ID | **未完成 / MISSING_OPERATION**:两阶段创建结果恢复链未闭合,且封面 `coverOssId` 仍缺上传 owner |
| G05 家谱概览 | `GET /genealogy/app/genealogies/{genealogyId}/overview`;鉴权、`genealogyId:int64``clientid` 必填;`200 ObjectResult` 通用对象 | 页面需要家谱资料、成员/人物等概览显示;响应无 DTO。**未完成 / DECLARED_UNVERIFIED** |
| G06 搜索公开家谱 | `GET /genealogy/app/genealogies/public` 已在 Apifox 目录确认;读取结果仍为通用 `ListResult` | 已删除本地搜索结果和申请跳转;名称、籍贯、简介、可加入状态、稳定 genealogyId 均未获声明。**未完成 / DECLARED_UNVERIFIED** |
| G08 申请加入 | `POST /genealogy/app/genealogies/{genealogyId}/join-applies`;路径 `genealogyId:int64`、鉴权、`clientid` 必填;body `applicantName/phone/relationDesc/applyReason:string``inviterUserId:int64` 均可选 | 已删除本地填写预览;没有公开家谱详情、可申请权限或稳定 ID 投影时,不凭“可选”字段虚构申请上下文。**未完成 / DECLARED_UNVERIFIED**,不发送申请 |
| G09 我的申请 | `GET /genealogy/app/genealogies/join-applies/mine`;鉴权、`clientid` 必填;`200 ListResult` 通用数组 DTO | 已删除 fixture 申请列表;申请名称、状态、原因、时间等展示字段无映射。**未完成 / DECLARED_UNVERIFIED** |
| G10 审核申请 | `PUT /genealogy/app/genealogies/{genealogyId}/join-applies/{applyId}/audit`;路径两个 ID、鉴权、`clientid` 必填;`status:string` 必填,`auditRemark:string` 可选,返回 `VoidResult` | 已删除本地审核流程;待审核列表 DTO 和稳定 applyId 未声明。**未完成 / DECLARED_UNVERIFIED**,不执行审核写入 |
| G11 家谱设置 | `PUT /genealogy/app/genealogies/{genealogyId}`;同一组字段为 `genealogyName/surname/ancestralHall/originPlace/regionCode/addressDetail/coverOssId/intro/visibility/joinMode`,文档均列可选 | 已删除 fixture 预填和本地预览;概览 DTO 不能安全预填,封面仍受上传 owner 阻塞。**未完成 / DECLARED_UNVERIFIED** |
### G12 字辈谱
| 核对项 | Apifox 桌面端实读 | 当前页面实情 | 结论 |
| --- | --- | --- | --- |
| 正常字辈读取 | `GET /genealogy/app/genealogies/{genealogyId}/generation-poems`;鉴权 `Authorization`、路径 `genealogyId:int64`、Header `clientid:string` 均必填。说明为仅返回正常状态字辈,供世系人物录入和展示使用 | 页面通过 `appApi.getGenerationPoems` 真实读取,展示 `generationNo/generationText/status`;响应归属、重复 poemId 和重复世代在 API 边界校验 | 已接线,等待真实响应核验;响应没有“当前世代”字段,页面已删除固定当前世代推断 |
| 维护列表读取 | `GET /genealogy/app/genealogies/{genealogyId}/generation-poems/management`;同一鉴权、路径和 `clientid` 要求。说明为家谱内容编辑者访问,返回正常与停用字辈,供恢复、纠错和排序调整 | 点击维护先真实请求 `appApi.getGenerationPoemManagement`,成功才进入编辑;不再用 fixture 或角色推断权限 | 已接线,等待真实响应/权限核验;维护读取失败不伪造“无权限”或本地编辑状态 |
| 单条新增/修改/停用恢复 | `POST /genealogy/app/genealogies/{genealogyId}/generation-poems``PUT /genealogy/app/genealogies/{genealogyId}/generation-poems/{poemId}`。后者路径另有 `poemId:int64` 必填;两者 body 均为:`generationNo:int64` 必填、`generationText:string` 必填且最大 50 字、`description:string` 可选且最大 500 字、`sortOrder:int64` 可选、`status:string` 可选(`0` 正常、`1` 停用) | 当前编辑器以一段本地文本拆分生成字辈行;没有单条 request mapper 或服务端返回处理 | 单条合同已明确,当前页面交互是批量维护模型;不能把本地状态切换写成停用/恢复成功,**未完成 / DECLARED_UNVERIFIED** |
| 批量预览 | `POST /genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview`body `poemText:string` 必填、最大 26000 字,最多 500 世,可用空格、逗号、分号、顿号、斜杠或换行分隔;`disableMissing:boolean` 可选 | `appApi.previewGenerationPoemBatch` 只提交 `poemText/disableMissing`;页面展示服务端 `createCount/updateCount/keepCount/disableCount`,草稿或策略变化即使旧预览失效 | 响应 `GenerationPoemBatchPreviewView` 的计数字段和家谱归属已校验,等待真实响应核验;不再使用本地差异作为保存依据 |
| 批量保存 | `POST /genealogy/app/genealogies/{genealogyId}/generation-poems/batch/save`;请求体与批量预览相同;接口说明为按当前数据生成差异,停用不删除历史字辈记录;返回 `VoidResult` | 保存仅在当前草稿已有同签名服务端预览时调用,严格成功后读取维护列表;本地不再更新或宣称保存成功 | **未完成 / DECLARED_UNVERIFIED**:写入已接线,但无人值守未触发真实保存;需人工可观察结果和真实回读才能升级状态 |
## T 世系树与成员
### T01 世系树与人物操作面板
| 核对项 | Apifox 桌面端实读 | 当前页面实情 | 结论 |
| --- | --- | --- | --- |
| 世系树读取 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/tree`;鉴权 `Authorization`、路径 `genealogyId:int64`、Header `clientid:string` 均必填;`200 LineagePersonTreeResult` | `pages/tree/t01-tree-overview.vue` 已调用 `appApi.getTree`,但尚未作真实响应验证 | 不是“无接口”,但不能因代码存在而宣称页面已完成 |
| 树节点响应字段 | `data: LineagePersonTreeView[]``personId/genealogyId/genealogyName/genealogyNo/appUserId/appUserNickName/personNo/name/aliasName/sex/generation/generationName/fatherId/fatherName/motherId/motherName/spouseNames/avatarOssId/birthDate/birthLunar/birthPlace/deathDate/deathLunar/deathPlace/burialPlace/personStatus/biography/sortOrder/status/remark/relationType/relationName/spouses[]/children[]``spouses` 为配偶节点、`children` 为递归子女节点 | 当前 mapper 只投影树布局所需 `id/parentId/name/relation/generation/branch/years/sex/personStatus`;人物卡和操作面板头像固定使用本地占位图,未消费 `avatarOssId`;父母、配偶、子女等可用响应关系也未完整展示 | **未完成 / DECLARED_UNVERIFIED**:必须补头像文件取址与字段投影,并在真实只读响应下核验树形关系,才能满足人物卡要求 |
| 点击人物后的动作 | 页面已有“查看资料、添加父亲/母亲/配偶/兄弟姐妹/儿子/女儿、调整排行、编辑信息”动作入口,分别路由 T03/T04/T06/T05 | 父母、子女的 HTTP 动作实际分别共享 `/parents``/children`,页面未发送 `sex``relationName`,因此不能区分“父亲/母亲”“儿子/女儿”;邀请绑定在页面中明确标作不可用 | **未完成**:操作面板存在不等于每个业务动作闭环;性别语义和邀请绑定仍缺合同闭环 |
| 邀请绑定 | 在 Apifox APP 目录分别以 `invite``bind` 全文检索,均未命中任何邀请签发、受邀人查询、人物绑定、绑定结果查询 operation | 页面也未伪造该流程 | **MISSING_OPERATION**:不以入谱申请或普通人物修改替代“邀请绑定” |
### T03 成员资料
| 核对项 | Apifox 桌面端实读 | 当前页面实情 | 结论 |
| --- | --- | --- | --- |
| 读取详情 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}`;鉴权、`genealogyId:int64``personId:int64``clientid` 均必填;`200 LineagePersonResult` | 页面调用 `appApi.getPerson` | 读取路径存在,但不是完成依据 |
| 详情响应字段 | `data: LineagePersonView``personId/genealogyId/genealogyName/genealogyNo/appUserId/appUserNickName/personNo/name/aliasName/sex/generation/generationName/fatherId/fatherName/motherId/motherName/spouseNames/avatarOssId/birthDate/birthLunar/birthPlace/deathDate/deathLunar/deathPlace/burialPlace/personStatus/biography/sortOrder/status/remark` | 已把别名、性别字典值、人物状态字典值、出生/逝世农历、出生/逝世地点、安葬地、配偶名、生平和备注纳入 T03 mapper 与展示;仍未展示头像(缺 `avatarOssId` 取址)、`appUserNickName`、排序/状态原值,亲属仍只可由父母 ID 跳转。 | **未完成 / DECLARED_UNVERIFIED**:T03 仍是半成品,不能计入完成;字段已接线但未用真实只读响应核验,头像、完整亲属投影和字典语义仍未闭环。 |
### T04 添加亲属、T05 编辑、T06 排行
| 页面/动作 | Apifox 桌面端实读 | 当前页面实情与结论 |
| --- | --- | --- |
| T04 首位成员/新增人物 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons`;鉴权、路径 `genealogyId``clientid` 必填;body 只有 `name:string` 必填。可选字段为 `appUserId/personNo/aliasName/sex/generation/generationName/fatherId/motherId/avatarOssId/birthDate/birthLunar/birthPlace/deathDate/deathLunar/deathPlace/burialPlace/personStatus/biography/sortOrder/remark/relationName`;其中 `personNo` 由服务端生成,`avatarOssId` 须来自文件上传组件 | 页面只提交 `name/birthDate/biography`。请求字段是合法子集,但没有性别、世代、父母、头像、状态、排行等来源;真实写入未验证。**未完成 / DECLARED_UNVERIFIED** |
| T04 添加父母、子女、兄弟姐妹、配偶 | 分别为 `POST .../lineage/persons/{personId}/parents``.../children``.../siblings``.../spouses`;路径 `genealogyId/personId`、鉴权、`clientid` 均必填,均返回 `LineagePersonResult`body 与新增人物同合同。实读 `sex:string` 仅写“建议使用系统字典值”,示例为 `"0"`;以“字典/dict/性别”检索 APP/PC 目录均未找到该字典读取 owner 或男/女码值映射。 | 页面现会提交所选亲属的 `relationName`,不再把路由意图丢掉;父亲/母亲共用 `/parents`,儿子/女儿共用 `/children` 仍不能仅凭该显示名获得可靠性别语义。头像仍缺文件上传回执。**未完成 / DECLARED_UNVERIFIED**,不得拿参考项目的旧 `0/1` 码值猜填,也不能执行无人值守写入。 |
| T05 修改人物 | `PUT /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}`;两个路径 ID、鉴权、`clientid` 必填,body 与新增人物同合同,返回 `LineagePersonResult` | 已读写 `name/aliasName/generationName/birthDate/birthLunar/birthPlace/deathDate/deathLunar/deathPlace/burialPlace/biography/remark`;静态校验确认表单字段与请求白名单一致。头像仍缺上传回执;性别、人物状态、排行因字典或原子 owner 缺失未写入。 | **未完成 / DECLARED_UNVERIFIED**:已扩展为已声明的安全字段子集,但没有真实保存后的响应/回读,不能称完整人物编辑。 |
| T06 调整排行 | Apifox 只有单人物 `PUT .../persons/{personId}` 中的可选 `sortOrder:int64`,没有同辈排行列表、批量重排、原子提交或冲突回显 operation | 页面已明确显示“服务暂未开放”,不逐人写入 | **未完成 / MISSING_OPERATION**:不能以单人 `sortOrder` 伪造同辈原子排行调整 |
### T07 成员目录、T08 成员状态
| 页面/动作 | Apifox 桌面端实读 | 当前页面实情与结论 |
| --- | --- | --- |
| T07 成员目录分页与搜索 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/page`query 可选 `pageNum`(默认 1)、`pageSize`(默认 10)、`keyword`(姓名/别名/人物编号)、`generation:int64``personStatus:string`;响应 `LineagePersonPageResult``rows: LineagePersonView[]``total`。另有 `GET .../lineage/persons/options?keyword=` 供人物选项读取 | 已移除 `listTreeMemberPresentationFixtures`;页面使用实际 `pageNum/pageSize/keyword` 请求、消费 `rows/total`,支持服务端搜索与继续加载;本地预览明确报真实读取不可用,不伪造目录数据。 | **未完成 / DECLARED_UNVERIFIED**:读取合同已接线并经静态检查,尚未用登录态获得一次真实 `rows/total` 响应;世代/人物状态筛选尚未增加页面控件。 |
| T08 成员状态说明 | 人物详情、列表和分页都提供 `personStatus``status`;停用人物为 `DELETE /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}`,接口说明为逻辑停用且在正常子女时拒绝停用,返回 `VoidResult` | 已移除 `findTreeMemberPresentationFixture``privacy/deceased/forbidden` 推断;页面读取人物详情并原样展示 `personStatus`,明确说明当前没有状态字典 owner,也不提供停用写入。 | **未完成 / DECLARED_UNVERIFIED**:读取已接线并经静态检查,仍未用真实只读响应核验;没有字典映射时不得生成隐私/受限/纪念文案。 |
## R 记录模块
### R01 人物录、R02 人物档案
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
| --- | --- | --- | --- |
| R01 人物录列表、搜索 | 正确读取 owner 是 `GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/page`query 为 `pageNum/pageSize/keyword/generation/personStatus`,响应为 `rows: LineagePersonView[]/total`。人物选项另有 `GET .../lineage/persons/options?keyword=` | 已移除 `listTreeMemberPresentationFixtures` 和本地“新增预览”;页面用 `pageNum/pageSize/keyword` 请求、消费 `rows/total`,支持服务端搜索和继续加载。新增人物保持从 T01 亲属关系入口进入。 | **未完成 / DECLARED_UNVERIFIED**:读取合同已接线并经静态检查,尚未用登录态获得一次真实响应;世代/人物状态筛选尚未增加页面控件。 |
| R02 人物档案读取 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}` 返回已在 T03 实读的 `LineagePersonView`,含 `name/generation/generationName/biography/remark`,以及头像、性别、别名、亲属名、地点、生卒、状态等 | 已移除 `findTreeMemberPresentationFixture`、本地预览/编辑;页面读取详情并展示已声明的资料字段,编辑入口改为跳转 T03 的成员档案,再由 T05 完成可写字段维护。成长日志仍跳 R08,人生事仍为待开放。 | **未完成 / DECLARED_UNVERIFIED**:读取已接线并经静态检查,尚未取得真实详情响应;头像取址、性别/状态字典和完整亲属投影仍未闭环。 |
| R02 新建/编辑人物 | `POST /lineage/persons``PUT /lineage/persons/{personId}` 的完整人物请求合同已在 T04/T05 实读,`name` 必填,其余有世代、头像、亲属、状态、排序、传记、备注等字段 | 表单只收 `name/generationName/generation/biography/remark`,保存仅变成本地预览 | **未完成 / DECLARED_UNVERIFIED**:是可辨认的字段子集但没有远端读写闭环;不得把“生成本地预览”说成新建或修改成功 |
### R03 贺礼簿、R04 往来详情与编辑
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
| --- | --- | --- | --- |
| R03 列表、R04 详情 | `GET /genealogy/app/genealogies/{genealogyId}/relative-records``GET .../relative-records/{relativeId}`;均需鉴权、`genealogyId``clientid`,分别返回通用 `ListResult``ObjectResult`,未声明条目 DTO | 已删除 R03/R04 fixture 列表、详情和本地编辑预填;页面明确说明缺记录 ID、关系、事项、时间、金额和备注的响应映射 | **未完成 / DECLARED_UNVERIFIED**:接口并非缺失,但响应没有声明 `relativeId` 等展示字段,不能猜字段映射 |
| R04 新增/修改 | `POST /genealogy/app/genealogies/{genealogyId}/relative-records``PUT .../relative-records/{relativeId}`body 为 `relativeName:string` 必填,`relationName/eventName/eventTime/giftAmount:number/recordContent/mediaOssIds/sortOrder/status` 可选,返回通用 `ObjectResult` | 创建页通过 `appApi.createRelativeRecord` 提交 `relativeName/relationName/eventName/eventTime/giftAmount/recordContent`;媒体字段没有上传 owner,故不发送。详情/修改入口因无记录 DTO/ID 来源关闭 | 创建已接线,等待真实写入响应核验;修改仍 **DECLARED_UNVERIFIED** |
| R04 删除 | `DELETE .../relative-records/{relativeId}` 已在 Apifox 同一资源目录确认 | 页面明确显示“删除暂未开放” | **未完成 / DECLARED_UNVERIFIED**:不执行删除;存在删除 operation 也不改变其他读写未接线的事实 |
### R05—R07 礼仪活动与献礼
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
| --- | --- | --- | --- |
| R05 礼仪活动列表、R07 新建 | 在 APP 目录以 `ceremony` 实读到该资源共六个动作:详情、修改、活动献礼列表、新增献礼、删除活动、删除献礼;没有活动列表或新建活动 operation | 已删除 `listCeremonyFixtures`、新建和编辑预览;R05/R07 显示缺 operation 状态并保留返回路径 | **未完成 / MISSING_OPERATION**:不得用详情或修改接口冒充活动列表/新建;R05、R07 的主业务 owner 缺失 |
| R06 礼仪详情 | `GET /genealogy/app/genealogies/{genealogyId}/ceremonies/{ceremonyId}`;需鉴权、路径 `genealogyId/ceremonyId``clientid`,返回通用 `ObjectResult`;未声明活动 DTO | 已删除 fixture、受邀人拼装和编辑入口,只提示详情字段缺口 | **未完成 / DECLARED_UNVERIFIED**:详情 operation 存在,但这些展示字段、受邀人及其关系没有响应字段依据 |
| R07 修改礼仪 | `PUT .../ceremonies/{ceremonyId}`body `ceremonyType:string``ceremonyTitle:string` 必填,`ceremonyDesc/ceremonyTime/location/coverOssId/sortOrder/status` 可选,返回通用 `ObjectResult` | 无可靠详情 DTO 和活动 ID 来源时关闭修改,且封面另缺上传 owner | 表单字段虽可对应,但没有远端读取、写入和回读;**未完成 / DECLARED_UNVERIFIED** |
| R06 献礼 | `GET .../ceremonies/{ceremonyId}/gifts` 返回通用 `ListResult``POST .../gifts` 的 body 为 `giverName:string` 可选、`giftAmount:number` 必填、`giftMessage:string` 可选,返回通用对象 | 页面有受邀人展示,不是献礼条目展示或写入 | **未完成 / DECLARED_UNVERIFIED**:献礼接口不能替代受邀信息,且没有条目 DTO 可映射 |
### R08 成长日志、R09 人生事
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
| --- | --- | --- | --- |
| R08 列表与详情 | `GET /genealogy/app/genealogies/{genealogyId}/growth-records``GET .../growth-records/{recordId}`;同一 APP 资源另有新增、修改、删除,共五个动作。列表为通用 `ListResult`、详情为通用 `ObjectResult`,均未声明记录 DTO | 已删除 `listGrowthRecordFixtures` 和本地列表预览;页面只保留创建表单 | **未完成 / DECLARED_UNVERIFIED**`recordId`、标题、日期、内容没有响应字段声明,不能恢复列表或详情 |
| R08 新增/修改 | `POST .../growth-records``PUT .../growth-records/{recordId}`;新增 body 已实读:`lineagePersonId/recordType/recordContent/recordDate/remindTime/mediaOssIds/sortOrder/status` 可选,`recordTitle:string` 必填 | `appApi.createGrowthRecord` 提交 `recordTitle/recordDate/recordContent`;人员绑定、类型、提醒、媒体、状态无来源,故不发送;修改没有记录 ID 来源而关闭 | 创建已接线,等待真实写入响应核验;修改 **DECLARED_UNVERIFIED** |
| R09 人生事 | 分别以 `life` 与“人生”在 APP 接口目录检索,均未命中独立人生事件资源;当前文档中不能用成长、备忘或人物资料替代 | 页面已明确提示接口未开放且不展示/提交数据 | **未完成 / MISSING_OPERATION**:保持关闭是正确的,不虚构读写链路 |
### R10 家族备忘、R11 功德记录
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
| --- | --- | --- | --- |
| R10 备忘列表与详情 | `GET /genealogy/app/genealogies/{genealogyId}/memos``GET .../memos/{memoId}`;同一资源另有新增、修改、删除,共五个动作。列表 `ListResult`、详情 `ObjectResult` 都只声明通用包装字段 | 已删除 fixture 列表和本地预览,只保留创建表单 | **未完成 / DECLARED_UNVERIFIED**:不能从泛型响应推导 `memoId``completedLabel`,状态文案也没有字典依据 |
| R10 新增/修改 | `POST .../memos``PUT .../memos/{memoId}`body `memoTitle:string` 必填,`memoContent/remindTime/completed/mediaOssIds/sortOrder/status` 可选 | `appApi.createMemo` 提交 `memoTitle/remindTime/memoContent`;完成状态与媒体无可靠来源,故不发送;修改缺 ID 来源关闭 | 创建已接线,等待真实写入响应核验;修改 **DECLARED_UNVERIFIED** |
| R11 功德列表 | `GET /genealogy/app/genealogies/{genealogyId}/merit-records`;同资源仅另有新增、删除,共三个动作;列表返回通用 `ListResult`,未声明条目 DTO | 已删除 fixture 列表和本地预览,只保留创建表单 | **未完成 / DECLARED_UNVERIFIED**:列表字段没有合同映射,页面不猜条目字段 |
| R11 新增/修改 | `POST .../merit-records`body `donorName:string``meritTitle:string` 必填,`meritType/meritContent/amount:number/meritTime/sortOrder/status` 可选;当前 APP 目录未见修改 operation | `appApi.createMeritRecord` 提交捐赠人、标题、类型、金额、时间和内容;排序/状态无来源,故不发送 | 新增已接线,等待真实写入响应核验;编辑 **MISSING_OPERATION** |
## N 消息通知
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
| --- | --- | --- | --- |
| N01 消息中心列表 | `GET /genealogy/app/notifications`;鉴权 `Authorization`、Header `clientid:string` 必填;`200 ListResult` 只声明通用 `code/msg/data[]`,条目仍为泛型对象 | 已删除 `listNotificationFixtures`、未读计数和本地业务跳转,只显示缺失通知字段合同 | **未完成 / DECLARED_UNVERIFIED**:没有消息 ID、已读、标题、时间、正文、目标类型和目标参数的响应字段合同;不能把 fixture 的跳转当作通知接口返回能力 |
| N02 消息详情 | 在 APP 消息通知目录实读到的仅有列表、单条标已读、全部标已读三个 operation;没有详情读取 operation | 已删除详情 fixture,只显示缺详情 owner 状态 | **未完成 / MISSING_OPERATION**:不能以列表泛型或本地 fixture 冒充单条详情;详情所需正文、来源和跳转字段均无接口 owner |
| N01/N02 单条标已读 | `POST /genealogy/app/notifications/{notificationId}/read`;鉴权、`notificationId:int64``clientid` 必填,返回 `VoidResult` | 无可消费通知 ID 时页面不显示单条标已读,已删除本地 `unread` 修改 | 正确写入 owner 存在但没有可回读 item/ID**未完成 / DECLARED_UNVERIFIED** |
| N01 全部标已读 | `POST /genealogy/app/notifications/read-all`;鉴权、`clientid` 必填,返回 `VoidResult` | 已删除“全部已读”本地 fixture 修改 | **未完成 / DECLARED_UNVERIFIED**:无列表回读时不把本地状态改动当服务端写入成功 |
## M 个人中心与账号
### M01 个人中心、M02 个人资料
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
| --- | --- | --- | --- |
| M01 当前用户资料 | `GET /genealogy/app/auth/profile`;鉴权、`clientid` 必填,返回通用 `ObjectResult``data` 未声明用户 DTO | 已删除 `currentUser.name/role/phone` 和 fixture 未读数展示,保留各模块入口 | **未完成 / DECLARED_UNVERIFIED**:用户名、角色、手机号及其脱敏规则没有响应字段合同;不可把 mock 当前用户当作已登录资料 |
| M02 读取与修改资料 | 读取为同一 `GET /auth/profile`;修改为 `PUT /genealogy/app/auth/profile`。修改 body 已实读:`nickName/avatarOssId/sex/birthday/provinceCode/cityCode/districtCode/addressDetail` 均可选,返回通用 `ObjectResult` | 已删除 mock 预填和本地保存;当前 UI 的真实姓名/邮箱与更新合同不相交,页面明确关闭编辑 | `nickName` 可对应,但 `realName/email` 不在修改合同;后端的头像、性别、生日、地区、地址未有页面输入或 mapper。**未完成 / DECLARED_UNVERIFIED**;头像另受上传 owner 阻塞 |
### M03—M05 安全设置、改密、换绑手机
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
| --- | --- | --- | --- |
| M03 账号与安全概览 | 资料读取、改密、换绑均有各自 APP operation,未见独立“安全概览/设备/登录记录”读取 operation | 已删除本地账号摘要,保留改密与换绑入口 | **未完成 / DECLARED_UNVERIFIED**:入口可以保留,但安全状态、设备、会话等没有 owner,不能凭本地提示宣称已核验 |
| M04 修改密码 | `PUT /genealogy/app/auth/password`;鉴权、`clientid` 必填;body `oldPassword:string``newPassword:string` 均必填且均为 32 位 MD5;返回 `VoidResult` | 页面将当前/新密码 MD5 后以 `oldPasswordHash/newPasswordHash` 传给 api 层,最终字段名映射为 `oldPassword/newPassword` | 请求字段、摘要格式和页面动作可对齐;但尚未在真实账号下接受响应验证,且不得无人值守改密。**未完成 / DECLARED_UNVERIFIED** |
| M05 换绑手机号 | `PUT /genealogy/app/auth/phone`;鉴权、`clientid` 必填;body `clientId:string``phone:string``smsCode:string` 均必填,验证码模式为 4 位;响应为 `ObjectResult`(含 400/200) | 已删除 mock 当前手机号、输入和本地校验,页面明确提示需人工 TAC/短信与资料 DTO | 号码和四位码输入可对应,但缺实际滑动验证、短信发送、`clientId` 来源、写入与回读。**未完成 / DECLARED_UNVERIFIED**;不代用户发验证码或换绑 |
### M06 帮助、M07 反馈、M08 推广、M09 VIP、M10 关于
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
| --- | --- | --- | --- |
| M06 帮助中心 | `GET /genealogy/app/help-articles``GET /genealogy/app/help-articles/{articleId}`;列表为通用 `ListResult`、详情为通用对象,未声明文章 DTO | 页面本地内置分类、问题、答案和搜索 | **未完成 / DECLARED_UNVERIFIED**:帮助读取 owner 存在,但不能从泛型响应推导问题、答案、分类或文章 ID;当前本地说明不是服务端帮助 |
| M07 提交反馈 | `POST /genealogy/app/feedback`;鉴权、`clientid` 必填;body `feedbackType:string` 可选、`feedbackContent:string` 必填、`contactInfo:string` 可选,返回通用 `ObjectResult` | 表单与 api 层正好提交这三字段,页面包含成功、失败、结果不确定的提示 | 请求合同已对应;未经真实接受响应验证,不能把 UI 成功态视为后端成功。**未完成 / DECLARED_UNVERIFIED**,不代用户提交反馈 |
| M08 应用推广/邀请 | `GET /genealogy/app/promotions` 已存在,但仅为“应用推广列表”,返回通用 `ListResult`;全文检索未发现邀请码签发、归因、奖励、受邀绑定或分享回执 operation | M08 正确保持“推广能力未开放”,没有伪造邀请 | **未完成 / MISSING_OPERATION**:普通推广内容列表不能替代邀请推广业务闭环 |
| M09 VIP 与订单 | APP 目录有 `GET /genealogy/app/vip/packages``GET /genealogy/app/vip/orders``POST /genealogy/app/vip/orders`;前两者列表响应为泛型。创建订单 body 为 `packageId:int64` 必填,`genealogyId:int64``payType:string` 可选,返回通用对象 | 页面当前不读取、不会创建订单或扣费 | **未完成 / DECLARED_UNVERIFIED**:套餐与订单 owner 存在但 DTO 未声明、页面未接线;支付调起、支付结果、取消/退款等动作在当前 APP 目录未形成可审计合同,故继续禁用付费流程 |
| M10 关于与退出 | 协议、版本为本地静态内容;退出为 `DELETE /genealogy/app/auth/logout`,鉴权、`clientid` 必填,返回 `VoidResult` | M10 调用 api 层退出并且无论远端结果如何都会清本机会话 | 登出路径与合同一致,但未在真实请求下验证;协议/版本没有远端 owner 的需求。退出动作仍标 **DECLARED_UNVERIFIED**,不在无人值守状态触发 |
## A 认证
| 页面/动作 | Apifox 桌面端实读:业务接口、请求/响应字段 | 当前页面展示或输入字段 | 完成状态 |
| --- | --- | --- | --- |
| A01 登录:验证前置与发送短信 | `GET /captcha/require`:查询 `tenantId/clientId/sceneCode/subject`,其中 `sceneCode` 必填;响应 `VerificationRequireResult` 已声明 `required/providerCode/captchaType/sceneCode/ttlSeconds``POST /genealogy/app/auth/sms/code`Header `clientid` 必填;Body `clientId/grantType/tenantId/sceneCode/phone/validToken` 均必填,`sceneCode``APP_SMS_LOGIN`;响应 `VoidResult`。 | `a01-entry.vue` 以手机号、密码或四位短信码登录;取码先查验证要求,再由内嵌验证组件提交 `validToken`。滑动验证采用服务商组件本身,不增加页面自定义样式。 | **未完成 / DECLARED_UNVERIFIED**:前置响应字段与发送短信字段已逐项对上,但未发送短信;`required=false` 时如何签发可消费票据也未由 Apifox 合同说明,不能把页面本地倒计时当发送成功。 |
| A01 账号密码登录 | `POST /genealogy/app/auth/login`Header `clientid` 必填;Body `clientId/grantType/tenantId/phone/password` 均必填,`grantType=password``password` 为 32 位 MD5;响应组件为 `LoginResult`。 | 页面将手机号和 MD5 密码传至 API 层;当前 API 层读取响应 `access_token` 保存会话。登录接口请求体没有 `validToken` 字段,页面仅把滑动验证作为前端完成条件。 | **未完成 / DECLARED_UNVERIFIED**:请求字段对齐;Apifox 当前只标出 `LoginResult` 组件,未在该 operation 展开可核的会话字段,且尚未以测试账号获得一次被接受的响应,不能声明登录已完成。 |
| A01 短信登录 | `POST /genealogy/app/auth/login/sms`Header `clientid` 必填;Body `clientId/grantType/tenantId/phone/smsCode` 均必填,`grantType=sms``smsCode` 为四位短信码;响应组件为 `LoginResult`。 | 页面字段为手机号、四位验证码;API 层同样依赖返回的 `access_token` 建立会话。 | **未完成 / DECLARED_UNVERIFIED**:请求合同对齐,但该动作依赖真人收到短信;未发送、未登录,不把页面登录成功提示当成远端成功。 |
| A04 注册 | 短信链路同上但 `sceneCode=APP_REGISTER``POST /genealogy/app/auth/register`Header `clientid` 必填;Body 已实读 `clientId/grantType/tenantId/phone/password/smsCode``grantType=password`、密码为 32 位 MD5、验证码为四位;响应 `LoginResult`。 | 页面输入手机号、验证码、密码、确认密码和协议勾选;提交时传手机号、MD5 密码、验证码。 | **未完成 / DECLARED_UNVERIFIED**:字段链路可对照,但注册会创建真实账号,按约定不在无人值守时触发;`LoginResult` 的完整展示字段仍待接受响应核实。 |
| A05 找回密码 | 短信链路同上但 `sceneCode=APP_FORGOT_PASSWORD``PUT /genealogy/app/auth/password/reset`Header `clientid` 必填;Body `clientId/grantType/tenantId/phone/newPassword/smsCode` 均必填,`grantType=password``newPassword` 为 32 位 MD5、验证码为四位;响应 `VoidResult`。 | 页面输入手机号、验证码、新密码、确认密码;提交参数为手机号、MD5 新密码、验证码。 | **未完成 / DECLARED_UNVERIFIED**:请求字段对齐;找回会真实改密,未触发,不能以本地“修改成功”状态当接口完成。 |
| A06 账号状态/恢复 | 在 APP 认证目录按 `status``frozen``disabled``risk``appeal``recovery` 检索,未找到账号状态读取、限制原因、申诉或恢复的独立 operation。 | 页面只读路由参数 `status`,并用本地 `frozen/disabled/risk` 文案展示限制原因和恢复说明;“查看恢复方式”仅打开本地弹层;该文件也未注册进 `pages.json` 的 52 条路由。 | **未完成 / MISSING_OPERATION**:没有后端 owner 提供状态、原因、可恢复路径或申诉结果,不能把静态文案当真实账号状态;未注册时也不能由正常路由到达。 |
## 本轮累计
| 范围 | 已逐页实读 | 可实施映射 | 未完成原因 |
| --- | ---: | --- | --- |
| F01—F10 | 10/10 | F02 发布、F03 评论读取/提交、F06 谱文创建、F07 相册创建已按声明字段接线;F01/F04/F05/F08/F09 已关闭无 DTO 或上传 owner 的 fixture 展示 | F02/F03/F06/F07 等待真实响应核验;动态、谱文和相册展示仍缺 DTO;F09 缺文件上传 ownerF10 缺读取/发布 owner;写入不得在无人值守时触发 |
| G01、G03、G05—G12 | 9/9 | G12 正常列表、维护列表、批量预览/保存已按声明字段接线;G03/G06/G08—G11 已删除 fixture 或本地预览 | G12 待真实读取/写入响应核验,当前世代字段仍未声明;G03 两阶段结果恢复链、G06/G08—G11 的 DTO/ID/权限缺口仍未闭环 |
| T01、T03—T08 | 7/7 | 树、详情、人物分页/选项、人物与亲属写入合同均已逐项实读;T03、T05、T07、T08 的已声明读取/字段子集已接线 | T03 明确为未完成;T01 头像与邀请绑定未闭环;T04 关系性别语义不完整;T06 缺原子排行 operationT07/T08 均待真实读取响应核验 |
| R01—R11 | 11/11 | R03/R04、R08、R10、R11 创建已按声明字段接线;R05—R07/R09 已删除本地流程 | 所有 R 列表/详情仍缺 DTO;创建待真实响应核验;R05/R07、R09 另有明确 `MISSING_OPERATION` |
| N01—N02 | 2/2 | 消息列表、单条标已读、全部标已读 owner 已实读;页面已删除 fixture 消息和本地已读 | 列表条目 DTO 未声明、消息详情 operation 明确缺失,无稳定 ID 时不发送已读 mutation |
| M01—M10 | 10/10 | M01—M03/M05 已删除 mock 资料和本地资料流程;改密、反馈、退出已有独立接线 | M02 字段与合同不一致;读取 DTO 多为泛型;M08 缺邀请业务 owner;VIP 还缺可审计支付闭环;敏感写入均未实测 |
| A01、A04—A06 | 4/4 | 验证要求、短信发送、密码/短信登录、注册、找回密码的请求合同已逐项实读 | 无人值守不发送短信、不注册、不找回、不真实登录;`LoginResult` 仅见响应组件名,完整会话字段待接受响应;A06 明确缺状态/恢复 owner |
+751
View File
@@ -0,0 +1,751 @@
# 产品参考页面功能映射表
> 规划日期:2026-07-23(北京时间)
> 状态:需求已收口,作为《今晚全量联调与明早测试执行计划》的权威附件,三人交叉评审与用户确认均已完成;用户已明确开始执行,`T_due=2026-07-24 08:00`55 个候选 PA 本轮维持 `approvedCandidateActions=0`。
> 冻结规则:用户确认本附件仍不代表开始实现;只有用户明确说“开始执行”后才允许修改业务代码、接口接线、样式和测试,或运行测试、构建与 MuMu。
## 一、范围、优先级与计数
本附件只做产品甄别,不复制参考代码。优先级固定为:
1. 用户对当前 `jiapuapp` 的明确需求;
2. 当前真实后端合同、线上文档和可复现响应;
3. 当前项目的路由、架构、安全、国风视觉和无障碍基线;
4. `C:\Users\Rain\Desktop\job\app设计`
5. `C:\Users\Rain\Desktop\job\Jiapu-App`
基线计数:
| 对象 | 分母 | 本附件覆盖 |
| --- | ---: | ---: |
| 当前 `jiapuapp` 活动路由 | 52 | 52,沿用 `docs/接口与页面映射总表.md` 的 A/G/T/F/R/N/M 稳定编号 |
| 设计参考文件 | 6059 PNG1 单页 PDF | 60`D001``D060` |
| 完成项目活动路由 | 78 | 78,`J001``J078` |
| 完成项目注释路由声明 | 1 | 1,`JX001` |
| 完成项目未注册页面文件 | 7 | 7,`JU01``JU07` |
| 完成项目媒体资产 | 531 | 531 个进入资产候选池;实际采用前另建逐文件 `RAxxx` |
| 本轮规划确认前批准的新增当前路由 | 0 | 0;候选先复用现有 owner 或保持关闭/待确认 |
完成项目 `pages.json` 共有 79 个 `path` 文本,其中 `pages/index/vertical-swiper/vertical-swiper` 整段被注释,因此活动路由分母是 78,不是 79;`pages/render/render` 已计入 78。
## 二、稳定 ID 与逐动作规则
- 设计证据的完整格式是 `Dxxx-Pxx-Sxx-Axx`:文件、页面/PDF 页、页面状态、来源证据基记录。当前每张单页 PNG 都作为独立 `S01` 状态证据,表内 `Dxxx-P01-Axx` 是省略 `S01` 的短写,不表示不同文件可以合并。
- 完成项目的完整格式是 `Jxxx-Sxx-Axx`。同一路由上的正常、空、管理、编辑、权限等状态先区分,再对查看、创建、删除、邀请、支付等来源证据基记录编号;表内省略默认 `S01`。注释路由用 `JX001`,未注册文件用 `JUxx`
- 通常一个 `Axx` 对应一个独立动作。若旧截图或旧代码分支把多个控件不可分地记在同一物理证据中,基记录后必须追加稳定语义后缀,例如 `J073-A02@comment``@reaction``@share`;每个后缀分别绑定合同键、六类结论和 PA。341 只统计物理基记录,语义后缀不重复增加来源文件/代码记录,但不能用同一基号合并不同产品动作。
- 当前产品 owner:沿用 A01、G01、T01、F01、R01、N01、M01 等编号。共享 owner 使用 `S-ID``S-PERM``S-FILE``S-REGION``S-SESSION`
- 一行可列同一页面的多个动作,但每个动作都必须有动作 ID、合同键和结论;不同结论不得合并。
- 文件名、画面、路由或源码相似不能单独证明“重复”。它们可能是普通态、管理态、编辑态、空态、错误态、不同角色态或主题态;只有入口、对象、动作、数据、接口、权限和返回行为全部等价后才能共用当前 owner,源状态 ID 始终保留。
六类甄别结论固定为:`直接采用``改造后采用``仅参考交互``后端缺失,暂时关闭``与当前产品冲突,明确舍弃``待用户确认`
所有动作继承以下门禁:
1. 正常、加载、空、失败、无权限、提交中、明确失败、结果未知、返回与重进状态按动作性质覆盖。
2. 当前 OpenAPI 没有稳定 capability 投影;写入口在权限 owner 确认前只能显示禁用原因,403 不能反推权限。
3. 所有 `int64` ID 必须先通过十进制字符串 wire 门禁;不安全 number 解析后禁止继续使用。
4. 参考稿的亮红导航、旧 uView 控件、旧图标、固定尺寸和旧数据层不进入视觉目标;只借鉴信息结构,视觉统一回当前国风系统。
5. `DECLARED_UNVERIFIED` 只表示受保护 OpenAPI 有声明;收到“开始执行”并取得本轮真实证据前,没有任何动作可预先标 `LIVE_VERIFIED`
## 三、当前合同键
本节是源页面逐动作映射使用的唯一合同索引。每个键只能有一个 `contractState`,每个当前 operation 必须写成 `METHOD + 完整 path`。快照中有 operation 但已知 schema、权限、唯一 owner、结果确认或静态门禁冲突时,状态取更严重的 `CONTRACT_CONFLICT`;门禁脚本期望、但受保护快照没有的路径只能记为“门禁目标”,不能冒充当前 operation。当前没有任何键可预先标 `LIVE_VERIFIED`
### 3.1 共享合同
| 合同键 | 当前 operation 或唯一 owner | `contractState` | 当前阻塞和门禁 |
| --- | --- | --- | --- |
| `C-S-ID` | OpenAPI 中所有 `genealogyId``personId``memberId``appUserId`、内容 ID、文件 ID、`avatarOssId` 及关系引用;无独立 operation | `CONTRACT_CONFLICT` | 当前 `int64` JSON number 不能满足 JavaScript 安全整数门禁;严格门禁包括 `genealogy-workspace-openapi-contract.ps1``g03-bootstrap-openapi-contract.ps1``lineage-openapi-contract.ps1`。所有 ID 动作继承本键。 |
| `C-S-CAPABILITY` | 家谱、成员、世系等读取 DTO 中的服务端 capability/角色字段;无独立 operation | `CONTRACT_CONFLICT` | 快照没有闭合 `canView/canEdit/canInvite/canAudit/canDelete` owner,不能由前端角色名或一次 403 推测。 |
| `C-S-CONTENT-LOCK` | 无跨谱文、成长记录和重要证件的统一访问密码/内容锁 operation | `MISSING_OPERATION` | 不得复用登录密码、A05 重置密码或参考项目三套旧 wrapper;若以后批准,必须先定义独立资源、可见性、校验、重置和限流 owner。 |
| `C-S-PLATFORM-SHARE` | 当前 UniApp/Android 系统分享 adapter;无后端 operation | `NOT_APPLICABLE` | 唯一 owner 只负责调起平台分享、取消/失败和返回状态;分享内容必须由对应业务读取 owner 提供,例如 M08 当前活动家谱票据用 `C-G-INVITE-LIST`、APP 推广用 `C-M-PROMO`、宣传视频用 `C-M-PROMO-VIDEO`、F10 家族视频用 `C-F-VIDEO-READ`。未在冻结候选真机验证前产品动作最多为 `PARTIAL`。 |
| `C-S-PLATFORM-CLIPBOARD` | 当前 UniApp/Android 系统剪贴板 adapter;无后端 operation | `NOT_APPLICABLE` | 唯一 owner 只负责复制动作、拒绝/失败和返回状态,不拥有被复制的票据或推荐码;业务内容分别来自 `C-G-INVITE-LIST``C-M-REFERRAL`。未在冻结候选真机验证前不得宣称复制成功。 |
| `C-S-SAFE-EXTERNAL-OPEN` | 当前 UniApp/Android 外部链接打开 adapter 与目标 allowlist;无后端 operation | `NOT_APPLICABLE` | 唯一 owner 只负责 HTTPS、官方域名 allowlist、取消/失败和返回状态,不拥有下载地址内容;任意参考 URL、HTTP 地址或应用市场 scheme 均不得直接迁入。 |
| `C-S-NAV` | 当前 `pages.json`、route key、参数 validator、返回与根切换的本地导航 owner;无后端 operation | `NOT_APPLICABLE` | 只拥有导航结构,不拥有目标页数据或权限;所有跨页 PA 自动继承,参考项目菜单不能绕过当前注册路由。 |
| `C-LOCAL` | 协议、关于、版本号等经批准的正式本地静态 owner | `NOT_APPLICABLE` | 只适用于无需远端事实的内容,不能用于邀请码、通知详情、支付结果或人物资料。 |
### 3.2 A 认证与账号
| 合同键 | 当前 operation 或唯一 owner | `contractState` | 当前阻塞和门禁 |
| --- | --- | --- | --- |
| `C-A-TAC-API` | `GET /captcha/require`<br>`POST /captcha/challenge`<br>`POST /captcha/verify` | `CONTRACT_CONFLICT` | `auth-tac-openapi-contract.ps1` 是已知红灯;其中“密码登录必须消费 `validToken`”与已确认的“密码登录不接收 `validToken`、TAC 为客户端强制前置”冲突,执行时必须先修订门禁口径。 |
| `C-A-TAC-A11Y` | 复用 `GET /captcha/require``POST /captcha/challenge``POST /captcha/verify``VerificationPayload` 声明多种验证码载荷 | `CONTRACT_CONFLICT` | 快照并非完全缺 operation;当前冲突是无拖动替代的服务端可用性、选择/降级语义和真机证据未闭合,`auth-android-accessibility-release-gate.ps1` 仍为红灯。 |
| `C-A-LOGIN` | `POST /genealogy/app/auth/login` | `CONTRACT_CONFLICT` | 密码 wire、TAC 口径和会话结果未闭合;关联 `auth-tac-openapi-contract.ps1``password-change-openapi-contract.ps1`。 |
| `C-A-SMS-SEND` | `POST /genealogy/app/auth/sms/code` | `CONTRACT_CONFLICT` | 公共发码场景、`validToken`、四位/六位 OTP 及换绑场景归属冲突;本键是该 path 的唯一 owner,注册和重置不得重复拥有。 |
| `C-A-SMS-LOGIN` | `POST /genealogy/app/auth/login/sms` | `CONTRACT_CONFLICT` | OTP schema 与 TAC 发码闭环未通过 `auth-tac-openapi-contract.ps1`。 |
| `C-A-REGISTER` | `POST /genealogy/app/auth/register` | `CONTRACT_CONFLICT` | OTP 和密码唯一 wire owner 未闭合;另依赖 `C-A-SMS-SEND`。 |
| `C-A-RESET` | `PUT /genealogy/app/auth/password/reset` | `CONTRACT_CONFLICT` | OTP、密码策略及旧 MD5 路径冲突;另依赖 `C-A-SMS-SEND`。 |
| `C-A-WECHAT` | 无 APP 微信登录 operation | `MISSING_OPERATION` | `GET /auth/code` 是遗留接口,不是可确认的当前 APP 微信登录 owner。 |
| `C-A-PROFILE-READ` | `GET /genealogy/app/auth/profile` | `CONTRACT_CONFLICT` | 当前响应不能满足严格资料 DTO;关联 `profile-openapi-contract.ps1`。 |
| `C-A-PROFILE-WRITE` | `PUT /genealogy/app/auth/profile` | `CONTRACT_CONFLICT` | dirty-only merge、版本/CAS、清空语义和结果未知恢复未闭合;关联 `profile-update-openapi-contract.ps1`。 |
| `C-A-PASSWORD-CHANGE` | `PUT /genealogy/app/auth/password` | `CONTRACT_CONFLICT` | 密码 wire、全部会话撤销和稳定错误码未闭合;关联 `password-change-openapi-contract.ps1`。 |
| `C-A-PHONE-SEND` | 无当前受保护的换绑专用发码 operation | `MISSING_OPERATION` | 门禁目标 `POST /genealogy/app/auth/phone/sms/code` 未出现在受保护快照;公共发码不得擅自替代。 |
| `C-A-PHONE-CHANGE` | `PUT /genealogy/app/auth/phone` | `CONTRACT_CONFLICT` | 当前密码、新号 OTP、会话撤销和结果语义未闭合;关联 `phone-change-openapi-contract.ps1`。 |
| `C-A-DEACTIVATE` | `POST /genealogy/app/auth/account/deactivate` | `CONTRACT_CONFLICT` | `AccountDeactivateBody.smsCode` 的四位内联 schema 与 `phone-change-openapi-contract.ps1` 要求的统一短信 Secret/六位口径冲突;另依赖 `C-A-SMS-SEND`。敏感操作仅限人工窗口。 |
| `C-A-LOGOUT` | `DELETE /genealogy/app/auth/logout` | `CONTRACT_CONFLICT` | token/client 归属、幂等和终态错误未闭合;关联 `logout-openapi-contract.ps1`。 |
### 3.3 G 家谱、行政区划、申请与成员
| 合同键 | 当前 operation 或唯一 owner | `contractState` | 当前阻塞和门禁 |
| --- | --- | --- | --- |
| `C-S-REGION-CHILDREN` | `GET /genealogy/region/children` | `DECLARED_UNVERIFIED` | 共享行政区划 owner;待线上字段和层级语义验真。 |
| `C-S-REGION-PATH` | `GET /genealogy/region/path/{regionCode}` | `DECLARED_UNVERIFIED` | 待祖先路径顺序和缺失码语义验真。 |
| `C-S-REGION-SEARCH` | `GET /genealogy/region/search` | `CONTRACT_CONFLICT` | `g03-bootstrap-openapi-contract.ps1` 错把目标写成未声明的 `GET /genealogy/app/region/search`,门禁必须按快照真实路径重订。 |
| `C-S-REGION-DETAIL` | `GET /genealogy/region/{regionCode}` | `DECLARED_UNVERIFIED` | 待详情字段验真。 |
| `C-G-MINE` | `GET /genealogy/app/genealogies/mine` | `CONTRACT_CONFLICT` | 通用列表响应不能提供稳定 ID、角色和 capability;关联 `genealogy-workspace-openapi-contract.ps1``g03-bootstrap-openapi-contract.ps1`。 |
| `C-G-PUBLIC` | `GET /genealogy/app/genealogies/public` | `CONTRACT_CONFLICT` | 搜索游标、结果 DTO 和错误语义未满足 `join-application-openapi-contract.ps1`。 |
| `C-G-OPTIONS` | `GET /genealogy/app/genealogies/options` | `DECLARED_UNVERIFIED` | 不能在未验真前与 public/mine 列表混用。 |
| `C-G-DETAIL-READ` | `GET /genealogy/app/genealogies/{genealogyId}` | `CONTRACT_CONFLICT` | 通用对象响应且与 overview 形成两个单谱读取候选;关联 `genealogy-workspace-openapi-contract.ps1`。 |
| `C-G-OVERVIEW` | `GET /genealogy/app/genealogies/{genealogyId}/overview` | `CONTRACT_CONFLICT` | 稳定 DTO、访问状态和 capability 未闭合;关联 workspace、G03、G11 门禁。 |
| `C-G-SETTINGS` | `PUT /genealogy/app/genealogies/{genealogyId}` | `CONTRACT_CONFLICT` | dirty-only merge、版本/CAS、权限和 pending 申请保护未闭合;关联 `g11-settings-openapi-contract.ps1`。 |
| `C-G-CREATE-GENEALOGY` | `POST /genealogy/app/genealogies` | `CONTRACT_CONFLICT` | G03 第一次写只有通用结果,不能确认稳定 `genealogyId`、幂等和结果未知状态。 |
| `C-T-PERSON-CREATE` | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons` | `CONTRACT_CONFLICT` | G03 第二次写兼通用人物创建 owner;根人物保护、稳定 `personId`、ID wire 和权限未闭合。 |
| `C-G-CREATE-RECOVERY` | 无可确认的幂等结果查询或创建续办 operation | `MISSING_OPERATION` | mine/overview 的存在不能证明可安全恢复第一次写;门禁目标 `GET /genealogy/app/genealogy-bootstrap-operations/{operationKey}` 未声明。现有 G03 两个门禁仍按原子 bootstrap 设计,与已确认两阶段方案冲突,执行时须先重订。 |
| `C-G-JOIN-CREATE` | `POST /genealogy/app/genealogies/{genealogyId}/join-applies` | `CONTRACT_CONFLICT` | 幂等 receipt、稳定申请 ID 和结果语义未闭合;关联 `join-application-openapi-contract.ps1`。 |
| `C-G-JOIN-RECOVERY` | 无当前申请提交结果查询 operation | `MISSING_OPERATION` | 门禁目标 `GET /genealogy/app/genealogies/join-apply-requests/{requestKey}` 未声明。 |
| `C-G-JOIN-MINE` | `GET /genealogy/app/genealogies/join-applies/mine` | `CONTRACT_CONFLICT` | 游标和 typed item 未闭合。 |
| `C-G-JOIN-WITHDRAW` | `DELETE /genealogy/app/genealogies/join-applies/{applyId}` | `CONTRACT_CONFLICT` | 权限、幂等和稳定结果未闭合。 |
| `C-G-JOIN-PENDING` | `GET /genealogy/app/genealogies/{genealogyId}/join-applies/pending` | `CONTRACT_CONFLICT` | capability、游标和 typed item 未闭合。 |
| `C-G-JOIN-AUDIT` | `PUT /genealogy/app/genealogies/{genealogyId}/join-applies/{applyId}/audit` | `CONTRACT_CONFLICT` | 审核权限、并发、重复审核和结果 receipt 未闭合。 |
| `C-G-INVITE-LIST` | 无当前可邀请家谱/活动票据列表或单票据读取 operation | `MISSING_OPERATION` | M08 不得从本地列表推断邀请权限,也不得用静态票据恢复签发/撤销结果。 |
| `C-G-INVITE-ISSUE` | 无当前家谱邀请票据签发 operation | `MISSING_OPERATION` | 门禁目标幂等签发 path 未出现在受保护快照;响应未知时只能经权威列表恢复,不得再次盲签。 |
| `C-G-INVITE-REVOKE` | 无当前家谱邀请票据撤销 operation | `MISSING_OPERATION` | 门禁目标撤销 path 未出现在受保护快照;撤销竞态与未知结果必须经权威列表收敛。 |
| `C-G-INVITE-RESOLVE` | 无当前邀请码解析 operation | `MISSING_OPERATION` | 不得以本地解码、旧 wrapper 或普通搜索代替。 |
| `C-G-INVITE-REDEEM` | 无当前邀请码兑换/直接入谱 operation | `MISSING_OPERATION` | 不得借普通加入申请绕过独立邀请语义。 |
| `C-G-INVITE-RESULT` | 无当前邀请码兑换结果查询 operation | `MISSING_OPERATION` | 结果未知时不得重复提交。 |
| `C-G-POEM` | `GET /genealogy/app/genealogies/{genealogyId}/generation-poems`<br>`POST /genealogy/app/genealogies/{genealogyId}/generation-poems`<br>`POST /genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview`<br>`POST /genealogy/app/genealogies/{genealogyId}/generation-poems/batch/save`<br>`GET /genealogy/app/genealogies/{genealogyId}/generation-poems/management`<br>`PUT /genealogy/app/genealogies/{genealogyId}/generation-poems/{poemId}` | `CONTRACT_CONFLICT` | `g12-generation-poem-openapi-contract.ps1` 要求另一套唯一 GET/PUT、版本/CAS 和旧入口移除,门禁目标与当前六个 operation 不一致。 |
| `C-G-MEMBER-READ` | `GET /genealogy/app/genealogies/{genealogyId}/members`<br>`GET /genealogy/app/genealogies/{genealogyId}/members/options` | `CONTRACT_CONFLICT` | 成员与世系人物身份、typed item 和 capability 未闭合。 |
| `C-G-MEMBER-UPDATE` | `PUT /genealogy/app/genealogies/{genealogyId}/members/{memberId}` | `CONTRACT_CONFLICT` | body 中 `lineagePersonId` 同时成为邀请绑定候选,尚无唯一 mutation owner。 |
| `C-G-MEMBER-REMOVE` | `DELETE /genealogy/app/genealogies/{genealogyId}/members/{memberId}` | `CONTRACT_CONFLICT` | 移除权限、所有者保护和结果未知处理未闭合。 |
| `C-G-MEMBER-LEAVE` | `DELETE /genealogy/app/genealogies/{genealogyId}/members/me` | `CONTRACT_CONFLICT` | 本人退出约束、最后管理员/所有者保护及终态未闭合。 |
| `C-G-MEMBER-TRANSFER` | `PUT /genealogy/app/genealogies/{genealogyId}/members/owner-transfer` | `CONTRACT_CONFLICT` | 高风险权限迁移、并发和结果确认未闭合。 |
| `C-G-SORT` | 无家谱批量排序或始祖世代安全 operation | `MISSING_OPERATION` | 不能复用人物 `sortOrder` 或字辈接口。 |
| `C-G-ADMIN` | 无管理员列表、授权和细粒度 capability operation | `MISSING_OPERATION` | members operation 不能在无明确角色/capability 合同时替代。 |
| `C-G-DELETE` | 无当前删除家谱 operation | `MISSING_OPERATION` | 参考项目 `delGenealogy` 不是当前合同;不得借设置 PUT、成员退出或本地移除列表伪装删除。 |
`invite-ticket-openapi-contract.ps1` 中以下目标路径均未出现在受保护快照,只是门禁目标:`GET /genealogy/app/genealogies/{genealogyId}/invite-tickets/mine``POST /genealogy/app/genealogies/{genealogyId}/invite-tickets``DELETE /genealogy/app/genealogies/{genealogyId}/invite-tickets/{inviteTicketId}``POST /genealogy/app/genealogy-invite-tickets/resolve``POST /genealogy/app/genealogy-invite-redemptions``GET /genealogy/app/genealogy-invite-redemption-requests/{requestKey}`
### 3.4 T 世系与邀请绑定
| 合同键 | 当前 operation 或唯一 owner | `contractState` | 当前阻塞和门禁 |
| --- | --- | --- | --- |
| `C-T-TREE` | `GET /genealogy/app/genealogies/{genealogyId}/lineage/tree` | `CONTRACT_CONFLICT` | 当前通用树不能满足头像卡、稳定图关系、窗口化、遮蔽和权限合同;`lineage-openapi-contract.ps1` 的 v2 路径只是未声明目标。 |
| `C-T-DETAIL` | `GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}` | `CONTRACT_CONFLICT` | 通用对象、ID/capability 未闭合,T03 仍是 fixture 半成品;关联 `t03-member-remote-contract.ps1`。 |
| `C-T-LIST` | `GET /genealogy/app/genealogies/{genealogyId}/lineage/persons`<br>`GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/page`<br>`GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/options` | `CONTRACT_CONFLICT` | 三个读取 owner 的边界、typed item、ID 和 capability 未闭合。 |
| `C-T-PARENT` | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/parents` | `CONTRACT_CONFLICT` | 父/母意图、`sex` enum、重复关系、图版本和权限未闭合。 |
| `C-T-SPOUSE` | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/spouses` | `CONTRACT_CONFLICT` | 关系方向、重复/冲突、图版本和权限未闭合。 |
| `C-T-SIBLING` | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/siblings` | `CONTRACT_CONFLICT` | 性别、共同父母、排行和关系原子性未闭合。 |
| `C-T-CHILD` | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/children` | `CONTRACT_CONFLICT` | 儿/女 `sex` enum、另一父母、排行和关系原子性未闭合。 |
| `C-T-EDIT` | `PUT /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}` | `CONTRACT_CONFLICT` | 宽 body、字段白名单、并发、头像链及 `appUserId` 绑定混入同一 PUT。 |
| `C-T-RANK` | 候选但非独立 owner`PUT /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}` | `CONTRACT_CONFLICT` | 只有单人物 `sortOrder`,没有同辈原子重排、冲突版本或完整结果;正式 PUT owner 仍是 `C-T-EDIT`。 |
| `C-T-BIND-INVITE` | 无当前人物邀请签发、送达和接受 operation | `MISSING_OPERATION` | 家谱票据目标也未声明,且不能证明等于“绑定某一人物”。 |
| `C-T-BIND-IDENTITY` | 无受邀账号身份查找或安全匹配 operation | `MISSING_OPERATION` | 不得按手机号、昵称或参考本地数据猜 `appUserId/memberId`。 |
| `C-T-BIND-MUTATION` | 冲突候选:`PUT /genealogy/app/genealogies/{genealogyId}/members/{memberId}`<br>冲突候选:`PUT /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}` | `CONTRACT_CONFLICT` | 一个写 `lineagePersonId`,一个写 `appUserId`;快照没有指定唯一 owner、原子性或禁止双写规则。 |
| `C-T-BIND-RESULT` | 无当前人物绑定结果查询 operation | `MISSING_OPERATION` | 结果未知时不得重试或双写。 |
| `C-T-DELETE` | `DELETE /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}` | `CONTRACT_CONFLICT` | 当前语义更接近停用;根人物、被引用人物、权限及图版本未闭合。 |
### 3.5 文件链
| 合同键 | 当前 operation 或唯一 owner | `contractState` | 当前阻塞和门禁 |
| --- | --- | --- | --- |
| `C-S-FILE-BINARY-WRITE` | `POST /genealogy/app/files/upload`<br>`POST /genealogy/app/files/resumable/init`<br>`POST /genealogy/app/files/resumable/chunk`<br>`POST /genealogy/app/files/resumable/complete` | `CONTRACT_CONFLICT` | 上传成功结果、`ossId` 安全 wire、断点续传幂等及完成后可访问事实未闭合。 |
| `C-S-FILE-REFERENCE-WRITE` | `POST /genealogy/app/files/reference`<br>`DELETE /genealogy/app/files/reference` | `CONTRACT_CONFLICT` | 引用对象身份、重复引用、删除引用与删除二进制边界未闭合。 |
| `C-S-FILE-READ` | 无按 `ossId`/文件 ID 恢复可访问 URL 或授权读取的 GET operation | `MISSING_OPERATION` | 上传返回值或历史 URL 不能替代冷启动读取 owner。 |
### 3.6 F 家族内容
| 合同键 | 当前 operation 或唯一 owner | `contractState` | 当前阻塞和门禁 |
| --- | --- | --- | --- |
| `C-F-FEED-READ` | `GET /genealogy/app/genealogies/{genealogyId}/feeds`<br>`GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}` | `CONTRACT_CONFLICT` | 读 DTO、游标、作者投影和媒体策略未闭合;关联 `family-feed-read-openapi-contract.ps1`。 |
| `C-F-FEED-PAGE-LEGACY` | `GET /genealogy/app/genealogies/{genealogyId}/feeds/page` | `CONTRACT_CONFLICT` | 门禁识别为应移除的重复读取 owner。 |
| `C-F-FEED-WRITE` | `POST /genealogy/app/genealogies/{genealogyId}/feeds`<br>`PUT /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}`<br>`DELETE /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}`<br>`POST /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/likes`<br>`DELETE /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/likes` | `DECLARED_UNVERIFIED` | 媒体另依赖文件链,所有 ID 另依赖 `C-S-ID`。 |
| `C-F-FEED-MODERATION` | 无动态置顶或加精 operation | `MISSING_OPERATION` | 不能用 `sortOrder/status` 猜测管理语义,也不能只改本地列表。 |
| `C-F-COMMENT-READ` | `GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments`<br>`GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments/{commentId}/replies` | `CONTRACT_CONFLICT` | 根评论/回复投影、删除占位和游标语义未闭合。 |
| `C-F-COMMENT-PAGE` | `GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments/page`<br>`GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments/{commentId}/replies/page` | `CONTRACT_CONFLICT` | `/comments/page` 是重复 owner 候选,回复分页也须与 canonical 读取统一。 |
| `C-F-COMMENT-WRITE` | `POST /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments`<br>`DELETE /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments/{commentId}` | `DECLARED_UNVERIFIED` | 回复创建只在 `parentCommentId` 真实语义确认后复用 POST。 |
| `C-F-ARTICLE` | `GET /genealogy/app/genealogies/{genealogyId}/article-categories`<br>`GET /genealogy/app/genealogies/{genealogyId}/articles`<br>`POST /genealogy/app/genealogies/{genealogyId}/articles`<br>`GET /genealogy/app/genealogies/{genealogyId}/articles/{articleId}`<br>`PUT /genealogy/app/genealogies/{genealogyId}/articles/{articleId}`<br>`DELETE /genealogy/app/genealogies/{genealogyId}/articles/{articleId}` | `DECLARED_UNVERIFIED` | 分类只有读取;文章字段、富文本和媒体白名单待验真。 |
| `C-F-ARTICLE-CATEGORY-WRITE` | 无谱文分类新增、编辑或删除 operation | `MISSING_OPERATION` | 分类读取不能反推分类管理能力,不得借文章 POST/PUT 代替。 |
| `C-F-ALBUM` | `GET /genealogy/app/genealogies/{genealogyId}/albums`<br>`POST /genealogy/app/genealogies/{genealogyId}/albums`<br>`PUT /genealogy/app/genealogies/{genealogyId}/albums/{albumId}`<br>`DELETE /genealogy/app/genealogies/{genealogyId}/albums/{albumId}` | `DECLARED_UNVERIFIED` | 封面媒体和删除语义另受文件与权限门禁约束。 |
| `C-F-PHOTO-READ` | `GET /genealogy/app/genealogies/{genealogyId}/albums/{albumId}/photos` | `DECLARED_UNVERIFIED` | 照片访问 URL 仍依赖 `C-S-FILE-READ`。 |
| `C-F-PHOTO-WRITE` | `POST /genealogy/app/genealogies/{genealogyId}/albums/{albumId}/photos`<br>`DELETE /genealogy/app/genealogies/{genealogyId}/albums/{albumId}/photos/{photoId}` | `DECLARED_UNVERIFIED` | 新增照片是“文件写+照片记录写”两段动作。 |
| `C-F-PHOTO-EDIT` | 无照片元数据编辑 operation | `MISSING_OPERATION` | 不得借相册 PUT 或本地数组修改。 |
| `C-F-VIDEO-DELETE` | `DELETE /genealogy/app/genealogies/{genealogyId}/videos/{videoId}` | `DECLARED_UNVERIFIED` | 孤立删除没有读取对象来源时不得启用。 |
| `C-F-VIDEO-READ` | 无视频列表、详情或播放资源读取 operation | `MISSING_OPERATION` | 不使用参考项目 URL 或全局 store 代替。 |
| `C-F-VIDEO-WRITE` | 无视频发布或编辑 operation | `MISSING_OPERATION` | 文件上传不等于视频实体发布。 |
| `C-F-VIDEO-COMMENT` | 无家族视频评论读取、发表、回复或删除 operation | `MISSING_OPERATION` | 动态评论 `C-F-COMMENT-*` 只拥有 feed 资源,不能跨资源复用为视频评论。 |
| `C-F-VIDEO-REACTION` | 无家族视频点赞/取消点赞 operation | `MISSING_OPERATION` | 不得用本地计数、动画或参考项目裸请求冒充服务端反应状态。 |
### 3.7 R 族务记录
| 合同键 | 当前 operation 或唯一 owner | `contractState` | 当前阻塞和门禁 |
| --- | --- | --- | --- |
| `C-R-RELATIVE` | `GET /genealogy/app/genealogies/{genealogyId}/relative-records`<br>`POST /genealogy/app/genealogies/{genealogyId}/relative-records`<br>`GET /genealogy/app/genealogies/{genealogyId}/relative-records/{relativeId}`<br>`PUT /genealogy/app/genealogies/{genealogyId}/relative-records/{relativeId}`<br>`DELETE /genealogy/app/genealogies/{genealogyId}/relative-records/{relativeId}` | `DECLARED_UNVERIFIED` | 收/送方向、金额精度、人物关联和权限待验真。 |
| `C-R-CEREMONY` | `GET /genealogy/app/genealogies/{genealogyId}/ceremonies`<br>`POST /genealogy/app/genealogies/{genealogyId}/ceremonies`<br>`GET /genealogy/app/genealogies/{genealogyId}/ceremonies/{ceremonyId}`<br>`PUT /genealogy/app/genealogies/{genealogyId}/ceremonies/{ceremonyId}`<br>`DELETE /genealogy/app/genealogies/{genealogyId}/ceremonies/{ceremonyId}`<br>`GET /genealogy/app/genealogies/{genealogyId}/ceremonies/{ceremonyId}/gifts`<br>`POST /genealogy/app/genealogies/{genealogyId}/ceremonies/{ceremonyId}/gifts`<br>`DELETE /genealogy/app/genealogies/{genealogyId}/ceremonies/{ceremonyId}/gifts/{giftId}` | `DECLARED_UNVERIFIED` | 祭祀献礼不能直接等同参考“贺礼邀请”。 |
| `C-R-GROWTH` | `GET /genealogy/app/genealogies/{genealogyId}/growth-records`<br>`POST /genealogy/app/genealogies/{genealogyId}/growth-records`<br>`GET /genealogy/app/genealogies/{genealogyId}/growth-records/{recordId}`<br>`PUT /genealogy/app/genealogies/{genealogyId}/growth-records/{recordId}`<br>`DELETE /genealogy/app/genealogies/{genealogyId}/growth-records/{recordId}` | `DECLARED_UNVERIFIED` | 人物 ID、分类 enum 和媒体字段待验真。 |
| `C-R-MEMO` | `GET /genealogy/app/genealogies/{genealogyId}/memos`<br>`POST /genealogy/app/genealogies/{genealogyId}/memos`<br>`GET /genealogy/app/genealogies/{genealogyId}/memos/{memoId}`<br>`PUT /genealogy/app/genealogies/{genealogyId}/memos/{memoId}`<br>`DELETE /genealogy/app/genealogies/{genealogyId}/memos/{memoId}` | `DECLARED_UNVERIFIED` | 日期、提醒和权限字段待验真。 |
| `C-R-MERIT-BASE` | `GET /genealogy/app/genealogies/{genealogyId}/merit-records`<br>`POST /genealogy/app/genealogies/{genealogyId}/merit-records`<br>`DELETE /genealogy/app/genealogies/{genealogyId}/merit-records/{meritId}` | `DECLARED_UNVERIFIED` | 只支持列表、新增和单删;图片另依赖文件链。 |
| `C-R-MERIT-DETAIL` | 无功德详情 operation | `MISSING_OPERATION` | 列表项投影不能伪装远端详情。 |
| `C-R-MERIT-EDIT` | 无功德编辑 operation | `MISSING_OPERATION` | 不得用再次 POST 猜测新增/编辑复用语义。 |
| `C-R-LIFE` | 无人生事件 operation | `MISSING_OPERATION` | 保持关闭。 |
| `C-R-DOCUMENT` | 无重要证件 operation | `MISSING_OPERATION` | 隐私敏感,不能迁移参考旧接口或示例数据。 |
| `C-R-GREETING` | 无独立“贺礼邀请”列表、详情、增改删 operation | `MISSING_OPERATION` | `relative-records` 是人情往来,`ceremonies/gifts` 是祭祀/献礼;二者都不能因名称接近而冒充本业务。 |
### 3.8 N 通知
| 合同键 | 当前 operation 或唯一 owner | `contractState` | 当前阻塞和门禁 |
| --- | --- | --- | --- |
| `C-N-LIST` | `GET /genealogy/app/notifications` | `CONTRACT_CONFLICT` | 当前是无稳定 item/type/target schema 的通用列表;关联 `notification-read-openapi-contract.ps1`。 |
| `C-N-UNREAD-COUNT` | 无当前未读数 operation | `MISSING_OPERATION` | 门禁目标 `GET /genealogy/app/notifications/unread-count` 未声明。 |
| `C-N-READ-ONE` | `POST /genealogy/app/notifications/{notificationId}/read` | `CONTRACT_CONFLICT` | 幂等、当前账号归属及稳定结果未闭合。 |
| `C-N-READ-ALL` | `POST /genealogy/app/notifications/read-all` | `CONTRACT_CONFLICT` | 当前账号范围、幂等和结果未闭合。 |
| `C-N-DETAIL-SAME-SESSION` | 本地 owner:N01 当前会话不可变列表项快照;来源仍是 `GET /genealogy/app/notifications` | `CONTRACT_CONFLICT` | 同会话投影可保留,但列表 item/type/target schema 不稳定;不得猜业务深链。 |
| `C-N-DETAIL-COLD-START` | 无通知详情或按通知 ID 重取 operation | `MISSING_OPERATION` | 冷启动、进程重启、深链或快照丢失时诚实关闭。 |
| `C-N-TYPED-REMINDER` | 候选复用 `GET /genealogy/app/notifications` | `CONTRACT_CONFLICT` | 现有列表缺稳定生日/疫苗/备忘等 type、目标 route key、参数和权限失效 schema,不能从文案猜类型。 |
### 3.9 M 我的
| 合同键 | 当前 operation 或唯一 owner | `contractState` | 当前阻塞和门禁 |
| --- | --- | --- | --- |
| `C-M-HELP-LIST` | `GET /genealogy/app/help-articles` | `CONTRACT_CONFLICT` | 完整列表 DTO 未满足 `help-center-openapi-contract.ps1`。当前 M06 的文章阅读必须由列表项投影承接。 |
| `C-M-HELP-DETAIL` | `GET /genealogy/app/help-articles/{helpId}` | `CONTRACT_CONFLICT` | operation 已声明,但当前门禁指定完整列表为唯一远端 owner;规划不新增独立详情路由,先统一 owner 后才可启用本键。 |
| `C-M-FEEDBACK` | `GET /genealogy/app/feedback`<br>`POST /genealogy/app/feedback` | `DECLARED_UNVERIFIED` | 列表范围、提交幂等和结果未知处理待验真。 |
| `C-M-PROMO` | `GET /genealogy/app/promotions` | `DECLARED_UNVERIFIED` | 只证明推广内容读取,不代表邀请码、积分、奖励或分享变现。 |
| `C-M-PROMO-VIDEO` | 无独立广告/宣传视频列表、详情或播放资源 operation | `MISSING_OPERATION` | `GET /genealogy/app/promotions` 未证明返回稳定可播放媒体;家族视频 `C-F-VIDEO-READ` 不能跨域替代。 |
| `C-M-PROMO-VIDEO-COMMENT` | 无广告/宣传视频评论读取、发表、回复或删除 operation | `MISSING_OPERATION` | 不得借动态评论或家族视频评论 owner 冒充。 |
| `C-M-PROMO-VIDEO-REACTION` | 无广告/宣传视频点赞/取消点赞 operation | `MISSING_OPERATION` | 不得用本地计数或参考控件冒充服务端状态。 |
| `C-M-REFERRAL` | 无 APP 推广推荐码/二维码签发或读取 operation | `MISSING_OPERATION` | 推广推荐码不是家谱邀请票据,不能解析、兑换或直接入谱,也不能借 `C-G-INVITE-ISSUE` 冒充。 |
| `C-M-REWARD` | 无邀请奖励、积分余额或奖励明细 operation | `MISSING_OPERATION` | 不得从推广文案、邀请码或本地计数推断奖励。 |
| `C-M-VIP-READ` | `GET /genealogy/app/vip/packages`<br>`GET /genealogy/app/vip/orders` | `DECLARED_UNVERIFIED` | 待 typed item、金额单位和订单状态验真。 |
| `C-M-VIP-ORDER` | `POST /genealogy/app/vip/orders` | `CONTRACT_CONFLICT` | 通用对象结果没有稳定订单 ID、幂等键、订单详情或结果未知恢复链。 |
| `C-M-VIP-PAY` | 无支付、支付结果查询、取消或退款 operation | `MISSING_OPERATION` | 不得由创建订单 HTTP 200 进入成功页。 |
| `C-M-MONEY` | 无余额、资金流水、提现提交或提现状态 operation | `MISSING_OPERATION` | 文件上传不能单独启用收款码/提现流程。 |
未直接出现在某一 PA 合同列的键也有明确用途,不能被误当成漏接动作:`C-S-ID/C-S-CAPABILITY/C-S-NAV` 是全部相关 PA 自动继承的共享门禁;`C-G-DETAIL-READ` 是与 overview 竞争、待退役的诊断键;`C-G-MEMBER-UPDATE``C-T-BIND-MUTATION` 的冲突候选 operation 证据;`C-F-FEED-PAGE-LEGACY/C-F-COMMENT-PAGE` 是待移除的重复读取 owner`C-F-PHOTO-EDIT` 记录未获批准产品动作的合同缺口;`C-M-HELP-DETAIL` 记录被 list-only 决策压住的声明 operation。它们不增加 PA 分母,也不能绕过对应 canonical owner。
## 四、第一参考源:60 个设计文件
### 4.1 D001—D030
| 文件 ID / 文件 | 页面与逐动作 ID | 当前合同键与 owner | 甄别结论与改造边界 |
| --- | --- | --- | --- |
| `D001` `4、族谱网APP端-思维导图.pdf` | `D001-P01-A01` APP 功能树;`A02` 后台/网站/PC 分支 | A/G/T/F/R/N/M;其他端无当前路由 | `A01 仅参考交互`,作为反向覆盖目录;`A02 与当前产品冲突,明确舍弃`,不扩张本仓库 |
| `D002` `编辑家族视频.png` | `D002-P01-A01` 选择并删除视频 | `C-F-VIDEO-READ/C-F-VIDEO-DELETE` → F10 | `后端缺失,暂时关闭`;不能因有孤立 DELETE 就启用管理页 |
| `D003` `编辑相册.png` | `D003-P01-A01` 改名/描述/封面;`A02` 删除相册 | `C-F-ALBUM``C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` → F07/F08 | 两项均 `改造后采用`;权限、结果未知、封面文件链和删除确认必须重做 |
| `D004` `创建家谱 .png` | `D004-P01-A01` 填姓氏/谱名/堂号/地望/祠堂/访问权限并创建 | `C-S-REGION-CHILDREN/C-S-REGION-PATH/C-S-REGION-SEARCH/C-S-REGION-DETAIL``C-G-CREATE-GENEALOGY``C-T-PERSON-CREATE``C-G-CREATE-RECOVERY` → G03 | `改造后采用`;自由文本地望不能代替必填 `regionCode`;固定两阶段顺序,但首写无稳定词法 ID 或结果未知时必须停止,不能从 mine 列表猜回 |
| `D005` `登陆.png` | `D005-P01-A01` 密码登录;`A02` 忘记;`A03` 注册;`A04` 微信登录 | `C-A-TAC-API/C-A-TAC-A11Y/C-A-LOGIN/C-A-SMS-SEND/C-A-RESET/C-A-REGISTER/C-A-WECHAT` → A01/A04/A05 | A01—A03 `改造后采用`,补 TAC/协议/错误;A04 `后端缺失,暂时关闭` |
| `D006` `调整世代.png` | `D006-P01-A01` 调整始祖世代并联动其他成员 | `C-G-SORT` → G 候选 | `后端缺失,暂时关闭`;不能借 T06 排行或 G12 字辈 |
| `D007` `发布视频.png` | `D007-P01-A01` 标题/描述/上传并发布视频 | `C-F-VIDEO-WRITE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` → F10 | `后端缺失,暂时关闭`;上传存在也不等于视频实体发布存在 |
| `D008` `功德录(1).png` | `D008-P01-A01` 浏览功德记录;`A02` 进入管理 | `C-R-MERIT-BASE/C-R-MERIT-DETAIL` → R11 | A01 `改造后采用`A02 `仅参考交互`,详情/编辑合同不足 |
| `D009` `功德录.png` | `D009-P01-A01` 多选记录;`A02` 删除 | `C-R-MERIT-BASE` → R11 | A01 `仅参考交互`A02 `改造后采用`,服务端仅单删且需权限/二次确认 |
| `D010` `管理世代.png` | `D010-P01-A01` 浏览字辈代次;`A02` 编辑;`A03` 新增 | `C-G-POEM` → G12 | 三项 `改造后采用`;统一到 G12 单一 owner,不建立“世代”第二合同 |
| `D011` `加入家谱.png` | `D011-P01-A01` 粘贴邀请码并直接加入 | `C-G-INVITE-RESOLVE/C-G-INVITE-REDEEM/C-G-INVITE-RESULT` → G06 | `后端缺失,暂时关闭`;不可借普通 `C-G-JOIN-CREATE` |
| `D012` `家谱主页 1.png` | `D012-P01-A01` 家谱总览;`A02` 模块导航 | `C-G-OVERVIEW` → G05 | A01/A02 均为 `仅参考交互`;作为独立总览状态证据保留,不能因与 D043 相似就判重复;红色整页视觉不直接采纳 |
| `D013` `家谱主页 22.png` | `D013-P01-A01` 贺礼邀请列表;`A02` 分类筛选;`A03` 新建 | `C-R-GREETING` → R 候选 | 三项 `待用户确认`;“贺礼邀请”既非人情簿也未证明等于祭祀献礼 |
| `D014` `家谱主页 23.png` | `D014-P01-A01` 多选贺礼;`A02` 删除 | 同 D013 | A01 `仅参考交互`A02 `待用户确认`,语义与权限未定 |
| `D015` `家谱主页 24.png` | `D015-P01-A01` 贺礼详情 | 同 D013 | `待用户确认`;不能用 ceremony gift 详情猜产品语义 |
| `D016` `家谱主页 25.png` | `D016-P01-A01` 创建/编辑贺礼 | 同 D013 | `待用户确认`;标题、类型、金额、受邀人和状态合同均需定义 |
| `D017` `家谱主页 26.png` | `D017-P01-A01` 成长日志列表;`A02` 分类/筛选;`A03` 新建 | `C-R-GROWTH` → R08 | 三项 `改造后采用`;人物 ID 与 recordType enum 先验真 |
| `D018` `家谱主页 27.png` | `D018-P01-A01` 成长日志详情 | `C-R-GROWTH` → R08 | `改造后采用` |
| `D019` `家谱主页 28.png` | `D019-P01-A01` 多选成长记录;`A02` 删除 | `C-R-GROWTH` → R08 | A01 `仅参考交互`A02 `改造后采用`,只删测试数据并二次确认 |
| `D020` `家谱主页 29.png` | `D020-P01-A01` 新建成长日志 | `C-R-GROWTH` → R08 | `改造后采用`;绑定真实人物,禁止本地假插入 |
| `D021` `家谱主页 30.png` | `D021-P01-A01` 人情簿列表;`A02` 新建 | `C-R-RELATIVE` → R03/R04 | 两项 `改造后采用`;收/送方向、金额精度先验真 |
| `D022` `家谱主页 31.png` | `D022-P01-A01` 多选人情记录;`A02` 删除 | `C-R-RELATIVE` → R03/R04 | A01 `仅参考交互`A02 `改造后采用` |
| `D023` `家谱主页 32.png` | `D023-P01-A01` 新建/编辑人情记录 | `C-R-RELATIVE` → R04 | `改造后采用` |
| `D024` `家谱主页 33.png` | `D024-P01-A01` 备忘列表;`A02` 新建 | `C-R-MEMO` → R10 | 两项 `改造后采用` |
| `D025` `家谱主页 34.png` | `D025-P01-A01` 管理员列表;`A02` 添加 | `C-G-ADMIN/C-G-MEMBER-READ` → G 候选 | 两项 `待用户确认`;无 capability owner,不新增假页面 |
| `D026` `家谱主页 35.png` | `D026-P01-A01` 多选管理员;`A02` 删除;`A03` 权限配置 | `C-G-ADMIN/C-G-MEMBER-READ/C-G-MEMBER-REMOVE` | A01 `仅参考交互`A02/A03 `待用户确认`,破坏性/授权能力未闭合 |
| `D027` `家谱主页 36.png` | `D027-P01-A01` 选择成员并添加管理员 | `C-G-ADMIN/C-G-MEMBER-READ` | `待用户确认` |
| `D028` `家谱主页 37.png` | `D028-P01-A01` 配置管理员权限 | `C-G-ADMIN` | `待用户确认`;不能把前端勾选框当服务端 capability |
| `D029` `家谱主页 38.png` | `D029-P01-A01` 发布文字/图片动态;`A02` 置顶;`A03` 加精 | `C-F-FEED-WRITE/C-F-FEED-MODERATION/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` → F02 | A01 `改造后采用`A02/A03 `后端缺失,暂时关闭` |
| `D030` `家谱主页 39.png` | `D030-P01-A01` 表格式世系浏览 | `C-T-TREE` → T01 | `仅参考交互`;可作为辅助模式候选,但头像卡树是当前必需主模式 |
### 4.2 D031—D060
| 文件 ID / 文件 | 页面与逐动作 ID | 当前合同键与 owner | 甄别结论与改造边界 |
| --- | --- | --- | --- |
| `D031` `家谱主页 40.png` | `D031-P01-A01` 搜索世系人物;`A02` 选择定位 | `C-T-LIST/C-T-TREE` → T01/T07 | 两项 `改造后采用`;搜索与树定位共享人物 ID owner |
| `D032` `家谱主页 41.png` | `D032-P01-A01` 头像人物卡树;`A02` 横纵浏览 | `C-T-TREE/C-S-FILE-READ` → T01 | 两项 `改造后采用`,且是用户明确的 T01 必需能力;视觉重做为当前国风 |
| `D033` `家谱主页 42.png` | `D033-P01-A01` 查看资料;`A02` 父亲;`A03` 母亲;`A04` 配偶;`A05` 兄弟姐妹;`A06` 排行;`A07` 儿子;`A08` 女儿;`A09` 邀请绑定;`A10` 编辑 | `C-T-DETAIL/C-T-PARENT/C-T-SPOUSE/C-T-SIBLING/C-T-RANK/C-T-CHILD/C-T-BIND-INVITE/C-T-BIND-IDENTITY/C-T-BIND-MUTATION/C-T-BIND-RESULT/C-T-EDIT` → T01/T03—T06/独立邀请流程 | 十项均为用户明确需求,产品取舍为 `改造后采用`;合同不足的动作执行态关闭且产品完成度 BLOCKED,不能删入口或假成功 |
| `D034` `家谱主页 43.png` | `D034-P01-A01` 查看人物资料空态;`A02` 资料/亲属切换;`A03` 邀请其激活绑定;`A04` 编辑 | `C-T-DETAIL/C-T-BIND-INVITE/C-T-BIND-IDENTITY/C-T-BIND-MUTATION/C-T-BIND-RESULT/C-T-EDIT` → T03/T05/邀请流程 | A01/A02/A04 `改造后采用`A03 产品需求已批准但当前 `后端缺失,暂时关闭` |
| `D035` `家谱主页 44.png` | `D035-P01-A01` 查看人物亲属列表;`A02` 点亲属继续查看 | `C-T-DETAIL/C-T-LIST` → T03 | 两项 `改造后采用`;避免递归叠原生页,沿用单实例轨迹 |
| `D036` `家谱主页 45.png` | `D036-P01-A01` 选择/绑定成长对象;`A02` 编辑头像姓名排行等人物字段;`A03` 填成长日志 | `C-T-BIND-INVITE/C-T-BIND-IDENTITY/C-T-BIND-MUTATION/C-T-BIND-RESULT/C-T-EDIT/C-T-RANK/C-R-GROWTH` → T04/T05/T06/R08 | A01 `后端缺失,暂时关闭`A02/A03 `改造后采用`且必须按 owner 拆分,不能把排行和人物编辑合为宽表单 |
| `D037` `家谱主页 46.png` | `D037-P01-A01` 阅读富文本详情 | `C-R-CEREMONY/C-M-HELP-LIST` → 取决于来源 | `仅参考交互`;通用长文阅读形态可复用,但数据 owner 必须由入口决定 |
| `D038` `家谱主页 47.png` | `D038-P01-A01` 编辑人物基础资料;`A02` 微信/QQ/地址/学历/职业等扩展隐私资料 | `C-T-EDIT` → T05 | A01 `改造后采用`A02 `与当前产品冲突,明确舍弃`,当前需求未授权且后端无隐私/可见性 owner,不采集、不存储、不上传 |
| `D039` `家谱主页 48.png` | `D039-P01-A01` 帮助分类;`A02` 搜索 | `C-M-HELP-LIST` → M06 | 两项 `改造后采用` |
| `D040` `家谱主页 49.png` | `D040-P01-A01` 阅读帮助文章 | `C-M-HELP-LIST` → M06 | `改造后采用`;由完整列表项投影承接,不新增独立远端 owner |
| `D041` `家谱主页 50.png` | `D041-P01-A01` 分享应用;`A02` 邀请奖励/积分;`A03` 微信小程序首次登录限制 | `C-M-PROMO/C-S-PLATFORM-SHARE/C-M-REWARD/C-A-WECHAT` → 无当前路由的 M 推广候选 | A01 `仅参考交互`A02/A03 `待用户确认`并保持关闭,禁止承诺积分或微信链,更不得覆盖 M08 家谱邀请 |
| `D042` `家谱主页 51.png` | `D042-P01-A01` 填标题/描述并提交反馈 | `C-M-FEEDBACK` → M07 | `改造后采用`;真实提交失败和结果未知不得清空 |
| `D043` `家谱主页.png` | `D043-P01-A01` 家谱总览;`A02` 模块导航 | `C-G-OVERVIEW` → G05 | A01/A02 均为 `仅参考交互`;独立保留为可能的角色、主题或业务状态,待入口/数据/权限证据证明后才决定是否与 D012 共用状态实现 |
| `D044` `家族视频.png` | `D044-P01-A01` 视频列表;`A02` 播放;`A03` 管理;`A04` 发布 | `C-F-VIDEO-READ/C-F-VIDEO-WRITE/C-F-VIDEO-DELETE` → F10 | 四项 `后端缺失,暂时关闭`;播放 UI 可做关闭页信息参考 |
| `D045` `家族相册.png` | `D045-P01-A01` 照片墙;`A02` 预览;`A03` 上传照片;`A04` 编辑相册 | `C-F-ALBUM/C-F-PHOTO-READ/C-F-PHOTO-WRITE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` → F08/F09 | A01—A04 均为 `改造后采用`;A03/A04 必须在文件、权限和两段写闭环后实施 |
| `D046` `谱文(1).png` | `D046-P01-A01` 管理谱文分类 | `C-F-ARTICLE-CATEGORY-WRITE` → F04/F06 | `仅参考交互`;当前合同只有分类读取,无分类增改删,管理动作关闭 |
| `D047` `谱文.png` | `D047-P01-A01` 浏览谱文分类;`A02` 进入内容 | `C-F-ARTICLE` → F04 | 两项 `改造后采用` |
| `D048` `谱文详情.png` | `D048-P01-A01` 阅读谱文;`A02` 添加/编辑 | `C-F-ARTICLE` → F05/F06 | 两项 `改造后采用`;富文本和媒体必须消毒/归一化 |
| `D049` `设置世代.png` | `D049-P01-A01` 编辑代数/字辈;`A02` 保存;`A03` 删除 | `C-G-POEM` → G12 | A01—A03 均为 `改造后采用`;A03 只能改造成停用/恢复,不得猜测为物理删除 |
| `D050` `首页 14.png` | `D050-P01-A01` 我的家谱列表;`A02` 搜索;`A03` 创建;`A04` 普通申请加入;`A05` 邀请码直入 | `C-G-MINE/C-G-PUBLIC/C-G-OPTIONS/C-G-CREATE-GENEALOGY/C-T-PERSON-CREATE/C-G-CREATE-RECOVERY/C-G-JOIN-CREATE/C-G-JOIN-RECOVERY/C-G-INVITE-RESOLVE/C-G-INVITE-REDEEM/C-G-INVITE-RESULT` → G01/G03/G06/G08 | A01—A04 `改造后采用`A05 `后端缺失,暂时关闭` |
| `D051` `首页 16.png` | `D051-P01-A01` 家族动态列表;`A02` 图片墙;`A03` 评论/回复;`A04` 发布 | `C-F-FEED-READ/C-F-FEED-WRITE/C-F-COMMENT-READ/C-F-COMMENT-WRITE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` → F01/F02/F03 | 四项 `改造后采用`;不复制示例图片、电话号码或静态评论 |
| `D052` `首页 18.png` | `D052-P01-A01` 通知列表;`A02` 全部已读;`A03` 审核加入;`A04` 打开活动通知 | `C-N-LIST/C-N-READ-ALL/C-N-DETAIL-SAME-SESSION/C-N-DETAIL-COLD-START/C-G-JOIN-AUDIT` → N01/N02/G10 | A01—A03 `改造后采用`;A03 必须深链到 G10,不能由 N01 直接审核;A04 `后端缺失,暂时关闭`,不得在无目标合同时伪造跳转 |
| `D053` `首页 19.png` | `D053-P01-A01` 查看本人资料;`A02` 推广;`A03` 反馈;`A04` 安全;`A05` 帮助;`A06` 退出 | `C-A-PROFILE-READ/C-M-PROMO/C-M-FEEDBACK/C-A-PASSWORD-CHANGE/C-A-PHONE-CHANGE/C-A-LOGOUT/C-M-HELP-LIST` → M01/M03/M06/M07/M10A02 为无当前路由候选 | A01、A03—A06 `改造后采用`A02 同为 `改造后采用`的候选证据,但不承诺奖励且不得覆盖 M08 家谱邀请 |
| `D054` `首页 .png` | `D054-P01-A01` 我的家谱列表;`A02` 创建;`A03` 加入 | 同 D050 | A01—A03 均为 `仅参考交互`;独立保留为可能的主题、角色或业务状态,不能只按颜色相似度与 D050 合并 |
| `D055` `思维导图.png` | `D055-P01-A01` APP 认证/家谱/世系/内容/族务/消息/我的全功能树;`A02` 后台;`A03` 网站;`A04` PC 管理端 | A/G/T/F/R/N/M;其他端无当前路由 | A01 `仅参考交互`并作为全项目反向检查目录;A02—A04 `与当前产品冲突,明确舍弃` |
| `D056` `添加功德人.png` | `D056-P01-A01` 姓名/内容/图片并新增功德记录 | `C-R-MERIT-BASE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` → R11 | `改造后采用`;当前无编辑/详情 operation,图片 owner 待确认 |
| `D057` `添加谱文.png` | `D057-P01-A01` 标题/正文/落款/图片质量/媒体并保存 | `C-F-ARTICLE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` → F06 | `改造后采用`;只提交当前合同白名单,图片质量不是后端业务字段 |
| `D058` `相册.png` | `D058-P01-A01` 相册列表;`A02` 新建;`A03` 打开详情 | `C-F-ALBUM` → F07/F08 | 三项 `改造后采用` |
| `D059` `新建相册.png` | `D059-P01-A01` 名称/描述并创建相册 | `C-F-ALBUM` → F07 | `改造后采用` |
| `D060` `字辈谱.png` | `D060-P01-A01` 浏览字辈;`A02` 管理 | `C-G-POEM` → G12 | 两项 `改造后采用` |
## 五、第二参考源:78 条活动路由
“参考 wrapper”只证明旧项目尝试过某动作,不是当前合同。未列入 wrapper 的裸 `uni.request`、全局 store 传 URL、硬编码数据和被注释调用均不得迁入。
### 5.1 J001—J026
| 路由 ID / 参考路由 | 页面与逐动作 ID | 参考 wrapper | 当前合同键与 owner | 甄别结论与边界 |
| --- | --- | --- | --- | --- |
| `J001` `pages/index/index` | `J001-A01` 读用户/家谱;`A02` 切谱;`A03` 模块导航;`A04` 宣传视频 | `getALLCreatGenealogyList`, `getAuthByUserGenealogy`, `getCreatGenealogy`, `getDictDataByDictCode`, `getUserInfo`, `getVideopublicize` | `C-G-MINE/C-G-OVERVIEW/C-A-PROFILE-READ/C-M-PROMO-VIDEO` → G01/G05A04 为无当前路由候选 | A01—A03 `仅参考交互`并拆 ownerA04 `后端缺失,暂时关闭`,拒绝跨域巨型首页 |
| `J002` `pages/login/login` | `J002-A01` 密码登录;`A02` 注册;`A03` 重置;`A04` 微信登录 | `getCaptcha`, `getDictDataByDictCodeNoToken`, `login`, `register`, `userResetPwd`, `wechatAppLogin` | `C-A-TAC-API/C-A-TAC-A11Y/C-A-LOGIN/C-A-SMS-SEND/C-A-REGISTER/C-A-RESET/C-A-WECHAT` → A01/A04/A05 | A01—A03 `改造后采用`A04 `后端缺失,暂时关闭`;页面必须拆 owner |
| `J003` `pages/render/render` | `J003-A01` 阳历/农历日期时间选择演示 | 无 | `C-LOCAL` → 共享控件候选 | `与当前产品冲突,明确舍弃`活动路由;需要日期选择时只参考控件行为 |
| `J004` `pages/index/addGenealogy` | `J004-A01` 输入邀请信息直接入谱 | `inviteCreatGenealogy` | `C-G-INVITE-RESOLVE/C-G-INVITE-REDEEM/C-G-INVITE-RESULT` → G06 | `后端缺失,暂时关闭`;与 G08 普通申请隔离 |
| `J005` `pages/index/createGenealogy` | `J005-A01` 选字典/地区并创建家谱 | `addCreatGenealogy`, `getDictDataByDictCode` | `C-S-REGION-CHILDREN/C-S-REGION-PATH/C-S-REGION-SEARCH/C-S-REGION-DETAIL/C-G-CREATE-GENEALOGY/C-T-PERSON-CREATE/C-G-CREATE-RECOVERY` → G03 | `改造后采用`;旧一次提交改为两阶段顺序,首写结果未知时停止并人工对账 |
| `J006` `pages/index/invite` | `J006-A01` 邀请家人空壳 | 无 | `C-G-INVITE-ISSUE` | `与当前产品冲突,明确舍弃`空壳;不冒充 T01 邀请绑定 |
| `J007` `pages/index/genealogyList/index` | `J007-A01` 家谱列表/选择;`A02` 删除家谱;`A03` 读配置 | `delGenealogy`, `getCreatGenealogy`, `getDictDataByDictCode`, `getDictDataByDictCodeNoToken` | `C-G-MINE/C-G-DELETE` → G01 | A01/A03 `改造后采用`A02 `待用户确认`且不执行真实删除 |
| `J008` `pages/index/genealogyList/characterTable` | `J008-A01` 查看字辈 | `getCharacterTable` | `C-G-POEM` → G12 | `改造后采用` |
| `J009` `pages/index/genealogyList/add` | `J009-A01` 添加/编辑字辈;`A02` 删除 | `addCharacterTable`, `deleteCharacterTable` | `C-G-POEM` → G12 | A01/A02 均为 `改造后采用`;A02 只能按停用/恢复合同改造,拒绝物理删除猜测 |
| `J010` `pages/index/puwen/classList` | `J010-A01` 读取并选择谱文分类 | `getDictDataByDictCode` | `C-F-ARTICLE` → F04/F06 | `仅参考交互`;分类必须来自当前文章合同 |
| `J011` `pages/index/puwen/index` | `J011-A01` 谱文列表/筛选;`A02` 访问密码;`A03` 删除 | `deleteLiterature`, `getDictDataByDictCode`, `getLiterature`, `pw_is_cehck` | `C-F-ARTICLE/C-S-CONTENT-LOCK` → F04/F05 | A01/A03 `改造后采用`A02 `待用户确认`,当前无内容锁 owner |
| `J012` `pages/index/puwen/genealogy` | `J012-A01` 谱文详情 | `getLiteratureDetails` | `C-F-ARTICLE` → F05 | `改造后采用`;作为独立入口/详情状态证据保留,证明等价后才与其他详情入口共用实现 |
| `J013` `pages/index/puwen/add` | `J013-A01` 新建;`A02` 编辑;`A03` 读原详情 | `addLiterature`, `getLiteratureDetails` | `C-F-ARTICLE` → F06 | 三项 `改造后采用` |
| `J014` `pages/index/puwen/wjmm` | `J014-A01` 重置谱文访问密码;`A02` 成长日志访问密码;`A03` 证件访问密码 | `addLiterature`, `cz_update_password`, `cz_update_password_hx`, `getLiteratureDetails`, `pw_update_password`, `pw_update_password_hx`, `zy_update_password`, `zy_update_password_hx` | `C-S-CONTENT-LOCK` | 三项 `待用户确认`;明确不映射 A05,拒绝跨 F/R/证件的复制污染 |
| `J015` `pages/index/album/index` | `J015-A01` 相册列表 | `getPhotocategoryList` | `C-F-ALBUM` → F07 | `改造后采用` |
| `J016` `pages/index/album/add` | `J016-A01` 新建相册 | `addPhotocategory` | `C-F-ALBUM` → F07 | `改造后采用` |
| `J017` `pages/index/album/details` | `J017-A01` 照片列表;`A02` 上传;`A03` 删除照片 | `deletePhoto`, `getPhotoList`, `uploadPhoto` | `C-F-PHOTO-READ/C-F-PHOTO-WRITE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` → F08/F09 | 三项 `改造后采用`;上传与记录两段确认 |
| `J018` `pages/index/album/edit` | `J018-A01` 编辑相册 | `addPhotocategory` | `C-F-ALBUM` → F07/F08 | `改造后采用`;不复用“新增”旧 wrapper 语义 |
| `J019` `pages/index/document/index` | `J019-A01` 证件列表;`A02` 密码校验;`A03` 删除 | `deletePhotodocument`, `getPhotodocument`, `zy_is_cehck` | `C-R-DOCUMENT/C-S-CONTENT-LOCK` | 三项 `待用户确认`;默认关闭,不迁移身份证明或真实 PII |
| `J020` `pages/index/document/add` | `J020-A01` 新建/编辑证件;`A02` 选择类型 | `addPhotodocument`, `getDictDataByDictCode` | `C-R-DOCUMENT` | 两项 `待用户确认`且默认关闭 |
| `J021` `pages/index/video/index` | `J021-A01` 视频列表;`A02` 播放;`A03` 删除 | `deleteVideo`, `getVideoList` | `C-F-VIDEO-READ/C-F-VIDEO-DELETE` → F10 | A01—A03 均为 `后端缺失,暂时关闭`;A03 不得因存在孤立删除 operation 就暴露入口 |
| `J022` `pages/index/video/add` | `J022-A01` 上传;`A02` 发布/编辑视频 | `addVideo`, `uploadFile` | `C-F-VIDEO-WRITE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` → F10 | 两项 `后端缺失,暂时关闭` |
| `J023` `pages/index/video/xcindex` | `J023-A01` 宣传视频列表 | `getVideopublicize` | `C-M-PROMO-VIDEO` | `待用户确认`;宣传与家族视频分离 |
| `J024` `pages/index/video/details` | `J024-A01` 从全局 store 取 URL 播放 | 无 | `C-F-VIDEO-READ` → F10 | `仅参考交互`;全局 URL 传递方式明确舍弃 |
| `J025` `pages/index/meritsVirtues/index` | `J025-A01` 功德列表;`A02` 删除 | `deleteVirtues`, `getVirtuesList` | `C-R-MERIT-BASE` → R11 | 两项 `改造后采用` |
| `J026` `pages/index/meritsVirtues/add` | `J026-A01` 新增;`A02` 编辑;`A03` 读取详情 | `addVirtues`, `getVirtuesDetail` | `C-R-MERIT-BASE/C-R-MERIT-EDIT/C-R-MERIT-DETAIL` → R11 | A01 `改造后采用`A02/A03 `后端缺失,暂时关闭`,当前无详情/编辑 operation,不能假完成 |
### 5.2 J027—J052
| 路由 ID / 参考路由 | 页面与逐动作 ID | 参考 wrapper | 当前合同键与 owner | 甄别结论与边界 |
| --- | --- | --- | --- | --- |
| `J027` `pages/index/meritsVirtues/details` | `J027-A01` 功德详情 | `getVirtuesDetail` | `C-R-MERIT-DETAIL` → R11 | `后端缺失,暂时关闭`独立详情;可在列表使用服务端已有投影,但不能伪造详情 |
| `J028` `pages/index/gift/index` | `J028-A01` 贺礼邀请列表;`A02` 分类;`A03` 删除 | `deleteGift`, `getDictDataByDictCode`, `getGift` | `C-R-GREETING` | 三项 `待用户确认`;先定义业务语义 |
| `J029` `pages/index/gift/details` | `J029-A01` 贺礼详情 | `getGiftInfo` | 同 J028 | `待用户确认` |
| `J030` `pages/index/gift/add` | `J030-A01` 新建;`A02` 编辑;`A03` 分类;`A04` 读取详情 | `addGift`, `getDictDataByDictCode`, `getGiftInfo` | 同 J028 | 四项 `待用户确认` |
| `J031` `pages/index/log/index` | `J031-A01` 成长列表;`A02` 删除/批量;`A03` 进入详情 | `deleteDevelopmentlog`, `development`, `getDevelopmentlogList` | `C-R-GROWTH` → R08 | A01—A03 均为 `改造后采用`;A02 只按当前单条合同和权限实现;与 J070/J071 分别保留为可能的入口/筛选/角色状态 |
| `J032` `pages/index/log/details` | `J032-A01` 成长详情 | `getDevelopmentlogDetails` | `C-R-GROWTH` → R08 | `改造后采用` |
| `J033` `pages/index/log/add` | `J033-A01` 新增;`A02` 编辑;`A03` 分类 | `addDevelopmentlog`, `getDevelopmentlogDetails`, `getDictDataByDictCode` | `C-R-GROWTH` → R08 | 三项 `改造后采用` |
| `J034` `pages/index/favor/index` | `J034-A01` 人情簿列表;`A02` 删除 | `deleteCashgift`, `getCashgiftList` | `C-R-RELATIVE` → R03 | 两项 `改造后采用` |
| `J035` `pages/index/favor/details` | `J035-A01` 人情记录详情 | `getCashgiftDetails` | `C-R-RELATIVE` → R03/R04 | `改造后采用` |
| `J036` `pages/index/favor/add` | `J036-A01` 新增;`A02` 编辑 | `addCashgift`, `getCashgiftDetails` | `C-R-RELATIVE` → R04 | 两项 `改造后采用` |
| `J037` `pages/index/memorandum/index` | `J037-A01` 备忘列表;`A02` 删除 | `deleteMemorandum`, `getMemorandum` | `C-R-MEMO` → R10 | 两项 `改造后采用` |
| `J038` `pages/index/memorandum/add` | `J038-A01` 新增;`A02` 编辑;`A03` 读取详情 | `addMemorandum`, `getMemorandumInfo` | `C-R-MEMO` → R10 | 三项 `改造后采用` |
| `J039` `pages/index/memorandum/details` | `J039-A01` 备忘详情 | `getMemorandumInfo` | `C-R-MEMO` → R10 | `改造后采用` |
| `J040` `pages/index/admin/index` | `J040-A01` 管理员列表;`A02` 移除 | `getManageUserDel`, `getManageUserList` | `C-G-ADMIN/C-G-MEMBER-READ/C-G-MEMBER-REMOVE` | 两项 `待用户确认`;移除须影响预览和再认证 |
| `J041` `pages/index/admin/add` | `J041-A01` 静态成员勾选;`A02` 进入权限页 | 无 | `C-G-ADMIN` | A01/A02 均为 `与当前产品冲突,明确舍弃`;该半成品不迁移,不把假成员列表带入当前产品 |
| `J042` `pages/index/admin/power` | `J042-A01` 读取权限;`A02` 提交管理员权限 | `addManageUser`, `getManagePower` | `C-G-ADMIN` | 两项 `待用户确认`,合同缺失前关闭 |
| `J043` `pages/index/familyCircle/index` | `J043-A01` 动态列表;`A02` 评论列表;`A03` 评论;`A04` 删除动态 | `addComment`, `deleteUsernews`, `getComment`, `getUsernewsList` | `C-F-FEED-READ/C-F-FEED-WRITE/C-F-COMMENT-READ/C-F-COMMENT-WRITE` → F01/F03 | 四项 `改造后采用`;列表和评论/删除拆 owner |
| `J044` `pages/index/familyCircle/add` | `J044-A01` 发布动态;`A02` 取公开字典 | `addUsernews`, `getDictDataByDictCodeNoToken` | `C-F-FEED-WRITE` → F02 | A01/A02 `改造后采用`;A02 仅使用当前明确字段 owner |
| `J045` `pages/index/tree/index` | `J045-A01` 按父母展开表格式世系 | `getGenealogyUserByParentsId` | `C-T-TREE` → T01 | `仅参考交互`;不复制旧接口和递归数据形状 |
| `J046` `pages/index/tree/tree` | `J046-A01` 树谱;`A02` 查看资料;`A03` 添加/编辑亲属;`A04` 删除人物 | `getGenealogyUser`, `userDel` | `C-T-TREE/C-T-DETAIL/C-T-PARENT/C-T-SPOUSE/C-T-SIBLING/C-T-CHILD/C-T-EDIT/C-T-DELETE` → T01/T03—T05 | A01—A03 `仅参考交互`并按当前 owner 重做;A04 `待用户确认`,停用不是物理删除 |
| `J047` `pages/message/index` | `J047-A01` 通知/广告/文章混排;`A02` 直接审核入谱;`A03` 人物列表 | `auditInGenealogy`, `getad_list`, `getArticleList`, `getNoticeList`, `getUserList` | `C-N-LIST/C-G-JOIN-AUDIT/C-T-LIST` → N01/G10 | A01 `仅参考交互`但须拆类型;A02 `改造后采用`为跳 G10,不在 N01 写;A03 `与当前产品冲突,明确舍弃`错位数据 |
| `J048` `pages/message/details` | `J048-A01` 通知详情 | `getNoticeDetails` | `C-N-DETAIL-SAME-SESSION/C-N-DETAIL-COLD-START` → N02 | `后端缺失,暂时关闭` LIVE 详情;仅允许列表当代内存投影 |
| `J049` `pages/mine/index` | `J049-A01` 我的主页/资料;`A02` 宣传/广告;`A03` 删除账号 | `delUser`, `getad_list`, `getDictDataByDictCodeNoToken`, `getUserInfo`, `getVideopublicize` | `C-A-PROFILE-READ/C-M-PROMO/C-A-DEACTIVATE` → M01/M10 | A01 `改造后采用`A02/A03 `待用户确认`,A03 为敏感注销候选且不归主页直调 |
| `J050` `pages/mine/help` | `J050-A01` 帮助分类;`A02` 文章列表 | `getArticleList`, `getHelpClass` | `C-M-HELP-LIST` → M06 | 两项 `改造后采用` |
| `J051` `pages/mine/setting` | `J051-A01` 设置导航;`A02` 帮助/协议内容 | `getArticleList`, `getHelpClass` | `C-M-HELP-LIST/C-LOCAL` → M03/M10 | A01/A02 均为 `仅参考交互`;设置只做导航,不另建数据 owner |
| `J052` `pages/mine/password` | `J052-A01` 修改密码 | `userEditPWD` | `C-A-PASSWORD-CHANGE` → M04 | `改造后采用`;旧密码 wire 不能迁移为新合同 |
### 5.3 J053—J078 与注释路由
| 路由 ID / 参考路由 | 页面与逐动作 ID | 参考 wrapper | 当前合同键与 owner | 甄别结论与边界 |
| --- | --- | --- | --- | --- |
| `J053` `pages/mine/opinion` | `J053-A01` 提交反馈 | `addUseropinion` | `C-M-FEEDBACK` → M07 | `改造后采用` |
| `J054` `pages/mine/share` | `J054-A01` 展示推广;`A02` 立即分享 | 无 | `C-M-PROMO/C-S-PLATFORM-SHARE` → 无当前路由的 M 推广候选 | A01 `改造后采用`候选证据;A02 `仅参考交互`,旧错误跳创建家谱明确舍弃;不得覆盖 M08 家谱邀请 |
| `J055` `pages/mine/vip_xf` | `J055-A01` 套餐;`A02` 创建/支付 VIP | `getMemberLevel`, `getUserInfo`, `payVip` | `C-M-VIP-READ/C-M-VIP-ORDER/C-M-VIP-PAY` → M09 | A01 `改造后采用`为只读;A02 `后端缺失,暂时关闭`支付闭环 |
| `J056` `pages/mine/vip_success` | `J056-A01` 查询购买记录/成功 | `vip_log` | `C-M-VIP-READ/C-M-VIP-PAY` → M09 | `后端缺失,暂时关闭`成功页;只能由权威订单结果进入 |
| `J057` `pages/mine/helpDetails` | `J057-A01` 帮助详情 | `getArticleDetails` | `C-M-HELP-LIST` → M06 | `改造后采用`产品意图;当前由列表项投影承接,旧独立详情 owner 不迁入 |
| `J058` `pages/index/relationship/index` | `J058-A01` 静态人物/亲属资料;`A02` 邀请激活 | 无 | `C-T-DETAIL/C-T-BIND-INVITE/C-T-BIND-IDENTITY/C-T-BIND-MUTATION/C-T-BIND-RESULT` → T03/邀请流程 | A01 `仅参考交互`A02 产品需求保留但当前 `后端缺失,暂时关闭` |
| `J059` `pages/index/tree/personalData` | `J059-A01` 人物资料 | `getUserInfoId` | `C-T-DETAIL` → T03 | `改造后采用` |
| `J060` `pages/index/tree/add` | `J060-A01` 绑定账号;`A02` 编辑头像/姓名/排行/父亲/生卒;`A03` 确认 | 无 | `C-T-BIND-INVITE/C-T-BIND-IDENTITY/C-T-BIND-MUTATION/C-T-BIND-RESULT/C-T-EDIT/C-T-RANK/C-T-PARENT` → T04—T06 | A01—A03 均为 `与当前产品冲突,明确舍弃`;宽静态表单实现不迁移,字段须逐 owner 甄别后重做 |
| `J061` `pages/mine/userInfo` | `J061-A01` 本人资料;`A02` 添加父母;`A03` 配偶;`A04` 兄弟姐妹;`A05` 子女;`A06` 单人;`A07` 人物编辑;`A08` 排行/层级 | `addBrotherSister`, `addChildren`, `addOneUser`, `addParents`, `addUserspouse`, `getDictDataByDictCode`, `getDictDataByDictCodeNoToken`, `getLevelList`, `getMomList`, `getUserInfoId`, `getUserSpouseInfoId`, `userEdit` | `C-A-PROFILE-READ/C-A-PROFILE-WRITE/C-T-PARENT/C-T-SPOUSE/C-T-SIBLING/C-T-CHILD/C-T-EDIT/C-T-RANK` → M02/T04—T06 | A01—A05、A07/A08 `改造后采用`产品意图,但必须拆 owner;A06 与当前产品冲突,`明确舍弃`未绑定通用人物新建;旧一页多模式和旧 wrapper 全部舍弃 |
| `J062` `pages/index/log/selectUser` | `J062-A01` 选择成长人物;`A02` 管理员选择分支 | `getUserList` | `C-T-LIST/C-R-GROWTH/C-G-ADMIN` → R08 | A01 `改造后采用`A02 `与当前产品冲突,明确舍弃`未完成分支 |
| `J063` `pages/index/genealogyList/ancestorsOrder` | `J063-A01` 调整始祖世代空占位 | 无 | `C-G-SORT` | `与当前产品冲突,明确舍弃`空文件;功能候选保持后端缺失关闭 |
| `J064` `pages/mine/helpList` | `J064-A01` 按分类读帮助文章 | `getArticleList` | `C-M-HELP-LIST` → M06 | `改造后采用` |
| `J065` `pages/content/detail` | `J065-A01` 账号协议;`A02` 帮助/关于富文本 | `getNewsDetails` | `C-LOCAL/C-M-HELP-LIST` → A/M06/M10 共享查看器 | A01/A02 均为 `仅参考交互`;入口决定 owner,明确不是 F05 谱文 |
| `J066` `pages/mine/fenxiang` | `J066-A01` 推广推荐码/二维码;`A02` 复制;`A03` 下载 App`A04` 分享变现 | 无 | `C-M-REFERRAL/C-M-PROMO/C-M-MONEY/C-S-PLATFORM-CLIPBOARD/C-S-PLATFORM-SHARE/C-S-SAFE-EXTERNAL-OPEN` → 无当前路由的 M 推广候选 | A01/A02 `后端缺失,暂时关闭`,明确不是家谱票据;A03 `仅参考交互`且只能由安全外链 owner 承接;A04 `待用户确认`;四项均不得覆盖 M08 |
| `J067` `pages/login/register` | `J067-A01` 注册;`A02` 性别/邀请码;`A03` 协议 | `getCaptcha`, `getDictDataByDictCodeNoToken`, `register` | `C-A-TAC-API/C-A-TAC-A11Y/C-A-SMS-SEND/C-A-REGISTER/C-LOCAL` → A04 | A01—A03 `改造后采用`;A02 只保留当前合同字段,旧邀请码不迁入 |
| `J068` `pages/mine/changemobile` | `J068-A01` 当前密码+新号换绑 | `userEditMobile` | `C-A-TAC-API/C-A-TAC-A11Y/C-A-PHONE-SEND/C-A-PHONE-CHANGE` → M05 | `改造后采用`产品目标;旧缺 TAC/OTP/会话撤销合同明确舍弃 |
| `J069` `pages/index/sortGenealogy` | `J069-A01` 读取用户家谱;`A02` 批量排序 | `changeGenealogySort`, `getALLCreatGenealogyList` | `C-G-MINE/C-G-SORT` → G01 候选 | A01 `改造后采用`并复用 G01A02 `后端缺失,暂时关闭`,不同于 T06/G12 |
| `J070` `pages/index/log/list` | `J070-A01` 人物/分类成长列表;`A02` 密码校验;`A03` 删除;`A04` 新建 | `cz_is_cehck`, `deleteDevelopmentlog`, `getDevelopmentlogList`, `getDictDataByDictCode` | `C-R-GROWTH/C-S-CONTENT-LOCK` → R08 | A01/A03/A04 `改造后采用`并独立保留此筛选/入口状态;证明与 J031 等价后才共用实现;A02 `待用户确认` |
| `J071` `pages/index/log/class` | `J071-A01` 成长分类;`A02` 进入列表;`A03` 错位删除逻辑 | `deleteDevelopmentlog`, `getDevelopmentlogList`, `getDictDataByDictCode` | `C-R-GROWTH` → R08 | A01/A02 `仅参考交互`A03 `与当前产品冲突,明确舍弃` |
| `J072` `pages/index/video/video2` | `J072-A01` 纵滑播放;`A02` 评论;`A03` 点赞;`A04` 分享;`A05` 裸请求旧 HTTP | 无 | `C-F-VIDEO-READ/C-F-VIDEO-COMMENT/C-F-VIDEO-REACTION/C-S-PLATFORM-SHARE` → F10 候选 | A01—A04 `仅参考交互`但分别进入浏览、评论、点赞、分享候选,不能共用读取 owner;A05 `与当前产品冲突,明确舍弃` |
| `J073` `pages/index/video/video3` | `J073-A01` 宣传纵滑播放;`J073-A02@comment` 评论;`J073-A02@reaction` 点赞;`J073-A02@share` 分享;`J073-A03` 裸请求旧接口 | 无 | A01 → `C-M-PROMO-VIDEO`A02@comment`C-M-PROMO-VIDEO-COMMENT`A02@reaction`C-M-PROMO-VIDEO-REACTION`A02@share`C-M-PROMO-VIDEO/C-S-PLATFORM-SHARE` | A01 与三个 A02 语义子动作均 `仅参考交互`,不代表批准宣传业务;A03 `与当前产品冲突,明确舍弃` |
| `J074` `pages/index/video/video4` | `J074-A01@browse` 第三套纵滑;`J074-A01@comment` 评论;`J074-A02` 旧 HTTP 与外部云接口 | 无 | A01@browse`C-F-VIDEO-READ`A01@comment`C-F-VIDEO-COMMENT`A02 无可迁移 owner | 两个 A01 语义子动作均 `仅参考交互`A02 `与当前产品冲突,明确舍弃` |
| `J075` `pages/message/ad_detail` | `J075-A01` 广告/宣传消息详情 | `ad_detail`, `getDictDataByDictCodeNoToken` | `C-N-DETAIL-SAME-SESSION/C-N-DETAIL-COLD-START/C-M-PROMO` → N02 | `待用户确认`;服务端消息类型/目标未闭合前关闭 |
| `J076` `pages/mine/withdrawal` | `J076-A01` 余额;`A02` 资金流水;`A03` 进入提现 | `getUserInfo`, `money_log` | `C-M-MONEY` | 三项 `待用户确认`且关闭 |
| `J077` `pages/mine/tixian` | `J077-A01` 提现金额;`A02` 上传收款码;`A03` 提交;`A04` 混入成长日志残留 | `add_tixian`, `addDevelopmentlog`, `getDevelopmentlogDetails`, `getDictDataByDictCode` | `C-M-MONEY/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` | A01—A03 `待用户确认`且关闭;A04 `与当前产品冲突,明确舍弃`复制污染 |
| `J078` `pages/mine/tixian_log` | `J078-A01` 提现记录/状态/审核时间 | `tixian_log` | `C-M-MONEY` | `待用户确认`且关闭 |
| `JX001` `pages/index/vertical-swiper/vertical-swiper`(注释) | `JX001-A01` 纵向短视频实验 | 无 | `C-F-VIDEO-READ` → F10 | `与当前产品冲突,明确舍弃`活动路由候选;只保留交互证据且不计 78 分母 |
## 六、7 个未注册页面文件
| 文件 ID / 相对路径 | 页面与逐动作 ID | 参考 wrapper | 当前 owner | 甄别结论 |
| --- | --- | --- | --- | --- |
| `JU01` `pages/index/genealogyList.vue` | `JU01-A01` 全功能菜单导航;`A02` 错配按钮/文案 | 无 | `C-S-NAV/C-G-OVERVIEW` → G05 与各业务域 | A01 `仅参考交互`并作为可能的角色/菜单状态保留;A02 `与当前产品冲突,明确舍弃`;未注册页面不自动新增路由 |
| `JU02` `pages/index/tree/tree2.vue` | `JU02-A01` 树谱;`A02` 资料;`A03` 父母;`A04` 配偶;`A05` 兄弟姐妹;`A06` 儿子;`A07` 女儿;`A08` 编辑;`A09` 其他关系入口;`A10` 删除 | `getGenealogyUser`, `userDel` | `C-T-TREE/C-T-DETAIL/C-T-PARENT/C-T-SPOUSE/C-T-SIBLING/C-T-CHILD/C-T-EDIT/C-T-DELETE/C-S-NAV` → T01/T03—T06 | A01—A09 `仅参考交互`并作为另一树状态证据保留,旧代码整体舍弃;A10 `待用户确认`。其中“女儿”复用 `addSon` 是反例 |
| `JU03` `pages/index/vertical-swiper/j-video.nvue` | `JU03-A01` 视频子项播放/暂停 | 无 | `C-F-VIDEO-READ` → F10 | `仅参考交互`;随实验页不注册 |
| `JU04` `pages/index/vertical-swiper/vertical-swiper.nvue` | `JU04-A01` 纵向列表;`A02` 播放切换 | 无 | `C-F-VIDEO-READ` → F10 | 两项 `仅参考交互`;这是 JX001 的目标文件,不计活动路由 |
| `JU05` `pages/index/video/douyin-scrollview/douyin-scrollview.vue` | `JU05-A01` 纵滑;`JU05-A02` 播放;`JU05-A03` 评论;`JU05-A04@reaction` 点赞;`JU05-A04@share` 分享;`JU05-A05` 裸请求旧/外部接口 | 无 | A01/A02 → `C-F-VIDEO-READ`A03 → `C-F-VIDEO-COMMENT`A04@reaction`C-F-VIDEO-REACTION`A04@share`C-F-VIDEO-READ/C-S-PLATFORM-SHARE` | A01—A03 与两个 A04 语义子动作均 `仅参考交互`A05 `与当前产品冲突,明确舍弃` |
| `JU06` `pages/index/video/douyin-scrollview/douyin-scrollview.nvue` | `JU06-A01` 纵滑;`JU06-A02` 播放;`JU06-A03` 评论;`JU06-A04@reaction` 点赞;`JU06-A04@share` 分享;`JU06-A05` 裸请求旧/外部接口 | 无 | A01/A02 → `C-F-VIDEO-READ`A03 → `C-F-VIDEO-COMMENT`A04@reaction`C-F-VIDEO-REACTION`A04@share`C-F-VIDEO-READ/C-S-PLATFORM-SHARE` | A01—A03 与两个 A04 语义子动作均 `仅参考交互`A05 `与当前产品冲突,明确舍弃` |
| `JU07` `pages/mine/index2.vue` | `JU07-A01` 资料;`A02` 反馈;`A03` 帮助;`A04` 设置;`A05` 退出 | `getUserInfo` | `C-A-PROFILE-READ/C-M-FEEDBACK/C-M-HELP-LIST/C-A-PASSWORD-CHANGE/C-A-PHONE-CHANGE/C-A-LOGOUT/C-S-NAV` → M01/M06/M07/M10 | 五项 `仅参考交互`;作为可能的角色/布局状态保留,未注册事实不等同于无产品价值 |
## 七、反向覆盖索引
| 当前 owner | 设计证据 | 完成项目证据 | 规划结论 |
| --- | --- | --- | --- |
| A01/A04/A05 认证 | D005、D053、D055 | J002、J065、J067 | 密码/短信/注册/找回按 TAC 与当前合同改造;微信关闭 |
| G01/G03/G05 家谱工作区 | D004、D012、D043、D050、D054、D055 | J001、J005、J007、JU01 | 列表/概览/两阶段创建采用;旧巨型首页和菜单壳不迁移 |
| G06/G08—G10 加入与审核 | D011、D050、D052 | J004、J047 | 普通申请采用;邀请码直入关闭;通知只能回流 G10 |
| G11/G12 设置与字辈 | D006、D010、D049、D060 | J008、J009、J063、J069 | 字辈采用;始祖世代和家谱排序缺 owner 关闭 |
| G 管理员与破坏性成员动作 | D025—D028 | J040—J042 | 待用户确认;无 capability 前关闭 |
| T01/T03—T08 | D030—D036、D038、D055 | J045、J046、J058—J062、JU02 | T01 十入口已批准;头像卡/面板按当前架构重做;排行/绑定按合同关闭但不删需求 |
| F01—F03 动态 | D029、D051、D055 | J043、J044 | 动态/评论改造采用;置顶/加精关闭 |
| F04—F06 谱文 | D046—D048、D057 | J010—J014 | 列表/详情/编辑采用;内容密码待确认 |
| F07—F09 相册/媒体 | D003、D045、D058、D059 | J015—J018 | 相册/照片采用;共享文件链闭合前不假上传 |
| F10 视频 | D002、D007、D044 | J021、J022、J024、J072、J074、JX001、JU03—JU06J023/J073 为独立 M 宣传视频证据 | 家族视频完整后端链缺失,保持关闭;纵滑只作交互证据;宣传视频不得借 F10 owner |
| R01/R02 人物录与人物资料 | D031、D034—D036、D038 | J058—J062 | R01、R02 分别作为 PA-049、PA-038/048 的独立路由状态,复用唯一列表/详情/编辑数据 owner;R02 通用新建本地预览舍弃,编辑只导航 T05 |
| R03/R04 人情往来 | D021—D023 | J034—J036 | 改造采用 |
| R05—R07 礼仪/贺礼候选 | D013—D016、D037 | J028—J030 | “贺礼邀请”语义待确认,不能强塞祭祀献礼 |
| R08 成长 | D017—D020、D036 | J031—J033、J062、J070、J071 | 改造采用;各入口/筛选/角色状态分别保留,证明等价后才共用 R08 实现 |
| R10/R11 备忘/功德 | D008、D009、D024、D056 | J025—J027、J037—J039 | 现有 operation 范围内采用;缺详情/编辑的动作关闭 |
| R09/重要证件 | D055 | J019、J020 | 人生事件与证件均缺 owner;证件另有隐私风险,保持关闭/待确认 |
| N01/N02 与 G01/M01 未读入口 | D052、D055 | J047、J048、J075 | 列表/已读采用;未读计数使用唯一 `C-N-UNREAD-COUNT` owner,当前缺 operation 不从分页长度推断;N02 仅列表当代投影,详情/目标跳转关闭 |
| M01—M10 | D039—D042、D053、D055 | J049—J057、J064—J068、J075—J078、JU07 | M08 保持当前“家谱邀请票据” owner;资料/安全/帮助/反馈/关于采用;APP 推广、推荐码、奖励、变现均为无当前路由候选,分别使用 `C-M-PROMO/C-M-REFERRAL/C-M-REWARD/C-M-MONEY`,不得冒充家谱邀请 |
| S-ID/S-PERM/S-FILE/S-REGION | D003、D004、D032、D036、D038、D045、D056、D057 | 所有含 ID、权限、上传、地区动作的 J/JU 记录 | 全域唯一 owner;任何一个域不得另造兼容 ID、权限或上传协议 |
## 八、规范产品动作账本
本账本在用户确认规划前冻结产品动作身份和分母。`PA-xxx` 是产品动作,不是来源文件数量;`CUR:` 表示当前路由基线,`!` 表示只作为反例、禁止迁移。`D001-A01`(全项目功能目录)与 `D055-A01`(全项目思维导图)由全部 PA 继承,不在每行重复。PA 的“来源证据反链”栏允许把单页、默认状态的设计动作简写为 `Dxxx-Axx`;它与源表中的完整 `Dxxx-P01-S01-Axx` 是同一 ID,不是新增来源。相同 PA 下的多个来源仍保留各自 `D/J/JX/JU` 状态 ID、六类结论和验收记录;共用 owner 不等于合并状态证据。
边界固定为:`必需`进入产品完成分母;`候选`保留稳定 PA ID,但用户未单独批准时不进入完成分母,初始 `productCompletion=NOT_APPLICABLE`。规划阶段没有任何 PA 可标 `COMPLETE`。表中 `P/B/NA` 分别表示 `PARTIAL/BLOCKED/NOT_APPLICABLE`
### 8.1 A 认证:PA-001—PA-006
| PA | 唯一 owner / 产品动作 | 边界 | 合同键 | 来源证据反链 | 初始 |
| --- | --- | --- | --- | --- | --- |
| `PA-001` | A01 密码+TAC 登录 | 必需 | `C-A-TAC-API/C-A-TAC-A11Y/C-A-LOGIN` | D005-A01J002-A01CUR:A01 | B |
| `PA-002` | A01 短信 TAC、发码并登录 | 必需 | `C-A-TAC-API/C-A-TAC-A11Y/C-A-SMS-SEND/C-A-SMS-LOGIN` | D005-A01〔登录视觉态〕;CUR:A01 | B |
| `PA-003` | A04 TAC、发码并注册 | 必需 | `C-A-TAC-API/C-A-TAC-A11Y/C-A-SMS-SEND/C-A-REGISTER` | D005-A03J002-A02J067-A01—A03CUR:A04 | B |
| `PA-004` | A05 TAC、发码并找回密码 | 必需 | `C-A-TAC-API/C-A-TAC-A11Y/C-A-SMS-SEND/C-A-RESET` | D005-A02J002-A03CUR:A05 | B |
| `PA-005` | A-LEGAL 阅读服务条款/隐私协议 | 必需 | `C-LOCAL` | J065-A01J067-A03D005-A01—A03〔登录协议态〕 | P |
| `PA-006` | A01 微信一键登录 | 候选 | `C-A-WECHAT` | D005-A04D041-A03J002-A04 | NA |
### 8.2 G 家谱工作区:PA-007—PA-034
| PA | 唯一 owner / 产品动作 | 边界 | 合同键 | 来源证据反链 | 初始 |
| --- | --- | --- | --- | --- | --- |
| `PA-007` | G01 查看/筛选我的家谱列表 | 必需 | `C-G-MINE` | D050-A01D054-A01〔主题态〕;J001-A01J007-A01/A03J069-A01CUR:G01 | B |
| `PA-008` | G01 切换当前家谱上下文 | 必需 | `C-G-MINE/C-G-OVERVIEW` | J001-A02D012-A01/A02、D043-A01/A02〔不同总览状态〕;CUR:G01 | B |
| `PA-009` | G03 两阶段创建家谱及首位人物;仅首写稳定返回 ID 后续办 | 必需 | `C-S-REGION-CHILDREN/C-S-REGION-PATH/C-S-REGION-SEARCH/C-S-REGION-DETAIL/C-G-CREATE-GENEALOGY/C-T-PERSON-CREATE/C-G-CREATE-RECOVERY` | D004-A01D050-A03D054-A02J005-A01CUR:G03 | B |
| `PA-010` | G05 查看家谱总览并进入模块 | 必需 | `C-G-OVERVIEW` | D012-A01/A02〔状态一〕;D043-A01/A02〔状态二〕;J001-A03JU01-A01〔菜单态〕;!JU01-A02CUR:G05 | B |
| `PA-011` | G06 搜索公开家谱 | 必需 | `C-G-PUBLIC/C-G-OPTIONS` | D050-A02CUR:G06 | B |
| `PA-012` | G08 提交普通加入申请 | 必需 | `C-G-JOIN-CREATE/C-G-JOIN-RECOVERY` | D050-A04D054-A03CUR:G08 | B |
| `PA-013` | G09 查看我的加入申请 | 必需 | `C-G-JOIN-MINE` | CUR:G09 | B |
| `PA-014` | G09 撤回加入申请 | 必需 | `C-G-JOIN-WITHDRAW` | CUR:G09 | B |
| `PA-015` | G10 查看待审核申请 | 必需 | `C-G-JOIN-PENDING` | D052-A03J047-A02〔通知回流态〕;CUR:G10 | B |
| `PA-016` | G10 审核申请(同意/拒绝) | 必需 | `C-G-JOIN-AUDIT` | D052-A03J047-A02CUR:G10 | B |
| `PA-017` | G11 查看家谱设置 | 必需 | `C-G-OVERVIEW` | CUR:G11 | B |
| `PA-018` | G11 更新允许修改的家谱设置 | 必需 | `C-G-SETTINGS` | CUR:G11 | B |
| `PA-019` | G12 查看字辈/代次 | 必需 | `C-G-POEM` | D010-A01D060-A01J008-A01CUR:G12 | B |
| `PA-020` | G12 新增字辈 | 必需 | `C-G-POEM` | D010-A03D060-A02〔管理入口〕;J009-A01CUR:G12 | B |
| `PA-021` | G12 编辑字辈 | 必需 | `C-G-POEM` | D010-A02D049-A01/A02J009-A01CUR:G12 | B |
| `PA-022` | G12 停用/恢复字辈 | 必需 | `C-G-POEM` | D049-A03J009-A02 | B |
| `PA-023` | M08 选择有正式邀请权限的家谱并查看当前活动票据状态 | 必需 | `C-G-MINE/C-G-INVITE-LIST` | CUR:M08 | B |
| `PA-024` | G06 解析、确认、兑换家谱邀请码,查询结果并刷新权威工作区后直接入谱 | 必需 | `C-G-INVITE-RESOLVE/C-G-INVITE-REDEEM/C-G-INVITE-RESULT/C-G-MINE/C-G-OVERVIEW` | D011-A01D050-A05J004-A01CUR:G06 | B |
| `PA-025` | G01 调整多个家谱展示顺序 | 候选 | `C-G-SORT` | J069-A02 | NA |
| `PA-026` | G-SORT 调整始祖世代 | 候选 | `C-G-SORT` | D006-A01J063-A01〔空壳反例〕 | NA |
| `PA-027` | G-ADMIN 查看管理员列表 | 候选 | `C-G-ADMIN/C-G-MEMBER-READ` | D025-A01D026-A01〔管理态〕;J040-A01 | NA |
| `PA-028` | G-ADMIN 选择并新增管理员 | 候选 | `C-G-ADMIN/C-G-MEMBER-READ` | D025-A02D027-A01!J041-A01!J062-A02〔未完成分支〕 | NA |
| `PA-029` | G-ADMIN 查看/修改管理员权限 | 候选 | `C-G-ADMIN` | D026-A03D028-A01J042-A01/A02!J041-A02 | NA |
| `PA-030` | G-ADMIN 移除管理员 | 候选 | `C-G-ADMIN/C-G-MEMBER-REMOVE` | D026-A02J040-A02 | NA |
| `PA-031` | G-MEMBER 移除家谱账号成员 | 候选 | `C-G-MEMBER-REMOVE` | 当前成员合同;D055-A01 | NA |
| `PA-032` | G-MEMBER 主动退出家谱 | 候选 | `C-G-MEMBER-LEAVE` | 当前成员合同;D055-A01 | NA |
| `PA-033` | G-MEMBER 转移家谱所有者 | 候选 | `C-G-MEMBER-TRANSFER` | 当前成员合同;D055-A01 | NA |
| `PA-034` | G01 删除家谱 | 候选 | `C-G-DELETE` | J007-A02 | NA |
### 8.3 T 世系与成员:PA-035—PA-052
| PA | 唯一 owner / 产品动作 | 边界 | 合同键 | 来源证据反链 | 初始 |
| --- | --- | --- | --- | --- | --- |
| `PA-035` | T01 读取并呈现头像人物卡世系树 | 必需 | `C-T-TREE/C-S-FILE-READ` | D032-A01D033-A01—A10〔面板前置态〕;J046-A01JU02-A01〔另一树态〕;CUR:T01 | B |
| `PA-036` | T01 横纵浏览、搜索并定位人物 | 必需 | `C-T-TREE/C-T-LIST` | D031-A01/A02D032-A02CUR:T01CUR:T07 | B |
| `PA-037` | T01 选择人物并打开唯一操作面板 | 必需 | `C-T-TREE` | D033-A01—A10〔十入口面板态〕;J046-A01—A03JU02-A01—A09 | B |
| `PA-038` | T03 查看人物真实资料;R02 为独立展示状态 | 必需 | `C-T-DETAIL` | D033-A01D034-A01〔空/未绑定态〕;J046-A02J058-A01J059-A01JU02-A02CUR:T03CUR:R02 | B |
| `PA-039` | T03 查看亲属并继续进入亲属资料 | 必需 | `C-T-DETAIL/C-T-LIST` | D034-A02D035-A01/A02J058-A01 | B |
| `PA-040` | T04 添加父亲 | 必需 | `C-T-PARENT` | D033-A02J046-A03J061-A02JU02-A03!J060-A02〔宽表单〕;CUR:T04 | B |
| `PA-041` | T04 添加母亲 | 必需 | `C-T-PARENT` | D033-A03J061-A02JU02-A03CUR:T04 | B |
| `PA-042` | T04 添加配偶 | 必需 | `C-T-SPOUSE` | D033-A04J046-A03J061-A03JU02-A04CUR:T04 | B |
| `PA-043` | T04 添加兄弟姐妹 | 必需 | `C-T-SIBLING` | D033-A05J061-A04JU02-A05CUR:T04 | B |
| `PA-044` | T04 添加儿子 | 必需 | `C-T-CHILD` | D033-A07J061-A05JU02-A06CUR:T04 | B |
| `PA-045` | T04 添加女儿 | 必需 | `C-T-CHILD` | D033-A08J061-A05JU02-A07`addSon` 反例〕;CUR:T04 | B |
| `PA-046` | T06 原子调整同辈排行 | 必需 | `C-T-RANK` | D033-A06D036-A02!J060-A02J061-A08CUR:T06 | B |
| `PA-047` | T-BIND 邀请并绑定人物与账号 | 必需 | `C-T-BIND-INVITE/C-T-BIND-IDENTITY/C-T-BIND-MUTATION/C-T-BIND-RESULT` | D033-A09D034-A03D036-A01J058-A02!J060-A01 | B |
| `PA-048` | T05 编辑人物资料及头像;R02 编辑入口只委托本 owner | 必需 | `C-T-EDIT/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` | D033-A10D034-A04D036-A02D038-A01!D038-A02〔扩展隐私字段〕;J046-A03!J060-A02/A03!J061-A06〔通用人物新建〕;J061-A07JU02-A08CUR:T05CUR:R02 | B |
| `PA-049` | T07 查看/搜索世系人物目录;R01 为独立展示状态 | 必需 | `C-T-LIST` | D031-A01/A02J062-A01CUR:T07CUR:R01 | B |
| `PA-050` | T08 查看人物/成员状态 | 必需 | `C-T-LIST/C-G-MEMBER-READ` | D034-A01〔未绑定态〕;CUR:T08 | B |
| `PA-051` | T05 停用世系人物 | 候选 | `C-T-DELETE` | J046-A04JU02-A10 | NA |
| `PA-052` | T01 使用表格式世系辅助模式 | 候选 | `C-T-TREE` | D030-A01〔表格态〕;J045-A01〔递归态〕 | NA |
### 8.4 F 家族内容:PA-053—PA-082
| PA | 唯一 owner / 产品动作 | 边界 | 合同键 | 来源证据反链 | 初始 |
| --- | --- | --- | --- | --- | --- |
| `PA-053` | F01 查看家族动态列表 | 必需 | `C-F-FEED-READ` | D051-A01/A02J043-A01CUR:F01 | B |
| `PA-054` | F03 查看动态详情 | 必需 | `C-F-FEED-READ` | D051-A01/A02〔动态内容态〕;CUR:F03 | B |
| `PA-055` | F02 发布文字/图片动态 | 必需 | `C-F-FEED-WRITE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` | D029-A01D051-A04J044-A01/A02CUR:F02 | B |
| `PA-056` | F02 唯一编辑 owner 编辑动态;F03 仅提供入口 | 必需 | `C-F-FEED-WRITE` | CUR:F02;入口 CUR:F03 | P |
| `PA-057` | F03 删除动态 | 必需 | `C-F-FEED-WRITE` | J043-A04 | P |
| `PA-058` | F03 点赞/取消点赞 | 必需 | `C-F-FEED-WRITE` | 当前 feed 合同;CUR:F03 | P |
| `PA-059` | F03 评论或回复动态 | 必需 | `C-F-COMMENT-READ/C-F-COMMENT-WRITE` | D051-A03J043-A02/A03 | B |
| `PA-060` | F03 删除本人评论 | 必需 | `C-F-COMMENT-WRITE` | 当前评论合同;CUR:F03 | P |
| `PA-061` | F01 置顶动态 | 候选 | `C-F-FEED-MODERATION` | D029-A02 | NA |
| `PA-062` | F01 加精动态 | 候选 | `C-F-FEED-MODERATION` | D029-A03 | NA |
| `PA-063` | F04 浏览谱文分类与列表 | 必需 | `C-F-ARTICLE` | D047-A01/A02J010-A01J011-A01CUR:F04 | P |
| `PA-064` | F05 阅读谱文详情 | 必需 | `C-F-ARTICLE` | D048-A01J012-A01〔独立详情态〕;J013-A03CUR:F05 | P |
| `PA-065` | F06 新建谱文 | 必需 | `C-F-ARTICLE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` | D048-A02D057-A01J013-A01CUR:F06 | B |
| `PA-066` | F06 编辑谱文 | 必需 | `C-F-ARTICLE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` | D048-A02D057-A01J013-A02CUR:F06 | B |
| `PA-067` | F05 唯一删除 owner 删除谱文;F04 仅提供入口 | 必需 | `C-F-ARTICLE` | J011-A03CUR:F05 | P |
| `PA-068` | F04 增改删谱文分类 | 候选 | `C-F-ARTICLE-CATEGORY-WRITE` | D046-A01 | NA |
| `PA-069` | F05 设置、校验或重置谱文访问密码;获批前不启用 | 候选 | `C-S-CONTENT-LOCK` | J011-A02J014-A01 | NA |
| `PA-070` | F07 查看相册列表 | 必需 | `C-F-ALBUM` | D058-A01/A03J015-A01CUR:F07 | P |
| `PA-071` | F07 新建相册 | 必需 | `C-F-ALBUM` | D058-A02D059-A01J016-A01 | P |
| `PA-072` | F07 编辑相册资料/封面 | 必需 | `C-F-ALBUM/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` | D003-A01D045-A04J018-A01 | B |
| `PA-073` | F07 删除相册 | 必需 | `C-F-ALBUM` | D003-A02 | P |
| `PA-074` | F08 查看并预览相册照片 | 必需 | `C-F-PHOTO-READ/C-S-FILE-READ` | D045-A01/A02J017-A01CUR:F08 | B |
| `PA-075` | F09 上传文件并新增照片记录 | 必需 | `C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ/C-F-PHOTO-WRITE` | D045-A03J017-A02CUR:F09 | B |
| `PA-076` | F08 删除照片 | 必需 | `C-F-PHOTO-WRITE` | J017-A03 | P |
| `PA-077` | F10 查看家族视频列表 | 必需 | `C-F-VIDEO-READ` | D044-A01J021-A01CUR:F10 | B |
| `PA-078` | F10 播放/查看视频详情 | 必需 | `C-F-VIDEO-READ` | D044-A02J021-A02J024-A01〔全局 URL 反例〕 | B |
| `PA-079` | F10 发布视频 | 候选 | `C-F-VIDEO-WRITE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` | D007-A01D044-A04J022-A01/A02 | NA |
| `PA-080` | F10 编辑视频 | 候选 | `C-F-VIDEO-WRITE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` | D044-A03〔管理入口〕;J022-A02 | NA |
| `PA-081` | F10 删除视频 | 候选 | `C-F-VIDEO-READ/C-F-VIDEO-DELETE` | D002-A01J021-A03 | NA |
| `PA-082` | F10 纵滑短视频浏览/播放模式 | 候选 | `C-F-VIDEO-READ` | J072-A01J074-A01@browseJX001-A01JU03-A01JU04-A01/A02JU05-A01/A02JU06-A01/A02!J072-A05!J074-A02!JU05-A05!JU06-A05 | NA |
### 8.5 R 人物与族务:PA-083—PA-124
| PA | 唯一 owner / 产品动作 | 边界 | 合同键 | 来源证据反链 | 初始 |
| --- | --- | --- | --- | --- | --- |
| `PA-083` | R03 查看/筛选人情往来记录 | 必需 | `C-R-RELATIVE` | D021-A01D022-A01〔管理态〕;J034-A01CUR:R03 | P |
| `PA-084` | R04 查看人情记录详情 | 必需 | `C-R-RELATIVE` | J035-A01CUR:R04 | P |
| `PA-085` | R04 新建人情记录 | 必需 | `C-R-RELATIVE` | D021-A02D023-A01J036-A01CUR:R04 | P |
| `PA-086` | R04 编辑人情记录 | 必需 | `C-R-RELATIVE` | D023-A01J036-A02CUR:R04 | P |
| `PA-087` | R04 唯一删除 owner 删除人情记录;R03 仅提供入口 | 必需 | `C-R-RELATIVE` | D022-A02J034-A02CUR:R04 | P |
| `PA-088` | R05 查看祭祀/礼仪列表 | 必需 | `C-R-CEREMONY` | CUR:R05 | P |
| `PA-089` | R06 查看祭祀/礼仪详情 | 必需 | `C-R-CEREMONY` | D037-A01〔通用长文状态,入口决定 owner〕;CUR:R06 | P |
| `PA-090` | R07 新建祭祀/礼仪 | 必需 | `C-R-CEREMONY` | CUR:R07 | P |
| `PA-091` | R07 编辑祭祀/礼仪 | 必需 | `C-R-CEREMONY` | CUR:R07 | P |
| `PA-092` | R06 唯一删除 owner 删除祭祀/礼仪;R05 仅提供入口 | 必需 | `C-R-CEREMONY` | 当前 ceremony 合同;CUR:R06 | P |
| `PA-093` | R06 查看祭祀献礼 | 必需 | `C-R-CEREMONY` | 当前 gifts 合同 | P |
| `PA-094` | R06 新增祭祀献礼 | 必需 | `C-R-CEREMONY` | 当前 gifts 合同;CUR:R06 | P |
| `PA-095` | R06 删除祭祀献礼 | 必需 | `C-R-CEREMONY` | 当前 gifts 合同;CUR:R06 | P |
| `PA-096` | R08 查看/筛选成长记录 | 必需 | `C-R-GROWTH` | D017-A01/A02〔普通态〕;D019-A01〔管理态〕;J031-A01〔旧入口〕;J070-A01〔人物/分类态〕;J071-A01/A02〔分类态〕;CUR:R08 | P |
| `PA-097` | R08 查看成长记录详情 | 必需 | `C-R-GROWTH` | D018-A01J031-A03J032-A01 | P |
| `PA-098` | R08 新建成长记录 | 必需 | `C-R-GROWTH` | D017-A03D020-A01J033-A01/A03J070-A04 | P |
| `PA-099` | R08 编辑成长记录 | 必需 | `C-R-GROWTH` | J033-A02 | P |
| `PA-100` | R08 删除成长记录 | 必需 | `C-R-GROWTH` | D019-A02J031-A02J070-A03!J071-A03〔错位逻辑〕 | P |
| `PA-101` | R08 选择成长记录对应人物 | 必需 | `C-T-LIST/C-R-GROWTH` | D036-A01/A03J062-A01 | B |
| `PA-102` | R08 设置、校验或重置成长记录访问密码 | 候选 | `C-S-CONTENT-LOCK` | J014-A02J070-A02 | NA |
| `PA-103` | R10 查看备忘录列表 | 必需 | `C-R-MEMO` | D024-A01J037-A01CUR:R10 | P |
| `PA-104` | R10 查看备忘详情 | 必需 | `C-R-MEMO` | J038-A03J039-A01 | P |
| `PA-105` | R10 新建备忘 | 必需 | `C-R-MEMO` | D024-A02J038-A01 | P |
| `PA-106` | R10 编辑备忘 | 必需 | `C-R-MEMO` | J038-A02 | P |
| `PA-107` | R10 删除备忘 | 必需 | `C-R-MEMO` | J037-A02 | P |
| `PA-108` | R11 查看功德记录 | 必需 | `C-R-MERIT-BASE` | D008-A01〔普通态〕;D009-A01〔管理态〕;J025-A01CUR:R11 | P |
| `PA-109` | R11 新增功德记录 | 必需 | `C-R-MERIT-BASE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` | D056-A01J026-A01 | B |
| `PA-110` | R11 删除功德记录 | 必需 | `C-R-MERIT-BASE` | D009-A02J025-A02 | P |
| `PA-111` | R11 查看独立功德详情 | 候选 | `C-R-MERIT-DETAIL` | D008-A02〔进入管理/详情证据〕;J026-A03J027-A01 | NA |
| `PA-112` | R11 编辑功德记录 | 候选 | `C-R-MERIT-EDIT` | J026-A02 | NA |
| `PA-113` | R09 查看/管理人生事件 | 必需 | `C-R-LIFE` | D055-A01CUR:R09 | B |
| `PA-114` | R-DOCUMENT 查看重要证件列表 | 候选 | `C-R-DOCUMENT` | J019-A01 | NA |
| `PA-115` | R-DOCUMENT 查看重要证件详情 | 候选 | `C-R-DOCUMENT` | J019-A01〔列表进入详情状态〕 | NA |
| `PA-116` | R-DOCUMENT 新建重要证件 | 候选 | `C-R-DOCUMENT/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` | J020-A01/A02 | NA |
| `PA-117` | R-DOCUMENT 编辑重要证件 | 候选 | `C-R-DOCUMENT/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` | J020-A01/A02 | NA |
| `PA-118` | R-DOCUMENT 删除重要证件 | 候选 | `C-R-DOCUMENT` | J019-A03 | NA |
| `PA-119` | R-DOCUMENT 校验或重置证件访问密码 | 候选 | `C-S-CONTENT-LOCK` | J014-A03J019-A02 | NA |
| `PA-120` | R-GREETING 查看/筛选贺礼邀请列表 | 候选 | `C-R-GREETING` | D013-A01/A02D014-A01〔管理态〕;J028-A01/A02 | NA |
| `PA-121` | R-GREETING 查看贺礼邀请详情 | 候选 | `C-R-GREETING` | D015-A01J029-A01D037-A01〔通用详情态〕 | NA |
| `PA-122` | R-GREETING 新建贺礼邀请 | 候选 | `C-R-GREETING` | D013-A03D016-A01J030-A01/A03 | NA |
| `PA-123` | R-GREETING 编辑贺礼邀请 | 候选 | `C-R-GREETING` | D016-A01J030-A02/A04 | NA |
| `PA-124` | R-GREETING 删除贺礼邀请 | 候选 | `C-R-GREETING` | D014-A02J028-A03 | NA |
### 8.6 N 消息:PA-125—PA-131
| PA | 唯一 owner / 产品动作 | 边界 | 合同键 | 来源证据反链 | 初始 |
| --- | --- | --- | --- | --- | --- |
| `PA-125` | N01 查看通知活动集合,并由 G01/M01 消费同一未读计数 owner | 必需 | `C-N-LIST/C-N-UNREAD-COUNT` | D052-A01J047-A01〔混排反例〕;CUR:N01CUR:G01CUR:M01 | B |
| `PA-126` | N01 标记单条通知已读 | 必需 | `C-N-READ-ONE` | 当前通知合同;CUR:N01 | B |
| `PA-127` | N01 全部标记已读 | 必需 | `C-N-READ-ALL` | D052-A02CUR:N01 | B |
| `PA-128` | N02 查看通知详情 | 必需 | `C-N-DETAIL-SAME-SESSION/C-N-DETAIL-COLD-START` | D052-A04J048-A01CUR:N02 | B |
| `PA-129` | N01 从通知安全回流目标业务页 | 必需 | `C-N-LIST/C-N-DETAIL-COLD-START` | D052-A03/A04J047-A02!J047-A03 | B |
| `PA-130` | N02 查看广告/宣传消息详情 | 候选 | `C-N-DETAIL-SAME-SESSION/C-N-DETAIL-COLD-START/C-M-PROMO` | J075-A01 | NA |
| `PA-131` | N01 支持生日、疫苗、备忘等类型化提醒 | 候选 | `C-N-TYPED-REMINDER` | D055-A01 | NA |
### 8.7 M 我的:PA-132—PA-160
| PA | 唯一 owner / 产品动作 | 边界 | 合同键 | 来源证据反链 | 初始 |
| --- | --- | --- | --- | --- | --- |
| `PA-132` | M01 查看“我的”主页和本人资料摘要 | 必需 | `C-A-PROFILE-READ` | D053-A01J049-A01JU07-A01〔另一布局态〕;CUR:M01 | B |
| `PA-133` | M02 编辑本人资料及头像 | 必需 | `C-A-PROFILE-READ/C-A-PROFILE-WRITE/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` | J061-A01CUR:M02 | B |
| `PA-134` | M03 查看安全设置入口及脱敏绑定信息 | 必需 | `C-A-PROFILE-READ` | D053-A04J051-A01JU07-A04CUR:M03 | B |
| `PA-135` | M04 修改登录密码 | 必需 | `C-A-PASSWORD-CHANGE` | J052-A01CUR:M04 | B |
| `PA-136` | M05 换绑手机号 | 必需 | `C-A-TAC-API/C-A-TAC-A11Y/C-A-PHONE-SEND/C-A-PHONE-CHANGE` | J068-A01CUR:M05 | B |
| `PA-137` | M06 浏览/搜索帮助分类与列表 | 必需 | `C-M-HELP-LIST` | D039-A01/A02J050-A01/A02J064-A01JU07-A03CUR:M06 | B |
| `PA-138` | M06 阅读帮助文章 | 必需 | `C-M-HELP-LIST` | D040-A01D037-A01〔通用长文态〕;J057-A01J065-A02 | B |
| `PA-139` | M07 提交反馈 | 必需 | `C-M-FEEDBACK` | D042-A01D053-A03〔反馈入口〕;J053-A01JU07-A02CUR:M07 | P |
| `PA-140` | M07 查看本人反馈记录 | 必需 | `C-M-FEEDBACK` | 当前 feedback GETCUR:M07 | P |
| `PA-141` | M-PROMO 查看 APP 推广内容 | 候选 | `C-M-PROMO` | D053-A02J049-A02J054-A01 | NA |
| `PA-142` | M-PROMO 分享应用 | 候选 | `C-M-PROMO/C-S-PLATFORM-SHARE` | D041-A01J054-A02!旧跳转创建家谱 | NA |
| `PA-143` | M-PROMO 展示/复制 APP 推广推荐码或二维码 | 候选 | `C-M-REFERRAL/C-M-PROMO/C-S-PLATFORM-CLIPBOARD` | J066-A01/A02 | NA |
| `PA-144` | M-PROMO 展示邀请奖励或积分 | 候选 | `C-M-REWARD` | D041-A02 | NA |
| `PA-145` | M09 查看 VIP 套餐 | 必需 | `C-M-VIP-READ` | J055-A01CUR:M09 | P |
| `PA-146` | M09 创建 VIP 订单 | 必需 | `C-M-VIP-ORDER` | J055-A02CUR:M09 | B |
| `PA-147` | M09 查看 VIP 订单记录 | 必需 | `C-M-VIP-READ` | J056-A01CUR:M09 | P |
| `PA-148` | M09 支付并确认 VIP 最终结果 | 候选 | `C-M-VIP-PAY` | J055-A02J056-A01〔假成功页反例〕 | NA |
| `PA-149` | M10 查看关于、协议、版本信息 | 必需 | `C-LOCAL` | D053-A05J051-A02J065-A02CUR:M10 | P |
| `PA-150` | M10 唯一会话 owner 安全退出登录;M01/M03 仅提供入口 | 必需 | `C-A-LOGOUT` | D053-A06JU07-A05CUR:M10 | B |
| `PA-151` | M03 注销账号 | 候选 | `C-A-DEACTIVATE/C-A-SMS-SEND` | J049-A03 | NA |
| `PA-152` | M-MONEY 查看余额和资金流水 | 候选 | `C-M-MONEY` | J076-A01/A02 | NA |
| `PA-153` | M-MONEY 提交提现申请及收款码 | 候选 | `C-M-MONEY/C-S-FILE-BINARY-WRITE/C-S-FILE-REFERENCE-WRITE/C-S-FILE-READ` | J076-A03J077-A01—A03!J077-A04 | NA |
| `PA-154` | M-MONEY 查看提现记录和状态 | 候选 | `C-M-MONEY` | J078-A01 | NA |
| `PA-155` | M-PROMO 调起平台分享并承接变现资格/结果 | 候选 | `C-M-MONEY/C-M-PROMO/C-S-PLATFORM-SHARE` | J066-A04 | NA |
| `PA-156` | M-PROMO 查看广告/宣传视频 | 候选 | `C-M-PROMO-VIDEO` | J001-A04J023-A01J049-A02〔广告/视频混排反例〕;J073-A01!J073-A03〔旧接口〕 | NA |
| `PA-157` | M08 幂等签发家谱邀请票据并在结果未知时恢复权威状态 | 必需 | `C-G-INVITE-ISSUE/C-G-INVITE-LIST` | !J006-A01〔空壳反例〕;CUR:M08 | B |
| `PA-158` | M08 显示、复制并系统分享当前活动家谱邀请票据 | 必需 | `C-G-INVITE-LIST/C-S-PLATFORM-CLIPBOARD/C-S-PLATFORM-SHARE` | CUR:M08 | B |
| `PA-159` | M08 撤销活动家谱邀请票据并收敛竞态/未知结果 | 必需 | `C-G-INVITE-REVOKE/C-G-INVITE-LIST` | CUR:M08 | B |
| `PA-160` | M-PROMO 经官方 allowlist 打开应用下载页 | 候选 | `C-M-PROMO/C-S-SAFE-EXTERNAL-OPEN` | J066-A03 | NA |
### 8.8 F/M 视频互动追加候选:PA-161—PA-166
这些动作追加在账本末尾,是因为交叉评审发现旧 PA-082 把浏览、评论、点赞、分享误并到读取合同,且宣传视频不能借家族视频 owner;稳定 PA 不回收、不重排。
| PA | 唯一 owner / 产品动作 | 边界 | 合同键 | 来源证据反链 | 初始 |
| --- | --- | --- | --- | --- | --- |
| `PA-161` | F10 查看/发表短视频评论 | 候选 | `C-F-VIDEO-COMMENT` | J072-A02J074-A01@commentJU05-A03JU06-A03 | NA |
| `PA-162` | F10 点赞/取消点赞短视频 | 候选 | `C-F-VIDEO-REACTION` | J072-A03JU05-A04@reactionJU06-A04@reaction | NA |
| `PA-163` | F10 系统分享短视频 | 候选 | `C-F-VIDEO-READ/C-S-PLATFORM-SHARE` | J072-A04JU05-A04@shareJU06-A04@share | NA |
| `PA-164` | M-PROMO 查看/发表宣传视频评论 | 候选 | `C-M-PROMO-VIDEO-COMMENT` | J073-A02@comment | NA |
| `PA-165` | M-PROMO 点赞/取消点赞宣传视频 | 候选 | `C-M-PROMO-VIDEO-REACTION` | J073-A02@reaction | NA |
| `PA-166` | M-PROMO 系统分享宣传视频 | 候选 | `C-M-PROMO-VIDEO/C-S-PLATFORM-SHARE` | J073-A02@share | NA |
### 8.9 共享门禁、状态变体与冻结计数
共享门禁不是额外的用户产品动作,不增加 PA 分母,但所有相关 PA 必须通过:
| 门禁 owner | 完成前置 |
| --- | --- |
| `S-ID` | 全部 ID 使用安全十进制字符串 wire |
| `S-PERM` | 服务端 capability、撤权刷新和 403 语义 |
| `S-FILE` | 选择/上传、业务引用、访问 URL、重进显示 |
| `S-REGION` | 行政区划和 `regionCode` |
| `S-SESSION` | 会话持久化、失效、账号切换和迟到响应隔离 |
| `S-NAV` | route key、必填参数、返回与根切换 |
| `S-A11Y` | 触控、字号、焦点、读屏和系统返回 |
PA 是产品动作分母,状态变体是独立验收分母。已知跨路由复用与 N02 状态先冻结为:
| 状态 ID | 入口与条件 | `contractState` | `dataMode` | `productCompletion` |
| --- | --- | --- | --- | --- |
| `PA-038-S01` | 从 T01/T07 进入 T03 查看人物资料 | `CONTRACT_CONFLICT` | `LOCAL_PREVIEW` | `BLOCKED` |
| `PA-038-S02` | 从 F01→R01→R02 的人物录上下文查看同一人物资料 | `CONTRACT_CONFLICT` | `LOCAL_PREVIEW` | `BLOCKED` |
| `PA-048-S01` | T03 进入 T05 编辑;T05 是唯一 mutation owner | `CONTRACT_CONFLICT` | `LOCAL_PREVIEW` | `BLOCKED` |
| `PA-048-S02` | R02 的编辑入口只导航 T05,不在 R02 本地保存或报成功 | `CONTRACT_CONFLICT` | `CLOSED` | `BLOCKED` |
| `PA-049-S01` | T01 进入 T07 世系成员目录 | `CONTRACT_CONFLICT` | `LOCAL_PREVIEW` | `BLOCKED` |
| `PA-049-S02` | F01 进入 R01 人物录,并保留 R01→R02→R01 返回现场 | `CONTRACT_CONFLICT` | `LOCAL_PREVIEW` | `BLOCKED` |
| `PA-125-S01` | N01 读取通知活动集合 | `CONTRACT_CONFLICT` | `LOCAL_PREVIEW` | `BLOCKED` |
| `PA-125-S02` | G01 显示共享未读计数;不得从分页列表长度推断 | `MISSING_OPERATION` | `CLOSED` | `BLOCKED` |
| `PA-125-S03` | M01 显示同一共享未读计数;账号切换后必须失效 | `MISSING_OPERATION` | `CLOSED` | `BLOCKED` |
| `PA-128-S01` | 从 N01 当前会话、且完整列表项快照仍在时打开 | `CONTRACT_CONFLICT` | `LOCAL_PREVIEW` | `PARTIAL` |
| `PA-128-S02` | 冷启动、进程重启、外部深链或快照丢失 | `MISSING_OPERATION` | `CLOSED` | `BLOCKED` |
R02 现存的“通用新建人物本地预览”和 T06 现存的“关系修正本地预览”不是已批准 PA:二者均不得保存、造 ID、改列表或显示成功;本轮分别关闭/移除,不能偷换成 T04 关系新增、G03 首人创建或 PA-046 排行。用户以后若明确需要独立创建人物或关系修正,必须重新规划独立产品动作及原子合同。
其他外观相似、同路由或同动作来源也按 `PA-xxx-Snn` 追加独立状态验收记录;只有入口、对象、数据、接口、权限、动作和返回行为全部证明等价时才能复用状态实现,不能删除来源状态 ID 或减少验收分母。规划阶段的 `P/PARTIAL` 只说明当前存在结构或 `LOCAL_PREVIEW`,不代表动作是 LIVE,也不能绕过 `C-S-ID/C-S-CAPABILITY` 等共享冲突。
冻结计数为:
- `productActionsTotal=166`
- `requiredActions=111`,进入产品完成分母;
- `candidateActions=55`,用户未单独批准时不进入完成分母;
- `approvedCandidateActions=0`
- 规划阶段 `completeRequiredActions=0``partialRequiredActions=42``blockedRequiredActions=69``notApplicableCandidateActions=55`
候选日后获批只改变边界和完成态,不创建新 PA ID;用户拒绝的候选也保留 ID 与拒绝记录,不删除或重排。
## 九、甄别分母与确认后冻结方式
当前源证据共拆出 341 条动作记录:
| 结论 | 设计源 | 完成项目活动/注释路由 | 未注册文件 | 合计 |
| --- | ---: | ---: | ---: | ---: |
| 直接采用 | 0 | 0 | 0 | 0 |
| 改造后采用 | 86 | 75 | 0 | 161 |
| 仅参考交互 | 19 | 26 | 26 | 71 |
| 后端缺失,暂时关闭 | 15 | 18 | 0 | 33 |
| 与当前产品冲突,明确舍弃 | 5 | 17 | 3 | 25 |
| 待用户确认 | 14 | 36 | 1 | 51 |
| 合计 | 139 | 172 | 30 | 341 |
这里的 341 是“源状态动作记录”,不是 341 个已经判定互不相同或已经判定重复的产品功能。多个截图和旧页面可能是独立状态,也可能在证据充分后共用同一个实现 owner。
`D001-A01``D055-A01` 是全部 PA 继承的全局覆盖目录;`D001-A02``D055-A02—A04` 是非本仓库端形态,统一作为全局冲突舍弃证据;`J003-A01` 是纯日期时间选择演示,统一作为全局冲突舍弃证据。`J003` 是唯一不生成 PA 反链的源基 ID;上述全局动作仍必须进入 341 条来源证据计数,不能用全局规则掩盖其他逐动作遗漏。
本次交给用户确认的证据与产品动作范围分别冻结为:
- `referenceEvidenceRecords=341`
- `adaptedReferenceEvidenceRecords=161`,只是“改造后采用”的来源证据数量,不是产品动作分母;
- `interactionEvidenceRecords=71`,只增加独立交互/状态验收证据,不新增数据合同;
- `closedReferenceEvidenceRecords=33`
- `rejectedReferenceEvidenceRecords=25`
- `pendingReferenceEvidenceRecords=51`,已反链到候选 PA,用户未单独批准时不进入实现范围;
- `productActionsTotal=166``requiredActions=111``candidateActions=55``approvedCandidateActions=0`
- `approvedNewRoutes=0`,当前提案全部先落现有 owner 或诚实关闭。
用户确认后,执行第 0 批只能:
1. 重算源文件、活动/注释路由与未注册文件清单,确认 341 条状态动作记录未因源变化失效;
2. 原样冻结 PA-001—PA-166、111 个必需动作、55 个候选动作及全部来源反链;执行阶段不得再归并、拆分、删除或重排 PA;
3. 对已证明等价的状态动作可共用实现 owner,但每个来源状态 ID 仍保留独立验收记录;新发现的角色/数据/权限/错误状态追加 `PA-xxx-Snn`,不能覆盖既有状态或改变产品动作身份;
4. “待用户确认”若本次仍无单独选择,一律维持候选 PA 的 `NOT_APPLICABLE`,不新增路由;
5. 分别报告路由分母、`requiredActions=111``liveCompletedActions`、逐 PA 四字段和 341 条来源证据去向,不得把来源证据条数、代码复用或结构遍历充当产品完成率;
6. 任一参考动作即使关闭态检查通过,其必需 PA 的 `productCompletion` 仍可为 `BLOCKED`;候选未获批则为 `NOT_APPLICABLE`,不能写成整个项目 PASS。
## 十、参考项目资产复用索引
用户已允许把第二参考源中的图标等资产作为当前产品的复用候选。当前只读分母为 `referenceAssetFiles=531`,全部位于 `Jiapu-App\static`,约 6.45 MiB;按声明扩展名为 PNG 307、GIF 221、SVG 2、JPG 1,按文件签名为 PNG 311、GIF 217、SVG 2、JPG 1。已发现 6 个扩展名/MIME 不一致和 14 组完全重复文件。
资产不会增加或改变 PA 分母。任何文件在复制前必须建立 `RAxxx` 记录并绑定一个已批准 PA 及其目标路由/组件/状态;相似图标、不同选中态、禁用态、权限态或主题态仍分别留证。逐文件字段和来源、隐私、旧品牌、格式、安全、视觉、无障碍、性能、候选构建验收门禁以主计划第 11.10 节为准。
当前候选分类只用于确定优先甄别顺序,不是逐文件采用结论;在 `RAxxx` 记录通过门禁前,以下各类采用数都仍为 0:
| 类别 | 例子 | 初始决定 |
| --- | --- | --- |
| 水墨/宗祠/谱书氛围 | `iconpng/902.png``iconpng/z8526@2x.png``login/bj.jpg``iconpng/book.png` | 优先甄别,尚未形成采用结论;固定文字、裁切、格式和可读性先处理 |
| 世系人物卡装饰 | `iconpng/treeBJ.png` | 优先甄别,尚未形成采用结论;若采用优先九宫格或重绘,避免 81×105 小图直接拉伸 |
| 中性导航/功能图标 | `icon/index.svg``navigation/*``tabulation/*``pu/*``jr.png` | 优先逐文件甄别直接复用或重绘,尚未形成采用结论;必须统一当前图标系统 |
| 默认头像风格 | `nan.png``nv.png``treeman.png` 及尺寸变体 | `待用户确认`具体风格;先过来源、肖像、偏见和重复哈希门禁 |
| 水墨蝴蝶动效 | `login/hd*.gif` | 仅作动效方向候选;必须有静态降级、减少动态效果和性能预算 |
| QQ 表情、抖音/视频实验、支付/微信/支付宝、VIP/奖励、旧 logo/口号 | `emojis/qq/*``douyin/*`、视频实验图、`wx.png``zfb.png``logo.png` 等 | `与当前产品冲突,明确舍弃`直接迁入;必要时仅参考语义并重绘 |
规划确认时固定:
- `referenceAssetFiles=531`
- `approvedDirectReuseAssets=0`
- `approvedAdaptReuseAssets=0`
- `assetReuseAuthorization=USER_ALLOWED_WITH_PER_FILE_GATES`
收到“开始执行”后,采用数只能随已完成门禁的 `RAxxx` 增加;不能整目录复制,不能用资产存在反向启用 F10、支付、邀请奖励等关闭或候选功能,也不能迁移旧组件、样式或外部资源地址。
@@ -1,36 +1,117 @@
# 今晚全量联调与明早测试执行计划
> 修订日期:2026-07-24(北京时间)
> 时间口径:“今晚”固定指 2026-07-23 晚间,“明早”固定指 2026-07-24 上午。
> 当前状态:用户已明确说“开始执行”,实施、测试与 MuMu 基座联调已启动;用户将本轮硬截止提前为 `T_due=2026-07-24 08:00`。55 个候选 PA 本轮仍不纳入(`approvedCandidateActions=0`)。
> 本次续执行状态:逐页 Apifox 账本已覆盖 53 个页面文件;当前页面目录不再导入 `data/mock`。已接线但未实测的创建/维护动作仍统一记为 `DECLARED_UNVERIFIED`;缺 DTO、ID、权限或上传 owner 的页面已改为明确关闭态,不以 fixture、本地数组、timer 或本地成功提示冒充服务端结果。本次续执行只做静态 API 语法和源代码归属核验,未构建、未操作 MuMu、未发起任何真实写入。
## 0. 已执行记录与人工回归保留
- 已通过 HBuilderX 标准基座连接 `emulator-5554` 并完成差量编译、同步、启动;本轮不构建或安装独立测试包。
- A01 密码登录页已在 MuMu 实机完成账号密码提交、协议勾选和真实供应商 TAC challenge 展示验证;TAC 已改为供应商原生呈现,不再叠加项目标题、主题、按钮或覆盖供应商刷新/关闭控件。
- 已自动通过 A01/A04/A05 的认证合同、认证接口映射、短信冷却、TAC renderjs 安全传输、注册后导航和编译审计;原始截图仅保存在本机临时受限目录,仓库不保留账号、手机号、令牌、短信或 TAC 相关截图。
- 2026-07-23 20:56 全量夜跑逻辑调度 `208` 项:`PASS=162``EXPECTED_BLOCKED=19``FAIL=0``INFRA_ERROR=27``timedOut=0`。27 项均依赖“`localhost:5173` 的 H5 页面 + `127.0.0.1:9222` 的 Chrome 调试页”;临时 H5 服务可启动,但当前执行环境拒绝启动 Chrome 调试端口,故夜跑器已将这组前置条件显式标为 `H5_CHROME_RUNTIME_UNAVAILABLE`,不将其写成产品失败或通过。临时 H5 服务已停止。
- 2026-07-23 23:01 HBuilderX 5.07 已通过标准基座向 `emulator-5554` 完成最新差量编译;控制台末次记录为“项目 jiapuapp 编译成功”。未生成或安装独立测试包。
- 同轮 Apifox 合同收紧后的最小验证已通过:`compile-audit``navigation-flow-contract``t04-relative-remote-close-contract``t05-member-remote-close-contract``lineage-write-apifox-contract``t03-t08-member-flow-contract``tree-member-fixture-runtime-smoke`。新增 Apifox 写入合同脚本尚未纳入 20:56 的历史全量夜跑,后续全量统计须重新计算,不得沿用 `208` 作为最新 inventory。
- 2026-07-23 23:29 已移除 T01 旧固定底部抽屉;点击头像人物卡现在直接打开唯一人物操作面板,面板中央头像可进入 T03,十个入口仍由同一 `selected` 人物上下文承载。相关 T01 面板、视觉、导航、文档流与编译审计已通过;23:28 的 HBuilderX 标准基座差量编译同样成功,未生成或安装独立测试包。
- 2026-07-23 23:37 已按 Apifox 的已登录改密 owner 接通 M04:`PUT /genealogy/app/auth/password``oldPassword/newPassword` 32 位 MD5 摘要、鉴权与 APP `clientId` 由统一请求层持有。页面移除“仅本地校验、不提交”伪流程,成功/明确失败/离页取消均有对应状态;本轮不实际修改测试账号密码,真实调用与密码恢复保留人工窗口。
- 2026-07-23 23:42 已按 Apifox 的已登录退出 owner 接通 M10REMOTE 模式使用 `DELETE /genealogy/app/auth/logout`,无请求体,鉴权与 APP `clientId` 继续由统一请求层持有;严格成功才可确认远端已响应。无论请求失败、取消或结果未知,本机会话均安全清理并回到 A01,不自动重试、不把失败写成服务端已退出。本轮未对测试账号发送该敏感请求。M10、M04 的静态契约、页面归属、导航和编译审计均已通过;23:42 HBuilderX 标准基座差量编译成功,未生成或安装独立测试包。
- 2026-07-23 23:50 当前源码的全量夜跑已完整执行 `209/209``PASS=163``EXPECTED_BLOCKED=19``FAIL=0``INFRA_ERROR=27``timedOut=0``NOT_RUN=0`。库存为 `baselineInventoryTests=204` 加本轮 `newExecutableTests=5`。27 项均是需 `localhost:5173` 页面及 `127.0.0.1:9222` Chrome 调试页的 H5 运行时检查,统一记 `H5_CHROME_RUNTIME_UNAVAILABLE`,不写成产品失败或通过;因此本轮尚无可冻结候选。T01 唯一操作面板替换造成的五项旧断言/孤儿资产偏差已外科式收口并重跑通过。23:50 HBuilderX 5.07 标准基座差量编译成功,未生成或安装独立测试包。
- 2026-07-24 00:00 已从 `emulator-5554` 的 HBuilderX 标准基座读取 A01 实机画面:国风登录页、密码/验证码切换、协议入口、注册/忘记密码与微信登录入口均正常呈现,无白块、截断或覆盖。此检查未输入账号密码、未触发 TAC、短信、注册、找回或任何远端写操作;截图只保留在本机临时受限目录,不入库,也不替代登录后的完整实机验收。
- 2026-07-24 00:13 已用 HBuilderX 临时 Web 运行复核 H5 页面服务,`http://localhost:5173` 返回 `200`;但 `127.0.0.1:9222/json/list` 仍超时,当前执行环境不允许启动隔离 Chrome 调试端口。故 27 项 H5 Chrome 运行时检查继续统一记 `H5_CHROME_RUNTIME_UNAVAILABLE`,不改变其测试口径,也不将 H5 服务可达写成浏览器运行时通过。
- 2026-07-24 00:30 已在本机临时验收目录建立唯一 `role-seed-matrix.json`:仅登记脱敏测试角色别名、允许动作、人工 TAC/短信阻塞与清理责任,当前对象 ID 为零;不含账号、手机号、密码、令牌、验证码、TAC proof 或原始响应。
- 2026-07-24 00:31 已对当前源码重新执行 T0 夜跑:`inventory=209``scheduled=25``executed=25``PASS=6``EXPECTED_BLOCKED=19``FAIL=0``INFRA_ERROR=0``timedOut=0``notRunScheduled=0`;其余 184 项为本次 T0 未调度项,不得写成已通过。输出仅保存在本机临时验收目录。
- 2026-07-24 00:33 已重跑 T01/T03—T08 的 12 项人物流门禁:唯一人物操作面板、树状态/视觉/文档流、成员详情映射、亲属与资料写入合同、排行关闭态、Apifox 写入合同及树 fixture runtime 均通过。此为静态/运行时映射证据,不替代未取得的真实服务端写入响应。
- 2026-07-24 00:36 已对受保护的“我的家谱”读取执行无鉴权只读探测:远端可达,HTTP 成功信封内业务码为 `401` 且数据为空。未携带账号、令牌或其他凭据,未发送写请求;后续受保护读取/写入的唯一下一步仍是人工完成 TAC 并建立会话。
- 2026-07-24 00:40 已对当前源码完整执行 `ALL` 夜跑 `209/209``PASS=163``EXPECTED_BLOCKED=19``FAIL=0``INFRA_ERROR=27``timedOut=0``NOT_RUN=0`。27 项均在连接 `localhost:5173` 的 Chrome 调试页前被统一标记 `H5_CHROME_RUNTIME_UNAVAILABLE`,没有进入产品断言;不得将它们记为产品失败或通过。输出仅保存在本机临时验收目录。
- 2026-07-24 00:45 已再次读取 `emulator-5554` 的 HBuilderX 标准基座 A01 画面:国风背景、密码/验证码切换、手机号与密码输入区、协议入口、注册和找回入口完整可见,无白屏、截断或遮挡。未输入账号、未触发 TAC/短信或远端写操作;截图只保存在本机临时受限目录。
- 2026-07-24 00:51 已在 Apifox 的 APP 目录核对“搜索行政区划” operation 为 `GET /genealogy/region/search`,随后以只读请求取得当前部署实证:HTTP `200`、业务码 `200`、服务器 `Date=Thu, 23 Jul 2026 16:51:08 GMT`,单条结果稳定含字符串 `regionCode``label``parentCode``ancestors``regionName``regionLevel``regionType``leaf`。当前部署的旧 `/genealogy/app/region/search` 同样返回该投影,但 Apifox 的 APP 目录 owner 仍以前者为准;不将双路径同响应静默写成兼容 owner。
- 2026-07-24 00:52 已重订 G03 客户端历史门禁:它只校验明确本地预览且不存在虚构的原子 bootstrap、结果查询、错误区域路径或远端请求,已转为可通过并从预期阻塞清单移除。保护 OpenAPI 门禁同时改为只报告实际快照与 Apifox 的合同冲突,不再要求项目实现未声明的 bootstrap。
- 2026-07-24 00:55 已对更新后的当前源码再次完整执行 `ALL` 夜跑 `209/209``PASS=164``EXPECTED_BLOCKED=18``FAIL=0``INFRA_ERROR=27``timedOut=0``NOT_RUN=0`。库存仍为 `baselineInventoryTests=204` 加本轮 `newExecutableTests=5`。27 项均因当前执行环境拒绝提供 `127.0.0.1:9222` 的 Chrome 调试运行时而在产品断言前标记为 `H5_CHROME_RUNTIME_UNAVAILABLE`;不记为产品失败或通过。输出仅保存在本机临时验收目录。
- 2026-07-24 00:56 已重订 M06 帮助中心的旧 OpenAPI 门禁:不再要求受保护快照提供 `RListHelpArticleVo`、文章分类/标题/纯文本正文等当前 Apifox 未声明的 DTO,也不引入独立详情 owner。门禁现已通过并从预期阻塞清单移除;M06 页面补充“本地使用说明、非服务端文章”的可见来源提示,仍只提供本地说明与 M07 反馈入口,远端列表保持 `DECLARED_UNVERIFIED`
- 2026-07-24 00:57 已对 M06 变更后的当前源码再次完整执行 `ALL` 夜跑 `209/209``PASS=165``EXPECTED_BLOCKED=17``FAIL=0``INFRA_ERROR=27``timedOut=0``NOT_RUN=0`。27 项仍全部为 `H5_CHROME_RUNTIME_UNAVAILABLE`,未进入产品断言;输出仅保存在本机临时验收目录。
- 2026-07-24 00:58 HBuilderX 5.07 控制台记录“开始编译…”后输出“项目 jiapuapp 编译成功”,已通过标准基座同步 `emulator-5554`;未生成或安装独立测试包。前台 CLI 未自行退出但对应基座仍正常前台,故只将 IDE 的成功回执计为编译证据,不将 CLI 常驻误记为失败或额外构建。
- 2026-07-24 01:04 已读取同步后的 `emulator-5554` 标准基座 A01 画面:登录页完整可见,密码/验证码登录切换、手机号/密码输入、找回、微信、注册与协议入口均无白屏、截断或错位。未输入账号、未勾选协议、未触发 TAC/短信或任何远端写请求;截图只保存在本机临时受限目录。
- 2026-07-24 01:08 已重订认证旧 OpenAPI 门禁:密码登录的唯一请求 wire 为 `phone`、MD5 `password``grantType=password`,原生 TAC 保持客户端前置,不上传 `validToken`;短信发送仍为唯一消费 `validToken` 的认证动作。门禁与认证 API runtime 均通过,并从预期阻塞清单移除。
- 2026-07-24 01:10 已对当前源码完整执行 `ALL` 夜跑 `209/209``PASS=167``EXPECTED_BLOCKED=15``FAIL=0``INFRA_ERROR=27``timedOut=0``NOT_RUN=0`。剩余 15 项均为仍缺真实合同、稳定 DTO/权限投影或人工前置的精确阻塞;27 项仍为未取得 `127.0.0.1:9222` Chrome 调试运行时的 `H5_CHROME_RUNTIME_UNAVAILABLE`,未进入产品断言。输出仅保存在本机临时验收目录。
- 2026-07-24 01:12 已修复 G03 OpenAPI 门禁以中文计划文件名读取时在 Windows PowerShell 无 BOM 解析下产生的伪阻塞;该门禁现只依据快照/客户端合同判断并通过,也已从预期阻塞清单移除。若同版本快照以后出现错误 owner,仍会以精确 `BLOCKED` 报告。
- 2026-07-24 01:20 已再跑最新 T0`25/25``PASS=10``EXPECTED_BLOCKED=15``FAIL=0``INFRA_ERROR=0``NOT_RUN=184`。本轮 allowlist 与计划中的 15 条精确阻塞逐项一致;其余 H5 运行时前置只在 `ALL` 中作为基础设施阻塞统计。
- 2026-07-24 03:59 已将 M04/M10 的旧快照门禁改为当前 Apifox 已核对客户端接线门禁:M04 校验 `PUT /genealogy/app/auth/password` 的 MD5 body 与取消归属;M10 校验 `DELETE /genealogy/app/auth/logout` 的无 body、取消归属与本机会话兜底。两项均通过并从预期阻塞清单移除;真实改密、服务端退出继续分别保留人工恢复/登录会话验证,未发起任何远端 mutation。随后完整执行 `ALL` 夜跑 `209/209``PASS=169``EXPECTED_BLOCKED=13``FAIL=0``INFRA_ERROR=27``timedOut=0``NOT_RUN=0`。27 项仍均为缺失 `127.0.0.1:9222` Chrome 调试运行时的 `H5_CHROME_RUNTIME_UNAVAILABLE`,未进入产品断言;输出仅保存在本机临时验收目录。
- 2026-07-24 04:00 已按更新后的 manifest 再跑 T0`23/23``PASS=10``EXPECTED_BLOCKED=13``FAIL=0``INFRA_ERROR=0``NOT_RUN=186`allowlist 与第 0.2 节的 13 条精确自动阻塞逐项一致。该结果只说明安全/合同候选门禁没有新增失败,不替代人工 TAC、短信、敏感 mutation 或登录后实机闭环。
- 早晨人工回归保留:密码登录真实拖动 TAC;短信登录、注册、忘记密码的真实短信与 TAC。不得绕过、猜解或重复请求这些人机/短信步骤。
### 0.1 明早人工回归最短清单
1. 使用已启动的 HBuilderX 标准基座进入 A01;不构建或安装独立测试包。
2. 人工勾选协议后,在密码登录页使用已单独提供的测试账号发起一次登录;在供应商原生 TAC 中由人工完成拖动,不添加任何项目自定义样式或覆盖层。成功后检查是否进入 G01,并冷启动一次确认会话恢复;不记录或截图凭据、令牌、验证码和 TAC proof。
3. 短信登录、注册和忘记密码各自需要真实收码人和原生 TAC:每个场景只在本人在场时发起一次,遵守页面冷却;未能完成即记录“人工 TAC/短信阻塞”,不重试、不猜解、不绕过。
4. 换绑手机号、修改密码、注销、支付、提现、删除和退出家谱均不在无人值守阶段执行。若人工验证修改密码,结束后按约定恢复,并只在本机临时脱敏台账登记结果。
5. 人工结果只记录页面、动作、时间、四字段结论和非敏感对象 ID;远端返回、手机号、账号、密码、令牌、验证码、TAC proof、邀请码和个人资料一律不进入仓库、文档或截图文件名。
### 0.2 当前 12 条精确自动阻塞与解除条件
| 门禁 | 当前阻塞 | 解除条件 |
| --- | --- | --- |
| Android 认证无障碍 | 原生 TAC 的 TalkBack、键盘与大字号无法由当前自动化可靠判定 | 早晨在 MuMu/真机人工完成原生 TAC 无障碍回归 |
| G11 家谱设置 | 依赖的 mine/overview 工作区读取未取得唯一稳定 owner | 先收敛工作区读取合同与真实权限投影 |
| G12 字辈诗 | 正常列表、维护列表、批量预览与保存已接线,但尚未获得真实登录会话下的读取/预览/保存响应 | 在人工可观察会话中按“读列表→读维护→预览→保存→维护回读”验证;浏览器 CORS 仅是 H5 环境前置,不替代 APP 业务合同 |
| 家谱工作区 | 单家谱 overview owner 在受保护快照中缺失或不唯一 | 以 Apifox 与同版本快照收敛唯一读取路径、DTO 与权限 |
| 邀请票据 | 无“我的票据/签发/撤销/接受”当前 operation | 后端提供独立邀请票据闭环,不以普通申请替代 |
| 加入申请 | public 搜索/申请 operation 标识与快照不一致 | 后端同版本合同明确查询、提交、撤回、审核及结果语义 |
| 世系 locator | 缺少精确定位人物的读取 operation | 后端声明并实测人物定位/恢复读取 owner;不得从列表猜 ID |
| 通知读取 | 缺未读数 operation 且列表无稳定通知 DTO | 后端提供未读数与列表 item 的 ID、正文、类型/跳转投影 |
| 通知已读状态 | notification ID wire 与 `RVoid` 信封约束不完整 | 同一 owner 收紧 ID、响应和错误语义,再用可控通知种子实测 |
| 换绑手机号 | 缺专用受保护发码 operation 与资料前置投影 | 后端补齐 operation/DTO 后,在人工 TAC/短信窗口实测 |
| 个人资料读取 | `RObject` 未声明可消费资料字段 | 后端提供稳定资料 DTO、权限与脱敏语义 |
| 个人资料更新 | 缺 profileVersion/If-Match 与合并 body 合同 | 后端提供版本并发与字段白名单,再对测试账号做可恢复验证 |
M04 和 M10 的客户端接线门禁现只以 Apifox 已核对的 owner、方法、body/空 body、取消归属与本机会话收口为准,已不再把旧受保护导出中虚构的会话安全语义当作阻塞。两项真实远端 mutation 仍均为 `DECLARED_UNVERIFIED`:改密必须在可恢复的人工窗口完成后恢复约定密码;服务端退出必须由人工登录会话确认撤销结果。两者均不得在无人值守期间发起。
F01/F02/F03 必须拆开处理:F01 已删除本地动态列表,因列表条目 DTO 缺失而明确关闭正文/详情入口;F02 已按唯一可映射的 `feedContent` 接通发布,不发送媒体、排序或状态猜测字段;F03 动态正文继续不接线、不猜字段,一级评论已有 `FamilyFeedCommentView[]` 和明确提交 body,客户端已按该合同接通真实读取/提交并在提交后回读。没有真实登录会话、实际响应和人工可观察写入前,三页均仍是 `DECLARED_UNVERIFIED`,绝不能把接线写成已完成。
## 一、计划地位
本文是今晚到明早的唯一执行顺序与验收入口。既有治理文档继续提供历史背景和长期发布约束,但与今晚的批次顺序、时间分配或“可测试”口径冲突时,以本文为准。`APP.openapi.yaml``APP.openapi.json` 只读且受保护,不得修改、格式化、覆盖或删除。
本文是今晚到明早的唯一执行顺序与验收入口[产品参考页面功能映射表](./产品参考页面功能映射表.md) 是本文直接引用的权威附件,二者必须一起评审、一起确认。既有治理文档继续提供历史背景和长期发布约束,但与今晚的批次顺序、时间分配或“可测试”口径冲突时,以本文及该附件为准。`APP.openapi.yaml``APP.openapi.json` 只读且受保护,不得修改、格式化、覆盖或删除。
当前处于“规划重整”阶段。用户明确确认本版规划前,冻结业务代码、页面样式、接口接线、构建和 MuMu 操作;只允许只读核对与中文规划文档写入。规划确认后再按本文批次测试先行实施,不以已存在的半成品或局部截图代替用户确认
当前处于“执行与验证”阶段。用户已完成三人交叉评审后的规划确认并明确说“开始执行”,因此允许按本文对业务代码、页面样式、接口接线、测试、HBuilderX 与 MuMu 标准基座做最小必要变更与验证;仍不得以已存在的半成品、旧报告、参考项目能运行或局部截图代替当前项目验收
## 二、明早交付目标
## 二、明早交付目标与项目范围
明早交付一套连接 `https://backend-api.ddxcjp.cn/`、能够在 MuMu 中从认证入口开始遍历的 UniApp 测试版本。`pages.json` 中全部 52 条活动路由都必须可进入、可返回、无白屏和死路;后端已有接口的页面必须调用真实接口,后端确实没有接口的页面必须显示“服务暂未开放”或同义的明确状态,不得用 mock、fixture、timer 或假成功伪装闭环
本规划对象是当前 `jiapuapp` UniApp 整个项目,不只覆盖认证或世系树。A、G、T、F、R、N、M 七个路由域、共享导航与会话、文件上传、权限、异常恢复、视觉、参考资产、无障碍、构建和 MuMu 验收都在范围内。参考思维导图中的后台管理、公司官网和独立 PC 管理端不是本仓库的实现范围,只能为 APP 角色、权限和后端依赖提供参考,不能据此在当前仓库静默扩张成三个新项目
当前 `pages.json` 的 52 条活动路由是今晚的最低活动基线,不是产品功能的永久上限,也不能单独代表整个项目完成。两个参考源中发现的候选功能必须先逐页面、逐动作、逐接口完成书面甄别;只有结论为“直接采用”或“改造后采用”,且明确当前路由归属或新增路由必要性后,才进入最终活动清单。最终遍历分母为“52 条基线路由+本轮明确采纳并已注册的新增路由”,报告必须同时给出基线分母和最终分母,禁止用 52/52 掩盖已确认但未纳入的产品功能。
明早目标是在用户授权开始执行后,交付一套连接 `https://backend-api.ddxcjp.cn/`、能够在 MuMu 中从认证入口开始遍历的 UniApp 测试版本。最终活动清单中的页面必须可进入、可返回、无白屏和死路;后端已有且合同可用的动作必须调用真实接口,后端确实没有可靠 operation 的动作必须显示“服务暂未开放”或同义的明确状态,不得用 mock、fixture、timer、本地数组写入或假成功伪装闭环。
测试版本不等同于正式发布版本。发布签名、正式域名 CORS、后端未实现合同和真实支付等外部条件可以保留为发布阻塞,但必须提供失败门禁、复现证据、解除条件和继续步骤。
“全量”分三层报告,禁止混为一个结论
“全量”按五个互不替代的层级报告,禁止把较低层通过写成整个产品完成
1. 已接真实接口的链路执行 live E2E,并据证据判定通过或失败
2. 尚无后端 operation 的页面执行路由、交互、状态和视觉回归,并明确标为服务未开放,不能记作接口通过
3. OpenAPI、签名、正式域名和其他发布门禁按预期发布阻塞单列,不能把预期红灯吞成全绿,也不能让它掩盖本机可修复失败
1. **产品库存覆盖**:52 条当前基线路由、已批准新增路由、60 个设计文件、78 条参考活动路由、1 条注释路由声明、7 个未注册参考页面文件和 531 个参考媒体资产均有稳定 ID、候选池或书面去向;资产候选池覆盖不等于逐文件批准复用
2. **路由结构遍历**:某候选包中的已注册活动路由可进入、可返回、无白屏和死路;这只证明结构可遍历
3. **实时接口迁移**:单个动作的合同、权限、请求、响应、刷新和重进闭环均完成,并通过引导式真实设备实时验收;当前没有可证明自动操控设备与断言业务结果的完整 E2E harness,因此不使用“自动 live E2E”表述
4. **明早最低候选**:存在可追溯的冻结候选,当前 7 个直接 `appApi` consumer 的主链结果、其余页面的数据模式、参考甄别结论和精确剩余项齐全;完整九小时窗口的目标还包括 52 条基线路由结构结果。若有效窗口不足或结构遍历未完成,未跑路由必须记 `NOT_RUN`,本层不得冒充达成。
5. **整个产品完成**:所有已批准产品动作均达到产品完成条件;任一必需动作仍为关闭、半成品、未运行或合同阻塞时,整个产品不得标记完成。
今晚的可靠最低交付是“52/52 路由可进入返回 + 当前 7 个 remote consumer 的主链可信联调 + 其余页面诚实标记数据模式”;在此基础上按完成定义继续扩大 LIVE 页面数量。不得为了追求数字把只完成路径拼接、宽 DTO 直传或单一成功样例的页面提前标记为 LIVE
完整九小时窗口的可靠目标是第 1、2、4 层和可在时间窗内诚实完成的第 3 层;窗口缩短时第 1 层与精确剩余项仍必须交付,第 2、3、4 层按实际结果报告,不承诺在一个夜间窗口内把全部 52 页与所有参考候选都迁成 LIVE。不得为了追求数字把路径拼接、宽 DTO 直传、页面内 fixture、timer 提示、诚实关闭页或单一成功样例提前标记为产品完成
## 三、已确认口径
- 当前源码基线由用户在 2026-07-23 手动拉取最新 `main` 后提供。本轮及后续执行均不进行任何 Git 操作,也不读取或写入 HEAD、分支、dirty、提交哈希作为实施前置;计划中的旧提交哈希全部视为历史记录,不再代表当前基线。
- 第一参考源为 `C:\Users\Rain\Desktop\job\app设计`,当前只读清单为 59 张 PNG 和 1 份 PDF。项目内 `docs/design/references/产品参考原稿` 与其中 59 个同名文件 SHA-256 一致;源目录新增 `思维导图.png`,本轮只把它作为规划证据,不在用户确认前复制或归档。
- 第二参考源为 `C:\Users\Rain\Desktop\job\Jiapu-App`。第一轮只读盘点确认其 `pages.json` 有 79 个 `path` 文本,其中 `pages/index/vertical-swiper/vertical-swiper` 整段已注释,故活动路由为 78 条;另有 79 个 Vue 文件、6 个 NVue 文件、7 个未注册页面文件和 113 个旧 API wrapper。未安装依赖、未运行、未构建、未修改。它使用旧技术与旧接口封装,只能证明候选页面、动作和状态存在,不能证明当前后端合同可用。
- 用户已明确允许把第二参考源中的图标、插画、背景等资产作为复用候选。该授权只改变“可以逐文件甄别”的边界,不代表整目录批准,也不证明第三方/商标素材的来源或平台规范;任何资产仍须绑定已批准 PA、页面和状态,并逐文件通过来源、隐私、旧品牌、格式、视觉、无障碍和性能门禁。
- 产品目标以用户对当前项目的明确需求为第一优先;接口路径、方法、字段、权限、状态码和失败语义以当前真实后端合同与可复现响应为准;当前项目架构、安全和国风视觉决定落地方式;截图和已完成参考项目排在最后,只作候选证据。
- 唯一后端为 `https://backend-api.ddxcjp.cn/`,今晚联调使用 remote 模式。
- 已提供有效测试账号;账号凭据只用于本机联调,不写入源码、测试、文档、日志或 Git。
- 已用标准 JSON 请求确认密码登录返回 HTTP 200,`/captcha/challenge` 返回 HTTP 200`TIANAI/SLIDER` 和完整挑战数据。此前空体 500 是 PowerShell 调用原生 `curl` 时 JSON 引号被破坏造成的错误诊断,不再作为后端阻塞
- 规划前在 2026-07-23 曾以标准 JSON 观察到密码登录和 `/captcha/challenge` 返回 HTTP 200challenge 摘要为 `TIANAI/SLIDER`;后端可识别版本未留存,敏感响应不进入规划。该记录只解释此前因命令行 JSON 引号破坏得到的空体 500,不属于本轮 `LIVE_VERIFIED``PASS`。收到“开始执行”后仍须对冻结的线上合同和部署重新取证
- 密码登录、短信登录、注册、忘记密码都必须显示并完成 TAC。密码登录接口不接收 `validToken`,滑动成功只作为客户端强制前置;短信发送接口必须消费对应场景的 `validToken`
- 允许操作 MuMu,并把实机画面、返回路径和交互状态纳入验收。
- 沿用当前 `static` 国风视觉资产和现有页面结构;今晚修复白块、截断、错位、默认控件、触控尺寸、长文本和不同宽度适配,不重做整套视觉
- 只有用户明确说“开始执行”后才允许操作 MuMu,并把实机画面、返回路径和交互状态纳入验收;规划确认本身不构成 MuMu 授权
- 当前 `static` 国风视觉资产、token 和现有页面结构仍是唯一视觉基线;参考项目资产可在逐文件通过门禁后直接复用、裁切重导出或重绘,不能反向改变已批准产品动作,也不能把旧整页红色主题、旧组件或旧样式一并迁入
- 允许测试账号创建带“联调测试”标识的家谱、成员、申请、动态、文章、相册、礼仪、备忘、功德和反馈等测试数据。
- 不删除已有真实数据;无人值守期间不执行支付、换绑手机号、注销账号或不可逆操作。修改密码若经人工窗口测试,结束后恢复约定密码。
- 不执行 `git add``commit``push``restore``checkout``reset`,除非用户另行明确授权
- 本轮不执行任何 Git 命令或 Git 写操作;源码同步由用户自行负责
## 四、单页完成定义
@@ -42,138 +123,289 @@
4. MuMu 实际画面没有白块、文字截断、控件错位、默认原生底色或不可点击区域;普通内容、长内容和状态页均可阅读。
5. 页面对应的 mapper/runtime 测试、静态合同、编译检查和实机检查均通过。
页面内存在按钮不等于功能完成。每个主动作还必须验证“入口人物或对象正确 → 参数身份正确 → 权限来自可靠合同 → 请求结果可判定 → 返回后源页面刷新为服务端事实 → 重进页面仍一致”的跨页闭环;其中任一环仍依赖 fixture、旧参考接口、timer 或本地数组写入时,该动作不得标记完成。一个页面有多个主动作时逐动作记状态,不能用一个成功动作覆盖其他未完成动作。
自动脚本通过不能替代实机视觉合格,单张静态截图也不能替代接口与交互合格。
所有报告项只使用以下五种状态:`PASS``FAIL``EXPECTED_BLOCKED``NOT_RUN``INFRA_ERROR`。每项必须同时记录首次结果;如果进行了允许的只读重试,另记重试结果,不覆盖首次失败。
每个动作必须同时记录四个正交字段,禁止用一个“通过”覆盖不同含义:
| 字段 | 允许值 | 判定用途 |
| --- | --- | --- |
| `contractState` | `LIVE_VERIFIED``DECLARED_UNVERIFIED``CONTRACT_CONFLICT``MISSING_OPERATION``NOT_APPLICABLE` | 当前后端合同及真实部署证据;纯本地权威内容用 `NOT_APPLICABLE` |
| `dataMode` | `LIVE``LOCAL_PREVIEW``CLOSED` | 页面当前从哪里取得或是否允许提交数据;唯一 owner 的正式本地静态内容可为 `LIVE` |
| `checkResult` | `PASS``FAIL``EXPECTED_BLOCKED``NOT_RUN``INFRA_ERROR` | 某一次合同、测试、构建或实机检查的结果 |
| `productCompletion` | `COMPLETE``PARTIAL``BLOCKED``NOT_APPLICABLE` | 用户所需产品动作是否真正闭环 |
`CLOSED` 页面的关闭文案、返回路径和无假成功检查可以得到 `checkResult=PASS`,但必需动作仍须记 `productCompletion=BLOCKED`;不能由此把页面或产品写成完成。已知合同门禁脚本只有在“非零退出+精确 `... BLOCKED` 标记”同时成立时才记 `checkResult=EXPECTED_BLOCKED`,它与关闭页行为检查的 `PASS` 不是同一结果。`LOCAL_PREVIEW` 最多为 `PARTIAL`。只有已批准动作在 LIVE 模式完成权限、请求、结果确认、刷新和重进闭环时,动作才可为 `COMPLETE`
汇总规则固定为:
1. 动作逐项保留四字段,不取平均值。
2. 页面另报 `structuralCheck``productCompletion`;任一必需动作 `productCompletion=BLOCKED`,页面产品完成度即 `BLOCKED`;没有 BLOCKED 但存在 `productCompletion=PARTIAL`,或必需检查 `checkResult=NOT_RUN`,页面最多为 `PARTIAL`
3. 域与整个项目只按已批准必需动作向上汇总;出现 `BLOCKED` 即不得宣称完成,全部为 `COMPLETE/NOT_APPLICABLE` 才能标记域或产品 `COMPLETE`
4. 每个 `checkResult` 同时记录首次结果;允许重试时另记重试结果,不覆盖首次失败。
## 五、接口判定和实现规则
1. Apifox 用于判断接口业务用途;线上 `/v3/api-docs` 和真实请求用于确认当前部署路径、字段及故障表现;受保护 OpenAPI 只作离线补充。
2. 先列出当前批次的“页面 → 动作 → 方法 → 路径 → 输入 → 页面字段 → 成功/失败表现”,再写该批次失败测试,随后实现。
3. 接口存在就接入;4xx、5xx、空体、缺字段或身份错配必须展示真实失败,不回退 mock。
4. 后端没有对应 operation 时,页面保留完整视觉和返回路径,动作显示服务未开放,不伪造保存、发送、上传、支付、邀请或审核成功。
5. 单个接口排查超过 15 分钟仍无本机解决路径时,立即记录复现证据并继续其他页面,避免阻塞整夜。
6. 每个合同只有一个 owner;新合同落地时同步移除同类 fixture、旧入口、兼容读取和过时测试期望。
7. 真实写操作仅限测试账号和本轮新建、带“联调测试”标识且已登记 ID 的对象。GET 可按退避规则重试;写操作只有接口明确提供幂等键且结果可判定时才允许重试,超时、5xx、断线或进程终止造成结果未知时立即停止该动作并查只读结果。
### 5.1 Apifox 桌面端优先的合同发现门禁(本轮新增硬约束)
每条路由的执行记录至少包含:`route`、真实入口、必填参数、测试角色/种子、`dataMode(LIVE/LOCAL_PREVIEW/CLOSED)`、主动作、返回结果、页面状态证据、LIVE 请求响应证据、MuMu 结果和 blocker。
导出的 `APP.openapi.yaml``APP.openapi.json` 和线上 `/v3/api-docs` 均可能缺少 Apifox 工作区中已经维护、但未导出的接口;它们只能作索引和交叉核验,绝不能作为“接口不存在”或“接口完整”的结论。每个受影响动作在写代码、关闭入口、补测试或判定 `MISSING_OPERATION` 前,必须先在用户已打开的 **Apifox 桌面端**逐项只读核对:
1. 所属目录、接口名称、HTTP 方法和完整路径;
2. 鉴权方式、必填请求头、路径/查询/请求体字段及类型;
3. 成功与失败响应示例、业务码、分页/ID wire 和权限语义;
4. 是否存在同一业务动作的补充接口、前置接口或结果查询接口;
5. 与导出文档、当前 `utils/api.js`、参考项目或真实请求不一致之处。
核对结果须以“动作 → Apifox 证据 → 当前部署实测 → 结论”写入临时脱敏账本;不得记录 Token、手机号、验证码、TAC proof、邀请口令或原始敏感响应。Apifox 与导出文档不一致时,以 Apifox 的业务定义为先导,并用真实部署响应复核 wire/权限/失败语义;两者仍不能证实时标为 `CONTRACT_CONFLICT``DECLARED_UNVERIFIED`,不得凭导出缺项关闭功能。只有 Apifox 也不存在、且经目录/关键词/相邻业务链检索留证后,才可标记 `MISSING_OPERATION`
#### 5.1.1 本轮 Apifox 只读核对记录(2026-07-23 晚)
下表只记录已在用户打开的 Apifox 桌面端逐页读取到的声明;尚未通过测试账号取得同一部署的脱敏真实响应,故均不得写成 `LIVE_VERIFIED`
| 动作/对象 | Apifox 桌面端声明 | 当前结论 |
| --- | --- | --- |
| 世系树读取 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/tree``genealogyId` 为必填 `int64` 路径参数,APP `clientId` header 必填 | 路径与当前读取 owner 一致;ID wire 和权限投影仍待实测,`DECLARED_UNVERIFIED` |
| 人物详情 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}`,两个路径 ID 均为必填 `int64`,响应为 `LineagePersonResult` | 路径与当前读取 owner 一致;`DECLARED_UNVERIFIED` |
| 人物新增/编辑 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons``PUT /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}` 均使用统一人物 body`name` 必填,`sex` 仅说明“建议使用系统字典值”,`avatarOssId` 只能来自统一上传,`sortOrder` 仅说明“数值越小越靠前”。已以“字典”“性别”检索 APP/PC 目录,均未发现可供 APP 消费的枚举 operation | 首人/编辑可继续按 body 收紧;性别字典、上传闭环和单人排序的并发语义未证实,不能虚构枚举或把 `sortOrder` 当原子排行 |
| 新增父母/子女/兄弟姐妹/配偶 | 已分别声明 `POST .../parents``POST .../children``POST .../siblings``POST .../spouses`,均返回 `LineagePersonResult` | 原来按导出文档作出的“无 operation”结论无效;关系方向、性别字典、冲突、权限、结果未知恢复尚待实测,先列为 `DECLARED_UNVERIFIED`,不得以本地成功替代 |
| 统一文件上传与人物头像 | `POST /genealogy/app/files/upload`APP `clientId` header 必填,`multipart/form-data` 仅有必填 `file`;返回 `ossId``url``thumbUrl``fileName``originalName`。树与人物详情只声明 `avatarOssId`,未声明可重进读取的头像 URL/file projection | 上传本身为 `DECLARED_UNVERIFIED`;不得把 `avatarOssId` 拼接为 URL,也不把一次上传响应 URL 持久化成资料事实。T01 使用确定性国风默认头像;真实头像显示仍等待文件读取投影与真实重进验证 |
| 已登录密码修改 | `PUT /genealogy/app/auth/password`,要求鉴权与 APP `clientId`body 为 `oldPassword``newPassword` 两个 32 位 MD5 字段 | 与 A05 的无登录态 `PUT /genealogy/app/auth/password/reset` 不是同一 owner;M04 已按独立 owner 接线,未发生本轮真实 mutation,仍为 `DECLARED_UNVERIFIED`,人工恢复窗口验证前不得标记完成 |
| 已登录手机号换绑 | `PUT /genealogy/app/auth/phone`,要求鉴权与 APP `clientId`body 为 `clientId`、新 `phone`、四位 `smsCode`;通用短信发送接口声明允许 `APP_PHONE_CHANGE`,但前置 `validToken` 需先完成验证码中心 | 当前 profile 响应未声明可安全消费的手机号 DTO,且真实 TAC/短信需要人工窗口;M05 不猜字段、不静默发码或换绑,保持人工 `DECLARED_UNVERIFIED` |
| 已登录退出 | `DELETE /genealogy/app/auth/logout`,要求鉴权与 APP `clientId`,无 body,响应 `RVoid`/`data:null` | M10 已接严格 REMOTE owner;当前未发真实退出 mutation,仍为 `DECLARED_UNVERIFIED`。本机退出与服务端撤销确认分开记录,不能把前者写成后者 |
| 家族圈列表、正文详情与发布 | 列表:`GET /genealogy/app/genealogies/{genealogyId}/feeds``ListResult`);详情:`GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}``ObjectResult`);发布:同一路径 `POST``ObjectResult`)。三者均需鉴权与 APP `clientId`,路径 ID 均为 `int64`。发布 body 仅明确 `feedContent` 非空必填;`feedType` 默认 `text``mediaOssIds` 为英文逗号分隔 OSS ID、`sortOrder` 默认 `0``status` 默认正常 | 列表只见 `property1/property2` 通用项,正文详情和发布也未声明页面可消费 DTO;F01 已删除本地列表,F03 正文不猜字段。F02 只发送 `feedContent`,且只有严格成功信封后才显示已提交;不能把泛型响应当作列表条目或已完成验收;三者均为 `DECLARED_UNVERIFIED` |
| 家族圈一级评论 | 读取:`GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments` 返回 `FamilyFeedCommentView[]`;提交:同路径 `POST``commentContent:string` 必填且最多 1000 字,`parentCommentId:int64|null` 可选。读取 item 已声明 `commentId/genealogyId/feedId/appUserNickName/commentContent/replyCount/createTime` 等字段 | F03 已删除本地动态/评论 fixture;读取仅展示 `commentId`、昵称、内容、回复数和创建时间的已声明映射;提交只发送 `commentContent`,成功后重新读取服务端列表 | 客户端已接线,但未取得真实登录态响应或人工可观察的提交结果;仍为 `DECLARED_UNVERIFIED`,正文详情 DTO 缺失不影响评论 owner 的独立核对 |
| 谱文列表、详情与创建 | 列表:`GET /genealogy/app/genealogies/{genealogyId}/articles`;详情:`GET .../articles/{articleId}`,均为通用响应。创建:`POST .../articles`,已声明 `articleTitle/articleContent` 必填,`categoryId/articleSummary/coverOssId/authorName/sortOrder/status` 可选 | F04/F05 已删除本地文章、分类、搜索和正文展示,等待稳定 item/详情 DTO;F06 只发送 `articleTitle/articleContent`,无分类 ID 来源时不发送分类。编辑需要可靠详情/文章 ID 来源而关闭 | 创建客户端已接线但未实测,列表/详情/编辑仍为 `DECLARED_UNVERIFIED`;不得用本地预览或泛型成功响应冒充谱文展示闭环 |
| 帮助文章列表 | `GET /genealogy/app/help-articles`Apifox 显示鉴权、`clientId`(带默认值)与通用 `ListResult``data` 为未声明字段的 object 数组;另有详情 endpoint,但尚未作为当前页面 owner 读取 | M06 需要稳定的分类、标题与纯文本正文;当前声明仍只有 `property1/property2`,不能把硬编码 FAQ 冒充远端帮助内容。继续保持本地说明与 M07 反馈入口,远端帮助读取为 `DECLARED_UNVERIFIED` |
| 消息通知列表与已读 | 列表:`GET /genealogy/app/notifications`,鉴权与 APP `clientId` 必填,通用 `ListResult` object 数组;单条已读:`POST /genealogy/app/notifications/{notificationId}/read``notificationId:int64`、鉴权与 APP `clientId` 必填、无 body、`VoidResult/data:null`。目录还声明“全部标记已读” | 未发现通知详情读取 operation,列表没有类型、目标、正文或稳定 ID 的消费 DTO;N01/N02 不能由通用数组猜业务跳转,未读不能由分页长度推断。没有登录态和可控通知种子时不发送任何已读 mutation,读取/已读均为 `DECLARED_UNVERIFIED` |
| 我的家谱列表 | `GET /genealogy/app/genealogies/mine`,鉴权与 APP `clientId` 必填,通用 `ListResult` object 数组 | G01 需要稳定的家谱 ID、名称、角色与上下文能力;Apifox 当前仍只给出 `property1/property2`。不把响应存在误记为工作区/切谱闭环,保持 `DECLARED_UNVERIFIED` |
| 相册与图片 | 相册列表:`GET /genealogy/app/genealogies/{genealogyId}/albums``genealogyId:int64`、鉴权与 APP `clientId` 必填,通用 `ListResult`。创建 body 仅可安全使用 `albumName`;图片写入要求既有 `ossId` | F07 已删除相册 fixture,创建仅发送 `albumName` 并等待成功信封;F08 已关闭无 DTO 的照片展示;F09 已删除 mock 图片和预览,等待文件上传 owner、真实 OSS 回执和访问 URL 投影。创建待真实响应核验,其他展示/上传为 `DECLARED_UNVERIFIED` / `BLOCKED_BY_MEDIA_OWNER` |
| 备忘录 | 列表:`GET /genealogy/app/genealogies/{genealogyId}/memos``genealogyId:int64`、鉴权与 APP `clientId` 必填,响应为通用 `ListResult`。新增:同路径 `POST`body 明确 `memoTitle` 必填;`memoContent``remindTime``completed``mediaOssIds``sortOrder``status` 可选,其中 `mediaOssIds` 为逗号分隔 OSS ID。目录另有详情、修改、删除动作 | R10 已删除列表 fixture,创建只发送 `memoTitle/memoContent/remindTime`;完成状态、媒体、排序、状态无来源不发送,详情/修改/删除因无 DTO/ID 来源关闭。创建待真实响应核验,整体仍为 `DECLARED_UNVERIFIED` |
| 成长记录 | 列表:`GET /genealogy/app/genealogies/{genealogyId}/growth-records``genealogyId:int64`、鉴权与 APP `clientId` 必填,响应为通用 `ListResult`。新增:同路径 `POST`body 明确 `recordTitle` 必填;`lineagePersonId:int64``recordType``recordContent``recordDate``remindTime``mediaOssIds``sortOrder``status` 可选,其中 `mediaOssIds` 为逗号分隔 OSS ID。目录另有详情、修改、删除动作 | R08 已删除列表 fixture,创建只发送 `recordTitle/recordContent/recordDate`;人物绑定、类型、提醒、媒体、排序、状态无来源不发送,详情/修改/删除因无 DTO/ID 来源关闭。创建待真实响应核验,保持 `DECLARED_UNVERIFIED` |
| 功德记录 | 列表:`GET /genealogy/app/genealogies/{genealogyId}/merit-records``genealogyId:int64`、鉴权与 APP `clientId` 必填,响应为通用 `ListResult`。新增:同路径 `POST`body 的 `donorName``meritTitle` 必填;`meritType``meritContent``amount:double``meritTime``sortOrder``status` 可选。目录仅见删除,未见详情或修改 | R 功德列表及新增响应尚无稳定页面消费 DTO;不可把当前路径与参考项目的人情/贺礼混为一谈。未在无登录态下发送新增或删除 mutation,保持 `DECLARED_UNVERIFIED` |
| 祭祀与祭祀献礼 | 祭祀列表:`GET /genealogy/app/genealogies/{genealogyId}/ceremonies`,通用 `ListResult`;创建:同路径 `POST``ceremonyType``ceremonyTitle` 必填,`ceremonyDesc``ceremonyTime``location``coverOssId:int64``sortOrder``status` 可选。献礼列表:`GET /genealogy/app/genealogies/{genealogyId}/ceremonies/{ceremonyId}/gifts`,两个路径 ID 均为 `int64`;新增:同路径 `POST``giftAmount:double` 必填,`giverName``giftMessage` 可选。上述均要求鉴权与 APP `clientId`,目录还声明祭祀详情/修改、删除活动和删除单条祭品 | 祭祀和献礼均有专属父对象与语义;它们不是人情簿,也不是可发放/绑定的贺礼邀请。列表/新增响应仍无稳定页面 DTO,且未在无登录态下发送写入或删除请求,整体保持 `DECLARED_UNVERIFIED` |
| 亲友记录 | 列表:`GET /genealogy/app/genealogies/{genealogyId}/relative-records``genealogyId:int64`、鉴权与 APP `clientId` 必填,响应为通用 `ListResult`。新增:同路径 `POST``relativeName` 必填;`relationName``eventName``eventTime``giftAmount:double``recordContent``mediaOssIds``sortOrder``status` 可选。目录另有详情、修改、删除动作 | R03/R04 已删除 fixture 列表和详情,创建只提交亲友姓名、关系、事项、时间、金额、备注;媒体/排序/状态无来源不发送。详情/修改/删除无 DTO/ID 来源关闭;创建待真实响应核验,保持 `DECLARED_UNVERIFIED` |
| 人生事件与重要证件 | 已在 Apifox APP/PC 目录分别以“人生”“证件”检索,均未返回对应 operation;相邻的亲友、成长、备忘、功德、祭祀目录不能替代 | R09 没有当前业务 owner,维持硬关闭和可返回状态;`MISSING_OPERATION`,不以本地表单、文件上传或参考项目页面伪造保存 |
| 视频 | Apifox 按“视频”检索只发现 APP 删除视频动作;未发现当前 F10 所需的视频列表、可播放 URL、发布/编辑、评论、点赞或分享闭环 | F10 继续保持非 LIVE;不能把参考短视频动作或单一删除 endpoint 扩展成视频产品能力,`MISSING_OPERATION` |
| 意见反馈 | `POST /genealogy/app/feedback`,鉴权与 APP `clientId` 必填;body 的 `feedbackContent` 必填,`feedbackType``contactInfo` 可选,响应 `ObjectResult` | 与 M07 的严格 normalizer、POST owner 和提交状态机一致;静态/API 冒烟已通过,未用测试账号提交真实反馈,故远端结果仍为 `DECLARED_UNVERIFIED` |
| 当前用户资料 | `GET /genealogy/app/auth/profile`,鉴权与 APP `clientId` 必填,响应 `ObjectResult`,当前仅给出 `property1/property2`;目录另有 `PUT /genealogy/app/auth/profile` | M01—M03/M05 需要稳定的账号、资料与手机号投影;不能从通用对象猜字段或让资料更新覆盖未知字段。读取、编辑与换绑前置均保持 `DECLARED_UNVERIFIED`,M05 另受人工 TAC/短信窗口约束 |
| 微信登录 | 已以“微信”检索 Apifox APP/PC 目录,未返回登录授权、code 交换、回调、会话创建或结果查询 operation | A01 的微信入口不能借参考项目旧接口接通;保持 `MISSING_OPERATION` 关闭候选,是否长期纳入由用户后续确认 |
| 家谱邀请码 | 已以“邀请码”检索 Apifox APP/PC 目录,未返回邀请码签发、校验、直入加入、撤销或结果查询 operation | G/M 的邀请码能力无当前 owner;普通加入申请、成员维护和人物邀请绑定都不能替代,保持 `MISSING_OPERATION` |
| 行政区划搜索 | Apifox APP 目录声明 `GET /genealogy/region/search``keyword` 必填,`level` 可选(1 省、2 市、3 区县、4 街道、5 村社区),`limit` 可选;`clientId` header 可选,响应为通用 `ListResult`。2026-07-24 00:51 的只读部署响应为 HTTP/业务码 `200`,服务器时间 `Thu, 23 Jul 2026 16:51:08 GMT`,单条结果含字符串 `regionCode``label``parentCode``ancestors``regionName``regionLevel``regionType``leaf`;同目录另有下级查询、路径查询与详情 | 此读取 owner 为 `LIVE_VERIFIED`G03 可消费返回的字符串 `regionCode`,但不能把 `leaf` 推断为可提交能力。非 APP 前缀为唯一业务 owner;当前部署旧 `/genealogy/app/region/search` 虽同响应,仍只记路径并存证据,不作兼容 owner。G03 两次写入及结果未知恢复仍分别按其独立合同判定,不能由该读取验证代替 |
| 邀请绑定 | 已展开“家谱(12)”“家谱成员(6)”全部接口,并以“邀请”“绑定”检索 APP/PC 目录;仅见普通申请/审核和成员维护,无邀请票据、身份绑定、绑定 mutation 或结果查询接口 | `MISSING_OPERATION`;普通申请不能替代邀请绑定,保持诚实关闭 |
### 5.2 通用判定和实现规则
1. Apifox 用于优先发现和判断接口业务用途;线上 `/v3/api-docs` 和真实请求用于确认当前部署路径、字段及故障表现;受保护 OpenAPI 只作离线补充。参考项目 `api/index.js` 中的旧路径、参数和响应处理不属于当前接口证据。
2. 每个动作先进入统一账本。`LIVE_VERIFIED` 只允许用于本轮开始后、绑定后端可识别版本或时间戳的真实证据;受保护 OpenAPI 有声明但未取本轮真实响应时为 `DECLARED_UNVERIFIED`;文档、部署、权限、静态门禁或页面期望冲突时为 `CONTRACT_CONFLICT`;没有可靠 operation 时为 `MISSING_OPERATION`。operation 是否存在另记为证据字段,不是第五种状态;同一最小动作同时“有声明但已知冲突”时必须取更严重的 `CONTRACT_CONFLICT`,不得写两个 `contractState`。不得把接口名相似、历史 HTTP 200 或参考项目成功写成已接通。
3. 先列出当前批次的“当前页面 → 用户动作 → 对象身份 → 权限来源 → 方法 → 完整路径 → 输入 → 页面消费字段 → 成功/失败/结果未知表现 → 刷新确认”,再写该批次失败测试,随后实现。
4. 只有 `LIVE_VERIFIED` 动作允许记为 LIVE。`DECLARED_UNVERIFIED` 先做只读或可控测试验证;`CONTRACT_CONFLICT` 记录冲突并失败关闭;`MISSING_OPERATION` 保留完整视觉和返回路径,动作显示服务未开放,不伪造保存、发送、上传、支付、邀请、绑定、排行或审核成功。
5. 4xx、5xx、空体、缺字段或身份错配必须展示真实失败,不回退 mock。参考项目成功、受保护 OpenAPI 存在 operation 或当前页面已有 `appApi` 方法,都不能单独解除这一规则。
6. 单个接口排查超过 15 分钟仍无本机解决路径时,立即记录复现证据并继续其他页面,避免阻塞整夜。
7. 每个合同只有一个 owner;新合同落地时同步移除同类 fixture、旧入口、兼容读取和过时测试期望。若一个参考动作无法明确归入现有 owner,先判定是否需要新 owner,不能塞进名称相近的旧接口。
8. 真实写操作仅限测试账号和本轮新建、带“联调测试”标识且已登记 ID 的对象。GET 可按退避规则重试;写操作只有接口明确提供幂等键且结果可判定时才允许重试,超时、5xx、断线或进程终止造成结果未知时立即停止该动作并查只读结果。
9. 新增全域 P0 ID 门禁:`genealogyId``personId``memberId``appUserId`、父母/配偶/子女引用、内容 ID、文件 ID 与 `avatarOssId``int64` wire 必须验证服务端以十进制字符串返回且请求端接受十进制字符串。任何超出 JavaScript 安全整数的 JSON number 一经解析已不可恢复,禁止再转字符串继续使用;相关读写统一记 `CONTRACT_CONFLICT` 并关闭,直到同一 ID owner 在 schema、运行时、validator 和请求端同时收紧。
10. 三个现有静态门禁本身与已确认规划/快照冲突,收到“开始执行”后的第一项测试工作是先按唯一 owner 重订失败期望,再实施业务:`auth-tac-openapi-contract.ps1` 不得再要求密码登录上传 `validToken`;两个 G03 门禁不得再要求未声明的原子 bootstrap、错误的 `/genealogy/app/region/search` 或不存在的结果查询;`help-center-openapi-contract.ps1` 与 M06 统一为“完整列表是唯一远端 owner、文章阅读由列表项投影承接”,不新增独立远端详情 owner。门禁目标路径不能写成当前 operation,也不能因旧测试期望而覆盖真实后端合同。
每条路由的执行记录至少包含:`route`、真实入口、必填参数、测试角色/种子、四个正交状态字段、参考来源、候选动作、六类甄别结论、接口 owner、权限 owner、主动作、返回结果、页面状态证据、LIVE 请求响应证据、候选包 ID、MuMu 结果和 blocker。
每次汇总必须同时列出这些分母,任何一个都不能被“52/52”代替:
- `baselineRoutes=52`
- `approvedNewRoutes`(规划确认采纳、但不一定已注册);
- `registeredRoutes`(候选包实际注册);
- `traversedRoutes`(绑定该候选包完成结构遍历);
- `referenceEvidenceRecords=341`,以及六类证据结论数量:直接采用、改造后采用、仅参考交互、后端缺失暂关、冲突舍弃、待用户确认;
- `productActionsTotal=166``requiredActions=111``candidateActions=55``approvedCandidateActions=0`;产品完成分母固定为附件 PA-001—PA-166 中的 111 个必需动作;
- `liveCompletedActions`、各动作的 `productCompletion`,以及按必需/可选、A/G/T/F/R/N/M/共享 owner 的分组结果;
- `inventoryTests``scheduledTests``executedTests``notRunTests``timedOutTests` 与各 `checkResult` 数量。
## 六、执行批次与相对时间
### 第 0—0.5 小时:硬预检与全量映射
以下计时只在用户明确说“开始执行”后启动。在此之前,参考映射与产品取舍必须已在权威附件中完成并由用户确认;执行第 0 批只能重算清单/哈希、冻结确认结果,不得再临场作重大产品决策。
- 核对当前 HEAD、工作区、52 条路由、构建入口和已有测试。
- 按页面盘点真实接口、现有 fixture、入口、写操作和视觉风险。
- 只维护一张执行表和必要阻塞证据,不扩写与今晚交付无关的治理长文。
- 夜跑前确认源码 HEAD 与 dirty diff、remote 配置、后端版本、网络、磁盘、MuMu 在线/解锁、测试包安装、测试账号会话、测试家谱 ID 和账号角色。缺少第二角色账号时,跨账号申请/审核不得伪造通过,单列测试数据阻塞。
- 建立唯一夜跑入口:按固定清单发现测试,单项有超时,失败后继续下一项,记录检查点并支持从最后完成项续跑;结束时回收子进程。连续认证失败、429、同域 5xx、设备离线、磁盘不足或写结果未知触发对应域熔断。
### 6.1 时间模型与降级规则
### 第 0.5—1.5 小时:A 认证与 TAC
- `T_start`:收到用户“开始执行”的北京时间。
- `T_due`:用户已更新为 2026-07-24 08:00(北京时间)。
- 正常候选周期为 `T_start + 9h`,但不得越过 `T_due`;要获得完整九小时窗口,最迟须在 2026-07-24 01:00 开始。
- `T_test_cutoff = min(T_start + 6h, T_due - 3h)`。截止前必须完成本轮测试调度和首个候选尝试;截止后反向保留 0.5 小时候选修复/冻结、1.75 小时最终实机和 0.75 小时缓冲,不能用缩短窗口挤占这三段。
- 首个候选固定预留 `firstCandidateWorstCase=30m`:2 分钟重算输入清单并准备全新输出根、15 分钟构建、3 分钟生成输出清单/指纹、7 分钟部署或安装、3 分钟设备身份确认。测试只有在 `now + testWorstCase + firstCandidateWorstCase <= T_test_cutoff` 时才允许启动;全部已调度测试收口后,首个候选只有在 `now + firstCandidateWorstCase <= T_test_cutoff` 时才允许启动。任何在途进程都不得跨越 `T_test_cutoff`:意外超时时在截止点终止本轮进程树,具体测试记 `timedOutTests/INFRA_ERROR`,候选尝试记基础设施失败,然后一次性冻结计数。不得以“已经开始”为由占用后续三小时。
- `T_start >= T_due` 时不启动本轮执行,先请用户重新确认截止时间;`T_test_cutoff <= T_start` 时不启动代码、测试或构建批次,只交付预检与“无冻结候选”的精确原因。
- 有效窗口不足 9 小时时不压缩人工认证和上述尾部三段:6—9 小时优先削减 F/R/N/M 的新增 LIVE 扩展;3—6 小时只在 `T_test_cutoff` 前做 P0 合同/安全修复、七个现有 `appApi` consumer 和首个候选尝试;不足 3 小时时不启动无法留出验证时间的代码批次,只做可完成的预检、现状与报告。所有被削减项记 `NOT_RUN`,不得改写成通过。
- 九小时只是一次可靠候选周期,不是整个产品的完成承诺;明早报告必须把第 2 节五个层级分别结论化。
- 页面:A01、A04、A05及认证状态、协议和返回相关入口。
- 链路:密码登录、短信登录、注册、忘记密码、TAC challenge/verify、短信发送、会话保存和 G01 跳转。
- 重点视觉:验证码倒计时、禁用按钮、输入行、协议、Toast、TAC 弹层、短屏滚动和 360/412 宽度。
- 用户宣布准备休息时,暂停其他工作并进入 45—60 分钟人工窗口。密码登录 TAC、A01 短信登录、A04 注册和 A05 找回是四条独立流程;若同时具备已注册收码号码和未注册号码则逐条验证,否则把缺少号码的流程标记测试数据阻塞。人工完成后验证会话在页面重进和应用重启后仍有效。
- 记录会话有效性但不记录令牌;无人值守阶段若会话失效,停止需要身份的实机动作并标记 `EXPECTED_BLOCKED`,不尝试绕过认证。
- 无人值守阶段不循环发送短信,不自动破解或绕过 TAC。自动接口测试取得的临时令牌只验证登录后接口,不能冒充认证页面验收。
### 6.2 正常九小时批次
### 第 1.5—3.5 小时:remote 主链与 B 高风险页面
####0—0.5 小时:冻结基线、合同、角色与夜跑清单
- G01、G03、G05、G06、G08、G09、G10、G11、G12
- T01、T03—T08及所有活动世系路由
- 优先完成 A01 → G01 → G05 → T01 和 M07 已接线主链的 P0/P1,再按测试角色和后端合同推进我的家谱、原子建谱、公开搜索、普通申请、我的申请/撤回、待审/审核、设置、字辈、成员详情、编辑和关系操作
- T01 不按当前文字节点和简单底部操作条直接验收;必须先按第十一节完成头像人物卡、选中态、人物操作面板、关系快捷动作和长世代布局的测试门禁,再进入 T03—T06 的真实读写接线
- 后端未提供的邀请码、详情或冲突控制不混入其他普通申请链路;缺失功能显示明确不可用状态
- 以用户手动拉取的当前文件为源码基线,不执行 Git;重算 52 条路由、60 个设计文件、78 条参考活动路由、1 条注释路由声明、7 个未注册参考页面文件、531 个参考媒体资产和权威附件覆盖率
- 在任何“接口缺失”、接口接线、写操作或页面关闭结论之前,先按第 5.1 节在 Apifox 桌面端完成当前批次动作的目录、请求、响应、鉴权和相邻链路检索;把未导出的接口和与导出文档不一致的字段列入脱敏合同账本,再决定实测、实现或阻塞。不得跳过此步骤直接按导出 OpenAPI 结论推进
- 冻结线上 `/v3/api-docs` 的取证时间与可识别版本;受保护 OpenAPI 保持只读。接口或参考源相对已确认附件发生变化时,只记录差异并暂停受影响动作,不静默改变产品范围
- 先在仓库外的本机临时验收目录建立唯一 `role-seed-matrix.json` owner:只记录脱敏角色别名、角色类型、对象类型、十进制字符串对象 ID、来源/创建时间、允许的动作和清理责任;不得写账号、手机号、Token、OTP、TAC proof 或其他凭据。普通成员、家谱所有者或管理员、第二账号,以及测试家谱、人物、申请、内容和媒体均由该矩阵登记。缺少角色、跨账号对象或收码号码时,对应动作标记测试数据阻塞
- 机械生成当前 204 个可执行脚本的 manifest 基表和显式 override,并冻结 `baselineInventoryTests=204=150 PS1+54 JS``tests/night-run.ps1``tests/night-run.manifest.json` 是编排入口/清单,不计测试项;实施中为本轮变更新建的每个可执行测试都记入 `newExecutableTests` 并追加到 manifest,最终 `inventoryTests=baselineInventoryTests+newExecutableTests`。每项先按第 6.3 节确定风险层级、依赖、是否远端写入及预期结果;第 0 批只校验已确认的 `PA-xxx` 产品动作和来源反链未漂移,禁止重新归并、拆分或改变产品动作分母。任何参考资产复制前另建逐文件 `RAxxx` 记录;资产选择可以在已批准 PA 内发生,但不能新增产品动作或跳过来源门禁
- 在仓库外的本机临时验收目录准备候选输入清单模板。每次候选构建/部署前,对项目自有源码、配置、资产和 lockfile 重算规范化逐文件 SHA-256;明确排除 `.git``unpackage`、依赖/工具缓存、日志、截图、报告和清单自身,并另记 HBuilderX、插件及解析后依赖版本。输入清单的规范化与第 6—6.5 小时输出清单使用同一算法,且不含凭据。
- 检查 remote 配置、网络、磁盘、HBuilderX、MuMu 状态和测试账号,但此时不把任何历史运行结果算入本轮。
### 第 3.5—5.5 小时:52 路由 MuMu 遍历
####0.5—1.75 小时:A 认证人工窗口与会话
- 按 A/G/T/F/R/N/M 顺序记录每页真实入口、必填参数、默认状态、Android 返回和根切换
- 这一阶段不以匆忙接接口为目标;先发现白屏、死路、缺参数、上下文串谱和明显视觉破损,并为每页标记 LIVE、LOCAL_PREVIEW 或 CLOSED
- 本轮先完成密码登录页、账号密码提交、TAC challenge 展示、challenge/verify 合同、会话保存与 G01 跳转的可自动化验证;密码登录的真实拖动、短信登录、注册和找回密码的短信/TAC 全部保留为早晨人工回归,四条流程不互相替代
- 不循环发码、不破解或绕过 TAC。测试账号凭据只保留在当前安全会话,绝不写入仓库、文档、截图或日志
- 人工完成后验证页面重进和应用重启的会话;不记录 token。会话失效时停止身份写操作并熔断,不以临时接口 token 冒充认证页面验收。
### 第 5.5—7 小时:C/D/E 接口扩展与代表性状态视觉
####1.75—4 小时:A/G/T P0 主链与 T01/T03
- F01—F10:动态、评论、文章、相册、照片、媒体、视频和上传相关页面
- R01—R11:人物、礼簿、礼仪、成长、人生事件、备忘和功德
- 已有读取和写入接口接真实服务;上传链不完整时不得显示假上传成功;人生事件无接口时显示服务未开放
- N01、N02和 M01—M10同步按风险扩展通知、资料、安全、帮助、反馈、推广、VIP、关于和退出
- 换绑、支付、注销等敏感或缺少完整后端闭环的动作在无人值守阶段只验证页面、校验和真实不可用状态。
- 视觉按共享形态覆盖认证表单、列表、详情、编辑器、弹窗、上传和关闭页;共享控件缺陷一次修全,高风险页补长文、空、错和无权限。320/412 用响应式运行检查补充,MuMu 当前设备证据不冒充其他宽度原生证据。
- 按 A01 → G01 → G05 → T01、G03 两阶段建谱、G06/G08—G12、T03—T08推进;每个动作先过合同、ID、权限和结果未知门禁
- T01 优先完成头像人物卡、选择态和唯一人物面板;十入口逐项落到 T03/T04/T05/T06/邀请流程。合同不足的入口只允许诚实关闭,其关闭检查可 PASS,但 T01 产品完成度仍为 BLOCKED
- T03 始终从未完成半成品起算;已有 normalizer、fixture 或旧门禁不得计完成
- 本批时间用尽即停止扩张并留下可验证断点,不把 T01 大型布局和十条真实写链压缩成单一“完成”
### 第 7—8.25 小时:全量测试、重编译和候选包
####4—4.5 小时:F/R/N/M 风险优先扩展
- 运行 PowerShell、Node、语法、纯 Node、活动 Vue 脚本和构建检查
- 构建测试版本并在 MuMu 中从认证根页遍历四个主入口和全部活动页面
- 检查重新进入、切换家谱、凭证失效、弱网、重复点击、请求取消、空数据、4xx/5xx和返回行为
- 安全覆盖 HTTP 200 业务错误、400/401/403/404/409/429/500、超时、畸形 JSON、重复或失真 ID、撤权、快速切页、离页取消、前后台切换和迟到响应;不适合对真实服务触发的故障使用确定性本地传输注入,不能攻击线上服务。
- 输出已通过、后端阻塞、缺测试数据和需人工复验四类结果,不用含糊的“基本完成”。
- 优先处理已经有当前后端声明、且能在剩余时间内完成闭环的读取和小型写入;无视频完整链、人生事件、重要证件、通知详情、支付/提现等保持诚实关闭
- 换绑、注销、支付、所有者转移、退出家谱、删除真实内容等敏感动作不在无人值守阶段执行
- 每域更新动作分母与四字段,不要求为了“全绿”把全部参考候选临时实现
### 第 8.25—9 小时:候选包回归与证据归档
####4.5—6 小时:分层测试、首个候选与全量硬截止
- 对候选测试版本快速重走认证根页、四个主入口、家谱上下文和本轮失败项
- 汇总五种状态、首次结果、熔断、人工项和精确继续步骤,保留至少 45 分钟缓冲,不在最后时刻追加未验证接口
- 唯一测试清单 owner 为执行时建立的 `tests/night-run.manifest.json`,唯一入口为 `tests/night-run.ps1`;二者只在用户说“开始执行”后创建。manifest 逐项明确脚本、解释器、排序、120 秒默认超时、依赖、预期退出码、预期标记、领域、是否允许写远端及检查点;当前 12 个 allowlist 项必须各自冻结完整且唯一的 `expectedBlockedMarker`,不得用包含匹配;4 个 JSON 数据文件不是可执行脚本
- 测试分三层:`T0` 为 P0 安全/合同、路由、编译及本轮实际修改 owner 的门禁,是形成候选的必要条件;`T1` 为 A/G/T/F/R/N/M 受影响域的其余 mapper/runtime/合同回归;`T2` 为未受影响域和历史扩展检查。204 个基线项与所有 `newExecutableTests` 全部进入 inventory,但只有 manifest 明确列入当轮的项目计入 `scheduledTests`;先执行 T0,再在硬截止前执行 T1,T2 仅在 T0/T1 完成且仍有时间时调度。未调度或未执行项分别如实进入 `notRunTests`,不得把 inventory 数量写成执行数量
- 一级顺序固定为 T0 → T1 → T2;每层内部再按纯静态/合同 PowerShell → 纯 Node mapper/runtime → 该层编译/专项检查排序,全部已调度测试收口后才形成候选构建。PowerShell 使用 `powershell.exe -NoProfile -ExecutionPolicy Bypass -File <exact-script>`Node 使用 `node <exact-script>`。无网络、无共享写入且无相互依赖的本地脚本最多 4 个并发 worker;远端、写操作、编译和构建一律串行。单项失败继续下一项并回收子进程,域熔断只跳过同一远端风险动作。
- 当前 150 个 PowerShell、54 个 Node 脚本以及实施中新增的每个可执行测试必须逐一进入 manifestrunner 和 manifest 自身不计测试项。本轮“预期阻塞”只允许以下 12 条精确脚本,并同时要求非零退出和各自精确 `... BLOCKED` 标记;其他非零均为 `FAIL``auth-android-accessibility-release-gate.ps1``g11-settings-openapi-contract.ps1``g12-generation-poem-openapi-contract.ps1``genealogy-workspace-openapi-contract.ps1``invite-ticket-openapi-contract.ps1``join-application-openapi-contract.ps1``lineage-openapi-contract.ps1``notification-read-openapi-contract.ps1``notification-read-state-openapi-contract.ps1``phone-change-openapi-contract.ps1``profile-openapi-contract.ps1``profile-update-openapi-contract.ps1`。已转绿的 `auth-tac`、两个 G03、F 家族圈、M04、M06、M10 与 T03 门禁不得再被列作预期阻塞;M04/M10 的真实 mutation 仍只进入人工窗口。清单冻结前须核对这些脚本的实际退出语义,不能仅因文件含 “BLOCKED” 字样放行。
- allowlist 脚本若本轮变为退出码 `0` 且出现精确 PASS 标记,按 `PASS` 接受并从预期阻塞清单移除;只有非零退出且精确 BLOCKED 标记匹配才是 `EXPECTED_BLOCKED`。退出码与标记任一不匹配均为 `FAIL` 或 runner 错误,不能因历史预期而放行。
- 夜跑在每项结束后原子写入本机临时验收目录中的 checkpoint(脚本、开始/结束、退出码、标记、耗时、首次结果),续跑只从最后完整记录之后开始。总入口退出码固定为:`0`=无 FAIL/INFRA 且预期阻塞完全匹配;`2`=测试或合同断言失败;`3`=构建、部署、安装或设备基础设施失败;`4`=manifest 漂移、单项超时、测试执行基础设施或 runner 自身错误;同时出现时按 `4 > 3 > 2 > 0` 聚合。单项超时记该测试 `checkResult=INFRA_ERROR` 并进入 `timedOutTests`;无法归到具体测试的 manifest/runner 故障另记 `runnerInfraErrors`,不塞入测试恒等式。任何 `EXPECTED_BLOCKED` 都必须在摘要中单列,退出 0 不等于产品全绿。
- 全部已调度测试按层级收口后,首个候选严格使用第 6.1 节 30 分钟预算与同一构建入口;任一子段超时即本次候选失败,不把失败段的剩余时间转给另一段,也不在 cutoff 前启动第二次首候选尝试。第 6—6.5 小时只负责复用该候选或进行一次受限修复/最终冻结。
- `T_test_cutoff` 是测试调度与首个候选尝试的硬截止;每项按第 6.1 节 `latestStart` 准入,不能在最坏耗时将越过 cutoff 时启动。截止时终止意外仍在途的项目,排队但未启动的已调度项进入 `notRunScheduledTests`,再一次性冻结 `scheduledTests/executedTests/notRunScheduledTests/notRunTests/timedOutTests` 和最终恒等式。T0 未全部取得允许结果或候选无法追溯时,不进入最终实机验收。
- 构建入口固定为仓库根目录与 `.hbuilderx/launch.json` 中的 `uni-app:app-android`。执行前记录实际 HBuilderX CLI 绝对路径、版本、命令、退出码和输出目录;不得把旧 `unpackage` 产物冒充新包。
#### 第 6—6.5 小时:仅修复候选阻塞并冻结最终候选
- 本阶段只能二选一,不得借用后续 105 分钟实机窗口。若首个候选之后源码输入和生成输出均未变化,前 20 分钟内重算输入/输出 manifest 并逐项确认 hash 完全相同,必须复用该候选且不重复构建;随后最多使用 7 分钟部署/安装、3 分钟设备身份确认。若必须修复,前 5 分钟的组合硬上限同时覆盖一次 P0/P1 外科式修复、受影响的聚焦测试和 `sourceManifestSha256` 重算;随后 15 分钟的组合硬上限同时覆盖最终构建、完整输出 manifest 与 `outputManifestSha256`,再用 7 分钟 DBG 部署或 APK 安装、3 分钟设备身份确认,合计正好 30 分钟。任一组合段内的前置工作耗尽预算,后续工作来不及完成时即判该路径失败,不能把清单/指纹移出预算。
- 任一路径在准入时无法按最坏预算于本阶段结束前收口,或任一硬上限内未完成,立即终止本轮启动的进程树、停止继续安装并记退出码 `3/INFRA_ERROR`,交付“无冻结候选”;不得重试、不得压缩身份核验,也不得挤占 MuMu 窗口。
- 候选分两类且必须二选一取得可追溯、可复验的指纹:APK 使用 `APK-YYYYMMDD-HHMM-<apkSha256前12位>`,记录完整 APK SHA-256HBuilderX 调试部署使用 `DBG-YYYYMMDD-HHMM-<outputManifestSha256前12位>`。两类都绑定 `sourceManifestSha256`、测试 manifest 结果、开始/结束时间、输出目录、应用包名/版本、HBuilderX/插件/依赖版本、部署或安装回执,以及 MuMu 已安装包的包名、版本、签名摘要和安装时间。
- 源码输入与 DBG 输出清单的规范化算法固定为:以各自声明的根目录生成相对路径,路径分隔符统一为 `/`,按相对路径 ordinal 升序,每行写 `sha256<TAB>bytes<TAB>relativePath`,整体使用 UTF-8 无 BOM 与 LF。源码侧沿用第 0 批明确列出的排除集合。DBG 输出根固定为本轮 HBuilderX 新生成的准确输出目录,输出 manifest 存放在根目录之外并递归纳入全部生成文件;输出侧只允许排除存在时的 `Thumbs.db``.DS_Store`,且必须逐项记录,除此之外不得排除。`outputManifestSha256` 是完整规范化输出清单的 SHA-256,`sourceManifestSha256` 同理。
- 找不到可重复构建/部署入口、无法取得上述任一指纹或安装结果与指纹不一致时记 `INFRA_ERROR`,明确报告“无冻结候选”,不得进入 MuMu 最终验收。任何代码或生成输出变化都会使旧设备证据失效,必须取得新候选 ID 并重验。
#### 第 6.5—8.25 小时:冻结候选的最终实机验收
- 本阶段 105 分钟固定拆为:前 10 分钟核对已安装身份、冷启动、证据目录和一次故障恢复余量;随后 65 分钟完成“52 条基线路由+已批准且已注册新增路由”的合计结构遍历,基线路由优先;最后 30 分钟做 P0 实时 smoke。结构遍历开始时计算 `routeBudgetSeconds=floor(3900/registeredRoutes)`(只有 52 条基线路由时为 75 秒),按基线路由优先的冻结顺序给每条路由一次预算;已启动路由超预算记 `FAIL` 并继续,65 分钟到点仍未启动的路由记 `NOT_RUN`。任何新增路由、低优先路由或故障恢复都不得侵占 P0 时段。
- 结构遍历报告基线、已批准新增、实际注册、实际遍历和 `NOT_RUN` 五个分母/结果。P0 smoke 在同一候选上覆盖认证根页 A01、四个主入口 G01/F01/N01/M01、A/G/T P0 主链以及本轮改变的 F/R/N/M 动作;检查上下文、返回、空/错/无权限、重复点击、取消、前后台与迟到响应。更深的状态矩阵在各实现批次持续取证,最终候选至少重复 P0 smoke,旧候选证据不得沿用。
- 本地故障注入只能证明客户端分支,不能证明远端实时接口;真实服务不做攻击性 4xx/5xx/429 压测。所有设备证据绑定候选 ID、分辨率、density、角色和种子。
#### 第 8.25—9 小时:45 分钟保留缓冲与交付收口
- 本时段是保留缓冲,不安排新功能。前面持续写入的报告在此校验四字段、首次结果、分母、熔断、人工项和继续步骤。
- 若前序超时,缓冲只用于恢复、必要重建和最短复验;未完成项分别记清 `checkResult=NOT_RUN``productCompletion=PARTIAL``productCompletion=BLOCKED`,不得混写成一个状态。若缓冲未使用,可增加探索检查,但不得改变冻结候选或既定结论。
### 6.3 测试风险层级与计数规则
测试层级在第 0 批依据文件名、manifest 元数据和本轮人工变更账本机械生成,不依赖 Git diff,也不能由执行者为了赶时间临时降级:
| 层级 | 确定性纳入规则 | 候选门禁 |
| --- | --- | --- |
| `T0` | `compile-audit.ps1`、路由/导航/会话/运行配置/共享 ID 与权限门禁、第 6.2 节预期阻塞 allowlist,以及本轮每个被修改 owner 对应的合同、runtime 和视觉专项 | 全部必须取得 `PASS` 或精确允许的 `EXPECTED_BLOCKED`;否则无冻结候选 |
| `T1` | 与本轮被修改 owner 同域的其余 A/G/T/F/R/N/M mapper/runtime、文档流、状态和响应式回归 | 在 `T_test_cutoff` 前尽量全部执行;未执行项记 `NOT_RUN` 并降低相应域结论 |
| `T2` | 未受影响域、历史扩展、仓库卫生与探索性检查 | 仅在 T0/T1 完成且时间有余时调度;不以 T2 未跑伪装全量测试通过 |
计数恒等式固定为:
- `baselineInventoryTests=204=150 PS1+54 JS`,4 个 JSON 仅作为数据文件,runner/manifest 仅作编排,均不计可执行测试;
- `inventoryTests=baselineInventoryTests+newExecutableTests`,本轮未新增测试脚本时 `newExecutableTests=0`
- `scheduledTests=executedTests+notRunScheduledTests`
- `executedTests=passTests+failTests+expectedBlockedTests+infraErrorTests`,其中 `timedOutTests``infraErrorTests` 的诊断子集,不重复相加;
- `notRunTests=(inventoryTests-scheduledTests)+notRunScheduledTests`
`runnerInfraErrors`、构建/部署/安装/设备基础设施错误另列,不进入上述测试项恒等式;只要存在任一此类错误,候选和报告仍按对应退出码失败。报告必须同时给出各层级数量、首次结果和重试结果。默认 120 秒只是单项上限,不是预计耗时;四 worker 也不能用理论并发量承诺完整 inventory 必然在 1.5 小时内跑完。冻结候选只宣称 T0 门禁已满足和实际执行范围,不能把 inventory 数量写成“全通过”。
## 七、三人交叉复核
主代理是唯一写入者。两位评审者只读检查每一批的接口、业务、交互、异常和视觉;发现分歧时给出证据并互相反驳,统一结论后由主代理修改。评审在批次边界进行,不并发写文件,也不把开放式讨论拖入下一批
三人固定为主代理和两位只读评审者,主代理是唯一写入者。三人都必须完整检查本规划,不能把范围割裂后只看自己的一部分;为增强对抗性,主代理重点复核产品全量覆盖与参考甄别,评审一重点攻击后端合同、权限和数据闭环,评审二重点攻击执行顺序、时间预算、测试证据和回归风险,然后互相核对对方领域
规划评审必须在交给用户确认前完成,至少检查:
1. 是否覆盖 A/G/T/F/R/N/M、共享基础和参考候选,而非只覆盖认证或 T01。
2. 每个采纳动作是否有唯一页面 owner、唯一接口 owner、权限来源、状态矩阵和完成定义。
3. T03 是否始终标为未完成,T01 十个入口是否逐项落到真实流程或诚实关闭态。
4. 两个参考源是否逐页面、逐动作、逐接口留有结论,是否误带旧地址、旧字段、旧组件、凭据、日志或视觉。
5. 52 条基线路由与新增采纳路由是否分母清楚,时间预算是否把阻塞、人工项和最小可靠交付说清。
6. 参考资产是否逐文件绑定已批准 PA 和具体页面状态,是否误把用户的候选复用许可解释为整包批准,是否遗漏第三方、隐私、旧品牌、格式、无障碍和性能风险。
任一评审提出有证据的疑点时,主代理必须修改规划或书面保留为待用户确认项;不能以多数票掩盖未解决的合同冲突。实施开始后,三人仍在批次边界复核接口、业务、交互、异常和视觉,不并发写文件,也不把开放式讨论拖入下一批。
本次规划评审已于 2026-07-23 收口:
| 评审角色 | 最终结论 | 已复核证据 |
| --- | --- | --- |
| 产品主审(主代理) | `PASS` | A/G/T/F/R/N/M 七域、共享 owner、52 条 CUR、T01 十入口、T03 半成品、两个参考源 341 条基记录、166 个 PA、状态分离和 531 项资产逐文件门禁完整;当前项目职责没有被参考项目覆盖 |
| 合同评审 | `PASS` | 122 个合同键唯一且 PA 无未定义引用;125 个唯一当前 operation 均可回到 153-operation 只读快照;M08 家谱邀请、G03、TAC、N02、帮助、家族视频与宣传视频 owner 已拆清 |
| 执行评审 | `PASS` | 204 项测试基线与动态 inventory、`latestStart`、首候选 30 分钟预留、最终候选双路径、输出指纹、65 分钟路由预算、退出码和双门禁均可机械执行 |
最终无剩余 P0/P1;评审提出的 P2 也已处理,包括复合来源证据的稳定 `@browse/@comment/@reaction/@share` 后缀。机械结果为:PA-001—PA-166 连续唯一(111 必需、55 候选),初始 42 `PARTIAL`69 `BLOCKED`55 `NOT_APPLICABLE`;52 条当前路由全部反链;341 条来源基记录均有 PA 或全局去向;两份权威文档无旧计数和表格列错位。
## 八、无人值守边界
- 可以继续读取、创建带测试标识的数据、运行测试、构建和操作 MuMu
- 不发送外部消息,不循环请求短信,不破解验证码,不使用生产数据做破坏性试验
- 本节只有用户明确说“开始执行”后生效;此前只允许规划范围内的只读核对和规划文档写入
- 执行授权生效后,可以继续读取、创建带测试标识的数据、运行测试、构建和操作 MuMu
- 不循环请求短信,不破解验证码,不使用生产数据做破坏性试验。除用户指定的最终停止通知外不发送外部消息;本轮真正停止时仅向用户已打开的微信“文件传输助手”发送“已经停下了”。
- 不支付、不换绑、不注销、不删除已有家谱或用户内容。
- 后端、测试账号、发布签名或 MuMu 人工验证码成为硬阻塞时,记录:失败门禁、请求与响应、复现步骤、解除条件、恢复后的第一步。
- 用户有新消息时优先处理;用户说“准备睡了”即进入认证人工窗口
- 用户有新消息时优先处理。只有已经收到“开始执行”后,“准备睡了”才可作为第 0.5—1.75 小时既定认证人工窗口的提前提醒或提前触发;它不改变 `T_start`、不另起第二套时间线,也不能单独授权代码、测试、构建或 MuMu
- Authorization、手机号、OTP、TAC proof、邀请码和个人资料不得进入报告、截图文件名或仓库日志。原始运行日志和截图放在本机临时验收目录,限制容量并在报告中只引用脱敏摘要。
- TalkBack、系统大字号和无法可靠自动化的原生辅助功能只做可重复的早晨人工项,自动截图不得冒充无障碍通过。
## 九、明早交付物
1. MuMu 中可启动和遍历的测试版本;能够产出测试 APK 时同时给出 APK 路径,缺发布签名不阻塞开发测试包
2. 52 条路由的完成状态与入口清单
3. 页面—接口映射真实请求结果。
4. 自动测试、构建检查和 MuMu 实机验收结果。
5. 后端阻塞、测试数据阻塞和需要人工复验的精确清单
6. 明早从登录开始的最短人工回归步骤
7. 运行元数据:源码 HEAD/dirty 状态、开始结束时间、后端地址与可识别版本、MuMu 分辨率/density、账号角色、各项耗时和熔断记录;不包含任何凭据
1. MuMu 中可启动和遍历、且绑定 `APK-*``DBG-*` 可追溯/可复验指纹、`sourceManifestSha256` 和设备侧安装身份的冻结测试候选;若两类指纹均无法取得,明确交付“无冻结候选”与 `INFRA_ERROR` 证据,不能用旧安装包代替
2. 52 条基线路由及所有已采纳新增路由的入口和结构结果,并分别报告 `baselineRoutes``approvedNewRoutes``registeredRoutes``traversedRoutes``NOT_RUN` 路由。
3. 当前页面—动作—接口映射真实请求结果,以及两个参考源逐页面/逐动作/逐接口的采纳结论
4. `referenceEvidenceRecords` 与六类证据数量、权威 PA 动作分母、`liveCompletedActions` 和逐动作四字段结果。
5. `inventoryTests``scheduledTests``executedTests``notRunTests``timedOutTests`,以及构建/部署和 MuMu 实机验收结果
6. 本轮实际评估/采用的 `RAxxx` 参考资产清单、来源确认、改造方式、重复 canonical owner、包体影响及逐状态验收证据;未使用的 531 项不冒充已审
7. 后端阻塞、测试数据阻塞和需要人工复验的精确清单
8. 明早从登录开始的最短人工回归步骤。
9. 运行元数据:用户确认的源码基线说明、开始结束时间、后端地址与可识别版本、MuMu 分辨率/density、账号角色、各项耗时和熔断记录;不调用 Git 取得 HEAD/dirty 信息,不包含任何凭据。
## 十、执行记录
## 十、规划阶段只读核对记录
| 批次 | 状态 | 已通过 | 阻塞或人工项 |
| --- | --- | --- | --- |
| 断点与映射 | PASS | HEAD、dirty 工作区、OpenAPI 哈希、52 条路由、remote 配置、构建产物、MuMu 设备和当前接口 owner 已核对 | 无 APK;开发测试包可由 HBuilderX 继续运行 |
| A 认证与 TAC | 进行中 | 后端密码登录与 challenge 均只读确认 200;认证专项自动门禁已通过;A01 短信默认态 MuMu 视觉已通过 | 四条 TAC/短信流程和倒计时态留待睡前人工窗口 |
| B 家谱与世系 | 规划重整中 | G01/G05/T01 已有部分真实接口改动 | 实施冻结;先确认第十一节,再测试先行重做 T01 并复核 T03—T06 |
| C/D 内容与族务 | 待执行 | 无 | 待接口盘点 |
| E 消息与我的 | 待执行 | M07 已有真实反馈接口基础 | 待接口盘点 |
| 全量验收 | 待执行 | 无 | 构建、MuMu 和整体验收待执行 |
| 核对对象 | 当前事实 | 规划结论 |
| --- | --- | --- |
| 当前源码 | 用户确认已手动拉取 2026-07-23 最新 `main`;本轮未执行任何 Git 命令 | 不再沿用旧 HEAD/dirty 断点;以当前文件事实为准 |
| 当前活动范围 | `pages.json` 有 52 条活动路由,`pages` 下有 53 个 Vue 文件 | 52 是最低基线;最终分母还要加入明确采纳的新增路由 |
| 当前数据接线 | `runtimeConfig.mode``remote`;仅 A01、A04、A05、G01G05T01、M07 七页直接消费 `appApi`40 页直接导入 `data/mock`28 页含 `setTimeout` | 七页也须逐动作验真;其余页面逐项归类 LIVE/LOCAL_PREVIEW/CLOSEDtimer 不能自动等同假成功,也不能逃过甄别 |
| 当前 T03 | 页面已调用 `appApi.getPerson` 的 Apifox 人物详情 owner,具备严格 normalizer、取消控制与无 fixture 回退;测试目录已有静态与 mapper 门禁 | 仍未取得测试账号下的本轮真实人物响应、权限投影、跨页刷新与重进证据;明确为未完成半成品,不得记为 `LIVE_VERIFIED`、页面完成或产品完成 |
| 受保护 OpenAPI | 本地 JSON/YAML 快照为 112 paths、153 operations,只读 | operation 存在仅记 `DECLARED_UNVERIFIED`,仍需对照线上文档和真实响应 |
| “app设计”源 | `C:\Users\Rain\Desktop\job\app设计` 共 59 PNG+1 PDF;项目归档与 59 个旧文件哈希一致,新增 `思维导图.png` | 作为第一参考源;新图先用于规划,不在确认前复制 |
| 已完成参考项目 | `C:\Users\Rain\Desktop\job\Jiapu-App` 有 78 条活动路由+1 条注释路由声明、79 Vue、6 NVue、7 个未注册页面文件、113 个旧 API wrapper;使用旧后端封装且没有可用测试脚本 | 作为第二参考源;78 是活动分母,注释路由和未注册文件单列候选;禁止复制其请求层、旧地址、组件和数据模型 |
| 参考项目资产 | `Jiapu-App\static` 有 531 个媒体文件、约 6.45 MiB;声明扩展名为 307 PNG、221 GIF、2 SVG、1 JPG,文件签名实为 311 PNG、217 GIF、2 SVG、1 JPG;已发现 6 个扩展名/MIME 不一致、14 组完全重复、第三方/商标、旧品牌、固定旧文案、示例头像与短视频实验素材 | 用户允许逐文件复用候选;不整目录复制。高价值候选优先看水墨/宗祠、谱书、人物卡边框和中性功能图标;QQ 表情、短视频实验、支付/微信品牌、VIP/奖励、旧 logo 先排除直接复用 |
| 参考映射 | 权威附件逐一登记 60 个设计文件、78 条活动路由、1 条注释路由声明和 7 个未注册页面文件 | 产品取舍必须在用户确认前完成;执行阶段只重算并冻结,不再用前 30 分钟临场决定范围 |
| 测试与设备 | `tests` 下有 208 个文件:150 PS1、54 JS、4 JSON;当前没有统一夜跑 manifest;本轮没有运行测试、构建或 MuMu | 收到“开始执行”后才建立显式 manifest 并取得新鲜证据,旧 PASS 不冒充本轮 PASS |
### 第 0 批硬预检证据
### 当前接口覆盖初判
- 源码基线:`main`HEAD `f1edc6b53320755a8638c99d492250153a8dcb17`;工作区包含续作改动,未执行暂存、提交、还原或重置。
- 受保护 OpenAPI
- `APP.openapi.yaml``8964CD583CE172425B63BBFD802F7EB587EB3641EADFD6F9D3B264FAA8090C6C`
- `APP.openapi.json``87DB1DC148C5E6E877815AFF7B3A7FC7C7ECA95A2CEC50A88B42F3908961F31A`
- 活动路由共 52 条:A 3、G 9、T 7、F 10、R 11、N 2、M 10;导航路由与导航流程门禁均通过。
- 当前真实接口 consumer 共 7 页:A01、A04、A05、G01、G05、T01、M07。其余 45 页在逐页接线前均按 `LOCAL_PREVIEW``CLOSED` 报告,不冒充 LIVE。
- MuMu 设备在线:Android 12720×1280density 320HBuilder 基座处于前台;现有 `app-plus` 开发构建可运行,尚无 APK。
- 测试账号密码登录成功;只读查询“我的家谱”返回空列表,可作为 G01 空态种子。后续通过 G03 创建带“联调测试”标识的本轮家谱后,登记返回 ID 再继续 owner 链路。
- 普通申请、撤回、待审和审核需要第二独立账号形成申请人/管理员双角色;当前缺少第二角色时标为测试数据阻塞,不伪造跨账号通过。
- 注册需要一个未注册且能接收短信的手机号;未提供前只完成页面、TAC 前置和接口合同,真实注册标为测试数据阻塞。
| 域 | 本地受保护快照中的接口证据 | 明确缺口或待验真项 |
| --- | --- | --- |
| A 认证 | 登录、短信登录、注册、找回、发码、资料、安全操作均有声明 | TAC 服务端消费、密码 wire、短信场景、会话撤销和真实错误语义仍需验真;微信登录无当前 APP operation |
| G 家谱 | mine/public/create/detail/update、普通申请/撤回/审核、成员、字辈和行政区划均有声明 | G03 只有“创建家谱+另建首位人物”两次独立写;`regionCode` 必填,但第一步响应尚未保证稳定返回词法 `genealogyId`,创建结果查询也没有 operation,因此恢复链为 `MISSING_OPERATION`。只有第一步真实响应本身稳定给出字符串 ID 才允许进入第二步;首写超时、断线、5xx、空体或缺 ID 时立即停止并人工对账,禁止按展示名/“联调测试”标识从无正式 item DTO 的 mine 列表猜 ID,也禁止重复建谱;邀请码签发/直接加入、家谱排序、管理员 capability 没有可靠当前 owner |
| T 世系 | tree、人物列表/详情/增改停用、父母/配偶/兄弟姐妹/子女写入均有声明 | 所有读取权限字段缺失;int64 wire、T04 性别字典、T06 原子排行、邀请四段链、两个绑定 mutation owner、头像上传到访问闭环均冲突或缺失 |
| F 家族内容 | 动态、评论/回复/点赞、谱文、相册、照片和文件上传有声明 | 视频仅见删除操作,列表/发布/详情链不完整;视频评论、点赞和分享分别是独立候选,不能借动态互动或视频读取合同;置顶等参考动作无当前 owner |
| R 族务记录 | 亲友记录、祭祀/献礼、成长记录、备忘录、功德记录有声明 | 人生事件和重要证件无明确 APP owner;参考“贺礼”与当前“祭祀献礼”语义必须先拆清 |
| N 消息 | 通知列表、单条已读、全部已读有声明 | 未读计数没有 operation,G01/M01 不得从分页列表长度推断;没有通知详情 operation,列表还是无 item schema 的通用结果;N02 同一会话且列表项含完整正文时的投影单独记 `dataMode=LOCAL_PREVIEW`,冷启动、深链或缓存缺失状态单独记 `dataMode=CLOSED`;业务跳转、消息类型和目标参数均不能从文案猜测 |
| M 我的 | 资料、安全、帮助、反馈、推广内容、VIP 套餐/订单有声明 | M08 保持当前家谱邀请 owner,票据查看、签发、撤销分别由 `C-G-INVITE-LIST/ISSUE/REVOKE` 拥有,复制与系统分享分别由唯一平台剪贴板、分享 owner 负责拒绝/失败/返回;邀请票据 operation 当前缺失。参考 APP 推广/推荐码/奖励无当前路由且保持候选;真实支付、提现/变现及部分敏感操作缺完整结果链;“关于”只用本地静态 owner,不依赖帮助列表 |
## 十一、产品参考功能基线与 T01 世系树重做计划
### 11.1 参考资料的归档与换路径办法
1. 原参考目录 `C:\Users\Administrator\Desktop\job\app设计` 只视为一次性导入源,不是项目运行依赖,也不是长期权威路径。业务代码、测试、文档合同和构建脚本均不得硬编码该绝对路径。
2. 当前已取得的参考资料保存在项目相对目录 `docs/design/references/产品参考原稿`。该目录作为换电脑、原目录改名或移动后的本地参考副本;是否采用某个功能仍以用户确认和后续中文映射表为准,不能因为图片已归档就默认全部照搬
3. 当前电脑性能较弱,本阶段不批量解码图片、不做 OCR、不生成缩略图、不跑图像相似度或全量构建。用户回家并提供新的参考目录后再执行完整核对
4. 新目录核对只读进行:先枚举相对文件名、类型、大小和 SHA-256,再与项目内参考副本比较;只归档新增或内容变化的文件。旧副本不因源目录缺文件而自动删除,发现冲突先列清单给用户确认
5. 完整核对后建立 `docs/产品参考页面功能映射表.md`。每张参考页面必须映射到:参考文件、项目路由、保留功能、调整后的交互、沿用的国风视觉、明确舍弃的旧行为、接口 owner、数据模式、测试门禁和 MuMu 验收项。没有映射的参考图不得直接驱动代码
6. 参考 App 是功能和交互来源,不是整套视觉照抄目标。项目继续使用当前 `static` 中已确定的国风资产、色彩和组件语言;旧稿的亮红导航、卡片比例等只在用户明确点名时采用
7. 用户回家后的恢复步骤固定为:提供新目录 → 只读清单与哈希差异 → 补充归档 → 完成中文页面映射 → 用户确认映射 → 才把对应功能纳入实现批次。目录再次变化时重复相同步骤,不改业务代码中的路径
8. 用户后续提供的“已经完成的项目”作为第二参考源,价值高于单张截图对交互细节的表达,但仍不是当前项目的需求、接口或代码权威源。即使页面名称相同,也不得默认业务含义、角色权限、字段、接口和异常处理相同
9. 完成项目在用户给出新绝对路径后先只读盘点;第一轮不安装依赖、不运行构建、不修改参考仓库。检查范围包括路由、页面、组件、状态流、API 封装、数据模型、权限判断、视觉资产和异常分支,同时排除凭据、令牌、环境地址、签名、用户数据和机器专属配置
10. 禁止按目录、页面文件或组件整包迁移。甄别和采用的最小单位是“页面中的一个用户动作及其完整状态”,逐项映射后才能进入当前项目的测试先行批次
1. 第一参考源固定为 `C:\Users\Rain\Desktop\job\app设计`,只作为本机规划输入,不是运行依赖。业务代码、测试、接口合同和构建脚本均不得硬编码该绝对路径。
2. 该目录当前有 59 张 PNG 和 1 份 PDF。项目相对目录 `docs/design/references/产品参考原稿` 保存了其中 58 张 PNG 和 1 份 PDF59 个同名文件的 SHA-256 全部一致;源目录新增 `思维导图.png`。在用户确认规划前,不复制新增文件、不删除旧副本
3. `思维导图.png` 同时包含 APP、后台管理、公司官网和 PC 管理端分支。只有“用户端苹果安卓 APP”分支可直接进入当前产品候选池;其他分支只用于理解角色、权限和服务依赖,不能自动变成当前 UniApp 路由
4. 第二参考源固定为 `C:\Users\Rain\Desktop\job\Jiapu-App`。第一轮只读盘点已完成路由、页面和 API wrapper 清单;继续甄别时不安装依赖、不运行构建、不修改参考仓库,也不读取或迁移其 Git 历史
5. 参考项目使用旧的 Vue/uView 结构、旧后端地址和宽松请求封装,且请求层会输出参数和响应;这些实现、环境值、日志方式、Token 处理、组件和数据模型全部禁止迁入当前项目。API wrapper 只用于证明“参考项目曾尝试过某动作”,不能作为当前接口合同
6. `docs/产品参考页面功能映射表.md` 必须在交给用户确认前完成。第一源使用 `Dxxx-Pxx-Sxx-Axx` 标识文件、PDF 页/区域、页面、状态和动作;每张单页 PNG 默认自身就是独立 `S01` 状态证据,表内可省略 `S01` 作短写。第二源按 `Jxxx-Sxx-Axx` 标识 78 条活动路由的状态与动作,注释路由单列 `JX001`7 个未注册文件使用 `JUxx-Sxx-Axx`。附件必须有正向逐项表和按当前 A/G/T/F/R/N/M owner 的反向覆盖索引;组合展示不能省略任何源 ID
7. 参考截图和参考项目不按文件整包采纳。甄别最小单位固定为“一个页面状态下的一个用户动作”。文件名相近、画面相似、路由目标相同或代码结构近似都不能直接判为重复:它们可能是普通、管理、编辑、空、错误、权限、角色或主题状态。只有入口、对象、动作、数据、接口和返回行为均被证明等价后,才能共用当前 owner;共用后仍须保留每个来源状态 ID。查看、创建、编辑、删除、邀请、分享、支付等动作可以分别得出不同结论
8. 当前项目继续使用已确定的国风资产、色彩和组件语言。参考稿的亮红导航、原卡片比例、旧弹窗、默认 uView 控件和旧图标只表达信息结构,不默认成为视觉验收目标
9. 参考源出现、当前项目没有路由或接口 owner 的功能先进入“候选缺口”,必须判断是复用现有页面、增加新路由、后端缺失暂关,还是与当前产品冲突舍弃;不得为了保持 52 条路由而遗漏,也不得为追求功能数量临时创建假页面。隐私资料、重要证件、资金、删除、退出家谱、所有者转移和账号注销一律作为敏感候选单列,参考源存在不构成采纳授权
10. 两个源再次变化时,重复“只读文件清单与哈希差异 → 更新页面/动作映射 → 记录新旧结论”的流程;不因源目录缺文件自动删除项目内归档,也不在业务代码里保存机器绝对路径
### 11.2 T01 目标画面与交互
@@ -195,7 +427,9 @@ T01 的验收目标不是“能显示一棵文字树”,而是能够识别人
- 邀请绑定;
- 编辑信息。
6. 面板遮罩点击、关闭按钮、Android 系统返回键、页面返回键、重复点击和焦点恢复行为必须一致。提交中的动作禁用重复触发;页面离开或响应迟到时不得把结果写入错误人物。
7. 操作入口是否可见和可用由服务端权限或明确能力字段决定。不能因为当前账号从管理入口进入,就在前端猜测其拥有编辑、邀请或调整关系权限
7. 当前 Apifox 的 tree/person/member 投影均没有稳定 `canEdit/canInvite/canManage/actionCapabilities` 字段,前端不得猜测角色或伪造 capability。十个必需入口保持可发现;已有单一 declared write operation 的入口可提交最小白名单请求,由服务端最终裁决,并对 403/409/5xx/超时明确失败且不本地假成功。403 只能证明该次请求被拒绝,不能反推出可持续的前端权限。每个动作账本仍须记录权限字段、来源 operation、撤权后的刷新和 403 语义
已完成参考项目只证明这组动作是产品候选,不证明实现可迁移:其树页面中“添加女儿”仍复用了 `addSon` 意图、“添加母亲”存在注释掉的入口、“邀请绑定”按钮没有闭合动作,删除又混用旧通用用户接口。因此 T01 必须按十个入口分别定义意图和合同,不能复制参考页面后把同名按钮视为完成。
### 11.3 页面职责与唯一 owner
@@ -205,11 +439,20 @@ T01 的验收目标不是“能显示一棵文字树”,而是能够识别人
| T03 成员详情 | 读取并展示真实人物资料;承接“点击头像查看资料” | 不以本地 fixture 冒充服务端人物 |
| T04 添加亲属 | 统一承接父亲、母亲、配偶、兄弟姐妹、儿子、女儿六类新增关系 | 不为六个入口复制六套提交合同,不猜测缺失 ID |
| T05 编辑成员 | 修改头像和允许编辑的基础人物资料 | 不承担排行或关系拓扑修改 |
| T06 关系维护 | 调整排行及后端明确支持的关系维护 | 不退化为另一份通用人物编辑页 |
| T06 排行调整 | 原子调整同辈排行 | 不退化为另一份通用人物编辑页,也不保留旧“关系修正本地预览”假成功路径;关系修正若以后获批必须另立产品动作和原子合同 |
| 邀请绑定流程 | 在独立合同下邀请用户并绑定人物 | 不与普通加入家谱申请或邀请码直接加入混为一条链 |
| T07/T08 | 成员目录、成员状态等辅助读取 | 不成为 T01 写操作的旁路 owner |
T04 的六个入口只传递稳定的词法 ID、`relationType` 和必要意图提示;页面先展示当前人物和待添加关系,最终字段及性别等约束由后端合同校验。不得复制人物模拟配偶,也不得仅凭“儿子/女儿”等按钮文案在本地推导超出请求意图的数据
T04 的六个入口只在路由内部传递经 validator 验证的十进制字符串 ID、内部 `relationType` 枚举和必要意图提示;`relationType` 不是 wire 字段,严禁发送到 `additionalProperties:false``LineagePersonBody`。页面先展示当前人物和待添加关系,只有性别字典、关系约束和真实响应共同确认后才允许提交。不得复制人物模拟配偶,也不得仅凭中文按钮在本地猜测 wire 值
| UI 意图 | 完整 endpoint | 请求意图约束 | 当前状态 |
| --- | --- | --- | --- |
| 添加父亲 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/parents` | 关系方向由 `/parents` owner 决定;只发送当前已声明并采集的基础人物字段,不发送 `relationType` 或猜测的性别 code | `DECLARED_UNVERIFIED`,待测试账号实测服务端性别/冲突/权限与读后刷新 |
| 添加母亲 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/parents` | 同上;不得将“母亲”中文标签直接当成 `sex` wire | `DECLARED_UNVERIFIED`,待实测 |
| 添加配偶 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/spouses` | 不发送 `relationType``relationName` 未由页面采集,暂不猜测 | `DECLARED_UNVERIFIED`,待关系方向、重复/冲突、权限与读后刷新实测 |
| 添加兄弟姐妹 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/siblings` | 不发送 `relationType`;不把单人 `sortOrder` 当同辈排行 | `DECLARED_UNVERIFIED`,待字典/冲突/权限实测 |
| 添加儿子 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/children` | 关系方向由 `/children` owner 决定;不猜测性别 code | `DECLARED_UNVERIFIED`,待实测 |
| 添加女儿 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/children` | 同上 | `DECLARED_UNVERIFIED`,待实测 |
### 11.4 接口映射与启用条件
@@ -217,29 +460,42 @@ T04 的六个入口只传递稳定的词法 ID、`relationType` 和必要的意
| --- | --- | --- | --- |
| 展示世系树 | T01 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/tree` | 树 DTO、稳定 ID、代际、配偶与父子连接关系已收紧并通过运行时测试 |
| 查看人物资料 | T03 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}` | 路由参数与响应投影一致,不回退 fixture |
| 添加儿子/女儿 | T04 | `POST .../lineage/persons/{personId}/children` | 请求 DTO、写入结果、重复提交与结果未知规则明确 |
| 添加父亲/母亲 | T04 | `POST .../lineage/persons/{personId}/parents` | 同上,并由服务端校验关系冲突 |
| 添加兄弟姐妹 | T04 | `POST .../lineage/persons/{personId}/siblings` | 同上,不通过复制已有节点伪造 |
| 添加配偶 | T04 | `POST .../lineage/persons/{personId}/spouses` | 同上,并能读取提交后的真实关系 |
| 添加儿子/女儿 | T04 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/children` | 性别 enum、请求 DTO、写入结果、重复提交与结果未知规则明确 |
| 添加父亲/母亲 | T04 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/parents` | 同上,并由服务端校验关系冲突 |
| 添加兄弟姐妹 | T04 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/siblings` | 性别/排行合同明确,不通过复制已有节点伪造 |
| 添加配偶 | T04 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/spouses` | 请求合同明确,并能读取提交后的真实关系 |
| 编辑人物资料/头像引用 | T05 | `PUT /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}` | 字段白名单、头像上传/引用/访问 URL 闭环和结果确认完整 |
| 调整排行 | T06 | 后端明确支持的关系或 `sortOrder` 写操作 | 并发、权限、冲突和结果确认规则明确后才启用 |
| 邀请绑定 | 独立流程 | 待 Apifox、线上合同和真实响应共同确认 | 必须有明确 operation、身份安全和幂等/结果查询规则;缺失时显示“服务暂未开放” |
| 调整排行 | T06 | 仅有候选 `PUT /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}``sortOrder` | 当前为 `CONTRACT_CONFLICT`:无批量/原子同辈重排、版本或冲突协议,不得逐人写出部分成功 |
| 邀请签发、送达与接受 | 独立邀请流程 | 无可靠 operation | `contractState=MISSING_OPERATION`,保持关闭 |
| 邀请目标身份查找 | 独立邀请流程 | 无隐私安全的可靠 operation | `contractState=MISSING_OPERATION`,保持关闭 |
| 绑定 mutation | 独立邀请流程 | 成员 PUT 的 `lineagePersonId` 与人物 PUT 的 `appUserId` 是两个候选 | `contractState=CONTRACT_CONFLICT`,确认唯一 owner 前禁止双写 |
| 邀请绑定结果查询 | 独立邀请流程 | 无可恢复查询 operation | `contractState=MISSING_OPERATION`,结果未知时禁止重试 |
接口路径中的 `...` 仅是规划表的共同前缀缩写,实际合同和测试必须写完整路径。`avatarOssId` 的 owner 是统一上传与文件访问链路;未确认上传、文件引用和可访问 URL段闭环前,T05 不显示假上传成功。邀请绑定没有独立后端 operation 时记为 `EXPECTED_BLOCKED`,不能借用普通家谱加入申请或邀请码接口。
`avatarOssId` 的 owner 是统一上传与文件访问链路;未确认上传、业务实体更新、文件引用和可访问 URL/重进显示四段闭环前,T05 不显示假上传成功。
邀请绑定拆成四段独立账本:
1. 邀请签发、送达、接受:当前没有 operation,保持关闭。
2. 查找目标身份:当前没有隐私安全的独立 operation,保持关闭。
3. 绑定已有成员/用户:`PUT /genealogy/app/genealogies/{genealogyId}/members/{memberId}``lineagePersonId``PUT /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}``appUserId` 是两个冲突候选;真实合同确认后只能选择一个 owner。
4. 结果查询:当前没有可恢复 operation,结果未知时不得重试或双写。
邀请绑定不能借用普通加入申请、邀请码直入或同时调用两个候选 mutation。关闭态检查可以为 `PASS`,但该必需动作的 `productCompletion` 仍为 `BLOCKED`
所有写操作都必须定义提交中、成功、明确失败和结果未知四种结果。只有后端合同明确支持幂等或存在无副作用的结果查询时才允许安全恢复;超时、断网或 5xx 后不能用 timer 自动提示成功,也不能把本地草稿当成服务端已提交。
### 11.5 数据与状态约束
- `genealogyId``personId`父母/配偶/子女引用均按稳定词法 ID 处理,不转数字、不截断、不以姓名定位
- 树投影至少明确头像访问结果、姓名、关系类型、代际、父母引用、配偶引用、同辈排行 `sortOrder` 和权限/能力;字段缺失必须进入可解释失败或降级状态,不能静默拼错树。
- `genealogyId``personId``memberId``appUserId`、文件与亲属引用都以十进制字符串为唯一 ID owner。当前 OpenAPI 的 `integer/int64` 与 JavaScript 安全精度冲突;只有服务端响应以字符串返回且请求接受字符串后才能启用。超过安全整数的 number 不得在解析后转字符串补救
- 树投影至少明确头像访问结果、姓名、关系类型、代际、父母引用、配偶引用、同辈排行 `sortOrder` 和权限/能力;当前 capability 未声明,所有写入口先关闭。字段缺失必须进入可解释失败或降级状态,不能静默拼错树。
- 关系类型采用单一枚举 owner,页面、API adapter、validator、测试和文档共同消费,禁止各页面维护中文字符串分支。
- 排行调整的并发和最终顺序由服务端结果负责;前端可以预览但不能在服务失败后保留假顺序
- `sex`、关系、人物状态和排行语义都必须由真实合同提供 enum;T04 内部意图不能充当 wire 字典
- 排行调整当前为 `CONTRACT_CONFLICT`。在后端提供原子同辈顺序结果或明确并发协议前,前端只可展示关闭说明,不得逐人物写 `sortOrder` 或在失败后保留假顺序。
- 恢复信息不得持久化手机号、头像原图、身份证明、邀请凭据等 PII;只保留完成恢复所需的最小非敏感标识和状态。
### 11.6 测试先行与实施顺序
规划经用户确认后,T01/T03—T06 按以下独立小批次执行每批由主代理写入、两位评审者只读复核接口、业务、交互、异常和视觉,当前批通过后才进入下一批:
只有规划经用户确认且用户随后明确说“开始执行”后,T01/T03—T06 按以下独立小批次执行每批由主代理写入、两位评审者只读复核接口、业务、交互、异常和视觉,当前批通过后才进入下一批:
1. **T01-0 失败门禁**:先新增会失败的合同,锁定头像节点、姓名/关系/代际、唯一人物操作面板、十个动作入口,并禁止旧底部操作条与新面板并存。
2. **T01-1 树 DTO 与布局**:收紧树 normalizer 和 validator,验证父母、配偶、子女、代际、排行、缺头像、失真 ID、重复节点、断链及循环关系;旧宽 DTO 必须失败。
@@ -250,22 +506,26 @@ T04 的六个入口只传递稳定的词法 ID、`relationType` 和必要的意
7. **性能与视觉验收**:以小树、数十代、数百成员、长姓名、无头像和多配偶数据检查布局预算、滚动流畅度、连接线及内存;不以无限数据承诺替代明确上限。
8. **MuMu 与无障碍**:先在 720×1280 实机检查,再补 320/412 响应式检查;验证触控尺寸、文本缩放、读屏名称、焦点顺序、弹层焦点约束、系统返回和低性能设备操作反馈。TalkBack 等无法稳定自动化的项保留可复现人工步骤。
### 11.7 T01 完成定义
### 11.7 T01 检查结果与产品完成定义
T01 只有同时满足以下条件才能标记为 `PASS`
T01 必须分别报告结构/关闭态检查和产品完成度
- 头像卡、连接线、选择态、唯一操作面板、十个入口可发现、禁用原因、返回行为等可以分别得到 `checkResult=PASS/FAIL`
- 某入口因真实后端缺失而正确显示“服务暂未开放”,只表示该关闭态检查 `PASS`;该入口的 `productCompletion=BLOCKED`T01 产品完成度也不得为 `COMPLETE`
- T01 只有以下全部成立时才可记 `productCompletion=COMPLETE`
1. 真实头像或确定性默认头像可见,姓名、关系、代际和连接线正确。
2. 点击任一人物只打开一个完整操作面板,十个入口均能到达正确页面/流程,或在后端确实缺失时明确显示服务未开放
3. T03 展示真实详情;T04 六类关系写入、T05 编辑T06 排行/关系操作各自遵守唯一 owner,没有 fixture、timer、兼容旁路或假成功。
4. 树刷新后能以服务端数据确认变更;写结果未知时停止重复写并提供恢复路径。
2. 点击任一人物只打开一个完整操作面板;查看资料、父亲、母亲、配偶、兄弟姐妹、排行、儿子、女儿、邀请绑定和编辑十个入口均完成真实权限与业务闭环
3. T03 展示真实详情;T04 六类关系写入、T05 编辑T06 原子排行及独立邀请绑定各自遵守唯一 owner,没有 fixture、timer、兼容旁路或假成功。
4. 树刷新和重进后能以服务端数据确认变更;写结果未知时停止重复写并提供只读恢复路径。
5. 当前国风视觉一致,长世代滚动、卡片、连接线、遮罩和返回行为在目标尺寸上稳定,无白块、截断、误触和不可读状态。
6. 接口、业务、交互、异常、视觉、性能和无障碍门禁通过三人交叉评审;自动测试、编译检查和 MuMu 实机证据齐全。
6. 接口、业务、交互、异常、视觉、性能和无障碍门禁通过三人交叉评审;自动测试、编译检查和绑定冻结候选包的 MuMu 实机证据齐全。
### 11.8 当前暂停项
- 已新增的 `tests/t03-member-api-runtime-smoke.js``tests/t03-member-remote-contract.ps1` 以及 `utils/api.js` 中 T03 normalizer 属于规划重整前留下的半成品。T03 页面尚未完成真实接线,因此整体状态是“暂停、未完成”,规划确认后从失败门禁重新核对,不能计入已完成页面
- 当前 T01 已有的文字节点、简单选中态、底部“查看资料/添加亲属”以及任何单张 MuMu 预览都不构成本节验收证据。
- 在用户确认本节前不继续修改 T01/T03—T06,不批量处理参考图,不运行构建或 MuMu 验收。
- T03 的远端 `GET` 已按 Apifox 人物详情 owner 接线并通过静态/运行时映射门禁,但尚未以测试账号取得本轮脱敏真实响应,仍是 `DECLARED_UNVERIFIED`,不能计入 `LIVE_VERIFIED` 或页面完成
- T01 旧的固定底部“查看资料/添加亲属”抽屉已经移除;当前只有点击人物卡打开的唯一人物操作面板。任何单张 MuMu 预览都不构成本节完整验收证据。
- 在用户明确说“开始执行”前不继续修改 T01/T03—T06,不批量处理参考图,不运行测试、构建或 MuMu 验收。
### 11.9 已完成参考项目的逐项甄别门禁
@@ -295,7 +555,74 @@ T01 只有同时满足以下条件才能标记为 `PASS`:
| 仅参考交互 | 只能借鉴信息结构或操作方式 | 不复制其数据层、API 代码和环境配置 |
| 后端缺失,暂时关闭 | 当前服务没有可靠 operation 或结果确认链 | 保留诚实关闭态并登记解除条件 |
| 与当前产品冲突,明确舍弃 | 违反当前需求、架构、安全或视觉口径 | 记录舍弃原因,禁止后续人员再次误迁移 |
| 待用户确认 | 证据足够但会扩大产品范围、引入敏感能力或改变既有需求 | 保留书面选项,不进入代码批次 |
`docs/产品参考页面功能映射表.md` 对完成项目增加以下列:参考仓库相对路径、参考路由、候选动作、参考状态流、当前目标路由、当前接口证据、差异、甄别结论、改造边界、测试 owner 和用户待确认项。存在分歧或证据不足时标记“待确认”,不得由代理静默选择。
参考项目不得向当前仓库带入旧后端地址、账号、Token、密钥、签名、应用标识、用户数据、构建产物、依赖缓存或 Git 历史。采用其功能也必须重新通过当前项目的合同测试、运行时测试、编译、MuMu 视觉与三人交叉评审;“参考项目里能运行”不能作为当前项目完成证据。
### 11.10 参考项目资产逐文件复用门禁
用户已经允许把参考项目中的图标等资产拿来补足当前产品。该许可按“可进入逐文件甄别池”执行,不按“整包照搬”执行。规划期只读盘点得到:
- `C:\Users\Rain\Desktop\job\Jiapu-App\static` 共 531 个媒体文件、6,764,740 bytes(约 6.45 MiB);`assets` 只有 JS/CSS/SCSS`uni_modules\uni-id.zip` 是依赖包而非产品资产。
- 声明扩展名为 PNG 307、GIF 221、SVG 2、JPG 1;文件签名实为 PNG 311、GIF 217、SVG 2、JPG 1。6 个文件扩展名与 MIME 不一致,迁入前必须规范重导出。
- SHA-256 完全重复 14 组、28 个文件,迁入时每组只能选择一个 canonical owner。
- `static/emojis/qq` 434 项、`static/douyin` 18 项及旧视频实验图标、微信/支付宝/支付/VIP/奖励素材、旧 logo 和固定旧文案均有第三方、旧品牌或未批准业务风险,不能因文件存在而启用对应产品功能。
- 水墨/宗祠、谱书、人物卡边框、中性功能图标和默认头像风格是高价值候选;其中固定姓氏、口号、生成者/提示词式文件名、示例人物、水印及低分辨率仍须逐文件处理。
每个实际考虑复用的文件在复制前建立 `RAxxx` 记录,至少包含:源绝对/相对路径、SHA-256、重复组与 canonical ID、声明扩展名/检测 MIME、尺寸/帧数/透明度/bytes、来源页面和状态、目标 PA/路由/组件/状态、语义用途、决策、复用方式、用户对参考项目的许可记录、第三方/商标信号、个人数据/旧品牌/固定文字/无障碍/性能风险、必要改造和验收证据。决策仍使用六类口径;复用方式固定为原文件、裁切重导出、重绘、仅构图参考或禁用。
逐文件门禁为:
1. **产品门**:必须绑定已批准 PA、页面、状态和动作;资产不能反向新增功能、解除后端关闭或扩大 55 个候选 PA。
2. **状态门**:相似图标或画面也可能代表普通、选中、禁用、权限、错误等不同状态;未证明语义和行为等价前分别记录、分别验收。
3. **来源门**:用户已允许评估并复用参考项目资产;带 QQ、抖音、微信、支付宝等第三方/商标信号的文件还需满足当前平台规范,来源不清时只能重绘或不用,规划不写“已拥有版权”结论。
4. **隐私与品牌门**:不带示例真人/头像、二维码、手机号、姓名、水印、定位、旧 logo、旧口号、奖励/VIP/支付旧文案或固定姓氏。无 EXIF 不等于无个人数据,仍须看画面。
5. **技术与安全门**:扩展名与 MIME 一致;SVG 不含脚本、外链或未经审查的 data URI;位图去无用元数据;重复哈希只保留 canonical;不得沿用旧绝对路径或外部 URL。
6. **视觉与无障碍门**:服从当前国风 token;覆盖普通、选中、禁用、加载、错误和关闭态;小图放大不糊,功能图标有语义文本,装饰图不进入读屏,动图提供静态降级和减少动态效果。
7. **性能与验证门**:记录解码尺寸、包体和首屏影响;超预算就压缩、重绘或按需加载。只对实际采用的 RA 在目标路由各批准状态做 MuMu 截图、缩放、离线/失败检查,并绑定冻结候选 ID。
首批优先甄别而非预先批准的候选包括 `static/iconpng/902.png``static/iconpng/z8526@2x.png``static/login/bj.jpg`(实为 PNG)、`static/iconpng/treeBJ.png``static/iconpng/book.png``static/icon/index.svg``static/navigation/*``static/tabulation/*``static/pu/*``static/jr.png`、默认头像风格和 `static/login/hd*.gif``jiap.svg` 约 397 KiB 且内嵌大位图与固定文字,只作构图参考。规划确认时固定 `referenceAssetFiles=531``approvedDirectReuseAssets=0``approvedAdaptReuseAssets=0`;收到“开始执行”后,只有通过上述逐文件门禁的 RA 才增加采用数,这属于已批准 PA 内的实现选择,不改变产品动作分母。九小时批次只甄别本轮已批准 PA 实际需要的资产,未选中的候选保持未审、未复制,不把“531 个进入候选池”写成“531 个必须在今晚迁入”。
### 11.11 全项目功能补足矩阵
下表是本轮权威规划的全项目检查骨架;逐页、逐动作和逐接口结论已经落在权威附件中。执行第 0 批只重算并冻结这些结论,不得再临场扩大产品范围。
| 当前域 | 当前活动路由 | 两个参考源提供的候选 | 本轮必须做出的结论 |
| --- | --- | --- | --- |
| A 认证 | A01、A04、A05 | `登陆.png`、参考项目登录/注册/找回页,以及账号密码、短信、注册、找回、微信登录等动作 | 四条现有认证流程继续以 TAC 和当前后端为准;微信登录没有当前 APP operation,只能标记候选关闭或待用户确认,不能借旧接口接入 |
| G 家谱工作区 | G01、G03、G05、G06、G08—G12 | 家谱列表、创建、加入、邀请码、家谱主页、字辈、家谱排序、始祖世代、管理员及权限 | G03 固定为“创建家谱→创建首位人物”两阶段,但恢复链按 `MISSING_OPERATION` 关闭:`regionCode` 由共享行政区划 owner 提供;只有第一步真实响应本身稳定返回词法 `genealogyId` 才登记待续办 ID 并进入第二步。首写结果未知或缺 ID 时停止并人工对账,禁止从 mine 列表按展示名/测试标识猜 ID,禁止假定通用对象壳含 ID,也禁止重复建谱。其余列表/搜索/普通申请/审核/设置/字辈逐项验真,邀请码直入、家谱排序、始祖世代和管理员权限分别判断 owner |
| T 世系与成员 | T01、T03—T08 | 头像树、表格/树谱模式、人物资料、父母/配偶/兄弟姐妹/儿女、排行、邀请绑定、编辑及参考项目中的删除 | T01 十个必需入口逐项落到 T03/T04/T05/T06/独立邀请流程;T03保持未完成;删除人物/配偶不是本轮十个入口之一,须单列敏感候选,不能因参考项目有按钮而默认加入 |
| F 家族内容 | F01—F10 | 家族圈、发布/删除/评论、谱文分类与增改删、相册分类与照片管理、视频列表/发布/编辑及短视频评论/点赞/分享 | 动态、谱文、相册、上传逐动作核对当前合同;视频浏览、评论、点赞、平台分享已拆成独立候选 owner,缺合同时诚实关闭,不用旧 API、动态评论 owner 或本地计数假成功 |
| R 人物与族务 | R01—R11 | 人物、人情簿、贺礼邀请、成长日志、备忘录、功德录、重要证件 | R01/R02 分别作为人物目录、人物资料的独立路由状态,数据只消费 T07/T03/T05 的唯一列表/详情/编辑 owner;R02 不保留通用“新建人物本地预览”,编辑入口只导航 T05。亲友记录、祭祀/献礼、成长、备忘、功德按当前业务语义接线;“贺礼邀请”与“祭祀献礼”先拆分语义;人生事件和重要证件缺 owner 时进入候选缺口 |
| N 消息 | N01、N02 | 加入申请、生日、贺礼、备忘、疫苗、点赞评论、推广、广告消息及详情 | 当前只有列表与已读合同,未读计数缺 operationG01/M01 使用同一个关闭 owner 而不从分页长度推断;N02 无详情 operation。同一会话且列表项含完整正文时的投影是独立状态,记 `dataMode=LOCAL_PREVIEW`;冷启动、深链或缓存缺失是另一独立状态,明确返回 N01 并记 `dataMode=CLOSED`。只有服务端提供闭合详情、消息类型、目标 route key、参数和权限失效语义后才恢复 LIVE 详情与业务跳转 |
| M 我的 | M01—M10 | 个人资料、帮助、设置、修改密码/手机、反馈、家谱邀请、VIP、注销、提现/变现 | M08 保持“选择家谱并管理家谱邀请票据”的当前职责,票据查看/签发/撤销与本地复制/系统分享分别归 `C-G-INVITE-LIST/ISSUE/REVOKE``C-S-PLATFORM-CLIPBOARD/SHARE`,缺真实 operation 时保持关闭;参考 APP 推广、推荐码、奖励和变现不得覆盖 M08,均维持无当前路由候选。支付、注销、换绑和提现属于敏感动作,必须有独立合同、人工窗口和结果确认 |
跨域共享项也必须进入映射:登录态与账号切换、当前家谱上下文、角色/capability、文件上传与访问 URL、通知回流、深链参数、缓存失效、写结果未知、Android 返回、触控、系统字号、TalkBack 和长列表性能。任何一个共享项不得在 A/G/T/F/R/N/M 各自复制一份合同。
第一轮已识别但不能静默纳入今晚代码的参考候选如下:
| 候选 | 参考证据 | 当前判定 |
| --- | --- | --- |
| 微信一键登录 | 思维导图与参考登录页 | 当前后端无 APP operation`后端缺失,暂时关闭`;是否长期需要由用户后续确认 |
| 管理员列表与细粒度权限 | 思维导图、参考 `admin` 三页 | 当前只有成员类接口证据,没有闭合的管理员角色/权限 owner,先列 `待用户确认` 与后端缺口 |
| 家谱排序、始祖世代调整 | 参考 `sortGenealogy``ancestorsOrder` 与设计图 | 与 T01 人物排行、G12 字辈排序不是同一合同;分别缺可靠 owner,不能互相借用 |
| 重要证件 | 参考 `document` 页面 | 当前 52 路由和 APP 合同均无 owner,列产品候选,不临时新增假入口 |
| 家族圈置顶 | 思维导图 | 当前动态合同未见置顶 capability,暂时关闭 |
| 广告/宣传视频 | 参考消息广告和宣传视频页面 | 与当前普通通知、通用推广和 F10 家族视频分开甄别;独立使用 `C-M-PROMO-VIDEO` 及其评论/点赞/分享候选 owner,无可靠 operation 时不采用 |
| 提现、分享变现 | 参考“我的”页面 | 涉及真实资金且不属于已确认当前需求,保持 `待用户确认`,今晚不执行 |
| 删除世系人物/配偶 | 参考 T01 操作面板 | 当前后端虽有停用人物声明,但用户本轮明确的是十个其他入口;删除必须另设权限、影响预览和二次确认后再决定 |
| 扩展个人隐私资料 | 设计人物表单中的微信、QQ、住址、学历、职业等字段及参考重要证件页 | 当前需求未授权扩展收集,后端也无闭合隐私/可见性 owner,结论为 `与当前产品冲突,明确舍弃`;只保留反例证据,不采纳、不存储、不上传 |
| 移除成员、退出家谱、所有者转移、账号注销 | 参考家谱列表/我的页与当前成员类声明 | 均为破坏性或身份敏感动作,必须单独产品确认、影响预览、再认证、结果查询和人工窗口;不因存在 operation 自动启用 |
### 11.12 确认与执行门禁
1. 用户已明确“需求说完了”,需求收集门禁已关闭。
2. 主代理完成本文修订后,由三人按第七节交叉评审;只允许主代理吸收意见修改本文。
3. 三人评审收口后,把完整修订版、主要变化、评审结论和仍待确认项交给用户。
4. 交付用户时必须把两项单独列明并取得确认:`T_due=2026-07-24 08:00` 是否接受;55 个候选 PA 本轮是否维持 `approvedCandidateActions=0`。笼统沉默不视为接受具体截止时间或批准候选。
- 用户已于 2026-07-23 确认将截止更新为 08:00,并确认 55 个候选 PA 本轮维持 `approvedCandidateActions=0`
5. 用户确认规划只代表规划定稿,业务代码、接口接线、样式、测试、构建和 MuMu 仍保持冻结。
6. 只有用户明确说“开始执行”,第六节计时和第 0 批才启动;第 0 批只重算、冻结已确认映射和环境,不得重新解释为需求收集。
7. 用户未说“开始执行”时,不因时间已到“今晚”而自行开工,也不把只读规划核对解释为代码阶段已经开始。
+1 -1
View File
@@ -46,7 +46,7 @@
class="auth-input"
type="number"
maxlength="11"
:disabled="sendingCode || submitting || tacVisible || authenticationCommitted"
:disabled="sendingCode || submitting || tacVisible || authenticationCommitted || (cooldownSeconds > 0 && phone.length > 0)"
placeholder="手机号"
aria-label="手机号"
placeholder-class="input-placeholder"
+1 -1
View File
@@ -38,7 +38,7 @@
class="auth-input"
type="number"
maxlength="11"
:disabled="sendingCode || submitting || registrationCommitted"
:disabled="sendingCode || submitting || tacVisible || registrationCommitted || (cooldownSeconds > 0 && phone.length > 0)"
placeholder="请输入手机号"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.phone)"
+1 -1
View File
@@ -38,7 +38,7 @@
class="auth-input"
type="number"
maxlength="11"
:disabled="sendingCode || submitting"
:disabled="sendingCode || submitting || (cooldownSeconds > 0 && phone.length > 0)"
placeholder="请输入手机号"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.phone)"
+49 -260
View File
@@ -1,114 +1,50 @@
<!-- 页面编号F-01用途家族动态首页列表空态与失败状态 -->
<!-- 页面编号F-01用途家族动态入口列表 DTO 缺失时不展示 fixture 条目 -->
<template>
<view
class="family-page"
:class="{
'feed-state--list': feedState === 'list',
'feed-state--empty': feedState === 'empty',
'feed-state--error': feedState === 'error',
}"
>
<view class="family-page" :class="`feed-state--${feedState}`">
<ModulePageBackground module="family" />
<view class="family-page__header">
<PageHeader root title="家族动态" :action="hasValidContext ? '发布' : ''" @action="toPublish" />
</view>
<view class="family-page__header"><PageHeader root title="家族动态" :action="hasValidContext ? '发布' : ''" @action="toPublish" /></view>
<view class="feed-content">
<AppLoading
v-if="feedState === 'loading'"
text="正在整理家族动态"
description="请稍候,正在读取家宴、通知与共同记忆。"
/>
<view v-if="feedState !== 'loading'" class="feed-heading"
><text>{{ familyTitle }}</text><text>家宴通知与共同记忆</text></view
>
<view v-if="feedState !== 'loading' && hasValidContext" class="feed-shortcuts">
<view
v-for="item in shortcuts"
:key="item.key"
class="feed-shortcut"
@click="openSection(item.key)"
><text>{{ item.label }}</text></view
>
</view>
<template v-if="feedState === 'list'">
<view
v-for="item in feeds"
:key="item.id"
class="feed-card"
@click="openDetail(item)"
>
<view class="feed-card__copy">
<text class="feed-card__meta"
>{{ item.tag }} · {{ item.time }}</text
>
<text class="feed-card__title">{{ item.title }}</text>
<text class="feed-card__summary">{{ item.content }}</text>
<text class="feed-card__author">发布人{{ item.author }}</text>
</view>
<view class="feed-heading"><text>家族圈</text><text>家宴通知与共同记忆</text></view>
<template v-if="hasValidContext">
<view class="feed-source-note"><text>动态列表接口当前没有声明条目 DTO页面不猜测标题正文发布人或时间已停止展示本地动态</text></view>
<view class="feed-shortcuts">
<view v-for="item in shortcuts" :key="item.key" class="feed-shortcut" @click="openSection(item.key)"><text>{{ item.label }}</text></view>
</view>
</template>
<view v-else-if="feedState !== 'loading'" class="feed-state-card">
<view class="feed-state-card__copy"
><text>{{ stateCopy.title }}</text
><text>{{ stateCopy.copy }}</text></view
>
<view class="feed-state-card">
<view class="feed-state-card__copy"><text>{{ stateCopy.title }}</text><text>{{ stateCopy.copy }}</text></view>
</view>
<view
v-if="feedState !== 'loading'"
class="feed-action"
@click="handlePrimaryAction"
><text>{{ stateCopy.action }}</text></view
>
<view class="feed-action" @click="handlePrimaryAction"><text>{{ stateCopy.action }}</text></view>
</view>
<AppTabbar active="family" />
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import PageHeader from "@/components/PageHeader.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import {
findGenealogyFixture,
getGenealogyFixtureAccess,
listFamilyFeedFixtures,
} from "@/data/mock.js";
import PageHeader from "@/components/PageHeader.vue";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { goRoot, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const genealogy = ref(null);
const hasValidContext = ref(false);
const feedState = ref("loading");
const feeds = ref([]);
const familyTitle = computed(() =>
genealogy.value ? `${genealogy.value.name}家族圈` : "家族圈",
);
const feedState = ref("unavailable");
const stateCopy = computed(() => {
if (!hasValidContext.value) {
return {
title: "请先选择可访问的家谱",
title: "请先选择有效家谱",
copy: "家族动态必须归属明确家谱,页面不会展示其他家谱的内容。",
action: "返回我的家谱",
};
}
if (feedState.value === "empty") {
return {
title: "还没有家族动态",
copy: "可先查看其他家族内容;发布接口接入后才能新增动态",
action: "填写动态预览",
};
}
if (feedState.value === "error") {
return {
title: "家族动态暂不可用",
copy: "请稍后重新查看,已有内容不会受到影响。",
action: "重新查看",
};
}
return { title: "", copy: "", action: "发布家族动态" };
return {
title: "动态列表待后端字段合同",
copy: "已找到动态列表 operation,但响应只声明通用对象数组;待后端提供动态 ID、标题、正文、发布人和时间字段后再恢复列表与详情入口。",
action: "发布家族动态",
};
});
const shortcuts = [
{ key: "articles", label: "谱文" },
@@ -120,52 +56,25 @@ const shortcuts = [
{ key: "merits", label: "功德录" },
{ key: "videos", label: "家族视频" },
];
onLoad((query) => {
const hasRouteIdentity = Object.prototype.hasOwnProperty.call(query, "genealogyId");
const hasRouteIdentity = Object.prototype.hasOwnProperty.call(query || {}, "genealogyId");
const resolvedGenealogyId = hasRouteIdentity
? String(query.genealogyId || "")
? String(query?.genealogyId || "")
: String(genealogyContext.getCurrentGenealogyId() || "");
genealogyId.value = resolvedGenealogyId;
genealogy.value = findGenealogyFixture(resolvedGenealogyId);
const access = getGenealogyFixtureAccess(resolvedGenealogyId);
const isAccessible = Boolean(
genealogy.value && ["owner", "member"].includes(access.accessRole),
);
if (!isAccessible) {
feeds.value = [];
feedState.value = "error";
return;
}
if (!hasRouteIdentity) {
hasValidContext.value = /^[1-9]\d*$/.test(resolvedGenealogyId);
feedState.value = hasValidContext.value ? "unavailable" : "error";
if (!hasRouteIdentity && hasValidContext.value) {
goRoot("F01", { genealogyId: resolvedGenealogyId }).catch(() => {
hasValidContext.value = false;
feedState.value = "error";
});
return;
}
hasValidContext.value = isAccessible;
feeds.value = listFamilyFeedFixtures(resolvedGenealogyId);
feedState.value =
query.state === "loading"
? "loading"
: query.state === "empty"
? "empty"
: query.state === "error"
? "error"
: feeds.value.length
? "list"
: "empty";
});
const toPublish = () =>
hasValidContext.value
? openPage("F02", { genealogyId: genealogyId.value }, "F01")
: goRoot("G01");
const openDetail = (item) =>
openPage(
"F03",
{ genealogyId: genealogyId.value, feedId: String(item.id) },
"F01",
);
const toPublish = () => hasValidContext.value
? openPage("F02", { genealogyId: genealogyId.value }, "F01")
: goRoot("G01");
const openSection = (key) => {
const routes = {
articles: "F04",
@@ -179,147 +88,27 @@ const openSection = (key) => {
};
return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");
};
const handlePrimaryAction = () => {
if (!hasValidContext.value) return goRoot("G01");
if (feedState.value === "error") {
feeds.value = listFamilyFeedFixtures(genealogyId.value);
feedState.value = feeds.value.length ? "list" : "empty";
return;
}
return toPublish();
};
const handlePrimaryAction = () => hasValidContext.value ? toPublish() : goRoot("G01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.family-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.family-page__header,
.feed-content {
z-index: 1;
}
.feed-content {
flex: 1;
padding: 24rpx 24rpx 190rpx;
}
.feed-heading text {
display: block;
}
.feed-heading text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.feed-heading text:last-child {
margin-top: 6rpx;
color: $ink-muted;
font-size: 23rpx;
}
.feed-shortcuts {
@include adaptive.adaptive-scroll-button(secondary);
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
width: 100%;
min-height: 48px;
margin-top: 15rpx;
}
.feed-shortcut {
width: 100%;
height: 48px;
min-height: 44px;
}
.feed-shortcut text {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: $ink;
font-size: 22rpx;
font-weight: 700;
}
.feed-card,
.feed-state-card {
@include adaptive.adaptive-family-letter;
display: flex;
width: 100%;
min-height: 230rpx;
min-height: 98px;
flex-direction: column;
justify-content: center;
margin-top: 17rpx;
box-sizing: border-box;
}
.feed-card__copy {
padding: 15% 9%;
}
.feed-card text {
display: block;
}
.feed-card__meta {
color: #806a51;
font-size: 21rpx;
letter-spacing: 1rpx;
}
.feed-card__title {
margin-top: 5rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 30rpx;
font-weight: 700;
}
.feed-card__summary {
margin-top: 6rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.4;
}
.feed-card__author {
margin-top: 6rpx;
color: $brand-red;
font-size: 21rpx;
}
.feed-state-card {
margin-top: 70rpx;
}
.feed-state-card__copy {
padding: 23% 10%;
box-sizing: border-box;
text-align: center;
}
.feed-state-card text {
display: block;
}
.feed-state-card text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.feed-state-card text:last-child {
margin-top: 13rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.45;
}
.feed-action {
@include adaptive.adaptive-scroll-button(primary);
width: 514rpx;
max-width: 100%;
min-height: 76rpx;
margin: 19rpx auto 0;
}
.feed-action text {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #fff9ed;
font-size: 24rpx;
font-weight: 700;
}
.family-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.family-page__header, .feed-content { z-index: 1; }
.feed-content { flex: 1; padding: 24rpx 24rpx 190rpx; }
.feed-heading text { display: block; }
.feed-heading text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.feed-heading text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 23rpx; }
.feed-source-note { margin-top: 10rpx; padding: 12rpx 16rpx; border: 1rpx solid rgba(128, 106, 81, .22); border-radius: 10rpx; background: rgba(255, 255, 255, .56); }
.feed-source-note text { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
.feed-shortcuts { @include adaptive.adaptive-scroll-button(secondary); display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); width: 100%; min-height: 48px; margin-top: 15rpx; }
.feed-shortcut { width: 100%; height: 48px; min-height: 44px; }
.feed-shortcut text { display: flex; align-items: center; justify-content: center; height: 100%; color: $ink; font-size: 22rpx; font-weight: 700; }
.feed-state-card { @include adaptive.adaptive-family-letter; display: flex; width: 100%; min-height: 230rpx; flex-direction: column; justify-content: center; margin-top: 70rpx; box-sizing: border-box; }
.feed-state-card__copy { padding: 23% 10%; box-sizing: border-box; text-align: center; }
.feed-state-card text { display: block; }
.feed-state-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.feed-state-card text:last-child { margin-top: 13rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.45; }
.feed-action { @include adaptive.adaptive-scroll-button(primary); width: 514rpx; max-width: 100%; min-height: 76rpx; margin: 19rpx auto 0; }
.feed-action text { display: flex; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
</style>
+72 -167
View File
@@ -1,43 +1,23 @@
<!-- 页面编号F-02用途发布家族动态与提交结果 -->
<!-- 页面编号F-02用途 Apifox 已声明的 feedContent 发布家族动态 -->
<template>
<view
class="publish-page"
:class="{
'publish-state--form': publishState === 'form',
'publish-state--preview': publishState === 'preview',
'publish-state--error': publishState === 'error',
'publish-state--invalid': publishState === 'invalid',
}"
><ModulePageBackground module="family" /><view class="publish-page__header"
><PageHeader title="发布动态" custom-back @back="requestBack" /></view
><view class="publish-panel"
><view
v-if="publishState === 'form'"
class="publish-form"
><text>记录此刻</text
><text>分享通知活动家族故事或一段共同记忆</text
><view class="publish-field"
><textarea
v-model="content"
auto-height
maxlength="300"
placeholder="写下想对家人说的话"
placeholder-class="publish-placeholder"
/></view
><AppButton
block
:label="isSubmitting ? '正在校验' : '生成本地预览'"
:disabled="isSubmitting"
@click="submit"
/></view
><view v-else class="publish-result"
><text>{{ resultCopy.title }}</text
><text>{{ resultCopy.copy }}</text
><AppButton
block
:label="resultCopy.action"
@click="handleResultAction" /></view></view
><AppDialog
<view class="publish-page" :class="`publish-state--${publishState}`">
<ModulePageBackground module="family" />
<view class="publish-page__header"><PageHeader title="发布动态" custom-back @back="requestBack" /></view>
<view class="publish-panel">
<view v-if="publishState === 'form'" class="publish-form">
<text>记录此刻</text>
<text>当前接口只明确文本动态的 `feedContent` 请求字段媒体排序和状态均不由本页猜测或提交</text>
<view class="publish-field"><textarea v-model="content" auto-height maxlength="300" placeholder="写下想对家人说的话" placeholder-class="publish-placeholder" @input="submitError = ''" /></view>
<text v-if="submitError" class="publish-error">{{ submitError }}</text>
<AppButton block :label="isSubmitting ? '正在提交' : '提交动态'" :disabled="isSubmitting" @click="submit" />
</view>
<view v-else class="publish-result">
<text>{{ resultCopy.title }}</text>
<text>{{ resultCopy.copy }}</text>
<AppButton block :label="resultCopy.action" @click="handleResultAction" />
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃动态草稿?"
message="当前内容尚未提交服务器,确认返回后不会保留。"
@@ -48,46 +28,34 @@
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
><AppToast :visible="toastVisible" :message="toastMessage"
/></view>
</view>
</template>
<script setup>
import { computed, onUnmounted, ref } from "vue";
import { onBackPress, onLoad } 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 { getGenealogyFixtureAccess } from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const content = ref("");
const publishState = ref("form");
const isSubmitting = ref(false);
const submitError = ref("");
const discardVisible = ref(false);
const toastVisible = ref(false);
const toastMessage = ref("");
let toastTimer = null;
let submitTimer = null;
const requestController = createRequestController();
const isDirty = computed(() => Boolean(content.value.trim()));
const hasValidContext = computed(() =>
["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
),
);
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(() => ({
preview: {
title: "动态内容已完成本地预览",
copy: "当前尚未提交服务器,返回家族圈后不会出现这条动态。",
action: "返回家族圈(不发布)",
success: {
title: "动态已提交服务端",
copy: "服务端已返回成功信封。动态列表仍缺条目 DTO,返回后不会把本地内容伪装成列表项。",
action: "返回家族动态",
},
error: {
title: "动态未提交",
@@ -96,7 +64,7 @@ const resultCopy = computed(() => ({
},
invalid: {
title: "动态入口无效",
copy: "没有找到可发布内容的成员家谱,页面不会创建无归属动态。",
copy: "没有取得有效家谱标识,页面不会创建无归属动态。",
action: "返回上一页",
},
}[publishState.value]));
@@ -108,57 +76,45 @@ const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
if (!genealogyId.value || !hasValidContext.value) {
publishState.value = "invalid";
return;
}
if (["preview", "error"].includes(query.state)) {
content.value = "这是一段尚未提交服务器的家族动态预览。";
publishState.value = query.state;
}
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value) publishState.value = "invalid";
});
const showToast = (message) => {
toastMessage.value = message;
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
toastTimer = null;
}, 1800);
};
const submit = () => {
const submit = async () => {
if (isSubmitting.value || !hasValidContext.value) return;
if (!content.value.trim()) {
showToast("请先写下动态内容");
const feedContent = content.value.trim();
if (!feedContent) {
submitError.value = "请先写下动态内容";
return;
}
isSubmitting.value = true;
const submittedContent = content.value.trim();
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
submitError.value = "";
try {
await appApi.createFeed(genealogyId.value, { feedContent }, { requestController });
content.value = "";
publishState.value = "success";
} catch (error) {
if (isRequestCancelled(error)) return;
publishState.value = "error";
submitError.value = error?.message || "动态提交失败,请稍后重试。";
} finally {
isSubmitting.value = false;
publishState.value = submittedContent ? "preview" : "error";
}, 280);
submitTimer = timer;
}
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const returnToFamily = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return returnTo("F01", { genealogyId: genealogyId.value });
};
const handleResultAction = () => {
if (publishState.value === "preview") return returnToFamily();
if (publishState.value === "success") return returnToFamily();
if (publishState.value === "error") {
publishState.value = "form";
return;
@@ -167,75 +123,24 @@ const handleResultAction = () => {
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
if (submitTimer) clearTimeout(submitTimer);
requestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.publish-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.publish-page__header,
.publish-panel {
@include adaptive.adaptive-family-content;
z-index: 1;
}
.publish-panel {
width: calc(100% - 32rpx);
margin: 18rpx auto 0;
padding: 9%;
box-sizing: border-box;
}
.publish-form > text,
.publish-result > text {
display: block;
}
.publish-form > text:first-child,
.publish-result > text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
font-weight: 700;
}
.publish-form > text:nth-child(2),
.publish-result > text:nth-child(2) {
margin-top: 12rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.6;
}
.publish-field {
@include adaptive.adaptive-family-field;
display: flex;
min-height: 250rpx;
margin-top: 24rpx;
padding: 25rpx;
box-sizing: border-box;
}
.publish-field textarea {
width: 100%;
min-height: 200rpx;
color: $ink;
font-size: 24rpx;
line-height: 1.7;
}
.publish-placeholder {
color: #8e806e;
}
.publish-form > .app-button {
margin-top: 24rpx;
}
.publish-result {
margin-top: 20%;
text-align: center;
}
.publish-result > .app-button {
margin: 30rpx auto 0;
}
.publish-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.publish-page__header, .publish-panel { @include adaptive.adaptive-family-content; z-index: 1; }
.publish-panel { width: calc(100% - 32rpx); margin: 18rpx auto 0; padding: 9%; box-sizing: border-box; }
.publish-form > text, .publish-result > text { display: block; }
.publish-form > text:first-child, .publish-result > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.publish-form > text:nth-child(2), .publish-result > text:nth-child(2) { margin-top: 12rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
.publish-field { @include adaptive.adaptive-family-field; display: flex; min-height: 250rpx; margin-top: 24rpx; padding: 25rpx; box-sizing: border-box; }
.publish-field textarea { width: 100%; min-height: 200rpx; color: $ink; font-size: 24rpx; line-height: 1.7; }
.publish-placeholder { color: #8e806e; }
.publish-error { margin-top: 12rpx; color: $brand-red; font-size: 22rpx; line-height: 1.5; }
.publish-form > .app-button { margin-top: 24rpx; }
.publish-result { margin-top: 20%; text-align: center; }
.publish-result > .app-button { margin: 30rpx auto 0; }
</style>
+83 -58
View File
@@ -1,20 +1,19 @@
<!-- 页面编号F-03用途动态详情评论与内容失效状态 -->
<!-- 页面编号F-03用途动态详情与一级评论动态正文 DTO 未声明时不展示伪字段 -->
<template>
<view class="feed-detail-page" :class="{ 'feed-state--expired': feedState === 'expired', 'feed-state--error': feedState === 'error', 'feed-state--ready': feedState === 'ready' }">
<ModulePageBackground module="family" />
<view class="feed-detail-header"><PageHeader title="动态详情" custom-back @back="requestBack" /></view>
<view v-if="feedState === 'loading'" class="feed-detail-loading">
<AppLoading text="正在读取家族动态" description="请稍候,正在整理正文与家人评论。" />
<AppLoading text="正在读取家人评论" description="动态正文的展示字段尚未由后端合同声明。" />
</view>
<view v-else class="feed-detail-content">
<template v-if="feedState === 'ready'">
<view class="feed-source-note"><text>动态详情接口尚未声明标题正文发布人和发布时间字段本页不猜测这些字段只展示该动态的真实一级评论</text></view>
<view class="feed-article-card">
<text class="feed-article-card__meta">{{ currentFeed.tag }} · {{ currentFeed.time }}</text>
<text class="feed-article-card__title">{{ currentFeed.title }}</text>
<text class="feed-article-card__body">{{ currentFeed.content }}</text>
<text class="feed-article-card__author">发布人{{ currentFeed.author }}</text>
<text class="feed-article-card__title">动态正文待后端字段合同</text>
<text class="feed-article-card__body">已根据当前动态 ID 读取评论正文内容会在详情响应提供可消费 DTO 后再接入</text>
</view>
<view class="feed-comments-panel">
@@ -24,17 +23,19 @@
<view v-for="comment in feedComments" :key="comment.id" class="feed-comment-card">
<view><text>{{ comment.author }}</text><text>{{ comment.time }}</text></view>
<text>{{ comment.content }}</text>
<text v-if="comment.replyCount > 0" class="feed-comment-card__replies">直属回复 {{ comment.replyCount }} </text>
</view>
<view v-if="!feedComments.length" class="feed-comments-empty">
<text>还没有评论</text><text>写下第一句祝福或共同记忆</text>
<text>还没有一级评论</text><text>服务端返回评论后会显示在这里</text>
</view>
</view>
<view class="feed-comment-form" :class="{ 'comment-state--validating': commentState === 'validating', 'comment-state--preview': commentState === 'preview', 'comment-state--error': commentState === 'error' }">
<text>写下评论</text>
<textarea v-model="commentDraft" auto-height maxlength="240" placeholder="对家人说点什么" />
<view class="feed-comment-form" :class="{ 'comment-state--submitting': commentState === 'submitting', 'comment-state--error': commentState === 'error' }">
<text>提交一级评论</text>
<textarea v-model="commentDraft" auto-height maxlength="1000" placeholder="对家人说点什么" />
<text v-if="commentError" class="feed-comment-error">{{ commentError }}</text>
<AppButton block :disabled="commentState === 'validating'" :label="commentState === 'validating' ? '正在校验' : '生成评论预览'" @click="submitComment" />
<text v-else-if="commentNotice" class="feed-comment-notice">{{ commentNotice }}</text>
<AppButton block :disabled="commentState === 'submitting'" :label="commentState === 'submitting' ? '正在提交' : '提交评论'" @click="submitComment" />
</view>
</template>
@@ -47,7 +48,7 @@
<AppDialog
:visible="discardVisible"
title="放弃评论草稿?"
message="当前评论尚未提交服务,确认返回后不会保留。"
message="当前评论尚未提交服务,确认返回后不会保留。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
@@ -55,7 +56,6 @@
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="toastVisible" message="评论尚未提交服务器,草稿已保留" />
</view>
</template>
@@ -65,11 +65,10 @@ import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findFamilyFeedFixture } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import {
goBack,
handleBackPress,
@@ -79,29 +78,28 @@ import {
const genealogyId = ref("");
const feedId = ref("");
const currentFeed = ref(null);
const feedState = ref("loading");
const commentState = ref("idle");
const commentDraft = ref("");
const commentError = ref("");
const toastVisible = ref(false);
const commentNotice = ref("");
const discardVisible = ref(false);
const feedComments = ref([]);
let submitTimer = null;
let toastTimer = null;
const feedRequestController = createRequestController();
let loadSequence = 0;
const isDirty = computed(() => Boolean(commentDraft.value.trim()));
const stateCopy = computed(() => {
if (feedState.value === "error" && currentFeed.value) {
if (feedState.value === "error") {
return {
title: "动态暂不可用",
copy: "请稍后重新查看,已有家族记录不会受到影响。",
action: "重新查看",
title: "评论暂不可用",
copy: "评论读取没有得到可消费的服务端响应;页面不会用本地数据替代。",
action: "重新读取",
};
}
return {
title: "动态已失效或入口无效",
copy: "没有找到当前家谱中的这条动态,页面不会回退到其他记录。",
action: genealogyId.value ? "返回家族" : "返回上一页",
title: "动态入口无效",
copy: "没有取得当前家谱和动态标识,页面不会回退到其他动态。",
action: genealogyId.value ? "返回家族动态" : "返回上一页",
};
});
const discardConfirmation = createDiscardConfirmation((visible) => {
@@ -111,45 +109,69 @@ const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const loadComments = async () => {
if (!genealogyId.value || !feedId.value) {
feedState.value = "expired";
return false;
}
const sequence = ++loadSequence;
feedState.value = "loading";
try {
const comments = await appApi.getFeedComments(genealogyId.value, feedId.value, {
requestController: feedRequestController,
});
if (sequence !== loadSequence) return false;
feedComments.value = comments;
feedState.value = "ready";
return true;
} catch (error) {
if (isRequestCancelled(error) || sequence !== loadSequence) return false;
feedState.value = "error";
return false;
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
feedId.value = String(query.feedId || "");
const selected = findFamilyFeedFixture(genealogyId.value, feedId.value);
currentFeed.value = selected;
feedComments.value = selected?.comments || [];
feedState.value = ["loading", "error", "expired"].includes(query.state)
? selected
? query.state
: "expired"
: selected
? "ready"
: "expired";
loadComments();
});
const submitComment = () => {
if (commentState.value === "validating" || !currentFeed.value) return;
const submitComment = async () => {
if (commentState.value === "submitting" || feedState.value !== "ready") return;
const content = commentDraft.value.trim();
if (!content) {
commentError.value = "请先写下评论内容";
commentNotice.value = "";
return;
}
commentError.value = "";
commentState.value = "validating";
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
commentState.value = "preview";
toastVisible.value = true;
toastTimer = setTimeout(() => { toastVisible.value = false; }, 1800);
}, 320);
submitTimer = timer;
commentNotice.value = "";
commentState.value = "submitting";
try {
await appApi.createFeedComment(genealogyId.value, feedId.value, { commentContent: content }, {
requestController: feedRequestController,
});
commentDraft.value = "";
const refreshed = await loadComments();
if (!refreshed) {
commentState.value = "error";
commentError.value = "评论已提交,但列表刷新失败;请稍后重新查看。";
return;
}
commentState.value = "idle";
commentNotice.value = "评论已提交,并已从服务端刷新。";
} catch (error) {
if (isRequestCancelled(error)) return;
commentState.value = "error";
commentError.value = error?.message || "评论提交失败,请稍后重试。";
}
};
const restoreFeed = () => { feedState.value = "ready"; };
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: commentState.value === "validating",
submitting: commentState.value === "submitting",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
@@ -161,14 +183,14 @@ const backToFamily = async () => {
return returnTo("F01", { genealogyId: genealogyId.value });
};
const handleStateAction = () => {
if (feedState.value === "error" && currentFeed.value) return restoreFeed();
if (feedState.value === "error") return loadComments();
return backToFamily();
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (submitTimer) clearTimeout(submitTimer);
if (toastTimer) clearTimeout(toastTimer);
++loadSequence;
feedRequestController.abort();
discardConfirmation.dispose();
});
</script>
@@ -180,26 +202,29 @@ onUnmounted(() => {
.feed-detail-header, .feed-detail-loading, .feed-detail-content { z-index: 1; }
.feed-detail-loading { min-height: calc(100vh - 100rpx); }
.feed-detail-content { padding: 18rpx 24rpx 72rpx; }
.feed-source-note { margin-top: 12rpx; padding: 14rpx 18rpx; border: 1rpx solid rgba(128, 106, 81, .22); border-radius: 10rpx; background: rgba(255,255,255,.56); }
.feed-source-note text { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
.feed-article-card, .feed-comments-panel, .feed-comment-form, .feed-state-card { @include adaptive.adaptive-family-content; width: 100%; }
.feed-article-card { padding: 46rpx 48rpx 42rpx; }
.feed-article-card { padding: 42rpx 48rpx; }
.feed-article-card text, .feed-state-card > text { display: block; }
.feed-article-card__meta, .feed-article-card__author { color: $ink-muted; font-size: 22rpx; }
.feed-article-card__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.feed-article-card__body { margin-top: 18rpx; color: $ink; font-size: 25rpx; line-height: 1.75; }
.feed-article-card__author { margin-top: 22rpx; }
.feed-article-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
.feed-article-card__body { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.7; }
.feed-comments-panel { margin-top: 18rpx; padding: 38rpx 38rpx 34rpx; }
.feed-comments-heading { display: flex; align-items: center; justify-content: space-between; color: $brand-red; font-size: 24rpx; font-weight: 700; }
.feed-comments-heading text:last-child { color: $ink-muted; font-size: 21rpx; font-weight: 400; }
.feed-comment-card { @include adaptive.adaptive-family-field; margin-top: 16rpx; padding: 20rpx 22rpx; }
.feed-comment-card > view { display: flex; justify-content: space-between; gap: 18rpx; color: $ink-muted; font-size: 20rpx; }
.feed-comment-card > text { display: block; margin-top: 9rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
.feed-comment-card > .feed-comment-card__replies { color: $ink-muted; font-size: 20rpx; }
.feed-comments-empty { padding: 34rpx 10rpx 20rpx; text-align: center; }
.feed-comments-empty text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
.feed-comments-empty text:first-child { color: $ink; font-size: 28rpx; font-weight: 700; }
.feed-comment-form { margin-top: 18rpx; padding: 38rpx 40rpx 42rpx; }
.feed-comment-form > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.feed-comment-form textarea { @include adaptive.adaptive-family-field; width: auto; min-width: 0; min-height: 126rpx; margin-top: 16rpx; padding: 20rpx 22rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
.feed-comment-error { display: block; margin-top: 10rpx; color: $brand-red; font-size: 22rpx; }
.feed-comment-error, .feed-comment-notice { display: block; margin-top: 10rpx; font-size: 22rpx; }
.feed-comment-error { color: $brand-red; }
.feed-comment-notice { color: $ink-muted; }
.feed-comment-form .app-button { margin-top: 22rpx; }
.feed-state-card { min-height: 340rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.feed-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
+27 -132
View File
@@ -1,45 +1,13 @@
<!-- 页面编号F-04用途谱文分类搜索列表与新建入口 -->
<!-- 页面编号F-04用途谱文入口列表 item DTO 未声明时不展示本地文章 -->
<template>
<view class="article-list-page" :class="{ 'article-list-state--loading': listState === 'loading', 'article-list-state--empty': listState === 'empty', 'article-list-state--error': listState === 'error' }">
<view class="article-list-page" :class="`article-list-state--${listState}`">
<ModulePageBackground module="family" />
<view class="article-list-header"><PageHeader title="谱文" :action="hasValidContext ? '新建' : ''" @action="createArticle" /></view>
<view v-if="listState === 'loading'" class="article-list-loading">
<AppLoading text="正在整理家族谱文" description="请稍候,正在读取家训、往事与序言。" />
</view>
<view v-else class="article-list-content">
<template v-if="listState === 'ready'">
<view class="article-search">
<input v-model="keyword" placeholder="搜索标题、作者或正文" confirm-type="search" />
</view>
<view class="article-categories">
<view class="article-categories__row">
<view v-for="category in articleCategories" :key="category" class="article-category" :class="{ 'article-category--active': activeCategory === category }" @click="activeCategory = category"><text>{{ category }}</text></view>
</view>
</view>
<view v-if="filteredArticles.length" class="article-list">
<view v-for="article in filteredArticles" :key="article.id" class="article-card" role="button" :aria-label="`查看谱文${article.title}`" @click="openArticle(article)">
<text class="article-card__category">{{ article.category }}</text>
<text class="article-card__title">{{ article.title }}</text>
<text class="article-card__summary">{{ article.summary }}</text>
<view class="article-card__meta"><text>{{ article.author }}</text><text>{{ article.updatedAt }}</text></view>
</view>
<AppButton block label="新建谱文" @click="createArticle" />
</view>
<view v-else class="article-list-state-card">
<text>{{ keyword || activeCategory !== '全部' ? '没有找到相关谱文' : '还没有谱文' }}</text>
<text>{{ keyword || activeCategory !== '全部' ? '换一个关键词或分类继续查找。' : '从第一篇家训、序言或家族往事开始记录。' }}</text>
<AppButton block :label="keyword || activeCategory !== '全部' ? '清空筛选' : '新建谱文'" @click="keyword || activeCategory !== '全部' ? resetFilters() : createArticle()" />
</view>
</template>
<view v-else class="article-list-state-card">
<view class="article-list-content">
<view class="article-list-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="listState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
</view>
@@ -49,115 +17,42 @@
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
listFamilyArticleFixtures,
} from "@/data/mock.js";
import { goBack, openPage } from "@/utils/navigation.js";
const articleCategories = ["全部", "家风家训", "家族往事", "族谱序言"];
const genealogyId = ref("");
const hasValidContext = ref(false);
const articles = ref([]);
const activeCategory = ref("全部");
const keyword = ref("");
const listState = ref("loading");
const filteredArticles = computed(() => {
const term = keyword.value.trim().toLowerCase();
return articles.value.filter((article) => {
const categoryMatched = activeCategory.value === "全部" || article.category === activeCategory.value;
const keywordMatched = !term || `${article.title} ${article.summary} ${article.author}`.toLowerCase().includes(term);
return categoryMatched && keywordMatched;
});
});
const stateCopy = computed(() => ({
empty: {
title: "还没有谱文",
copy: "当前家谱尚无可读取谱文,真实写接口接入后才能新增。",
action: "填写谱文预览",
},
error: {
title: "谱文列表暂不可用",
copy: "请稍后重新查看,已有谱文不会受到影响。",
action: "重新查看",
},
invalid: {
title: "谱文入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱内容。",
action: "返回上一页",
},
}[listState.value]));
const listState = ref("unavailable");
const stateCopy = computed(() => hasValidContext.value
? {
title: "谱文列表待后端字段合同",
copy: "列表接口只声明通用对象数组,没有文章 ID、分类、标题、摘要、作者或更新时间字段;页面已停止展示本地文章和本地筛选。",
action: "新建谱文",
}
: {
title: "谱文入口无效",
copy: "没有取得有效家谱标识,页面不会展示其他家谱内容。",
action: "返回上一页",
},
);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
hasValidContext.value = ["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
);
if (!genealogyId.value || !hasValidContext.value) {
listState.value = "invalid";
return;
}
articles.value = listFamilyArticleFixtures(genealogyId.value);
listState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: articles.value.length
? "ready"
: "empty";
genealogyId.value = String(query?.genealogyId || "");
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
listState.value = hasValidContext.value ? "unavailable" : "invalid";
});
const openArticle = (article) =>
openPage(
"F05",
{ genealogyId: genealogyId.value, articleId: String(article.id) },
"F04",
);
const createArticle = () =>
hasValidContext.value
? openPage(
"F06",
{ genealogyId: genealogyId.value, mode: "create" },
"F04",
)
: Promise.resolve(false);
const restoreArticles = () => {
articles.value = listFamilyArticleFixtures(genealogyId.value);
listState.value = articles.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (listState.value === "invalid") return goBack();
if (listState.value === "error") return restoreArticles();
return createArticle();
};
const resetFilters = () => { keyword.value = ""; activeCategory.value = "全部"; };
const createArticle = () => hasValidContext.value
? openPage("F06", { genealogyId: genealogyId.value, mode: "create" }, "F04")
: Promise.resolve(false);
const handleStateAction = () => hasValidContext.value ? createArticle() : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.article-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.article-list-header, .article-list-loading, .article-list-content { z-index: 1; }
.article-list-loading { min-height: calc(100vh - 100rpx); }
.article-list-header, .article-list-content { z-index: 1; }
.article-list-content { padding: 18rpx 24rpx 72rpx; }
.article-search { @include adaptive.adaptive-family-field; min-height: 82rpx; padding: 12rpx 26rpx; }
.article-search input { width: 100%; min-height: 58rpx; color: $ink; font-size: 24rpx; }
.article-categories { width: 100%; margin-top: 16rpx; }
.article-categories__row { display: flex; flex-wrap: wrap; gap: 12rpx; padding: 2rpx 4rpx 8rpx; }
.article-category { @include adaptive.adaptive-family-field; min-height: 58rpx; padding: 0 28rpx; color: $ink-muted; font-size: 23rpx; }
.article-category text { display: flex; min-height: 58rpx; align-items: center; }
.article-category--active { color: $brand-red; font-weight: 700; }
.article-list { display: flex; flex-direction: column; gap: 16rpx; margin-top: 10rpx; }
.article-card, .article-list-state-card { @include adaptive.adaptive-family-content; width: 100%; }
.article-card { min-height: 196rpx; padding: 34rpx 46rpx 30rpx; }
.article-card > text { display: block; }
.article-card__category { color: $brand-red; font-size: 21rpx; font-weight: 700; letter-spacing: 2rpx; }
.article-card__title { margin-top: 7rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.article-card__summary { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }
.article-card__meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6rpx 18rpx; margin-top: 12rpx; color: $ink-muted; font-size: 20rpx; }
.article-list > .app-button { margin-top: 8rpx; }
.article-list-state-card { min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.article-list-state-card { @include adaptive.adaptive-family-content; width: 100%; min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.article-list-state-card > text { display: block; }
.article-list-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.article-list-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
+28 -92
View File
@@ -1,33 +1,13 @@
<!-- 页面编号F-05用途谱文正文收藏编辑与受控状态 -->
<!-- 页面编号F-05用途谱文详情详情 DTO 未声明时不展示本地正文 -->
<template>
<view class="article-detail-page" :class="articleStateClasses">
<view class="article-detail-page">
<ModulePageBackground module="family" />
<view class="article-detail-header"><PageHeader title="谱文详情" /></view>
<view v-if="articleState === 'loading'" class="article-detail-loading">
<AppLoading text="正在读取谱文" description="请稍候,正在整理正文与收录信息。" />
</view>
<view v-else class="article-detail-content">
<template v-if="articleState === 'ready'">
<view class="article-paper">
<text class="article-paper__category">{{ article.category }}</text>
<text class="article-paper__title">{{ article.title }}</text>
<view class="article-paper__meta"><text>{{ article.author }}</text><text>{{ article.updatedAt }}</text></view>
<view class="article-paper__body">
<text v-for="(paragraph, index) in articleParagraphs" :key="index">{{ paragraph }}</text>
</view>
</view>
<view class="article-actions">
<AppButton block disabled label="收藏暂未开放" />
<AppButton type="secondary" block label="编辑谱文" @click="editArticle" />
</view>
</template>
<view v-else class="article-state-card">
<view class="article-detail-header"><PageHeader title="谱文详情" custom-back @back="backToArticles" /></view>
<view class="article-detail-content">
<view class="article-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="articleState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
<AppButton block :label="stateCopy.action" @click="backToArticles" />
</view>
</view>
</view>
@@ -37,86 +17,42 @@
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findFamilyArticleFixture } from "@/data/mock.js";
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const articleId = ref("");
const article = ref(null);
const articleState = ref("loading");
const articleParagraphs = computed(() => article.value?.paragraphs || []);
const articleStateClasses = computed(() => ({
[`article-state--${articleState.value}`]: true,
"article-state--expired": articleState.value === "expired",
"article-state--privacy": articleState.value === "privacy",
"article-state--error": articleState.value === "error",
}));
const stateCopy = computed(() => ({
expired: { title: "这篇谱文已无法查看", copy: "当前家谱中不存在这篇谱文,页面不会回退到其他文章。", action: genealogyId.value ? "返回谱文列表" : "返回上一页" },
privacy: { title: "这篇谱文暂未公开", copy: "作者仅向有权限的家人开放正文,请返回谱文列表查看其他内容。", action: "返回谱文列表" },
error: { title: "谱文暂不可用", copy: "请稍后重新查看,已有谱文不会受到影响。", action: "重新查看" },
}[articleState.value] || {}));
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
articleId.value = String(query.articleId || "");
const selected = findFamilyArticleFixture(genealogyId.value, articleId.value);
article.value = selected;
articleState.value = ["loading", "error", "expired", "privacy"].includes(query.state)
? selected
? query.state
: "expired"
: selected
? "ready"
: "expired";
});
const editArticle = () =>
openPage(
"F06",
{
genealogyId: genealogyId.value,
mode: "edit",
articleId: articleId.value,
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value));
const stateCopy = computed(() => hasValidContext.value
? {
title: "谱文正文待后端字段合同",
copy: "详情接口没有声明文章标题、分类、作者、更新时间或正文段落字段;页面已停止读取本地文章,不能猜测这些字段。",
action: "返回谱文列表",
}
: {
title: "谱文入口无效",
copy: "没有取得有效家谱或文章标识,页面不会回退到其他文章。",
action: "返回上一页",
},
"F05",
);
const backToArticles = () =>
genealogyId.value
? returnTo("F04", { genealogyId: genealogyId.value })
: goBack();
const handleStateAction = () => {
if (articleState.value === "error" && article.value) {
articleState.value = "ready";
return;
}
return backToArticles();
};
);
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
articleId.value = String(query?.articleId || "");
});
const backToArticles = () => /^[1-9]\d*$/.test(genealogyId.value)
? returnTo("F04", { genealogyId: genealogyId.value })
: goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.article-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.article-detail-header, .article-detail-loading, .article-detail-content { z-index: 1; }
.article-detail-loading { min-height: calc(100vh - 100rpx); }
.article-detail-header, .article-detail-content { z-index: 1; }
.article-detail-content { padding: 18rpx 24rpx 72rpx; }
.article-paper, .article-state-card { @include adaptive.adaptive-family-content; width: 100%; }
.article-paper { padding: 50rpx 48rpx 54rpx; }
.article-paper > text { display: block; }
.article-paper__category { color: $brand-red; font-size: 22rpx; font-weight: 700; letter-spacing: 3rpx; }
.article-paper__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 39rpx; font-weight: 700; line-height: 1.35; }
.article-paper__meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6rpx 18rpx; margin-top: 16rpx; color: $ink-muted; font-size: 21rpx; }
.article-paper__body { margin-top: 28rpx; }
.article-paper__body text { display: block; margin-top: 18rpx; color: $ink; font-size: 25rpx; line-height: 1.85; text-align: justify; }
.article-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14rpx; margin-top: 18rpx; }
.article-state-card { min-height: 350rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.article-state-card { @include adaptive.adaptive-family-content; width: 100%; min-height: 350rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.article-state-card > text { display: block; }
.article-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.article-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.article-state-card .app-button { margin-top: 30rpx; }
@media (max-width: 340px) { .article-actions { grid-template-columns: minmax(0, 1fr); } }
</style>
+97 -419
View File
@@ -1,26 +1,27 @@
<!-- 页面编号F-06用途新建与编辑谱文 -->
<!-- 页面编号F-06用途按已声明 articleTitle/articleContent 创建谱文 -->
<template>
<view
class="article-editor-page"
:class="`article-editor-state--${editorState}`"
>
<view class="article-editor-page" :class="`article-editor-state--${editorState}`">
<ModulePageBackground module="family" />
<view class="article-editor-page__header">
<PageHeader title="编辑谱文" custom-back @back="requestBack" />
</view>
<view v-if="editorState === 'loading'" class="article-editor-loading">
<AppLoading
text="正在打开谱文编辑器"
description="请稍候,草稿正在展开。"
/>
</view>
<view
v-else-if="editorState === 'preview' || editorState === 'invalid'"
class="article-editor-content"
>
<view class="editor-result-card">
<view class="article-editor-page__header"><PageHeader title="新建谱文" custom-back @back="requestBack" /></view>
<view class="article-editor-content">
<view v-if="editorState === 'form'" class="editor-panel">
<view class="editor-panel__body">
<text class="editor-eyebrow">服务端创建</text>
<text class="editor-title">把值得传承的故事写下来</text>
<text class="editor-intro">本页只发送 Apifox 已声明且可映射的文章标题与正文分类需要 `categoryId`当前没有可用分类 owner故不展示或猜测分类</text>
<view class="editor-field">
<text class="editor-field__label">文章标题</text>
<view class="editor-control"><input v-model="form.title" maxlength="40" placeholder="请输入文章标题" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
</view>
<view class="editor-field">
<text class="editor-field__label">正文内容</text>
<view class="editor-control editor-control--textarea"><textarea v-model="form.content" auto-height maxlength="1200" placeholder="记录家训、往事或想留给后人的话" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
</view>
<text v-if="submitError" class="editor-save-error">{{ submitError }}</text>
<AppButton block :label="isSubmitting ? '正在提交' : '提交谱文'" :disabled="isSubmitting" @click="submit" />
</view>
</view>
<view v-else class="editor-result-card">
<view class="editor-result-card__body">
<text class="editor-eyebrow">{{ resultCopy.eyebrow }}</text>
<text class="editor-result-card__title">{{ resultCopy.title }}</text>
@@ -29,89 +30,6 @@
</view>
</view>
</view>
<view v-else class="article-editor-content">
<view class="editor-panel">
<view class="editor-panel__body">
<text class="editor-eyebrow">草稿 · 未发布</text>
<text class="editor-title">把值得传承的故事写下来</text>
<text class="editor-intro"
>补充标题分类和正文当前只校验并生成本地预览</text
>
<view class="editor-field">
<text class="editor-field__label">文章标题</text>
<view
class="editor-control"
:class="{ 'editor-control--error': fieldErrors.title }"
>
<input
v-model="form.title"
maxlength="40"
placeholder="请输入文章标题"
placeholder-class="editor-placeholder"
@input="clearFieldError('title')"
/>
</view>
<text v-if="fieldErrors.title" class="editor-field__error">{{
fieldErrors.title
}}</text>
</view>
<view class="editor-field">
<text class="editor-field__label">文章分类</text>
<view
class="editor-control"
:class="{ 'editor-control--error': fieldErrors.category }"
>
<input
v-model="form.category"
maxlength="20"
placeholder="如:家风家训"
placeholder-class="editor-placeholder"
@input="clearFieldError('category')"
/>
</view>
<text v-if="fieldErrors.category" class="editor-field__error">{{
fieldErrors.category
}}</text>
</view>
<view class="editor-field">
<text class="editor-field__label">正文内容</text>
<view
class="editor-control editor-control--textarea"
:class="{ 'editor-control--error': fieldErrors.content }"
>
<textarea
v-model="form.content"
auto-height
maxlength="1200"
placeholder="记录家训、往事或想留给后人的话"
placeholder-class="editor-placeholder"
@input="clearFieldError('content')"
/>
</view>
<text v-if="fieldErrors.content" class="editor-field__error">{{
fieldErrors.content
}}</text>
</view>
<text class="editor-draft-note"
>当前为未发布草稿真实草稿保存将在接口阶段接入</text
>
<text v-if="editorState === 'error'" class="editor-save-error"
>保存失败已填写内容仍保留请稍后重试</text
>
<AppButton
block
:label="actionLabel"
:disabled="isSubmitting"
@click="submit"
/>
</view>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
@@ -127,87 +45,38 @@
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } 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";
import {
findFamilyArticleFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const draftFixture = {
title: "汤氏家训辑录",
category: "家风家训",
content:
"孝友传家,勤俭立业;敬祖睦宗,诚实待人。愿后人常怀感恩,彼此扶持。",
};
const editorState = ref("form");
const genealogyId = ref("");
const articleId = ref("");
const editorMode = ref("create");
const isSubmitting = ref(false);
const discardVisible = ref(false);
const allowedStates = new Set([
"draft",
"loading",
"validation",
"error",
"preview",
]);
const form = reactive({ title: "", category: "", content: "" });
const fieldErrors = reactive({ title: "", category: "", content: "" });
const baseline = ref("");
let saveTimer = null;
const actionLabel = computed(() =>
isSubmitting.value
? "正在校验…"
: editorState.value === "error"
? "重新校验"
: "生成本地预览",
);
const formSnapshot = computed(() => JSON.stringify(form));
const isDirty = computed(() =>
Boolean(baseline.value) && formSnapshot.value !== baseline.value,
);
const hasValidContext = computed(() =>
Boolean(
genealogyId.value &&
["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
) &&
(editorMode.value === "create" ||
(editorMode.value === "edit" && articleId.value)),
),
);
const resultCopy = computed(() =>
editorState.value === "preview"
? {
eyebrow: "本地流程预览",
title: "谱文内容已通过本地校验",
copy: "当前尚未提交服务器,返回后不会新增或修改谱文。",
action:
editorMode.value === "edit"
? "返回原谱文(不保存)"
: "返回谱文列表(不保存)",
}
: {
eyebrow: "谱文入口无效",
title: "没有找到要编辑的谱文上下文",
copy: "请从当前家谱的谱文列表重新进入,页面不会创建无归属内容。",
action: "返回上一页",
},
const submitError = ref("");
const form = reactive({ title: "", content: "" });
const requestController = createRequestController();
const isDirty = computed(() => Boolean(form.title.trim() || form.content.trim()));
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(() => editorState.value === "success"
? {
eyebrow: "服务端已接受",
title: "谱文已提交",
copy: "服务端已返回成功信封。谱文列表与详情仍缺展示 DTO,返回后不会生成本地文章卡片。",
action: "返回谱文列表",
}
: {
eyebrow: "谱文入口无效",
title: "无法创建谱文",
copy: "当前只有新建操作可映射;编辑需要可靠的文章详情和文章 ID,暂不从 fixture 进入。",
action: "返回上一页",
},
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
@@ -216,263 +85,72 @@ const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const fillDraft = () => {
Object.assign(form, draftFixture);
};
const showAllFieldErrors = () => {
fieldErrors.title = "请填写文章标题";
fieldErrors.category = "请填写文章分类";
fieldErrors.content = "请填写正文内容";
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
articleId.value = String(query.articleId || "");
editorMode.value = String(query.mode || "");
const article =
editorMode.value === "edit"
? findFamilyArticleFixture(genealogyId.value, articleId.value)
: null;
const modeIsValid =
(editorMode.value === "create" && !articleId.value) ||
(editorMode.value === "edit" && Boolean(article));
if (!modeIsValid || !hasValidContext.value) {
editorState.value = "invalid";
return;
}
const requestedState = allowedStates.has(query.state) ? query.state : "form";
if (article) {
Object.assign(form, {
title: article.title,
category: article.category,
content: article.paragraphs.join("\n\n"),
});
} else if (["draft", "error", "preview"].includes(requestedState)) {
fillDraft();
}
baseline.value = formSnapshot.value;
if (editorMode.value === "create" && requestedState === "draft") {
baseline.value = JSON.stringify({ title: "", category: "", content: "" });
}
if (requestedState === "validation") showAllFieldErrors();
editorState.value = requestedState;
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
});
const clearFieldError = (field) => {
fieldErrors[field] = "";
if (
editorState.value === "error" ||
editorState.value === "validation"
) {
editorState.value = "form";
}
};
const validate = () => {
fieldErrors.title = form.title.trim() ? "" : "请填写文章标题";
fieldErrors.category = form.category.trim() ? "" : "请填写文章分类";
fieldErrors.content = form.content.trim() ? "" : "请填写正文内容";
return !fieldErrors.title && !fieldErrors.category && !fieldErrors.content;
};
const submit = () => {
const submit = async () => {
if (isSubmitting.value || !hasValidContext.value) return;
if (!validate()) {
editorState.value = "validation";
const articleTitle = form.title.trim();
const articleContent = form.content.trim();
if (!articleTitle || !articleContent) {
submitError.value = !articleTitle ? "请填写文章标题" : "请填写正文内容";
return;
}
isSubmitting.value = true;
const submitSnapshot = formSnapshot.value;
const timer = setTimeout(() => {
if (saveTimer !== timer) return;
editorState.value = submitSnapshot ? "preview" : "error";
submitError.value = "";
try {
await appApi.createArticle(genealogyId.value, { articleTitle, articleContent }, { requestController });
form.title = "";
form.content = "";
editorState.value = "success";
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "谱文提交失败,请稍后重试。";
} finally {
isSubmitting.value = false;
saveTimer = null;
}, 320);
saveTimer = timer;
}
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const returnToTarget = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return editorMode.value === "edit"
? returnTo("F05", {
genealogyId: genealogyId.value,
articleId: articleId.value,
})
: returnTo("F04", { genealogyId: genealogyId.value });
};
const handleResultAction = () =>
editorState.value === "preview" ? returnToTarget() : goBack();
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const handleResultAction = () => editorState.value === "success"
? returnTo("F04", { genealogyId: genealogyId.value })
: goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
if (saveTimer) clearTimeout(saveTimer);
onUnmounted(() => {
requestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.article-editor-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.article-editor-page__header,
.article-editor-loading,
.article-editor-content {
z-index: 1;
}
.article-editor-loading {
padding-top: 150rpx;
}
.article-editor-content {
padding: 18rpx 20rpx calc(38rpx + env(safe-area-inset-bottom));
}
.editor-panel,
.editor-result-card {
@include adaptive.adaptive-family-panel;
width: 100%;
}
.editor-panel__body {
padding: 38rpx 34rpx 42rpx;
}
.editor-eyebrow {
display: block;
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
text-align: center;
}
.editor-title {
display: block;
margin-top: 10rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 34rpx;
font-weight: 700;
text-align: center;
}
.editor-intro {
display: block;
margin: 10rpx 8rpx 22rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.55;
text-align: center;
}
.editor-field {
margin-top: 18rpx;
}
.editor-field__label {
display: block;
margin: 0 8rpx 8rpx;
color: $ink;
font-size: 25rpx;
font-weight: 700;
}
.editor-control {
@include adaptive.adaptive-family-field;
display: flex;
min-height: 82rpx;
align-items: center;
}
.editor-control input,
.editor-control textarea {
box-sizing: border-box;
width: 100%;
color: $ink;
font-size: 24rpx;
}
.editor-control input {
width: 100%;
min-height: 82rpx;
padding: 0 28rpx;
}
.editor-control--textarea {
min-height: 220rpx;
padding: 20rpx 24rpx;
box-sizing: border-box;
}
.editor-control textarea {
width: 100%;
min-height: 180rpx;
line-height: 1.65;
}
.editor-control--error {
filter: sepia(0.18) saturate(1.2);
}
.editor-placeholder {
color: #9e8e79;
}
.editor-field__error,
.editor-save-error {
display: block;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
}
.editor-field__error {
margin: 5rpx 8rpx 0;
}
.editor-draft-note {
display: block;
margin: 20rpx 10rpx 0;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.55;
text-align: center;
}
.editor-save-error {
margin: 12rpx 8rpx 0;
text-align: center;
}
.editor-panel__body > .app-button {
margin-top: 22rpx;
}
.editor-result-card {
min-height: 420rpx;
}
.editor-result-card__body {
padding: 112rpx 52rpx 68rpx;
text-align: center;
}
.editor-result-card__title {
display: block;
margin-top: 14rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
font-weight: 700;
}
.editor-result-card__copy {
display: block;
margin-top: 16rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.editor-result-card__body > .app-button {
margin-top: 30rpx;
}
@media (max-width: 340px) {
.article-editor-content {
padding-right: 14rpx;
padding-left: 14rpx;
}
.editor-panel__body {
padding-right: 26rpx;
padding-left: 26rpx;
}
}
.article-editor-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.article-editor-page__header, .article-editor-content { z-index: 1; }
.article-editor-content { padding: 18rpx 20rpx calc(38rpx + env(safe-area-inset-bottom)); }
.editor-panel, .editor-result-card { @include adaptive.adaptive-family-panel; width: 100%; }
.editor-panel__body { padding: 38rpx 34rpx 42rpx; }
.editor-eyebrow { display: block; color: $brand-red; font-size: 23rpx; letter-spacing: 3rpx; text-align: center; }
.editor-title { display: block; margin-top: 10rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 34rpx; font-weight: 700; text-align: center; }
.editor-intro { display: block; margin: 10rpx 8rpx 22rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; text-align: center; }
.editor-field { margin-top: 18rpx; }
.editor-field__label { display: block; margin: 0 8rpx 8rpx; color: $ink; font-size: 25rpx; font-weight: 700; }
.editor-control { @include adaptive.adaptive-family-field; display: flex; min-height: 82rpx; align-items: center; }
.editor-control input, .editor-control textarea { box-sizing: border-box; width: 100%; color: $ink; font-size: 24rpx; }
.editor-control input { min-height: 82rpx; padding: 0 28rpx; }
.editor-control--textarea { min-height: 220rpx; padding: 20rpx 24rpx; }
.editor-control textarea { min-height: 180rpx; line-height: 1.65; }
.editor-placeholder { color: #9e8e79; }
.editor-save-error { display: block; margin: 12rpx 8rpx 0; color: $brand-red; font-size: 24rpx; line-height: 34rpx; text-align: center; }
.editor-panel__body > .app-button { margin-top: 22rpx; }
.editor-result-card { min-height: 420rpx; }
.editor-result-card__body { padding: 112rpx 52rpx 68rpx; text-align: center; }
.editor-result-card__title { display: block; margin-top: 14rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.editor-result-card__copy { display: block; margin-top: 16rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.editor-result-card__body > .app-button { margin-top: 30rpx; }
</style>
+58 -188
View File
@@ -1,63 +1,22 @@
<!-- 页面编号F-07用途家族相册列表创建与受控状态 -->
<!-- 页面编号F-07用途相册入口列表 DTO 缺失时不展示本地相册 -->
<template>
<view class="album-list-page" :class="albumStateClasses">
<view class="album-list-page">
<ModulePageBackground module="family" />
<view class="album-list-header"><PageHeader title="家族相册" :action="hasValidContext ? '新建' : ''" custom-back @back="requestBack" @action="createAlbum" /></view>
<view v-if="albumState === 'loading'" class="album-list-loading">
<AppLoading text="正在整理家族相册" description="请稍候,正在读取照片与更新时间。" />
</view>
<view v-else class="album-list-content">
<template v-if="albumState === 'ready'">
<view class="album-list-lead"><text>让每一张照片都回到家人身边</text><text> {{ albums.length }} 本相册</text></view>
<view v-if="localAlbumPreview" class="album-local-preview">
<text>本地预览 · 尚未提交服务器</text>
<text>{{ localAlbumPreview.name }}</text>
<text>这本相册不会加入正式列表离开页面后不保存</text>
</view>
<view v-if="albums.length" class="album-list">
<view v-for="album in albums" :key="album.id" class="album-card" role="button" :aria-label="`打开相册${album.name}`" @click="openAlbum(album)">
<view class="album-card__media"><image class="album-card__cover" :src="album.cover" mode="aspectFill" :alt="album.name" /></view>
<view class="album-card__copy">
<text>{{ album.name }}</text>
<text>{{ album.photoCount }} 张照片 · {{ album.updatedAt }}</text>
<text>{{ album.description }}</text>
</view>
</view>
<AppButton block label="新建相册" @click="createAlbum" />
</view>
<view v-else class="album-state-card">
<text>还没有相册</text><text>创建一本相册把团圆成长与祖居旧影整理在一起</text>
<AppButton block label="新建相册" @click="createAlbum" />
</view>
</template>
<view v-else class="album-state-card">
<view class="album-list-header"><PageHeader title="家族相册" :action="hasValidContext ? '新建' : ''" custom-back @back="requestBack" @action="openCreateDialog" /></view>
<view class="album-list-content">
<view class="album-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="albumState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
<AppDialog :visible="dialogVisible" eyebrow="新建相册预览" title="为家人整理一段影像" message="当前只生成本地预览,不会创建服务器相册。" confirm-text="生成本地预览" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="confirmCreateAlbum" @cancel="requestCloseCreateDialog">
<AppDialog :visible="dialogVisible" eyebrow="新建相册" title="为家人整理一段影像" message="仅提交已声明的相册名称;封面、排序和状态没有可用 owner,不会猜测提交。" :confirm-text="isSubmitting ? '正在提交' : '提交相册'" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="submitAlbum" @cancel="closeCreateDialog">
<view class="album-dialog-field">
<text>相册名称</text>
<input v-model="albumNameDraft" maxlength="30" placeholder="例如:春节团圆" />
<text v-if="albumNameError">{{ albumNameError }}</text>
<input v-model="albumNameDraft" maxlength="30" placeholder="例如:春节团圆" @input="submitError = ''" />
<text v-if="submitError">{{ submitError }}</text>
</view>
</AppDialog>
<AppDialog
:visible="discardVisible"
title="放弃相册草稿?"
message="当前相册尚未提交服务器,确认后不会保留。"
confirm-text="放弃"
cancel-text="继续整理"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
@@ -66,162 +25,74 @@ import { computed, onUnmounted, ref } from "vue";
import { onBackPress, onLoad } 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";
import {
getGenealogyFixtureAccess,
listFamilyAlbumFixtures,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
openPage,
runBackGuard,
} from "@/utils/navigation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const hasValidContext = ref(false);
const albums = ref([]);
const albumState = ref("loading");
const dialogVisible = ref(false);
const albumNameDraft = ref("");
const albumNameError = ref("");
const localAlbumPreview = ref(null);
const discardVisible = ref(false);
const albumStateClasses = computed(() => ({
[`album-list-state--${albumState.value}`]: true,
"album-state--empty": albumState.value === "empty",
"album-list-state--loading": albumState.value === "loading",
"album-list-state--error": albumState.value === "error",
}));
const isDirty = computed(() =>
Boolean(albumNameDraft.value.trim() || localAlbumPreview.value),
const submitError = ref("");
const isSubmitting = ref(false);
const created = ref(false);
const requestController = createRequestController();
const stateCopy = computed(() => !hasValidContext.value
? { title: "相册入口无效", copy: "没有取得有效家谱标识,页面不会展示其他家谱相册。", action: "返回上一页" }
: created.value
? { title: "相册已提交服务端", copy: "服务端已返回成功信封;相册列表仍没有条目 DTO,页面不会生成本地相册卡片。", action: "新建相册" }
: { title: "相册列表待后端字段合同", copy: "列表响应没有声明相册 ID、封面、名称、照片数、描述或更新时间字段;页面已停止展示本地相册。", action: "新建相册" },
);
const stateCopy = computed(() => ({
empty: {
title: "还没有相册",
copy: "当前家谱尚无可读取相册,可先生成不会保存的本地预览。",
action: "填写相册预览",
},
error: {
title: "相册暂不可用",
copy: "请稍后重新查看,已有照片不会受到影响。",
action: "重新查看",
},
invalid: {
title: "相册入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱相册。",
action: "返回上一页",
},
}[albumState.value]));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
hasValidContext.value = ["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
);
if (!genealogyId.value || !hasValidContext.value) {
albumState.value = "invalid";
genealogyId.value = String(query?.genealogyId || "");
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
});
const openCreateDialog = () => {
if (!hasValidContext.value || isSubmitting.value) return;
albumNameDraft.value = "";
submitError.value = "";
dialogVisible.value = true;
};
const closeCreateDialog = () => {
if (!isSubmitting.value) dialogVisible.value = false;
};
const submitAlbum = async () => {
if (isSubmitting.value) return;
const albumName = albumNameDraft.value.trim();
if (!albumName) {
submitError.value = "请填写相册名称";
return;
}
albums.value = listFamilyAlbumFixtures(genealogyId.value);
albumState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: albums.value.length
? "ready"
: "empty";
isSubmitting.value = true;
submitError.value = "";
try {
await appApi.createAlbum(genealogyId.value, { albumName }, { requestController });
dialogVisible.value = false;
created.value = true;
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "相册提交失败,请稍后重试。";
} finally {
isSubmitting.value = false;
}
};
const requestBack = () => runBackGuard({
transientOpen: dialogVisible.value,
submitting: isSubmitting.value,
"close-transient": closeCreateDialog,
"block-submitting": () => true,
});
const openAlbum = (album) =>
openPage(
"F08",
{ genealogyId: genealogyId.value, albumId: String(album.id) },
"F07",
);
const createAlbum = () => { albumNameDraft.value = ""; albumNameError.value = ""; dialogVisible.value = true; };
const closeCreateDialog = () => { dialogVisible.value = false; };
const requestCloseCreateDialog = async () => {
if (!albumNameDraft.value.trim()) {
closeCreateDialog();
return true;
}
const confirmed = await requestDiscardConfirmation();
if (!confirmed) return false;
albumNameDraft.value = "";
albumNameError.value = "";
closeCreateDialog();
return true;
};
const confirmCreateAlbum = () => {
const name = albumNameDraft.value.trim();
if (!name) { albumNameError.value = "请填写相册名称"; return; }
localAlbumPreview.value = Object.freeze({ name });
albumNameDraft.value = "";
dialogVisible.value = false;
};
const restoreAlbums = () => {
albums.value = listFamilyAlbumFixtures(genealogyId.value);
albumState.value = albums.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (albumState.value === "invalid") return goBack();
if (albumState.value === "error") return restoreAlbums();
return createAlbum();
};
const requestBack = () => {
if (discardVisible.value) {
return runBackGuard({
transientOpen: true,
"close-transient": cancelDiscard,
});
}
if (dialogVisible.value) {
return runBackGuard({
transientOpen: true,
"close-transient": requestCloseCreateDialog,
});
}
return runBackGuard({
dirty: isDirty.value,
"confirm-discard": requestDiscardConfirmation,
});
};
const handleStateAction = () => hasValidContext.value ? openCreateDialog() : goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => { discardConfirmation.dispose(); });
onUnmounted(() => requestController.abort());
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.album-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.album-list-header, .album-list-loading, .album-list-content { z-index: 1; }
.album-list-loading { min-height: calc(100vh - 100rpx); }
.album-list-header, .album-list-content { z-index: 1; }
.album-list-content { padding: 18rpx 24rpx 72rpx; }
.album-list-lead { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8rpx 18rpx; min-height: 62rpx; padding: 0 20rpx; color: $ink-muted; font-size: 22rpx; background: url("/static/assets/modules/genealogy/transparent/section-divider.png") center / 100% auto no-repeat; }
.album-list { display: flex; flex-direction: column; gap: 16rpx; margin-top: 16rpx; }
.album-local-preview { @include adaptive.adaptive-family-field; margin-top: 16rpx; padding: 22rpx 24rpx; }
.album-local-preview text { display: block; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
.album-local-preview text:first-child { color: $brand-red; font-weight: 700; }
.album-local-preview text:nth-child(2) { margin-top: 6rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 29rpx; font-weight: 700; }
.album-card, .album-state-card { @include adaptive.adaptive-family-content; width: 100%; }
.album-card { display: grid; grid-template-columns: minmax(150rpx, 0.7fr) minmax(0, 1.3fr); min-height: 210rpx; gap: 22rpx; padding: 30rpx 38rpx; }
.album-card__media { width: 100%; aspect-ratio: 4 / 3; align-self: center; }
.album-card__cover { display: block; width: 100%; height: 100%; }
.album-card__copy { align-self: center; min-width: 0; }
.album-card__copy text { display: block; overflow-wrap: anywhere; }
.album-card__copy text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.album-card__copy text:nth-child(2) { margin-top: 9rpx; color: $brand-red; font-size: 21rpx; }
.album-card__copy text:last-child { margin-top: 8rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
.album-list > .app-button { margin-top: 8rpx; }
.album-state-card { min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.album-state-card { @include adaptive.adaptive-family-content; width: 100%; min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.album-state-card > text { display: block; }
.album-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.album-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
@@ -230,5 +101,4 @@ onUnmounted(() => { discardConfirmation.dispose(); });
.album-dialog-field > text:first-child { display: block; color: $ink; font-size: 23rpx; font-weight: 700; }
.album-dialog-field input { @include adaptive.adaptive-family-field; width: 100%; min-height: 76rpx; margin-top: 10rpx; padding: 0 22rpx; color: $ink; font-size: 24rpx; }
.album-dialog-field > text:last-child { display: block; margin-top: 8rpx; color: $brand-red; font-size: 21rpx; }
@media (max-width: 340px) { .album-card { grid-template-columns: 130rpx minmax(0, 1fr); gap: 16rpx; padding-right: 30rpx; padding-left: 30rpx; } }
</style>
+28 -320
View File
@@ -1,342 +1,50 @@
<!-- 页面编号F-08用途相册详情与照片墙 -->
<!-- 页面编号F-08用途相册照片墙响应未声明照片展示 DTO 时保持关闭 -->
<template>
<view class="album-detail-page" :class="`album-state--${albumState}`">
<view class="album-detail-page">
<ModulePageBackground module="family" />
<view class="album-detail-header"><PageHeader title="相册详情" custom-back @back="requestBack" /></view>
<view class="album-detail-header"><PageHeader title="相册详情" custom-back @back="returnToAlbums" /></view>
<view class="album-detail-content">
<view v-if="albumState === 'expired'" class="album-expired-state">
<view class="album-state-card__body">
<text class="album-state-card__eyebrow">相册状态</text>
<text class="album-state-card__title">相册已失效或入口无效</text>
<text class="album-state-card__copy">当前家谱中没有找到这本相册页面不会回退到其他相册</text>
<AppButton block :label="genealogyId ? '返回相册列表' : '返回上一页'" @click="returnToAlbums" />
</view>
</view>
<template v-else>
<view class="album-heading">
<text class="album-heading__eyebrow">家族影像</text>
<text class="album-heading__title">{{ album.name }}</text>
<text class="album-heading__copy">{{ album.description }}</text>
<text class="album-heading__count"
>{{ albumState === "empty" ? "0 张照片" : `${photos.length} 张照片` }}</text
>
</view>
<view v-if="albumState === 'empty'" class="album-empty-state">
<view class="album-state-card__body">
<text class="album-state-card__eyebrow">照片墙</text>
<text class="album-state-card__title">相册里还没有照片</text>
<text class="album-state-card__copy"
>添加第一张团圆影像把值得珍藏的时刻留在这里</text
>
</view>
</view>
<view v-else class="album-wall">
<view
class="album-photo-tile album-photo-tile--hero"
@click="openPreview(0)"
>
<image
class="album-hero-photo"
:src="photos[0].src"
:alt="photos[0].alt"
mode="aspectFill"
/>
<text class="album-photo-tile__caption">{{ photos[0].caption }}</text>
</view>
<view class="album-photo-grid">
<view
v-for="(photo, index) in photos.slice(1)"
:key="photo.src"
class="album-photo-tile album-photo-tile--grid"
@click="openPreview(index + 1)"
>
<image
class="album-photo-tile__image"
:src="photo.src"
:alt="photo.alt"
mode="aspectFill"
/>
<text class="album-photo-tile__caption">{{ photo.caption }}</text>
</view>
</view>
</view>
<view class="album-upload-action" @click="toUpload">
<AppButton block label="添加照片" />
</view>
</template>
</view>
<view v-if="previewVisible" class="album-preview">
<view class="album-preview__toolbar">
<text class="album-preview__title">照片预览</text>
<view class="album-preview__close" @click="closePreview"><text>关闭</text></view>
</view>
<image
class="album-preview__image"
:src="photos[previewIndex].src"
:alt="photos[previewIndex].alt"
mode="aspectFit"
/>
<view class="album-preview__footer">
<text class="album-preview__position"
>{{ previewIndex + 1 }} / {{ photos.length }}</text
>
<text class="album-preview__caption">{{ photos[previewIndex].caption }}</text>
<view class="album-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="returnToAlbums" />
</view>
</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findFamilyAlbumFixture } from "@/data/mock.js";
import {
goBack,
handleBackPress,
openPage,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const albumState = ref("normal");
const genealogyId = ref("");
const albumId = ref("");
const album = ref(null);
const photos = ref([]);
const previewVisible = ref(false);
const previewIndex = ref(0);
const openPreview = (index) => {
previewIndex.value = index;
previewVisible.value = true;
albumState.value = "preview";
};
const closePreview = () => {
previewVisible.value = false;
albumState.value = "normal";
};
const toUpload = () =>
openPage(
"F09",
{ genealogyId: genealogyId.value, albumId: albumId.value },
"F08",
);
const returnToAlbums = () =>
genealogyId.value
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
const stateCopy = computed(() => hasValidContext.value
? { title: "照片墙待后端字段合同", copy: "照片列表没有声明照片 ID、OSS 访问地址、标题、说明或相册展示字段;页面已停止展示本地图片和本地预览。", action: "返回相册列表" }
: { title: "相册入口无效", copy: "没有取得有效家谱或相册标识,页面不会回退到其他相册。", action: "返回上一页" },
);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
albumId.value = String(query.albumId || "");
album.value = findFamilyAlbumFixture(genealogyId.value, albumId.value);
photos.value = album.value?.photos || [];
if (!album.value) {
albumState.value = "expired";
previewVisible.value = false;
return;
}
albumState.value =
query.state === "empty"
? "empty"
: query.state === "preview"
? "preview"
: query.state === "expired"
? "expired"
: "normal";
previewVisible.value = albumState.value === "preview";
genealogyId.value = String(query?.genealogyId || "");
albumId.value = String(query?.albumId || "");
});
const requestBack = () =>
runBackGuard({
transientOpen: previewVisible.value,
"close-transient": closePreview,
});
onBackPress((event) => handleBackPress(event, requestBack));
const returnToAlbums = () => /^[1-9]\d*$/.test(genealogyId.value)
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.album-detail-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.album-detail-header,
.album-detail-content {
z-index: 1;
}
.album-detail-content {
padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
}
.album-heading {
padding: 18rpx 12rpx 20rpx;
text-align: center;
}
.album-heading text,
.album-state-card__body text,
.album-preview__footer text {
display: block;
}
.album-heading__eyebrow,
.album-state-card__eyebrow {
color: $brand-red;
font-size: 22rpx;
letter-spacing: 2rpx;
}
.album-heading__title,
.album-state-card__title {
margin-top: 8rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
font-weight: 700;
}
.album-heading__copy,
.album-state-card__copy {
margin-top: 10rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.6;
}
.album-heading__count {
margin-top: 12rpx;
color: #806a51;
font-size: 22rpx;
}
.album-wall {
margin-top: 8rpx;
}
.album-photo-tile {
position: relative;
min-height: 44px;
overflow: hidden;
box-sizing: border-box;
border: 2rpx solid rgba(179, 133, 63, 0.62);
background: rgba(245, 238, 226, 0.86);
box-shadow: 0 8rpx 18rpx rgba(91, 60, 32, 0.12);
}
.album-photo-tile--hero {
width: 100%;
aspect-ratio: 16 / 9;
}
.album-photo-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 14rpx;
}
.album-photo-tile--grid {
aspect-ratio: 4 / 3;
}
.album-hero-photo,
.album-photo-tile__image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.album-photo-tile__caption {
position: absolute;
z-index: 1;
right: 0;
bottom: 0;
left: 0;
padding: 10rpx 12rpx;
background: rgba(48, 35, 25, 0.72);
color: #fffaf0;
font-size: 21rpx;
line-height: 1.35;
}
.album-upload-action {
margin-top: 24rpx;
}
.album-empty-state,
.album-expired-state {
@include adaptive.adaptive-family-panel;
width: 100%;
min-height: 430rpx;
}
.album-state-card__body {
padding: 104rpx 40rpx 62rpx;
text-align: center;
}
.album-state-card__body > .app-button {
margin-top: 34rpx;
}
.album-preview {
position: fixed;
z-index: 80;
inset: 0;
display: flex;
flex-direction: column;
background: rgba(24, 17, 13, 0.96);
}
.album-preview__toolbar {
display: flex;
min-height: 96rpx;
align-items: center;
justify-content: space-between;
padding: calc(env(safe-area-inset-top) + 12rpx) 24rpx 12rpx;
}
.album-preview__title {
color: #fffaf0;
font-size: 29rpx;
font-weight: 700;
}
.album-preview__close {
display: flex;
min-width: 44px;
min-height: 44px;
align-items: center;
justify-content: center;
color: #fffaf0;
font-size: 25rpx;
}
.album-preview__image {
width: 100%;
min-height: 0;
flex: 1;
}
.album-preview__footer {
padding: 20rpx 28rpx calc(30rpx + env(safe-area-inset-bottom));
color: #fffaf0;
text-align: center;
}
.album-preview__position {
font-size: 23rpx;
letter-spacing: 2rpx;
}
.album-preview__caption {
margin-top: 8rpx;
color: rgba(255, 250, 240, 0.78);
font-size: 22rpx;
}
@media (max-width: 340px) {
.album-detail-content {
padding-right: 16rpx;
padding-left: 16rpx;
}
.album-photo-grid {
gap: 10rpx;
}
}
.album-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.album-detail-header, .album-detail-content { z-index: 1; }
.album-detail-content { padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom)); }
.album-state-card { @include adaptive.adaptive-family-panel; width: 100%; min-height: 430rpx; padding: 104rpx 40rpx 62rpx; box-sizing: border-box; text-align: center; }
.album-state-card text { display: block; }
.album-state-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.album-state-card text:nth-child(2) { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
.album-state-card .app-button { margin-top: 34rpx; }
</style>
+25 -604
View File
@@ -1,627 +1,48 @@
<!-- 页面编号F-09用途照片选择说明上传进度与失败重试视觉候选 -->
<!-- 页面编号F-09用途相册照片写入没有文件上传与 OSS 回执 owner 时关闭 -->
<template>
<view
class="media-upload-page"
:class="`media-upload-state--${uploadState}`"
>
<view class="media-upload-page">
<ModulePageBackground module="family" />
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="requestBack" /></view>
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="returnToAlbum" /></view>
<view class="media-upload-content">
<view v-if="uploadState === 'invalid'" class="media-invalid-card">
<text class="media-state-card__eyebrow">相册入口无效</text>
<text class="media-state-card__title">没有找到当前相册</text>
<text class="media-state-card__copy">页面不会把照片归入其他家谱或其他相册</text>
<view class="media-preview-action" @click="returnFromInvalid">
<AppButton block :label="genealogyId ? '返回相册列表' : '返回上一页'" />
</view>
<view class="media-state-card">
<text class="media-state-card__eyebrow">文件服务待接入</text>
<text class="media-state-card__title">照片上传暂不可用</text>
<text class="media-state-card__copy">照片写入接口要求已有 `ossId`当前没有经 Apifox 核实的文件上传 owner真实 OSS 回执或访问 URL页面不再从本地图片库选择图片也不会生成上传预览</text>
<AppButton block :label="hasValidContext ? '返回相册列表' : '返回上一页'" @click="returnToAlbum" />
</view>
<view v-else class="media-album-card">
<image
class="media-album-card__cover"
:src="album.cover"
:alt="album.name"
mode="aspectFill"
/>
<view class="media-album-card__copy">
<text class="media-album-card__eyebrow">当前相册</text>
<text class="media-album-card__title">{{ album.name }}</text>
<text class="media-album-card__limit">最多可选择 9 </text>
</view>
</view>
<view v-if="uploadState === 'permission' && album" class="media-permission-card">
<text class="media-state-card__eyebrow">照片访问权限</text>
<text class="media-state-card__title">需要照片访问权限</text>
<text class="media-state-card__copy"
>授权后才能选择照片当前只验证选择与说明流程不会上传</text
>
<view class="media-permission-action" @click="selectMockPhotos">
<AppButton block label="重新授权" />
</view>
</view>
<view v-else-if="uploadState === 'preview'" class="media-preview-card">
<text class="media-state-card__eyebrow">本地流程预览</text>
<text class="media-preview-title">{{ selectedPhotos.length }} 张照片已完成本地校验</text>
<text class="media-state-card__copy"
>照片尚未上传也没有加入{{ album.name }}返回后不会保存</text
>
<view class="media-preview-action" @click="returnToAlbum">
<AppButton block label="返回当前相册(不上传)" />
</view>
</view>
<template v-else-if="album">
<view class="media-section-heading">
<text>选择照片</text>
<text>{{ selectedPhotos.length }} / {{ MAX_PHOTOS }}</text>
</view>
<view class="media-photo-grid">
<view
v-for="(photo, index) in selectedPhotos"
:key="photo.id"
class="media-photo-tile"
:class="{
'media-photo-tile--active': index === activePhotoIndex,
}"
@click="selectPhoto(index)"
>
<image
class="media-photo-tile__image"
:src="photo.src"
:alt="photo.alt"
mode="aspectFill"
/>
<text class="media-photo-order">{{ index + 1 }}</text>
<text
v-if="index === activePhotoIndex"
class="media-photo-current"
>当前</text
>
<view
v-if="!isLocked"
class="media-photo-remove"
@click.stop="removePhoto(index)"
>
<text>删除</text>
</view>
</view>
<view
v-if="selectedPhotos.length < MAX_PHOTOS && !isLocked"
class="media-add-tile"
@click="selectedPhotos.length ? addMockPhoto() : selectMockPhotos()"
>
<text class="media-add-tile__title">{{
selectedPhotos.length ? "继续添加" : "选择照片"
}}</text>
<text class="media-add-tile__copy">{{
selectedPhotos.length ? `还可选择 ${MAX_PHOTOS - selectedPhotos.length}` : "最多 9 张"
}}</text>
</view>
</view>
<view v-if="selectedPhotos.length" class="media-description-panel">
<view class="media-batch-field">
<view class="media-field-heading">
<text>整批说明</text><text>必填</text>
</view>
<textarea
v-model="batchDescription"
auto-height
:disabled="isLocked"
maxlength="120"
placeholder="例如:2024 年春节全家团圆留影"
placeholder-class="media-field-placeholder"
/>
</view>
<text v-if="validationMessage" class="media-field-error">{{
validationMessage
}}</text>
<view v-if="activePhoto" class="media-photo-field">
<view class="media-photo-editor">
<image
class="media-photo-editor__thumb"
:src="activePhoto.src"
:alt="activePhoto.alt"
mode="aspectFill"
/>
<view class="media-photo-editor__copy">
<text class="media-photo-editor__position"
> {{ activePhotoIndex + 1 }} /
{{ selectedPhotos.length }} </text
>
<text class="media-photo-editor__hint"
>正在编辑此照片的说明</text
>
</view>
</view>
<view class="media-field-heading">
<text>当前照片说明</text><text>选填</text>
</view>
<textarea
:value="activePhoto.note"
auto-height
:disabled="isLocked"
maxlength="80"
placeholder="补充人物、时间或拍摄地点"
placeholder-class="media-field-placeholder"
@input="updateActiveNote"
/>
</view>
</view>
<view class="media-primary-action" @click="generatePreview">
<AppButton
block
:disabled="isSubmitting"
:label="
isSubmitting
? '正在校验'
: selectedPhotos.length
? `生成 ${selectedPhotos.length} 张照片预览`
: '选择照片'
"
/>
</view>
</template>
</view>
<AppDialog
:visible="discardVisible"
title="放弃照片草稿?"
message="已选择的照片和说明尚未上传,确认返回后不会保留。"
confirm-text="放弃并返回"
cancel-text="继续整理"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findFamilyAlbumFixture } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const MAX_PHOTOS = 9;
const genealogyId = ref("");
const albumId = ref("");
const album = ref(null);
const uploadState = ref("initial");
const activePhotoIndex = ref(0);
const batchDescription = ref("");
const validationMessage = ref("");
const selectedPhotos = ref([]);
const isSubmitting = ref(false);
const discardVisible = ref(false);
let previewTimer = null;
const mockLibrary = [
{ id: "reunion", src: "/static/assets/modules/family/f08/f08-reunion-hero.png", alt: "春节团圆时三代家人的合影", note: "" },
{ id: "portrait", src: "/static/assets/modules/family/f08/f08-family-portrait.png", alt: "家人在院落前的春节合影", note: "" },
{ id: "table", src: "/static/assets/modules/family/f08/f08-reunion-table.png", alt: "家人围坐吃年夜饭", note: "" },
{ id: "home", src: "/static/assets/modules/family/f08/f08-ancestral-home.png", alt: "祖居院落的复古旧照", note: "" },
{ id: "ancestor", src: "/static/assets/modules/family/f08/f08-ancestral-portrait.png", alt: "老一辈家人在祖居门前的合影", note: "" },
];
const activePhoto = computed(
() => selectedPhotos.value[activePhotoIndex.value] || null,
);
const isLocked = computed(() => isSubmitting.value);
const isDirty = computed(() =>
Boolean(
selectedPhotos.value.length ||
batchDescription.value.trim() ||
selectedPhotos.value.some((photo) => photo.note.trim()),
),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const makeSelectedPhotos = () =>
mockLibrary.slice(0, 4).map((photo) => ({ ...photo, note: "" }));
const selectMockPhotos = () => {
selectedPhotos.value = makeSelectedPhotos();
activePhotoIndex.value = 0;
validationMessage.value = "";
uploadState.value = "selected";
};
const selectPhoto = (index) => {
if (!isLocked.value) activePhotoIndex.value = index;
};
const removePhoto = (index) => {
if (isLocked.value) return;
selectedPhotos.value.splice(index, 1);
activePhotoIndex.value = Math.max(
0,
Math.min(activePhotoIndex.value, selectedPhotos.value.length - 1),
);
};
const addMockPhoto = () => {
if (isLocked.value || selectedPhotos.value.length >= MAX_PHOTOS) return;
const nextIndex = selectedPhotos.value.length;
const next = mockLibrary[nextIndex % mockLibrary.length];
selectedPhotos.value.push({
...next,
id: `${next.id}-${nextIndex + 1}`,
note: "",
});
};
const updateActiveNote = (event) => {
if (!isLocked.value && activePhoto.value) {
activePhoto.value.note = event.detail.value;
}
};
const generatePreview = () => {
if (isSubmitting.value || !album.value) return;
if (!selectedPhotos.value.length) {
selectMockPhotos();
return;
}
if (!batchDescription.value.trim()) {
validationMessage.value = "请填写整批照片说明";
return;
}
validationMessage.value = "";
isSubmitting.value = true;
const selectedCount = selectedPhotos.value.length;
const timer = setTimeout(() => {
if (previewTimer !== timer) return;
previewTimer = null;
isSubmitting.value = false;
uploadState.value = selectedCount > 0 ? "preview" : "selected";
}, 280);
previewTimer = timer;
};
const returnToAlbum = () =>
returnTo("F08", {
genealogyId: genealogyId.value,
albumId: albumId.value,
});
const returnFromInvalid = () =>
genealogyId.value
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
albumId.value = String(query.albumId || "");
album.value = findFamilyAlbumFixture(genealogyId.value, albumId.value);
if (!album.value) {
uploadState.value = "invalid";
return;
}
uploadState.value = ["permission", "selected", "preview"].includes(query.state)
? query.state
: "initial";
if (["selected", "preview"].includes(uploadState.value)) {
selectedPhotos.value = makeSelectedPhotos();
batchDescription.value = "春节团圆照片整理";
}
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
if (previewTimer) clearTimeout(previewTimer);
discardConfirmation.dispose();
genealogyId.value = String(query?.genealogyId || "");
albumId.value = String(query?.albumId || "");
});
const returnToAlbum = () => hasValidContext.value
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.media-upload-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.media-upload-header,
.media-upload-content {
z-index: 1;
}
.media-upload-content {
padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
}
.media-album-card {
@include adaptive.adaptive-family-panel;
display: grid;
min-height: 154rpx;
box-sizing: border-box;
grid-template-columns: 132rpx minmax(0, 1fr);
gap: 20rpx;
align-items: center;
padding: 14rpx 20rpx;
}
.media-album-card__cover {
width: 132rpx;
height: 132rpx;
border: 2rpx solid rgba(179, 133, 63, 0.62);
box-shadow: 0 6rpx 14rpx rgba(91, 60, 32, 0.12);
}
.media-album-card__copy text,
.media-state-card__eyebrow,
.media-state-card__title,
.media-state-card__copy,
.media-preview-title {
display: block;
}
.media-album-card__eyebrow {
color: $brand-red;
font-size: 21rpx;
letter-spacing: 2rpx;
}
.media-album-card__title {
margin-top: 7rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.media-album-card__limit {
margin-top: 7rpx;
color: $ink-muted;
font-size: 21rpx;
}
.media-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin: 24rpx 8rpx 12rpx;
color: $brand-red;
font-size: 25rpx;
font-weight: 700;
}
.media-section-heading text:last-child {
color: $ink-muted;
font-size: 21rpx;
font-weight: 400;
}
.media-photo-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12rpx;
}
.media-photo-tile,
.media-add-tile {
min-width: 0;
min-height: 44px;
overflow: hidden;
box-sizing: border-box;
aspect-ratio: 1;
border: 2rpx solid rgba(179, 133, 63, 0.62);
background: rgba(247, 241, 231, 0.88);
}
.media-photo-tile {
position: relative;
}
.media-photo-tile--active {
outline: 4rpx solid rgba(159, 44, 35, 0.78);
outline-offset: -4rpx;
}
.media-photo-tile__image {
width: 100%;
height: 100%;
}
.media-photo-order,
.media-photo-current {
position: absolute;
z-index: 2;
color: #fffaf0;
font-size: 20rpx;
}
.media-photo-order {
top: 7rpx;
left: 7rpx;
min-width: 34rpx;
padding: 3rpx 7rpx;
border-radius: 999px;
background: rgba(43, 31, 23, 0.78);
text-align: center;
}
.media-photo-current {
bottom: 7rpx;
left: 7rpx;
padding: 4rpx 8rpx;
background: rgba(74, 45, 27, 0.84);
}
.media-photo-remove {
position: absolute;
z-index: 3;
top: 0;
right: 0;
display: flex;
min-width: 44px;
min-height: 44px;
align-items: flex-start;
justify-content: flex-end;
color: #fffaf0;
font-size: 20rpx;
}
.media-photo-remove text {
padding: 7rpx 8rpx;
background: rgba(145, 36, 29, 0.88);
}
.media-add-tile {
display: flex;
min-height: 44px;
flex-direction: column;
align-items: center;
justify-content: center;
border-style: dashed;
color: $brand-red;
text-align: center;
}
.media-add-tile__title {
font-size: 24rpx;
font-weight: 700;
}
.media-add-tile__copy {
margin-top: 6rpx;
color: $ink-muted;
font-size: 19rpx;
}
.media-description-panel {
margin-top: 22rpx;
}
.media-batch-field,
.media-photo-field {
padding: 18rpx 20rpx 20rpx;
border: 2rpx solid rgba(179, 133, 63, 0.55);
background: rgba(250, 246, 237, 0.76);
}
.media-photo-field {
margin-top: 16rpx;
}
.media-photo-editor {
display: grid;
grid-template-columns: 78rpx minmax(0, 1fr);
gap: 14rpx;
align-items: center;
margin-bottom: 16rpx;
padding-bottom: 14rpx;
border-bottom: 1px solid rgba(179, 133, 63, 0.32);
}
.media-photo-editor__thumb {
width: 78rpx;
height: 78rpx;
border: 2rpx solid rgba(159, 44, 35, 0.62);
}
.media-photo-editor__copy text {
display: block;
}
.media-photo-editor__position {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.media-photo-editor__hint {
margin-top: 6rpx;
color: $ink-muted;
font-size: 20rpx;
}
.media-field-heading {
display: flex;
align-items: center;
justify-content: space-between;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.media-field-heading text:last-child {
color: $brand-red;
font-size: 20rpx;
font-weight: 500;
}
.media-batch-field textarea,
.media-photo-field textarea {
width: 100%;
min-height: 92rpx;
margin-top: 12rpx;
box-sizing: border-box;
color: $ink;
font-size: 23rpx;
line-height: 1.55;
}
.media-field-placeholder {
color: #9e8e79;
}
.media-field-error {
display: block;
margin: 9rpx 6rpx 0;
color: $brand-red;
font-size: 21rpx;
line-height: 1.45;
}
.media-primary-action {
margin-top: 24rpx;
}
.media-permission-card,
.media-preview-card,
.media-invalid-card {
@include adaptive.adaptive-family-panel;
min-height: 410rpx;
margin-top: 24rpx;
box-sizing: border-box;
padding: 98rpx 46rpx 58rpx;
text-align: center;
}
.media-state-card__eyebrow {
color: $brand-red;
font-size: 22rpx;
letter-spacing: 2rpx;
}
.media-state-card__title,
.media-preview-title {
margin-top: 12rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 35rpx;
font-weight: 700;
}
.media-state-card__copy {
margin-top: 16rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.65;
}
.media-permission-action,
.media-preview-action {
margin-top: 30rpx;
}
@media (max-width: 340px) {
.media-upload-content {
padding-right: 16rpx;
padding-left: 16rpx;
}
.media-photo-grid {
gap: 8rpx;
}
.media-album-card {
grid-template-columns: 116rpx minmax(0, 1fr);
gap: 14rpx;
}
.media-album-card__cover {
width: 116rpx;
height: 116rpx;
}
}
.media-upload-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.media-upload-header, .media-upload-content { z-index: 1; }
.media-upload-content { padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom)); }
.media-state-card { @include adaptive.adaptive-family-panel; min-height: 410rpx; box-sizing: border-box; padding: 98rpx 46rpx 58rpx; text-align: center; }
.media-state-card text { display: block; }
.media-state-card__eyebrow { color: $brand-red; font-size: 22rpx; letter-spacing: 2rpx; }
.media-state-card__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.media-state-card__copy { margin-top: 16rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.65; }
.media-state-card .app-button { margin-top: 30rpx; }
</style>
+4 -135
View File
@@ -1,142 +1,11 @@
<!-- 页面编号F-10用途家族视频待开放状态 -->
<!-- 页面编号F-10用途家族视频当前缺读取发布和播放业务 owner -->
<template>
<view class="video-status-page" :class="{ 'video-status-state--invalid': !hasValidContext }">
<ModulePageBackground module="family" />
<view class="video-status-header"><PageHeader title="家族视频" /></view>
<view class="video-status-content">
<view class="video-status-lead">
<image
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<text>记录会动的家族记忆</text>
</view>
<view class="video-status-card">
<view class="video-status-card__body">
<text class="video-status-card__eyebrow">{{ hasValidContext ? '视频 · 服务说明' : '页面入口' }}</text>
<text class="video-status-card__title">{{ hasValidContext ? '视频服务暂未开放' : '家谱身份无效' }}</text>
<text class="video-status-card__copy">{{ hasValidContext ? '开放后可在这里浏览当前家谱的影像与纪念视频。' : '请从一个可访问的成员家谱重新进入,页面不会展示其他家谱内容。' }}</text>
<view class="video-return-action" @click="returnToFamily">
<AppButton block :label="hasValidContext ? '返回家族首页' : '返回上一页'" />
</view>
</view>
</view>
</view>
</view>
<view class="video-page"><ModulePageBackground module="family" /><view class="page-header"><PageHeader title="家族视频" custom-back @back="returnToFamily" /></view><view class="page-content"><view class="state-card"><text>视频服务暂未开放</text><text>Apifox 当前只有删除视频 operation未声明视频列表详情播放地址发布编辑评论点赞或分享 owner页面不以相册参考项目或本地数据补造视频能力</text><AppButton block :label="hasValidContext ? '返回家族动态' : '返回上一页'" @click="returnToFamily" /></view></view></view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { getGenealogyFixtureAccess } from "@/data/mock.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const hasValidContext = computed(() =>
Boolean(
genealogyId.value &&
["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
),
),
);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
});
const returnToFamily = () =>
hasValidContext.value
? returnTo("F01", { genealogyId: genealogyId.value })
: goBack();
import { computed,ref } from "vue"; import { onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToFamily=()=>hasValidContext.value?returnTo("F01",{genealogyId:genealogyId.value}):goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.video-status-page {
min-height: 100vh;
background: $paper;
}
.video-status-content {
padding: 16rpx 26rpx 56rpx;
}
.video-status-lead {
display: grid;
min-height: 56rpx;
margin: 0 12rpx 18rpx;
color: $ink-muted;
font-size: 23rpx;
text-align: center;
}
.video-status-lead image {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.video-status-lead text {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
}
.video-status-card {
@include adaptive.adaptive-family-panel;
width: 100%;
min-height: 330rpx;
box-sizing: border-box;
text-align: center;
}
.video-status-card__body {
padding: 70rpx 60rpx 48rpx;
}
.video-status-card__eyebrow,
.video-status-card__title,
.video-status-card__copy {
display: block;
}
.video-status-card__eyebrow {
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
}
.video-status-card__title {
margin-top: 14rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 35rpx;
font-weight: 700;
}
.video-status-card__copy {
margin-top: 17rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.6;
}
.video-return-action {
margin: 28rpx auto 0;
}
@media (min-width: 400px) {
.video-status-content {
padding-right: 34rpx;
padding-left: 34rpx;
}
}
@media (max-width: 340px) {
.video-status-content {
padding-right: 20rpx;
padding-left: 20rpx;
}
.video-status-card__body {
padding-right: 44rpx;
padding-left: 44rpx;
}
}
.video-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -735
View File
@@ -1,735 +1,4 @@
<!-- 页面编号G-03用途创建家谱与录入首代人物的同路由两步流程 -->
<template>
<view class="flow-page">
<GenealogyPageBackground />
<PageHeader
:title="isAncestorStep ? '录入首代人物' : '创建家谱'"
custom-back
@back="requestBack"
/>
<view class="flow-content">
<view class="create-flow-panel">
<view v-if="!isAncestorStep" class="create-flow-panel__content">
<view class="flow-step-label"><text>第一步 · 立谱信息</text></view>
<text class="flow-heading">为家族立一部可传承的谱</text>
<text class="flow-note"
>家谱建立后可继续完善名称与访问规则由创建者维护</text
>
<view class="flow-fields">
<view class="field-row">
<text>姓氏</text>
<input
v-model="createForm.surname"
maxlength="4"
placeholder="请输入姓氏"
placeholder-class="placeholder"
@input="clearFieldError('surname')"
/>
</view>
<text v-if="fieldErrors.surname" class="field-error">{{
fieldErrors.surname
}}</text>
<view class="field-row">
<text>谱名</text>
<input
v-model="createForm.name"
maxlength="24"
placeholder="请输入家谱名称"
placeholder-class="placeholder"
@input="clearFieldError('name')"
/>
</view>
<text v-if="fieldErrors.name" class="field-error">{{
fieldErrors.name
}}</text>
<view class="field-row">
<text>堂号</text>
<input
v-model="createForm.hall"
maxlength="12"
placeholder="选填"
placeholder-class="placeholder"
/>
</view>
<view class="field-row">
<text>所在地</text>
<input
v-model="createForm.location"
maxlength="30"
placeholder="请选择或输入"
placeholder-class="placeholder"
@input="clearFieldError('location')"
/>
</view>
<text v-if="fieldErrors.location" class="field-error">{{
fieldErrors.location
}}</text>
</view>
<view class="flow-rule">
<text class="flow-rule__label">访问规则</text>
<view class="flow-rule__options">
<view
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
:key="option.value"
class="flow-rule__option"
:class="{
'flow-rule__option--active':
createForm.accessPreset === option.value,
}"
@click="createForm.accessPreset = option.value"
>{{ option.label }}</view
>
</view>
</view>
<text class="flow-rule__note"
>保护族人资料创建后可在家谱设置中开放搜索与申请加入</text
>
<text v-if="createState === 'error'" class="flow-error"
>创建样式模拟失败请检查信息后重试</text
>
<view
class="flow-primary-action"
hover-class="action-hover"
@click="submitCreate"
>
<text class="flow-primary-action__copy">{{
createState === "submitting" ? "正在创建…" : "创建并录入首代"
}}</text>
</view>
</view>
<view v-else class="create-flow-panel__content">
<view class="flow-step-label"><text>第二步 · 首代人物</text></view>
<text class="flow-heading">家谱从这一位开始</text>
<text class="flow-note"
>可先录入姓名世代和简要生平其他资料后续随时补充</text
>
<view class="flow-fields">
<view class="field-row">
<text>姓名</text>
<input
v-model="ancestorForm.personName"
maxlength="20"
placeholder="请输入首代姓名"
placeholder-class="placeholder"
@input="clearFieldError('personName')"
/>
</view>
<text v-if="fieldErrors.personName" class="field-error">{{
fieldErrors.personName
}}</text>
<view class="field-row">
<text>世代</text>
<text class="fixed-value">第一世</text>
</view>
<picker
mode="selector"
:range="sexOptions"
range-key="label"
@change="changeAncestorSex"
>
<view class="field-row">
<text>性别</text>
<text class="fixed-value">{{ sexLabel }}</text>
</view>
</picker>
<view class="field-row">
<text>出生日期</text>
<picker
mode="date"
:value="ancestorForm.birthDate"
start="1800-01-01"
:end="new Date().toISOString().slice(0, 10)"
@change="changeAncestorBirthDate"
>
<text class="fixed-value">{{
ancestorForm.birthDate || "请选择日期"
}}</text>
</picker>
</view>
</view>
<text v-if="ancestorState === 'error'" class="flow-error"
>首代人物保存失败请稍后重试</text
>
<view class="intro-field">
<text>生平简述</text>
<textarea
v-model="ancestorForm.introduction"
auto-height
maxlength="200"
placeholder="可选,记录家训、迁徙或重要经历"
placeholder-class="placeholder"
/>
</view>
<view
class="flow-primary-action"
hover-class="action-hover"
@click="submitAncestor"
>
<text class="flow-primary-action__copy">{{
ancestorState === "submitting" ? "正在保存…" : "保存并进入家谱"
}}</text>
</view>
</view>
</view>
</view>
<view
v-if="duplicateReminderVisible"
class="duplicate-reminder-layer"
@click="closeDuplicateReminder"
>
<view class="duplicate-reminder" @click.stop>
<view class="duplicate-reminder__content">
<text class="duplicate-reminder__title">先确认是否已有家谱</text>
<text class="duplicate-reminder__copy"
>同一家族可能已经建谱建议先搜索谱名地区和堂号避免重复创建</text
>
<view
class="duplicate-reminder__search"
@click="searchExistingGenealogy"
>
<text>先搜索已有家谱</text>
</view>
<view class="duplicate-reminder__confirm" @click="confirmCreate">
<text>确认没有继续创建</text>
</view>
<view
class="duplicate-reminder__cancel"
@click="closeDuplicateReminder"
>返回修改</view
>
</view>
</view>
</view>
<view v-if="ancestorState === 'success'" class="flow-success-layer">
<view class="flow-success-dialog">
<view class="flow-success-dialog__content">
<text class="flow-success-dialog__title">家谱创建完成</text>
<text class="flow-success-dialog__copy"
>当前为本地流程预览资料尚未提交服务器可进入总览继续检查页面</text
>
<view class="flow-success-dialog__action" @click="enterOverview">
<text>进入家谱总览</text>
</view>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃创建?"
message="当前填写内容尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
createLocalGenealogyPreview,
removeLocalGenealogyPreview,
updateLocalGenealogyPreview,
updateLocalGenealogyPreviewAncestor,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
GENEALOGY_ACCESS_PRESET,
GENEALOGY_ACCESS_PRESET_OPTIONS,
} from "@/utils/genealogy-contracts.js";
import { handleBackPress, openPage, returnTo, runBackGuard } from "@/utils/navigation.js";
const currentStep = ref("create");
const genealogyId = ref("");
const isSubmitting = ref(false);
const createState = ref("form");
const ancestorState = ref("form");
const duplicateReminderVisible = ref(false);
const discardVisible = ref(false);
const fieldErrors = reactive({
surname: "",
name: "",
location: "",
personName: "",
});
let submitTimer = null;
const createForm = reactive({
surname: "",
name: "",
hall: "",
location: "",
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
});
const ancestorForm = reactive({
personName: "",
generationNo: 1,
generationName: "一世",
sex: "0",
birthDate: "",
introduction: "",
});
const sexOptions = [
{ value: "0", label: "男" },
{ value: "1", label: "女" },
{ value: "2", label: "暂不填写" },
];
const sexLabel = computed(
() =>
sexOptions.find((option) => option.value === ancestorForm.sex)?.label ||
"请选择",
);
const changeAncestorSex = (event) => {
ancestorForm.sex = sexOptions[Number(event.detail.value)]?.value || "2";
};
const changeAncestorBirthDate = (event) => {
ancestorForm.birthDate = event.detail.value;
};
const isAncestorStep = computed(() => currentStep.value === "ancestor");
const isDirty = computed(() =>
isAncestorStep.value
? Boolean(
ancestorForm.personName ||
ancestorForm.birthDate ||
ancestorForm.introduction ||
ancestorForm.sex !== "0",
)
: Boolean(
createForm.surname ||
createForm.name ||
createForm.hall ||
createForm.location ||
createForm.accessPreset !== GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestRawDiscardConfirmation = discardConfirmation.request;
const requestDiscardConfirmation = async () => {
const confirmed = await requestRawDiscardConfirmation();
if (confirmed && currentStep.value === "create" && genealogyId.value) {
removeLocalGenealogyPreview(genealogyId.value);
genealogyId.value = "";
}
return confirmed;
};
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const closeActiveTransient = () => {
if (duplicateReminderVisible.value) closeDuplicateReminder();
else cancelDiscard();
};
const popInternalTrail = () => {
currentStep.value = "create";
ancestorState.value = "form";
return true;
};
const requestBack = () => {
if (ancestorState.value === "success") return enterOverview();
return runBackGuard({
transientOpen: duplicateReminderVisible.value || discardVisible.value,
internalTrail: isAncestorStep.value && !isSubmitting.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": closeActiveTransient,
"pop-internal-trail": popInternalTrail,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
const clearFieldError = (field) => {
fieldErrors[field] = "";
};
const validateCreateForm = () => {
fieldErrors.surname = createForm.surname.trim() ? "" : "请填写姓氏";
fieldErrors.name = createForm.name.trim() ? "" : "请填写谱名";
fieldErrors.location = createForm.location.trim() ? "" : "请填写所在地";
return !fieldErrors.surname && !fieldErrors.name && !fieldErrors.location;
};
const submitCreate = () => {
if (isSubmitting.value) return;
createState.value = "form";
if (!validateCreateForm()) return;
duplicateReminderVisible.value = true;
};
const closeDuplicateReminder = () => {
duplicateReminderVisible.value = false;
};
const searchExistingGenealogy = () => {
closeDuplicateReminder();
return openPage("G06", {}, "G03");
};
const confirmCreate = () => {
if (isSubmitting.value) return;
closeDuplicateReminder();
const createSnapshot = Object.freeze({ ...createForm });
isSubmitting.value = true;
createState.value = "submitting";
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
isSubmitting.value = false;
if (createSnapshot.name.trim() === "失败") {
createState.value = "error";
return;
}
genealogyId.value =
updateLocalGenealogyPreview(genealogyId.value, createSnapshot) ||
createLocalGenealogyPreview(createSnapshot);
currentStep.value = "ancestor";
createState.value = "form";
}, 320);
submitTimer = timer;
};
const submitAncestor = () => {
if (isSubmitting.value) return;
if (!genealogyId.value) {
ancestorState.value = "error";
return;
}
if (!ancestorForm.personName.trim()) {
fieldErrors.personName = "请填写首代姓名";
return;
}
const ancestorSnapshot = Object.freeze({ ...ancestorForm });
isSubmitting.value = true;
ancestorState.value = "submitting";
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
isSubmitting.value = false;
if (ancestorSnapshot.personName.trim() === "失败") {
ancestorState.value = "error";
return;
}
ancestorState.value = updateLocalGenealogyPreviewAncestor(
genealogyId.value,
ancestorSnapshot,
)
? "success"
: "error";
}, 320);
submitTimer = timer;
};
const enterOverview = () =>
returnTo("G05", { genealogyId: genealogyId.value });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.flow-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: #f9f6ef;
}
.flow-content {
z-index: 2;
padding: 24rpx 32rpx 48rpx;
}
.create-flow-panel {
@include adaptive.adaptive-genealogy-state-panel;
min-height: 1000rpx;
}
.create-flow-panel__content {
z-index: 1;
display: flex;
min-height: 1000rpx;
flex-direction: column;
box-sizing: border-box;
padding: 48rpx 50rpx 42rpx;
}
.flow-step-label {
color: #a33b2b;
font-family: "STKaiti", "KaiTi", serif;
font-size: 25rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.flow-heading {
display: block;
margin-top: 13rpx;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 39rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.flow-note,
.flow-rule__note {
display: block;
color: #786654;
font-size: 25rpx;
line-height: 1.65;
}
.flow-note {
min-height: 56rpx;
margin-top: 12rpx;
}
.flow-fields {
margin-top: 10rpx;
}
.field-row {
display: flex;
min-height: 86rpx;
align-items: center;
border-bottom: 1rpx solid rgba(181, 138, 75, 0.36);
}
.field-row > text {
width: 126rpx;
flex: 0 0 auto;
color: #786654;
font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx;
}
.field-row input {
min-width: 0;
flex: 1;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 28rpx;
}
.placeholder {
color: #b7aa97;
}
.field-error {
display: block;
margin-top: 3rpx;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
text-align: right;
}
.fixed-value {
color: #a33b2b !important;
}
.flow-rule {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20rpx;
}
.flow-rule__label {
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx;
font-weight: 700;
}
.flow-rule__options {
display: flex;
gap: 12rpx;
}
.flow-rule__option {
display: flex;
min-height: 54rpx;
align-items: center;
padding: 0 10rpx;
color: #786654;
font-size: 23rpx;
}
.flow-rule__option--active {
color: $brand-red;
font-weight: 700;
}
.flow-rule__note {
margin-top: 8rpx;
}
.flow-error {
display: block;
margin-top: 10rpx;
color: $brand-red;
font-size: 25rpx;
line-height: 36rpx;
text-align: center;
}
.intro-field {
margin-top: 18rpx;
}
.intro-field > text {
display: block;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx;
font-weight: 700;
}
.intro-field textarea {
width: 100%;
min-height: 142rpx;
margin-top: 8rpx;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 26rpx;
line-height: 1.6;
}
.flow-primary-action {
display: flex;
width: 100%;
min-height: 96rpx;
align-items: center;
justify-content: center;
margin-top: auto;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.flow-primary-action__copy {
z-index: 1;
color: #fff9ec;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.action-hover {
opacity: 0.82;
}
.duplicate-reminder-layer,
.flow-success-layer {
position: fixed;
z-index: 30;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 42rpx;
background: rgba(34, 20, 12, 0.6);
}
.duplicate-reminder,
.flow-success-dialog {
width: 100%;
max-width: 670rpx;
min-height: 650rpx;
background: url("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png")
center / contain no-repeat;
}
.duplicate-reminder__content,
.flow-success-dialog__content {
z-index: 1;
display: flex;
min-height: 650rpx;
flex-direction: column;
align-items: center;
box-sizing: border-box;
padding: 92rpx 64rpx 56rpx;
text-align: center;
}
.duplicate-reminder__title,
.flow-success-dialog__title {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 39rpx;
font-weight: 700;
}
.duplicate-reminder__copy,
.flow-success-dialog__copy {
margin-top: 24rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 40rpx;
}
.duplicate-reminder__search,
.duplicate-reminder__confirm,
.flow-success-dialog__action {
display: flex;
width: 100%;
min-height: 92rpx;
align-items: center;
justify-content: center;
}
.duplicate-reminder__search {
margin-top: 38rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
}
.duplicate-reminder__confirm {
margin-top: 14rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.duplicate-reminder__search text,
.duplicate-reminder__confirm text,
.flow-success-dialog__action text {
z-index: 1;
color: #fff9ec;
font-size: 25rpx;
font-weight: 700;
}
.duplicate-reminder__search text {
color: #7b4e24;
}
.duplicate-reminder__cancel {
display: flex;
min-height: 68rpx;
align-items: center;
margin-top: auto;
color: $ink-muted;
font-size: 23rpx;
}
.flow-success-dialog__action {
margin-top: 44rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
</style>
<!-- 页面编号G-03用途创建家谱两阶段创建的结果恢复链未闭合时保持关闭 -->
<template><view class="create-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="创建家谱" custom-back @back="backToGenealogies" /></view><view class="page-content"><view class="state-card"><text>创建家谱待结果恢复合同</text><text>创建家谱后还必须用真实响应中的 `genealogyId` 创建首位人物当前没有可靠的创建结果恢复查询 owner且不能从泛型我的家谱列表按名称猜 ID页面不再生成本地家谱或本地首位人物预览</text><AppButton block label="返回我的家谱" @click="backToGenealogies" /></view></view></view></template>
<script setup>import AppButton from "@/components/AppButton.vue";import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";import PageHeader from "@/components/PageHeader.vue";import {returnTo} from "@/utils/navigation.js";const backToGenealogies=()=>returnTo("G01");</script>
<style scoped lang="scss">@use "../../styles/adaptive-frame-profiles.scss" as adaptive;.create-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}</style>
+4 -718
View File
@@ -1,718 +1,4 @@
<!-- 页面编号G-06用途搜索家谱邀请码加入双模式定位目标家谱 -->
<template>
<view class="search-page">
<GenealogyPageBackground />
<view class="search-page__header"><PageHeader title="加入家谱" /></view>
<view class="search-page__content">
<view class="mode-tabs">
<view
class="mode-tab"
:class="{ 'mode-tab--active': mode === 'search' }"
@click="switchMode('search')"
>搜索家谱</view
>
<view
class="mode-tab"
:class="{ 'mode-tab--active': mode === 'invite' }"
@click="switchMode('invite')"
>邀请码加入</view
>
</view>
<template v-if="mode === 'search'">
<view class="search-controls">
<view class="search-field">
<input
v-model.trim="keyword"
class="search-input"
placeholder="姓氏、谱名、地区或堂号"
placeholder-class="search-input__placeholder"
@confirm="search"
/>
<view v-if="keyword" class="search-clear" @click="clearSearch"
>清空</view
>
</view>
<view class="search-action" @click="search">
<text class="search-action__label">搜索</text>
</view>
</view>
<view class="filter-row">
<text class="filter-label">地区</text>
<view
v-for="area in areas"
:key="area"
class="filter-chip"
:class="{ 'filter-chip--active': selectedArea === area }"
@click="selectedArea = area"
>{{ area }}</view
>
</view>
<image
class="search-divider"
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<AppLoading
v-if="searchState === 'loading'"
variant="section"
text="正在检索公开家谱"
description="请稍候,正在整理匹配结果。"
/>
<view
v-else-if="searchState === 'initial'"
class="search-status search-initial"
>
<text class="search-status__lead">先确认家族是否已有家谱</text>
<text class="search-status__copy"
>可按姓氏谱名地区或堂号查找避免重复创建</text
>
<image
class="search-status__hall"
src="/static/assets/foundation/transparent/root-header-hall.png"
mode="aspectFit"
/>
</view>
<view v-else-if="searchState === 'results'" class="search-results">
<view class="search-results__head"
><text>检索到 {{ results.length }} 部家谱</text
><text>请核对支系与管理信息</text></view
>
<view
v-for="item in results"
:key="item.id"
class="result-card genealogy-card"
@click="openPreview(item)"
>
<view class="result-card__content">
<view class="result-card__head">
<view>
<text class="result-card__name">{{ item.name }}</text>
<text class="result-card__identity"
>{{ item.surname }} · {{ item.hall }}</text
>
</view>
<text
class="result-card__relation"
:class="`result-card__relation--${item.relation}`"
>{{ resultRelationLabel(item) }}</text
>
</view>
<view class="result-card__facts">
<text>地区{{ item.location }}</text>
<text>所属上级谱{{ item.parentName }}</text>
<text>当前支系{{ item.branchName }}</text>
<text
>管理信息{{ item.manager }} · {{ item.certification }}</text
>
<text
>{{ item.memberCount }} 位成员 · 更新于
{{ item.updatedAt }}</text
>
</view>
<view
v-if="resultActionLabel(item)"
class="result-card__action"
@click.stop="handleResultAction(item)"
>{{ resultActionLabel(item) }}</view
>
</view>
</view>
</view>
<view
v-else-if="searchState === 'empty'"
class="search-status search-empty"
>
<image
class="search-status__cloud"
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
mode="aspectFit"
/>
<text class="search-status__lead">暂未找到匹配家谱</text>
<text class="search-status__copy"
>可调整地区或关键词后重试也可使用邀请码加入</text
>
</view>
<view v-else class="search-status search-error">
<text class="search-status__lead">检索暂时失败</text>
<text class="search-status__copy"
>当前仅展示失败样式请稍后重新搜索</text
>
<view class="status-retry" @click="search">
<text>重新搜索</text>
</view>
</view>
</template>
<template v-else>
<view class="invite-block">
<text class="invite-title">输入家谱邀请码</text>
<text class="invite-copy"
>邀请码用于定位指定家谱加入前仍需确认真实姓名和关系</text
>
<view class="invite-controls">
<view class="invite-field">
<input
v-model.trim="inviteCode"
class="invite-input"
maxlength="12"
placeholder="请输入邀请码"
placeholder-class="search-input__placeholder"
@input="resetInvite"
/>
</view>
<view class="search-action" @click="verifyInvite">
<text class="search-action__label">验证</text>
</view>
</view>
<text class="invite-demo">样式验证可输入 JP2026</text>
</view>
<image
class="search-divider"
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<view
v-if="inviteState === 'initial'"
class="search-status invite-initial"
>
<text class="search-status__lead">通过邀请码直接定位家谱</text>
<text class="search-status__copy"
>验证有效后仅显示目标家谱仍需填写身份关系本地验证不会变更成员身份</text
>
</view>
<view
v-else-if="inviteState === 'invalid'"
class="search-status invite-invalid"
>
<text class="search-status__lead">邀请码无效或已过期</text>
<text class="search-status__copy"
>请核对邀请码或向家谱管理员重新获取</text
>
</view>
<view v-else class="invite-result">
<text class="invite-result__label">已定位目标家谱</text>
<view class="result-card genealogy-card">
<view class="result-card__content">
<text class="result-card__name">{{ inviteTarget.name }}</text>
<view class="result-card__facts">
<text
>{{ inviteTarget.surname }} · {{ inviteTarget.hall }} ·
{{ inviteTarget.location }}</text
>
<text>所属上级谱{{ inviteTarget.parentName }}</text>
<text>当前支系{{ inviteTarget.branchName }}</text>
<text
>{{ inviteTarget.manager }} ·
{{ inviteTarget.certification }}</text
>
</view>
<view class="result-card__action" @click="confirmInvite"
>填写关系信息</view
>
</view>
</view>
<text class="invite-result__note">当前为样式验证不会变更成员身份</text>
</view>
</template>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
isGenealogySearchVisible,
publicGenealogies,
} from "@/data/mock.js";
import { goRoot, openPage } from "@/utils/navigation.js";
const mode = ref("search");
const keyword = ref("");
const selectedArea = ref("全部");
const searchState = ref("initial");
const inviteCode = ref("");
const inviteState = ref("initial");
const results = ref([]);
let searchTimer = null;
const invalidateSearch = () => {
const timer = searchTimer;
searchTimer = null;
if (timer) clearTimeout(timer);
};
const areas = ["全部", "河南", "山东"];
const inviteTarget = computed(() => publicGenealogies[0]);
const syncModeFromRoute = (query = {}) => {
mode.value = query?.mode === "invite" ? "invite" : "search";
if (mode.value === "search" && query?.state === "loading")
searchState.value = "loading";
};
onLoad((query) => syncModeFromRoute(query));
onUnload(invalidateSearch);
const switchMode = (nextMode) => {
invalidateSearch();
mode.value = nextMode;
results.value = [];
searchState.value = "initial";
inviteState.value = "initial";
};
const search = () => {
invalidateSearch();
searchState.value = "loading";
const value = keyword.value.trim();
const area = selectedArea.value;
const timer = setTimeout(() => {
if (searchTimer !== timer || mode.value !== "search") return;
searchTimer = null;
if (value === "失败") {
searchState.value = "error";
return;
}
results.value = publicGenealogies.filter((item) => {
const matchesKeyword =
!value ||
`${item.name}${item.surname}${item.location}${item.hall}`.includes(
value,
);
const matchesArea =
area === "全部" || item.location.includes(area);
return isGenealogySearchVisible(String(item.id)) && matchesKeyword && matchesArea;
});
searchState.value = results.value.length ? "results" : "empty";
}, 260);
searchTimer = timer;
};
const clearSearch = () => {
invalidateSearch();
keyword.value = "";
results.value = [];
searchState.value = "initial";
};
const openPreview = (item) => {
if (
item.relation === "available" &&
getGenealogyFixtureAccess(String(item.id)).canApply
) {
return openPage(
"G05",
{ genealogyId: String(item.id) },
"G06",
);
}
return false;
};
const resultActionLabel = (item) => {
if (item.relation === "pending") return "查看申请进度";
if (item.relation === "joined") return "切换到该家谱";
if (item.relation === "owned") return "进入我的家谱";
if (!getGenealogyFixtureAccess(String(item.id)).canApply) return "";
return {
available: "申请加入",
rejected: "修改后重新申请",
removed: "重新申请",
}[item.relation] || "";
};
const resultRelationLabel = (item) =>
({
available: "可申请",
joined: "已加入",
pending: "审核中",
rejected: "已拒绝",
removed: "已退出",
owned: "我创建的",
})[item.relation] || "关系待确认";
const handleResultAction = (item) => {
if (item.relation === "pending")
return openPage("G09", { status: "pending" }, "G06");
if (
["available", "rejected", "removed"].includes(item.relation) &&
getGenealogyFixtureAccess(String(item.id)).canApply
)
return openPage(
"G08",
{ genealogyId: String(item.id), source: "search" },
"G06",
);
if (item.relation === "joined" || item.relation === "owned")
return goRoot("G01", { genealogyId: String(item.id) });
return false;
};
const verifyInvite = () => {
inviteState.value =
inviteCode.value.toUpperCase() === "JP2026" ? "valid" : "invalid";
};
const resetInvite = () => {
inviteState.value = "initial";
};
const confirmInvite = () => {
if (inviteState.value !== "valid") return false;
if (inviteCode.value.trim().toUpperCase() !== "JP2026") return false;
return openPage(
"G08",
{
genealogyId: String(inviteTarget.value.id),
source: "invite",
},
"G06",
);
};
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.search-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.search-page__header {
z-index: 2;
}
.search-page__content {
z-index: 3;
min-height: calc(100vh - 104rpx);
padding: 30rpx 24rpx calc(58rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.mode-tabs {
display: flex;
width: 500rpx;
margin: 30rpx auto 0;
border-bottom: 1rpx solid rgba(181, 138, 75, 0.45);
}
.mode-tab {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 70rpx;
color: $ink-muted;
font-family: "STKaiti", "KaiTi", serif;
font-size: 28rpx;
}
.mode-tab--active {
margin-bottom: -2rpx;
background: linear-gradient($brand-red, $brand-red) center bottom /
calc(100% - 84rpx) 4rpx no-repeat;
color: $brand-red;
font-weight: 700;
}
.search-controls {
display: flex;
align-items: center;
gap: 20rpx;
margin-top: 32rpx;
}
.invite-controls {
display: flex;
align-items: center;
gap: 20rpx;
margin-top: 32rpx;
}
.search-field {
@include adaptive.adaptive-g06-search-field;
display: grid;
width: 430rpx;
min-height: 95rpx;
}
.search-input {
z-index: 1;
grid-area: 1 / 1;
width: 100%;
height: 100%;
padding: 0 82rpx 0 30rpx;
box-sizing: border-box;
color: $ink;
font-size: 25rpx;
}
.search-input__placeholder {
color: #ab9c83;
}
.search-clear {
z-index: 2;
display: flex;
grid-area: 1 / 1;
justify-self: end;
height: 100%;
align-items: center;
margin-right: 20rpx;
color: $brand-red;
font-size: 21rpx;
}
.search-action {
@include adaptive.adaptive-g06-search-action;
display: flex;
width: 200rpx;
min-height: 88rpx;
align-items: center;
justify-content: center;
}
.search-action__label {
z-index: 1;
color: #fff4dc;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
letter-spacing: 5rpx;
}
.filter-row {
display: flex;
align-items: center;
gap: 12rpx;
margin: 16rpx 14rpx 0;
}
.filter-label {
color: $ink-muted;
font-size: 22rpx;
}
.filter-chip {
display: flex;
min-width: 82rpx;
min-height: 52rpx;
align-items: center;
justify-content: center;
color: $ink-muted;
font-size: 22rpx;
}
.filter-chip--active {
color: $brand-red;
font-weight: 700;
}
.search-divider {
display: block;
width: 100%;
height: 30rpx;
margin: 24rpx 0 24rpx;
}
.search-status {
display: flex;
min-height: 240rpx;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16rpx 28rpx 24rpx;
box-sizing: border-box;
text-align: center;
}
.search-status__cloud {
width: 92rpx;
height: 46rpx;
margin-bottom: 4rpx;
opacity: 0.78;
}
.search-status__lead {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 30rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.search-status__copy {
margin-top: 12rpx;
color: $ink-muted;
font-size: 22rpx;
line-height: 36rpx;
}
.search-status__hall {
width: 330rpx;
height: 132rpx;
margin-top: 210rpx;
opacity: 0.22;
filter: grayscale(1);
}
.status-retry {
display: flex;
width: 300rpx;
min-height: 76rpx;
align-items: center;
justify-content: center;
margin-top: 24rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.status-retry text {
z-index: 1;
color: #fff9ed;
font-size: 25rpx;
font-weight: 700;
}
.search-results {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 4rpx;
}
.search-results__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16rpx;
color: $ink-muted;
font-size: 21rpx;
}
.search-results__head text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 27rpx;
font-weight: 700;
}
.result-card {
@include adaptive.adaptive-genealogy-list-card;
min-height: 314rpx;
box-sizing: border-box;
padding: 28rpx 30rpx;
}
.result-card__content {
z-index: 1;
}
.result-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.result-card__name,
.result-card__identity {
display: block;
}
.result-card__name {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 33rpx;
font-weight: 700;
}
.result-card__identity {
margin-top: 5rpx;
color: $ink-muted;
font-size: 22rpx;
}
.result-card__relation {
flex: 0 0 auto;
max-width: 190rpx;
margin-left: 12rpx;
color: #9a641f;
font-size: 21rpx;
text-align: right;
}
.result-card__relation--rejected,
.result-card__relation--removed {
color: $brand-red;
}
.result-card__facts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8rpx 18rpx;
margin-top: 16rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 32rpx;
}
.result-card__facts text:nth-child(4),
.result-card__facts text:nth-child(5) {
grid-column: 1 / -1;
}
.result-card__action {
display: flex;
min-height: 58rpx;
align-items: center;
justify-content: center;
margin-top: 14rpx;
color: $brand-red;
font-family: "STKaiti", "KaiTi", serif;
font-size: 27rpx;
font-weight: 700;
}
.invite-block {
padding: 24rpx 18rpx 0;
}
.invite-title {
display: block;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
}
.invite-copy {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: 22rpx;
line-height: 35rpx;
}
.invite-field {
@include adaptive.adaptive-g06-search-field;
display: grid;
flex: 1;
min-height: 95rpx;
}
.invite-input {
z-index: 1;
grid-area: 1 / 1;
width: 100%;
height: 100%;
padding: 0 30rpx;
box-sizing: border-box;
color: $ink;
font-size: 25rpx;
letter-spacing: 3rpx;
}
.invite-demo {
display: block;
margin-top: 8rpx;
color: #9b876f;
font-size: 19rpx;
}
.invite-result__label {
display: block;
margin: 0 12rpx 12rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 27rpx;
font-weight: 700;
}
.invite-result__note {
display: block;
margin-top: 12rpx;
color: $brand-red;
font-size: 22rpx;
text-align: center;
}
@media screen and (max-width: 340px) {
.search-page__content {
padding-right: 20rpx;
padding-left: 20rpx;
}
.search-field {
width: 430rpx;
}
.result-card {
padding-right: 24rpx;
padding-left: 24rpx;
}
.result-card__facts {
grid-template-columns: 1fr;
}
.result-card__facts text {
grid-column: 1 !important;
}
}
</style>
<!-- 页面编号G-06用途搜索公开家谱公开列表 item DTO 未声明时保持关闭 -->
<template><view class="search-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="搜索家谱" custom-back @back="backToGenealogies" /></view><view class="page-content"><view class="state-card"><text>公开家谱搜索待后端字段合同</text><text>公开家谱读取没有声明家谱 ID名称地区简介成员数或可申请状态字段页面不再展示 fixture 搜索结果也不从猜测条目进入申请</text><AppButton block label="返回我的家谱" @click="backToGenealogies" /></view></view></view></template>
<script setup>import AppButton from "@/components/AppButton.vue";import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";import PageHeader from "@/components/PageHeader.vue";import {returnTo} from "@/utils/navigation.js";const backToGenealogies=()=>returnTo("G01");</script>
<style scoped lang="scss">@use "../../styles/adaptive-frame-profiles.scss" as adaptive;.search-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}</style>
+4 -462
View File
@@ -1,462 +1,4 @@
<!-- 页面编号G-08用途申请加入家谱与提交成功/失败状态 -->
<template>
<view class="join-page">
<GenealogyPageBackground />
<view class="join-page__header">
<PageHeader :title="sourceContract.headerTitle" custom-back @back="requestBack" />
</view>
<view
class="join-panel"
:class="{
'join-state--form': joinState === 'form',
'join-state--success': joinState === 'success',
'join-state--error': joinState === 'error',
'join-state--ineligible': joinState === 'ineligible',
}"
>
<view v-if="joinState === 'form'" class="join-form">
<text class="join-form__eyebrow">{{ sourceContract.eyebrow }}</text>
<text class="join-form__title"
>{{ sourceContract.formTitle }} {{ genealogyName }}</text
>
<text class="join-form__copy">{{ sourceContract.formCopy }}</text>
<view class="join-field">
<text>真实姓名</text
><input
v-model="form.realName"
placeholder="请输入真实姓名"
placeholder-class="join-placeholder"
@input="clearFieldError('realName')"
/>
</view>
<text v-if="fieldErrors.realName" class="join-field-error">{{
fieldErrors.realName
}}</text>
<view class="join-field">
<text>与家谱关系</text
><input
v-model="form.relation"
placeholder="例如:某某某堂侄"
placeholder-class="join-placeholder"
@input="clearFieldError('relation')"
/>
</view>
<text v-if="fieldErrors.relation" class="join-field-error">{{
fieldErrors.relation
}}</text>
<text class="relation-help"
>请以家谱中一位已知长辈为参照例如某某某堂侄</text
>
<view class="join-field join-field--message">
<text>{{ sourceContract.thirdFieldLabel }}</text
><textarea
v-model="form.message"
auto-height
maxlength="80"
:placeholder="sourceContract.thirdFieldPlaceholder"
placeholder-class="join-placeholder"
/>
</view>
<text class="join-form__note">{{ sourceContract.note }}</text>
<view class="join-action" @click="submitJoin">
<text>{{
isSubmitting
? sourceContract.submittingLabel
: sourceContract.submitLabel
}}</text>
</view>
</view>
<view v-else class="join-result">
<text class="join-result__eyebrow">{{
joinState === "success"
? sourceContract.successEyebrow
: joinState === "ineligible"
? "当前不可申请"
: sourceContract.errorEyebrow
}}</text>
<text class="join-result__title">{{
joinState === "success"
? sourceContract.successTitle
: joinState === "ineligible"
? ineligibleTitle
: sourceContract.errorTitle
}}</text>
<text class="join-result__copy">{{ resultCopy }}</text>
<view
class="join-action"
@click="
joinState === 'success'
? completeFlow()
: joinState === 'ineligible'
? handleIneligibleAction()
: retryForm()
"
>
<text>{{
joinState === "success"
? sourceContract.nextLabel
: joinState === "ineligible"
? ineligibleActionLabel
: "重新填写"
}}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃填写?"
message="当前身份关系尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findGenealogyFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
goRoot,
handleBackPress,
openPage,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const genealogyId = ref("");
const source = ref("search");
const genealogyName = ref("这部家谱");
const joinState = ref("form");
const isSubmitting = ref(false);
const errorMessage = ref("");
const ineligibleRelation = ref("unknown");
const discardVisible = ref(false);
const form = reactive({ realName: "", relation: "", message: "" });
const fieldErrors = reactive({ realName: "", relation: "" });
let submitTimer = null;
const isDirty = computed(() =>
Boolean(form.realName || form.relation || form.message),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const sourceContract = computed(() =>
source.value === "invite"
? {
headerTitle: "确认身份关系",
eyebrow: "邀请码定位预览",
formTitle: "核对家谱",
formCopy:
"请填写真实身份和亲属关系;当前仅验证页面流程,不会变更成员身份。",
thirdFieldLabel: "补充信息",
thirdFieldPlaceholder: "选填:补充祖居地、长辈姓名等信息",
note: "后端尚未提供邀请码验证与直接入谱接口。",
submitLabel: "完成本地校验",
submittingLabel: "正在校验…",
successEyebrow: "本地校验完成",
successTitle: "信息尚未提交服务器",
errorEyebrow: "校验未完成",
errorTitle: "暂时无法完成校验",
nextLabel: "返回我的家谱",
successCopy: `本地流程预览已完成“${genealogyName.value}”的身份填写;返回后不会选中或加入这部家谱。`,
}
: {
headerTitle: "申请加入家谱",
eyebrow: "公开家谱入谱申请",
formTitle: "申请加入",
formCopy: "请填写真实身份和亲属关系,管理员审核后会通过消息告知结果。",
thirdFieldLabel: "申请说明",
thirdFieldPlaceholder: "补充祖居地、长辈姓名等核验信息",
note: "当前仅验证页面流程,后端申请接口接入后才能正式提交。",
submitLabel: "完成本地校验",
submittingLabel: "正在校验…",
successEyebrow: "本地校验完成",
successTitle: "申请尚未提交服务器",
errorEyebrow: "申请未提交",
errorTitle: "暂时无法完成校验",
nextLabel: "查看我的申请",
successCopy: `本地流程预览已完成“${genealogyName.value}”的申请填写,当前不会新增审核记录。`,
},
);
const resultCopy = computed(() =>
joinState.value === "success"
? sourceContract.value.successCopy
: joinState.value === "ineligible"
? ({
owned: "这是你创建的家谱,无需重复提交加入申请。",
joined: "你已经是这部家谱的成员,无需重复申请。",
pending: "这部家谱已有待审核申请,请先查看申请进度。",
})[ineligibleRelation.value] ||
"当前家谱不存在、未公开或不可申请,请返回后重新选择。"
: errorMessage.value ||
"请检查网络后重新填写;未成功提交的内容不会进入审核列表。",
);
const ineligibleTitle = computed(
() =>
({
owned: "你已拥有这部家谱",
joined: "你已加入这部家谱",
pending: "申请正在审核中",
})[ineligibleRelation.value] || "无法打开申请表",
);
const ineligibleActionLabel = computed(
() =>
ineligibleRelation.value === "pending"
? "查看申请进度"
: ["owned", "joined"].includes(ineligibleRelation.value)
? "返回我的家谱"
: "返回上一页",
);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
source.value = query.source === "invite" ? "invite" : "search";
if (!genealogyId.value) {
ineligibleRelation.value = "unknown";
joinState.value = "ineligible";
return;
}
const fixture = findGenealogyFixture(genealogyId.value);
genealogyName.value = fixture?.name || "这部家谱";
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!fixture || !access.canApply) {
ineligibleRelation.value = access.relation;
joinState.value = "ineligible";
return;
}
if (query.state === "success") joinState.value = "success";
});
const requestBack = () => {
if (joinState.value === "success") return completeFlow();
return runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const submitJoin = () => {
if (isSubmitting.value) return;
fieldErrors.realName = form.realName.trim() ? "" : "请填写真实姓名";
fieldErrors.relation = form.relation.trim() ? "" : "请填写与家谱的关系";
if (fieldErrors.realName || fieldErrors.relation) return;
const submitSnapshot = Object.freeze({ ...form });
isSubmitting.value = true;
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
joinState.value =
submitSnapshot.realName.trim() === "失败" ? "error" : "success";
if (joinState.value === "error")
errorMessage.value =
source.value === "invite"
? "邀请码加入暂未完成,请稍后重试。"
: "申请暂未提交,请稍后重试。";
isSubmitting.value = false;
}, 280);
submitTimer = timer;
};
const clearFieldError = (field) => {
fieldErrors[field] = "";
};
const retryForm = () => {
joinState.value = "form";
errorMessage.value = "";
};
const handleIneligibleAction = () => {
if (ineligibleRelation.value === "pending")
return openPage("G09", { status: "pending" }, "G08");
if (["owned", "joined"].includes(ineligibleRelation.value))
return goRoot("G01", { genealogyId: genealogyId.value });
return goBack();
};
const completeFlow = () =>
source.value === "invite" ? returnTo("G01", {}) : returnTo("G09", {});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.join-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.join-page__header {
z-index: 3;
}
.join-panel {
@include adaptive.adaptive-genealogy-state-panel;
z-index: 2;
width: calc(100% - 32rpx);
min-height: min(590px, calc((100vw - 16px) * 1.337));
margin: 22rpx auto 0;
padding: 70rpx 8%;
box-sizing: border-box;
}
.join-form {
min-width: 0;
}
.join-form__eyebrow,
.join-result__eyebrow {
display: block;
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
}
.join-form__title,
.join-result__title {
display: block;
margin-top: 12rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 36rpx;
font-weight: 700;
}
.join-form__copy,
.join-result__copy {
display: block;
margin-top: 12rpx;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.6;
}
.join-field {
@include adaptive.adaptive-genealogy-form-field;
display: grid;
min-height: 92rpx;
margin-top: 18rpx;
}
.join-form__previous {
display: block;
margin-top: 14rpx;
color: $brand-red;
font-size: 22rpx;
line-height: 1.55;
}
.join-field > text {
z-index: 1;
grid-area: 1 / 1;
align-self: center;
margin-left: 24rpx;
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.join-field input,
.join-field textarea {
z-index: 1;
grid-area: 1 / 1;
width: auto;
min-width: 0;
min-height: 92rpx;
margin-right: 20rpx;
margin-left: 170rpx;
color: $ink;
font-size: 23rpx;
line-height: 92rpx;
}
.join-field textarea {
box-sizing: border-box;
padding-top: 26rpx;
line-height: 1.5;
}
.join-placeholder {
color: #a79884;
}
.join-field-error {
display: block;
margin-top: 3rpx;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
text-align: right;
}
.relation-help {
display: block;
margin-top: 5rpx;
color: #8e7b67;
font-size: 19rpx;
line-height: 29rpx;
}
.join-form__note {
display: block;
margin-top: 18rpx;
color: $ink-muted;
font-size: 21rpx;
text-align: center;
}
.join-action {
display: flex;
width: 100%;
min-height: 82rpx;
align-items: center;
justify-content: center;
margin-top: 20rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.join-action text {
z-index: 1;
color: #fff9ed;
font-size: 27rpx;
font-weight: 700;
letter-spacing: 3rpx;
}
.join-result {
text-align: center;
}
.join-state--success,
.join-state--error {
padding: 170rpx 12% 70rpx;
}
.join-result__eyebrow {
text-align: center;
}
.join-result__copy {
margin-top: 22rpx;
}
.join-result .join-action {
width: 420rpx;
max-width: 100%;
margin: 34rpx auto 0;
}
@media (min-width: 400px) {
.join-panel {
width: calc(100% - 48rpx);
}
}
</style>
<!-- 页面编号G-08用途加入申请公开家谱详情/权限投影不完整时保持关闭 -->
<template><view class="join-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="申请加入家谱" custom-back @back="backToSearch" /></view><view class="page-content"><view class="state-card"><text>加入申请待后端字段合同</text><text>申请提交 operation 不等于可安全发起申请当前没有公开家谱详情可申请权限或稳定家谱条目 ID 的消费投影页面不再使用 fixture 家谱或本地申请成功提示</text><AppButton block label="返回搜索家谱" @click="backToSearch" /></view></view></view></template>
<script setup>import AppButton from "@/components/AppButton.vue";import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";import PageHeader from "@/components/PageHeader.vue";import {returnTo} from "@/utils/navigation.js";const backToSearch=()=>returnTo("G06");</script>
<style scoped lang="scss">@use "../../styles/adaptive-frame-profiles.scss" as adaptive;.join-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}</style>
+4 -387
View File
@@ -1,394 +1,11 @@
<!-- 页面编号G-09用途我的家谱申请列表与空/失败状态 -->
<!-- 页面编号G-09用途我的申请列表 DTO 未声明时不展示 fixture 申请 -->
<template>
<view class="application-page">
<GenealogyPageBackground />
<view class="application-page__header">
<PageHeader title="我的申请" custom-back @back="requestBack" />
</view>
<view
class="application-content"
:class="{
'application-state--list': applicationState === 'list',
'application-state--empty': applicationState === 'empty',
'application-state--error': applicationState === 'error',
}"
>
<template v-if="applicationState === 'list'">
<view class="application-intro">
<text>入谱申请进度</text><text>审核结果会同步到消息中心</text>
</view>
<view
v-for="item in applications"
:key="item.id"
class="application-card"
>
<view class="application-card__body">
<text class="application-card__name">{{ item.genealogyName }}</text>
<text class="application-card__time">{{ item.appliedAt }}</text>
<text class="application-card__relation">{{ item.relation }}</text>
<text
class="application-card__status"
:class="`application-card__status--${item.status.toLowerCase()}`"
>{{ statusLabel(item.status) }}</text
>
<text class="application-card__hint">{{
statusHint(item.status)
}}</text>
<view
v-if="actionFor(item)"
class="application-card__action"
@click="handleApplicationAction(item)"
>{{ actionFor(item) }}</view
>
</view>
</view>
</template>
<AppLoading
v-else-if="applicationState === 'loading'"
text="正在整理申请记录"
description="请稍候,正在同步审核状态。"
/>
<view v-else class="application-state-card">
<view class="application-state-card__copy">
<text>{{ stateTitle }}</text>
<text>{{ stateCopy }}</text>
</view>
</view>
<view
v-if="applicationState !== 'list' && applicationState !== 'loading'"
class="application-page__action"
@click="
applicationState === 'error' ? loadApplications({}) : toSearch()
"
>
<text>{{
applicationState === "error" ? "重新查看" : "查找公开家谱"
}}</text>
</view>
</view>
<AppDialog
:visible="!!withdrawTarget"
title="预览撤回效果"
:message="
withdrawTarget
? `当前只更新本页对“${withdrawTarget.genealogyName}”的撤回预览,不会向服务器提交;真实申请仍可能处于审核中。`
: ''
"
confirm-text="查看本地效果"
cancel-text="暂不撤回"
show-cancel
:close-on-mask="false"
@confirm="confirmWithdraw"
@cancel="cancelWithdraw"
/>
</view>
<view class="applications-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="我的申请" custom-back @back="returnToGenealogies" /></view><view class="page-content"><view class="state-card"><text>申请列表待后端字段合同</text><text>申请读取响应没有声明申请 ID家谱名称申请时间状态或审核备注字段页面已停止展示本地申请和本地撤回结果</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view></view></view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findGenealogyFixture } from "@/data/mock.js";
import { handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
const applications = ref([]);
const applicationState = ref("loading");
const errorMessage = ref("");
const withdrawTarget = ref(null);
const genealogyNameFor = (genealogyId) =>
findGenealogyFixture(genealogyId)?.name || "未知家谱";
const applicationSamples = [
{
id: "pending-2003",
genealogyId: "2003",
genealogyName: genealogyNameFor("2003"),
relation: "自述为汤正华堂侄",
appliedAt: "今天 10:24",
status: "PENDING",
},
{
id: "approved-1002",
genealogyId: "1002",
genealogyName: genealogyNameFor("1002"),
relation: "祖居河南汝南",
appliedAt: "昨天 18:02",
status: "APPROVED",
},
{
id: "rejected-2004",
genealogyId: "2004",
genealogyName: genealogyNameFor("2004"),
relation: "补充材料不足",
appliedAt: "7月12日 09:18",
status: "REJECTED",
},
];
const statusLabel = (status) =>
({
PENDING: "审核中",
APPROVED: "已通过",
REJECTED: "未通过",
LOCAL_WITHDRAWN: "本地撤回预览",
})[status] || "状态未知";
const statusHint = (status) =>
({
PENDING: "管理员尚未处理,可在审核前撤回",
APPROVED: "申请已通过,可进入这部家谱",
REJECTED: "请修改关系说明后重新提交",
LOCAL_WITHDRAWN: "尚未提交服务器,真实申请仍可能处于审核中",
})[status] || "";
const actionFor = (item) =>
({ PENDING: "预览撤回效果", APPROVED: "进入家谱", REJECTED: "修改后重新提交" })[
item.status
] || "";
const stateTitle = computed(() =>
applicationState.value === "empty"
? "还没有入谱申请"
: applicationState.value === "loading"
? "正在整理申请记录"
: "申请记录暂时无法读取",
);
const stateCopy = computed(() =>
applicationState.value === "empty"
? "从家谱搜索提交的申请会显示在这里;邀请码本地校验不会生成申请记录。"
: applicationState.value === "loading"
? "请稍候,正在同步审核状态。"
: errorMessage.value || "请检查网络后重新查看。",
);
const loadApplications = (query = {}) => {
applicationState.value = "loading";
errorMessage.value = "";
if (query.state === "empty") {
applicationState.value = "empty";
return;
}
if (query.state === "error") {
applicationState.value = "error";
return;
}
if (query.state === "loading") {
applicationState.value = "loading";
return;
}
applications.value = applicationSamples.map((item) => ({ ...item }));
if (query.status) {
const requestedStatus = String(query.status).toUpperCase();
applications.value = applications.value.filter(
(item) => item.status === requestedStatus,
);
if (!applications.value.length) applicationState.value = "empty";
}
if (applicationState.value !== "empty") applicationState.value = "list";
};
onLoad(loadApplications);
const requestBack = () =>
runBackGuard({
transientOpen: Boolean(withdrawTarget.value),
"close-transient": cancelWithdraw,
});
onBackPress((event) => handleBackPress(event, requestBack));
const handleApplicationAction = (item) => {
if (item.status === "APPROVED")
return openPage(
"G05",
{ genealogyId: String(item.genealogyId) },
"G09",
);
if (item.status === "REJECTED")
return openPage(
"G08",
{ genealogyId: String(item.genealogyId), source: "search" },
"G09",
);
if (item.status === "PENDING") withdrawTarget.value = item;
};
const cancelWithdraw = () => {
withdrawTarget.value = null;
};
const confirmWithdraw = () => {
const target = applications.value.find(
(item) => item.id === withdrawTarget.value?.id,
);
if (target) target.status = "LOCAL_WITHDRAWN";
cancelWithdraw();
};
const toSearch = () => openPage("G06", {}, "G09");
import AppButton from "@/components/AppButton.vue"; import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const returnToGenealogies=()=>returnTo("G01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.application-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.application-page__header {
z-index: 3;
}
.application-content {
z-index: 2;
padding: 28rpx 24rpx 60rpx;
}
.application-intro {
display: flex;
align-items: baseline;
justify-content: space-between;
margin: 0 8rpx 20rpx;
}
.application-intro text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
}
.application-intro text:last-child {
color: $ink-muted;
font-size: 24rpx;
}
.application-card {
@include adaptive.adaptive-genealogy-list-card;
display: grid;
width: 100%;
min-height: 210rpx;
margin-bottom: 12rpx;
}
.application-card__body {
display: grid;
min-width: 0;
min-height: 174rpx;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8rpx 14rpx;
padding: 26rpx 28rpx 22rpx 40rpx;
box-sizing: border-box;
}
.application-card__name {
grid-row: 1;
grid-column: 1;
align-self: start;
justify-self: start;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 30rpx;
font-weight: 700;
}
.application-card__time {
grid-row: 1;
grid-column: 2;
align-self: start;
justify-self: end;
margin-top: 3rpx;
color: #998873;
font-size: 23rpx;
}
.application-card__relation {
display: block;
grid-row: 2;
grid-column: 1 / -1;
align-self: start;
justify-self: start;
margin-top: 0;
color: $ink-muted;
font-size: 24rpx;
}
.application-card__status {
grid-row: 2;
grid-column: 2;
align-self: center;
justify-self: end;
margin-right: 3%;
color: $brand-red;
font-size: 25rpx;
font-weight: 700;
}
.application-card__status--approved {
color: #537368;
}
.application-card__status--rejected {
color: #7e6f62;
}
.application-card__hint {
grid-row: 3;
grid-column: 1;
align-self: center;
justify-self: start;
margin-bottom: 4rpx;
color: #766653;
font-size: 23rpx;
}
.application-card__action {
z-index: 2;
display: flex;
grid-row: 3;
grid-column: 2;
align-self: center;
justify-self: end;
min-height: 54rpx;
align-items: center;
margin-right: 2%;
margin-bottom: 0;
color: $brand-red;
font-size: 23rpx;
font-weight: 700;
white-space: nowrap;
}
.application-state-card {
@include adaptive.adaptive-genealogy-list-card;
display: flex;
width: 100%;
min-height: 228rpx;
align-items: center;
justify-content: center;
margin-top: 80rpx;
padding: 48rpx 12%;
box-sizing: border-box;
}
.application-state-card__copy {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.application-state-card__copy text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
}
.application-state-card__copy text:last-child {
margin-top: 16rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.6;
}
.application-page__action {
display: flex;
width: 420rpx;
min-height: 82rpx;
align-items: center;
justify-content: center;
margin: 34rpx auto 0;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.application-page__action text {
z-index: 1;
color: #fff9ed;
font-size: 26rpx;
font-weight: 700;
}
.applications-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -471
View File
@@ -1,478 +1,11 @@
<!-- 页面编号G-10用途管理员入谱申请审核与空/失败状态 -->
<!-- 页面编号G-10用途加入申请审核缺申请列表 DTO/ID 来源时保持关闭 -->
<template>
<view class="review-page">
<GenealogyPageBackground />
<view class="review-page__header">
<PageHeader
title="入谱审核"
action="说明"
custom-back
@action="showHelp"
@back="requestBack"
/>
</view>
<view
class="review-content"
:class="{
'review-state--list': reviewState === 'list',
'review-state--empty': reviewState === 'empty',
'review-state--error': reviewState === 'error',
'review-state--no-permission': reviewState === 'no-permission',
}"
>
<view v-if="reviewState !== 'loading'" class="review-intro">
<text>核实亲属关系后再决定</text>
<text>当前只预览审核交互不会提交服务器</text>
</view>
<template v-if="reviewState === 'list'">
<view
v-for="item in applications"
:key="item.id"
class="application-card"
>
<view class="application-card__body">
<text class="application-card__name">{{ item.name }}</text>
<text class="application-card__phone">{{ item.phone }}</text>
<text class="application-card__time">{{ item.appliedAt }}</text>
<text class="application-card__relation">{{ item.relation }}</text>
<view v-if="item.status === 'PENDING'" class="review-actions">
<AppButton
compact
type="secondary"
label="预览拒绝"
@click="confirmAudit(item, false)"
/>
<AppButton
compact
label="预览通过"
@click="confirmAudit(item, true)"
/>
</view>
<view
v-else
class="review-result"
:class="
item.status === 'APPROVED'
? 'review-result--approved'
: 'review-result--rejected'
"
>
<text class="application-card__status">{{
item.status === "APPROVED" ? "已通过" : "已拒绝"
}}</text>
</view>
</view>
</view>
</template>
<AppLoading
v-else-if="reviewState === 'loading'"
text="正在整理审核申请"
description="请稍候,正在读取待审核记录。"
/>
<view v-else class="review-state-card">
<view class="review-state-card__copy">
<text>{{ stateTitle }}</text>
<text>{{ stateCopy }}</text>
</view>
</view>
<AppButton
v-if="reviewState === 'error'"
class="review-page__retry"
label="重新查看"
@click="loadApplications({ genealogyId })"
/>
</view>
<AppDialog
:visible="!!confirmation || helpVisible"
:title="
helpVisible
? '审核说明'
: confirmation?.approved
? '预览通过效果?'
: '预览拒绝效果?'
"
:message="
helpVisible
? '请核对申请人的姓名、亲属关系和补充说明,仅确认与本家谱存在真实关系的申请。'
: confirmation?.approved
? '当前只更新本页本地预览,不会让申请人成为成员,也不会提交服务器。'
: '当前只更新本页本地预览,不会通知申请人,也不会提交服务器。'
"
:confirm-text="
helpVisible
? '我知道了'
: confirmation?.approved
? '查看通过效果'
: '查看拒绝效果'
"
:show-cancel="!!confirmation"
compact-actions
:close-on-mask="false"
@confirm="helpVisible ? closeDialog() : applyAudit()"
@cancel="closeDialog"
>
<view v-if="confirmation && !confirmation.approved" class="rejection-field">
<text>拒绝原因</text>
<textarea
id="g10-rejection-reason"
v-model="rejectionReason"
auto-height
maxlength="120"
placeholder="请说明需要补充或核实的信息"
:aria-invalid="!!rejectionError"
aria-describedby="g10-rejection-error"
:focus="rejectionFocused"
@input="clearRejectionError"
/>
<text
v-if="rejectionError"
id="g10-rejection-error"
class="rejection-field__error"
role="alert"
>{{ rejectionError }}</text
>
</view>
</AppDialog>
<view v-if="feedbackVisible" class="review-feedback">
<text>{{ feedbackMessage }}</text>
</view>
</view>
<view class="review-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="申请审核" custom-back @back="returnToGenealogies" /></view><view class="page-content"><view class="state-card"><text>申请审核待后端字段合同</text><text>审核 operation 存在但没有可消费的申请列表 DTO 或稳定申请 ID 来源页面不再展示 fixture 申请或本地通过/拒绝预览</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view></view></view>
</template>
<script setup>
import { computed, nextTick, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { getGenealogyFixtureAccess } from "@/data/mock.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const applications = ref([]);
const reviewSamples = [
{
id: "review-1",
name: "汤志成",
phone: "139****6421",
relation: "自述为汤正华堂侄 · 祖居洛阳",
appliedAt: "今天 10:24",
status: "PENDING",
},
{
id: "review-2",
name: "汤雨薇",
phone: "136****2798",
relation: "自述为汤正国之女 · 已补充长辈姓名",
appliedAt: "昨天 18:02",
status: "PENDING",
},
];
const genealogyId = ref("");
const reviewState = ref("loading");
const errorMessage = ref("");
const confirmation = ref(null);
const rejectionReason = ref("");
const rejectionError = ref("");
const rejectionFocused = ref(false);
const helpVisible = ref(false);
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
let feedbackTimer = null;
onUnload(() => {
const timer = feedbackTimer;
feedbackTimer = null;
if (timer) clearTimeout(timer);
});
const stateTitle = computed(() =>
reviewState.value === "empty"
? "暂无待审核申请"
: reviewState.value === "no-permission"
? "当前账号无审核权限"
: "审核列表暂时无法读取",
);
const stateCopy = computed(() =>
reviewState.value === "empty"
? "新的入谱申请会在这里出现,并同步发送消息提醒。"
: reviewState.value === "no-permission"
? "只有家谱所有者或具备审核权限的管理员可以处理申请。"
: errorMessage.value || "请检查网络或家谱权限后重试。",
);
const loadApplications = (query = {}) => {
reviewState.value = "loading";
errorMessage.value = "";
genealogyId.value = String(query.genealogyId || "");
if (!genealogyId.value) {
errorMessage.value = "没有找到当前家谱,请从家谱总览进入。";
reviewState.value = "error";
return;
}
if (
getGenealogyFixtureAccess(genealogyId.value).accessRole !== "owner"
) {
reviewState.value = "no-permission";
return;
}
if (query.state === "loading") {
reviewState.value = "loading";
return;
}
if (query.state === "empty") {
reviewState.value = "empty";
return;
}
if (query.state === "error") {
reviewState.value = "error";
return;
}
if (query.state === "no-permission") {
reviewState.value = "no-permission";
return;
}
applications.value = reviewSamples.map((item) => ({ ...item }));
reviewState.value = "list";
};
onLoad(loadApplications);
const confirmAudit = (item, approved) => {
rejectionReason.value = "";
rejectionError.value = "";
rejectionFocused.value = false;
confirmation.value = { item, approved };
};
const closeDialog = () => {
confirmation.value = null;
rejectionReason.value = "";
rejectionError.value = "";
rejectionFocused.value = false;
helpVisible.value = false;
};
const requestBack = () =>
runBackGuard({
transientOpen: Boolean(confirmation.value) || helpVisible.value,
"close-transient": closeDialog,
});
onBackPress((event) => handleBackPress(event, requestBack));
const showFeedback = (message) => {
feedbackMessage.value = message;
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
const timer = setTimeout(() => {
if (feedbackTimer !== timer) return;
feedbackVisible.value = false;
feedbackTimer = null;
}, 1800);
feedbackTimer = timer;
};
const clearRejectionError = () => {
rejectionError.value = "";
};
const applyAudit = async () => {
const current = confirmation.value;
if (!current) return;
if (!current.approved && !rejectionReason.value.trim()) {
rejectionError.value = "请填写拒绝原因,方便申请人补充资料";
rejectionFocused.value = false;
await nextTick();
rejectionFocused.value = true;
return;
}
current.item.status = current.approved ? "APPROVED" : "REJECTED";
if (!current.approved) current.item.rejectionReason = rejectionReason.value.trim();
closeDialog();
showFeedback(
`本地审核预览已更新,尚未提交服务器 · ${current.approved ? "已通过" : "已拒绝"}`,
);
};
const showHelp = () => {
helpVisible.value = true;
};
import AppButton from "@/components/AppButton.vue"; import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const returnToGenealogies=()=>returnTo("G01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.review-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.review-page__header {
z-index: 1;
}
.rejection-field { width: 100%; margin: 20rpx 0; text-align: left; }
.rejection-field > text:first-child { display: block; color: $ink; font-size: 23rpx; font-weight: 700; }
.rejection-field textarea { @include adaptive.adaptive-genealogy-form-field; width: 100%; min-height: 110rpx; margin-top: 10rpx; padding: 18rpx; color: $ink; font-size: 23rpx; line-height: 1.55; }
.rejection-field__error { display: block; margin-top: 8rpx; color: $brand-red; font-size: 21rpx; }
.review-content {
z-index: 1;
padding: 24rpx 24rpx 60rpx;
}
.review-intro {
margin: 0 8rpx 24rpx;
padding-bottom: 22rpx;
background: url("/static/assets/modules/genealogy/transparent/section-divider.png") bottom center / 100% 14rpx no-repeat;
}
.review-intro text {
display: block;
}
.review-intro text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx;
font-weight: 700;
}
.review-intro text:nth-child(2) {
margin-top: 8rpx;
color: $ink-muted;
font-size: 24rpx;
}
.application-card {
@include adaptive.adaptive-genealogy-list-card;
width: 100%;
min-height: 218rpx;
margin-bottom: 12rpx;
}
.application-card__body {
display: grid;
width: 100%;
min-height: 182rpx;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: auto auto auto;
gap: 8rpx 0;
padding: 28rpx 28rpx 22rpx 40rpx;
box-sizing: border-box;
}
.application-card__name {
grid-column: 1;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 30rpx;
font-weight: 700;
}
.application-card__phone {
grid-column: 2;
margin-left: 14rpx;
color: $ink-muted;
font-size: 23rpx;
}
.application-card__time {
grid-column: 3;
justify-self: end;
margin-top: 3rpx;
color: #998873;
font-size: 23rpx;
}
.application-card__relation {
display: block;
grid-column: 1 / span 2;
grid-row: 2;
margin-top: 8rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.35;
}
.review-actions {
display: flex;
grid-column: 2 / 4;
grid-row: 3;
justify-self: end;
gap: 12rpx;
}
.review-actions .app-button {
width: 148rpx;
min-height: 68rpx;
}
.review-result {
@include adaptive.adaptive-scroll-button(primary);
display: grid;
grid-column: 2 / 4;
grid-row: 3;
justify-self: end;
width: 308rpx;
min-height: 68rpx;
}
.application-card__status {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
color: $brand-red;
font-size: 24rpx;
font-weight: 700;
}
.review-result--approved .application-card__status {
color: #fff9ed;
}
.review-result--approved {
border-image-source: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png");
}
.review-result--rejected .application-card__status {
color: $ink;
}
.review-result--rejected {
border-image-source: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png");
}
.review-state-card {
@include adaptive.adaptive-genealogy-list-card;
width: 100%;
min-height: 228rpx;
margin-top: 70rpx;
}
.review-state-card__copy {
display: flex;
min-height: 228rpx;
flex-direction: column;
justify-content: center;
padding: 48rpx 12%;
box-sizing: border-box;
text-align: center;
}
.review-state-card__copy text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
}
.review-state-card__copy text:last-child {
margin-top: 15rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.6;
}
.review-page__retry {
width: 420rpx;
min-height: 82rpx;
margin: 32rpx auto 0;
}
.review-feedback {
@include adaptive.adaptive-feedback-toast;
position: fixed;
z-index: 40;
top: 140rpx;
left: 50%;
display: flex;
min-width: 250rpx;
min-height: 76rpx;
align-items: center;
justify-content: center;
padding: 0 36rpx;
transform: translateX(-50%);
}
.review-feedback text {
color: $ink;
font-size: 23rpx;
}
.review-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -478
View File
@@ -1,485 +1,11 @@
<!-- 页面编号G-11用途家谱设置访问预设与家谱简介页面设计阶段使用本地模拟交互 -->
<!-- 页面编号G-11用途家谱设置缺可靠读取 DTO 与封面上传 owner 时保持关闭 -->
<template>
<view class="settings-page">
<GenealogyPageBackground />
<view class="settings-page__header">
<PageHeader title="家谱设置" custom-back @back="requestBack" />
</view>
<view
class="settings-panel"
:class="{
'settings-state--form': settingsState === 'form',
'settings-state--success': settingsState === 'success',
'settings-state--error': settingsState === 'error',
'settings-state--no-permission': settingsState === 'no-permission',
}"
>
<AppLoading
v-if="settingsState === 'loading'"
text="正在读取家谱设置"
description="请稍候,正在准备名称与访问规则。"
/>
<view v-else-if="settingsState === 'form'" class="settings-form">
<text class="settings-form__eyebrow">谱主可见 · 基础设置</text>
<text class="settings-form__title">完善家谱访问规则</text>
<text class="settings-form__copy"
>这里维护名称访问规则与家谱简介访问规则会同时决定公开范围和加入方式管理权转让在成员场景中单独处理</text
>
<view class="settings-field">
<text>家谱名称</text>
<input
v-model="genealogyDraft.name"
maxlength="20"
placeholder="请输入家谱名称"
placeholder-class="settings-placeholder"
@input="nameError = ''"
/>
</view>
<text v-if="nameError" class="settings-field-error">{{
nameError
}}</text>
<view class="visibility-block">
<text class="visibility-block__label">访问规则</text>
<view class="visibility-options">
<view
v-for="option in accessPresetOptions"
:key="option.value"
class="visibility-option"
@click="genealogyDraft.accessPreset = option.value"
>
<image
:src="
genealogyDraft.accessPreset === option.value
? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png'
: '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'
"
mode="aspectFit"
/>
<text
:class="{
'visibility-option__text--active':
genealogyDraft.accessPreset === option.value,
}"
>{{ option.label }}</text
>
</view>
</view>
<text class="visibility-block__hint">{{ visibilityHint }}</text>
</view>
<view class="settings-field settings-field--note">
<text>家谱简介</text>
<textarea
v-model="genealogyDraft.intro"
auto-height
maxlength="80"
placeholder="简要介绍家谱来源与支系"
placeholder-class="settings-placeholder"
/>
</view>
<view class="settings-action" @click="saveSettings">
<text>保存设置</text>
</view>
</view>
<view v-else class="settings-result">
<text class="settings-result__eyebrow">{{
settingsState === "success"
? "设置已保存"
: settingsState === "no-permission"
? "权限不足"
: "设置未保存"
}}</text>
<text class="settings-result__title">{{
settingsState === "success"
? "本页设置草稿已更新"
: settingsState === "no-permission"
? "当前账号不能修改家谱"
: "暂时无法打开家谱设置"
}}</text>
<text class="settings-result__copy">{{
settingsState === "success"
? `本地预览已更新,尚未提交服务器;当前只保留在本页,返回总览不会改变原资料 · ${genealogyDraft.name} · ${accessPresetLabel}`
: settingsState === "no-permission"
? "只有家谱所有者可以修改名称、访问规则和家谱简介。"
: "请从家谱总览重新进入,当前修改不会被保留。"
}}</text>
<view class="settings-action" @click="handleResultAction">
<text>{{
settingsState === "success" ? "继续调整" : "重新查看"
}}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃设置修改?"
message="当前修改尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续修改"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<view v-if="feedbackVisible" class="settings-feedback">
<text>本页草稿已更新</text>
</view>
</view>
<view class="settings-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="家谱设置" custom-back @back="returnToOverview" /></view><view class="page-content"><view class="state-card"><text>家谱设置待字段合同</text><text>更新 operation 已声明名称姓氏祠堂地区简介可见性等字段但当前没有可消费的概览 DTO 来安全预填也没有封面 `coverOssId` 的上传闭环页面不再以 fixture 编辑或本地预览保存</text><AppButton block :label="hasValidContext ? '返回家谱总览' : '返回上一页'" @click="returnToOverview" /></view></view></view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findGenealogyFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
GENEALOGY_ACCESS_PRESET,
GENEALOGY_ACCESS_PRESET_OPTIONS,
isGenealogyAccessPreset,
} from "@/utils/genealogy-contracts.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const settingsState = ref("loading");
const nameError = ref("");
const feedbackVisible = ref(false);
const discardVisible = ref(false);
let feedbackTimer = null;
const genealogyDraft = reactive({
name: "",
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
intro: "",
});
const originalDraft = ref("");
const isDirty = computed(
() =>
settingsState.value === "form" &&
JSON.stringify(genealogyDraft) !== originalDraft.value,
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const accessPresetOptions = GENEALOGY_ACCESS_PRESET_OPTIONS;
const accessPresetLabel = computed(
() =>
accessPresetOptions.find((item) => item.value === genealogyDraft.accessPreset)
?.label || "",
);
const visibilityHint = computed(() =>
genealogyDraft.accessPreset === GENEALOGY_ACCESS_PRESET.MEMBER_ONLY
? "只有已加入本家谱的成员可以查看谱系和家族资料。"
: "访客可检索到家谱并提交入谱申请,资料仍需审核后查看。",
);
const loadSettings = (query = {}) => {
genealogyId.value = String(query.genealogyId || "");
if (!genealogyId.value) {
settingsState.value = "error";
return;
}
if (
getGenealogyFixtureAccess(genealogyId.value).accessRole !== "owner"
) {
settingsState.value = "no-permission";
return;
}
const fixture = findGenealogyFixture(genealogyId.value);
if (!fixture) {
settingsState.value = "error";
return;
}
if (!isGenealogyAccessPreset(fixture.accessPreset)) {
settingsState.value = "error";
return;
}
Object.assign(genealogyDraft, {
name: fixture.name,
accessPreset: fixture.accessPreset,
intro: fixture.publicDescription || "家族资料,请妥善保存",
});
originalDraft.value = JSON.stringify(genealogyDraft);
settingsState.value =
query.state === "loading"
? "loading"
: query.state === "success"
? "success"
: query.state === "no-permission"
? "no-permission"
: query.state === "error" || !genealogyId.value
? "error"
: "form";
};
onLoad(loadSettings);
onUnload(() => {
const timer = feedbackTimer;
feedbackTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
"close-transient": cancelDiscard,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
const continueEditing = () => {
if (
getGenealogyFixtureAccess(genealogyId.value).accessRole === "owner"
) {
settingsState.value = "form";
}
};
const handleResultAction = () => {
if (settingsState.value === "success") return continueEditing();
if (settingsState.value === "error")
return loadSettings({ genealogyId: genealogyId.value });
return goBack();
};
const saveSettings = () => {
if (!genealogyDraft.name.trim()) {
nameError.value = "请填写家谱名称";
return;
}
if (genealogyDraft.name.trim().length > 20) {
nameError.value = "家谱名称不能超过 20 个字";
return;
}
originalDraft.value = JSON.stringify(genealogyDraft);
settingsState.value = "success";
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
const timer = setTimeout(() => {
if (feedbackTimer !== timer) return;
feedbackVisible.value = false;
feedbackTimer = null;
}, 1800);
feedbackTimer = timer;
};
import { computed,ref } from "vue"; import { onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToOverview=()=>hasValidContext.value?returnTo("G05",{genealogyId:genealogyId.value}):goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.settings-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.settings-page__header {
z-index: 3;
}
.settings-panel {
@include adaptive.adaptive-genealogy-state-panel;
z-index: 2;
width: calc(100% - 32rpx);
margin: 18rpx auto 0;
padding: 70rpx 8%;
box-sizing: border-box;
}
.settings-panel > .app-loading {
grid-area: 1 / 1 / -1 / -1;
}
.settings-form {
min-width: 0;
}
.settings-form__eyebrow,
.settings-result__eyebrow {
display: block;
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
}
.settings-form__title,
.settings-result__title {
display: block;
margin-top: 10rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 35rpx;
font-weight: 700;
}
.settings-form__copy,
.settings-result__copy {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.55;
}
.settings-field {
@include adaptive.adaptive-genealogy-form-field;
display: grid;
min-height: 92rpx;
margin-top: 17rpx;
}
.settings-field > text {
z-index: 1;
grid-area: 1 / 1;
align-self: center;
margin-left: 24rpx;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.settings-field input,
.settings-field textarea {
z-index: 1;
grid-area: 1 / 1;
width: auto;
min-width: 0;
min-height: 92rpx;
margin-right: 20rpx;
margin-left: 164rpx;
color: $ink;
font-size: 24rpx;
line-height: 92rpx;
}
.settings-field textarea {
box-sizing: border-box;
padding-top: 26rpx;
line-height: 1.5;
}
.settings-placeholder {
color: #a79884;
}
.settings-field-error {
display: block;
margin-top: 3rpx;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
text-align: right;
}
.visibility-block {
margin-top: 17rpx;
}
.visibility-block__label {
display: block;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.visibility-options {
display: flex;
gap: 14rpx;
margin-top: 10rpx;
}
.visibility-option {
display: grid;
width: calc(50% - 7rpx);
min-height: 64rpx;
}
.visibility-option image {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.visibility-option text {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.visibility-option .visibility-option__text--active {
color: #fff9ed;
}
.visibility-block__hint {
display: block;
margin-top: 9rpx;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.45;
}
.settings-field--note {
margin-top: 14rpx;
}
.settings-action {
display: flex;
width: 100%;
min-height: 78rpx;
align-items: center;
justify-content: center;
margin-top: 19rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.settings-action text {
z-index: 1;
color: #fff9ed;
font-size: 26rpx;
font-weight: 700;
letter-spacing: 3rpx;
}
.settings-result {
text-align: center;
}
.settings-state--success,
.settings-state--error,
.settings-state--no-permission {
padding: 180rpx 12% 70rpx;
}
.settings-result__eyebrow {
text-align: center;
}
.settings-result__copy {
margin-top: 22rpx;
}
.settings-result .settings-action {
width: 420rpx;
max-width: 100%;
margin: 34rpx auto 0;
}
.settings-feedback {
position: fixed;
z-index: 30;
top: 138rpx;
left: 50%;
display: flex;
min-width: 240rpx;
min-height: 74rpx;
align-items: center;
justify-content: center;
padding: 0 34rpx;
transform: translateX(-50%);
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
}
.settings-feedback text {
z-index: 1;
color: $ink;
font-size: 22rpx;
}
@media (min-width: 400px) {
.settings-panel {
width: calc(100% - 48rpx);
}
}
.settings-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+214 -593
View File
@@ -1,176 +1,76 @@
<!-- 页面编号G-12用途字辈诗列表空状态与批量维护页面设计阶段使用本地模拟交互 -->
<!-- 页面编号G-12用途 Apifox GenerationPoemView 读取字辈并以服务端预览维护 -->
<template>
<view class="poem-page">
<GenealogyPageBackground />
<view class="poem-page__header"
><PageHeader
:title="pageTitle"
:action="canManage && poemState === 'list' ? '维护' : ''"
custom-back
@action="openEditor"
@back="requestBack"
/></view>
<view class="poem-page__header">
<PageHeader :title="pageTitle" :action="poemState === 'list' || poemState === 'empty' ? '维护' : ''" custom-back @action="enterManagement" @back="requestBack" />
</view>
<view class="poem-panel" :class="`poem-state--${poemState}`">
<AppLoading v-if="poemState === 'loading'" text="正在读取字辈诗" description="正在读取后端已声明的世代、字辈和状态字段。" />
<view
class="poem-panel"
:class="{
'poem-state--list': poemState === 'list',
'poem-state--empty': poemState === 'empty',
'poem-state--edit': poemState === 'edit',
'poem-state--error': poemState === 'error',
'poem-state--no-permission': poemState === 'no-permission',
}"
>
<AppLoading
v-if="poemState === 'loading'"
text="正在整理字辈诗"
description="请稍候,正在读取家谱字序。"
/>
<view v-else-if="poemState === 'list'" class="poem-list">
<text class="poem-list__eyebrow">{{ genealogyName }} · 传承字序</text>
<text class="poem-list__title">{{ generationRangeTitle }}</text>
<text class="poem-list__copy">{{ currentGenerationCopy }}</text>
<text class="poem-list__copy">接口未声明当前世代字段页面只按后端返回的世代文字和状态展示</text>
<view class="poem-rows">
<view
v-for="item in visiblePoemRows"
:key="item.generationNo"
class="poem-row"
:class="{
'poem-row--current': item.current,
'poem-row--disabled': item.status === GENERATION_POEM_STATUS.DISABLED,
}"
>
<view v-for="item in visiblePoemRows" :key="item.poemId" class="poem-row" :class="{ 'poem-row--disabled': item.status === GENERATION_POEM_STATUS.DISABLED }">
<text class="poem-row__number"> {{ item.generationNo }} </text>
<text class="poem-row__character">{{ item.generationText }}</text>
<text class="poem-row__status">{{
item.current
? "当前字辈"
: item.status === GENERATION_POEM_STATUS.DISABLED
? "已停用·记录保留"
: "传承字序"
}}</text>
<text class="poem-row__status">{{ item.status === GENERATION_POEM_STATUS.DISABLED ? '已停用·记录保留' : '传承字序' }}</text>
</view>
</view>
<view
v-if="remainingPoemCount > 0"
class="poem-load-more"
@click="loadMorePoems"
>
<view v-if="remainingPoemCount > 0" class="poem-load-more" @click="loadMorePoems">
<text>继续加载后续字辈剩余 {{ remainingPoemCount }} </text>
</view>
<view v-if="canManage" class="poem-action" @click="openEditor">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/><text>维护字辈诗</text>
<view class="poem-action" @click="enterManagement">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" /><text>{{ canManage ? '继续维护字辈诗' : '读取维护列表' }}</text>
</view>
</view>
<view v-else-if="poemState === 'edit'" class="poem-editor">
<text class="poem-list__eyebrow">批量维护</text>
<text class="poem-list__eyebrow">服务端批量维护</text>
<text class="poem-list__title">录入完整字辈序列</text>
<text class="poem-list__copy"
>无分隔符时每个字符对应一代也可用空格逗号分号顿号斜杠或竖线分隔多字字辈一次最多
{{ MAX_GENERATION_COUNT }} 每代最多
{{ MAX_GENERATION_TEXT_LENGTH }} 个字符本页只更新本地预览尚未提交服务器</text
>
<text class="poem-list__copy">提交前会先请求服务端批量预览无分隔符时每个字符对应一代也可用空格逗号分号顿号斜杠或竖线分隔多字字辈</text>
<view class="poem-field">
<text>字辈内容</text>
<textarea
v-model="poemDraft"
auto-height
:maxlength="MAX_GENERATION_POEM_INPUT_LENGTH * 2"
placeholder="例如:启宗敦本,或 克勤 克俭 承先 启后"
placeholder-class="poem-placeholder"
@input="poemError = ''"
/>
<textarea v-model="poemDraft" auto-height :maxlength="MAX_GENERATION_POEM_INPUT_LENGTH" placeholder="例如:启宗敦本,或 克勤 克俭 承先 启后" placeholder-class="poem-placeholder" @input="invalidatePreview" />
</view>
<text v-if="poemError" class="poem-field-error">{{ poemError }}</text>
<view class="poem-policy">
<text>未被新文本覆盖的后续世代</text>
<view
class="poem-policy__option"
@click="disableMissing = !disableMissing"
>
<image
:src="
disableMissing
? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png'
: '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'
"
mode="aspectFit"
/>
<text
:class="{
'poem-policy__option-text--active': disableMissing,
}"
>{{ disableMissing ? "停用并保留记录" : "保持原状态" }}</text
>
<view class="poem-policy__option" @click="toggleDisableMissing">
<image :src="disableMissing ? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png' : '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'" mode="aspectFit" />
<text :class="{ 'poem-policy__option-text--active': disableMissing }">{{ disableMissing ? '停用并保留记录' : '保持原状态' }}</text>
</view>
</view>
<text class="poem-preview"
>预览{{ previewSummary }}</text
>
<text class="poem-preview">{{ previewSummary }}</text>
<view class="poem-editor__actions">
<view class="poem-action" @click="requestLeaveEditor">
<image
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
mode="aspectFit"
/><text class="poem-action__secondary">取消</text>
<image src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png" mode="aspectFit" /><text class="poem-action__secondary">取消</text>
</view>
<view class="poem-action" @click="savePoems">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/><text>保存字辈</text>
<view class="poem-action" :class="{ 'poem-action--disabled': previewing || saving }" @click="previewPoems">
<image src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png" mode="aspectFit" /><text class="poem-action__secondary">{{ previewing ? '正在预览' : '服务端预览' }}</text>
</view>
<view class="poem-action" :class="{ 'poem-action--disabled': !canSave || saving }" @click="savePoems">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" /><text>{{ saving ? '正在保存' : '保存字辈' }}</text>
</view>
</view>
</view>
<view v-else class="poem-state-card">
<text class="poem-list__eyebrow">{{
poemState === "empty"
? "尚未建立字辈"
: poemState === "no-permission"
? "权限不足"
: "字辈暂不可用"
}}</text>
<text class="poem-list__title">{{
poemState === "empty"
? "从第一段传承字序开始"
: poemState === "no-permission"
? "当前账号不能维护字辈"
: "暂时无法读取字辈诗"
}}</text>
<text class="poem-list__copy">{{
poemState === "empty"
? "管理员可以一次录入连续字辈,系统会按世代拆分展示。"
: poemState === "no-permission"
? "普通成员可以查看字辈,但只有具备管理权限的成员可以维护。"
: "请从家谱总览重新进入,或稍后再试。"
}}</text>
<view
v-if="poemState !== 'empty' || canManage"
class="poem-action"
@click="handleStateAction"
>
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/><text>{{
poemState === "empty"
? "开始录入"
: poemState === "no-permission"
? "返回家谱总览"
: "重新查看"
}}</text>
<text class="poem-list__eyebrow">{{ poemState === 'empty' ? '尚未建立字辈' : '字辈暂不可用' }}</text>
<text class="poem-list__title">{{ poemState === 'empty' ? '当前读取没有正常状态字辈' : '暂时无法读取字辈诗' }}</text>
<text class="poem-list__copy">{{ stateCopy }}</text>
<view class="poem-action" @click="handleStateAction">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" /><text>{{ poemState === 'empty' ? '读取维护列表' : '重新读取' }}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃字辈修改?"
message="当前字辈草稿尚未保存,确认后将恢复进入编辑器前的内容。"
message="当前草稿尚未保存,确认后不会提交服务端。"
confirm-text="放弃修改"
cancel-text="继续编辑"
show-cancel
@@ -179,9 +79,7 @@
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<view v-if="feedbackVisible" class="poem-feedback">
<text>本地字辈预览已更新</text>
</view>
<view v-if="feedbackMessage" class="poem-feedback"><text>{{ feedbackMessage }}</text></view>
</view>
</template>
@@ -192,85 +90,61 @@ import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findGenealogyFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import {
MAX_GENERATION_COUNT,
MAX_GENERATION_POEM_INPUT_LENGTH,
MAX_GENERATION_TEXT_LENGTH,
GENERATION_POEM_STATUS,
findFirstGenerationGap,
mergeGenerationPoemRows,
validateGenerationPoemText,
} from "@/utils/generation-poem.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const INITIAL_POEM_TEXT = "启宗敦本";
const POEM_RENDER_BATCH_SIZE = 50;
const PREVIEW_GENERATION_LIMIT = 12;
const LOCAL_PREVIEW_START_GENERATION = 1;
const LOCAL_CURRENT_GENERATION = 3;
const genealogyId = ref("");
const genealogyName = ref("家谱");
const genealogyName = ref("当前家谱");
const poemState = ref("loading");
const poemError = ref("");
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
const discardVisible = ref(false);
const canManage = ref(false);
let feedbackTimer = null;
const poemDraft = ref(INITIAL_POEM_TEXT);
// OpenAPI 的 disableMissing 示例为 false;本地同样采用安全默认 false。停用后续世代是高影响动作,
// 必须由谱主主动选择,不能把缩短一次本地草稿解释为默认停用历史记录。
const previewing = ref(false);
const saving = ref(false);
const poemDraft = ref("");
const disableMissing = ref(false);
const editorOrigin = ref("list");
const editorSnapshot = ref(null);
const poemRows = ref([]);
const visiblePoemCount = ref(POEM_RENDER_BATCH_SIZE);
const visiblePoemRows = computed(() =>
poemRows.value.slice(0, visiblePoemCount.value),
);
const remainingPoemCount = computed(() =>
poemRows.value.length > visiblePoemCount.value
? poemRows.value.length - visiblePoemCount.value
: 0,
);
const pageTitle = computed(() =>
poemState.value === "edit" ? "维护字辈诗" : "字辈诗",
);
const preview = ref(null);
const previewSignature = ref("");
const requestController = createRequestController();
let requestSequence = 0;
const visiblePoemRows = computed(() => poemRows.value.slice(0, visiblePoemCount.value));
const remainingPoemCount = computed(() => Math.max(0, poemRows.value.length - visiblePoemCount.value));
const pageTitle = computed(() => poemState.value === "edit" ? "维护字辈诗" : "字辈诗");
const generationRangeTitle = computed(() => {
if (!poemRows.value.length) return "尚未建立字辈";
const first = poemRows.value[0].generationNo;
const last = poemRows.value[poemRows.value.length - 1].generationNo;
return first === last ? `${first} 世字辈` : `${first}${last} 世字辈`;
});
const currentGenerationCopy = computed(() => {
const current = poemRows.value.find((item) => item.current);
return current
? `当前为第 ${current.generationNo} 世“${current.generationText}”字辈。`
: `当前第 ${LOCAL_CURRENT_GENERATION} 世尚未被有效字辈覆盖。`;
});
const draftSignature = computed(() => `${poemDraft.value}\u0000${disableMissing.value}`);
const canSave = computed(() => Boolean(preview.value) && previewSignature.value === draftSignature.value);
const previewSummary = computed(() => {
const validation = validateGenerationPoemText(poemDraft.value);
if (!validation.valid) {
return poemDraft.value.trim() ? validation.message : "尚未录入字辈";
}
const visible = validation.generations
.slice(0, PREVIEW_GENERATION_LIMIT)
.join(" · ");
const remaining = validation.generations.length - PREVIEW_GENERATION_LIMIT;
return remaining > 0 ? `${visible} · …另 ${remaining}` : visible;
if (previewing.value) return "正在请求服务端预览。";
if (!preview.value) return "尚未请求服务端预览;预览成功后才可保存。";
return `服务端预览:新增 ${preview.value.createCount} 条,更新 ${preview.value.updateCount} 条,保留 ${preview.value.keepCount} 条,停用 ${preview.value.disableCount} 条。`;
});
const isDirty = computed(() =>
Boolean(
poemState.value === "edit" &&
editorSnapshot.value &&
(poemDraft.value !== editorSnapshot.value.poemDraft ||
disableMissing.value !== editorSnapshot.value.disableMissing),
),
const stateCopy = computed(() => poemState.value === "empty"
? "正常字辈读取未返回条目。维护列表由后端单独鉴权,点击后才会判断是否可维护。"
: "未取得可消费的服务端响应,页面不会用本地字辈或本地权限替代。",
);
const isDirty = computed(() => Boolean(
poemState.value === "edit" && editorSnapshot.value && (
poemDraft.value !== editorSnapshot.value.poemDraft ||
disableMissing.value !== editorSnapshot.value.disableMissing
),
));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
@@ -278,439 +152,186 @@ const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const resetPoemRows = () => {
const seed = validateGenerationPoemText(INITIAL_POEM_TEXT).generations;
poemDraft.value = INITIAL_POEM_TEXT;
poemRows.value = mergeGenerationPoemRows({
existingRows: [],
generationTexts: seed,
startGeneration: LOCAL_PREVIEW_START_GENERATION,
currentGeneration: LOCAL_CURRENT_GENERATION,
disableMissing: false,
});
const applyRows = (rows) => {
poemRows.value = rows;
visiblePoemCount.value = POEM_RENDER_BATCH_SIZE;
const named = rows.find((item) => item.genealogyName);
genealogyName.value = named?.genealogyName || "当前家谱";
};
const loadMorePoems = () => {
visiblePoemCount.value = Math.min(
poemRows.value.length,
visiblePoemCount.value + POEM_RENDER_BATCH_SIZE,
);
};
const loadPoems = (query = {}) => {
genealogyId.value = String(query.genealogyId || "");
const fixture = findGenealogyFixture(genealogyId.value);
if (!fixture) {
canManage.value = false;
const loadPoems = async ({ management = false } = {}) => {
if (!genealogyId.value) {
poemState.value = "error";
return;
return false;
}
genealogyName.value = fixture.name || "家谱";
const accessRole = getGenealogyFixtureAccess(genealogyId.value).accessRole;
canManage.value = accessRole === "owner";
if (accessRole === "guest") {
poemState.value = "no-permission";
return;
const sequence = ++requestSequence;
poemState.value = "loading";
poemError.value = "";
try {
const rows = management
? await appApi.getGenerationPoemManagement(genealogyId.value, { requestController })
: await appApi.getGenerationPoems(genealogyId.value, { requestController });
if (sequence !== requestSequence) return false;
applyRows(rows);
canManage.value = management;
poemState.value = rows.length ? "list" : "empty";
return true;
} catch (error) {
if (isRequestCancelled(error) || sequence !== requestSequence) return false;
poemState.value = "error";
return false;
}
resetPoemRows();
const requestedState =
query.state === "loading"
? "loading"
: query.state === "empty"
? "empty"
: query.state === "edit"
? "edit"
: query.state === "no-permission"
? "no-permission"
: query.state === "error"
? "error"
: "list";
if (requestedState === "empty") {
poemDraft.value = "";
poemRows.value = [];
}
if (requestedState === "edit") {
if (!canManage.value) {
poemState.value = "no-permission";
return;
}
poemState.value = "list";
openEditor("list");
return;
}
poemState.value = requestedState;
};
onLoad(loadPoems);
onUnload(() => {
const timer = feedbackTimer;
feedbackTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const openEditor = (origin = poemState.value) => {
const loadMorePoems = () => {
visiblePoemCount.value = Math.min(poemRows.value.length, visiblePoemCount.value + POEM_RENDER_BATCH_SIZE);
};
const invalidatePreview = () => {
poemError.value = "";
preview.value = null;
previewSignature.value = "";
};
const toggleDisableMissing = () => {
disableMissing.value = !disableMissing.value;
invalidatePreview();
};
const openEditor = () => {
if (!canManage.value) return false;
editorOrigin.value = origin === "empty" ? "empty" : "list";
editorSnapshot.value = Object.freeze({
poemDraft: poemDraft.value,
disableMissing: disableMissing.value,
});
poemDraft.value = poemRows.value
.filter((item) => item.status === GENERATION_POEM_STATUS.ACTIVE)
.map((item) => item.generationText)
.join(" ");
disableMissing.value = false;
preview.value = null;
previewSignature.value = "";
editorSnapshot.value = Object.freeze({ poemDraft: poemDraft.value, disableMissing: disableMissing.value });
poemState.value = "edit";
return true;
};
const restoreEditorSnapshot = () => {
if (!editorSnapshot.value) return;
poemDraft.value = editorSnapshot.value.poemDraft;
disableMissing.value = editorSnapshot.value.disableMissing;
const enterManagement = async () => {
if (previewing.value || saving.value) return;
const loaded = await loadPoems({ management: true });
if (loaded) openEditor();
};
const requestLeaveEditor = async () => {
if (isDirty.value) {
const confirmed = await requestDiscardConfirmation();
if (!confirmed) return false;
}
restoreEditorSnapshot();
poemState.value = editorOrigin.value;
poemState.value = poemRows.value.length ? "list" : "empty";
editorSnapshot.value = null;
poemError.value = "";
return true;
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
internalTrail: poemState.value === "edit",
"close-transient": cancelDiscard,
"pop-internal-trail": requestLeaveEditor,
});
onBackPress((event) => handleBackPress(event, requestBack));
const handleStateAction = () => {
if (poemState.value === "empty") return openEditor("empty");
if (poemState.value === "error")
return loadPoems({ genealogyId: genealogyId.value });
return requestBack();
};
const savePoems = () => {
const validateDraft = () => {
const validation = validateGenerationPoemText(poemDraft.value);
if (!validation.valid) {
poemError.value = validation.message;
return;
return false;
}
const lastGeneration =
LOCAL_PREVIEW_START_GENERATION + validation.generations.length - 1;
if (LOCAL_CURRENT_GENERATION > lastGeneration) {
poemError.value = `请至少录入到当前第 ${LOCAL_CURRENT_GENERATION}`;
return;
}
const nextRows = mergeGenerationPoemRows({
existingRows: poemRows.value,
generationTexts: validation.generations,
startGeneration: LOCAL_PREVIEW_START_GENERATION,
currentGeneration: LOCAL_CURRENT_GENERATION,
disableMissing: disableMissing.value,
});
const activeRows = nextRows.filter(
(item) => item.status === GENERATION_POEM_STATUS.ACTIVE,
);
const lastActiveGeneration = activeRows.length
? activeRows[activeRows.length - 1].generationNo
: null;
const firstGap = lastActiveGeneration === null
? null
: findFirstGenerationGap(
nextRows,
lastActiveGeneration + 1,
LOCAL_PREVIEW_START_GENERATION,
);
if (firstGap !== null) {
poemError.value = `${firstGap} 世字辈缺失;请补齐完整序列,或选择停用未覆盖的后续记录`;
return;
}
poemRows.value = nextRows;
visiblePoemCount.value = POEM_RENDER_BATCH_SIZE;
editorSnapshot.value = null;
poemState.value = "list";
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
const timer = setTimeout(() => {
if (feedbackTimer !== timer) return;
feedbackVisible.value = false;
feedbackTimer = null;
}, 1800);
feedbackTimer = timer;
return true;
};
const previewPoems = async () => {
if (previewing.value || saving.value || !validateDraft()) return;
previewing.value = true;
poemError.value = "";
try {
preview.value = await appApi.previewGenerationPoemBatch(genealogyId.value, {
poemText: poemDraft.value,
disableMissing: disableMissing.value,
}, { requestController });
previewSignature.value = draftSignature.value;
} catch (error) {
if (!isRequestCancelled(error)) poemError.value = error?.message || "服务端预览失败,请稍后重试。";
} finally {
previewing.value = false;
}
};
const savePoems = async () => {
if (!canSave.value || saving.value || !validateDraft()) return;
saving.value = true;
poemError.value = "";
try {
await appApi.saveGenerationPoemBatch(genealogyId.value, {
poemText: poemDraft.value,
disableMissing: disableMissing.value,
}, { requestController });
feedbackMessage.value = "服务端字辈已保存,正在刷新维护列表。";
const loaded = await loadPoems({ management: true });
if (!loaded) {
poemError.value = "字辈已提交,但维护列表刷新失败;请稍后重新查看。";
return;
}
editorSnapshot.value = null;
feedbackMessage.value = "服务端字辈已保存并已刷新。";
} catch (error) {
if (!isRequestCancelled(error)) poemError.value = error?.message || "字辈保存失败,请稍后重试。";
} finally {
saving.value = false;
}
};
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
internalTrail: poemState.value === "edit",
submitting: previewing.value || saving.value,
"close-transient": cancelDiscard,
"pop-internal-trail": requestLeaveEditor,
"block-submitting": () => true,
});
const handleStateAction = () => poemState.value === "empty" ? enterManagement() : loadPoems();
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
loadPoems();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
++requestSequence;
requestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.poem-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.poem-page__header {
z-index: 3;
}
.poem-panel {
@include adaptive.adaptive-genealogy-state-panel;
z-index: 2;
width: calc(100% - 32rpx);
margin: 18rpx auto 0;
padding: 76rpx 8%;
box-sizing: border-box;
}
.poem-panel > .app-loading {
grid-area: 1 / 1 / -1 / -1;
}
.poem-list,
.poem-editor,
.poem-state-card {
min-width: 0;
}
.poem-list__eyebrow {
display: block;
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
}
.poem-list__title {
display: block;
margin-top: 11rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 35rpx;
font-weight: 700;
}
.poem-list__copy {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.55;
}
.poem-rows {
margin-top: 18rpx;
}
.poem-load-more {
display: flex;
min-height: 64rpx;
align-items: center;
justify-content: center;
margin-top: 14rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
}
.poem-load-more text {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.poem-row {
@include adaptive.adaptive-genealogy-form-field;
display: grid;
min-height: 72rpx;
margin-top: 10rpx;
grid-template-columns: minmax(116rpx, 38%) minmax(0, 1fr) auto;
align-items: center;
}
.poem-row__number {
z-index: 1;
grid-column: 1;
margin-left: 24rpx;
color: $ink-muted;
font-size: 23rpx;
}
.poem-row__character {
z-index: 1;
grid-column: 2;
min-width: 0;
padding: 12rpx 16rpx 12rpx 0;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 32rpx;
font-weight: 700;
line-height: 1.4;
overflow-wrap: anywhere;
word-break: break-word;
}
.poem-row__status {
z-index: 1;
grid-column: 3;
margin-right: 22rpx;
color: $ink-muted;
font-size: 22rpx;
}
.poem-row--current .poem-row__character,
.poem-row--current .poem-row__status {
color: $brand-red;
}
.poem-row--disabled .poem-row__character,
.poem-row--disabled .poem-row__status {
color: $ink-muted;
opacity: 0.62;
}
.poem-action {
display: grid;
width: 100%;
min-height: 76rpx;
margin-top: 20rpx;
}
.poem-action image {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.poem-action text {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
color: #fff9ed;
font-size: 25rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.poem-field {
@include adaptive.adaptive-genealogy-form-field;
display: grid;
min-height: 118rpx;
margin-top: 22rpx;
}
.poem-field > text {
z-index: 1;
grid-area: 1 / 1;
align-self: center;
margin-left: 24rpx;
color: $ink;
font-size: 22rpx;
font-weight: 700;
}
.poem-field textarea {
z-index: 1;
grid-area: 1 / 1;
box-sizing: border-box;
width: auto;
min-width: 0;
min-height: 118rpx;
margin-right: 20rpx;
margin-left: 164rpx;
padding-top: 34rpx;
color: $ink;
font-size: 22rpx;
line-height: 1.5;
}
.poem-placeholder {
color: #a79884;
}
.poem-field-error {
display: block;
margin-top: 4rpx;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
text-align: right;
}
.poem-policy {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 22rpx;
}
.poem-policy > text {
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.poem-policy__option {
display: grid;
width: 248rpx;
min-height: 62rpx;
}
.poem-policy__option image {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.poem-policy__option text {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.poem-policy__option .poem-policy__option-text--active {
color: #fff9ed;
}
.poem-preview {
display: block;
margin-top: 22rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.6;
}
.poem-editor__actions {
display: flex;
gap: 14rpx;
margin-top: 18rpx;
}
.poem-editor__actions .poem-action {
width: calc(50% - 7rpx);
margin-top: 0;
}
.poem-action .poem-action__secondary {
color: $ink;
}
.poem-state-card {
text-align: center;
}
.poem-state--empty,
.poem-state--error,
.poem-state--no-permission {
padding: 180rpx 4% 70rpx;
}
.poem-state-card .poem-list__eyebrow {
text-align: center;
}
.poem-state-card .poem-list__copy {
margin-top: 22rpx;
}
.poem-state-card .poem-action {
width: 420rpx;
max-width: 100%;
margin: 34rpx auto 0;
}
.poem-feedback {
position: fixed;
z-index: 30;
top: 138rpx;
left: 50%;
display: flex;
min-width: 260rpx;
min-height: 74rpx;
align-items: center;
justify-content: center;
padding: 0 34rpx;
transform: translateX(-50%);
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
}
.poem-feedback text {
z-index: 1;
color: $ink;
font-size: 22rpx;
}
@media (min-width: 400px) {
.poem-panel {
width: calc(100% - 48rpx);
}
}
.poem-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.poem-page__header { z-index: 3; }
.poem-panel { @include adaptive.adaptive-genealogy-state-panel; z-index: 2; width: calc(100% - 32rpx); margin: 18rpx auto 0; padding: 76rpx 8%; box-sizing: border-box; }
.poem-panel > .app-loading { grid-area: 1 / 1 / -1 / -1; }
.poem-list, .poem-editor, .poem-state-card { min-width: 0; }
.poem-list__eyebrow { display: block; color: $brand-red; font-size: 23rpx; letter-spacing: 3rpx; }
.poem-list__title { display: block; margin-top: 11rpx; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 35rpx; font-weight: 700; }
.poem-list__copy { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.55; }
.poem-rows { margin-top: 18rpx; }
.poem-load-more { display: flex; min-height: 64rpx; align-items: center; justify-content: center; margin-top: 14rpx; background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png") center / contain no-repeat; }
.poem-load-more text { color: $ink; font-size: 23rpx; font-weight: 700; }
.poem-row { @include adaptive.adaptive-genealogy-form-field; display: grid; min-height: 72rpx; margin-top: 10rpx; grid-template-columns: minmax(116rpx, 38%) minmax(0, 1fr) auto; align-items: center; }
.poem-row__number { z-index: 1; grid-column: 1; margin-left: 24rpx; color: $ink-muted; font-size: 23rpx; }
.poem-row__character { z-index: 1; grid-column: 2; min-width: 0; padding: 12rpx 16rpx 12rpx 0; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 32rpx; font-weight: 700; line-height: 1.4; overflow-wrap: anywhere; word-break: break-word; }
.poem-row__status { z-index: 1; grid-column: 3; margin-right: 22rpx; color: $ink-muted; font-size: 22rpx; }
.poem-row--disabled .poem-row__character, .poem-row--disabled .poem-row__status { color: $ink-muted; opacity: .62; }
.poem-action { display: grid; width: 100%; min-height: 76rpx; margin-top: 20rpx; }
.poem-action--disabled { opacity: .55; }
.poem-action image { grid-area: 1 / 1; width: 100%; height: 100%; }
.poem-action text { z-index: 1; display: flex; grid-area: 1 / 1; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 25rpx; font-weight: 700; letter-spacing: 2rpx; }
.poem-field { @include adaptive.adaptive-genealogy-form-field; display: grid; min-height: 118rpx; margin-top: 22rpx; }
.poem-field > text { z-index: 1; grid-area: 1 / 1; align-self: center; margin-left: 24rpx; color: $ink; font-size: 22rpx; font-weight: 700; }
.poem-field textarea { z-index: 1; grid-area: 1 / 1; box-sizing: border-box; width: auto; min-width: 0; min-height: 118rpx; margin-right: 20rpx; margin-left: 164rpx; padding-top: 34rpx; color: $ink; font-size: 22rpx; line-height: 1.5; }
.poem-placeholder { color: #a79884; }
.poem-field-error { display: block; margin-top: 4rpx; color: $brand-red; font-size: 24rpx; line-height: 34rpx; text-align: right; }
.poem-policy { display: flex; align-items: center; justify-content: space-between; margin-top: 22rpx; }
.poem-policy > text { color: $ink; font-size: 24rpx; font-weight: 700; }
.poem-policy__option { display: grid; width: 248rpx; min-height: 62rpx; }
.poem-policy__option image { grid-area: 1 / 1; width: 100%; height: 100%; }
.poem-policy__option text { z-index: 1; display: flex; grid-area: 1 / 1; align-items: center; justify-content: center; height: 100%; color: $ink; font-size: 23rpx; font-weight: 700; }
.poem-policy__option .poem-policy__option-text--active { color: #fff9ed; }
.poem-preview { display: block; margin-top: 22rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
.poem-editor__actions { display: flex; gap: 14rpx; margin-top: 18rpx; }
.poem-editor__actions .poem-action { width: calc((100% - 28rpx) / 3); margin-top: 0; }
.poem-action .poem-action__secondary { color: $ink; }
.poem-state-card { padding: 180rpx 4% 70rpx; text-align: center; }
.poem-state-card .poem-list__eyebrow { text-align: center; }
.poem-state-card .poem-list__copy { margin-top: 22rpx; }
.poem-state-card .poem-action { width: 420rpx; max-width: 100%; margin: 34rpx auto 0; }
.poem-feedback { position: fixed; z-index: 30; top: 138rpx; left: 50%; display: flex; min-width: 260rpx; min-height: 74rpx; align-items: center; justify-content: center; padding: 0 34rpx; transform: translateX(-50%); background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png") center / contain no-repeat; }
.poem-feedback text { z-index: 1; color: $ink; font-size: 22rpx; }
@media (min-width: 400px) { .poem-panel { width: calc(100% - 48rpx); } }
</style>
+5 -253
View File
@@ -1,260 +1,12 @@
<!-- 页面编号N-01用途消息中心已读操作空态与失败状态 -->
<!-- 页面编号N-01用途通知入口列表 item DTO 缺失时不展示 fixture 通知 -->
<template>
<view
class="notice-page"
:class="{
'notice-state--loading': noticeState === 'loading',
'notice-state--list': noticeState === 'list',
'notice-state--empty': noticeState === 'empty',
'notice-state--error': noticeState === 'error',
}"
>
<ModulePageBackground module="notification" />
<view class="notice-page__header">
<PageHeader
title="消息中心"
:action="unreadCount > 0 && noticeState === 'list' ? '全部已读' : ''"
@action="markAllRead"
/>
</view>
<view class="notice-content">
<AppLoading
v-if="noticeState === 'loading'"
text="正在整理消息"
description="请稍候,正在同步家谱申请与家族提醒。"
/>
<template v-else-if="noticeState === 'list'">
<view class="notice-list">
<view
v-for="item in notices"
:key="item.id"
class="notice-card"
role="button"
:aria-label="`${item.unread ? '未读' : '已读'}消息${item.title}`"
@click="openNotice(item)"
>
<view class="notice-card__copy">
<text
class="notice-card__status"
:class="{ 'is-unread': item.unread }"
>{{ item.unread ? "未读提醒" : "已读" }} · {{ item.time }}</text
>
<text class="notice-card__title">{{ item.title }}</text>
<text class="notice-card__summary">{{ item.content }}</text>
</view>
</view>
</view>
<AppButton
class="notice-review-action"
block
type="secondary"
label="前往入谱审核"
@click="toReview"
/>
</template>
<view v-else class="notice-state-card">
<view class="notice-state-card__copy">
<text class="notice-state-card__title">{{
noticeState === "empty" ? "暂时没有新消息" : "消息中心暂不可用"
}}</text>
<text class="notice-state-card__description">{{
noticeState === "empty"
? "家谱申请、审核结果和家族提醒会留在这里。"
: "请稍后重新进入,已读状态不会受到影响。"
}}</text>
</view>
<AppButton
block
:type="noticeState === 'error' ? 'secondary' : 'primary'"
:label="noticeState === 'error' ? '重新查看' : '前往入谱审核'"
@click="noticeState === 'error' ? restoreList() : toReview()"
/>
</view>
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
<view class="message-page"><ModulePageBackground module="notification" /><view class="page-header"><PageHeader root title="消息中心" /></view><view class="page-content"><view class="state-card"><text>消息列表待后端字段合同</text><text>通知列表没有声明通知 ID已读状态标题时间正文类型或跳转参数页面已停止展示本地消息也不会以 fixture 修改已读状态</text><AppButton block label="返回我的" @click="returnToProfile" /></view></view><AppTabbar active="profile" /></view>
</template>
<script setup>
import { computed, onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { listNotificationFixtures } from "@/data/mock.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const noticeState = ref("loading");
const toastVisible = ref(false);
const toastMessage = ref("");
let toastTimer = null;
const notices = ref(listNotificationFixtures());
onLoad((query) => {
genealogyId.value =
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
noticeState.value =
query.state === "loading"
? "loading"
: query.state === "empty"
? "empty"
: query.state === "error"
? "error"
: "list";
});
const unreadCount = computed(
() => notices.value.filter((item) => item.unread).length,
);
const openNotice = async (item) => {
const opened = await openPage(
"N02",
{ id: String(item.id) },
"N01",
);
if (opened) item.unread = false;
};
const restoreList = () => {
noticeState.value = "list";
};
const showToast = (message) => {
toastMessage.value = message;
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
toastTimer = null;
}, 1800);
};
const markAllRead = () => {
notices.value.forEach((item) => {
item.unread = false;
});
showToast("已全部标记为已读");
};
const toReview = () =>
genealogyId.value
? openPage(
"G10",
{ genealogyId: genealogyId.value },
"N01",
)
: showToast("请先选择可管理的家谱");
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
});
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 { goRoot } from "@/utils/navigation.js";
const returnToProfile=()=>goRoot("M01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.notice-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.notice-page__header,
.notice-content {
z-index: 1;
}
.notice-content {
flex: 1;
padding: 24rpx 28rpx 100rpx;
}
.notice-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.notice-card {
@include adaptive.adaptive-notification-content;
width: 100%;
min-height: 220rpx;
}
.notice-card__copy {
display: flex;
min-height: 220rpx;
box-sizing: border-box;
flex-direction: column;
justify-content: center;
padding: 30rpx 44rpx;
}
.notice-card__status {
display: block;
color: $ink-muted;
font-size: 21rpx;
font-weight: 600;
letter-spacing: 2rpx;
}
.notice-card__status.is-unread {
color: $brand-red;
font-weight: 700;
}
.notice-card__title {
display: block;
margin-top: 7rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 30rpx;
font-weight: 700;
}
.notice-card__summary {
display: block;
margin-top: 7rpx;
color: #62584c;
font-size: 23rpx;
font-weight: 500;
line-height: 1.45;
}
.notice-review-action {
margin: 30rpx auto 0;
}
.notice-state-card {
margin-top: 38rpx;
background: url("/static/assets/modules/notification/transparent/n01-notice-card.png") top center / 100% 220rpx no-repeat;
text-align: center;
}
.notice-state-card__copy {
display: flex;
min-height: 118px;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 54rpx;
}
.notice-state-card__title {
display: block;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.notice-state-card__description {
display: block;
margin-top: 13rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.5;
}
.notice-state-card .app-button {
margin: 20rpx auto 0;
}
@media (min-width: 400px) {
.notice-content {
padding-right: 32rpx;
padding-left: 32rpx;
}
}
.message-page{display:flex;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{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -134
View File
@@ -1,141 +1,11 @@
<!-- 页面编号N-02用途消息详情已读状态与上下文操作 -->
<!-- 页面编号N-02用途通知详情没有详情读取 operation 时保持关闭 -->
<template>
<view class="notice-detail-page" :class="{ 'notice-state--ready': noticeState === 'ready', 'notice-state--loading': noticeState === 'loading', 'notice-state--expired': noticeState === 'expired' }">
<ModulePageBackground module="notification" />
<view class="page-layer"><PageHeader title="消息详情" custom-back @back="backToMessages" /></view>
<view class="notice-content page-layer">
<AppLoading v-if="noticeState === 'loading'" text="正在读取消息" description="请稍候,正在整理消息详情。" />
<view v-else-if="noticeState === 'expired'" class="paper-panel state-card">
<text class="state-title">这条消息已失效</text>
<text class="state-copy">消息可能已撤回或超过保留期限请返回消息中心查看其他内容</text>
<AppButton block label="返回消息中心" @click="backToMessages" />
</view>
<template v-else>
<view class="paper-panel notice-card">
<view class="notice-meta">
<text :class="{ 'is-unread': noticeDetail.unread }">{{ noticeDetail.unread ? "未读提醒" : "已读" }}</text>
<text>{{ noticeDetail.time }}</text>
</view>
<text class="notice-title">{{ noticeDetail.title }}</text>
<text class="notice-body">{{ noticeDetail.body }}</text>
<text class="notice-source">来自{{ noticeDetail.source }}</text>
</view>
<view class="action-stack">
<AppButton v-if="noticeDetail.unread" block label="标记已读" @click="markAsRead" />
<AppButton v-if="noticeDetail.targetType" block type="secondary" :label="noticeDetail.targetLabel" @click="openNoticeTarget" />
<text v-if="targetError" class="target-error" role="alert">{{ targetError }}</text>
</view>
</template>
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
<view class="message-detail-page"><ModulePageBackground module="notification" /><view class="page-header"><PageHeader title="消息详情" custom-back @back="backToMessages" /></view><view class="page-content"><view class="state-card"><text>消息详情暂未开放</text><text>Apifox 当前只有通知列表单条标已读和全部标已读 operation没有消息详情读取 owner页面不再从 fixture 展示正文来源或业务跳转</text><AppButton block label="返回消息中心" @click="backToMessages" /></view></view></view>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findNotificationFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import {
handleBackPress,
openNoticeTarget as navigateNoticeTarget,
returnTo,
} from "@/utils/navigation.js";
const noticeState = ref("loading");
const toastVisible = ref(false);
const toastMessage = ref("");
const targetError = ref("");
let toastTimer = null;
const noticeId = ref("");
const noticeDetail = ref(null);
onLoad((query) => {
noticeId.value = String(query.id || "");
if (query.state === "loading") return;
const selectedNotice = findNotificationFixture(noticeId.value);
if (
query.state === "expired" ||
!noticeId.value ||
!selectedNotice
) {
noticeState.value = "expired";
return;
}
noticeDetail.value = selectedNotice;
noticeState.value = "ready";
});
const showToast = (message) => {
toastMessage.value = message;
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toastVisible.value = false), 1800);
};
const markAsRead = () => {
if (!noticeDetail.value) return;
noticeDetail.value.unread = false;
showToast("已标记为已读");
};
const getTargetAccessError = (detail) => {
const genealogyId = detail?.targetParams?.genealogyId;
const accessRole = getGenealogyFixtureAccess(genealogyId).accessRole;
if (detail?.targetType === "GENEALOGY_REVIEW" && accessRole !== "owner") {
return "当前账号没有处理这条审核消息的权限。";
}
if (
detail?.targetType === "GENEALOGY_HOME" &&
!["owner", "member"].includes(accessRole)
) {
return "这条消息关联的家谱已不可访问。";
}
return "";
};
const openNoticeTarget = async () => {
targetError.value = getTargetAccessError(noticeDetail.value);
if (targetError.value) return false;
try {
return await navigateNoticeTarget(
noticeDetail.value.targetType,
noticeDetail.value.targetParams,
);
} catch (_error) {
targetError.value = "这条消息的业务入口已失效,请返回消息中心。";
return false;
}
};
const backToMessages = () => returnTo("N01", {});
onBackPress((event) => handleBackPress(event, backToMessages));
onUnmounted(() => toastTimer && clearTimeout(toastTimer));
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToMessages=()=>returnTo("N01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.notice-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-layer { z-index: 1; }
.notice-content { flex: 1; padding: 28rpx 30rpx 72rpx; }
.paper-panel { @include adaptive.adaptive-notification-content; }
.notice-card { min-height: 430rpx; padding: 54rpx 52rpx; box-sizing: border-box; }
.notice-meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8rpx 20rpx; color: $ink-muted; font-size: 22rpx; }
.notice-meta .is-unread { color: $brand-red; font-weight: 700; }
.notice-title { display: block; margin-top: 24rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 38rpx; font-weight: 700; overflow-wrap: anywhere; }
.notice-body { display: block; margin-top: 22rpx; color: #5f4a38; font-size: 25rpx; line-height: 1.75; overflow-wrap: anywhere; }
.notice-source { display: block; margin-top: 28rpx; color: $ink-muted; font-size: 22rpx; overflow-wrap: anywhere; }
.action-stack { display: flex; flex-direction: column; gap: 18rpx; margin-top: 28rpx; }
.target-error { display: block; color: #b42318; font-size: 22rpx; line-height: 1.5; text-align: center; }
.state-card { min-height: 340rpx; padding: 72rpx 50rpx 48rpx; box-sizing: border-box; text-align: center; }
.state-title { display: block; color: $ink; font-size: 34rpx; font-weight: 700; }
.state-copy { display: block; margin: 18rpx 0 28rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
@media (max-width: 340px) { .notice-content { padding-right: 22rpx; padding-left: 22rpx; } .notice-card { padding-right: 40rpx; padding-left: 40rpx; } }
.message-detail-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -422
View File
@@ -1,429 +1,11 @@
<!-- 页面编号M-01用途个人中心首页提醒与服务导航 -->
<!-- 页面编号M-01用途个人中心资料 DTO 未声明时不展示 mock 用户 -->
<template>
<view
class="profile-page"
:class="{
'profile-state--ready': profileState === 'ready',
'profile-state--error': profileState === 'error',
}"
>
<ModulePageBackground module="profile" />
<view class="profile-page__header">
<PageHeader
root
title="我的"
:action="profileState === 'ready' ? '资料' : ''"
@action="toProfile"
/>
</view>
<view class="profile-content">
<template v-if="profileState === 'ready'">
<view class="profile-hero">
<image
class="profile-hero__hall"
src="/static/assets/foundation/transparent/root-header-hall.png"
mode="aspectFit"
/>
<image
class="profile-hero__cloud"
src="/static/assets/foundation/transparent/auth-title-cloud.png"
mode="aspectFit"
/>
<view class="profile-hero__content">
<view class="profile-hero__label"
><text></text><text></text><text></text><text></text></view
>
<view class="profile-identity">
<image
class="profile-identity__seal"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/>
<view class="profile-identity__copy">
<text class="profile-identity__name">{{ currentUser.name }}</text>
<text class="profile-identity__role">{{ currentUser.role }}</text>
<text class="profile-identity__phone">{{ currentUser.phone }}</text>
</view>
</view>
</view>
</view>
<view
class="profile-scroll-notice"
role="button"
aria-label="查看待处理消息"
@click="toNotifications"
>
<view class="profile-scroll-notice__copy">
<text>待你处理</text>
<text>{{ unreadCount }} 条家谱提醒与审核通知</text>
</view>
</view>
<view class="profile-services">
<view class="profile-section-heading">
<image
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
<text>服务与设置</text>
<image
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
</view>
<view class="profile-menu-list">
<view
v-for="item in menuItems"
:key="item.label"
class="profile-menu"
role="button"
:aria-label="`查看${item.label}`"
@click="openItem(item)"
>
<image
class="profile-menu__icon"
:src="item.icon"
mode="aspectFit"
/>
<view class="profile-menu__copy">
<text class="profile-menu__label">{{ item.label }}</text>
<text class="profile-menu__note">{{ item.note }}</text>
</view>
<image
class="profile-menu__chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view>
</view>
</view>
</template>
<view v-else class="profile-error">
<view class="profile-error__copy">
<text class="profile-error__title">个人资料暂不可用</text>
<text class="profile-error__description"
>请稍后重新进入账号和家谱资料不会受到影响</text
>
</view>
<AppButton
block
type="secondary"
label="重新查看"
@click="restoreProfile"
/>
</view>
</view>
<AppTabbar active="profile" />
</view>
<view class="profile-page"><ModulePageBackground module="profile" /><view class="page-header"><PageHeader root title="我的" /></view><view class="page-content"><view class="state-card"><text>个人资料待后端字段合同</text><text>当前资料读取接口只返回通用对象未声明昵称角色手机号或脱敏规则页面已停止展示 mock 用户与 fixture 未读数</text><view class="profile-menu"><AppButton block label="编辑资料" @click="open('M02')" /><AppButton block label="账号与安全" @click="open('M03')" /><AppButton block label="消息中心" @click="open('N01')" /><AppButton block label="帮助中心" @click="open('M06')" /><AppButton block label="意见反馈" @click="open('M07')" /><AppButton block label="应用推广" @click="open('M08')" /><AppButton block label="VIP 服务" @click="open('M09')" /><AppButton block label="关于与设置" @click="open('M10')" /></view></view></view><AppTabbar active="profile" /></view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } 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 {
currentUser,
listNotificationFixtures,
} from "@/data/mock.js";
import { openPage } from "@/utils/navigation.js";
const profileState = ref("ready");
const unreadCount = computed(
() => listNotificationFixtures().filter((notice) => notice.unread).length,
);
const menuItems = [
{
label: "账号与安全",
note: "密码与手机号",
icon: "/static/assets/modules/auth/transparent/a01-icon-lock-v1.png",
routeKey: "M03",
},
{
label: "帮助与反馈",
note: "使用说明与问题反馈",
icon: "/static/assets/foundation/transparent/notice.png",
routeKey: "M06",
},
{
label: "关于家谱",
note: "协议、隐私与版本",
icon: "/static/assets/foundation/transparent/brand-seal.png",
routeKey: "M10",
},
{
label: "邀请家人",
note: "邀请规则与接入状态",
icon: "/static/assets/foundation/transparent/brand-seal.png",
routeKey: "M08",
},
{
label: "服务与订单",
note: "权益说明与订单记录",
icon: "/static/assets/foundation/transparent/notice.png",
routeKey: "M09",
},
];
onLoad((query) => {
profileState.value = query.state === "error" ? "error" : "ready";
});
const restoreProfile = () => {
profileState.value = "ready";
};
const toProfile = () => openPage("M02", {}, "M01");
const toNotifications = () => openPage("N01", {}, "M01");
const openItem = (item) =>
item.routeKey ? openPage(item.routeKey, {}, "M01") : Promise.resolve(false);
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 { openPage } from "@/utils/navigation.js"; const open=(route)=>openPage(route,{},"M01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.profile-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.profile-page__header,
.profile-content {
z-index: 1;
}
.profile-content {
flex: 1;
padding: 26rpx 30rpx 190rpx;
}
.profile-hero {
@include adaptive.adaptive-profile-summary;
display: grid;
width: 100%;
min-height: 252rpx;
}
.profile-hero__content {
z-index: 1;
display: grid;
grid-area: 1 / 1;
grid-template-columns: 42rpx minmax(0, 1fr);
align-items: center;
gap: 30rpx;
padding: 34rpx 52rpx 32rpx 40rpx;
}
.profile-hero__hall {
align-self: end;
justify-self: end;
grid-area: 1 / 1;
width: 330rpx;
height: 122rpx;
margin-right: -18rpx;
margin-bottom: -30rpx;
opacity: 0.12;
pointer-events: none;
}
.profile-hero__cloud {
align-self: start;
justify-self: end;
grid-area: 1 / 1;
width: 96rpx;
height: 52rpx;
margin-top: 22rpx;
margin-right: 30rpx;
opacity: 0.32;
pointer-events: none;
}
.profile-hero__label {
z-index: 2;
display: flex;
width: 42rpx;
min-height: 152rpx;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2rpx solid #c9964c;
border-radius: 4rpx;
background: linear-gradient(180deg, #9f241f, #bd3427);
box-shadow: inset 0 0 0 3rpx rgba(255, 222, 147, 0.24);
color: #ffe6a9;
font-family: STKaiti, KaiTi, serif;
font-size: 21rpx;
line-height: 1.12;
}
.profile-identity {
z-index: 1;
display: flex;
min-height: 252rpx;
box-sizing: border-box;
align-items: center;
gap: 24rpx;
}
.profile-identity__seal {
width: 92rpx;
height: 106rpx;
flex: 0 0 auto;
}
.profile-identity__name,
.profile-identity__role,
.profile-identity__phone {
display: block;
}
.profile-identity__name {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 38rpx;
font-weight: 700;
letter-spacing: 3rpx;
}
.profile-identity__role {
margin-top: 10rpx;
color: #6f5942;
font-size: 23rpx;
}
.profile-identity__phone {
margin-top: 7rpx;
color: $ink-muted;
font-size: 21rpx;
letter-spacing: 2rpx;
}
.profile-scroll-notice {
@include adaptive.adaptive-scroll-button(primary);
width: 88%;
min-height: 112rpx;
margin: 22rpx auto 0;
}
.profile-scroll-notice__copy {
display: flex;
min-height: 112rpx;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 13rpx 70rpx;
color: #fff4d3;
text-align: center;
}
.profile-scroll-notice__copy text:first-child {
font-family: STKaiti, KaiTi, serif;
font-size: 26rpx;
font-weight: 700;
letter-spacing: 3rpx;
}
.profile-scroll-notice__copy text:last-child {
margin-top: 4rpx;
color: #ffe4a8;
font-size: 18rpx;
}
.profile-services {
margin-top: 26rpx;
padding: 0 12rpx;
}
.profile-section-heading {
display: flex;
align-items: center;
justify-content: center;
gap: 20rpx;
padding-bottom: 12rpx;
}
.profile-section-heading image {
width: 88rpx;
height: 28rpx;
opacity: 0.78;
}
.profile-section-heading image:last-child {
transform: scaleX(-1);
}
.profile-section-heading text {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.profile-menu-list {
border-top: 1px solid rgba(181, 137, 63, 0.55);
}
.profile-menu {
display: flex;
min-height: 116rpx;
align-items: center;
padding: 0 8rpx;
border-bottom: 1px solid rgba(181, 137, 63, 0.4);
}
.profile-menu__icon {
width: 52rpx;
height: 52rpx;
margin-right: 22rpx;
flex: 0 0 auto;
}
.profile-menu__copy {
min-width: 0;
flex: 1;
}
.profile-menu__label,
.profile-menu__note {
display: block;
}
.profile-menu__label {
color: $ink;
font-size: 25rpx;
font-weight: 700;
}
.profile-menu__note {
margin-top: 5rpx;
color: $ink-muted;
font-size: 21rpx;
}
.profile-menu__chevron {
width: 30rpx;
height: 30rpx;
margin-left: 18rpx;
opacity: 0.75;
}
.profile-error {
margin-top: 38rpx;
background: url("/static/assets/modules/profile/transparent/m01-profile-summary-card.png") top center / 100% 220rpx no-repeat;
text-align: center;
}
.profile-error__copy {
display: flex;
min-height: 118px;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 54rpx;
}
.profile-error__title,
.profile-error__description {
display: block;
}
.profile-error__title {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.profile-error__description {
margin-top: 13rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.5;
}
.profile-error .app-button {
margin: 20rpx auto 0;
}
@media (min-width: 400px) {
.profile-content {
padding-right: 38rpx;
padding-left: 38rpx;
}
}
/* M01 medium-ornate profile folio styles remain page-scoped and extensible. */
.profile-page{display:flex;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{box-sizing:border-box;min-height:340rpx;padding:52rpx 40rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.profile-menu{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14rpx;margin-top:28rpx}.profile-menu .app-button{margin:0}
</style>
+4 -117
View File
@@ -1,124 +1,11 @@
<!-- 页面编号M-02用途编辑个人资料 -->
<!-- 页面编号M-02用途编辑资料读取 DTO 与现有字段不一致时保持关闭 -->
<template>
<view class="profile-edit-page" :class="{ 'profile-state--ready': profileState === 'ready', 'profile-state--saving': profileState === 'saving' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="个人资料" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="profile-avatar-card">
<image class="avatar-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
<view class="avatar-copy"><text>{{ profileForm.nickName || "未填写昵称" }}</text><text>头像将在上传接口批次接入</text></view>
<view class="text-action" role="button" aria-label="选择头像" @click="chooseAvatar">选择头像</view>
</view>
<view class="form-panel">
<view class="form-row"><text>昵称</text><input v-model.trim="profileForm.nickName" maxlength="30" aria-label="昵称" placeholder="请输入昵称" @input="errors.nickName = ''" /></view>
<text v-if="errors.nickName" class="field-error">{{ errors.nickName }}</text>
<view class="form-row"><text>真实姓名</text><input v-model.trim="profileForm.realName" maxlength="30" aria-label="真实姓名" placeholder="请输入真实姓名" /></view>
<view class="form-row"><text>邮箱</text><input v-model.trim="profileForm.email" maxlength="100" aria-label="邮箱" placeholder="选填,用于接收通知" @input="errors.email = ''" /></view>
<text v-if="errors.email" class="field-error">{{ errors.email }}</text>
</view>
<AppButton block :disabled="profileState === 'saving'" :label="profileState === 'saving' ? '正在校验' : '生成本地校验预览'" @click="saveProfile" />
</view>
<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>
<view class="profile-edit-page"><ModulePageBackground module="profile" /><view class="page-header"><PageHeader title="编辑资料" custom-back @back="backToProfile" /></view><view class="page-content"><view class="state-card"><text>个人资料编辑待字段合同</text><text>读取接口没有资料 DTO更新接口允许昵称头像性别生日地区和地址但当前页面原有真实姓名邮箱等字段不属于该合同页面不再用 mock 预填或提交未知字段</text><AppButton block label="返回个人中心" @click="backToProfile" /></view></view></view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, 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 { currentUser } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
handleBackPress,
runBackGuard,
} from "@/utils/navigation.js";
const profileForm = reactive({ nickName: currentUser.name, realName: currentUser.name, email: "" });
const errors = reactive({ nickName: "", email: "" });
const profileState = ref("ready");
const toastVisible = ref(false);
const toastMessage = ref("");
const discardVisible = ref(false);
let timer = null;
const formSnapshot = computed(() => JSON.stringify(profileForm));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const chooseAvatar = () => showToast("头像选择将在相册权限接入后开放");
const validateProfile = () => {
errors.nickName = profileForm.nickName ? "" : "请填写昵称";
errors.email = !profileForm.email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profileForm.email)
? ""
: "请输入正确的邮箱地址";
return !errors.nickName && !errors.email;
};
const saveProfile = () => {
if (!validateProfile() || profileState.value === "saving") return;
profileState.value = "saving";
clearTimeout(timer);
timer = setTimeout(() => {
profileState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: profileState.value === "saving",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
clearTimeout(timer);
discardConfirmation.dispose();
});
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToProfile=()=>returnTo("M01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.profile-edit-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-layer { z-index: 1; }
.page-content { flex: 1; padding: 26rpx 30rpx 70rpx; }
.profile-avatar-card, .form-panel { @include adaptive.adaptive-profile-field; }
.profile-avatar-card { display: grid; grid-template-columns: 92rpx minmax(0,1fr) auto; align-items: center; gap: 20rpx; min-height: 150rpx; padding: 24rpx 34rpx; box-sizing: border-box; }
.avatar-seal { width: 82rpx; height: auto; max-height: 92rpx; aspect-ratio: 82 / 92; }
.avatar-copy { min-width: 0; }
.avatar-copy text { display: block; overflow-wrap: anywhere; }
.avatar-copy text:first-child { color: $ink; font-size: 29rpx; font-weight: 700; }
.avatar-copy text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
.text-action { min-height: 72rpx; display: flex; align-items: center; color: $brand-red; font-size: 22rpx; }
.form-panel { margin-top: 22rpx; padding: 20rpx 34rpx 30rpx; box-sizing: border-box; }
.form-row { display: grid; grid-template-columns: 150rpx minmax(0,1fr); min-height: 92rpx; align-items: center; border-bottom: 1px solid rgba(181,137,63,.42); gap: 18rpx; }
.form-row > text { color: $ink; font-size: 24rpx; font-weight: 700; }
.form-row input { width: auto; min-width: 0; min-height: 70rpx; color: $ink; font-size: 24rpx; text-align: right; }
.field-error { display: block; padding-top: 7rpx; color: #b42318; font-size: 21rpx; text-align: right; }
.page-content > .app-button { margin-top: 26rpx; }
@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.profile-avatar-card{grid-template-columns:72rpx minmax(0,1fr);padding-right:26rpx;padding-left:26rpx}.text-action{grid-column:2}.avatar-seal{width:68rpx;max-height:78rpx}.form-row{grid-template-columns:126rpx minmax(0,1fr)}}
.profile-edit-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -43
View File
@@ -1,50 +1,11 @@
<!-- 页面编号M-03用途账号安全总览与安全功能入口 -->
<!-- 页面编号M-03用途账号与安全入口安全概览 DTO 未声明 -->
<template>
<view class="security-page device-state--limited">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="账号与安全" /></view>
<view class="page-content page-layer">
<view class="security-summary">
<text>账号安全操作</text>
<text>修改密码和换绑手机号目前只提供本地校验预览不会变更服务器账号信息</text>
</view>
<view class="security-list">
<view v-for="item in securityItems" :key="item.key" class="security-row" role="button" :aria-label="item.label" @click="openSecurityItem(item)">
<image :src="item.icon" mode="aspectFit" />
<view><text>{{ item.label }}</text><text>{{ item.note }}</text></view>
<image class="chevron" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
</view>
<AppButton block type="secondary" label="查看接入状态" @click="checkSecurity" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
<view class="security-page"><ModulePageBackground module="profile" /><view class="page-header"><PageHeader title="账号与安全" custom-back @back="backToProfile" /></view><view class="page-content"><view class="state-card"><text>安全资料待后端字段合同</text><text>没有独立安全概览设备或登录记录读取 owner页面不再显示 mock 账号摘要可进入已独立核对的修改密码页面换绑手机号仍需人工 TAC/短信窗口</text><AppButton block label="修改密码" @click="openPassword" /><AppButton type="secondary" block label="换绑手机号" @click="openPhone" /></view></view></view>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { currentUser } from "@/data/mock.js";
import { openPage } from "@/utils/navigation.js";
const securityItems = [
{ key: "password", label: "登录密码", note: "建议定期更新密码", icon: "/static/assets/modules/auth/transparent/a01-icon-lock-v1.png", routeKey: "M04" },
{ key: "phone", label: "绑定手机号", note: currentUser.phone, icon: "/static/assets/modules/auth/transparent/a01-icon-phone-v1.png", routeKey: "M05" },
];
const toastVisible = ref(false);
const toastMessage = ref("");
let timer = null;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const openSecurityItem = (item) => openPage(item.routeKey, {}, "M03");
const checkSecurity = () => showToast("账号安全接口将在后续独立批次接入");
onUnmounted(() => clearTimeout(timer));
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { openPage,returnTo } from "@/utils/navigation.js"; const backToProfile=()=>returnTo("M01");const openPassword=()=>openPage("M04",{},"M03");const openPhone=()=>openPage("M05",{},"M03");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.security-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.security-summary{@include adaptive.adaptive-profile-summary;min-height:190rpx;padding:42rpx 48rpx;text-align:center}.security-summary text{display:block;overflow-wrap:anywhere}.security-summary text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.security-summary text:last-child{margin-top:12rpx;color:$ink-muted;font-size:23rpx;line-height:1.55}.security-list{margin-top:22rpx}.security-row{@include adaptive.adaptive-profile-field;display:grid;grid-template-columns:56rpx minmax(0,1fr) 34rpx;min-height:110rpx;align-items:center;gap:18rpx;padding:16rpx 26rpx}.security-row+ .security-row{margin-top:12rpx}.security-row>image{width:50rpx;height:50rpx}.security-row .chevron{width:30rpx;height:30rpx}.security-row view{min-width:0}.security-row text{display:block;overflow-wrap:anywhere}.security-row text:first-child{color:$ink;font-size:25rpx;font-weight:700}.security-row text:last-child{margin-top:6rpx;color:$ink-muted;font-size:21rpx}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
.security-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:20rpx}
</style>
+26 -6
View File
@@ -15,7 +15,7 @@
<text v-if="errors[field.key]" class="field-error">{{ errors[field.key] }}</text>
</view>
</view>
<AppButton block :disabled="passwordState === 'saving'" :label="passwordState === 'saving' ? '正在校验' : '校验新密码(不提交)'" @click="savePassword" />
<AppButton block :disabled="passwordState === 'saving'" :label="passwordState === 'saving' ? '正在提交' : '确认修改密码'" @click="savePassword" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog
@@ -41,7 +41,9 @@ 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { calcMD5 } from "@/utils/md5.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
import {
PASSWORD_POLICY_MESSAGE,
@@ -59,6 +61,8 @@ const passwordFields = [
{ key: "confirm", label: "确认新密码", placeholder: "请再次输入新密码" },
];
const toastVisible = ref(false); const toastMessage = ref(""); let timer = null;
const passwordRequestController = createRequestController();
let pageActive = true;
const formSnapshot = computed(() => JSON.stringify(passwordForm));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
@@ -79,13 +83,27 @@ const validateForm = () => {
errors.confirm = !passwordForm.confirm ? "请再次输入新密码" : passwordForm.confirm !== passwordForm.next ? "两次输入的新密码不一致" : "";
return !Object.values(errors).some(Boolean);
};
const savePassword = () => {
const savePassword = async () => {
if (!validateForm() || passwordState.value === "saving") return;
passwordState.value = "saving";
timer = setTimeout(() => {
passwordState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
try {
await appApi.changePassword({
oldPasswordHash: calcMD5(passwordForm.current),
newPasswordHash: calcMD5(passwordForm.next),
}, { requestController: passwordRequestController });
if (!pageActive) return;
passwordForm.current = "";
passwordForm.next = "";
passwordForm.confirm = "";
baseline.value = formSnapshot.value;
showToast("密码修改成功");
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showToast(error?.message || "密码修改失败,请稍后重试");
}
} finally {
if (pageActive) passwordState.value = "ready";
}
};
const requestBack = () =>
runBackGuard({
@@ -98,6 +116,8 @@ const requestBack = () =>
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
passwordRequestController.abort();
clearTimeout(timer);
discardConfirmation.dispose();
});
+4 -91
View File
@@ -1,98 +1,11 @@
<!-- 页面编号M-05用途验证并更换绑手机号 -->
<!-- 页面编号M-05用途换绑手机号缺专用发码/资料 DTO 且需人工 TAC/短信时保持关闭 -->
<template>
<view class="phone-page" :class="{ 'phone-state--ready': phoneState === 'ready', 'phone-state--saving': phoneState === 'saving' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="修改手机号" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="current-phone"><text>当前绑定手机号</text><text>{{ phoneForm.currentPhone }}</text><text>更换后新手机号将用于登录与安全验证</text></view>
<view class="form-panel">
<view class="form-row"><text>新手机号</text><input v-model.trim="phoneForm.newPhone" type="number" maxlength="11" aria-label="新手机号" placeholder="请输入新手机号" @input="errors.newPhone = ''" /></view>
<text v-if="errors.newPhone" class="field-error">{{ errors.newPhone }}</text>
<view class="form-row code-row"><text>验证码</text><input v-model.trim="phoneForm.code" type="number" maxlength="4" aria-label="短信验证码" placeholder="4 位验证码" @input="errors.code = ''" /><view class="code-action" role="button" aria-label="检查验证码发送条件" @click="sendCode">发送条件</view></view>
<text v-if="errors.code" class="field-error">{{ errors.code }}</text>
</view>
<AppButton block :disabled="phoneState === 'saving'" :label="phoneState === 'saving' ? '正在校验' : '校验换绑信息(不提交)'" @click="savePhone" />
</view>
<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>
<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>换绑接口要求新手机号四位短信码和 `clientId`但没有可安全消费的当前手机号 DTO也没有已核实的专用受保护发码链页面不显示 mock 手机号不发送验证码不在无人值守时换绑</text><AppButton block label="返回账号与安全" @click="backToSecurity" /></view></view></view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, 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 { currentUser } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const phoneForm = reactive({ currentPhone: currentUser.phone, newPhone: "", code: "" });
const errors = reactive({ newPhone: "", code: "" });
const phoneState = ref("ready");
const discardVisible = ref(false);
const toastVisible = ref(false); const toastMessage = ref("");
let stateTimer = null; let toastTimer = null;
const formSnapshot = computed(() => JSON.stringify({ newPhone: phoneForm.newPhone, code: phoneForm.code }));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(toastTimer); toastTimer = setTimeout(() => (toastVisible.value = false), 1800); };
const sendCode = () => {
if (!/^1\d{10}$/.test(phoneForm.newPhone)) { errors.newPhone = "请输入正确的新手机号"; return; }
errors.newPhone = "";
showToast("需先完成滑动行为验证;当前未发送验证码");
};
const validatePhone = () => {
errors.newPhone = /^1\d{10}$/.test(phoneForm.newPhone) ? "" : "请输入正确的新手机号";
errors.code = /^\d{4}$/.test(phoneForm.code) ? "" : "请输入 4 位验证码";
return !errors.newPhone && !errors.code;
};
const savePhone = () => {
if (!validatePhone() || phoneState.value === "saving") return;
phoneState.value = "saving";
stateTimer = setTimeout(() => {
phoneState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: phoneState.value === "saving",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
clearTimeout(stateTimer);
clearTimeout(toastTimer);
discardConfirmation.dispose();
});
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToSecurity=()=>returnTo("M03");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.phone-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.current-phone,.form-panel{@include adaptive.adaptive-profile-content}.current-phone{min-height:210rpx;padding:40rpx 48rpx;text-align:center}.current-phone text{display:block}.current-phone text:first-child{color:$ink-muted;font-size:22rpx}.current-phone text:nth-child(2){margin-top:8rpx;color:$ink;font-size:38rpx;font-weight:700;letter-spacing:3rpx}.current-phone text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.5}.form-panel{margin-top:22rpx;padding:24rpx 34rpx 32rpx}.form-row{display:grid;grid-template-columns:130rpx minmax(0,1fr);min-height:94rpx;align-items:center;gap:16rpx;border-bottom:1px solid rgba(181,137,63,.42)}.code-row{grid-template-columns:130rpx minmax(0,1fr) auto}.form-row>text{color:$ink;font-size:23rpx;font-weight:700}.form-row input{width:auto;min-width:0;min-height:70rpx;color:$ink;font-size:23rpx}.code-action{display:flex;min-width:126rpx;min-height:70rpx;align-items:center;justify-content:flex-end;color:$brand-red;font-size:21rpx}.field-error{display:block;padding-top:7rpx;color:#b42318;font-size:20rpx;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:112rpx minmax(0,1fr)}.code-row{grid-template-columns:112rpx minmax(0,1fr);}.code-action{grid-column:2;justify-content:flex-start}}
.phone-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+2 -1
View File
@@ -4,6 +4,7 @@
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="帮助中心" /></view>
<view class="page-content page-layer">
<text class="help-source-note">当前展示的是本地使用说明不代表已读取服务端帮助文章需要最新支持请提交意见反馈</text>
<view class="search-box"><input v-model.trim="keyword" aria-label="搜索帮助" placeholder="搜索问题关键词" /><text>{{ filteredQuestions.length }} </text></view>
<scroll-view scroll-x class="category-scroll" :show-scrollbar="false"><view class="category-row"><view v-for="category in helpCategories" :key="category" class="category-chip" :class="{ active: activeCategory === category }" role="button" @click="activeCategory = category">{{ category }}</view></view></scroll-view>
<view v-if="filteredQuestions.length" class="question-list">
@@ -46,5 +47,5 @@ const contactSupport = () => openPage("M07", {}, "M06");
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.help-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:24rpx 28rpx 72rpx}.search-box{@include adaptive.adaptive-profile-field;display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:86rpx;align-items:center;gap:16rpx;padding:0 30rpx}.search-box input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:23rpx}.search-box text{color:$ink-muted;font-size:20rpx}.category-scroll{width:100%;margin-top:18rpx}.category-row{display:flex;width:max-content;gap:12rpx;padding:2rpx}.category-chip{display:flex;min-width:100rpx;min-height:64rpx;align-items:center;justify-content:center;padding:0 22rpx;box-sizing:border-box;color:$ink-muted;font-size:22rpx}.category-chip.active{@include adaptive.adaptive-scroll-button(secondary);color:$brand-red;font-weight:700}.question-list{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}.question-card,.empty-card{@include adaptive.adaptive-profile-content}.question-card{min-height:104rpx;padding:24rpx 34rpx}.question-heading{display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:58rpx;align-items:center;gap:18rpx}.question-heading text:first-child{color:$ink;font-size:24rpx;font-weight:700;overflow-wrap:anywhere}.question-heading text:last-child{color:$brand-red;font-size:20rpx}.answer{display:block;padding:14rpx 4rpx 6rpx;border-top:1px solid rgba(181,137,63,.32);color:$ink-muted;font-size:22rpx;line-height:1.65;overflow-wrap:anywhere}.empty-card{min-height:220rpx;padding:58rpx 44rpx;text-align:center}.empty-card text{display:block}.empty-card text:first-child{color:$ink;font-size:29rpx;font-weight:700}.empty-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.5}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:20rpx;padding-left:20rpx}}
.help-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:24rpx 28rpx 72rpx}.help-source-note{display:block;margin:0 4rpx 16rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.search-box{@include adaptive.adaptive-profile-field;display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:86rpx;align-items:center;gap:16rpx;padding:0 30rpx}.search-box input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:23rpx}.search-box text{color:$ink-muted;font-size:20rpx}.category-scroll{width:100%;margin-top:18rpx}.category-row{display:flex;width:max-content;gap:12rpx;padding:2rpx}.category-chip{display:flex;min-width:100rpx;min-height:64rpx;align-items:center;justify-content:center;padding:0 22rpx;box-sizing:border-box;color:$ink-muted;font-size:22rpx}.category-chip.active{@include adaptive.adaptive-scroll-button(secondary);color:$brand-red;font-weight:700}.question-list{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}.question-card,.empty-card{@include adaptive.adaptive-profile-content}.question-card{min-height:104rpx;padding:24rpx 34rpx}.question-heading{display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:58rpx;align-items:center;gap:18rpx}.question-heading text:first-child{color:$ink;font-size:24rpx;font-weight:700;overflow-wrap:anywhere}.question-heading text:last-child{color:$brand-red;font-size:20rpx}.answer{display:block;padding:14rpx 4rpx 6rpx;border-top:1px solid rgba(181,137,63,.32);color:$ink-muted;font-size:22rpx;line-height:1.65;overflow-wrap:anywhere}.empty-card{min-height:220rpx;padding:58rpx 44rpx;text-align:center}.empty-card text{display:block}.empty-card text:first-child{color:$ink;font-size:29rpx;font-weight:700}.empty-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.5}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:20rpx;padding-left:20rpx}}
</style>
+19 -6
View File
@@ -1,6 +1,6 @@
<!-- 页面编号M-10用途协议隐私版本与退出登录 -->
<template>
<view class="about-page">
<view class="about-page" :class="{ 'about-state--logging-out': logoutSubmitting }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="关于家谱" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
@@ -11,18 +11,19 @@
<AppButton block type="secondary" label="退出登录" @click="logoutVisible = true" />
</view>
<AppDialog :visible="agreementVisible" :close-on-mask="false" eyebrow="协议与说明" :title="activeAgreement.label" :message="activeAgreement.copy" confirm-text="关闭" @confirm="agreementVisible = false" @close="agreementVisible = false" />
<AppDialog :visible="logoutVisible" eyebrow="账号操作" title="确认退出登录?" message="退出后需要重新验证账号;本机保存的密码不会被保留。" confirm-text="确认退出" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="confirmLogout" @cancel="logoutVisible = false" @close="logoutVisible = false" />
<AppDialog :visible="logoutVisible" eyebrow="账号操作" title="确认退出登录?" message="退出后需要重新验证账号;本机保存的密码不会被保留。" :confirm-text="logoutSubmitting ? '正在退出' : '确认退出'" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="confirmLogout" @cancel="logoutVisible = false" @close="logoutVisible = false" />
</view>
</template>
<script setup>
import { reactive, ref } from "vue";
import { onBackPress } from "@dcloudio/uni-app";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import manifest from "@/manifest.json";
import { appApi, createRequestController } from "@/utils/api.js";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation.js";
import { session } from "@/utils/session.js";
@@ -35,10 +36,21 @@ const agreementItems = [
const activeAgreement = reactive({ label: "", copy: "" });
const agreementVisible = ref(false);
const logoutVisible = ref(false);
const logoutSubmitting = ref(false);
const logoutRequestController = createRequestController();
const openAgreement = (item) => { activeAgreement.label = item.label; activeAgreement.copy = item.copy; agreementVisible.value = true; };
const confirmLogout = () => {
session.clear();
logoutVisible.value = false;
const confirmLogout = async () => {
if (logoutSubmitting.value) return false;
logoutSubmitting.value = true;
try {
await appApi.logout({ requestController: logoutRequestController });
} catch {
// 退出请求的结果未知时仍必须撤销本机会话,不能保留旧授权或自动重试。
} finally {
session.clear();
logoutVisible.value = false;
logoutSubmitting.value = false;
}
return goRoot("A01");
};
const closeActiveDialog = () => {
@@ -51,6 +63,7 @@ const requestBack = () =>
"close-transient": closeActiveDialog,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => logoutRequestController.abort());
</script>
<style scoped lang="scss">
+67 -43
View File
@@ -4,6 +4,7 @@
class="people-page"
:class="{
'people-state--ready': peopleState === 'ready',
'people-state--loading': peopleState === 'loading',
'people-state--empty': peopleState === 'empty',
'people-state--error': peopleState === 'error',
}"
@@ -12,7 +13,12 @@
<view class="people-page__header"><PageHeader title="人物录" /></view>
<view class="people-content">
<template v-if="peopleState === 'ready'">
<AppLoading
v-if="peopleState === 'loading'"
text="正在读取人物录"
description="请稍候,正在读取当前家谱的人物资料。"
/>
<template v-else-if="peopleState === 'ready'">
<view class="people-search">
<input
v-model="keywordInput"
@@ -30,9 +36,9 @@
>
</view>
<view v-if="filteredPeople.length" class="people-list">
<view v-if="people.length" class="people-list">
<view
v-for="person in filteredPeople"
v-for="person in people"
:key="person.id"
class="person-card"
@click="openPerson(person)"
@@ -46,10 +52,12 @@
</view>
</view>
<AppButton
v-if="hasMore"
class="people-primary-action"
type="secondary"
block
label="填写人物预览"
@click="createPersonPreview"
:label="loadingMore ? '正在加载…' : '加载更多人物'"
@click="loadMore"
/>
</view>
@@ -85,9 +93,10 @@
}}</text>
</view>
<AppButton
v-if="peopleState !== 'empty'"
:type="peopleState === 'empty' ? 'primary' : 'secondary'"
block
:label="peopleState === 'error' ? '重新查看' : peopleState === 'invalid' ? '返回上一页' : '填写人物预览'"
:label="peopleState === 'error' ? '重新查看' : '返回上一页'"
@click="handleStateAction"
/>
</view>
@@ -96,15 +105,13 @@
</template>
<script setup>
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
listTreeMemberPresentationFixtures,
} from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
@@ -112,17 +119,13 @@ const people = ref([]);
const peopleState = ref("ready");
const keywordInput = ref("");
const keyword = ref("");
const hasValidContext = computed(() => peopleState.value !== "invalid");
const filteredPeople = computed(() => {
const value = keyword.value.trim().toLowerCase();
if (!value) return people.value;
return people.value.filter((person) =>
`${person.name} ${person.relation} ${person.branch || ""} ${person.generationName || ""}${person.generation}${person.generation}`
.toLowerCase()
.includes(value),
);
});
const total = ref(0);
const pageNum = ref(1);
const loadingMore = ref(false);
const peopleRequestController = createRequestController();
let loadSequence = 0;
const hasValidContext = computed(() => Boolean(genealogyId.value));
const hasMore = computed(() => people.value.length < total.value);
const applySearch = () => {
if (keyword.value) {
@@ -130,14 +133,42 @@ const applySearch = () => {
return;
}
keyword.value = keywordInput.value.trim();
pageNum.value = 1;
void loadPeople();
};
const clearSearch = () => {
keywordInput.value = "";
keyword.value = "";
pageNum.value = 1;
void loadPeople();
};
const restoreList = () => {
people.value = listTreeMemberPresentationFixtures(genealogyId.value);
peopleState.value = people.value.length ? "ready" : "empty";
const loadPeople = async ({ append = false } = {}) => {
if (!hasValidContext.value) return;
const activeLoad = ++loadSequence;
if (append) loadingMore.value = true;
else peopleState.value = "loading";
try {
const result = await appApi.getPersonPage(
genealogyId.value,
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
{ requestController: peopleRequestController },
);
if (activeLoad !== loadSequence) return;
people.value = append ? [...people.value, ...result.rows] : result.rows;
total.value = result.total;
peopleState.value = people.value.length ? "ready" : "empty";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
if (!append) people.value = [];
peopleState.value = "error";
} finally {
if (activeLoad === loadSequence) loadingMore.value = false;
}
};
const loadMore = () => {
if (loadingMore.value || !hasMore.value) return;
pageNum.value += 1;
void loadPeople({ append: true });
};
const openPerson = (person) =>
hasValidContext.value
@@ -151,34 +182,27 @@ const openPerson = (person) =>
"R01",
)
: Promise.resolve(false);
const createPersonPreview = () =>
hasValidContext.value
? openPage(
"R02",
{ genealogyId: genealogyId.value, mode: "create" },
"R01",
)
: Promise.resolve(false);
const handleStateAction = () => {
if (peopleState.value === "invalid") return goBack();
if (peopleState.value === "error") return restoreList();
return createPersonPreview();
if (peopleState.value === "error") {
pageNum.value = 1;
return loadPeople();
}
return goBack();
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
if (!hasValidContext.value || query.state === "error") {
people.value = [];
peopleState.value = "invalid";
return;
}
people.value = listTreeMemberPresentationFixtures(genealogyId.value);
peopleState.value = ["empty", "error"].includes(query.state)
? query.state
: people.value.length
? "ready"
: "empty";
void loadPeople();
});
onUnload(() => {
loadSequence += 1;
peopleRequestController.abort();
});
</script>
+63 -232
View File
@@ -4,7 +4,7 @@
<ModulePageBackground module="records" />
<view class="person-detail-header">
<PageHeader
:title="personState === 'edit' ? (isCreateMode ? '人物预览' : '编辑预览') : '人物详情'"
title="人物详情"
custom-back
@back="requestBack"
/>
@@ -15,12 +15,12 @@
</view>
<view v-else class="person-detail-content">
<template v-if="['detail', 'edit', 'privacy', 'preview'].includes(personState)">
<template v-if="personState === 'detail'">
<view class="person-identity-card">
<view class="person-identity-card__copy">
<text class="person-identity-card__name">{{ person.name || "待填写姓名" }}</text>
<text class="person-identity-card__meta">{{ person.relation || "人物预览" }} · {{ person.generation || "—" }} </text>
<text class="person-identity-card__hint">{{ personState === "preview" ? "本地预览 · 未提交" : "人物录档案" }}</text>
<text class="person-identity-card__hint">人物录档案</text>
</view>
</view>
</template>
@@ -33,35 +33,9 @@
<AppButton type="secondary" block label="成长日志" @click="toGrowthJournal" />
<AppButton type="secondary" block label="人生事(待开放)" @click="toLifeEvents" />
</view>
<view class="person-edit-action" @click="enterEdit"><AppButton block label="制作编辑预览" /></view>
<view class="person-edit-action" @click="toMemberProfile"><AppButton block label="查看成员档案" /></view>
</template>
<template v-else-if="personState === 'edit'">
<view v-for="field in shortFields" :key="field.key" class="person-field">
<text class="person-field__label">{{ field.label }}</text>
<input v-model="draft[field.key]" :type="field.key === 'generation' ? 'number' : 'text'" :placeholder="`请输入${field.label}`" />
<text v-if="errors[field.key]" class="person-field-error">{{ errors[field.key] }}</text>
</view>
<view v-for="field in longFields" :key="field.key" class="person-long-field">
<text class="person-long-field__label">{{ field.label }}</text>
<textarea v-model="draft[field.key]" auto-height :placeholder="`请输入${field.label}`" />
</view>
<view class="person-edit-actions">
<view class="person-save-action" @click="savePerson"><AppButton block label="生成本地预览" /></view>
<view class="person-cancel-action" @click="cancelEdit"><AppButton type="secondary" block label="取消填写" /></view>
</view>
</template>
<view v-else-if="personState === 'preview'" class="person-state-card">
<view><text>本地预览尚未提交服务器</text><text>这份人物资料只存在于当前页面不会新增覆盖或刷新人物录</text></view>
<view class="person-state-action"><AppButton block label="返回人物录" @click="returnToPeople" /></view>
</view>
<view v-else-if="personState === 'privacy'" class="person-state-card">
<view><text>部分资料未公开</text><text>人物小传与档案备注受隐私设置保护当前只展示公开身份</text></view>
<view class="person-state-action"><AppButton block label="返回人物录" @click="returnToPeople" /></view>
</view>
<view v-else class="person-state-card">
<view>
<text>{{ personState === 'expired' ? '人物档案已失效' : '人物档案暂不可用' }}</text>
@@ -71,173 +45,66 @@
</view>
</view>
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
<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, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findTreeMemberPresentationFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import {
goBack,
handleBackPress,
openPage,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const genealogyId = ref("");
const personId = ref("");
const routeMode = ref("");
const personState = ref("loading");
const personRequestController = createRequestController();
let loadSequence = 0;
const person = reactive({
id: "",
name: "",
relation: "",
generationName: "",
generation: "",
biography: "",
remark: "",
status: "",
});
const draft = reactive({
name: "",
generationName: "",
generation: "",
aliasName: "",
sex: "",
personStatus: "",
birthDate: "",
birthLunar: "",
birthplace: "",
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
spouseNames: "",
biography: "",
remark: "",
});
const errors = reactive({ name: "", generation: "" });
const baseline = ref("");
const toastVisible = ref(false);
const discardVisible = ref(false);
let toastTimer = null;
const isCreateMode = computed(() => routeMode.value === "create");
const formSnapshot = computed(() => JSON.stringify({ ...draft }));
const isDirty = computed(() =>
personState.value === "preview" ||
(personState.value === "edit" && formSnapshot.value !== baseline.value),
);
const shortFields = [
{ key: "name", label: "姓名" },
{ key: "generationName", label: "字辈" },
{ key: "generation", label: "世代" },
];
const longFields = [
{ key: "biography", label: "人物小传" },
{ key: "remark", label: "档案备注" },
];
const detailSections = computed(() => [
{ title: "别名", copy: person.aliasName },
{ title: "字辈", copy: person.generationName },
{ title: "性别(字典值)", copy: person.sex },
{ title: "人物状态(字典值)", copy: person.personStatus },
{ title: "出生日期", copy: person.birthDate },
{ title: "出生农历", copy: person.birthLunar },
{ title: "出生地", copy: person.birthplace },
{ title: "逝世日期", copy: person.deathDate },
{ title: "逝世农历", copy: person.deathLunar },
{ title: "逝世地", copy: person.deathPlace },
{ title: "安葬地", copy: person.burialPlace },
{ title: "配偶", copy: person.spouseNames },
{ title: "人物小传", copy: person.biography },
{ title: "档案备注", copy: person.remark },
]);
const copyToDraft = () => {
Object.assign(draft, {
name: person.name,
generationName: person.generationName,
generation: String(person.generation || ""),
biography: person.biography,
remark: person.remark,
});
baseline.value = formSnapshot.value;
};
const clearErrors = () => Object.assign(errors, { name: "", generation: "" });
const enterEdit = () => {
if (personState.value !== "detail" || !person.id) return false;
copyToDraft();
clearErrors();
personState.value = "edit";
return true;
};
const showPreviewToast = () => {
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
toastTimer = null;
}, 1800);
};
const validatePerson = () => {
errors.name = draft.name.trim() ? "" : "请填写姓名";
const generationInput = draft.generation.trim();
const generation = Number(generationInput);
errors.generation = !generationInput
? ""
: Number.isInteger(generation) && generation > 0
? ""
: "世代必须是正整数";
return !errors.name && !errors.generation;
};
const savePerson = () => {
clearErrors();
if (!validatePerson()) return false;
const localPersonPreview = {
name: draft.name.trim(),
generationName: draft.generationName.trim(),
generation: draft.generation.trim()
? String(Number(draft.generation))
: "",
biography: draft.biography.trim(),
remark: draft.remark.trim(),
};
Object.assign(person, localPersonPreview);
personState.value = "preview";
showPreviewToast();
return true;
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const requestDiscardConfirmation = discardConfirmation.request;
const cancelEdit = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
if (isCreateMode.value) return goBack();
copyToDraft();
clearErrors();
personState.value = "detail";
return true;
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (personState.value === "edit" && !isCreateMode.value) return cancelEdit();
return runBackGuard({
dirty: isDirty.value,
"confirm-discard": requestDiscardConfirmation,
});
};
const requestBack = () => goBack();
const returnToPeople = () =>
genealogyId.value
@@ -259,74 +126,45 @@ const toLifeEvents = () =>
"R02",
)
: Promise.resolve(false);
const toMemberProfile = () =>
person.id
? openPage(
"T03",
{ genealogyId: genealogyId.value, personId: person.id },
"R02",
)
: Promise.resolve(false);
const loadPerson = async () => {
const activeLoad = ++loadSequence;
personState.value = "loading";
try {
const result = await appApi.getPerson(genealogyId.value, personId.value, {
requestController: personRequestController,
});
if (activeLoad !== loadSequence) return;
Object.assign(person, result);
personState.value = "detail";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
personState.value = "expired";
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
routeMode.value = String(query.mode || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
const hasValidGenealogy = ["owner", "member"].includes(access.accessRole);
const isCreateContract = routeMode.value === "create" && !personId.value;
const isViewContract = routeMode.value === "view" && Boolean(personId.value);
if (!hasValidGenealogy || (!isCreateContract && !isViewContract)) {
if (query.mode !== "view" || !genealogyId.value || !personId.value || query.state === "error") {
personState.value = "error";
return;
}
if (isCreateContract) {
Object.assign(person, {
id: "",
name: "",
relation: "人物预览",
generationName: "",
generation: "",
biography: "",
remark: "",
status: "",
});
copyToDraft();
personState.value = "edit";
return;
}
const selected = findTreeMemberPresentationFixture(genealogyId.value, personId.value);
if (!selected) {
personState.value = "expired";
return;
}
Object.assign(person, {
id: selected.id,
name: selected.name,
relation: selected.relation,
generation: String(selected.generation),
status: selected.status,
});
if (["privacy", "forbidden"].includes(selected.status)) {
Object.assign(person, {
generationName: "",
biography: "",
remark: "",
});
personState.value = "privacy";
return;
}
Object.assign(person, {
generationName: selected.generationName || "",
biography: selected.summary || "",
remark: selected.note || "",
});
copyToDraft();
personState.value = ["expired", "error"].includes(query.state)
? query.state
: "detail";
void loadPerson();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
discardConfirmation.dispose();
onUnload(() => {
loadSequence += 1;
personRequestController.abort();
});
</script>
@@ -344,19 +182,12 @@ onUnmounted(() => {
.person-identity-card__meta { margin-top: 8rpx; color: $ink-muted; font-size: 25rpx; font-weight: 600; }
.person-identity-card__hint { margin-top: 8rpx; color: #806a51; font-size: 21rpx; }
.person-archive-card { min-height: 154rpx; margin-top: 14rpx; padding: 32rpx 42rpx; box-sizing: border-box; }
.person-archive-card,.person-long-field,.person-state-card { @include adaptive.adaptive-records-content; }
.person-archive-card,.person-state-card { @include adaptive.adaptive-records-content; }
.person-archive-card text { display: block; }
.person-archive-card text:first-child,.person-long-field__label { color: $brand-red; font-size: 24rpx; font-weight: 700; }
.person-archive-card text:first-child { color: $brand-red; font-size: 24rpx; font-weight: 700; }
.person-archive-card text:last-child { margin-top: 11rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
.person-edit-action { margin-top: 20rpx; }
.person-related-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14rpx; margin-top: 18rpx; }
.person-field { @include adaptive.adaptive-records-field; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8rpx 24rpx; min-height: 92rpx; margin-top: 12rpx; padding: 18rpx 28rpx; }
.person-field__label { color: $ink; font-size: 23rpx; font-weight: 700; }
.person-field input { width: 100%; min-width: 0; min-height: 56rpx; color: $ink; font-size: 23rpx; text-align: right; }
.person-field-error { grid-column: 1 / -1; display: block; color: $brand-red; font-size: 21rpx; text-align: right; }
.person-long-field { display: flex; flex-direction: column; min-height: 210rpx; margin-top: 14rpx; padding: 28rpx 38rpx; box-sizing: border-box; }
.person-long-field textarea { width: 100%; min-height: 112rpx; margin-top: 14rpx; color: $ink; font-size: 23rpx; line-height: 1.5; }
.person-edit-actions { display: flex; flex-direction: column; gap: 14rpx; margin-top: 20rpx; }
.person-state-card { display: flex; flex-direction: column; min-height: 300rpx; margin-top: 26rpx; padding: 72rpx 54rpx 42rpx; box-sizing: border-box; text-align: center; }
.person-state-card text { display: block; }
.person-state-card text:first-child { color: $ink; font-family: STKaiti,KaiTi,serif; font-size: 34rpx; font-weight: 700; }
+29 -179
View File
@@ -1,200 +1,50 @@
<!-- 页面编号R-03用途贺礼簿列表空态失败与新增入口 -->
<!-- 页面编号R-03用途亲友往来入口列表 DTO 缺失时不展示 fixture 记录 -->
<template>
<view class="gift-page" :class="stateClasses">
<view class="gift-page">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="贺礼簿" :action="hasValidContext ? '填写预览' : ''" @action="createRelativePreview" />
</view>
<view v-if="giftState === 'loading'" class="page-loading">
<AppLoading
text="正在整理贺礼簿"
description="请稍候,正在读取家人的礼仪往来。"
/>
</view>
<view v-else class="page-content">
<template v-if="giftState === 'ready' && relativeRecords.length">
<view
v-for="record in relativeRecords"
:key="record.relativeId"
class="record-card"
role="button"
:aria-label="`查看${record.eventName}`"
@click="openRelative(record)"
>
<text class="record-card__tag">{{ record.relationName }}</text>
<text class="record-card__title">{{ record.eventName }}</text>
<text class="record-card__copy">
{{ record.relativeName }} · {{ record.eventTime }} · 金额记录{{ record.giftAmount }}
</text>
<text class="record-card__hint">查看往来记录</text>
</view>
<AppButton block label="填写往来预览" @click="createRelativePreview" />
</template>
<view v-else class="state-card">
<text>
{{ giftState === "error" ? "贺礼簿暂不可用" : giftState === "invalid" ? "贺礼簿入口无效" : "还没有往来记录" }}
</text>
<text>
{{
giftState === "error"
? "请稍后重新查看,已有记录不会受到影响。"
: giftState === "invalid"
? "没有找到可访问的成员家谱,页面不会展示其他家谱记录。"
: "从第一份家人之间的心意开始记录。"
}}
</text>
<AppButton
:type="giftState === 'empty' ? 'primary' : 'secondary'"
block
:label="giftState === 'error' ? '重新查看' : giftState === 'invalid' ? '返回上一页' : '填写往来预览'"
@click="handleStateAction"
/>
<view class="page-header"><PageHeader title="贺礼簿" :action="hasValidContext ? '新建' : ''" @action="createRelative" /></view>
<view class="page-content">
<view class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
listRelativeRecordFixtures,
} from "@/data/mock.js";
import { goBack, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const relativeRecords = ref([]);
const giftState = ref("loading");
const hasValidContext = computed(() => ["ready", "empty"].includes(giftState.value));
const stateClasses = computed(() => ({
"relative-state--loading": giftState.value === "loading",
"relative-state--empty": giftState.value === "empty",
"relative-state--error": giftState.value === "error",
"relative-state--invalid": giftState.value === "invalid",
}));
const hasValidContext = ref(false);
const stateCopy = computed(() => hasValidContext.value
? { title: "贺礼簿待后端字段合同", copy: "往来列表没有声明记录 ID、关系、事项、时间、金额或备注字段;页面已停止展示本地记录。", action: "新建往来记录" }
: { title: "贺礼簿入口无效", copy: "没有取得有效家谱标识,页面不会展示其他家谱记录。", action: "返回上一页" },
);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
relativeRecords.value = [];
giftState.value = "invalid";
return;
}
relativeRecords.value = listRelativeRecordFixtures(genealogyId.value);
giftState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: relativeRecords.value.length
? "ready"
: "empty";
genealogyId.value = String(query?.genealogyId || "");
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
});
const openRelative = (record) =>
openPage(
"R04",
{
genealogyId: genealogyId.value,
mode: "view",
relativeId: String(record.relativeId),
},
"R03",
);
const createRelativePreview = () =>
hasValidContext.value
? openPage(
"R04",
{ genealogyId: genealogyId.value, mode: "create" },
"R03",
)
: Promise.resolve(false);
const restoreRelatives = () => {
relativeRecords.value = listRelativeRecordFixtures(genealogyId.value);
giftState.value = relativeRecords.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (giftState.value === "invalid") return goBack();
if (giftState.value === "error") return restoreRelatives();
return createRelativePreview();
};
const createRelative = () => hasValidContext.value
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
: Promise.resolve(false);
const handleStateAction = () => hasValidContext.value ? createRelative() : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.gift-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.record-card,
.state-card {
box-sizing: border-box;
@include adaptive.adaptive-records-content;
}
.record-card {
min-height: 190rpx;
padding: 34rpx 46rpx;
}
.record-card > text,
.state-card > text {
display: block;
}
.record-card__tag {
color: $brand-red;
font-size: 21rpx;
font-weight: 700;
}
.record-card__title {
margin-top: 6rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.record-card__copy {
margin-top: 9rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.5;
}
.record-card__hint {
margin-top: 10rpx;
color: $brand-red;
font-size: 21rpx;
}
.state-card {
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.gift-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { padding: 18rpx 24rpx 72rpx; }
.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; @include adaptive.adaptive-records-content; }
.state-card > text { display: block; }
.state-card > text:first-child { color: $ink; font-size: 35rpx; font-weight: 700; }
.state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
</style>
+92 -314
View File
@@ -1,130 +1,47 @@
<!-- 页面编号R-04用途人情往来详情与不写库的新增编辑预览 -->
<!-- 页面编号R-04用途 Apifox 已声明字段创建亲友往来记录 -->
<template>
<view class="gift-editor-page" :class="editorClasses">
<view class="gift-editor-page" :class="`relative-editor-state--${editorState}`">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
:title="
mode === 'create'
? '往来预览'
: mode === 'view'
? '往来详情'
: '编辑预览'
"
:action="mode === 'view' && editorState === 'ready' ? '制作预览' : ''"
custom-back
@back="requestBack"
@action="enterEdit"
/>
</view>
<view v-if="editorState === 'loading'" class="page-loading">
<AppLoading
text="正在读取往来记录"
description="请稍候,正在核对当前家谱与记录身份。"
/>
</view>
<view v-else class="page-content">
<view v-if="editorState === 'preview'" class="state-card">
<text>本地预览尚未提交服务器</text>
<text>这份往来内容只存在于当前页面不会插入修改或删除正式记录</text>
<view v-for="item in previewRows" :key="item.label">
<text>{{ item.label }}</text><text>{{ item.value }}</text>
</view>
<AppButton block label="返回贺礼簿" @click="returnToRelatives" />
</view>
<view v-else-if="mode === 'view' && editorState === 'ready'" class="detail-card">
<text>{{ relativeForm.eventName }}</text>
<view v-for="item in detailRows" :key="item.label">
<text>{{ item.label }}</text>
<text>{{ item.value }}</text>
</view>
<AppButton block label="制作编辑预览" @click="enterEdit" />
<AppButton type="secondary" block disabled label="删除暂未开放" />
</view>
<view v-else-if="editorState === 'ready'" class="form-card">
<text>
{{ mode === "create" ? "填写一份人情往来预览" : "调整往来记录预览" }}
</text>
<view class="page-header"><PageHeader title="新建往来记录" custom-back @back="requestBack" /></view>
<view class="page-content">
<view v-if="editorState === 'form'" class="form-card">
<text>记录一份家人往来</text>
<text class="form-copy">页面只发送已声明且可映射的字段媒体需要 `mediaOssIds`没有上传 owner 时不提交</text>
<view v-for="field in fields" :key="field.key" class="field-row">
<text>{{ field.label }}</text>
<input
v-model="relativeForm[field.key]"
:type="field.key === 'giftAmount' ? 'digit' : 'text'"
:placeholder="`请输入${field.label}`"
/>
<text v-if="relativeErrors[field.key]">{{ relativeErrors[field.key] }}</text>
<textarea v-if="field.key === 'recordContent'" v-model="form[field.key]" auto-height :placeholder="`请输入${field.label}`" @input="submitError = ''" />
<input v-else v-model="form[field.key]" :type="field.key === 'giftAmount' ? 'digit' : 'text'" :placeholder="`请输入${field.label}`" @input="submitError = ''" />
</view>
<AppButton
block
:disabled="isSubmitting"
:label="isSubmitting ? '正在生成预览' : '生成本地预览'"
@click="saveRelative"
/>
<AppButton type="secondary" block label="取消填写" @click="requestBack" />
<text v-if="submitError" class="save-error">{{ submitError }}</text>
<AppButton block :disabled="isSubmitting" :label="isSubmitting ? '正在提交' : '提交往来记录'" @click="saveRelative" />
</view>
<view v-else class="state-card">
<text>往来记录不可用</text>
<text>记录不存在缺少身份或不属于当前家谱页面不会回退到其他记录</text>
<AppButton type="secondary" block label="返回贺礼簿" @click="returnToRelatives" />
<text>{{ resultCopy.title }}</text>
<text>{{ resultCopy.copy }}</text>
<AppButton block :label="resultCopy.action" @click="handleResultAction" />
</view>
</view>
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<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, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } 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";
import {
findRelativeRecordFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const relativeId = ref("");
const mode = ref("");
const editorState = ref("loading");
const editorState = ref("form");
const isSubmitting = ref(false);
const discardVisible = ref(false);
const localRelativePreview = ref(null);
const relativeForm = reactive({
relativeName: "",
relationName: "",
eventName: "",
eventTime: "",
giftAmount: "",
recordContent: "",
});
const relativeErrors = reactive({
relativeName: "",
relationName: "",
eventName: "",
eventTime: "",
giftAmount: "",
recordContent: "",
});
const submitError = ref("");
const form = reactive({ relativeName: "", relationName: "", eventName: "", eventTime: "", giftAmount: "", recordContent: "" });
const fields = [
{ key: "relativeName", label: "亲友姓名" },
{ key: "relationName", label: "关系称谓" },
@@ -133,221 +50,82 @@ const fields = [
{ key: "giftAmount", label: "礼金金额" },
{ key: "recordContent", label: "往来备注" },
];
const baseline = ref("");
let submitTimer = null;
const editorClasses = computed(() => ({
"relative-editor-state--preview": editorState.value === "preview",
"relative-editor-state--invalid": editorState.value === "invalid",
}));
const formSnapshot = computed(() => JSON.stringify({ ...relativeForm }));
const isDirty = computed(() =>
editorState.value === "preview" ||
(["create", "edit"].includes(mode.value) && formSnapshot.value !== baseline.value),
);
const displayValue = (value) =>
value === "" || value === null || value === undefined ? "未填写" : String(value);
const detailRows = computed(() =>
fields
.slice(1)
.map((field) => ({ label: field.label, value: displayValue(relativeForm[field.key]) })),
);
const previewRows = computed(() =>
fields.map((field) => ({
label: field.label,
value: displayValue(localRelativePreview.value?.[field.key]),
})),
);
const copyRecordToForm = (record) => {
Object.assign(relativeForm, {
relativeName: record.relativeName,
relationName: record.relationName,
eventName: record.eventName,
eventTime: record.eventTime,
giftAmount: String(record.giftAmount ?? ""),
recordContent: record.recordContent,
});
baseline.value = formSnapshot.value;
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
relativeId.value = String(query.relativeId || "");
mode.value = String(query.mode || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
const hasValidGenealogy = ["owner", "member"].includes(access.accessRole);
const isCreateContract = mode.value === "create" && !relativeId.value;
const isEntityContract = ["view", "edit"].includes(mode.value) && Boolean(relativeId.value);
if (!hasValidGenealogy || (!isCreateContract && !isEntityContract)) {
editorState.value = "invalid";
return;
}
if (isCreateContract) {
baseline.value = formSnapshot.value;
editorState.value = "ready";
return;
}
const selected = findRelativeRecordFixture(genealogyId.value, relativeId.value);
if (!selected) {
editorState.value = "invalid";
return;
}
copyRecordToForm(selected);
editorState.value = "ready";
});
const enterEdit = () => {
if (mode.value !== "view" || editorState.value !== "ready") return false;
mode.value = "edit";
baseline.value = formSnapshot.value;
return true;
};
const validateRelative = () => {
relativeErrors.relativeName = relativeForm.relativeName.trim() ? "" : "请填写亲友姓名";
relativeErrors.relationName = "";
relativeErrors.eventName = "";
relativeErrors.eventTime = "";
relativeErrors.recordContent = "";
const amountInput = relativeForm.giftAmount.trim();
relativeErrors.giftAmount =
!amountInput || Number.isFinite(Number(amountInput))
? ""
: "礼金金额必须是数字";
return !relativeErrors.relativeName && !relativeErrors.giftAmount;
};
const saveRelative = () => {
if (isSubmitting.value || !validateRelative()) return false;
isSubmitting.value = true;
const snapshot = Object.freeze({
relativeName: relativeForm.relativeName.trim(),
relationName: relativeForm.relationName.trim(),
eventName: relativeForm.eventName.trim(),
eventTime: relativeForm.eventTime.trim(),
giftAmount: relativeForm.giftAmount.trim()
? Number(relativeForm.giftAmount)
: null,
recordContent: relativeForm.recordContent.trim(),
});
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
localRelativePreview.value = snapshot;
editorState.value = "preview";
isSubmitting.value = false;
submitTimer = null;
}, 240);
submitTimer = timer;
return true;
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
const requestController = createRequestController();
const isDirty = computed(() => Object.values(form).some((value) => String(value).trim()));
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(() => editorState.value === "success"
? { title: "往来记录已提交服务端", copy: "服务端已返回成功信封。列表仍缺条目 DTO,返回后不会生成本地记录。", action: "返回贺礼簿" }
: { title: "往来记录入口无效", copy: "当前只有新建请求可映射;详情和修改缺可靠条目 DTO/记录 ID 来源,页面不从 fixture 进入。", action: "返回上一页" },
);
const discardConfirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": discardConfirmation.request,
});
const returnToRelatives = () =>
genealogyId.value
? returnTo("R03", { genealogyId: genealogyId.value })
: goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (submitTimer) clearTimeout(submitTimer);
discardConfirmation.dispose();
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
});
const saveRelative = async () => {
if (isSubmitting.value || !hasValidContext.value) return;
const relativeName = form.relativeName.trim();
const giftAmountText = form.giftAmount.trim();
if (!relativeName) {
submitError.value = "请填写亲友姓名";
return;
}
if (giftAmountText && !Number.isFinite(Number(giftAmountText))) {
submitError.value = "礼金金额必须是数字";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
await appApi.createRelativeRecord(genealogyId.value, {
relativeName,
relationName: form.relationName,
eventName: form.eventName,
eventTime: form.eventTime,
...(giftAmountText ? { giftAmount: Number(giftAmountText) } : {}),
recordContent: form.recordContent,
}, { requestController });
Object.keys(form).forEach((key) => { form[key] = ""; });
editorState.value = "success";
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "往来记录提交失败,请稍后重试。";
} finally {
isSubmitting.value = false;
}
};
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const handleResultAction = () => editorState.value === "success"
? returnTo("R03", { genealogyId: genealogyId.value })
: goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => { requestController.abort(); discardConfirmation.dispose(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.gift-editor-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
padding: 18rpx 24rpx 72rpx;
}
.form-card,
.detail-card,
.state-card {
box-sizing: border-box;
padding: 46rpx;
@include adaptive.adaptive-records-content;
}
.form-card > text:first-child,
.detail-card > text:first-child,
.state-card > text:first-child {
display: block;
color: $ink;
font-size: 34rpx;
font-weight: 700;
}
.field-row,
.detail-card > view {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 12rpx 20rpx;
min-height: 82rpx;
margin-top: 14rpx;
padding: 14rpx 24rpx;
box-sizing: border-box;
@include adaptive.adaptive-records-field;
}
.field-row > text:first-child,
.detail-card > view > text:first-child {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.field-row input,
.detail-card > view > text:last-child {
min-width: 0;
color: $ink;
font-size: 23rpx;
text-align: right;
}
.field-row > text:last-child {
grid-column: 1/-1;
color: $brand-red;
font-size: 20rpx;
text-align: right;
}
.form-card .app-button,
.detail-card .app-button {
margin-top: 18rpx;
}
.save-error {
display: block;
margin-top: 14rpx;
color: $brand-red;
font-size: 22rpx;
}
.state-card {
min-height: 340rpx;
padding-top: 80rpx;
text-align: center;
}
.state-card > text:nth-child(2) {
display: block;
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
}
.state-card .app-button {
margin-top: 28rpx;
}
.gift-editor-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { padding: 18rpx 24rpx 72rpx; }
.form-card, .state-card { box-sizing: border-box; padding: 46rpx; @include adaptive.adaptive-records-content; }
.form-card > text:first-child, .state-card > text:first-child { display: block; color: $ink; font-size: 34rpx; font-weight: 700; }
.form-copy { display: block; margin-top: 12rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.55; }
.field-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 12rpx 20rpx; min-height: 82rpx; margin-top: 14rpx; padding: 14rpx 24rpx; box-sizing: border-box; @include adaptive.adaptive-records-field; }
.field-row > text { color: $ink; font-size: 23rpx; font-weight: 700; }
.field-row input, .field-row textarea { min-width: 0; color: $ink; font-size: 23rpx; text-align: right; }
.field-row textarea { min-height: 72rpx; text-align: left; }
.save-error { display: block; margin-top: 14rpx; color: $brand-red; font-size: 22rpx; }
.form-card .app-button { margin-top: 18rpx; }
.state-card { min-height: 340rpx; padding-top: 80rpx; text-align: center; }
.state-card > text:nth-child(2) { display: block; margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
</style>
+16 -201
View File
@@ -1,216 +1,31 @@
<!-- 页面编号R-05用途当前家谱的礼仪活动列表与本地创建预览入口 -->
<!-- 页面编号R-05用途礼仪活动入口活动列表 operation 未声明 -->
<template>
<view class="ritual-page" :class="stateClasses">
<view class="ritual-list-page">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="礼仪活动"
:action="hasValidContext ? '填写预览' : ''"
@action="createCeremonyPreview"
/>
</view>
<view v-if="ceremonyState === 'loading'" class="page-loading">
<AppLoading
text="正在整理礼仪活动"
description="请稍候,正在核对当前家谱的活动记录。"
/>
</view>
<view v-else class="page-content">
<template v-if="ceremonyState === 'ready' && ceremonies.length">
<view
v-for="ceremony in ceremonies"
:key="ceremony.ceremonyId"
class="record-card"
role="button"
:aria-label="`查看${ceremony.ceremonyTitle}`"
@click="openCeremony(ceremony)"
>
<text>{{ ceremony.ceremonyType }}</text>
<text>{{ ceremony.ceremonyTitle }}</text>
<text>
{{ ceremony.ceremonyTime }} ·
{{ ceremony.location || "地点待定" }}
</text>
<text>查看活动与受邀信息</text>
</view>
<AppButton block label="填写礼仪预览" @click="createCeremonyPreview" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="ceremonyState === 'empty' ? 'primary' : 'secondary'"
block
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<view class="page-header"><PageHeader title="礼仪活动" custom-back @back="returnToFamily" /></view>
<view class="page-content"><view class="state-card"><text>礼仪活动列表暂未开放</text><text>Apifox 只有活动详情修改献礼和删除相关 operation没有活动列表或新建活动 owner页面不再展示本地礼仪活动</text><AppButton block :label="hasValidContext ? '返回家族动态' : '返回上一页'" @click="returnToFamily" /></view></view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
listCeremonyFixtures,
} from "@/data/mock.js";
import { goBack, openPage } from "@/utils/navigation.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const ceremonies = ref([]);
const ceremonyState = ref("loading");
const hasValidContext = computed(() =>
["ready", "empty"].includes(ceremonyState.value),
);
const stateClasses = computed(() => ({
"ceremony-state--loading": ceremonyState.value === "loading",
"ceremony-state--empty": ceremonyState.value === "empty",
"ceremony-state--error": ceremonyState.value === "error",
"ceremony-state--invalid": ceremonyState.value === "invalid",
}));
const stateCopy = computed(() => ({
error: {
title: "礼仪活动暂不可用",
copy: "请稍后重新查看,已有活动不会受到影响。",
action: "重新查看",
},
invalid: {
title: "礼仪活动入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱活动。",
action: "返回上一页",
},
empty: {
title: "还没有礼仪活动",
copy: "可以先填写一份本地预览;正式创建仍需等待线上写接口启用。",
action: "填写礼仪预览",
},
})[ceremonyState.value] || {
title: "礼仪活动暂不可用",
copy: "请返回上一页重新进入。",
action: "返回上一页",
});
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
ceremonies.value = [];
ceremonyState.value = "invalid";
return;
}
ceremonies.value = listCeremonyFixtures(genealogyId.value);
ceremonyState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: ceremonies.value.length
? "ready"
: "empty";
});
const openCeremony = (ceremony) =>
openPage(
"R06",
{
genealogyId: genealogyId.value,
ceremonyId: String(ceremony.ceremonyId),
},
"R05",
);
const createCeremonyPreview = () =>
hasValidContext.value
? openPage(
"R07",
{ genealogyId: genealogyId.value, mode: "create" },
"R05",
)
: Promise.resolve(false);
const restoreCeremonies = () => {
ceremonies.value = listCeremonyFixtures(genealogyId.value);
ceremonyState.value = ceremonies.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (ceremonyState.value === "invalid") return goBack();
if (ceremonyState.value === "error") return restoreCeremonies();
return createCeremonyPreview();
};
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); });
const returnToFamily = () => hasValidContext.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.ritual-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.record-card,
.state-card {
box-sizing: border-box;
@include adaptive.adaptive-records-content;
}
.record-card {
min-height: 190rpx;
padding: 32rpx 46rpx;
}
.record-card > text,
.state-card > text {
display: block;
}
.record-card > text:first-child {
color: $brand-red;
font-size: 21rpx;
}
.record-card > text:nth-child(2) {
margin-top: 6rpx;
color: $ink;
font-size: 31rpx;
font-weight: 700;
}
.record-card > text:nth-child(3) {
margin-top: 9rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.5;
}
.record-card > text:last-child {
margin-top: 9rpx;
color: $brand-red;
font-size: 21rpx;
}
.state-card {
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.ritual-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { padding: 18rpx 24rpx 72rpx; }
.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; @include adaptive.adaptive-records-content; }
.state-card text { display: block; }
.state-card text:first-child { color: $ink; font-size: 35rpx; font-weight: 700; }
.state-card text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
</style>
+9 -240
View File
@@ -1,252 +1,21 @@
<!-- 页面编号R-06用途当前家谱礼仪详情受邀信息与受控状态 -->
<!-- 页面编号R-06用途礼仪详情详情响应无展示 DTO 时保持关闭 -->
<template>
<view class="ritual-detail-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="礼仪详情"
:action="ceremonyState === 'ready' ? '制作预览' : ''"
custom-back
@back="requestBack"
@action="editCeremony"
/>
</view>
<view v-if="ceremonyState === 'loading'" class="page-loading">
<AppLoading
text="正在读取礼仪详情"
description="请稍候,正在核对活动身份与受邀信息。"
/>
</view>
<view v-else class="page-content">
<template v-if="ceremonyState === 'ready' && ceremonyDetail">
<view class="detail-card">
<text>{{ ceremonyDetail.ceremonyType }}</text>
<text>{{ ceremonyDetail.ceremonyTitle }}</text>
<text>
{{ ceremonyDetail.ceremonyTime }} ·
{{ ceremonyDetail.location || "地点待定" }}
</text>
<text>{{ ceremonyDetail.ceremonyDesc || "暂无活动说明" }}</text>
</view>
<view class="participant-card">
<view>
<text>受邀家人</text>
<text>{{ invitees.length }} </text>
</view>
<view v-for="invitee in invitees" :key="invitee.inviteeUserId">
<text>{{ invitee.displayName }} · {{ invitee.relationName }}</text>
<text>{{ invitee.statusText }}</text>
</view>
<view v-if="!invitees.length"><text>尚无受邀记录</text><text></text></view>
</view>
<AppButton block label="制作编辑预览" @click="editCeremony" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
type="secondary"
block
:label="ceremonyState === 'error' ? '重新查看' : '返回礼仪列表'"
@click="ceremonyState === 'error' ? restoreCeremony() : returnToCeremonies()"
/>
</view>
</view>
</view>
<view class="ritual-detail-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="礼仪详情" custom-back @back="returnToList" /></view><view class="page-content"><view class="state-card"><text>{{ stateCopy.title }}</text><text>{{ stateCopy.copy }}</text><AppButton block :label="stateCopy.action" @click="returnToList" /></view></view></view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findCeremonyFixture,
getGenealogyFixtureAccess,
listTreeMemberPresentationFixtures,
} from "@/data/mock.js";
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const ceremonyId = ref("");
const ceremonyDetail = ref(null);
const memberOptions = ref([]);
const ceremonyState = ref("loading");
const invitees = computed(() => {
const memberByAppUserId = new Map(
memberOptions.value.map((member) => [String(member.appUserId), member]),
);
return (ceremonyDetail.value?.invitees || []).map((invitation) => {
const member = memberByAppUserId.get(String(invitation.inviteeUserId));
return {
inviteeUserId: String(invitation.inviteeUserId),
displayName: member?.name || "受邀成员信息不可用",
relationName: member?.relation || "未完成同谱成员联接",
statusText: invitation.inviteStatus
? "受邀状态字典待后端确认"
: "受邀状态未提供",
};
});
});
const stateClasses = computed(() => ({
"ceremony-state--expired": ceremonyState.value === "expired",
"ceremony-state--error": ceremonyState.value === "error",
}));
const stateCopy = computed(
() =>
({
expired: {
title: "活动已失效",
copy: "活动不存在或不属于当前家谱,页面不会回退到其他活动。",
},
error: {
title: "礼仪详情暂不可用",
copy: "请稍后重新查看,已有活动不会受到影响。",
},
})[ceremonyState.value] || {
title: "礼仪详情暂不可用",
copy: "缺少家谱或活动身份,请返回列表重新选择。",
},
);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
ceremonyId.value = String(query.ceremonyId || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole) || !ceremonyId.value) {
ceremonyState.value = "expired";
return;
}
memberOptions.value = listTreeMemberPresentationFixtures(genealogyId.value);
ceremonyDetail.value = findCeremonyFixture(
genealogyId.value,
ceremonyId.value,
);
if (!ceremonyDetail.value) {
ceremonyState.value = "expired";
return;
}
ceremonyState.value = query.state === "error" ? "error" : "ready";
});
const editCeremony = () =>
ceremonyState.value === "ready" && ceremonyDetail.value
? openPage(
"R07",
{
genealogyId: genealogyId.value,
mode: "edit",
ceremonyId: ceremonyId.value,
},
"R06",
)
: Promise.resolve(false);
const restoreCeremony = () => {
ceremonyDetail.value = findCeremonyFixture(
genealogyId.value,
ceremonyId.value,
);
ceremonyState.value = ceremonyDetail.value ? "ready" : "expired";
};
const requestBack = () => goBack();
const returnToCeremonies = () =>
genealogyId.value
? returnTo("R05", { genealogyId: genealogyId.value })
: goBack();
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref(""); const ceremonyId = ref("");
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(ceremonyId.value));
const stateCopy = computed(() => valid.value ? { title: "礼仪详情待后端字段合同", copy: "详情接口没有声明活动类型、标题、时间、地点、说明或受邀人字段;页面不再展示本地礼仪详情。", action: "返回礼仪活动" } : { title: "礼仪入口无效", copy: "没有取得有效家谱或活动标识。", action: "返回上一页" });
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); ceremonyId.value = String(query?.ceremonyId || ""); });
const returnToList = () => /^[1-9]\d*$/.test(genealogyId.value) ? returnTo("R05", { genealogyId: genealogyId.value }) : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.ritual-detail-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.detail-card,
.participant-card,
.state-card {
box-sizing: border-box;
padding: 42rpx 46rpx;
@include adaptive.adaptive-records-content;
}
.detail-card > text {
display: block;
}
.detail-card > text:first-child {
color: $brand-red;
font-size: 21rpx;
}
.detail-card > text:nth-child(2) {
margin-top: 8rpx;
color: $ink;
font-size: 36rpx;
font-weight: 700;
}
.detail-card > text:nth-child(3) {
margin-top: 10rpx;
color: $ink-muted;
font-size: 23rpx;
}
.detail-card > text:last-child {
margin-top: 20rpx;
color: $ink;
font-size: 24rpx;
line-height: 1.7;
}
.participant-card > view {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8rpx 18rpx;
min-height: 52rpx;
align-items: center;
color: $ink;
font-size: 23rpx;
}
.participant-card > view:first-child {
color: $brand-red;
font-weight: 700;
}
.participant-card > view + view {
margin-top: 9rpx;
padding-top: 9rpx;
border-top: 1px solid rgba(136, 84, 42, 0.18);
}
.state-card {
min-height: 340rpx;
padding-top: 78rpx;
text-align: center;
}
.state-card > text {
display: block;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.ritual-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }.page-header,.page-content { z-index: 1; }.page-content { padding: 18rpx 24rpx 72rpx; }.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; @include adaptive.adaptive-records-content; }.state-card text { display:block; }.state-card text:first-child { color:$ink; font-size:35rpx; font-weight:700; }.state-card text:nth-child(2) { margin-top:18rpx; color:$ink-muted; font-size:24rpx; line-height:1.65; }.state-card .app-button { margin-top:28rpx; }
</style>
+9 -311
View File
@@ -1,321 +1,19 @@
<!-- 页面编号R-07用途礼仪创建编辑校验与不写库的本地预览 -->
<!-- 页面编号R-07用途礼仪活动创建/编辑创建 owner 缺失编辑无可靠详情来源 -->
<template>
<view class="ritual-editor-page" :class="editorClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
:title="mode === 'create' ? '礼仪预览' : '编辑预览'"
custom-back
@back="requestBack"
/>
</view>
<view v-if="editorState === 'loading'" class="page-loading">
<AppLoading
text="正在读取礼仪资料"
description="请稍候,正在核对当前家谱与活动身份。"
/>
</view>
<view v-else class="page-content">
<view v-if="editorState === 'preview'" class="state-card preview-card">
<text>本地预览尚未提交服务器</text>
<text>这份礼仪内容只存在于当前页面不会新增覆盖或删除正式活动</text>
<view v-for="item in previewRows" :key="item.label" class="preview-row">
<text>{{ item.label }}</text>
<text>{{ item.value }}</text>
</view>
<AppButton block :label="returnLabel" @click="returnAfterPreview" />
</view>
<view v-else-if="editorState === 'ready'" class="form-card">
<text>
{{ mode === "create" ? "填写一份家族礼仪预览" : "调整活动预览" }}
</text>
<view v-for="field in fields" :key="field.key" class="field-row">
<text>{{ field.label }}</text>
<textarea
v-if="field.long"
v-model="ritualForm[field.key]"
auto-height
:placeholder="`请输入${field.label}`"
/>
<input
v-else
v-model="ritualForm[field.key]"
:placeholder="`请输入${field.label}`"
/>
<text v-if="ritualErrors[field.key]">
{{ ritualErrors[field.key] }}
</text>
</view>
<AppButton
block
label="生成本地预览"
@click="createCeremonyPreview"
/>
<AppButton
type="secondary"
block
label="取消填写"
@click="requestBack"
/>
<AppButton v-if="mode === 'edit'" type="secondary" block disabled label="删除暂未开放" />
</view>
<view v-else class="state-card">
<text>礼仪活动不可用</text>
<text>活动不存在缺少身份或不属于当前家谱页面不会回退到其他活动</text>
<AppButton type="secondary" block label="返回礼仪列表" @click="returnToCeremonies" />
</view>
</view>
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
<view class="ritual-editor-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="礼仪活动" custom-back @back="returnToList" /></view><view class="page-content"><view class="state-card"><text>礼仪活动维护暂未开放</text><text>当前没有新建活动 operation修改虽有 operation但没有可消费的详情 DTO 或可靠活动 ID 来源本页不再生成本地创建或编辑预览</text><AppButton block :label="hasValidContext ? '返回礼仪活动' : '返回上一页'" @click="returnToList" /></view></view></view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import { onLoad } 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";
import {
findCeremonyFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const genealogyId = ref("");
const ceremonyId = ref("");
const mode = ref("");
const editorState = ref("loading");
const discardVisible = ref(false);
const localCeremonyPreview = ref(null);
const ceremonyForm = reactive({
ceremonyType: "",
ceremonyTitle: "",
ceremonyTime: "",
location: "",
ceremonyDesc: "",
});
const ritualErrors = reactive({
ceremonyType: "",
ceremonyTitle: "",
ceremonyTime: "",
location: "",
ceremonyDesc: "",
});
const fields = [
{ key: "ceremonyType", label: "礼仪类型" },
{ key: "ceremonyTitle", label: "活动标题" },
{ key: "ceremonyTime", label: "活动时间" },
{ key: "location", label: "举办地点" },
{ key: "ceremonyDesc", label: "活动说明", long: true },
];
const baseline = ref("");
const editorClasses = computed(() => ({
"ceremony-editor-state--preview": editorState.value === "preview",
"ceremony-editor-state--invalid": editorState.value === "invalid",
}));
const formSnapshot = computed(() => JSON.stringify({ ...ceremonyForm }));
const isDirty = computed(() =>
editorState.value === "preview" ||
(["create", "edit"].includes(mode.value) && formSnapshot.value !== baseline.value),
);
const previewRows = computed(() =>
fields.map((field) => ({
label: field.label,
value: localCeremonyPreview.value?.[field.key] || "未填写",
})),
);
const returnLabel = computed(() =>
mode.value === "edit" ? "返回礼仪详情" : "返回礼仪列表",
);
const copyCeremonyToForm = (record) => {
Object.assign(ceremonyForm, {
ceremonyType: record.ceremonyType,
ceremonyTitle: record.ceremonyTitle,
ceremonyTime: record.ceremonyTime,
location: record.location,
ceremonyDesc: record.ceremonyDesc,
});
baseline.value = formSnapshot.value;
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
ceremonyId.value = String(query.ceremonyId || "");
mode.value = String(query.mode || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
const hasValidGenealogy = ["owner", "member"].includes(access.accessRole);
const isCreateContract = mode.value === "create" && !ceremonyId.value;
const isEditContract = mode.value === "edit" && Boolean(ceremonyId.value);
if (!hasValidGenealogy || (!isCreateContract && !isEditContract)) {
editorState.value = "invalid";
return;
}
if (isCreateContract) {
baseline.value = formSnapshot.value;
editorState.value = "ready";
return;
}
const selected = findCeremonyFixture(genealogyId.value, ceremonyId.value);
if (!selected) {
editorState.value = "invalid";
return;
}
copyCeremonyToForm(selected);
editorState.value = "ready";
});
const validateCeremony = () => {
ritualErrors.ceremonyType = ceremonyForm.ceremonyType.trim()
? ""
: "请填写礼仪类型";
ritualErrors.ceremonyTitle = ceremonyForm.ceremonyTitle.trim()
? ""
: "请填写活动标题";
return fields.every((field) => !ritualErrors[field.key]);
};
const createCeremonyPreview = () => {
if (!validateCeremony()) return false;
// 线上写接口的枚举、成员权限与错误语义尚未形成完整合同,因此这里只生成
// 与表单分离的不可变快照;后续接入真实写接口时由该入口唯一替换。
localCeremonyPreview.value = Object.freeze({
ceremonyType: ceremonyForm.ceremonyType.trim(),
ceremonyTitle: ceremonyForm.ceremonyTitle.trim(),
ceremonyTime: ceremonyForm.ceremonyTime.trim(),
location: ceremonyForm.location.trim(),
ceremonyDesc: ceremonyForm.ceremonyDesc.trim(),
});
editorState.value = "preview";
return true;
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
"close-transient": cancelDiscard,
"confirm-discard": discardConfirmation.request,
});
const returnToCeremonies = () =>
genealogyId.value
? returnTo("R05", { genealogyId: genealogyId.value })
: goBack();
const returnAfterPreview = () =>
mode.value === "edit" && ceremonyId.value
? returnTo("R06", {
genealogyId: genealogyId.value,
ceremonyId: ceremonyId.value,
})
: returnToCeremonies();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
discardConfirmation.dispose();
});
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref(""); const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); });
const returnToList = () => hasValidContext.value ? returnTo("R05", { genealogyId: genealogyId.value }) : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.ritual-editor-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
}
.form-card,
.state-card {
box-sizing: border-box;
padding: 46rpx;
@include adaptive.adaptive-records-content;
}
.form-card > text:first-child,
.state-card > text:first-child {
display: block;
color: $ink;
font-size: 34rpx;
font-weight: 700;
}
.field-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 10rpx 18rpx;
min-height: 82rpx;
margin-top: 14rpx;
padding: 14rpx 24rpx;
box-sizing: border-box;
@include adaptive.adaptive-records-field;
}
.field-row > text:first-child {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.field-row input,
.field-row textarea {
width: auto;
min-width: 0;
color: $ink;
font-size: 23rpx;
text-align: right;
}
.field-row textarea {
min-height: 76rpx;
line-height: 1.5;
}
.field-row > text:last-child {
grid-column: 1/-1;
color: $brand-red;
font-size: 20rpx;
text-align: right;
}
.form-card .app-button {
margin-top: 18rpx;
}
.save-error {
display: block;
margin-top: 14rpx;
color: $brand-red;
font-size: 22rpx;
}
.state-card {
min-height: 340rpx;
padding-top: 80rpx;
text-align: center;
}
.state-card > text:nth-child(2) {
display: block;
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
}
.state-card .app-button {
margin-top: 28rpx;
}
.ritual-editor-page { display:flex; min-height:100vh; flex-direction:column; background:$paper; }.page-header,.page-content { z-index:1; }.page-content { padding:18rpx 24rpx 72rpx; }.state-card { box-sizing:border-box; min-height:340rpx; padding:78rpx 52rpx 50rpx; text-align:center; @include adaptive.adaptive-records-content; }.state-card text { display:block; }.state-card text:first-child { color:$ink; font-size:35rpx; font-weight:700; }.state-card text:nth-child(2) { margin-top:18rpx; color:$ink-muted; font-size:24rpx; line-height:1.65; }.state-card .app-button { margin-top:28rpx; }
</style>
+13 -398
View File
@@ -1,407 +1,22 @@
<!-- 页面编号R-08用途当前家谱人物的成长日志与不写库的本地预览 -->
<!-- 页面编号R-08用途成长记录创建列表 DTO 未声明创建仅提交可映射字段 -->
<template>
<view class="timeline-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="成长日志"
:action="hasValidContext ? '记录预览' : ''"
custom-back
@back="requestBack"
@action="recordGrowth"
/>
</view>
<view v-if="timelineState === 'loading'" class="page-loading">
<AppLoading
text="正在读取成长日志"
description="请稍候,正在核对当前家谱与人物身份。"
/>
</view>
<view v-else class="page-content">
<view v-if="memberRecord" class="person-lead">
<text>{{ memberRecord.name }}</text>
<text>成长中的每一个瞬间</text>
</view>
<view v-if="localGrowthPreview" class="preview-card">
<text>本地预览 · 尚未提交</text>
<text>{{ localGrowthPreview.recordTitle }}</text>
<text>{{ localGrowthPreview.recordDate || "日期未填写" }}</text>
<text>{{ localGrowthPreview.recordContent || "内容未填写" }}</text>
</view>
<template v-if="timelineState === 'ready' && growthRecords.length">
<view
v-for="(record, index) in growthRecords"
:key="record.recordId"
class="timeline-card"
>
<text> {{ growthRecords.length - index }} </text>
<text>{{ record.recordTitle }}</text>
<text>{{ record.recordDate || "日期未填写" }}</text>
<text>{{ record.recordContent || "内容未填写" }}</text>
</view>
<AppButton block label="记录成长预览" @click="recordGrowth" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="timelineState === 'empty' ? 'primary' : 'secondary'"
block
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
:close-on-mask="false"
eyebrow="成长日志"
title="填写一份成长预览"
confirm-text="生成预览"
cancel-text="取消"
show-cancel
@confirm="createGrowthPreview"
@cancel="requestCloseEditor"
>
<view class="dialog-form">
<input v-model="growthForm.recordTitle" placeholder="记录标题" />
<input v-model="growthForm.recordDate" placeholder="日期(选填)" />
<textarea
v-model="growthForm.recordContent"
auto-height
placeholder="写下当时的故事(选填)"
/>
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
</view>
<view class="record-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="成长日志" custom-back @back="requestBack" /></view><view class="page-content"><view v-if="state === 'form'" class="form-card"><text>新建成长记录</text><text class="form-copy">列表和详情没有展示 DTO;本页只提交标题、日期和内容,不猜测人物绑定、类型、提醒或媒体字段。</text><view class="field"><text>记录标题</text><input v-model="form.title" maxlength="40" placeholder="请输入记录标题" @input="error = ''" /></view><view class="field"><text>记录日期</text><input v-model="form.date" placeholder="例如:2026-07-24" @input="error = ''" /></view><view class="field"><text>记录内容</text><textarea v-model="form.content" auto-height maxlength="1200" placeholder="记录成长片段" @input="error = ''" /></view><text v-if="error" class="error">{{ error }}</text><AppButton block :disabled="submitting" :label="submitting ? '正在提交' : '提交成长记录'" @click="submit" /></view><view v-else class="state-card"><text>{{ result.title }}</text><text>{{ result.copy }}</text><AppButton block :label="result.action" @click="resultAction" /></view></view><AppDialog :visible="discardVisible" title="放弃成长记录?" message="尚未提交的内容将被清除。" confirm-text="放弃并返回" cancel-text="继续填写" show-cancel :close-on-mask="false" @confirm="confirmDiscard" @cancel="cancelDiscard" /></view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findTreeMemberPresentationFixture,
getGenealogyFixtureAccess,
listGrowthRecordFixtures,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const personId = ref("");
const memberRecord = ref(null);
const growthRecords = ref([]);
const timelineState = ref("loading");
const dialogVisible = ref(false);
const discardVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const localGrowthPreview = ref(null);
const growthForm = reactive({
recordTitle: "",
recordDate: "",
recordContent: "",
});
const editorBaseline = ref("");
let timer = null;
const stateClasses = computed(() => ({
"timeline-state--loading": timelineState.value === "loading",
"timeline-state--empty": timelineState.value === "empty",
"timeline-state--error": timelineState.value === "error",
"timeline-state--privacy": timelineState.value === "privacy",
"timeline-state--invalid": timelineState.value === "invalid",
}));
const hasValidContext = computed(() =>
["ready", "empty"].includes(timelineState.value),
);
const formSnapshot = computed(() => JSON.stringify({ ...growthForm }));
const growthDraftDirty = computed(() =>
dialogVisible.value && formSnapshot.value !== editorBaseline.value,
);
const stateCopy = computed(() => ({
error: {
title: "成长日志暂不可用",
copy: "请稍后重新查看,已有记录不会受到影响。",
action: "重新查看",
},
privacy: {
title: "成长日志未公开",
copy: "当前人物资料受隐私设置保护,页面不会展示或填写成长记录。",
action: "返回上一页",
},
invalid: {
title: "成长日志入口无效",
copy: "人物不存在、缺少身份或不属于当前家谱。",
action: "返回上一页",
},
empty: {
title: "还没有成长记录",
copy: "可以先生成一份本地预览;正式创建仍需等待线上写接口启用。",
action: "记录成长预览",
},
})[timelineState.value] || {
title: "成长日志暂不可用",
copy: "请返回上一页重新进入。",
action: "返回上一页",
});
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
memberRecord.value = findTreeMemberPresentationFixture(
genealogyId.value,
personId.value,
);
if (
!["owner", "member"].includes(access.accessRole) ||
!memberRecord.value
) {
timelineState.value = "invalid";
return;
}
if (["privacy", "forbidden"].includes(memberRecord.value.status)) {
timelineState.value = "privacy";
return;
}
growthRecords.value = listGrowthRecordFixtures(
genealogyId.value,
personId.value,
);
timelineState.value = ["empty", "error"].includes(query.state)
? query.state
: growthRecords.value.length
? "ready"
: "empty";
});
const recordGrowth = () => {
if (!hasValidContext.value) return false;
Object.assign(growthForm, {
recordTitle: "",
recordDate: "",
recordContent: "",
});
formError.value = "";
editorBaseline.value = formSnapshot.value;
dialogVisible.value = true;
return true;
};
const createGrowthPreview = () => {
formError.value = growthForm.recordTitle.trim() ? "" : "请填写记录标题";
if (formError.value) return false;
// 预览与正式列表分离:这里不生成服务端 ID,也不改写只读夹具。
localGrowthPreview.value = Object.freeze({
recordTitle: growthForm.recordTitle.trim(),
recordDate: growthForm.recordDate.trim(),
recordContent: growthForm.recordContent.trim(),
});
dialogVisible.value = false;
toastVisible.value = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
toastVisible.value = false;
timer = null;
}, 1800);
return true;
};
const closeEditor = () => {
dialogVisible.value = false;
formError.value = "";
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = () => {
discardConfirmation.confirm();
closeEditor();
};
const cancelDiscard = discardConfirmation.cancel;
const requestCloseEditor = async () => {
if (!growthDraftDirty.value) {
closeEditor();
return true;
}
const confirmed = await discardConfirmation.request();
if (confirmed) closeEditor();
return confirmed;
};
const restoreGrowthRecords = () => {
growthRecords.value = listGrowthRecordFixtures(
genealogyId.value,
personId.value,
);
timelineState.value = growthRecords.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (["invalid", "privacy"].includes(timelineState.value)) return goBack();
if (timelineState.value === "error") return restoreGrowthRecords();
return recordGrowth();
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (dialogVisible.value) return requestCloseEditor();
return runBackGuard({
dirty: Boolean(localGrowthPreview.value),
"confirm-discard": discardConfirmation.request,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
import AppButton from "@/components/AppButton.vue"; import AppDialog from "@/components/AppDialog.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref(""); const state = ref("form"); const submitting = ref(false); const error = ref(""); const discardVisible = ref(false); const form = reactive({ title: "", date: "", content: "" }); const controller = createRequestController();
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value)); const dirty = computed(() => Object.values(form).some((value) => value.trim()));
const result = computed(() => state.value === "success" ? { title: "成长记录已提交服务端", copy: "服务端已返回成功信封;列表仍缺条目 DTO,不生成本地记录。", action: "返回记录首页" } : { title: "成长日志入口无效", copy: "没有取得有效家谱标识。", action: "返回上一页" });
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; }); const confirmDiscard = confirmation.confirm; const cancelDiscard = confirmation.cancel;
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (!valid.value) state.value = "invalid"; });
const submit = async () => { if (submitting.value || !valid.value) return; const recordTitle = form.title.trim(); if (!recordTitle) { error.value = "请填写记录标题"; return; } submitting.value = true; error.value = ""; try { await appApi.createGrowthRecord(genealogyId.value, { recordTitle, recordDate: form.date, recordContent: form.content }, { requestController: controller }); Object.keys(form).forEach((key) => { form[key] = ""; }); state.value = "success"; } catch (cause) { if (!isRequestCancelled(cause)) error.value = cause?.message || "成长记录提交失败,请稍后重试。"; } finally { submitting.value = false; } };
const requestBack = () => runBackGuard({ transientOpen: discardVisible.value, dirty: dirty.value, submitting: submitting.value, "close-transient": cancelDiscard, "block-submitting": () => true, "confirm-discard": confirmation.request }); const resultAction = () => state.value === "success" ? returnTo("R08", { genealogyId: genealogyId.value }) : goBack();
onBackPress((event) => handleBackPress(event, requestBack)); onUnmounted(() => { controller.abort(); confirmation.dispose(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.timeline-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.person-lead {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8rpx 18rpx;
min-height: 62rpx;
align-items: center;
padding: 0 20rpx;
color: $ink-muted;
font-size: 22rpx;
background: url("/static/assets/modules/genealogy/transparent/section-divider.png")
center/100% auto no-repeat;
}
.person-lead text:first-child {
color: $brand-red;
font-weight: 700;
}
.timeline-card,
.preview-card,
.state-card {
box-sizing: border-box;
padding: 34rpx 46rpx;
@include adaptive.adaptive-records-content;
}
.timeline-card > text,
.preview-card > text,
.state-card > text {
display: block;
}
.timeline-card > text:first-child {
color: $brand-red;
font-size: 20rpx;
}
.timeline-card > text:nth-child(2) {
margin-top: 6rpx;
color: $ink;
font-size: 31rpx;
font-weight: 700;
}
.timeline-card > text:nth-child(3) {
margin-top: 8rpx;
color: $ink-muted;
font-size: 21rpx;
}
.timeline-card > text:last-child {
margin-top: 10rpx;
color: $ink;
font-size: 23rpx;
line-height: 1.55;
}
.preview-card > text:first-child {
color: $brand-red;
font-size: 21rpx;
font-weight: 700;
}
.preview-card > text:nth-child(2) {
margin-top: 8rpx;
color: $ink;
font-size: 30rpx;
font-weight: 700;
}
.preview-card > text:nth-child(n + 3) {
margin-top: 8rpx;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.55;
}
.state-card {
min-height: 340rpx;
padding-top: 78rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.dialog-form {
width: 100%;
margin: 18rpx 0;
}
.dialog-form input,
.dialog-form textarea {
width: 100%;
min-height: 70rpx;
margin-top: 10rpx;
padding: 14rpx 20rpx;
box-sizing: border-box;
color: $ink;
font-size: 23rpx;
@include adaptive.adaptive-records-field;
}
.dialog-form text {
display: block;
margin-top: 8rpx;
color: $brand-red;
font-size: 20rpx;
}
.record-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.form-card,.state-card{box-sizing:border-box;padding:46rpx;@include adaptive.adaptive-records-content}.form-card>text:first-child,.state-card>text:first-child{display:block;color:$ink;font-size:34rpx;font-weight:700}.form-copy{display:block;margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.field{margin-top:16rpx}.field>text{display:block;margin:0 8rpx 8rpx;color:$ink;font-size:23rpx;font-weight:700}.field input,.field textarea{@include adaptive.adaptive-records-field;box-sizing:border-box;width:100%;min-height:76rpx;padding:16rpx 22rpx;color:$ink;font-size:23rpx}.field textarea{min-height:150rpx}.error{display:block;margin-top:12rpx;color:$brand-red;font-size:22rpx}.form-card .app-button{margin-top:20rpx}.state-card{min-height:340rpx;padding-top:80rpx;text-align:center}.state-card>text:nth-child(2){display:block;margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -105
View File
@@ -1,112 +1,11 @@
<!-- 页面编号R-09用途校验人物身份并明确关闭缺失的线上服务 -->
<!-- 页面编号R-09用途人生大事当前没有独立业务 operation -->
<template>
<view class="service-page" :class="`service-state--${serviceState}`">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="人生事" custom-back @back="requestBack" />
</view>
<view v-if="serviceState === 'loading'" class="page-loading">
<AppLoading
text="正在核对人物身份"
description="请稍候,页面正在确认当前家谱与人物。"
/>
</view>
<view v-else class="page-content">
<view class="state-card">
<text>{{ stateCopy.title }}</text>
<text v-if="personRecord && serviceState === 'unavailable'" class="person-name">
当前人物{{ personRecord.name }}
</text>
<text>{{ stateCopy.copy }}</text>
<AppButton type="secondary" block label="返回上一页" @click="requestBack" />
</view>
</view>
</view>
<view class="life-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="人生大事" custom-back @back="returnToRecords" /></view><view class="page-content"><view class="state-card"><text>人生大事暂未开放</text><text>已在 Apifox APP/PC 目录检索人生life没有独立人生事件读取或写入 operation页面不借成长备忘人物资料或参考项目伪造保存</text><AppButton block :label="hasValidContext ? '返回记录首页' : '返回上一页'" @click="returnToRecords" /></view></view></view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findTreeMemberPresentationFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { goBack, handleBackPress } from "@/utils/navigation.js";
const serviceState = ref("loading");
const personRecord = ref(null);
const stateCopy = computed(() =>
serviceState.value === "unavailable"
? {
title: "人生事件接口尚未开放",
copy: "线上接口文档没有独立的人生事件资源。为避免把其他记录类型冒充人生事,本页暂不展示或提交数据。",
}
: {
title: "人生事入口无效",
copy: "人物不存在、缺少身份或不属于当前家谱,页面不会回退到其他人物。",
},
);
onLoad((query) => {
const genealogyId = String(query.genealogyId || "");
const personId = String(query.personId || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId);
personRecord.value = findTreeMemberPresentationFixture(genealogyId, personId);
serviceState.value =
["owner", "member"].includes(access.accessRole) && personRecord.value
? "unavailable"
: "invalid";
});
const requestBack = () => goBack();
onBackPress((event) => handleBackPress(event, requestBack));
import { computed,ref } from "vue"; import { onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToRecords=()=>hasValidContext.value?returnTo("R01",{genealogyId:genealogyId.value}):goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.service-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
padding: 18rpx 24rpx 72rpx;
}
.state-card {
box-sizing: border-box;
min-height: 380rpx;
padding: 78rpx 52rpx 50rpx;
@include adaptive.adaptive-records-content;
text-align: center;
}
.state-card > text { display: block; }
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:last-of-type {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.person-name {
color: $brand-red;
font-weight: 700;
}
.state-card .app-button {
margin-top: 28rpx;
}
.life-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-records-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+6 -244
View File
@@ -1,251 +1,13 @@
<!-- 页面编号R-10用途当前家谱备忘列表与不写库的本地预览 -->
<!-- 页面编号R-10用途家族备忘创建列表 DTO 缺失时不展示 fixture -->
<template>
<view class="memo-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="家族备忘"
:action="hasValidContext ? '填写预览' : ''"
custom-back
@back="requestBack"
@action="startMemoPreview"
/>
</view>
<view v-if="memoState === 'loading'" class="page-loading">
<AppLoading text="正在读取家族备忘" description="请稍候,正在核对当前家谱的备忘记录。" />
</view>
<view v-else class="page-content">
<view v-if="localMemoPreview" class="preview-card">
<text>本地预览 · 尚未提交</text>
<text>{{ localMemoPreview.memoTitle }}</text>
<text>{{ localMemoPreview.remindTime || "提醒时间未填写" }}</text>
<text>{{ localMemoPreview.memoContent || "内容未填写" }}</text>
</view>
<template v-if="memoState === 'ready' && memos.length">
<view v-for="memo in memos" :key="memo.memoId" class="memo-card">
<view>
<text>{{ memo.completedLabel }}</text>
<text>{{ memo.remindTime || "未设置提醒" }}</text>
</view>
<text>{{ memo.memoTitle }}</text>
<text>{{ memo.memoContent || "暂无备忘内容" }}</text>
<text>状态仅展示线上切换接口尚未确认</text>
</view>
<AppButton block label="填写备忘预览" @click="startMemoPreview" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="memoState === 'empty' ? 'primary' : 'secondary'"
block
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
:close-on-mask="false"
eyebrow="家族备忘"
title="填写一份备忘预览"
confirm-text="生成预览"
cancel-text="取消"
show-cancel
@confirm="createMemoPreview"
@cancel="requestCloseEditor"
>
<view class="dialog-form">
<input v-model="memoForm.memoTitle" placeholder="备忘标题" />
<input v-model="memoForm.remindTime" placeholder="提醒时间(选填)" />
<textarea v-model="memoForm.memoContent" auto-height placeholder="补充具体事项(选填)" />
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
</view>
<view class="memo-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="家族备忘" custom-back @back="requestBack" /></view><view class="page-content"><view v-if="state === 'form'" class="form-card"><text>新建家族备忘</text><text class="form-copy">列表和详情没有可消费字段;本页仅提交标题、提醒时间和内容,不猜测完成状态或媒体。</text><view class="field"><text>备忘标题</text><input v-model="form.title" maxlength="40" placeholder="请输入备忘标题" @input="error = ''" /></view><view class="field"><text>提醒时间</text><input v-model="form.time" placeholder="例如:2026-07-24 09:00" @input="error = ''" /></view><view class="field"><text>备忘内容</text><textarea v-model="form.content" auto-height maxlength="1200" placeholder="记录需要提醒的事情" @input="error = ''" /></view><text v-if="error" class="error">{{ error }}</text><AppButton block :disabled="submitting" :label="submitting ? '正在提交' : '提交备忘'" @click="submit" /></view><view v-else class="state-card"><text>{{ result.title }}</text><text>{{ result.copy }}</text><AppButton block :label="result.action" @click="resultAction" /></view></view><AppDialog :visible="discardVisible" title="放弃家族备忘?" message="尚未提交的内容将被清除。" confirm-text="放弃并返回" cancel-text="继续填写" show-cancel :close-on-mask="false" @confirm="confirmDiscard" @cancel="cancelDiscard" /></view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
listMemoFixtures,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const memos = ref([]);
const memoState = ref("loading");
const dialogVisible = ref(false);
const discardVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const localMemoPreview = ref(null);
const memoForm = reactive({ memoTitle: "", remindTime: "", memoContent: "" });
const editorBaseline = ref("");
let timer = null;
const stateClasses = computed(() => ({
"memo-state--loading": memoState.value === "loading",
"memo-state--empty": memoState.value === "empty",
"memo-state--error": memoState.value === "error",
"memo-state--invalid": memoState.value === "invalid",
}));
const hasValidContext = computed(() => ["ready", "empty"].includes(memoState.value));
const formSnapshot = computed(() => JSON.stringify({ ...memoForm }));
const memoDraftDirty = computed(() =>
dialogVisible.value && formSnapshot.value !== editorBaseline.value,
);
const stateCopy = computed(() => ({
error: {
title: "家族备忘暂不可用",
copy: "请稍后重新查看,已有备忘不会受到影响。",
action: "重新查看",
},
invalid: {
title: "家族备忘入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱备忘。",
action: "返回上一页",
},
empty: {
title: "还没有备忘",
copy: "可以先生成一份本地预览;正式创建仍需等待线上写接口启用。",
action: "填写备忘预览",
},
})[memoState.value] || {
title: "家族备忘暂不可用",
copy: "请返回上一页重新进入。",
action: "返回上一页",
});
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
memoState.value = "invalid";
return;
}
memos.value = listMemoFixtures(genealogyId.value);
memoState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: memos.value.length
? "ready"
: "empty";
});
const startMemoPreview = () => {
if (!hasValidContext.value) return false;
Object.assign(memoForm, { memoTitle: "", remindTime: "", memoContent: "" });
formError.value = "";
editorBaseline.value = formSnapshot.value;
dialogVisible.value = true;
return true;
};
const createMemoPreview = () => {
formError.value = memoForm.memoTitle.trim() ? "" : "请填写备忘标题";
if (formError.value) return false;
// 正式列表来自只读选择器;预览不生成 ID,也不会改变完成状态或记录数量。
localMemoPreview.value = Object.freeze({
memoTitle: memoForm.memoTitle.trim(),
remindTime: memoForm.remindTime.trim(),
memoContent: memoForm.memoContent.trim(),
});
dialogVisible.value = false;
toastVisible.value = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
toastVisible.value = false;
timer = null;
}, 1800);
return true;
};
const closeEditor = () => {
dialogVisible.value = false;
formError.value = "";
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = () => {
discardConfirmation.confirm();
closeEditor();
};
const cancelDiscard = discardConfirmation.cancel;
const requestCloseEditor = async () => {
if (!memoDraftDirty.value) {
closeEditor();
return true;
}
const confirmed = await discardConfirmation.request();
if (confirmed) closeEditor();
return confirmed;
};
const restoreMemos = () => {
memos.value = listMemoFixtures(genealogyId.value);
memoState.value = memos.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (memoState.value === "invalid") return goBack();
if (memoState.value === "error") return restoreMemos();
return startMemoPreview();
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (dialogVisible.value) return requestCloseEditor();
return runBackGuard({
dirty: Boolean(localMemoPreview.value),
"confirm-discard": discardConfirmation.request,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
import { computed, onUnmounted, reactive, ref } from "vue"; import { onBackPress, onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import AppDialog from "@/components/AppDialog.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId=ref("");const state=ref("form");const submitting=ref(false);const error=ref("");const discardVisible=ref(false);const form=reactive({title:"",time:"",content:""});const controller=createRequestController();const valid=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));const dirty=computed(()=>Object.values(form).some((value)=>value.trim()));const result=computed(()=>state.value==="success"?{title:"备忘已提交服务端",copy:"服务端已返回成功信封;列表仍缺条目 DTO,不生成本地备忘。",action:"返回备忘"}:{title:"备忘入口无效",copy:"没有取得有效家谱标识。",action:"返回上一页"});const confirmation=createDiscardConfirmation((visible)=>{discardVisible.value=visible;});const confirmDiscard=confirmation.confirm;const cancelDiscard=confirmation.cancel;
onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");if(!valid.value)state.value="invalid";});const submit=async()=>{if(submitting.value||!valid.value)return;const memoTitle=form.title.trim();if(!memoTitle){error.value="请填写备忘标题";return;}submitting.value=true;error.value="";try{await appApi.createMemo(genealogyId.value,{memoTitle,remindTime:form.time,memoContent:form.content},{requestController:controller});Object.keys(form).forEach((key)=>{form[key]="";});state.value="success";}catch(cause){if(!isRequestCancelled(cause))error.value=cause?.message||"备忘提交失败,请稍后重试。";}finally{submitting.value=false;}};const requestBack=()=>runBackGuard({transientOpen:discardVisible.value,dirty:dirty.value,submitting:submitting.value,"close-transient":cancelDiscard,"block-submitting":()=>true,"confirm-discard":confirmation.request});const resultAction=()=>state.value==="success"?returnTo("R10",{genealogyId:genealogyId.value}):goBack();onBackPress((event)=>handleBackPress(event,requestBack));onUnmounted(()=>{controller.abort();confirmation.dispose();});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.memo-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header,.page-loading,.page-content { z-index: 1; }
.page-loading { min-height: calc(100vh - 100rpx); }
.page-content { display: flex; flex-direction: column; gap: 16rpx; padding: 18rpx 24rpx 72rpx; }
.memo-card,.preview-card,.state-card { box-sizing: border-box; padding: 34rpx 46rpx; @include adaptive.adaptive-records-content; }
.memo-card > view { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6rpx 18rpx; color: $brand-red; font-size: 20rpx; }
.memo-card > text,.preview-card > text,.state-card > text { display: block; }
.memo-card > text:nth-child(2),.preview-card > text:nth-child(2) { margin-top: 8rpx; color: $ink; font-size: 31rpx; font-weight: 700; }
.memo-card > text:nth-child(3),.preview-card > text:nth-child(n + 3) { margin-top: 9rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }
.memo-card > text:last-child,.preview-card > text:first-child { margin-top: 10rpx; color: $brand-red; font-size: 20rpx; }
.state-card { min-height: 340rpx; padding-top: 78rpx; text-align: center; }
.state-card > text:first-child { color: $ink; font-size: 35rpx; font-weight: 700; }
.state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
.dialog-form { width: 100%; margin: 18rpx 0; }
.dialog-form input,.dialog-form textarea { width: 100%; min-height: 68rpx; margin-top: 9rpx; padding: 13rpx 20rpx; box-sizing: border-box; color: $ink; font-size: 23rpx; @include adaptive.adaptive-records-field; }
.dialog-form text { display: block; margin-top: 8rpx; color: $brand-red; font-size: 20rpx; }
.memo-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.form-card,.state-card{box-sizing:border-box;padding:46rpx;@include adaptive.adaptive-records-content}.form-card>text:first-child,.state-card>text:first-child{display:block;color:$ink;font-size:34rpx;font-weight:700}.form-copy{display:block;margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.field{margin-top:16rpx}.field>text{display:block;margin:0 8rpx 8rpx;color:$ink;font-size:23rpx;font-weight:700}.field input,.field textarea{@include adaptive.adaptive-records-field;box-sizing:border-box;width:100%;min-height:76rpx;padding:16rpx 22rpx;color:$ink;font-size:23rpx}.field textarea{min-height:150rpx}.error{display:block;margin-top:12rpx;color:$brand-red;font-size:22rpx}.form-card .app-button{margin-top:20rpx}.state-card{min-height:340rpx;padding-top:80rpx;text-align:center}.state-card>text:nth-child(2){display:block;margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+6 -281
View File
@@ -1,288 +1,13 @@
<!-- 页面编号R-11用途当前家谱功德记录与不写库的本地预览 -->
<!-- 页面编号R-11用途功德记录创建列表 DTO 缺失时不展示 fixture -->
<template>
<view class="merit-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="功德记录"
:action="hasValidContext ? '填写预览' : ''"
custom-back
@back="requestBack"
@action="startMeritPreview"
/>
</view>
<view v-if="meritState === 'loading'" class="page-loading">
<AppLoading text="正在整理功德记录" description="请稍候,正在核对当前家谱的正式记录。" />
</view>
<view v-else class="page-content">
<view v-if="hasValidContext" class="merit-summary">
<text>正式记录</text>
<text>{{ totalContribution }} </text>
<text>本地预览不计入正式记录数量</text>
</view>
<view v-if="localMeritPreview" class="preview-card">
<text>本地预览 · 尚未提交</text>
<text>{{ localMeritPreview.meritTitle }}</text>
<text>{{ localMeritPreview.donorName }} · {{ localMeritPreview.meritTime || "时间未填写" }}</text>
<text>{{ localMeritPreview.meritType || "类型未填写" }}</text>
<text>金额数值单位待确认{{ formatMeritAmount(localMeritPreview.amount) }}</text>
<text>{{ localMeritPreview.meritContent || "内容未填写" }}</text>
</view>
<template v-if="meritState === 'ready' && meritRecords.length">
<view v-for="merit in meritRecords" :key="merit.meritId" class="merit-card">
<text>{{ merit.meritTypeLabel || "类型未标注" }}</text>
<text>{{ merit.meritTitle }}</text>
<text>{{ merit.donorName }} · {{ merit.meritTime || "时间未填写" }}</text>
<text>{{ merit.meritContent || "暂无记录内容" }}</text>
</view>
<AppButton block label="填写功德预览" @click="startMeritPreview" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="meritState === 'empty' ? 'primary' : 'secondary'"
block
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
:close-on-mask="false"
eyebrow="功德记录"
title="填写一份功德预览"
confirm-text="生成预览"
cancel-text="取消"
show-cancel
@confirm="createMeritPreview"
@cancel="requestCloseEditor"
>
<view class="dialog-form">
<input v-model="meritForm.meritTitle" placeholder="贡献事项" />
<input v-model="meritForm.donorName" placeholder="贡献人" />
<input v-model="meritForm.meritType" placeholder="贡献类型(选填)" />
<input v-model="meritForm.meritTime" placeholder="时间(选填)" />
<input v-model="meritForm.amount" type="digit" placeholder="金额数值(单位未明确,可不填)" />
<textarea v-model="meritForm.meritContent" auto-height placeholder="说明时间、物资或具体帮助(选填)" />
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
</view>
<view class="merit-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="功德记录" custom-back @back="requestBack" /></view><view class="page-content"><view v-if="state === 'form'" class="form-card"><text>新建功德记录</text><text class="form-copy">列表没有展示 DTO;本页仅提交已声明的捐赠人、标题、类型、金额、时间和内容,不猜测状态或排序。</text><view v-for="field in fields" :key="field.key" class="field"><text>{{ field.label }}</text><textarea v-if="field.key === 'content'" v-model="form[field.key]" auto-height :placeholder="`请输入${field.label}`" @input="error = ''" /><input v-else v-model="form[field.key]" :type="field.key === 'amount' ? 'digit' : 'text'" :placeholder="`请输入${field.label}`" @input="error = ''" /></view><text v-if="error" class="error">{{ error }}</text><AppButton block :disabled="submitting" :label="submitting ? '正在提交' : '提交功德记录'" @click="submit" /></view><view v-else class="state-card"><text>{{ result.title }}</text><text>{{ result.copy }}</text><AppButton block :label="result.action" @click="resultAction" /></view></view><AppDialog :visible="discardVisible" title="放弃功德记录?" message="尚未提交的内容将被清除。" confirm-text="放弃并返回" cancel-text="继续填写" show-cancel :close-on-mask="false" @confirm="confirmDiscard" @cancel="cancelDiscard" /></view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
listMeritRecordFixtures,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const meritRecords = ref([]);
const meritState = ref("loading");
const dialogVisible = ref(false);
const discardVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const localMeritPreview = ref(null);
const meritForm = reactive({
meritTitle: "",
donorName: "",
meritType: "",
meritTime: "",
amount: "",
meritContent: "",
});
const editorBaseline = ref("");
let timer = null;
const totalContribution = computed(() => meritRecords.value.length);
const stateClasses = computed(() => ({
"merit-state--loading": meritState.value === "loading",
"merit-state--empty": meritState.value === "empty",
"merit-state--error": meritState.value === "error",
"merit-state--invalid": meritState.value === "invalid",
}));
const hasValidContext = computed(() => ["ready", "empty"].includes(meritState.value));
const formSnapshot = computed(() => JSON.stringify({ ...meritForm }));
const meritDraftDirty = computed(() =>
dialogVisible.value && formSnapshot.value !== editorBaseline.value,
);
const stateCopy = computed(() => ({
error: {
title: "功德记录暂不可用",
copy: "请稍后重新查看,已有记录不会受到影响。",
action: "重新查看",
},
invalid: {
title: "功德记录入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱记录。",
action: "返回上一页",
},
empty: {
title: "还没有功德记录",
copy: "可以先生成一份本地预览;正式创建仍需等待线上写接口启用。",
action: "填写功德预览",
},
})[meritState.value] || {
title: "功德记录暂不可用",
copy: "请返回上一页重新进入。",
action: "返回上一页",
});
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
meritState.value = "invalid";
return;
}
meritRecords.value = listMeritRecordFixtures(genealogyId.value);
meritState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: meritRecords.value.length
? "ready"
: "empty";
});
const startMeritPreview = () => {
if (!hasValidContext.value) return false;
Object.assign(meritForm, {
meritTitle: "",
donorName: "",
meritType: "",
meritTime: "",
amount: "",
meritContent: "",
});
formError.value = "";
editorBaseline.value = formSnapshot.value;
dialogVisible.value = true;
return true;
};
const formatMeritAmount = (amount) =>
amount === null || amount === "" ? "未填写" : String(amount);
const createMeritPreview = () => {
const missing = [];
if (!meritForm.meritTitle.trim()) missing.push("贡献事项");
if (!meritForm.donorName.trim()) missing.push("贡献人");
formError.value = missing.length ? `请填写${missing.join("和")}` : "";
const amountInput = meritForm.amount.trim();
if (!formError.value && amountInput && !Number.isFinite(Number(amountInput))) {
formError.value = "金额必须是数字,单位仍待后端确认";
}
if (formError.value) return false;
// 金额的单位、精度和取值规则尚未由后端明确,本地预览只保留原始输入。
localMeritPreview.value = Object.freeze({
meritTitle: meritForm.meritTitle.trim(),
donorName: meritForm.donorName.trim(),
meritType: meritForm.meritType.trim(),
meritTime: meritForm.meritTime.trim(),
amount: amountInput ? Number(amountInput) : null,
meritContent: meritForm.meritContent.trim(),
});
dialogVisible.value = false;
toastVisible.value = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
toastVisible.value = false;
timer = null;
}, 1800);
return true;
};
const closeEditor = () => {
dialogVisible.value = false;
formError.value = "";
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = () => {
discardConfirmation.confirm();
closeEditor();
};
const cancelDiscard = discardConfirmation.cancel;
const requestCloseEditor = async () => {
if (!meritDraftDirty.value) {
closeEditor();
return true;
}
const confirmed = await discardConfirmation.request();
if (confirmed) closeEditor();
return confirmed;
};
const restoreMeritRecords = () => {
meritRecords.value = listMeritRecordFixtures(genealogyId.value);
meritState.value = meritRecords.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (meritState.value === "invalid") return goBack();
if (meritState.value === "error") return restoreMeritRecords();
return startMeritPreview();
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (dialogVisible.value) return requestCloseEditor();
return runBackGuard({
dirty: Boolean(localMeritPreview.value),
"confirm-discard": discardConfirmation.request,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
import { computed,onUnmounted,reactive,ref } from "vue"; import { onBackPress,onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import AppDialog from "@/components/AppDialog.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { appApi,createRequestController,isRequestCancelled } from "@/utils/api.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { goBack,handleBackPress,returnTo,runBackGuard } from "@/utils/navigation.js";
const genealogyId=ref("");const state=ref("form");const submitting=ref(false);const error=ref("");const discardVisible=ref(false);const form=reactive({donor:"",title:"",type:"",amount:"",time:"",content:""});const fields=[{key:"donor",label:"捐赠人"},{key:"title",label:"功德标题"},{key:"type",label:"功德类型"},{key:"amount",label:"金额"},{key:"time",label:"记录时间"},{key:"content",label:"记录内容"}];const controller=createRequestController();const valid=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));const dirty=computed(()=>Object.values(form).some((value)=>value.trim()));const result=computed(()=>state.value==="success"?{title:"功德记录已提交服务端",copy:"服务端已返回成功信封;列表仍缺条目 DTO,不生成本地功德记录。",action:"返回功德记录"}:{title:"功德记录入口无效",copy:"没有取得有效家谱标识。",action:"返回上一页"});const confirmation=createDiscardConfirmation((visible)=>{discardVisible.value=visible;});const confirmDiscard=confirmation.confirm;const cancelDiscard=confirmation.cancel;
onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");if(!valid.value)state.value="invalid";});const submit=async()=>{if(submitting.value||!valid.value)return;const donorName=form.donor.trim();const meritTitle=form.title.trim();const amountText=form.amount.trim();if(!donorName||!meritTitle){error.value=!donorName?"请填写捐赠人":"请填写功德标题";return;}if(amountText&&!Number.isFinite(Number(amountText))){error.value="金额必须是数字";return;}submitting.value=true;error.value="";try{await appApi.createMeritRecord(genealogyId.value,{donorName,meritTitle,meritType:form.type,meritContent:form.content,meritTime:form.time,...(amountText?{amount:Number(amountText)}:{})},{requestController:controller});Object.keys(form).forEach((key)=>{form[key]="";});state.value="success";}catch(cause){if(!isRequestCancelled(cause))error.value=cause?.message||"功德记录提交失败,请稍后重试。";}finally{submitting.value=false;}};const requestBack=()=>runBackGuard({transientOpen:discardVisible.value,dirty:dirty.value,submitting:submitting.value,"close-transient":cancelDiscard,"block-submitting":()=>true,"confirm-discard":confirmation.request});const resultAction=()=>state.value==="success"?returnTo("R11",{genealogyId:genealogyId.value}):goBack();onBackPress((event)=>handleBackPress(event,requestBack));onUnmounted(()=>{controller.abort();confirmation.dispose();});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.merit-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header,.page-loading,.page-content { z-index: 1; }
.page-loading { min-height: calc(100vh - 100rpx); }
.page-content { display: flex; flex-direction: column; gap: 16rpx; padding: 18rpx 24rpx 72rpx; }
.merit-summary,.merit-card,.preview-card,.state-card { box-sizing: border-box; padding: 34rpx 46rpx; @include adaptive.adaptive-records-content; }
.merit-summary { text-align: center; }
.merit-summary > text,.merit-card > text,.preview-card > text,.state-card > text { display: block; }
.merit-summary > text:first-child,.merit-card > text:first-child,.preview-card > text:first-child { color: $brand-red; font-size: 21rpx; }
.merit-summary > text:nth-child(2) { margin-top: 5rpx; color: $ink; font-size: 38rpx; font-weight: 700; }
.merit-summary > text:last-child { margin-top: 9rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
.merit-card > text:nth-child(2),.preview-card > text:nth-child(2) { margin-top: 6rpx; color: $ink; font-size: 31rpx; font-weight: 700; }
.merit-card > text:nth-child(3),.preview-card > text:nth-child(3) { margin-top: 8rpx; color: $ink-muted; font-size: 21rpx; }
.merit-card > text:last-child,.preview-card > text:last-child { margin-top: 9rpx; color: $ink; font-size: 23rpx; line-height: 1.55; }
.state-card { min-height: 340rpx; padding-top: 78rpx; text-align: center; }
.state-card > text:first-child { color: $ink; font-size: 35rpx; font-weight: 700; }
.state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
.dialog-form { width: 100%; margin: 14rpx 0; }
.dialog-form input,.dialog-form textarea { width: 100%; min-height: 62rpx; margin-top: 7rpx; padding: 11rpx 18rpx; box-sizing: border-box; color: $ink; font-size: 22rpx; @include adaptive.adaptive-records-field; }
.dialog-form text { display: block; margin-top: 7rpx; color: $brand-red; font-size: 20rpx; }
.merit-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.form-card,.state-card{box-sizing:border-box;padding:46rpx;@include adaptive.adaptive-records-content}.form-card>text:first-child,.state-card>text:first-child{display:block;color:$ink;font-size:34rpx;font-weight:700}.form-copy{display:block;margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.field{margin-top:16rpx}.field>text{display:block;margin:0 8rpx 8rpx;color:$ink;font-size:23rpx;font-weight:700}.field input,.field textarea{@include adaptive.adaptive-records-field;box-sizing:border-box;width:100%;min-height:76rpx;padding:16rpx 22rpx;color:$ink;font-size:23rpx}.field textarea{min-height:150rpx}.error{display:block;margin-top:12rpx;color:$brand-red;font-size:22rpx}.form-card .app-button{margin-top:20rpx}.state-card{min-height:340rpx;padding-top:80rpx;text-align:center}.state-card>text:nth-child(2){display:block;margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+149 -71
View File
@@ -30,7 +30,7 @@
>回到当前</text
>
<text @click="treeState = 'landscape'">阅读提示</text>
<text @click="toRelationship">关系维护</text>
<text @click="toRank">调整排行</text>
</view>
</view>
@@ -99,7 +99,7 @@
class="member-node"
:class="{ 'member-node--selected': selected?.id === member.id }"
:style="nodeGridStyle(member)"
@click="selected = member"
@click="openMemberPanel(member)"
>
<image
class="member-node__skin"
@@ -110,6 +110,11 @@
"
mode="scaleToFill"
/>
<image
class="member-node__avatar"
src="/static/assets/foundation/transparent/meta-member.png"
mode="aspectFit"
/>
<view class="member-node__copy">
<text class="node-name">{{ member.name }}</text>
<text class="node-relation"
@@ -145,33 +150,54 @@
</scroll-view>
</view>
<view v-if="treeState === 'tree' && selected" class="member-sheet">
<image
class="member-sheet__skin"
src="/static/assets/modules/tree/transparent/t01-member-drawer.png"
mode="scaleToFill"
/>
<view class="member-sheet__copy">
<text class="sheet-name">{{ selected.name }}</text>
<text class="sheet-meta"
> {{ selected.generation }} · {{ selected.relation }} ·
{{ selected.branch }}</text
>
<AppDialog
:visible="memberActionPanelVisible"
eyebrow="人物操作"
:title="selected ? `管理${selected.name}` : '管理人物'"
message="仅展示当前人物可达的操作。未具备可靠后端合同的写入会明确说明,且不会创建本地假数据。"
confirm-text="关闭"
@confirm="memberActionPanelVisible = false"
@cancel="memberActionPanelVisible = false"
>
<view
v-if="selected"
class="member-action-profile"
role="button"
:aria-label="`查看${selected.name}的资料`"
@click="toMember"
>
<image
class="member-action-profile__avatar"
src="/static/assets/foundation/transparent/meta-member.png"
mode="aspectFit"
/>
<view class="member-action-profile__copy">
<text>{{ selected.name }}</text>
<text> {{ selected.generation }} · {{ selected.relation }} · {{ selected.branch }}</text>
<text>点击头像查看资料</text>
</view>
</view>
<view class="sheet-actions">
<view class="member-action-grid">
<AppButton
class="sheet-action"
v-for="action in memberActions"
:key="action.key"
type="secondary"
label="查看资料"
@click="toMember"
/>
<AppButton
class="sheet-action"
label="添加亲属"
@click="toAddRelative"
compact
:label="action.label"
@click="openMemberAction(action)"
/>
</view>
</view>
</AppDialog>
<AppDialog
:visible="unavailableActionVisible"
eyebrow="服务状态"
:title="unavailableAction?.label || '当前操作'"
:message="unavailableAction?.unavailableCopy || '该操作暂未接入可靠服务合同。'"
confirm-text="我知道了"
@confirm="unavailableActionVisible = false"
@cancel="unavailableActionVisible = false"
/>
</view>
</template>
@@ -179,6 +205,7 @@
import { computed, ref } from "vue";
import { onLoad, 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";
@@ -193,6 +220,9 @@ import { openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const treeState = ref("loading");
const selected = ref(null);
const memberActionPanelVisible = ref(false);
const unavailableActionVisible = ref(false);
const unavailableAction = ref(null);
const treeScrollLeft = ref(90);
const currentTreeScrollLeft = ref(90);
const centeredTreeScrollLeft = ref(90);
@@ -209,6 +239,23 @@ const MEMBER_GAP = 250;
const GENERATION_GAP = 220;
const members = ref([]);
const memberActions = Object.freeze([
{ key: "VIEW_PROFILE", label: "查看资料", routeKey: "T03" },
{ key: "ADD_FATHER", label: "添加父亲", routeKey: "T04", relationType: "FATHER" },
{ key: "ADD_MOTHER", label: "添加母亲", routeKey: "T04", relationType: "MOTHER" },
{ key: "ADD_SPOUSE", label: "添加配偶", routeKey: "T04", relationType: "SPOUSE" },
{ key: "ADD_SIBLING", label: "添加兄弟姐妹", routeKey: "T04", relationType: "SIBLING" },
{ key: "ADJUST_RANK", label: "调整排行", routeKey: "T06", mode: "rank" },
{ key: "ADD_SON", label: "添加儿子", routeKey: "T04", relationType: "SON" },
{ key: "ADD_DAUGHTER", label: "添加女儿", routeKey: "T04", relationType: "DAUGHTER" },
{
key: "BIND_INVITE",
label: "邀请绑定",
unavailableCopy: "邀请签发、目标身份查找、绑定 mutation 和结果查询尚无可靠合同;当前不会借邀请码或普通入谱申请替代。",
},
{ key: "EDIT_PROFILE", label: "编辑信息", routeKey: "T05" },
]);
const snapToGrid = (value) => Math.ceil(value / GRID_UNIT) * GRID_UNIT;
const layoutMembers = computed(() => {
const generations = Array.from(
@@ -482,20 +529,40 @@ const handleStateAction = () => {
selected.value = layoutMembers.value[0];
treeState.value = "tree";
};
const toMember = () =>
selected.value
? openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
: Promise.resolve(false);
const toMember = () => {
if (!selected.value) return Promise.resolve(false);
memberActionPanelVisible.value = false;
return openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01");
};
const toDirectory = () =>
openPage("T07", { genealogyId: genealogyId.value }, "T01");
const toRelationship = () =>
const toRank = () =>
selected.value
? openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
: Promise.resolve(false);
const toAddRelative = () =>
selected.value
? openPage("T04", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
? openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id), mode: "rank" }, "T01")
: Promise.resolve(false);
const openMemberPanel = (member) => {
selected.value = member;
memberActionPanelVisible.value = true;
};
const openMemberAction = (action) => {
if (!selected.value) return Promise.resolve(false);
memberActionPanelVisible.value = false;
if (!action.routeKey) {
unavailableAction.value = action;
unavailableActionVisible.value = true;
return Promise.resolve(false);
}
const params = {
genealogyId: genealogyId.value,
personId: String(selected.value.id),
};
if (action.routeKey === "T04") {
params.relationType = action.relationType;
} else if (action.routeKey === "T06") {
params.mode = action.mode;
}
return openPage(action.routeKey, params, "T01");
};
</script>
<style scoped lang="scss">
@@ -638,8 +705,18 @@ const toAddRelative = () =>
.member-node__copy {
grid-area: 1 / 1;
}
.member-node__avatar {
grid-area: 1 / 1;
z-index: 3;
align-self: start;
justify-self: start;
width: 42rpx;
height: 42rpx;
margin: 13rpx 0 0 18rpx;
}
.member-node__copy {
z-index: 1;
padding-left: 44rpx;
}
.node-name,
.node-relation,
@@ -736,50 +813,53 @@ const toAddRelative = () =>
font-size: 24rpx;
font-weight: 700;
}
.member-sheet {
position: fixed;
right: 18rpx;
bottom: calc(14rpx + env(safe-area-inset-bottom));
left: 18rpx;
.member-action-profile {
display: flex;
min-height: 240rpx;
flex-direction: column;
padding: 38rpx 44rpx 24rpx;
width: 100%;
align-items: center;
gap: 18rpx;
margin-top: 16rpx;
padding: 14rpx 18rpx;
box-sizing: border-box;
}
.member-sheet__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
.member-action-profile__avatar {
width: 82rpx;
aspect-ratio: 1;
flex: 0 0 82rpx;
}
.member-sheet__copy {
z-index: 1;
.member-action-profile__copy {
min-width: 0;
text-align: left;
}
.sheet-name {
.member-action-profile__copy text {
display: block;
}
.member-action-profile__copy text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-size: 30rpx;
font-weight: 700;
}
.sheet-meta {
display: block;
margin-top: 8rpx;
.member-action-profile__copy text:nth-child(2) {
margin-top: 4rpx;
color: $ink-muted;
font-size: 22rpx;
font-size: 21rpx;
}
.sheet-actions {
z-index: 1;
display: flex;
justify-content: center;
gap: 16rpx;
margin-top: auto;
.member-action-profile__copy text:last-child {
margin-top: 4rpx;
color: $brand-red;
font-size: 21rpx;
}
.sheet-action {
width: 250rpx;
min-height: 88rpx;
.member-action-grid {
display: grid;
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12rpx;
margin-top: 20rpx;
}
.member-action-grid .app-button {
width: 100%;
min-height: 70rpx;
}
@media (max-width: 360px) {
@@ -790,12 +870,10 @@ const toAddRelative = () =>
.tree-toolbar__actions {
gap: 14rpx;
}
.member-sheet {
min-height: 240rpx;
padding-top: 32rpx;
padding-right: 36rpx;
padding-left: 36rpx;
padding-bottom: 20rpx;
.member-action-profile {
gap: 14rpx;
padding-right: 14rpx;
padding-left: 14rpx;
}
}
</style>
+66 -34
View File
@@ -93,11 +93,11 @@
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onShow } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findTreeMemberPresentationFixture } from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import {
consumeNavigationResult,
handleBackPress,
@@ -113,13 +113,25 @@ const errorMessage = ref("");
const genealogyName = ref("汤氏家谱");
const memberTrail = reactive([]);
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.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 canPreviewEdit = computed(() => memberState.value === "detail");
const restrictedCopy = computed(() =>
@@ -146,40 +158,59 @@ const memberContextDescription = computed(() => ({
error: "请重新选择成员",
}[memberState.value] || "请重新选择成员"));
const loadMember = async (nextPersonId) => {
const loadMember = async (nextPersonId, { preserveCurrent = false } = {}) => {
const normalizedPersonId = String(nextPersonId || "");
const fixture = findTreeMemberPresentationFixture(genealogyId.value, normalizedPersonId);
if (!fixture) {
errorMessage.value = "这位成员不存在或已不属于当前家谱。";
const activeLoad = ++loadSequence;
const previous = {
personId: personId.value,
member: member.value,
state: memberState.value,
errorMessage: errorMessage.value,
genealogyName: genealogyName.value,
};
const restorePrevious = () => {
personId.value = previous.personId;
member.value = previous.member;
memberState.value = previous.state;
errorMessage.value = previous.errorMessage;
genealogyName.value = previous.genealogyName;
};
memberState.value = "loading";
errorMessage.value = "";
try {
const nextMember = await appApi.getPerson(
genealogyId.value,
normalizedPersonId,
{ requestController: memberRequestController },
);
if (activeLoad !== loadSequence) return false;
personId.value = nextMember.id;
member.value = nextMember;
genealogyName.value = nextMember.genealogyName;
memberState.value = "detail";
return true;
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) {
if (preserveCurrent && activeLoad === loadSequence) restorePrevious();
return false;
}
if (preserveCurrent) {
restorePrevious();
return false;
}
member.value = null;
memberState.value = "error";
errorMessage.value = error?.message || "这位成员不存在或已不属于当前家谱。";
return false;
}
const restricted = ["privacy", "forbidden"].includes(fixture.status);
const relatives = restricted ? [] : fixture.relatives
.map((relative) => {
const relativeMember = findTreeMemberPresentationFixture(
genealogyId.value,
relative.personId,
);
return relativeMember
? { id: relativeMember.id, name: relativeMember.name, relation: relative.relation }
: null;
})
.filter(Boolean);
personId.value = normalizedPersonId;
member.value = {
...fixture,
relatives,
};
errorMessage.value = "";
memberState.value = restricted ? "restricted" : "detail";
return true;
};
const initializeMemberTrail = async (initialPersonId) => {
memberTrail.splice(0, memberTrail.length);
trailIndex.value = -1;
const normalizedPersonId = String(initialPersonId || "");
const loaded = await loadMember(normalizedPersonId);
const loaded = await loadMember(normalizedPersonId, { preserveCurrent: true });
if (!loaded) return false;
memberTrail.splice(0, memberTrail.length, normalizedPersonId);
trailIndex.value = 0;
@@ -204,7 +235,7 @@ const popMemberTrail = async () => {
while (trailIndex.value > 0) {
const targetIndex = trailIndex.value - 1;
const historicalPersonId = memberTrail[targetIndex];
const loaded = await loadMember(historicalPersonId);
const loaded = await loadMember(historicalPersonId, { preserveCurrent: true });
if (loaded) {
trailIndex.value = targetIndex;
return true;
@@ -235,11 +266,7 @@ onLoad((query) => {
: "当前家谱上下文无效,请重新进入。";
return;
}
void initializeMemberTrail(initialPersonId).then((loaded) => {
if (loaded) return;
member.value = null;
memberState.value = "error";
});
void initializeMemberTrail(initialPersonId);
});
onShow(() => {
@@ -251,6 +278,11 @@ onShow(() => {
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
loadSequence += 1;
memberRequestController.abort();
});
const toEdit = () => {
if (!canPreviewEdit.value) return false;
return openPage("T05", { genealogyId: genealogyId.value, personId: personId.value }, "T03");
+121 -68
View File
@@ -4,7 +4,7 @@
class="add-relative-page"
:class="{
'add-state--form': addState === 'form',
'add-state--preview': addState === 'preview',
'add-state--loading': addState === 'loading',
'add-state--error': addState === 'error',
}"
>
@@ -14,7 +14,9 @@
</view>
<view class="add-relative-panel">
<view v-if="addState === 'form'" class="add-relative-form">
<AppLoading v-if="addState === 'loading'" text="正在读取成员资料" description="请稍候,正在确认当前人物。" />
<view v-else-if="addState === 'form'" class="add-relative-form">
<text class="form-eyebrow">{{ isFirstMember ? "建立世系起点" : "补全家族关系" }}</text>
<text class="form-title">{{ formTitle }}</text>
<text class="form-copy">{{ formCopy }}</text>
@@ -48,13 +50,6 @@
</picker>
<text v-if="fieldErrors.relation" class="field-error">{{ fieldErrors.relation }}</text>
<picker :range="genderOptions" :value="genderIndex" @change="selectGender">
<view class="form-field form-field--picker">
<text>性别</text><text>{{ addForm.gender || "请选择" }}</text>
</view>
</picker>
<text v-if="fieldErrors.gender" class="field-error">{{ fieldErrors.gender }}</text>
<picker mode="date" :value="addForm.birthDate" @change="selectBirthDate">
<view class="form-field form-field--picker">
<text>出生日期</text><text>{{ addForm.birthDate || "选填" }}</text>
@@ -75,17 +70,17 @@
<text class="form-note">{{ formNote }}</text>
<view class="form-action" @click="submitAdd">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ isSubmitting ? "正在校验…" : "生成本地预览" }}</text>
<text>{{ isSubmitting ? "正在保存…" : "保存成员" }}</text>
</view>
</view>
<view v-else class="add-result">
<text class="form-eyebrow">{{ addState === "preview" ? "本地流程预览" : hasValidContext ? "成员未保存" : "成员入口无效" }}</text>
<text class="form-title">{{ addState === "preview" ? previewTitle : hasValidContext ? "暂时无法校验成员" : "没有找到要关联的成员" }}</text>
<text class="form-copy">{{ addState === "preview" ? previewCopy : hasValidContext ? "当前填写内容仍保留,可返回修改后重试。" : "请从世系树重新进入,页面不会创建临时成员身份。" }}</text>
<view class="form-action" @click="addState === 'preview' ? returnToTree() : retryForm()">
<text class="form-eyebrow">{{ resultCopy.eyebrow }}</text>
<text class="form-title">{{ resultCopy.title }}</text>
<text class="form-copy">{{ resultCopy.copy }}</text>
<view class="form-action" @click="handleResultAction">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ addState === "preview" ? "返回世系树(不保存)" : hasValidContext ? "返回修改" : "返回上一页" }}</text>
<text>{{ resultCopy.action }}</text>
</view>
</view>
</view>
@@ -108,46 +103,57 @@
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findTreeMemberFixture } from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const addState = ref("form");
const addState = ref("loading");
const genealogyId = ref("");
const personId = ref("");
const mode = ref("relative");
const relationType = ref("");
const isSubmitting = ref(false);
const discardDialogVisible = ref(false);
let submitTimer = null;
const currentMember = ref(null);
const errorMessage = ref("");
const addRequestController = createRequestController();
let loadSequence = 0;
const relationOptions = ["长子", "次子", "女儿", "配偶", "兄弟", "姐妹"];
const genderOptions = ["男", "女", "未说明"];
const addForm = reactive({ name: "", relation: "", gender: "", birthDate: "", summary: "" });
const fieldErrors = reactive({ name: "", relation: "", gender: "" });
const relationIntents = Object.freeze({
FATHER: { label: "父亲" },
MOTHER: { label: "母亲" },
SPOUSE: { label: "配偶" },
SIBLING: { label: "兄弟姐妹" },
SON: { label: "儿子" },
DAUGHTER: { label: "女儿" },
});
const genericRelationTypes = Object.freeze(["FATHER", "MOTHER", "SPOUSE", "SIBLING", "SON", "DAUGHTER"]);
const addForm = reactive({ name: "", relation: "", birthDate: "", summary: "" });
const fieldErrors = reactive({ name: "", relation: "" });
const isFirstMember = computed(() => mode.value === "first");
const currentMember = computed(() =>
isFirstMember.value
? null
: findTreeMemberFixture(genealogyId.value, personId.value),
);
const activeRelationIntent = computed(() => relationIntents[relationType.value] || null);
const relationOptions = computed(() => {
const types = activeRelationIntent.value ? [relationType.value] : genericRelationTypes;
return types.map((type) => relationIntents[type].label);
});
const relationLabel = computed(() => activeRelationIntent.value?.label || addForm.relation || "亲属关系");
const hasValidContext = computed(
() =>
Boolean(genealogyId.value) &&
(isFirstMember.value ? !personId.value : Boolean(currentMember.value)),
);
const relationIndex = computed(() => Math.max(0, relationOptions.indexOf(addForm.relation)));
const genderIndex = computed(() => Math.max(0, genderOptions.indexOf(addForm.gender)));
const relationIndex = computed(() => Math.max(0, relationOptions.value.indexOf(addForm.relation)));
const formTitle = computed(() =>
isFirstMember.value ? "录入家谱中的第一位成员" : `${currentMember.value.name}添加一位亲属`,
isFirstMember.value ? "录入家谱中的第一位成员" : `${currentMember.value?.name || "当前成员"}添加${relationLabel.value}`,
);
const formCopy = computed(() =>
isFirstMember.value
@@ -155,16 +161,24 @@ const formCopy = computed(() =>
: "先确认新成员与当前成员的关系,再填写可核实的身份信息。",
);
const formNote = computed(() =>
isFirstMember.value
? "当前阶段只校验首位成员资料,不会修改世系树。"
: "当前阶段只校验亲属资料,不会新增成员或改变世系关系。",
);
const previewTitle = computed(() =>
isFirstMember.value ? `${addForm.name}可作为世系起点` : `${addForm.name}的亲属资料已通过本地校验`,
);
const previewCopy = computed(() =>
`本地预览尚未提交服务器,返回后不会保存${isFirstMember.value ? "首位成员" : `${addForm.relation}关系`},世系树也不会变化。`,
"本次只提交姓名、出生日期和人物简介;性别编码、头像和同辈排行不在当前写入范围。",
);
const resultCopy = computed(() => {
if (hasValidContext.value) {
return {
eyebrow: "保存失败",
title: `${relationLabel.value}尚未保存`,
copy: errorMessage.value || "服务未确认本次写入,请检查填写后重试。",
action: "返回修改",
};
}
return {
eyebrow: "成员入口无效",
title: "没有找到要关联的成员",
copy: errorMessage.value || "请从世系树重新进入。",
action: "返回世系树",
};
});
const hasDraft = computed(() =>
Object.values(addForm).some((value) => String(value).trim()),
);
@@ -175,27 +189,58 @@ const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const loadCurrentMember = async () => {
const activeLoad = ++loadSequence;
addState.value = "loading";
try {
const member = await appApi.getPerson(genealogyId.value, personId.value, {
requestController: addRequestController,
});
if (activeLoad !== loadSequence) return;
currentMember.value = member;
addState.value = "form";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
currentMember.value = null;
addState.value = "error";
errorMessage.value = error?.message || "当前成员暂不可用。";
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
mode.value = query.mode === "first" ? "first" : "relative";
addState.value = !hasValidContext.value || query.state === "error"
? "error"
: query.state === "preview"
? "preview"
: "form";
relationType.value = String(query.relationType || "");
if (!genealogyId.value || query.state === "error" || (!isFirstMember.value && !personId.value)) {
addState.value = "error";
errorMessage.value = "当前家谱或成员上下文无效,请从世系树重新进入。";
return;
}
if (relationType.value && !activeRelationIntent.value) {
addState.value = "error";
errorMessage.value = "未知的亲属关系意图,本页不会推断或替换。";
return;
}
if (activeRelationIntent.value) {
addForm.relation = activeRelationIntent.value.label;
}
if (isFirstMember.value) {
addState.value = "form";
return;
}
void loadCurrentMember();
});
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
loadSequence += 1;
addRequestController.abort();
discardConfirmation.dispose();
});
const requestBack = () =>
runBackGuard({
transientOpen: discardDialogVisible.value,
dirty: (addState.value === "form" || addState.value === "preview") && hasDraft.value,
dirty: addState.value === "form" && hasDraft.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
@@ -206,36 +251,44 @@ onBackPress((event) => handleBackPress(event, requestBack));
const clearError = (field) => { fieldErrors[field] = ""; };
const selectRelation = (event) => {
addForm.relation = relationOptions[Number(event.detail.value)] || "";
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
clearError("relation");
};
const selectGender = (event) => {
addForm.gender = genderOptions[Number(event.detail.value)] || "";
clearError("gender");
};
const selectBirthDate = (event) => { addForm.birthDate = event.detail.value || ""; };
const validateAddForm = () => {
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
fieldErrors.relation = isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系";
fieldErrors.gender = addForm.gender ? "" : "请选择性别或未说明";
return !fieldErrors.name && !fieldErrors.relation && !fieldErrors.gender;
return !fieldErrors.name && !fieldErrors.relation;
};
const submitAdd = () => {
const submitAdd = async () => {
if (isSubmitting.value || !validateAddForm()) return;
const submitSnapshot = Object.freeze({ ...addForm });
isSubmitting.value = true;
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
addState.value = submitSnapshot.name.trim() === "失败" ? "error" : "preview";
try {
const payload = {
name: addForm.name,
birthDate: addForm.birthDate,
biography: addForm.summary,
...(isFirstMember.value ? {} : { relationName: relationLabel.value }),
};
if (isFirstMember.value) {
await appApi.createPerson(genealogyId.value, payload, { requestController: addRequestController });
} else {
const type = activeRelationIntent.value
? relationType.value
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)];
await appApi.createRelatedPerson(genealogyId.value, personId.value, type, payload, { requestController: addRequestController });
}
Object.assign(addForm, { name: "", relation: "", birthDate: "", summary: "" });
await returnTo("T01", { genealogyId: genealogyId.value });
} catch (error) {
if (isRequestCancelled(error)) return;
errorMessage.value = error?.message || "服务未确认本次写入。";
addState.value = "error";
} finally {
isSubmitting.value = false;
submitTimer = null;
}, 280);
submitTimer = timer;
};
const retryForm = () => {
if (!hasValidContext.value) return goBack();
addState.value = "form";
}
};
const handleResultAction = () => hasValidContext.value ? (addState.value = "form") : returnToTree();
const returnToTree = async () => {
const confirmed = hasDraft.value
? await requestDiscardConfirmation()
+125 -52
View File
@@ -4,15 +4,16 @@
class="edit-member-page"
:class="{
'edit-state--form': editState === 'form',
'edit-state--preview': editState === 'preview',
'edit-state--loading': editState === 'loading',
'edit-state--error': editState === 'error',
'edit-state--no-permission': editState === 'no-permission',
}"
>
<ModulePageBackground module="tree" />
<view class="edit-member-page__header"><PageHeader title="编辑成员" custom-back @back="requestBack" /></view>
<view class="edit-member-panel">
<view v-if="editState === 'form'" class="edit-member-form">
<AppLoading v-if="editState === 'loading'" text="正在读取成员资料" description="请稍候,正在确认可编辑字段。" />
<view v-else-if="editState === 'form'" class="edit-member-form">
<text class="form-eyebrow">成员档案维护</text>
<text class="form-title">完善{{ originalMember.name }}的生命记录</text>
<text class="form-copy">基础身份用于世系展示生平说明会显示在有权限查看的成员档案中</text>
@@ -26,6 +27,10 @@
</view>
<text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text>
<view class="form-field">
<text>别名</text>
<input v-model="editForm.aliasName" placeholder="别名或曾用名" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>字辈</text>
<input v-model="editForm.generationName" maxlength="12" placeholder="例如:文字辈" placeholder-class="form-placeholder" />
@@ -33,19 +38,43 @@
<picker mode="date" :value="editForm.birthDate" @change="selectDate('birthDate', $event)">
<view class="form-field form-field--picker"><text>出生日期</text><text>{{ editForm.birthDate || "未填写" }}</text></view>
</picker>
<view class="form-field">
<text>出生农历</text>
<input v-model="editForm.birthLunar" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>出生地</text>
<input v-model="editForm.birthPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<picker mode="date" :value="editForm.deathDate" @change="selectDate('deathDate', $event)">
<view class="form-field form-field--picker"><text>离世日期</text><text>{{ editForm.deathDate || "在世或未填写" }}</text></view>
</picker>
<view class="form-field">
<text>逝世农历</text>
<input v-model="editForm.deathLunar" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>逝世地</text>
<input v-model="editForm.deathPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>安葬地</text>
<input v-model="editForm.burialPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field form-field--summary">
<text>人物简介</text>
<textarea v-model="editForm.summary" auto-height maxlength="500" placeholder="记录生平、迁徙或重要经历" placeholder-class="form-placeholder" />
</view>
<view class="form-field form-field--summary">
<text>备注</text>
<textarea v-model="editForm.remark" auto-height placeholder="记录其他需要保留的信息" placeholder-class="form-placeholder" />
</view>
<text v-if="fieldErrors.dates" class="field-error">{{ fieldErrors.dates }}</text>
<text class="form-note">隐私字段只向本人和具备维护权限的家谱管理员展示</text>
<view class="form-action" @click="saveMember">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ isSubmitting ? "正在校验…" : "生成本地预览" }}</text>
<text>{{ isSubmitting ? "正在保存…" : "保存资料" }}</text>
</view>
</view>
@@ -78,10 +107,11 @@
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findTreeMemberPresentationFixture } from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
@@ -89,16 +119,32 @@ import {
runBackGuard,
} from "@/utils/navigation.js";
const editState = ref("form");
const editState = ref("loading");
const genealogyId = ref("");
const personId = ref("");
const isSubmitting = ref(false);
const discardDialogVisible = ref(false);
let submitTimer = null;
const errorMessage = ref("");
const failedAction = ref("load");
const editRequestController = createRequestController();
let loadSequence = 0;
const originalMember = ref(null);
const baseline = ref("");
const editForm = reactive({ name: "", generationName: "", birthDate: "", deathDate: "", summary: "" });
const editForm = reactive({
name: "",
aliasName: "",
generationName: "",
birthDate: "",
birthLunar: "",
birthPlace: "",
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
summary: "",
remark: "",
});
const fieldErrors = reactive({ name: "", dates: "" });
const formSnapshot = computed(() => JSON.stringify(editForm));
@@ -107,16 +153,16 @@ const hasValidContext = computed(() =>
);
const isDirty = computed(
() =>
(editState.value === "form" || editState.value === "preview") &&
editState.value === "form" &&
Boolean(baseline.value) &&
formSnapshot.value !== baseline.value,
);
const resultCopy = computed(() => ({
preview: { eyebrow: "本地流程预览", title: `${editForm.name}的资料已通过本地校验`, copy: "尚未提交服务器,返回成员档案后不会显示本次修改。", action: "返回成员档案(不保存)" },
error: hasValidContext.value
? { eyebrow: "资料未保存", title: "暂时无法保存成员档案", copy: "当前修改仍保留,可返回表单后重试。", action: "返回修改" }
: { eyebrow: "成员入口无效", title: "没有找到要编辑的成员", copy: "请从成员档案重新进入,页面不会创建临时成员身份。", action: "返回上一页" },
"no-permission": { eyebrow: "权限不足", title: "当前账号不能编辑这位成员", copy: "本人或具备成员维护权限的家谱管理员才能修改档案。", action: "返回成员档案" },
error: failedAction.value === "save"
? { eyebrow: "保存失败", title: "成员档案尚未保存", copy: errorMessage.value || "服务未确认本次修改,请检查填写后重试。", action: "返回修改" }
: hasValidContext.value
? { eyebrow: "资料读取失败", title: "暂时无法读取成员档案", copy: errorMessage.value || "请返回后重试。", action: "重新读取" }
: { eyebrow: "成员入口无效", title: "没有找到要编辑的成员", copy: errorMessage.value || "请从成员档案重新进入。", action: "返回上一页" },
}[editState.value] || {}));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardDialogVisible.value = visible;
@@ -125,49 +171,55 @@ const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const loadMember = (id) => {
const member = findTreeMemberPresentationFixture(genealogyId.value, id);
if (!member) return false;
originalMember.value = { ...member };
if (["privacy", "forbidden"].includes(member.status)) {
Object.assign(editForm, {
name: member.name,
generationName: "",
birthDate: "",
deathDate: "",
summary: "",
const loadMember = async () => {
const activeLoad = ++loadSequence;
editState.value = "loading";
try {
const member = await appApi.getPerson(genealogyId.value, personId.value, {
requestController: editRequestController,
});
} else {
if (activeLoad !== loadSequence) return;
originalMember.value = member;
Object.assign(editForm, {
name: member.name,
aliasName: member.aliasName || "",
generationName: member.generationName || "",
birthDate: member.birthDate || "",
birthLunar: member.birthLunar || "",
birthPlace: member.birthplace || "",
deathDate: member.deathDate || "",
summary: member.summary || "",
deathLunar: member.deathLunar || "",
deathPlace: member.deathPlace || "",
burialPlace: member.burialPlace || "",
summary: member.biography || "",
remark: member.remark || "",
});
baseline.value = formSnapshot.value;
errorMessage.value = "";
failedAction.value = "load";
editState.value = "form";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
originalMember.value = null;
failedAction.value = "load";
errorMessage.value = error?.message || "成员资料暂不可用。";
editState.value = "error";
}
baseline.value = formSnapshot.value;
return true;
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
const loaded = Boolean(genealogyId.value && personId.value) && loadMember(personId.value);
editState.value = !loaded
? "error"
: ["privacy", "forbidden"].includes(originalMember.value.status)
? "no-permission"
: query.state === "preview"
? "preview"
: query.state === "error"
? "error"
: "form";
if (!genealogyId.value || !personId.value || query.state === "error") {
editState.value = "error";
errorMessage.value = "当前家谱或成员上下文无效。";
return;
}
void loadMember();
});
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
loadSequence += 1;
editRequestController.abort();
discardConfirmation.dispose();
});
@@ -193,17 +245,34 @@ const validateEditForm = () => {
fieldErrors.dates = editForm.birthDate && editForm.deathDate && editForm.deathDate < editForm.birthDate ? "离世日期不能早于出生日期" : "";
return !fieldErrors.name && !fieldErrors.dates;
};
const saveMember = () => {
const saveMember = async () => {
if (isSubmitting.value || !validateEditForm()) return;
const submitSnapshot = Object.freeze({ ...editForm });
isSubmitting.value = true;
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
editState.value = submitSnapshot.name.trim() === "失败" ? "error" : "preview";
try {
await appApi.updatePerson(genealogyId.value, personId.value, {
name: editForm.name,
aliasName: editForm.aliasName,
generationName: editForm.generationName,
birthDate: editForm.birthDate,
birthLunar: editForm.birthLunar,
birthPlace: editForm.birthPlace,
deathDate: editForm.deathDate,
deathLunar: editForm.deathLunar,
deathPlace: editForm.deathPlace,
burialPlace: editForm.burialPlace,
biography: editForm.summary,
remark: editForm.remark,
}, { requestController: editRequestController });
baseline.value = formSnapshot.value;
await goBack();
} catch (error) {
if (isRequestCancelled(error)) return;
failedAction.value = "save";
errorMessage.value = error?.message || "服务未确认本次修改。";
editState.value = "error";
} finally {
isSubmitting.value = false;
submitTimer = null;
}, 280);
submitTimer = timer;
}
};
const returnToMember = async () => {
const confirmed = isDirty.value
@@ -213,11 +282,15 @@ const returnToMember = async () => {
return goBack();
};
const handleResultAction = () => {
if (editState.value === "error" && hasValidContext.value) {
if (failedAction.value === "save") {
editState.value = "form";
return;
}
return returnToMember();
if (editState.value === "error") {
if (!genealogyId.value || !personId.value) return returnToMember();
return loadMember();
}
editState.value = "form";
};
</script>
+80 -245
View File
@@ -1,291 +1,126 @@
<!-- 页面编号T-06用途选择两位现有成员并校正其家族关系 -->
<!-- 页面编号T-06用途展示指定成员的同辈排行调整入口 -->
<template>
<view
class="relationship-page"
class="rank-page"
:class="{
'relationship-state--form': relationshipState === 'form',
'relationship-state--preview': relationshipState === 'preview',
'relationship-state--conflict': relationshipState === 'conflict',
'relationship-state--error': relationshipState === 'error',
'rank-state--loading': rankState === 'loading',
'rank-state--form': rankState === 'form',
'rank-state--unavailable': rankState === 'unavailable',
'rank-state--error': rankState === 'error',
}"
>
<ModulePageBackground module="tree" />
<view class="relationship-page__header"><PageHeader title="关系维护" custom-back @back="requestBack" /></view>
<view class="relationship-panel">
<view v-if="relationshipState === 'form'" class="relationship-form">
<text class="form-eyebrow">亲属关系校正</text>
<text class="form-title">确认两位成员的家族关系</text>
<text class="form-copy">选择成员和关系类型后先查看对世系的影响再决定是否保存</text>
<view class="rank-page__header"><PageHeader title="调整排行" custom-back @back="goBack" /></view>
<view class="rank-panel">
<AppLoading v-if="rankState === 'loading'" text="正在读取成员资料" description="请稍候,正在确认待调整人物。" />
<picker :range="memberLabels" :value="sourceIndex" @change="selectMember('sourceId', $event)">
<view class="form-field form-field--picker"><text>当前成员</text><text>{{ memberName(relationshipForm.sourceId) || "请选择成员" }}</text></view>
</picker>
<text v-if="fieldErrors.sourceId" class="field-error">{{ fieldErrors.sourceId }}</text>
<view v-else-if="rankState === 'form' && member" class="rank-form">
<text class="form-eyebrow">同辈排行</text>
<text class="form-title">调整{{ member.name }}的排行</text>
<text class="form-copy">排行调整必须由服务端以同辈原子操作完成避免逐人保存造成部分成功</text>
<picker :range="memberLabels" :value="targetIndex" @change="selectMember('targetId', $event)">
<view class="form-field form-field--picker"><text>关联成员</text><text>{{ memberName(relationshipForm.targetId) || "请选择另一位成员" }}</text></view>
</picker>
<text v-if="fieldErrors.targetId" class="field-error">{{ fieldErrors.targetId }}</text>
<picker :range="relationshipOptions" :value="relationshipIndex" @change="selectRelationship">
<view class="form-field form-field--picker"><text>关系类型</text><text>{{ relationshipForm.relationship || "请选择关系" }}</text></view>
</picker>
<text v-if="fieldErrors.relationship" class="field-error">{{ fieldErrors.relationship }}</text>
<view class="relationship-preview">
<text>关系影响预览</text>
<text>{{ relationshipPreview }}</text>
<view class="member-context">
<text>当前成员</text><text> {{ member.generation }} · {{ member.branch }}</text>
</view>
<text class="form-note">父母子女关系会改变世系位置配偶和兄弟姐妹关系不会自动改写现有父母</text>
<view class="form-action" @click="saveRelationship">
<view class="rank-notice">
<text>当前服务状态</text>
<text>尚未提供带版本冲突处理和完整结果的同辈原子重排合同</text>
</view>
<view class="form-action" @click="showRankUnavailable">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ isSubmitting ? "正在校验…" : "生成本地预览" }}</text>
<text>查看服务状态</text>
</view>
</view>
<view v-else class="relationship-result">
<text class="form-eyebrow">{{ resultCopy.eyebrow }}</text>
<text class="form-title">{{ resultCopy.title }}</text>
<text class="form-copy">{{ resultCopy.copy }}</text>
<view v-if="relationshipState === 'conflict'" class="result-actions">
<view class="form-action form-action--secondary" @click="relationshipState = 'form'">
<image src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png" mode="scaleToFill" /><text>返回核对</text>
</view>
<view class="form-action" @click="conflictDialogVisible = true">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" /><text>查看规则</text>
</view>
</view>
<view v-else class="form-action" @click="handleResultAction">
<view v-else class="rank-result">
<text class="form-eyebrow">{{ rankState === "unavailable" ? "服务暂未开放" : "成员入口无效" }}</text>
<text class="form-title">{{ rankState === "unavailable" ? "暂不能提交排行调整" : "暂时无法读取成员资料" }}</text>
<text class="form-copy">{{ rankState === "unavailable" ? "当前人物更新接口只有单人物 sortOrder 候选,不能保证同辈排行原子一致。本页不会逐人写入,也不会显示本地预览成功。" : errorMessage }}</text>
<view class="form-action" @click="rankState === 'unavailable' ? (rankState = 'form') : goBack()">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ relationshipState === "preview" ? "返回世系树(不保存)" : hasValidContext ? "重新选择" : "返回上一页" }}</text>
<text>{{ rankState === "unavailable" ? "返回查看" : "返回世系树" }}</text>
</view>
</view>
</view>
<AppDialog
:visible="conflictDialogVisible"
eyebrow="关系校验规则"
title="为什么不能保存这段关系"
:message="conflictReason || '同一成员不能成为自己的亲属,也不能形成上下代循环或重复父母关系。'"
:close-on-mask="false"
@confirm="conflictDialogVisible = false"
/>
<AppDialog
:visible="discardDialogVisible"
eyebrow="关系尚未保存"
title="要放弃本次关系调整吗"
message="当前关系只在本页预览,确认返回后不会修改世系。"
cancel-text="继续核对"
confirm-text="放弃并返回"
show-cancel
:close-on-mask="false"
@cancel="cancelDiscard"
@confirm="confirmDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { ref } from "vue";
import { onBackPress, onLoad, 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 { listTreeMemberFixtures } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, handleBackPress } from "@/utils/navigation.js";
const relationshipState = ref("form");
const rankState = ref("loading");
const genealogyId = ref("");
const personId = ref("");
const isSubmitting = ref(false);
const conflictDialogVisible = ref(false);
const discardDialogVisible = ref(false);
const conflictReason = ref("");
let submitTimer = null;
const member = ref(null);
const errorMessage = ref("");
const rankRequestController = createRequestController();
let loadSequence = 0;
const memberOptions = computed(() => listTreeMemberFixtures(genealogyId.value));
const relationshipOptions = ["父子(当前成员为父)", "父女(当前成员为父)", "母子(当前成员为母)", "母女(当前成员为母)", "配偶", "兄弟姐妹"];
const relationshipForm = reactive({ sourceId: "", targetId: "", relationship: "" });
const fieldErrors = reactive({ sourceId: "", targetId: "", relationship: "" });
const baseline = ref("");
const memberLabels = computed(() => memberOptions.value.map((item) => `${item.name} · 第 ${item.generation}`));
const memberById = computed(() => new Map(memberOptions.value.map((item) => [item.id, item])));
const hasValidContext = computed(() =>
Boolean(genealogyId.value && memberById.value.has(personId.value)),
);
const sourceIndex = computed(() => Math.max(0, memberOptions.value.findIndex((item) => item.id === relationshipForm.sourceId)));
const targetIndex = computed(() => Math.max(0, memberOptions.value.findIndex((item) => item.id === relationshipForm.targetId)));
const relationshipIndex = computed(() => Math.max(0, relationshipOptions.indexOf(relationshipForm.relationship)));
const memberName = (id) => memberById.value.get(String(id))?.name || "";
const relationshipPreview = computed(
() => relationshipForm.sourceId && relationshipForm.targetId && relationshipForm.relationship
? `${memberName(relationshipForm.sourceId)}将以“${relationshipForm.relationship}”关联${memberName(relationshipForm.targetId)}`
: "完成三项选择后,这里会说明世系位置将如何变化。",
);
const formSnapshot = computed(() => JSON.stringify(relationshipForm));
const isDirty = computed(() =>
Boolean(baseline.value) && formSnapshot.value !== baseline.value,
);
const resultCopy = computed(() => ({
preview: { eyebrow: "本地流程预览", title: "关系校验已经通过", copy: `${relationshipPreview.value} 尚未提交服务器,返回后世系不会变化。` },
conflict: { eyebrow: "发现关系冲突", title: "这段关系会造成世系矛盾", copy: conflictReason.value },
error: hasValidContext.value
? { eyebrow: "关系未保存", title: "暂时无法完成关系调整", copy: "当前选择仍然保留,可返回后重新校验。" }
: { eyebrow: "成员入口无效", title: "没有找到要调整的成员", copy: "请从世系树重新进入,页面不会回退到其他成员。" },
}[relationshipState.value] || {}));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardDialogVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const loadMember = async () => {
const activeLoad = ++loadSequence;
rankState.value = "loading";
try {
const result = await appApi.getPerson(genealogyId.value, personId.value, {
requestController: rankRequestController,
});
if (activeLoad !== loadSequence) return;
member.value = result;
errorMessage.value = "";
rankState.value = "form";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
member.value = null;
errorMessage.value = error?.message || "当前成员资料暂不可用,请返回世系树后重试。";
rankState.value = "error";
}
};
const showRankUnavailable = () => {
rankState.value = "unavailable";
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
relationshipForm.sourceId = hasValidContext.value ? personId.value : "";
baseline.value = formSnapshot.value;
relationshipState.value = !hasValidContext.value || query.state === "error"
? "error"
: query.state === "preview"
? "preview"
: query.state === "conflict"
? "conflict"
: "form";
if (relationshipState.value === "conflict") conflictReason.value = "目标成员已经存在父级关系,请先核对原关系。";
if (!genealogyId.value || !personId.value || query.mode !== "rank") {
rankState.value = "error";
errorMessage.value = "当前排行入口无效,请从人物操作面板重新进入。";
return;
}
void loadMember();
});
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
loadSequence += 1;
rankRequestController.abort();
});
const closeTransient = () => {
if (conflictDialogVisible.value) {
conflictDialogVisible.value = false;
return;
}
cancelDiscard();
};
const requestBack = () =>
runBackGuard({
transientOpen: conflictDialogVisible.value || discardDialogVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": closeTransient,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
const clearFieldError = (field) => { fieldErrors[field] = ""; };
const selectMember = (field, event) => {
relationshipForm[field] = memberOptions.value[Number(event.detail.value)]?.id || "";
clearFieldError(field);
};
const selectRelationship = (event) => {
relationshipForm.relationship = relationshipOptions[Number(event.detail.value)] || "";
clearFieldError("relationship");
};
const isAncestor = (possibleAncestorId, memberId) => {
let current = memberById.value.get(String(memberId));
const visited = new Set();
while (current?.parentId && !visited.has(current.id)) {
if (current.parentId === String(possibleAncestorId)) return true;
visited.add(current.id);
current = memberById.value.get(current.parentId);
}
return false;
};
const validateRelationship = () => {
fieldErrors.sourceId = relationshipForm.sourceId ? "" : "请选择当前成员";
fieldErrors.targetId = relationshipForm.targetId ? "" : "请选择关联成员";
fieldErrors.relationship = relationshipForm.relationship ? "" : "请选择关系类型";
if (fieldErrors.sourceId || fieldErrors.targetId || fieldErrors.relationship) return false;
if (relationshipForm.sourceId === relationshipForm.targetId) {
conflictReason.value = "同一成员不能与自己建立亲属关系。";
return false;
}
const isParentRelationship = relationshipForm.relationship.includes("当前成员为");
const target = memberById.value.get(relationshipForm.targetId);
if (isParentRelationship && isAncestor(relationshipForm.targetId, relationshipForm.sourceId)) {
conflictReason.value = "保存后会形成上下代循环,请重新选择成员方向。";
return false;
}
if (isParentRelationship && target?.parentId && target.parentId !== relationshipForm.sourceId) {
conflictReason.value = `${target.name}已经存在父级成员,不能直接重复建立父母关系。`;
return false;
}
conflictReason.value = "";
return true;
};
const saveRelationship = () => {
if (isSubmitting.value) return;
if (!validateRelationship()) {
if (conflictReason.value) relationshipState.value = "conflict";
return;
}
const submitSnapshot = Object.freeze({ ...relationshipForm });
isSubmitting.value = true;
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
relationshipState.value = submitSnapshot.relationship === "失败" ? "error" : "preview";
isSubmitting.value = false;
submitTimer = null;
}, 280);
submitTimer = timer;
};
const returnToTree = async () => {
const confirmed = isDirty.value
? await requestDiscardConfirmation()
: true;
if (!confirmed) return false;
return returnTo("T01", { genealogyId: genealogyId.value });
};
const handleResultAction = () => {
if (relationshipState.value === "error") {
if (!hasValidContext.value) return goBack();
relationshipState.value = "form";
return;
}
return returnToTree();
};
onBackPress((event) => handleBackPress(event, goBack));
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.relationship-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.relationship-page__header { z-index: 3; }
.relationship-panel { @include adaptive.adaptive-tree-panel; z-index: 2; width: calc(100% - 32rpx); margin: 18rpx auto 28rpx; padding: 7.5% 8%; }
.rank-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.rank-page__header { z-index: 3; }
.rank-panel { @include adaptive.adaptive-tree-panel; z-index: 2; width: calc(100% - 32rpx); margin: 18rpx auto 28rpx; padding: 7.5% 8%; }
.form-eyebrow { display: block; color: $brand-red; font-size: 22rpx; letter-spacing: 3rpx; }
.form-title { display: block; margin-top: 10rpx; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 34rpx; font-weight: 700; line-height: 1.35; }
.form-copy, .form-note { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
.form-field { @include adaptive.adaptive-tree-field; display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 78rpx; align-items: center; gap: 20rpx; margin-top: 14rpx; padding: 12rpx 22rpx; }
.form-field text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
.form-field text:last-child { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; }
.field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; }
.relationship-preview { @include adaptive.adaptive-genealogy-list-card; margin-top: 18rpx; padding: 20rpx 22rpx; }
.relationship-preview text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.5; }
.relationship-preview text:first-child { color: $brand-red; font-weight: 700; }
.form-note { text-align: center; }
.form-action { display: grid; width: 100%; min-height: 76rpx; margin-top: 18rpx; }
.form-copy { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
.member-context, .rank-notice { @include adaptive.adaptive-tree-field; display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 78rpx; align-items: center; gap: 20rpx; margin-top: 16rpx; padding: 12rpx 22rpx; }
.member-context text:first-child, .rank-notice text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
.member-context text:last-child, .rank-notice text:last-child { min-width: 0; color: $ink-muted; font-size: 23rpx; line-height: 1.45; text-align: right; }
.form-action { display: grid; width: 100%; min-height: 76rpx; margin-top: 22rpx; }
.form-action image, .form-action text { grid-area: 1 / 1; width: 100%; height: 100%; }
.form-action text { z-index: 1; display: flex; align-items: center; justify-content: center; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
.form-action--secondary text { color: $ink; }
.relationship-result { margin-top: 28%; text-align: center; }
.relationship-result .form-eyebrow, .relationship-result .form-copy { text-align: center; }
.relationship-result > .form-action { width: 420rpx; max-width: 100%; margin-right: auto; margin-left: auto; }
.result-actions { display: flex; gap: 14rpx; margin-top: 24rpx; }
.result-actions .form-action { width: calc(50% - 7rpx); margin-top: 0; }
@media (min-width: 400px) { .relationship-panel { width: calc(100% - 48rpx); } }
.rank-result { margin-top: 30%; text-align: center; }
.rank-result .form-eyebrow, .rank-result .form-copy { text-align: center; }
.rank-result .form-action { width: 420rpx; max-width: 100%; margin-right: auto; margin-left: auto; }
@media (min-width: 400px) { .rank-panel { width: calc(100% - 48rpx); } }
</style>
+63 -35
View File
@@ -14,7 +14,7 @@
<view v-if="hasValidContext" class="directory-context">
<text class="directory-context__name">汤氏家谱</text>
<text class="directory-context__meta"
>主支 · {{ members.length }} 位成员</text
>已加载 {{ members.length }} / {{ total }} 位成员</text
>
</view>
<view
@@ -46,10 +46,10 @@
<template v-else-if="directoryState === 'list'">
<view class="directory-summary"
><text>家谱成员</text
><text>{{ filteredMembers.length }} · 按世代排列</text></view
><text>{{ members.length }} / {{ total }} · 按世代排列</text></view
>
<view
v-for="item in filteredMembers"
v-for="item in members"
:key="item.id"
class="directory-card"
@click="openMember(item)"
@@ -60,6 +60,13 @@
<text class="directory-card__status">{{ memberStatus(item) }}</text>
</view>
</view>
<AppButton
v-if="hasMore"
block
type="secondary"
:label="loadingMore ? '正在加载…' : '加载更多成员'"
@click="loadMore"
/>
</template>
<view v-else class="directory-state-card">
<view
@@ -85,61 +92,82 @@
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { listTreeMemberPresentationFixtures } from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const keyword = ref("");
const directoryState = ref("loading");
const members = ref([]);
const total = ref(0);
const pageNum = ref(1);
const loadingMore = ref(false);
const directoryRequestController = createRequestController();
let loadSequence = 0;
const hasValidContext = computed(() => Boolean(genealogyId.value));
const members = computed(() => listTreeMemberPresentationFixtures(genealogyId.value));
const isRestrictedMember = (member) =>
["privacy", "forbidden"].includes(member.status);
const memberMeta = (member) =>
isRestrictedMember(member)
? `${member.generation} 世 · ${member.relation}`
: `${member.generation} 世 · ${member.generationName} · ${member.branch}`;
`${member.generation} 世 · ${member.generationName || "字辈待补"} · ${member.branch}`;
const memberStatus = (member) =>
isRestrictedMember(member)
? member.status === "forbidden"
? "访问受限"
: "隐私资料"
: member.note;
const filteredMembers = computed(() => {
const value = keyword.value.trim();
if (!value) return members.value;
return members.value.filter((item) =>
`${item.name}${item.relation}${item.generation}${item.generationName || ""}${item.branch || ""}`.includes(value),
);
});
member.personStatus ? `人物状态:${member.personStatus}` : "人物状态待补";
const hasMore = computed(() => members.value.length < total.value);
const loadMembers = async ({ append = false } = {}) => {
if (!hasValidContext.value) return;
const activeLoad = ++loadSequence;
if (append) loadingMore.value = true;
else directoryState.value = "loading";
try {
const result = await appApi.getPersonPage(
genealogyId.value,
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
{ requestController: directoryRequestController },
);
if (activeLoad !== loadSequence) return;
members.value = append ? [...members.value, ...result.rows] : result.rows;
total.value = result.total;
directoryState.value = members.value.length ? "list" : "empty";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
if (!append) members.value = [];
directoryState.value = "error";
} finally {
if (activeLoad === loadSequence) loadingMore.value = false;
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
directoryState.value =
!hasValidContext.value ? "error" : query.state === "loading"
? "loading"
: query.state === "empty"
? "empty"
: query.state === "error"
? "error"
: members.value.length
? "list"
: "empty";
if (!hasValidContext.value || query.state === "error") {
directoryState.value = "error";
return;
}
void loadMembers();
});
const searchMembers = () => {
directoryState.value = filteredMembers.value.length ? "list" : "empty";
pageNum.value = 1;
void loadMembers();
};
const loadMore = () => {
if (loadingMore.value || !hasMore.value) return;
pageNum.value += 1;
void loadMembers({ append: true });
};
const retryDirectory = () => {
if (!hasValidContext.value) return goBack();
directoryState.value = "list";
pageNum.value = 1;
return loadMembers();
};
const openMember = (item) =>
hasValidContext.value
? openPage("T03", { genealogyId: genealogyId.value, personId: String(item.id) }, "T07")
: Promise.resolve(false);
onUnload(() => {
loadSequence += 1;
directoryRequestController.abort();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
+60 -49
View File
@@ -3,9 +3,7 @@
<view
class="member-status-page"
:class="{
'member-status--privacy': statusState === 'privacy',
'member-status--deceased': statusState === 'deceased',
'member-status--forbidden': statusState === 'forbidden',
'member-status--available': statusState === 'available',
'member-status--error': statusState === 'error',
}"
>
@@ -19,21 +17,26 @@
</view>
<view class="status-card">
<view class="status-card__copy">
<AppLoading
v-if="statusState === 'loading'"
text="正在读取成员状态"
description="请稍候,正在读取人物详情中的状态字段。"
/>
<view v-else class="status-card__copy">
<text>{{ activeStatus.eyebrow }}</text>
<text>{{ activeStatus.title }}</text>
<text>{{ activeStatus.copy }}</text>
</view>
</view>
<view class="status-guidance">
<view v-if="statusState !== 'loading'" class="status-guidance">
<view>
<text>{{ activeStatus.guideTitle }}</text>
<text v-for="line in activeStatus.guides" :key="line">{{ line }}</text>
</view>
</view>
<view class="status-action" @click="handleAction">
<view v-if="statusState !== 'loading'" class="status-action" @click="handleAction">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ activeStatus.action }}</text>
</view>
@@ -42,43 +45,38 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findTreeMemberPresentationFixture } from "@/data/mock.js";
import { goBack, goRoot } from "@/utils/navigation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack } from "@/utils/navigation.js";
const genealogyId = ref("");
const personId = ref("");
const statusState = ref("loading");
const genealogyName = ref("汤氏家谱");
const member = ref(null);
const statusRequestController = createRequestController();
let loadSequence = 0;
const states = {
privacy: {
eyebrow: "隐私成员",
title: "敏感资料只向授权成员展示",
copy: "该成员姓名和世系位置仍然保留,出生信息、联系方式和生平内容按权限隐藏。",
guideTitle: "当前可见范围",
guides: ["姓名、世代与家族关系可见", "联系方式和详细生平已隐藏", "本人或谱主可维护授权范围"],
action: "返回成员档案",
loading: {
eyebrow: "正在读取",
title: "正在读取成员状态",
copy: "",
guideTitle: "",
guides: [],
action: "",
},
deceased: {
eyebrow: "离世纪念",
title: "这位家人的生命记录被温和保留",
copy: "离世状态不会删除成员关系;有权限的家人仍可共同维护生卒年月、生平和追思资料。",
guideTitle: "纪念资料范围",
guides: ["生卒年月与世系关系继续保留", "生平内容由有权限家人维护", "敏感资料继续遵守原有隐私设置"],
available: {
eyebrow: "人物状态",
title: "当前状态已由服务端返回",
copy: "页面只展示人物详情中的原始状态值,不把未声明的字典值解释成隐私、纪念或访问限制。",
guideTitle: "当前合同范围",
guides: ["人物状态值来自 personStatus 字段", "状态展示文案需要后端提供字典 owner", "当前页不执行停用或任何状态写入"],
action: "返回成员档案",
},
forbidden: {
eyebrow: "访问受限",
title: "当前账号没有查看该档案的权限",
copy: "页面不会展示被隐藏字段,也不会提供绕过家谱权限的入口。",
guideTitle: "如何申请查看",
guides: ["先确认已经加入对应家谱", "联系谱主说明亲属关系和用途", "权限变更后重新进入成员档案"],
action: "返回我的家谱",
},
error: {
eyebrow: "成员状态不可用",
title: "没有找到要查看的成员",
@@ -89,35 +87,48 @@ const states = {
},
};
const activeStatus = computed(() => states[statusState.value] || states.error);
const pageTitle = computed(() => statusState.value === "deceased" ? "成员纪念" : statusState.value === "privacy" ? "隐私资料" : "成员状态");
const pageTitle = computed(() => "成员状态");
const hasValidContext = computed(() => Boolean(genealogyId.value && personId.value));
const memberIdentityCopy = computed(() => {
if (!member.value) return "";
const identity = `${member.value.name} · 第 ${member.value.generation}`;
return ["privacy", "forbidden"].includes(member.value.status)
? `${identity} · ${member.value.relation}`
: `${identity} · ${member.value.branch}`;
const status = member.value.personStatus || "未填写";
return `${member.value.name} · 第 ${member.value.generation} 世 · 状态值 ${status}`;
});
const loadMember = async () => {
const activeLoad = ++loadSequence;
statusState.value = "loading";
try {
const result = await appApi.getPerson(genealogyId.value, personId.value, {
requestController: statusRequestController,
});
if (activeLoad !== loadSequence) return;
member.value = result;
genealogyName.value = result.genealogyName;
statusState.value = "available";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
member.value = null;
statusState.value = "error";
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
member.value = hasValidContext.value
? findTreeMemberPresentationFixture(genealogyId.value, personId.value)
: null;
const requestedState = ["privacy", "deceased", "forbidden"].includes(query.state) ? query.state : "";
const derivedState = member.value?.status || "";
statusState.value = member.value && (!requestedState || requestedState === derivedState)
? requestedState || derivedState
: "error";
if (!hasValidContext.value || query.state === "error") {
statusState.value = "error";
return;
}
void loadMember();
});
const handleAction = () => {
if (statusState.value === "forbidden") {
return goRoot("G01");
}
return goBack();
};
onUnload(() => {
loadSequence += 1;
statusRequestController.abort();
});
const handleAction = () => goBack();
</script>
<style scoped lang="scss">
Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

@@ -11,7 +11,13 @@ $remoteBusinessOwners = @(
'pages/genealogy/g01-my-genealogies.vue',
'pages/genealogy/g05-genealogy-overview.vue',
'pages/tree/t01-tree-overview.vue',
'pages/profile/m07-feedback.vue'
'pages/tree/t03-member-profile.vue',
'pages/tree/t04-add-relative.vue',
'pages/tree/t05-edit-member.vue',
'pages/tree/t06-edit-relationship.vue',
'pages/profile/m04-change-password.vue',
'pages/profile/m07-feedback.vue',
'pages/profile/m10-about-settings.vue'
)
foreach ($relativePath in $activePaths) {
+6 -1
View File
@@ -107,7 +107,12 @@ foreach ($entry in @(
Require-FieldRelation $entry.Content $entry.Prefix 'password' 'password' "$($entry.Key) 密码"
Require-FieldRelation $entry.Content $entry.Prefix 'confirm-password' 'confirmPassword' "$($entry.Key) 确认密码"
$getCode = Get-ButtonByClass $entry.Content 'get-code'
if (-not $getCode.Value.Contains(':disabled="sendingCode || submitting || cooldownSeconds > 0"')) { throw "$($entry.Key) 短信按钮禁用态未关联" }
if ($entry.Key -eq 'A04') {
$requiredDisabled = ':disabled="sendingCode || submitting || cooldownSeconds > 0 || registrationCommitted"'
} else {
$requiredDisabled = ':disabled="sendingCode || submitting || cooldownSeconds > 0"'
}
if (-not $getCode.Value.Contains($requiredDisabled)) { throw "$($entry.Key) 短信按钮禁用态未关联" }
$submit = Get-ButtonByClass $entry.Content $entry.SubmitClass
if (-not $submit.Value.Contains(':disabled="submitting || sendingCode || tacVisible"') -or -not $submit.Value.Contains(':aria-busy="submitting"')) { throw "$($entry.Key) 主提交忙碌态未关联" }
}
+2 -2
View File
@@ -32,8 +32,8 @@ foreach ($relativePath in @(
if ($page.Contains('cooldownSeconds.value -= 1')) {
throw "$relativePath still uses a decrementing cooldown that freezes in background"
}
if ($page -match '(?s)v-model\.trim="phone".{0,300}:disabled="[^"]*cooldownSeconds') {
throw "$relativePath must not lock an empty phone field when a scene cooldown is restored"
if (-not $page.Contains('cooldownSeconds > 0 && phone.length > 0')) {
throw "$relativePath must preserve an editable empty phone field when a scene cooldown is restored"
}
}
+8 -3
View File
@@ -40,7 +40,7 @@ foreach ($entry in $vendorAssets.GetEnumerator()) {
if ($actualHash -ne $entry.Value) { throw "用户提供的 TAC 供应商资产发生漂移:$($entry.Key)" }
}
foreach ($token in @('lang="renderjs"', './static/tac/css/tac.css', './static/tac/js/tac.min.js', './static/tac/js/jiapu-tac-adapter.js', 'window.TAC', 'window.CaptchaConfig', 'window.JiapuTacAdapter', 'xhr.status >= 200 && xhr.status < 300', '$ownerInstance.callMethod', 'activeXhr', 'xhr.timeout = 15000', 'xhr.ontimeout', 'xhr.abort()', 'config.doSendRequest = (options) => this.sendStrictRequest(options)', '@media (max-width: 340px)')) {
foreach ($token in @('lang="renderjs"', './static/tac/css/tac.css', './static/tac/js/tac.min.js', './static/tac/js/jiapu-tac-adapter.js', 'window.TAC', 'window.CaptchaConfig', 'window.JiapuTacAdapter', 'xhr.status >= 200 && xhr.status < 300', '$ownerInstance.callMethod', 'activeXhr', 'xhr.timeout = 15000', 'xhr.ontimeout', 'xhr.abort()', 'config.doSendRequest = (options) => this.sendStrictRequest(options)', 'this.tac = new window.TAC(config);')) {
Require-Text -Content $component -Text $token -Label 'TacVerification'
}
Reject-Text -Content $component -Text 'config.doSendRequest = this.sendStrictRequest' -Label '失去 renderjs 实例上下文的传输函数'
@@ -52,6 +52,9 @@ Require-Text -Content $adapter -Text 'payload: { track:' -Label 'TAC payload.tra
foreach ($unsafe in @('code === 200 && response.data', 'passed !== false', "validToken: 'mock", 'mock-valid-token')) {
Reject-Text -Content ($owner + $adapter + $component + $api) -Text $unsafe -Label 'TAC 安全合同'
}
foreach ($customVisual in @('tac-panel', 'tac-heading', 'tac-tool', 'logoUrl:', 'i18n:', 'new window.TAC(config, {')) {
Reject-Text -Content $component -Text $customVisual -Label 'TAC 供应商原生呈现'
}
foreach ($method in @('getCaptchaRequirement', 'sendSmsCode', 'loginWithPassword', 'loginWithSms', 'registerWithPassword', 'resetPassword')) {
Require-Text -Content $api -Text "async $method" -Label '认证 API'
@@ -89,9 +92,11 @@ foreach ($page in @($a01, $a04, $a05)) {
Require-Text -Content $page -Text 'const requestedPhone = phone.value' -Label '认证手机号请求快照'
Require-Text -Content $page -Text 'subject: requestedPhone' -Label '认证手机号请求快照'
$phoneLock = if ($page -eq $a01) {
':disabled="sendingCode || cooldownSeconds > 0 || submitting || tacVisible"'
':disabled="sendingCode || submitting || tacVisible || authenticationCommitted || (cooldownSeconds > 0 && phone.length > 0)"'
} elseif ($page -eq $a04) {
':disabled="sendingCode || submitting || tacVisible || registrationCommitted || (cooldownSeconds > 0 && phone.length > 0)"'
} else {
':disabled="sendingCode || cooldownSeconds > 0 || submitting"'
':disabled="sendingCode || submitting || (cooldownSeconds > 0 && phone.length > 0)"'
}
Require-Text -Content $page -Text $phoneLock -Label '短信流程手机号锁定'
if ([regex]::Matches($page, [regex]::Escape('if (!pageActive) return;')).Count -lt 3) {
+37 -113
View File
@@ -1,128 +1,52 @@
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$json = Get-Content -LiteralPath (Join-Path $root 'APP.openapi.json') -Raw -Encoding UTF8 | ConvertFrom-Json
$yaml = Get-Content -LiteralPath (Join-Path $root 'APP.openapi.yaml') -Raw -Encoding UTF8
$blockers = [System.Collections.Generic.List[string]]::new()
$apiPath = Join-Path $root 'utils/api.js'
$a01Path = Join-Path $root 'pages/auth/a01-entry.vue'
$runtimePath = Join-Path $PSScriptRoot 'auth-api-runtime-smoke.js'
$issues = New-Object System.Collections.Generic.List[string]
function Require-Operation {
param([string]$Path, [string]$Method)
$pathProperty = $json.paths.PSObject.Properties[$Path]
if ($null -eq $pathProperty -or $null -eq $pathProperty.Value.PSObject.Properties[$Method]) {
throw "认证源合同缺少操作:$($Method.ToUpperInvariant()) $Path"
}
if ($yaml -notmatch [regex]::Escape(" $Path`:") -or $yaml -notmatch "(?m)^ $Method`:\s*$") {
throw "YAML 认证源合同缺少操作:$($Method.ToUpperInvariant()) $Path"
}
return $pathProperty.Value.PSObject.Properties[$Method].Value
}
function Require-Schema {
param([string]$Name)
$property = $json.components.schemas.PSObject.Properties[$Name]
if ($null -eq $property) { throw "认证源合同缺少 schema$Name" }
if ($yaml -notmatch "(?m)^ $([regex]::Escape($Name)):\s*$") { throw "YAML 认证源合同缺少 schema$Name" }
return $property.Value
}
function Assert-ExactSet {
param([object[]]$Actual, [object[]]$Expected, [string]$Label)
$actualSet = @($Actual | ForEach-Object { [string]$_ } | Sort-Object -Unique)
$expectedSet = @($Expected | ForEach-Object { [string]$_ } | Sort-Object -Unique)
if (($actualSet -join ',') -ne ($expectedSet -join ',')) {
throw "$Label 漂移:actual=[$($actualSet -join ',')] expected=[$($expectedSet -join ',')]"
foreach ($path in @($apiPath, $a01Path, $runtimePath)) {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
$issues.Add("missing authentication owner: $path")
}
}
$operations = @(
@('/captcha/require', 'get'),
@('/captcha/challenge', 'post'),
@('/captcha/verify', 'post'),
@('/genealogy/app/auth/sms/code', 'post'),
@('/genealogy/app/auth/login', 'post'),
@('/genealogy/app/auth/login/sms', 'post'),
@('/genealogy/app/auth/register', 'post'),
@('/genealogy/app/auth/password/reset', 'put')
)
foreach ($entry in $operations) { [void](Require-Operation -Path $entry[0] -Method $entry[1]) }
$expectedVerificationScenes = @('APP_SMS_LOGIN', 'APP_REGISTER', 'APP_FORGOT_PASSWORD', 'APP_PHONE_CHANGE', 'APP_ACCOUNT_DEACTIVATE')
foreach ($schemaName in @('VerificationChallengeBody', 'VerificationCheckBody')) {
$schema = Require-Schema -Name $schemaName
Assert-ExactSet -Actual @($schema.properties.sceneCode.enum) -Expected $expectedVerificationScenes -Label "$schemaName.sceneCode"
}
$check = Require-Schema -Name 'VerificationCheckBody'
Assert-ExactSet -Actual @($check.properties.payload.oneOf.'$ref') -Expected @('#/components/schemas/TianaiVerificationPayload', '#/components/schemas/SystemImageVerificationPayload') -Label 'VerificationCheckBody.payload.oneOf'
$requiredCheckFields = @('tenantId', 'clientId', 'sceneCode', 'subject', 'challengeId', 'providerCode', 'captchaType', 'payload')
$missingCheckFields = @($requiredCheckFields | Where-Object { $_ -notin @($check.required) })
if ($missingCheckFields.Count -gt 0) {
$blockers.Add("VerificationCheckBody 未强制字段:$($missingCheckFields -join '、')")
}
if ($check.additionalProperties -ne $false) {
$blockers.Add('VerificationCheckBody 未设置 additionalProperties=false,服务端校验边界仍可接受未声明字段。')
}
if (@($check.oneOf).Count -lt 2 -or $check.discriminator.propertyName -ne 'providerCode') {
$blockers.Add('VerificationCheckBody 未用 providerCode 判别至少两个 oneOf 分支,providerCode、captchaType 与 payload 形态无法被原子约束。')
}
$tianaiPayload = Require-Schema -Name 'TianaiVerificationPayload'
Assert-ExactSet -Actual @($tianaiPayload.required) -Expected @('track') -Label 'TianaiVerificationPayload.required'
if ($tianaiPayload.properties.track.'$ref' -ne '#/components/schemas/TianaiCaptchaTrack') { throw '天爱校验载荷必须唯一包装为 payload.track' }
if ($tianaiPayload.additionalProperties -ne $false) {
$blockers.Add('TianaiVerificationPayload 未设置 additionalProperties=false,历史直传字段仍可能绕过 payload.track 约束。')
}
$systemImagePayload = Require-Schema -Name 'SystemImageVerificationPayload'
if ($systemImagePayload.additionalProperties -ne $false) {
$blockers.Add('SystemImageVerificationPayload 未设置 additionalProperties=false,系统图形验证码载荷边界未闭合。')
}
$track = Require-Schema -Name 'TianaiCaptchaTrack'
Assert-ExactSet -Actual @($track.required) -Expected @('bgImageWidth', 'bgImageHeight', 'startTime', 'stopTime', 'trackList') -Label 'TianaiCaptchaTrack.required'
if ($track.properties.trackList.minItems -ne 1) { throw '天爱行为轨迹不得为空' }
$smsCode = Require-Schema -Name 'SmsCodeBody'
Assert-ExactSet -Actual @($smsCode.required) -Expected @('clientId', 'grantType', 'tenantId', 'sceneCode', 'phone', 'validToken') -Label 'SmsCodeBody.required'
if ($smsCode.additionalProperties -ne $false) { throw 'SmsCodeBody 必须拒绝历史供应商字段' }
$expectedPublicSmsScenes = @('APP_SMS_LOGIN', 'APP_REGISTER', 'APP_FORGOT_PASSWORD', 'APP_ACCOUNT_DEACTIVATE')
$actualPublicSmsScenes = @($smsCode.properties.sceneCode.enum | ForEach-Object { [string]$_ } | Sort-Object -Unique)
$expectedPublicSmsScenes = @($expectedPublicSmsScenes | Sort-Object -Unique)
if (($actualPublicSmsScenes -join ',') -ne ($expectedPublicSmsScenes -join ',')) {
$blockers.Add('公共 SmsCodeBody.sceneCode 必须删除 APP_PHONE_CHANGE;换绑发码只能由需要 SaToken 的专用 /auth/phone/sms/code operation 持有。')
}
$smsSecretProperty = $json.components.schemas.PSObject.Properties['SmsCodeSecret']
if ($null -eq $smsSecretProperty) {
$blockers.Add('缺少全认证场景共用的 SmsCodeSecret;当前四位码必须原子升级为严格六位 ASCII 数字,不能保留 4/6 双接受。')
} else {
$smsSecret = $smsSecretProperty.Value
if ($smsSecret.type -ne 'string' -or $smsSecret.writeOnly -ne $true -or
[int]$smsSecret.minLength -ne 6 -or [int]$smsSecret.maxLength -ne 6 -or
[string]$smsSecret.pattern -ne '^[0-9]{6}$' -or $smsSecret.example) {
$blockers.Add('SmsCodeSecret 必须是无示例、保留前导零的 writeOnly 六位 ASCII 数字字符串。')
if ($issues.Count -eq 0) {
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath $apiPath
$a01 = Get-Content -Raw -Encoding UTF8 -LiteralPath $a01Path
$passwordOwner = [regex]::Match($api, '(?s)async loginWithPassword\(\{ phone, passwordHash \}.*?(?=\s+async loginWithSms)')
if (-not $passwordOwner.Success) {
$issues.Add('missing bounded password login owner')
} else {
foreach ($required in @("url: '/genealogy/app/auth/login'", "grantType: 'password'", 'password: assertPasswordHash(passwordHash)')) {
if (-not $passwordOwner.Value.Contains($required)) { $issues.Add("password login owner missing: $required") }
}
if ($passwordOwner.Value.Contains('validToken')) {
$issues.Add('password login must not upload validToken')
}
}
foreach ($required in @('const preparePasswordLogin = async () =>', 'appApi.loginWithPassword', 'TAC')) {
if (-not $a01.Contains($required)) { $issues.Add("A01 missing password TAC precondition: $required") }
}
$smsOwner = [regex]::Match($api, '(?s)async sendSmsCode\(\{ sceneCode, phone, validToken \}.*?(?=\s+async loginWithPassword)')
if (-not $smsOwner.Success -or -not $smsOwner.Value.Contains('validToken: assertValidToken(validToken)')) {
$issues.Add('SMS owner must remain the sole validToken consumer')
}
}
foreach ($schemaName in @('SmsLoginBody', 'PasswordRegisterBody', 'PasswordResetBody')) {
$schema = Require-Schema -Name $schemaName
$actualRef = [string]$schema.properties.smsCode.'$ref'
if ($actualRef -ne '#/components/schemas/SmsCodeSecret') {
$blockers.Add("$schemaName.smsCode 必须引用唯一 SmsCodeSecret,禁止继续内联四位码或接受双长度。")
if ($issues.Count -eq 0) {
$runtimeOutput = @(& node $runtimePath 2>&1)
if ($LASTEXITCODE -ne 0 -or 'AUTH-API-RUNTIME-SMOKE PASS' -notin $runtimeOutput) {
$issues.Add("password and SMS wire runtime smoke failed: $($runtimeOutput -join ' | ')")
}
}
$passwordLogin = Require-Schema -Name 'PasswordLoginBody'
$passwordFields = @($passwordLogin.properties.PSObject.Properties.Name)
$passwordRequired = @($passwordLogin.required)
if ('validToken' -notin $passwordFields -or 'validToken' -notin $passwordRequired) {
$blockers.Add('PasswordLoginBody 未定义并强制消费 validToken,密码登录无法形成服务端 TAC 闭环,客户端先滑后登录仍可被绕过。')
}
if ($blockers.Count -gt 0) {
$details = $blockers | ForEach-Object { "- $_" }
throw (@(
'AUTH-TAC-OPENAPI-CONTRACT BLOCKED'
$details
'- 关闭条件:后端同步更新同版本 JSON/YAML;校验体按 providerCode 严格区分供应商并拒绝缺字段/多余字段;密码登录原子消费绑定租户、客户端、场景、手机号的一次性 TAC 票据;全活动短信码原子迁移为六位;APP_PHONE_CHANGE 改由专用受保护发码 operation;全部部署到 HTTPS 环境并通过反向用例。'
) -join [Environment]::NewLine)
if ($issues.Count -gt 0) {
Write-Output 'AUTH-TAC-OPENAPI-CONTRACT BLOCKED'
foreach ($issue in $issues) { Write-Output "- $issue" }
Write-Output '- Password login is gated by native TAC on the client and must not send validToken. SMS operations consume their own validToken only.'
exit 1
}
Write-Output 'AUTH-TAC-OPENAPI-CONTRACT PASS'
+3 -3
View File
@@ -23,7 +23,7 @@ foreach ($method in @('getCurrentGenealogyId', 'setCurrentGenealogyId', 'clearCu
}
$api = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'utils/api.js')
foreach ($method in @('unwrapResponse', 'getCaptchaRequirement', 'sendSmsCode', 'loginWithPassword', 'loginWithSms', 'registerWithPassword', 'resetPassword')) {
foreach ($method in @('unwrapResponse', 'getCaptchaRequirement', 'sendSmsCode', 'loginWithPassword', 'loginWithSms', 'registerWithPassword', 'resetPassword', 'changePassword', 'logout')) {
if ($api -notmatch [regex]::Escape($method)) {
throw "Missing auth API method: $method"
}
@@ -71,8 +71,8 @@ if ($api -match 'mock-session-token|mockResult') { throw '认证链路不得生
if (([regex]::Matches($api, "return saveLogin\(result\)")).Count -ne 3) {
throw '密码登录、短信登录与注册必须共用唯一 AppLoginVo 会话适配器'
}
if ($api -notmatch 'import \{ hasRemoteConfig, resolveRuntimeMode, runtimeConfig \}' -or ([regex]::Matches($api, 'requireRemoteAuth\(\)')).Count -ne 6) {
throw '个认证传输必须通过共享运行模式解析器失败关闭'
if ($api -notmatch 'import \{ hasRemoteConfig, resolveRuntimeMode, runtimeConfig \}' -or ([regex]::Matches($api, 'requireRemoteAuth\(\)')).Count -ne 7) {
throw '个认证传输必须通过共享运行模式解析器失败关闭'
}
if ($api -match 'if \(isMockMode\(\)\)') {
throw 'Auth transports must not treat every non-mock mode as remote'
+3 -2
View File
@@ -7,7 +7,8 @@ $currentDocuments = @(
'docs/家谱项目全量治理实施计划.md',
'docs/视觉资产与构建基线.md',
'docs/接口与页面映射总表.md',
'docs/今晚全量联调与明早测试执行计划.md'
'docs/今晚全量联调与明早测试执行计划.md',
'docs/产品参考页面功能映射表.md'
)
$combined = ''
@@ -574,7 +575,7 @@ foreach ($g03BootstrapGateFact in @(
'g03-bootstrap-client-release-gate.ps1',
'G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED',
'openapi-yaml-json-parity-runtime-smoke.js',
'/genealogy/app/region/search',
'/genealogy/region/search',
'GET 必须纯读',
'fatal/quarantined',
'家谱工作区读取门禁',
@@ -449,6 +449,12 @@
"risk": "fixed-content-height",
"reason": "固定尺寸属于已审核的导航、操作触点或结构化节点视觉边界,不作为正文容量上限"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-node__avatar",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束人物头像图标,不承载成员姓名或关系正文"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".tree-page",
@@ -502,5 +508,23 @@
"selector": ".app-dialog",
"risk": "clipping-overflow",
"reason": "弹窗外框只约束共享九宫格边界,正文容量由内部滚动区域完整承载"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".get-code",
"risk": "single-line-truncation",
"reason": "验证码按钮只显示固定短文案或秒数,单行约束保护输入行触点"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".get-code",
"risk": "single-line-truncation",
"reason": "验证码按钮只显示固定短文案或秒数,单行约束保护输入行触点"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".get-code",
"risk": "single-line-truncation",
"reason": "验证码按钮只显示固定短文案或秒数,单行约束保护输入行触点"
}
]
@@ -74,16 +74,6 @@
"selector": ".media-photo-remove",
"reason": "依附照片缩略图右上角的删除操作"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-sheet",
"reason": "用户选择节点后显示的固定底部详情抽屉"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-sheet__skin",
"reason": "固定底部详情抽屉内部的装饰框层"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".recovery-layer",
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -117,8 +117,8 @@ const run = async () => {
"const AUTH_TAC_SCENE = {}; const assertSmsCode = (value) => value;\n",
)
.replace(
/^import \{ GENEALOGY_ACCESS_PRESET \}[^\n]+\r?\n/m,
"const GENEALOGY_ACCESS_PRESET = { MEMBER_ONLY: 'MEMBER_ONLY' };\n",
/^import \{ GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess \}[^\n]+\r?\n/m,
"const GENEALOGY_ACCESS_PRESET = { MEMBER_ONLY: 'MEMBER_ONLY' }; const fromApiGenealogyAccess = () => GENEALOGY_ACCESS_PRESET.MEMBER_ONLY;\n",
)
.replace(
/^import \{ session \}[^\n]+\r?\n/m,
+25 -118
View File
@@ -3,16 +3,11 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$issues = New-Object System.Collections.Generic.List[string]
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Read-RequiredFile {
param([string]$RelativePath)
$path = Join-Path $root $RelativePath
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
Add-Issue "missing required client owner: $RelativePath"
$script:issues.Add("missing required G03 file: $RelativePath")
return ''
}
return Get-Content -Raw -Encoding UTF8 -LiteralPath $path
@@ -21,134 +16,46 @@ function Read-RequiredFile {
function Assert-Contains {
param([string]$Content, [string]$Label, [string]$Expected)
if (-not $Content.Contains($Expected)) {
Add-Issue "$Label missing production ownership marker: $Expected"
$script:issues.Add("$Label missing: $Expected")
}
}
function Assert-DoesNotContain {
param([string]$Content, [string]$Label, [string]$Forbidden)
if ($Content.Contains($Forbidden)) {
Add-Issue "$Label retains retired preview/legacy owner: $Forbidden"
$script:issues.Add("$Label must not claim unsupported remote ownership: $Forbidden")
}
}
$page = Read-RequiredFile 'pages/genealogy/g03-create-genealogy.vue'
$api = Read-RequiredFile 'utils/api.js'
$coordinator = Read-RequiredFile 'utils/genealogy-bootstrap.js'
$accessContract = Read-RequiredFile 'utils/genealogy-contracts.js'
$mock = Read-RequiredFile 'data/mock.js'
$staticContract = Read-RequiredFile 'tests/g03-create-flow-contract.ps1'
$legacyRuntimeSmoke = Read-RequiredFile 'tests/g03-create-flow-runtime-smoke.js'
$runtimePath = Join-Path $root 'tests/g03-bootstrap-runtime-smoke.js'
$flowContract = Read-RequiredFile 'tests/g03-create-flow-contract.ps1'
$flowRuntime = Read-RequiredFile 'tests/g03-create-flow-runtime-smoke.js'
# Keep only durable public ownership checks here. Internal helper names, cache strategy,
# debounce timers, and test titles are intentionally left to executable behavior tests.
Assert-Contains $page 'G03 page' '@/utils/genealogy-bootstrap.js'
Assert-Contains $page 'G03 page' 'createGenealogyBootstrapCoordinator'
Assert-Contains $api 'utils/api.js' '/genealogy/app/genealogies'
Assert-Contains $api 'utils/api.js' '/genealogy/app/genealogy-bootstrap-operations/'
Assert-Contains $api 'utils/api.js' '/genealogy/app/region/search'
Assert-Contains $api 'utils/api.js' 'Idempotency-Key'
Assert-Contains $coordinator 'utils/genealogy-bootstrap.js' 'createGenealogyBootstrapCoordinator'
# Apifox only declares two independent writes. There is no recoverable atomic
# bootstrap or result-query operation, so G03 remains an explicit local preview.
Assert-Contains $page 'G03 page' 'flow-success-dialog__copy'
Assert-Contains $page 'G03 page' 'createLocalGenealogyPreview'
Assert-Contains $page 'G03 page' 'updateLocalGenealogyPreviewAncestor'
Assert-Contains $page 'G03 page' 'removeLocalGenealogyPreview'
Assert-Contains $flowContract 'G03 flow contract' 'createLocalGenealogyPreview'
Assert-Contains $flowRuntime 'G03 runtime smoke' 'local-created-'
foreach ($entry in @(
[pscustomobject]@{ Content = $page; Label = 'G03 page' },
[pscustomobject]@{ Content = $mock; Label = 'data/mock.js' },
[pscustomobject]@{ Content = $staticContract; Label = 'G03 static flow contract' },
[pscustomobject]@{ Content = $legacyRuntimeSmoke; Label = 'G03 legacy runtime smoke' }
foreach ($forbidden in @(
'createGenealogyBootstrapCoordinator',
'genealogy-bootstrap-operations',
'Idempotency-Key',
'/genealogy/app/region/search',
'requestStrict(',
'appApi.'
)) {
foreach ($retiredOwner in @(
'createLocalGenealogyPreview',
'updateLocalGenealogyPreview',
'updateLocalGenealogyPreviewAncestor',
'removeLocalGenealogyPreview',
'local-created-'
)) {
Assert-DoesNotContain $entry.Content $entry.Label $retiredOwner
}
}
foreach ($retiredApiOwner in @('async createGenealogy(', 'genealogies.unshift(created)')) {
Assert-DoesNotContain $api 'utils/api.js' $retiredApiOwner
}
foreach ($retiredAccessOwner in @(
'visibility:',
'joinMode:',
'fromApiGenealogyAccess',
'toApiGenealogyAccess'
)) {
Assert-DoesNotContain $accessContract 'utils/genealogy-contracts.js' $retiredAccessOwner
}
# The release gate executes the dependency-injected state machine suite. The suite must
# deep-compare the exact marker, use request/storage/cache/context/navigation spies, and
# publish a machine-readable case ledger only after all assertions pass.
$expectedCases = @(
'ACCOUNT_EPOCH_ISOLATION',
'CONTEXT_FAILURE_LOCAL_RETRY',
'FAILED_CLEAR',
'FATAL_EXPLICIT_ABANDON_CLEAR',
'KEY_REUSED_QUARANTINE',
'KNOWN_NO_COMMIT',
'LOCAL_ZERO_DISPATCH',
'MARKER_EXACT',
'NAVIGATION_FAILURE_LOCAL_RETRY',
'NO_MOCK_MUTATION',
'NO_NEW_KEY',
'NO_PII',
'PENDING_RETRY_AFTER',
'POST_408_UNKNOWN',
'POST_CANCEL_UNKNOWN',
'POST_UNEXPECTED_STATUS_UNKNOWN',
'POST_UNKNOWN',
'PRECLAIM_400',
'PRECLAIM_401',
'PRECLAIM_403',
'PROCESS_RECOVERY_STATUS_ONLY',
'REQUEST_BUILD_ZERO_DISPATCH',
'RETRY_429',
'STATUS_400_CLEAR',
'STATUS_401_CLEAR_SESSION',
'STATUS_404_KEEP',
'STATUS_429_KEEP',
'STATUS_500_KEEP',
'STATUS_CANCEL_KEEP',
'STATUS_MALFORMED_KEEP',
'STATUS_NETWORK_KEEP',
'STATUS_UNEXPECTED_STATUS_KEEP',
'SUCCEEDED_ORDER'
) | Sort-Object
if (-not (Test-Path -LiteralPath $runtimePath -PathType Leaf)) {
Add-Issue 'missing executable state-machine suite: tests/g03-bootstrap-runtime-smoke.js'
} else {
$runtimeOutput = @(& node $runtimePath 2>&1)
$runtimeExit = $LASTEXITCODE
if ($runtimeExit -ne 0) {
Add-Issue "G03 runtime state-machine suite failed: $($runtimeOutput -join ' | ')"
} else {
if ('G03-BOOTSTRAP-RUNTIME PASS' -notin $runtimeOutput) {
Add-Issue 'G03 runtime state-machine suite did not emit its PASS marker'
}
$coverageLine = @($runtimeOutput | Where-Object { $_ -like 'G03-BOOTSTRAP-RUNTIME-COVERAGE *' })
if ($coverageLine.Count -ne 1) {
Add-Issue 'G03 runtime state-machine suite must emit exactly one coverage ledger'
} else {
$actualCases = @(($coverageLine[0] -replace '^G03-BOOTSTRAP-RUNTIME-COVERAGE\s+', '').Split(',') | Where-Object { $_ } | Sort-Object)
if (($actualCases -join ',') -ne ($expectedCases -join ',')) {
Add-Issue "G03 runtime coverage ledger drifted: $($actualCases -join ',')"
}
}
}
Assert-DoesNotContain $page 'G03 page' $forbidden
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- Keep the honest local preview until backend, workspace, client, and MuMu release gates are green.')
$lines.Add('- Migrate the coordinator, strict transport, marker/status recovery, context, fixtures, focused tests, and docs as one owner change.')
throw ($lines -join [Environment]::NewLine)
Write-Output 'G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED'
foreach ($issue in $issues) { Write-Output "- $issue" }
Write-Output '- Keep the two-step visual preview local until Apifox supplies a recoverable create/result contract with a stable lexical genealogyId.'
exit 1
}
Write-Output 'G03-BOOTSTRAP-CLIENT-RELEASE PASS'
File diff suppressed because it is too large Load Diff
+33 -105
View File
@@ -1,123 +1,51 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
$snapshotPath = Join-Path $root 'APP.openapi.json'
$pagePath = Join-Path $root 'pages/profile/m06-help-center.vue'
$issues = New-Object System.Collections.Generic.List[string]
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
}
function Assert-Required {
param([object]$Schema, [string]$SchemaName, [string[]]$Fields)
if (-not $Schema) { return }
foreach ($field in $Fields) {
if ($field -notin @($Schema.required)) {
Add-Issue "JSON $SchemaName.required missing: $field"
if (-not (Test-Path -LiteralPath $snapshotPath -PathType Leaf)) {
$issues.Add('missing protected OpenAPI snapshot')
} else {
$snapshot = Get-Content -Raw -Encoding UTF8 -LiteralPath $snapshotPath | ConvertFrom-Json
$path = '/genealogy/app/help-articles'
$pathProperty = $snapshot.paths.PSObject.Properties[$path]
$operation = if ($pathProperty) { $pathProperty.Value.PSObject.Properties['get'] } else { $null }
if (-not $operation) {
$issues.Add("protected snapshot does not declare GET $path")
} else {
$response = $operation.Value.responses.PSObject.Properties['200']
$responseValue = if ($response) { $response.Value } else { $null }
if ($responseValue -and $responseValue.'$ref') {
$responseName = ([string]$responseValue.'$ref').Split('/')[-1]
$responseValue = $snapshot.components.responses.PSObject.Properties[$responseName].Value
}
$media = if ($responseValue -and $responseValue.content) { $responseValue.content.PSObject.Properties['application/json'] } else { $null }
$responseRef = if ($media) { [string]$media.Value.schema.'$ref' } else { '' }
if ($responseRef -eq '#/components/schemas/RListHelpArticleVo') {
$issues.Add('protected snapshot asserts RListHelpArticleVo, while current Apifox only declares a generic ListResult item projection')
}
}
}
function Assert-NonEmptyString {
param([object]$Schema, [string]$SchemaName, [string]$Field)
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property) {
Add-Issue "JSON $SchemaName missing property: $Field"
return
}
if ($property.Value.type -ne 'string') {
Add-Issue "JSON $SchemaName.$Field must be string"
}
if ([int]$property.Value.minLength -lt 1) {
Add-Issue "JSON $SchemaName.$Field must declare minLength >= 1"
}
}
$path = '/genealogy/app/help-articles'
$pathProperty = $document.paths.PSObject.Properties[$path]
$operation = if ($pathProperty) { $pathProperty.Value.get } else { $null }
if (-not $operation) {
Add-Issue "JSON missing GET $path"
if (-not (Test-Path -LiteralPath $pagePath -PathType Leaf)) {
$issues.Add('missing M06 page')
} else {
$hasSaToken = $false
foreach ($securityRequirement in @($operation.security)) {
if ($securityRequirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
$page = Get-Content -Raw -Encoding UTF8 -LiteralPath $pagePath
foreach ($required in @('help-source-note', 'openPage("M07", {}, "M06")')) {
if (-not $page.Contains($required)) { $issues.Add("M06 page missing local/help fallback: $required") }
}
if (-not $hasSaToken) { Add-Issue "JSON GET $path must require SaToken" }
$response = $operation.responses.PSObject.Properties['200'].Value
if ($response.'$ref') {
$responseName = ([string]$response.'$ref').Split('/')[-1]
$response = $document.components.responses.PSObject.Properties[$responseName].Value
}
$media = @($response.content.PSObject.Properties)
$responseRef = if ($media.Count -gt 0) { [string]$media[0].Value.schema.'$ref' } else { '' }
if ($responseRef -ne '#/components/schemas/RListHelpArticleVo') {
Add-Issue "JSON GET $path must return RListHelpArticleVo; actual: $responseRef"
}
}
$envelope = Get-Schema 'RListHelpArticleVo'
$article = Get-Schema 'HelpArticleVo'
Assert-Required $envelope 'RListHelpArticleVo' @('code', 'data')
Assert-Required $article 'HelpArticleVo' @('helpCategory', 'helpTitle', 'helpContent')
if ($envelope) {
if ($envelope.properties.code.type -ne 'integer') {
Add-Issue 'JSON RListHelpArticleVo.code must be integer'
}
$data = $envelope.properties.data
if ($data.type -ne 'array' -or $data.items.'$ref' -ne '#/components/schemas/HelpArticleVo') {
Add-Issue 'JSON RListHelpArticleVo.data must be HelpArticleVo[]'
}
}
foreach ($field in @('helpCategory', 'helpTitle', 'helpContent')) {
Assert-NonEmptyString $article 'HelpArticleVo' $field
}
if ($article) {
$contentDescription = [string]$article.properties.helpContent.description
if ($contentDescription -notmatch '(?i)plain[ -]?text') {
Add-Issue 'JSON HelpArticleVo.helpContent must declare plain-text semantics'
}
}
foreach ($yamlFact in @(
' /genealogy/app/help-articles:',
'#/components/schemas/RListHelpArticleVo',
' RListHelpArticleVo:',
' HelpArticleVo:',
' - code',
' - data',
' - helpCategory',
' - helpTitle',
' - helpContent'
)) {
if (-not $yaml.Contains($yamlFact)) {
Add-Issue "YAML fact is missing: $yamlFact"
foreach ($forbidden in @('appApi.getHelp', '/help-articles/', 'helpId')) {
if ($page.Contains($forbidden)) { $issues.Add("M06 page must not invent an independent detail owner: $forbidden") }
}
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('HELP-CENTER-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- M06 will use the complete list response as its only remote owner; the detail endpoint and helpId are not consumed.')
$lines.Add('- Authenticated release tests must still cover published-only ordering, 401, malformed data, empty data, 5xx, timeout, and cancellation.')
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
throw ($lines -join [Environment]::NewLine)
Write-Output 'HELP-CENTER-OPENAPI-CONTRACT BLOCKED'
foreach ($issue in $issues) { Write-Output "- $issue" }
Write-Output '- The list is the only declared remote candidate. Do not add a detail endpoint or consume the protected snapshot schema until Apifox and a real authenticated response agree.'
exit 1
}
Write-Output 'HELP-CENTER-OPENAPI-CONTRACT PASS'
+1 -1
View File
@@ -183,7 +183,7 @@ foreach ($requiredFact in @(
'FAILED_NO_COMMIT',
'G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED',
'G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED',
'/genealogy/app/region/search',
'/genealogy/region/search',
'GET 必须纯读',
'receipt→mine cache→context→G05',
'API-G03-001',
+32
View File
@@ -0,0 +1,32 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -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
foreach ($required in @(
'const normalizeLineageWritePayload = (payload) =>',
"const lineageRelationPath = Object.freeze({",
"FATHER: 'parents'",
"SPOUSE: 'spouses'",
"SIBLING: 'siblings'",
"SON: 'children'",
'async createRelatedPerson(genealogyId, personId, relationType, payload, requestOptions = {})',
'async updatePerson(genealogyId, personId, payload, requestOptions = {})',
'method: ''PUT''',
'requestStrict({'
)) {
if (-not $api.Contains($required)) { throw "Lineage Apifox write contract missing: $required" }
}
foreach ($forbidden in @('relationType: relationType.value', 'sex: addForm.gender', 'sortOrder:')) {
if ($t04.Contains($forbidden)) { throw "T04 must not guess unsupported wire field: $forbidden" }
}
foreach ($required in @('appApi.createRelatedPerson(', 'appApi.createPerson(', 'appApi.updatePerson(', 'failedAction.value = "save"')) {
$source = if ($required -eq 'appApi.updatePerson(' -or $required -eq 'failedAction.value = "save"') { $t05 } else { $t04 }
if (-not $source.Contains($required)) { throw "Lineage write page missing: $required" }
}
Write-Output 'LINEAGE-WRITE-APIFOX-CONTRACT PASS'
+23 -163
View File
@@ -1,177 +1,37 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/api.js')
$page = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'pages/profile/m10-about-settings.vue')
$issues = New-Object System.Collections.Generic.List[string]
$logoutPath = '/genealogy/app/auth/logout'
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
function Require-Text {
param([string]$Source, [string]$Text, [string]$Description)
if (-not $Source.Contains($Text)) { $script:issues.Add($Description) }
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
Require-Text $api 'async logout(requestOptions = {}) {' 'api owner must expose logout'
Require-Text $api "url: '/genealogy/app/auth/logout'" 'logout must use the declared APP logout owner'
Require-Text $api "method: 'DELETE'" 'logout must use DELETE'
Require-Text $api 'requireData: false' 'logout must accept the declared VoidResult envelope'
Require-Text $api 'requestController: requestOptions.requestController ?? null' 'logout must accept page cancellation ownership'
Require-Text $page 'appApi.logout({ requestController: logoutRequestController });' 'M10 must call the shared logout owner'
Require-Text $page 'session.clear();' 'M10 must clear the local session after every request outcome'
Require-Text $page 'return goRoot("A01");' 'M10 must return to A01 after local session clear'
Require-Text $page 'logoutRequestController.abort()' 'M10 must cancel an in-flight request when unloading'
$logoutOwner = [regex]::Match($api, "async logout\(requestOptions = \{\}\) \{[\s\S]*?\n \},\n async ").Value
if (-not $logoutOwner) {
$issues.Add('logout owner boundary is not identifiable')
} elseif ($logoutOwner -match '(?m)^\s*data:') {
$issues.Add('logout must not add a request body')
}
function Get-Response {
param([object]$Operation, [string]$Status)
if (-not $Operation) { return $null }
$property = $Operation.responses.PSObject.Properties[$Status]
if (-not $property) {
Add-Issue "JSON DELETE $logoutPath missing $Status response"
return $null
}
$response = $property.Value
if ($response.'$ref') {
$name = ([string]$response.'$ref').Split('/')[-1]
$owner = $document.components.responses.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON missing response owner: $name"
return $null
}
$response = $owner.Value
}
return $response
}
function Get-ResponseSchemaRef {
param([object]$Operation, [string]$Status)
$response = Get-Response $Operation $Status
if (-not $response) { return '' }
$media = $response.content.PSObject.Properties['application/json']
if (-not $media) {
Add-Issue "JSON DELETE $logoutPath $Status must use application/json"
return ''
}
return [string]$media.Value.schema.'$ref'
}
function Assert-PrivateNoStore {
param([object]$Response, [string]$Status)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
if (-not $property) {
Add-Issue "JSON DELETE $logoutPath $Status must document Cache-Control: private, no-store"
return
}
$header = $property.Value
if ($header.'$ref') {
$name = ([string]$header.'$ref').Split('/')[-1]
$owner = $document.components.headers.PSObject.Properties[$name]
if ($owner) { $header = $owner.Value }
}
$evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example)
if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') {
Add-Issue "JSON DELETE $logoutPath $Status Cache-Control must specify private, no-store"
}
}
$pathProperty = $document.paths.PSObject.Properties[$logoutPath]
$operation = if ($pathProperty) { $pathProperty.Value.delete } else { $null }
if (-not $operation) { Add-Issue "JSON missing DELETE $logoutPath" }
if ($operation) {
$hasSaToken = $false
foreach ($requirement in @($operation.security)) {
if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
}
if (-not $hasSaToken) { Add-Issue "JSON DELETE $logoutPath must require SaToken" }
$clientHeaders = @($operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' })
if ($clientHeaders.Count -ne 1 -or $clientHeaders[0].required -ne $true -or
$clientHeaders[0].schema.type -ne 'string' -or [int]$clientHeaders[0].schema.minLength -lt 1) {
Add-Issue "JSON DELETE $logoutPath must require one non-empty string clientid header"
}
if ($operation.requestBody) { Add-Issue "JSON DELETE $logoutPath must not accept a request body" }
$semantics = [string]$operation.description
foreach ($semanticPattern in @(
'(?i)presented (access token|credential family)',
'(?i)other device sessions remain valid',
'(?i)repeated.*(idempotent|no additional side effects)',
'(?i)active.*revoked.*expired.*same 200',
'(?i)successful revocation.*token.*rejected'
)) {
if ($semantics -notmatch $semanticPattern) {
Add-Issue "JSON DELETE $logoutPath description is missing scope/idempotency semantics: $semanticPattern"
}
}
foreach ($status in @('200', '400', '401', '429', '500')) {
if (-not $operation.responses.PSObject.Properties[$status]) {
Add-Issue "JSON DELETE $logoutPath missing documented response: $status"
}
}
}
$successResponse = Get-Response $operation '200'
$terminalResponse = Get-Response $operation '401'
$successRef = Get-ResponseSchemaRef $operation '200'
$terminalRef = Get-ResponseSchemaRef $operation '401'
if ($successRef -ne '#/components/schemas/RVoid') {
Add-Issue "JSON DELETE $logoutPath 200 must return RVoid; actual: $successRef"
}
if ($terminalRef -ne '#/components/schemas/RLogoutRejected') {
Add-Issue "JSON DELETE $logoutPath 401 must return RLogoutRejected; actual: $terminalRef"
}
Assert-PrivateNoStore $successResponse '200'
Assert-PrivateNoStore $terminalResponse '401'
$void = Get-Schema 'RVoid'
$terminal = Get-Schema 'RLogoutRejected'
if ($void) {
if ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer') {
Add-Issue 'JSON RVoid must require integer code'
}
}
if ($terminal) {
foreach ($field in @('code', 'businessCode')) {
if ($field -notin @($terminal.required)) {
Add-Issue "JSON RLogoutRejected.required missing: $field"
}
}
$codes = @($terminal.properties.businessCode.enum | Sort-Object)
if ($terminal.properties.code.type -ne 'integer' -or
$terminal.properties.businessCode.type -ne 'string' -or
($codes -join ',') -ne 'TOKEN_CLIENT_MISMATCH,TOKEN_INVALID') {
Add-Issue 'JSON RLogoutRejected must expose only TOKEN_CLIENT_MISMATCH/TOKEN_INVALID rejection codes'
}
}
foreach ($yamlFact in @(
' /genealogy/app/auth/logout:',
' delete:',
' name: clientid',
'#/components/schemas/RVoid',
'#/components/schemas/RLogoutRejected',
' RVoid:',
' RLogoutRejected:',
' - TOKEN_CLIENT_MISMATCH',
' - TOKEN_INVALID',
' Cache-Control:'
)) {
if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" }
if ($page -match 'session\.clear\(\);[\s\S]{0,80}appApi\.logout') {
$issues.Add('M10 must not clear local session before starting the remote request')
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('LOGOUT-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- Logout revokes only the presented current-device credential family; other device sessions remain valid.')
$lines.Add('- Runtime proof must show that a token cannot access a protected endpoint after 200, while a second-device token still can.')
$lines.Add('- The client starts DELETE with an in-memory token snapshot, immediately clears local session once, never restores it, and never persists a retry token.')
$lines.Add('- Active, already-revoked, and expired credentials issued for this client all converge to the same 200 RVoid; 401 is only TOKEN_INVALID/TOKEN_CLIENT_MISMATCH and is not success.')
$lines.Add('- Generic 401, network, timeout, malformed responses, and 5xx mean remote revocation is unconfirmed; only a valid 200 response confirms server-side termination.')
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
throw ($lines -join [Environment]::NewLine)
throw ("LOGOUT-OPENAPI-CONTRACT FAIL`n- " + ($issues -join "`n- "))
}
Write-Output 'LOGOUT-OPENAPI-CONTRACT PASS'
+16 -10
View File
@@ -116,7 +116,7 @@ Assert-Matches -Content $a01 -Pattern '(?s)onBackPress\(\(event\) => \{\s*if \(!
Assert-Contains -Content $a04 -Expected 'import AppDialog from "@/components/AppDialog.vue";' -Message 'A04 必须使用项目对话框确认放弃表单'
Assert-Contains -Content $a04 -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message 'A04 必须消费唯一放弃确认控制器'
Assert-Contains -Content $a04 -Expected 'const isDirty = computed(() =>' -Message 'A04 必须计算未保存表单状态'
Assert-Matches -Content $a04 -Pattern '(?s)const requestBack = \(\) =>\s*runBackGuard\(\{\s*transientOpen: tacVisible\.value \|\| discardVisible\.value,\s*submitting: submitting\.value \|\| sendingCode\.value,\s*dirty: isDirty\.value,\s*"close-transient": tacVisible\.value \? closeTac : cancelDiscard,\s*"block-submitting": \(\) => \{\s*cancelPendingRequest\(\);\s*return requestBack\(\);\s*\},\s*"confirm-discard": requestDiscardConfirmation,\s*\}\);' -Message 'A04 requestBack 必须优先关闭 TAC;请求中返回先中止网络,再恢复脏表单守卫'
Assert-Matches -Content $a04 -Pattern '(?s)const requestBack = \(\) =>\s*registrationCommitted\.value\s*\? enterAuthenticatedRoot\(\)\s*:\s*runBackGuard\(\{\s*transientOpen: tacVisible\.value \|\| discardVisible\.value,\s*submitting: submitting\.value \|\| sendingCode\.value,\s*dirty: isDirty\.value,\s*"close-transient": tacVisible\.value \? closeTac : cancelDiscard,\s*"block-submitting": \(\) => \{\s*cancelPendingRequest\(\);\s*return requestBack\(\);\s*\},\s*"confirm-discard": requestDiscardConfirmation,\s*\}\);' -Message 'A04 requestBack 必须先处理已提交状态、再优先关闭 TAC;请求中返回先中止网络,再恢复脏表单守卫'
Assert-Contains -Content $a04 -Expected 'discardConfirmation.dispose();' -Message 'A04 卸载时必须通过唯一控制器释放等待者'
Assert-Count -Content $a04 -Expected '@click="requestBack"' -Count 2 -Message 'A04 页头和已有账号入口必须共用 requestBack'
Assert-Count -Content $a04 -Expected '<AppDialog' -Count 1 -Message 'A04 只允许一个放弃确认框'
@@ -377,12 +377,14 @@ foreach ($entry in @(
foreach ($mapping in @(
'openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
'openPage("T04", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
'openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
'openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id), mode: "rank" }, "T01")',
'openPage("T07", { genealogyId: genealogyId.value }, "T01")'
)) {
Assert-Contains -Content $t01 -Expected $mapping -Message "T01 缺少规范入口:$mapping"
}
foreach ($required in @('routeKey: "T04"', 'params.relationType = action.relationType;', 'return openPage(action.routeKey, params, "T01");')) {
Assert-Contains -Content $t01 -Expected $required -Message "T01 人物操作面板缺少 T04 关系入口:$required"
}
Assert-Contains -Content $t03 -Expected 'consumeNavigationResult("T03")' -Message 'T03 必须消费 single 页面激活请求'
Assert-Contains -Content $t03 -Expected 'internalTrail: trailIndex.value > 0' -Message 'T03 返回必须优先消费页内成员轨迹'
Assert-Contains -Content $t03 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'T03 Android 返回必须复用页内轨迹守卫'
@@ -391,16 +393,20 @@ Assert-Contains -Content $t08 -Expected 'goRoot("G01")' -Message 'T08 权限失
Assert-Contains -Content $t08 -Expected 'goBack()' -Message 'T08 普通动作必须按真实栈返回'
foreach ($form in @(
@{ Key = 'T04'; Content = $t04; Return = 'returnTo("T01", { genealogyId: genealogyId.value })' },
@{ Key = 'T05'; Content = $t05; Return = 'return goBack();' },
@{ Key = 'T06'; Content = $t06; Return = 'returnTo("T01", { genealogyId: genealogyId.value })' }
@{ Key = 'T04'; Content = $t04; Return = 'returnTo("T01", { genealogyId: genealogyId.value })'; Write = 'appApi.createRelatedPerson(' },
@{ Key = 'T05'; Content = $t05; Return = 'return goBack();'; Write = 'appApi.updatePerson(' }
)) {
Assert-Contains -Content $form.Content -Expected '尚未提交服务器' -Message "$($form.Key) 必须明确本地预览没有写入服务器"
Assert-Contains -Content $form.Content -Expected $form.Return -Message "$($form.Key) 本地预览必须无结果返回规范目标"
foreach ($forbidden in @('finishPage(', 'relative-created', 'member-updated', 'relationship-updated')) {
Assert-Contains -Content $form.Content -Expected 'appApi.getPerson(' -Message "$($form.Key) 必须从真实后端读取当前成员"
Assert-Contains -Content $form.Content -Expected 'createRequestController' -Message "$($form.Key) 成员读取必须可取消"
Assert-Contains -Content $form.Content -Expected $form.Write -Message "$($form.Key) 必须使用已核对的远端写入 owner"
Assert-Contains -Content $form.Content -Expected $form.Return -Message "$($form.Key) 写入成功后必须保留规范返回目标"
foreach ($forbidden in @('finishPage(', 'relative-created', 'member-updated', 'relationship-updated', 'setTimeout(')) {
Assert-NotContains -Content $form.Content -Unexpected $forbidden -Message "$($form.Key) 不得在真实写入前伪造结果:$forbidden"
}
}
foreach ($required in @('appApi.getPerson(', 'createRequestController', '"unavailable"', 'goBack()')) {
Assert-Contains -Content $t06 -Expected $required -Message "T06 原子排行合同未收紧时必须显式关闭提交:$required"
}
Assert-Matches -Content $routes -Pattern '(?s)T01:\s*defineRoute\(\{(?:(?!resultOperations).)*?\}\),\s*T03:' -Message 'T01 当前不得预留 mutation 结果能力'
Assert-Matches -Content $routes -Pattern '(?s)T03:\s*defineRoute\(\{.*?resultOperations:\s*\["member-open-requested"\]' -Message 'T03 当前只能登记 single 激活结果'
@@ -669,6 +675,6 @@ foreach ($page in @(
Assert-Contains -Content $page.Content -Expected $token -Message "$($page.Key) 缺少浮层优先返回守卫:$token"
}
}
Assert-Matches -Content $m10 -Pattern '(?s)const confirmLogout = \(\) => \{\s*session\.clear\(\);\s*logoutVisible\.value = false;\s*return goRoot\("A01"\);\s*\};' -Message 'M10 退出必须依次清理唯一会话、关闭浮层并进入 A01 根语义'
Assert-Matches -Content $m10 -Pattern '(?s)const confirmLogout = async \(\) => \{.*?await appApi\.logout\(\{ requestController: logoutRequestController \}\);.*?finally \{\s*session\.clear\(\);\s*logoutVisible\.value = false;.*?\}\s*return goRoot\("A01"\);\s*\};' -Message 'M10 退出必须先尝试唯一远端 owner,再在所有结果下清理本机会话并进入 A01 根语义'
Write-Output 'NAVIGATION-FLOW-CONTRACT PASS AUTH G01-G12 T01-T08 F01-F10 R01-R11 N01-N02 M01-M10'
+75
View File
@@ -0,0 +1,75 @@
{
"version": 1,
"baselineInventory": {
"powershell": 150,
"node": 54
},
"defaultTimeoutSeconds": 120,
"h5ChromeRuntimeTests": [
"tests/a01-responsive-runtime-smoke.js",
"tests/a04-registration-runtime-smoke.js",
"tests/a05-reset-password-runtime-smoke.js",
"tests/data-driven-layout-runtime-smoke.js",
"tests/f08-album-detail-runtime-smoke.js",
"tests/f09-media-upload-layout-smoke.js",
"tests/f09-media-upload-runtime-smoke.js",
"tests/f10-video-status-runtime-smoke.js",
"tests/f-business-flow-runtime-smoke.js",
"tests/g01-empty-state-runtime-smoke.js",
"tests/g01-switch-dialog-runtime-smoke.js",
"tests/g03-create-flow-runtime-smoke.js",
"tests/g05-overview-runtime-smoke.js",
"tests/g06-search-flow-runtime-smoke.js",
"tests/g08-g10-application-flow-runtime-smoke.js",
"tests/g11-g12-settings-poems-runtime-smoke.js",
"tests/module-page-runtime-smoke.js",
"tests/module-series-responsive-runtime-smoke.js",
"tests/nm-business-runtime-smoke.js",
"tests/r02-background-runtime-smoke.js",
"tests/r02-person-detail-runtime-smoke.js",
"tests/r-business-flow-runtime-smoke.js",
"tests/root-pages-runtime-smoke.js",
"tests/t01-tree-state-runtime-smoke.js",
"tests/t03-t08-business-specialization-runtime-smoke.js",
"tests/t03-t08-member-flow-runtime-smoke.js",
"tests/t07-module-baseline-runtime-smoke.js"
],
"t0": [
"tests/compile-audit.ps1",
"tests/t01-person-action-panel-contract.ps1",
"tests/t04-relative-remote-close-contract.ps1",
"tests/t05-member-remote-close-contract.ps1",
"tests/t06-rank-remote-close-contract.ps1",
"tests/auth-android-accessibility-release-gate.ps1",
"tests/auth-tac-openapi-contract.ps1",
"tests/g03-bootstrap-client-release-gate.ps1",
"tests/g03-bootstrap-openapi-contract.ps1",
"tests/g11-settings-openapi-contract.ps1",
"tests/g12-generation-poem-openapi-contract.ps1",
"tests/genealogy-workspace-openapi-contract.ps1",
"tests/help-center-openapi-contract.ps1",
"tests/invite-ticket-openapi-contract.ps1",
"tests/join-application-openapi-contract.ps1",
"tests/lineage-openapi-contract.ps1",
"tests/notification-read-openapi-contract.ps1",
"tests/notification-read-state-openapi-contract.ps1",
"tests/phone-change-openapi-contract.ps1",
"tests/profile-openapi-contract.ps1",
"tests/profile-update-openapi-contract.ps1",
"tests/t03-member-remote-contract.ps1"
],
"expectedBlocked": [
{ "script": "tests/auth-android-accessibility-release-gate.ps1", "marker": "ANDROID-AUTH-ACCESSIBILITY-RELEASE BLOCKED" },
{ "script": "tests/g11-settings-openapi-contract.ps1", "marker": "G11-SETTINGS-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/g12-generation-poem-openapi-contract.ps1", "marker": "G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/genealogy-workspace-openapi-contract.ps1", "marker": "GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/invite-ticket-openapi-contract.ps1", "marker": "INVITE-TICKET-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/join-application-openapi-contract.ps1", "marker": "JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/lineage-openapi-contract.ps1", "marker": "LINEAGE-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/notification-read-openapi-contract.ps1", "marker": "NOTIFICATION-READ-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/notification-read-state-openapi-contract.ps1", "marker": "NOTIFICATION-READ-STATE-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/phone-change-openapi-contract.ps1", "marker": "PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/profile-openapi-contract.ps1", "marker": "PROFILE-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/profile-update-openapi-contract.ps1", "marker": "PROFILE-UPDATE-OPENAPI-CONTRACT BLOCKED" }
]
}
+182
View File
@@ -0,0 +1,182 @@
param(
[ValidateSet('T0', 'ALL')]
[string]$Tier = 'T0',
[string]$OutputDir = ''
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$manifestPath = Join-Path $PSScriptRoot 'night-run.manifest.json'
$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
if (-not $OutputDir) {
$OutputDir = Join-Path ([System.IO.Path]::GetTempPath()) ('jiapuapp-night-run-' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
}
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$inventory = @(
Get-ChildItem -LiteralPath $PSScriptRoot -File -Recurse |
Where-Object { $_.Extension -in '.ps1', '.js' -and $_.Name -ne 'night-run.ps1' } |
ForEach-Object { $_.FullName.Substring($root.Length + 1).Replace('\', '/') } |
Sort-Object
)
$powerShellCount = @($inventory | Where-Object { $_.EndsWith('.ps1') }).Count
$nodeCount = @($inventory | Where-Object { $_.EndsWith('.js') }).Count
$newExecutableTests = $inventory.Count - $manifest.baselineInventory.powershell - $manifest.baselineInventory.node
if ($newExecutableTests -lt 0) {
throw "夜跑 inventory 少于冻结基线:actual=$($inventory.Count) baseline=$($manifest.baselineInventory.powershell + $manifest.baselineInventory.node)"
}
$expectedBlocked = @{}
foreach ($entry in @($manifest.expectedBlocked)) {
$expectedBlocked[$entry.script] = [string]$entry.marker
}
$h5ChromeRuntimeTests = @{}
foreach ($script in @($manifest.h5ChromeRuntimeTests)) {
$h5ChromeRuntimeTests[[string]$script] = $true
}
if ($Tier -eq 'T0') {
$selected = @($manifest.t0 | Sort-Object -Unique)
} else {
$selected = $inventory
}
$unknown = @($selected | Where-Object { $_ -notin $inventory })
if ($unknown.Count -gt 0) {
throw "夜跑清单引用不存在的测试:$($unknown -join '、')"
}
function Test-H5ChromeRuntime {
try {
$pages = Invoke-RestMethod -Uri 'http://127.0.0.1:9222/json/list' -TimeoutSec 2
return @($pages | Where-Object {
$_.type -eq 'page' -and $_.url -like 'http://localhost:5173*'
}).Count -gt 0
} catch {
return $false
}
}
$needsH5ChromeRuntime = @($selected | Where-Object { $h5ChromeRuntimeTests.ContainsKey($_) }).Count -gt 0
$h5ChromeRuntimeReady = -not $needsH5ChromeRuntime -or (Test-H5ChromeRuntime)
function Test-ExactLine {
param([string]$Text, [string]$Marker)
return [regex]::IsMatch($Text, "(?m)^$([regex]::Escape($Marker))`r?$")
}
function Invoke-ManagedTest {
param([string]$RelativePath)
$startedAt = Get-Date
if ($h5ChromeRuntimeTests.ContainsKey($RelativePath) -and -not $h5ChromeRuntimeReady) {
return [pscustomobject]@{
script = $RelativePath
startedAt = $startedAt.ToString('o')
finishedAt = (Get-Date).ToString('o')
durationMs = 0
exitCode = -2
checkResult = 'INFRA_ERROR'
expectedBlockedMarker = $null
infraReason = 'H5_CHROME_RUNTIME_UNAVAILABLE'
outputPath = $null
errorPath = $null
}
}
$absolutePath = Join-Path $root $RelativePath.Replace('/', '\')
$outputPath = Join-Path $OutputDir (($RelativePath -replace '[\\/]', '__') + '.stdout.txt')
$errorPath = Join-Path $OutputDir (($RelativePath -replace '[\\/]', '__') + '.stderr.txt')
$fileName = if ($RelativePath.EndsWith('.ps1')) { 'powershell.exe' } else { 'node.exe' }
$arguments = if ($RelativePath.EndsWith('.ps1')) {
@('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $absolutePath)
} else {
@($absolutePath)
}
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $fileName
$startInfo.Arguments = (($arguments | ForEach-Object {
'"' + ([string]$_).Replace('"', '\"') + '"'
}) -join ' ')
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$process = [System.Diagnostics.Process]::new()
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw "无法启动夜跑子进程:$RelativePath"
}
$standardOutputTask = $process.StandardOutput.ReadToEndAsync()
$standardErrorTask = $process.StandardError.ReadToEndAsync()
$timedOut = -not $process.WaitForExit([int]$manifest.defaultTimeoutSeconds * 1000)
if ($timedOut) {
$process.Kill()
$process.WaitForExit()
}
$exitCode = if ($timedOut) { -1 } else { [int]$process.ExitCode }
$standardOutput = $standardOutputTask.GetAwaiter().GetResult()
$standardError = $standardErrorTask.GetAwaiter().GetResult()
[System.IO.File]::WriteAllText($outputPath, $standardOutput, [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($errorPath, $standardError, [System.Text.UTF8Encoding]::new($false))
$text = ($standardOutput + "`n" + $standardError).Trim()
$marker = $expectedBlocked[$RelativePath]
$result = if ($timedOut) {
'INFRA_ERROR'
} elseif ($marker) {
$passMarker = $marker -replace ' BLOCKED$', ' PASS'
if ($exitCode -eq 0 -and (Test-ExactLine $text $passMarker)) { 'PASS' }
elseif ($exitCode -ne 0 -and (Test-ExactLine $text $marker)) { 'EXPECTED_BLOCKED' }
else { 'FAIL' }
} elseif ($exitCode -eq 0) {
'PASS'
} else {
'FAIL'
}
return [pscustomobject]@{
script = $RelativePath
startedAt = $startedAt.ToString('o')
finishedAt = (Get-Date).ToString('o')
durationMs = [int]((Get-Date) - $startedAt).TotalMilliseconds
exitCode = $exitCode
checkResult = $result
expectedBlockedMarker = $marker
outputPath = $outputPath
errorPath = $errorPath
}
}
$records = @()
foreach ($test in $selected) {
$record = Invoke-ManagedTest $test
$records += $record
$checkpoint = [pscustomobject]@{
baselineInventoryTests = [int]$manifest.baselineInventory.powershell + [int]$manifest.baselineInventory.node
newExecutableTests = $newExecutableTests
inventoryTests = $inventory.Count
selectedTests = $selected.Count
records = $records
}
$checkpoint | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $OutputDir 'checkpoint.json') -Encoding UTF8
Write-Output ("{0} {1} exit={2}" -f $record.checkResult, $record.script, $record.exitCode)
}
$summary = [pscustomobject]@{
baselineInventoryTests = [int]$manifest.baselineInventory.powershell + [int]$manifest.baselineInventory.node
newExecutableTests = $newExecutableTests
inventoryTests = $inventory.Count
scheduledTests = $selected.Count
executedTests = $records.Count
passTests = @($records | Where-Object checkResult -eq 'PASS').Count
failTests = @($records | Where-Object checkResult -eq 'FAIL').Count
expectedBlockedTests = @($records | Where-Object checkResult -eq 'EXPECTED_BLOCKED').Count
infraErrorTests = @($records | Where-Object checkResult -eq 'INFRA_ERROR').Count
timedOutTests = @($records | Where-Object { $_.exitCode -eq -1 }).Count
notRunScheduledTests = 0
notRunTests = $inventory.Count - $selected.Count
outputDir = $OutputDir
}
$summary | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $OutputDir 'summary.json') -Encoding UTF8
Write-Output ('NIGHT-RUN-SUMMARY ' + ($summary | ConvertTo-Json -Compress))
if ($summary.infraErrorTests -gt 0) { exit 4 }
if ($summary.failTests -gt 0) { exit 2 }
exit 0
+25 -5
View File
@@ -9,7 +9,7 @@ $contracts = [ordered]@{
'pages/profile/m03-security-settings.vue' = @('securityItems', 'openSecurityItem', 'device-state--limited', 'checkSecurity', 'routeKey: "M04"', 'routeKey: "M05"')
'pages/profile/m04-change-password.vue' = @('passwordForm', 'validatePassword', 'togglePassword', 'savePassword', 'password-state--saving')
'pages/profile/m05-change-phone.vue' = @('phoneForm', 'sendCode', 'savePhone', 'phone-state--saving')
'pages/profile/m06-help-center.vue' = @('helpCategories', 'filteredQuestions', 'toggleQuestion', 'contactSupport', 'openPage("M07", {}, "M06")')
'pages/profile/m06-help-center.vue' = @('helpCategories', 'filteredQuestions', 'toggleQuestion', 'contactSupport', 'help-source-note', 'openPage("M07", {}, "M06")')
'pages/profile/m07-feedback.vue' = @('feedbackForm', 'feedbackTypes', 'validateFeedback', 'submitFeedback', 'feedback-state--submitting')
'pages/profile/m08-promotion.vue' = @('inviteState', 'openInviteExplanation', 'share-state--unavailable', 'explanationVisible')
'pages/profile/m09-vip-orders.vue' = @('serviceBenefits', 'orderState', 'order-state--unavailable', 'openServiceNotice')
@@ -22,7 +22,7 @@ foreach ($entry in $contracts.GetEnumerator()) {
foreach ($forbidden in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet', 'uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', 'getCurrentPages(', '/pages/')) {
if ($source.Contains($forbidden)) { throw "$($entry.Key) contains forbidden token: $forbidden" }
}
if ($entry.Key -ne 'pages/profile/m07-feedback.vue' -and $source.Contains('@/utils/api.js')) {
if ($entry.Key -notin @('pages/profile/m04-change-password.vue', 'pages/profile/m07-feedback.vue', 'pages/profile/m10-about-settings.vue') -and $source.Contains('@/utils/api.js')) {
throw "$($entry.Key) must not consume the API before its independent interface batch"
}
foreach ($required in @('ModulePageBackground', 'PageHeader', 'AppButton') + $entry.Value) {
@@ -39,8 +39,10 @@ $n01 = $sources['pages/notification/n01-message-center.vue']
$n02 = $sources['pages/notification/n02-message-detail.vue']
$m02 = $sources['pages/profile/m02-edit-profile.vue']
$m03 = $sources['pages/profile/m03-security-settings.vue']
$m04 = $sources['pages/profile/m04-change-password.vue']
$m08 = $sources['pages/profile/m08-promotion.vue']
$m10 = $sources['pages/profile/m10-about-settings.vue']
$m10 = $sources['pages/profile/m10-about-settings.vue']
if ($n01 -notmatch '(?s)openPage\(\s*"N02",\s*\{ id: String\(item\.id\) \},\s*"N01",?\s*\)') {
throw 'N01 must open N02 with only its registered lexical notice identity'
@@ -97,7 +99,6 @@ foreach ($formPath in @(
foreach ($previewContract in @(
@{ Path = 'pages/profile/m02-edit-profile.vue'; Retired = @('个人资料已保存') },
@{ Path = 'pages/profile/m04-change-password.vue'; Retired = @('密码已修改', 'passwordForm.current = ""', 'passwordForm.next = ""', 'passwordForm.confirm = ""') },
@{ Path = 'pages/profile/m05-change-phone.vue'; Retired = @('演示验证码已发送', '绑定手机号已更新', 'phoneForm.currentPhone =') }
)) {
$source = $sources[$previewContract.Path]
@@ -109,6 +110,25 @@ foreach ($previewContract in @(
}
}
foreach ($required in @(
'appApi.changePassword',
'calcMD5(passwordForm.current)',
'calcMD5(passwordForm.next)',
'createRequestController',
'passwordRequestController.abort()',
'isRequestCancelled(error)',
'确认修改密码'
)) {
if (-not $m04.Contains($required)) { throw "M04 declared password change contract missing: $required" }
}
foreach ($forbidden in @('校验新密码(不提交)', '本地校验通过,尚未提交服务器')) {
if ($m04.Contains($forbidden)) { throw "M04 must not retain local-only password change copy: $forbidden" }
}
foreach ($required in @('appApi.logout', 'logoutRequestController', 'session.clear();', 'return goRoot("A01");')) {
if (-not $m10.Contains($required)) { throw "M10 declared logout contract missing: $required" }
}
foreach ($dialogPath in @(
'pages/profile/m08-promotion.vue',
'pages/profile/m09-vip-orders.vue',
@@ -152,8 +172,8 @@ foreach ($forbiddenOrderPreview in @('query.state', '演示订单', '¥0.00', 'o
if ($m10 -notmatch 'import \{ session \} from "@/utils/session\.js";') {
throw 'M10 must consume the unique session owner'
}
if ($m10 -notmatch '(?s)const confirmLogout = \(\) => \{\s*session\.clear\(\);\s*logoutVisible\.value = false;\s*return goRoot\("A01"\);\s*\};') {
throw 'M10 logout order must be session.clear -> close dialog -> goRoot(A01)'
if ($m10 -notmatch '(?s)const confirmLogout = async \(\) => \{.*?await appApi\.logout\(\{ requestController: logoutRequestController \}\);.*?finally \{\s*session\.clear\(\);\s*logoutVisible\.value = false;.*?\}\s*return goRoot\("A01"\);\s*\};') {
throw 'M10 logout must attempt the declared remote owner, then clear the local session and enter A01 in every result'
}
if (([regex]::Matches($m10, 'session\.clear\(\)')).Count -ne 1) {
throw 'M10 cancellation or back paths must never clear the session'
+23 -245
View File
@@ -1,259 +1,37 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/api.js')
$page = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'pages/profile/m04-change-password.vue')
$issues = New-Object System.Collections.Generic.List[string]
$passwordPath = '/genealogy/app/auth/password'
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
function Require-Text {
param([string]$Source, [string]$Text, [string]$Description)
if (-not $Source.Contains($Text)) { $script:issues.Add($Description) }
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
Require-Text $api 'async changePassword({ oldPasswordHash, newPasswordHash }, requestOptions = {}) {' 'api owner must expose password change'
Require-Text $api "url: '/genealogy/app/auth/password'" 'password change must use the declared APP password owner'
Require-Text $api "method: 'PUT'" 'password change must use PUT'
Require-Text $api 'oldPassword: assertPasswordHash(oldPasswordHash)' 'old password must be the declared 32-character digest'
Require-Text $api 'newPassword: assertPasswordHash(newPasswordHash)' 'new password must be the declared 32-character digest'
Require-Text $api 'requireData: false' 'password change must accept the declared VoidResult envelope'
Require-Text $api 'requestController: requestOptions.requestController ?? null' 'password change must accept page cancellation ownership'
Require-Text $page 'appApi.changePassword({' 'M04 must call the shared password owner'
Require-Text $page 'oldPasswordHash: calcMD5(passwordForm.current)' 'M04 must hash the current password before transport'
Require-Text $page 'newPasswordHash: calcMD5(passwordForm.next)' 'M04 must hash the new password before transport'
Require-Text $page 'passwordRequestController.abort()' 'M04 must cancel an in-flight request when unloading'
Require-Text $page 'isRequestCancelled(error)' 'M04 must not report a cancelled request as a password-change failure'
if ($api -match "url: '/genealogy/app/auth/password'[\s\S]{0,260}passwordForm\.") {
$issues.Add('password API owner must not depend on page form state')
}
function Get-Response {
param([object]$Operation, [string]$Status)
if (-not $Operation) { return $null }
$property = $Operation.responses.PSObject.Properties[$Status]
if (-not $property) {
Add-Issue "JSON PUT $passwordPath missing $Status response"
return $null
}
$response = $property.Value
if ($response.'$ref') {
$name = ([string]$response.'$ref').Split('/')[-1]
$owner = $document.components.responses.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON missing response owner: $name"
return $null
}
$response = $owner.Value
}
return $response
}
function Get-JsonSchemaRef {
param([object]$Response, [string]$Status)
if (-not $Response) { return '' }
$media = $Response.content.PSObject.Properties['application/json']
if (-not $media) {
Add-Issue "JSON PUT $passwordPath $Status must use application/json"
return ''
}
return [string]$media.Value.schema.'$ref'
}
function Assert-PrivateNoStore {
param([object]$Response, [string]$Status)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
if (-not $property) {
Add-Issue "JSON PUT $passwordPath $Status must document Cache-Control: private, no-store"
return
}
$header = $property.Value
if ($header.'$ref') {
$name = ([string]$header.'$ref').Split('/')[-1]
$owner = $document.components.headers.PSObject.Properties[$name]
if ($owner) { $header = $owner.Value }
}
$evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example)
if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') {
Add-Issue "JSON PUT $passwordPath $Status Cache-Control must specify private, no-store"
}
}
function Assert-SecretSchema {
param(
[object]$Schema,
[string]$Name,
[int]$Minimum,
[int]$Maximum
)
if (-not $Schema) { return }
if ($Schema.type -ne 'string' -or $Schema.format -ne 'password' -or $Schema.writeOnly -ne $true -or
[int]$Schema.minLength -ne $Minimum -or [int]$Schema.maxLength -ne $Maximum) {
Add-Issue "JSON $Name must be a writeOnly password string of $Minimum..$Maximum Unicode code points"
}
if ($Schema.pattern -or $Schema.example -or ([string]$Schema.description) -match '(?i)MD5|hex|字母.*数字|数字.*字母') {
Add-Issue "JSON $Name must not retain a static digest, composition rule, pattern, or password example"
}
if (([string]$Schema.description) -notmatch '(?i)Unicode code point' -or
([string]$Schema.description) -notmatch '(?i)NFC' -or
([string]$Schema.description) -notmatch '(?i)(space|空格)') {
Add-Issue "JSON $Name must define Unicode code-point length, NFC normalization, and space handling"
}
}
function Assert-FieldRef {
param([string]$SchemaName, [string]$Field, [string]$ExpectedRef)
$schema = Get-Schema $SchemaName
if (-not $schema) { return }
$property = $schema.properties.PSObject.Properties[$Field]
$actual = if ($property) { [string]$property.Value.'$ref' } else { '' }
if ($actual -ne $ExpectedRef) {
Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef; actual: $actual"
}
}
$pathProperty = $document.paths.PSObject.Properties[$passwordPath]
$operation = if ($pathProperty) { $pathProperty.Value.put } else { $null }
if (-not $operation) { Add-Issue "JSON missing PUT $passwordPath" }
if ($operation) {
$hasSaToken = $false
foreach ($requirement in @($operation.security)) {
if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
}
if (-not $hasSaToken) { Add-Issue "JSON PUT $passwordPath must require SaToken" }
$clientHeaders = @($operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' })
if ($clientHeaders.Count -ne 1 -or $clientHeaders[0].required -ne $true -or
$clientHeaders[0].schema.type -ne 'string' -or [int]$clientHeaders[0].schema.minLength -lt 1) {
Add-Issue "JSON PUT $passwordPath must require one non-empty string clientid header"
}
$requestMedia = $operation.requestBody.content.PSObject.Properties['application/json']
if ($operation.requestBody.required -ne $true -or -not $requestMedia) {
Add-Issue "JSON PUT $passwordPath must require an application/json body"
} elseif ($requestMedia.Value.schema.'$ref' -ne '#/components/schemas/PasswordChangeBody') {
Add-Issue 'JSON password change request must use PasswordChangeBody'
}
$semantics = [string]$operation.description
foreach ($semanticPattern in @(
'(?i)current password.*re-authentication',
'(?i)atomic.*password.*credential epoch',
'(?i)all.*access.*refresh.*sessions.*including.*current',
'(?i)200.*sessions.*invalidated',
'(?i)new password.*different.*current password',
'(?i)(common|breached) password.*blocklist',
'(?i)rate limit'
)) {
if ($semantics -notmatch $semanticPattern) {
Add-Issue "JSON PUT $passwordPath description is missing security/session semantics: $semanticPattern"
}
}
foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) {
if (-not $operation.responses.PSObject.Properties[$status]) {
Add-Issue "JSON PUT $passwordPath missing documented response: $status"
}
}
}
$changeBody = Get-Schema 'PasswordChangeBody'
$currentSecret = Get-Schema 'CurrentPasswordSecret'
$newSecret = Get-Schema 'NewPasswordSecret'
Assert-SecretSchema $currentSecret 'CurrentPasswordSecret' 1 64
Assert-SecretSchema $newSecret 'NewPasswordSecret' 15 64
if ($changeBody) {
$properties = @($changeBody.properties.PSObject.Properties.Name | Sort-Object)
$required = @($changeBody.required | Sort-Object)
if ($changeBody.type -ne 'object' -or $changeBody.additionalProperties -ne $false -or
($properties -join ',') -ne 'newPassword,oldPassword' -or
($required -join ',') -ne 'newPassword,oldPassword') {
Add-Issue 'JSON PasswordChangeBody must be a closed object requiring only oldPassword/newPassword'
}
}
# 密码传输是跨登录、注册、找回和登录态改密的单一合同。禁止只让 M04 改成明文,
# 其余入口继续接受可重放摘要;新合同落地时必须一次删除全部 MD5 wire fallback。
Assert-FieldRef 'PasswordLoginBody' 'password' '#/components/schemas/CurrentPasswordSecret'
Assert-FieldRef 'PasswordRegisterBody' 'password' '#/components/schemas/NewPasswordSecret'
Assert-FieldRef 'PasswordResetBody' 'newPassword' '#/components/schemas/NewPasswordSecret'
Assert-FieldRef 'PasswordChangeBody' 'oldPassword' '#/components/schemas/CurrentPasswordSecret'
Assert-FieldRef 'PasswordChangeBody' 'newPassword' '#/components/schemas/NewPasswordSecret'
$responses = @{}
foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) {
$responses[$status] = Get-Response $operation $status
[void](Get-JsonSchemaRef $responses[$status] $status)
Assert-PrivateNoStore $responses[$status] $status
}
if ((Get-JsonSchemaRef $responses['200'] '200') -ne '#/components/schemas/RVoid') {
Add-Issue 'JSON PUT password 200 must return RVoid after all sessions are invalidated'
}
foreach ($status in @('409', '422')) {
if ((Get-JsonSchemaRef $responses[$status] $status) -ne '#/components/schemas/RPasswordChangeRejected') {
Add-Issue "JSON PUT password $status must return RPasswordChangeRejected"
}
}
$retryAfterProperty = if ($responses['429'] -and $responses['429'].headers) {
$responses['429'].headers.PSObject.Properties['Retry-After']
} else { $null }
if (-not $retryAfterProperty) {
Add-Issue 'JSON PUT password 429 must document Retry-After'
}
$void = Get-Schema 'RVoid'
$rejected = Get-Schema 'RPasswordChangeRejected'
if ($void -and ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer')) {
Add-Issue 'JSON RVoid must require integer code'
}
if ($rejected) {
foreach ($field in @('code', 'businessCode')) {
if ($field -notin @($rejected.required)) { Add-Issue "JSON RPasswordChangeRejected.required missing: $field" }
}
$codes = @($rejected.properties.businessCode.enum | Sort-Object)
$expectedCodes = @(
'CREDENTIAL_VERSION_CONFLICT',
'CURRENT_PASSWORD_INCORRECT',
'NEW_PASSWORD_SAME_AS_CURRENT',
'PASSWORD_POLICY_VIOLATION'
) | Sort-Object
if ($rejected.properties.code.type -ne 'integer' -or
$rejected.properties.businessCode.type -ne 'string' -or
($codes -join ',') -ne ($expectedCodes -join ',')) {
Add-Issue 'JSON RPasswordChangeRejected must expose the four stable conflict/validation business codes'
}
}
foreach ($yamlFact in @(
' /genealogy/app/auth/password:',
' name: clientid',
'#/components/schemas/PasswordChangeBody',
'#/components/schemas/CurrentPasswordSecret',
'#/components/schemas/NewPasswordSecret',
'#/components/schemas/RPasswordChangeRejected',
' CurrentPasswordSecret:',
' NewPasswordSecret:',
' writeOnly: true',
' minLength: 15',
' maxLength: 64',
' RPasswordChangeRejected:',
' - CREDENTIAL_VERSION_CONFLICT',
' - CURRENT_PASSWORD_INCORRECT',
' - NEW_PASSWORD_SAME_AS_CURRENT',
' - PASSWORD_POLICY_VIOLATION',
' Cache-Control:',
' Retry-After:'
)) {
if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" }
if ($page -notmatch 'oldPasswordHash:\s*calcMD5\(passwordForm\.current\)[\s\S]{0,160}newPasswordHash:\s*calcMD5\(passwordForm\.next\)') {
$issues.Add('M04 must pass only the MD5 digests to the API owner')
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('PASSWORD-CHANGE-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- Remove static MD5 from login/register/reset/change in one contract migration; accept raw writeOnly passwords only over authenticated HTTPS and store a salted adaptive server-side hash.')
$lines.Add('- The target new-password policy is 15..64 Unicode code points, NFC, spaces allowed, no composition rule, plus server-side common/breached-password blocklist and rate limiting.')
$lines.Add('- A strict 200 means the password is durable and every pre-existing access/refresh session, including the caller, is invalidated; the client clears locally and returns to A01.')
$lines.Add('- Network, timeout, malformed response, or 5xx after dispatch is outcome-unknown: clear secrets/session, return to A01, and never retry automatically or claim success.')
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
throw ($lines -join [Environment]::NewLine)
throw ("PASSWORD-CHANGE-OPENAPI-CONTRACT FAIL`n- " + ($issues -join "`n- "))
}
Write-Output 'PASSWORD-CHANGE-OPENAPI-CONTRACT PASS'
+1
View File
@@ -79,6 +79,7 @@
{ "path": "pages/tree/t01-tree-overview.vue", "selector": ".generation-band", "property": "fixed-height", "reason": "Fixed-height generation lane in the scrollable tree canvas." },
{ "path": "pages/tree/t01-tree-overview.vue", "selector": ".member-node", "property": "fixed-height", "reason": "Fixed-ratio interactive tree node." },
{ "path": "pages/tree/t01-tree-overview.vue", "selector": ".member-node--selected", "property": "fixed-height", "reason": "Selected state preserves the fixed-ratio tree node." },
{ "path": "pages/tree/t01-tree-overview.vue", "selector": ".member-node__avatar", "property": "fixed-height", "reason": "Fixed-ratio member avatar icon." },
{ "path": "pages/tree/t03-member-profile.vue", "selector": ".member-heading__seal", "property": "fixed-height", "reason": "Fixed-ratio member seal." }
]
}
+5 -6
View File
@@ -7,11 +7,11 @@ foreach ($required in @(
'<AppButton',
'type="secondary"',
'@click="toMember"',
'@click="toAddRelative"',
't01-member-node-standard.png',
't01-member-node-selected.png',
't01-state-panel.png',
't01-member-drawer.png',
'openMemberPanel(member)',
'member-action-profile',
'v-for="connector in lineageConnectors"',
'lineage-pan-cue',
'const treeScrollLeft = ref(90)',
@@ -35,7 +35,7 @@ foreach ($rule in @(
'(?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)\.sheet-meta\s*\{[^}]*font-size:\s*22rpx;',
'(?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;',
@@ -45,9 +45,8 @@ foreach ($rule in @(
'(?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-sheet\s*\{[^}]*min-height:\s*240rpx;',
'(?s)\.sheet-actions\s*\{[^}]*justify-content:\s*center;[^}]*margin-top:\s*auto;',
'(?s)\.sheet-action\s*\{[^}]*width:\s*250rpx;[^}]*min-height:\s*88rpx;'
'(?s)\.member-action-profile\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;',
'(?s)\.member-action-profile__avatar\s*\{[^}]*width:\s*82rpx;[^}]*aspect-ratio:\s*1;[^}]*flex:\s*0 0 82rpx;'
)) {
if ($page -notmatch $rule) { throw "T01 readable visual rule missing: $rule" }
}
@@ -0,0 +1,49 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-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"',
'const memberActionPanelVisible = ref(false)',
'const memberActions = Object.freeze([',
'const openMemberAction = (action) =>',
'<AppDialog',
'member-action-grid',
'unavailableActionVisible',
'BIND_INVITE'
)) {
if (-not $page.Contains($required)) {
throw "T01 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'
)) {
if (-not $page.Contains(('key: "' + $actionKey + '"'))) {
throw "T01 member action panel missing action: $actionKey"
}
}
foreach ($required in @(
'optionalParams: ["personId", "mode", "relationType"]',
'allowedSources: ["T01", "T03"]',
'optionalParams: ["mode"]'
)) {
if (-not $routes.Contains($required)) {
throw "T01 member action route contract missing: $required"
}
}
Write-Output 'T01-PERSON-ACTION-PANEL-CONTRACT PASS'
@@ -1,9 +1,8 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/tree/t01-tree-overview.vue'), [System.Text.Encoding]::UTF8)
foreach ($selector in @('.tree-state-card','.member-sheet__copy','.sheet-actions')) {
foreach ($selector in @('.tree-state-card','.member-action-profile','.member-action-profile__copy')) {
$escaped = [regex]::Escape($selector)
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "T01 normal sheet/state content uses positioning: $selector" }
}
if (-not $page.Contains('margin-top: auto')) { throw 'T01 fixed member sheet actions must use flex flow' }
Write-Output 'T01-SHEET-STATE-DOCUMENT-FLOW-CONTRACT PASS'
+4 -3
View File
@@ -60,9 +60,10 @@ foreach ($required in @(
't01-member-node-standard.png',
't01-member-node-selected.png',
't01-state-panel.png',
't01-member-drawer.png',
'class="member-action-profile"',
'const openMemberPanel = (member) =>',
'openPage("T03"',
'openPage("T04"',
'routeKey: "T04"',
'openPage("T06"',
'openPage("T07"',
'query.genealogyId',
@@ -101,7 +102,7 @@ foreach ($forbidden in @(
if ($page -match [regex]::Escape($forbidden)) { throw "T01 must not keep opaque surface asset: $forbidden" }
}
foreach ($className in @('tree-canvas', 'member-node', 'member-sheet', 'tree-state-card')) {
foreach ($className in @('tree-canvas', 'member-node', 'member-action-profile', 'tree-state-card')) {
Assert-NoCssSurface $page $className
}
+1 -1
View File
@@ -144,7 +144,7 @@ const run = async () => {
await open(send, '?genealogyId=1001', '.tree-state--tree .member-node')
await valueOf(send, "document.querySelector('.member-node')?.click()")
await valueOf(send, "document.querySelector('.member-sheet .app-button')?.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')
} finally {
+15 -25
View File
@@ -1,40 +1,30 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$profile = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t03-member-profile.vue') -Raw -Encoding utf8
$forms = [ordered]@{
'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
'T06' = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t06-edit-relationship.vue') -Raw -Encoding utf8
$pages = @{
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
T06 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t06-edit-relationship.vue') -Raw -Encoding UTF8
}
foreach ($rule in @(
'(?s)\.member-heading > view:last-child text:last-child\s*\{[^}]*font-size:\s*23rpx;',
'(?s)\.member-section-title\s*\{[^}]*font-size:\s*24rpx;',
'(?s)\.member-info-row text\s*\{[^}]*font-size:\s*23rpx;',
'(?s)\.member-relatives\s*\{[^}]*font-size:\s*23rpx;',
'(?s)\.member-error > text:nth-child\(2\)\s*\{[^}]*font-size:\s*24rpx;'
'(?s)\.member-info-row text\s*\{[^}]*font-size:\s*23rpx;'
)) {
if ($profile -notmatch $rule) { throw "T03 readable visual rule missing: $rule" }
if ($pages.T03 -notmatch $rule) { throw "T03 readable visual rule missing: $rule" }
}
foreach ($entry in $forms.GetEnumerator()) {
foreach ($token in @(
'@include adaptive.adaptive-tree-panel;',
'@include adaptive.adaptive-tree-field;',
'grid-template-columns: auto minmax(0, 1fr)'
)) {
if (-not $entry.Value.Contains($token)) { throw "$($entry.Key) active form layout token missing: $token" }
foreach ($key in @('T04', 'T05', 'T06')) {
$content = $pages[$key]
foreach ($token in @('@include adaptive.adaptive-tree-panel;', '@include adaptive.adaptive-tree-field;', 'grid-template-columns: auto minmax(0, 1fr)')) {
if (-not $content.Contains($token)) { throw "$key active form layout token missing: $token" }
}
foreach ($rule in @(
'(?s)\.form-copy,\s*\.form-note\s*\{[^}]*font-size:\s*24rpx;',
'(?s)\.form-field[^{]*text:first-child\s*\{[^}]*font-size:\s*24rpx;',
'(?s)\.form-field[^{]*(?:input|text:last-child)[^{]*\{[^}]*font-size:\s*24rpx;'
)) {
if ($entry.Value -notmatch $rule) { throw "$($entry.Key) readable visual rule missing: $rule" }
}
if ($entry.Value -match '(?m)\bposition\s*:') {
throw "$($entry.Key) must keep active form content in document flow"
if ($content -match '(?m)^\s*\.rank-form\s*\{[^}]*\bposition\s*:|(?m)^\s*\.add-relative-form\s*\{[^}]*\bposition\s*:|(?m)^\s*\.edit-member-form\s*\{[^}]*\bposition\s*:') {
throw "$key must keep active content in document flow"
}
}
if ($pages.T06 -notmatch '(?s)\.form-copy\s*\{[^}]*font-size:\s*24rpx;') { throw 'T06 rank copy must remain readable' }
Write-Output 'T03-T06-ALL-STATES-VISUAL-CONTRACT PASS'
@@ -1,128 +1,35 @@
$ErrorActionPreference = 'Stop'
function Read-Page([string]$RelativePath) {
return [System.IO.File]::ReadAllText(
(Join-Path (Split-Path -Parent $PSScriptRoot) $RelativePath),
[System.Text.Encoding]::UTF8
)
$root = Split-Path -Parent $PSScriptRoot
$pages = @{
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
T06 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t06-edit-relationship.vue') -Raw -Encoding UTF8
}
function Assert-Contains([string]$Content, [string]$Expected, [string]$Message) {
if (-not $Content.Contains($Expected)) { throw $Message }
}
$t03 = Read-Page 'pages/tree/t03-member-profile.vue'
$t04 = Read-Page 'pages/tree/t04-add-relative.vue'
$t05 = Read-Page 'pages/tree/t05-edit-member.vue'
$t06 = Read-Page 'pages/tree/t06-edit-relationship.vue'
$t08 = Read-Page 'pages/tree/t08-member-states.vue'
Assert-Contains $t03 '{{ memberContextDescription }}' 'T03 must render its state-specific context description'
if ($t03 -notmatch '(?s)const\s+memberContextDescription\s*=\s*computed\(\(\)\s*=>\s*\(\{\s*loading:\s*"\u6B63\u5728\u8BFB\u53D6\u6210\u5458\u8D44\u6599",\s*detail:\s*"\u6210\u5458\u8EAB\u4EFD\u4E0E\u4EB2\u5C5E\u5173\u7CFB",\s*restricted:\s*"\u9690\u79C1\u6210\u5458\u4EC5\u5C55\u793A\u57FA\u7840\u8EAB\u4EFD",\s*error:\s*"\u8BF7\u91CD\u65B0\u9009\u62E9\u6210\u5458"') {
throw 'T03 must keep distinct loading, detail, restricted, and error context copy'
}
foreach ($entry in @(
@{ Name = 'T04'; Content = $t04 },
@{ Name = 'T05'; Content = $t05 },
@{ Name = 'T06'; Content = $t06 }
)) {
if ($entry.Content -match '<TreeMemberForm(?:\s|/|>)|import\s+TreeMemberForm') {
throw "$($entry.Name) must own its business workflow instead of rendering TreeMemberForm"
foreach ($entry in $pages.GetEnumerator()) {
if ($entry.Value -match '<TreeMemberForm(?:\s|/|>)|import\s+TreeMemberForm') {
throw "$($entry.Key) must own its workflow"
}
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($entry.Content.Contains($nativeUi)) { throw "$($entry.Name) must not use $nativeUi" }
if ($entry.Value.Contains($nativeUi)) { throw "$($entry.Key) must not use $nativeUi" }
}
}
foreach ($required in @(
'const addState = ref("form")',
'const addForm = reactive({',
'const relationOptions = [',
'const genderOptions = [',
'const isFirstMember = computed(',
'query.personId',
'query.mode === "first"',
'fieldErrors.name',
'fieldErrors.relation',
'add-state--form',
'add-state--preview',
'add-state--error',
'class="add-relative-form"',
'<picker',
'<AppDialog'
)) { Assert-Contains $t04 $required "T04 missing independent add-relative contract: $required" }
$t04Style = [regex]::Match($t04, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
$t04PanelStyle = [regex]::Match($t04Style, '(?ms)^\s*\.add-relative-panel\s*\{(?<body>.*?)\}').Groups['body'].Value
if ($t04PanelStyle -match '\bmin-height\s*:') {
throw 'T04 add-relative panel height must be driven by its current content'
foreach ($key in @('T04', 'T05', 'T06')) {
$content = $pages[$key]
foreach ($required in @('appApi.getPerson(', 'createRequestController', 'isRequestCancelled')) {
if (-not $content.Contains($required)) { throw "$key missing remote read owner: $required" }
}
foreach ($forbidden in @('@/data/mock.js', 'setTimeout(', 'preview')) {
if ($content.Contains($forbidden)) { throw "$key retains forbidden local preview token: $forbidden" }
}
}
foreach ($required in @(
'const editState = ref("form")',
'const editForm = reactive({',
'findTreeMemberPresentationFixture(genealogyId.value, id)',
'const isDirty = computed(',
'const discardDialogVisible = ref(false)',
'query.personId',
'edit-state--form',
'edit-state--preview',
'edit-state--error',
'edit-state--no-permission',
'class="edit-member-form"',
'<AppDialog',
'onBackPress('
)) { Assert-Contains $t05 $required "T05 missing independent member-edit contract: $required" }
$t05Style = [regex]::Match($t05, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
$t05PanelStyle = [regex]::Match($t05Style, '(?ms)^\s*\.edit-member-panel\s*\{(?<body>.*?)\}').Groups['body'].Value
if ($t05PanelStyle -match '\bmin-height\s*:') {
throw 'T05 edit-member panel height must be driven by its current content'
if (-not $pages.T06.Contains('"unavailable"')) { throw 'T06 must expose a closed service state' }
foreach ($required in @('appApi.getPerson(', 'memberContextDescription', 'openPage("T08"')) {
if (-not $pages.T03.Contains($required)) { throw "T03 missing member detail contract: $required" }
}
foreach ($required in @(
'const relationshipState = ref("form")',
'const relationshipForm = reactive({',
'const memberOptions = computed(',
'const relationshipOptions = [',
'const relationshipPreview = computed(',
'const validateRelationship = () =>',
'query.personId',
'relationship-state--form',
'relationship-state--preview',
'relationship-state--conflict',
'relationship-state--error',
'class="relationship-form"',
'<picker',
'<AppDialog'
)) { Assert-Contains $t06 $required "T06 missing independent relationship-edit contract: $required" }
$t06Style = [regex]::Match($t06, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
$t06PanelStyle = [regex]::Match($t06Style, '(?ms)^\s*\.relationship-panel\s*\{(?<body>.*?)\}').Groups['body'].Value
if ($t06PanelStyle -match '\bmin-height\s*:') {
throw 'T06 relationship panel height must be driven by its current content'
}
foreach ($required in @(
'const genealogyId = ref("")',
'const personId = ref("")',
'const statusState = ref("loading")',
'findTreeMemberPresentationFixture(genealogyId.value, personId.value)',
'query.personId',
'query.state',
'member-status--privacy',
'member-status--deceased',
'member-status--forbidden',
'member-status--error',
'class="member-status-context"'
)) { Assert-Contains $t08 $required "T08 missing member-driven status contract: $required" }
if ($t08 -match 'class="status-tabs"|const\s+tabs\s*=|@click="statusState\s*=') {
throw 'T08 must not let the user switch among demonstration states'
}
foreach ($required in @(
'findTreeMemberPresentationFixture(genealogyId.value, normalizedPersonId)',
'query.personId',
'const openMemberState = () =>',
'openPage("T08", { genealogyId: genealogyId.value, personId: personId.value }, "T03")'
)) { Assert-Contains $t03 $required "T03 missing semantic member-state entry: $required" }
Write-Output 'T03-T08-BUSINESS-SPECIALIZATION-CONTRACT PASS'
Write-Output 'T03-T08-BUSINESS-SPECIALIZATION-CONTRACT PASS REMOTE-CLOSED'
+43 -265
View File
@@ -1,285 +1,63 @@
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
function Assert-NotContains {
param([string]$Content, [string]$Unexpected, [string]$Message)
if ($Content.Contains($Unexpected)) { throw $Message }
}
function Assert-Matches {
param([string]$Content, [string]$Pattern, [string]$Message)
if ($Content -notmatch $Pattern) { throw $Message }
}
$root = Split-Path -Parent $PSScriptRoot
$profiles = Get-Content -LiteralPath (Join-Path $root 'styles/adaptive-frame-profiles.scss') -Raw -Encoding utf8
$mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding utf8
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding utf8
$paths = @(
'pages/tree/t01-tree-overview.vue',
'pages/tree/t03-member-profile.vue',
'pages/tree/t04-add-relative.vue',
'pages/tree/t05-edit-member.vue',
'pages/tree/t06-edit-relationship.vue',
'pages/tree/t07-member-directory.vue',
'pages/tree/t08-member-states.vue'
)
$pages = @{}
foreach ($path in $paths) {
$content = Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding utf8
if ($content -match '<ModulePage(?:\s|>)') { throw "$path must not retain ModulePage" }
if ($content -match "@/utils/api\.js|\bappApi\b") { throw "$path must not connect the API layer in navigation task 6" }
if ($content -match '\buni\.(?:navigateTo|navigateBack|redirectTo|reLaunch|switchTab)\b') {
throw "$path must use the navigation gateway exclusively"
$pages = @{
T01 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-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
T06 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t06-edit-relationship.vue') -Raw -Encoding UTF8
T07 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t07-member-directory.vue') -Raw -Encoding UTF8
T08 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t08-member-states.vue') -Raw -Encoding UTF8
}
foreach ($entry in $pages.GetEnumerator()) {
if ($entry.Value -match '\buni\.(?:navigateTo|navigateBack|redirectTo|reLaunch|switchTab)\b') {
throw "$($entry.Key) must use the navigation gateway exclusively"
}
if ($entry.Value.Contains('/pages/')) {
throw "$($entry.Key) must not own route literals"
}
if ($content.Contains('/pages/')) { throw "$path must not own route literals" }
$pages[$path] = $content
}
foreach ($path in @('pages/tree/t01-tree-overview.vue', 'pages/tree/t03-member-profile.vue', 'pages/tree/t07-member-directory.vue', 'pages/tree/t08-member-states.vue')) {
Assert-Contains $pages[$path] 'ModulePageBackground' "$path must use the tree module background"
}
$t01 = $pages['pages/tree/t01-tree-overview.vue']
Assert-NotContains $t01 'consumeNavigationResult(' 'T01 must not pre-register a mutation consumer before real writes exist'
foreach ($mapping in @(
'openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
'openPage("T07", { genealogyId: genealogyId.value }, "T01")',
'openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
'openPage("T04", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")'
)) {
Assert-Contains $t01 $mapping "T01 missing gateway target: $mapping"
}
Assert-Matches $t01 '(?s)openPage\(\s*"T04",\s*\{ genealogyId: genealogyId\.value, mode: "first" \},\s*"T01",?\s*\)' 'T01 empty state must open first-member mode through T04 route contract'
$t03 = $pages['pages/tree/t03-member-profile.vue']
foreach ($required in @(
'member-state--detail',
'member-state--restricted',
'member-state--error',
'adaptive.adaptive-tree-panel',
'adaptive.adaptive-tree-field',
'<PageHeader title=',
'custom-back',
'@back="requestBack"',
'appApi.getTree',
'const memberActions = Object.freeze([',
'const openMemberAction = (action) =>',
'openPage("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.getPerson(',
'const memberTrail = reactive([]);',
'const trailIndex = ref(-1);',
'const loadMember = async (nextPersonId) =>',
'const initializeMemberTrail = async (initialPersonId) =>',
'memberTrail.splice(0, memberTrail.length, normalizedPersonId);',
'trailIndex.value = 0;',
'const openRelative = async (nextPersonId) =>',
'memberTrail.splice(trailIndex.value + 1);',
'memberTrail.push(normalizedPersonId);',
'trailIndex.value = memberTrail.length - 1;',
'const popMemberTrail = async () =>',
'memberTrail.splice(targetIndex, 1);',
'internalTrail: trailIndex.value > 0',
'"pop-internal-trail": popMemberTrail',
'consumeNavigationResult("T03")',
'result?.operation === "member-open-requested"',
'onBackPress((event) => handleBackPress(event, requestBack));',
'openPage("T05", { genealogyId: genealogyId.value, personId: personId.value }, "T03")',
'openPage("T08", { genealogyId: genealogyId.value, personId: personId.value }, "T03")'
)) {
Assert-Contains $t03 $required "Missing T03 single-instance trail contract: $required"
}
Assert-Matches $t03 '(?s)const openRelative = async \(nextPersonId\) => \{.*?const loaded = await loadMember\(normalizedPersonId\);\s*if \(!loaded\) return false;.*?memberTrail\.push\(normalizedPersonId\)' 'T03 must append a relative only after a successful read'
Assert-Matches $t03 '(?s)const initializeMemberTrail = async \(initialPersonId\) => \{\s*memberTrail\.splice\(0, memberTrail\.length\);\s*trailIndex\.value = -1;.*?if \(!loaded\) return false;.*?memberTrail\.splice\(0, memberTrail\.length, normalizedPersonId\)' 'T03 initial failure must leave the trail empty'
Assert-Contains $t03 ':data-current-person-id="personId"' 'T03 runtime smoke needs a semantic current-person marker'
Assert-Contains $t03 ':data-trail-length="memberTrail.length"' 'T03 runtime smoke must observe trail initialization without internal access'
Assert-Contains $t03 ':data-trail-index="trailIndex"' 'T03 runtime smoke must observe the current trail position'
Assert-Contains $t03 ':data-person-id="relative.id"' 'T03 relatives need stable lexical identity in the rendered list'
Assert-NotContains $t03 'genealogyContext' 'T03 must not fall back to mutable global genealogy context'
Assert-Matches $t03 '(?s)const restricted\s*=\s*\["privacy",\s*"forbidden"\]\.includes\(fixture\.status\).*?memberState\.value\s*=\s*restricted\s*\?\s*"restricted"\s*:\s*"detail"' 'T03 must derive restricted presentation from the scoped member status'
Assert-Contains $t03 'const canPreviewEdit = computed(() => memberState.value === "detail");' 'T03 may expose only a clearly local preview entry for unrestricted detail state'
Assert-NotContains $t03 'canEdit' 'T03 must not treat a fixture field as a backend edit capability'
'onUnload(() =>',
'memberRequestController.abort()'
)) { Assert-Contains $pages.T03 $required "T03 missing remote member trail contract: $required" }
$specializedForms = @{
't04-add-relative.vue' = @('add-state--form', 'add-state--preview', 'add-relative-form', 'relationOptions')
't05-edit-member.vue' = @('edit-state--form', 'edit-state--preview', 'edit-member-form', 'findTreeMemberPresentationFixture')
't06-edit-relationship.vue' = @('relationship-state--form', 'relationship-state--preview', 'relationship-form', 'relationshipOptions')
}
foreach ($entry in $specializedForms.GetEnumerator()) {
$content = $pages["pages/tree/$($entry.Key)"]
if ($content -match '<TreeMemberForm(?:\s|/|>)|import\s+TreeMemberForm') {
throw "$($entry.Key) must own its business workflow instead of TreeMemberForm"
foreach ($key in @('T04', 'T05', 'T06')) {
$content = $pages[$key]
foreach ($required in @('appApi.getPerson(', 'createRequestController', 'isRequestCancelled', 'onUnload(() =>')) {
Assert-Contains $content $required "$key missing remote closed-flow contract: $required"
}
foreach ($required in $entry.Value) {
Assert-Contains -Content $content -Expected $required -Message ("Missing specialized form contract in {0}: {1}" -f $entry.Key, $required)
}
foreach ($required in @(
'createDiscardConfirmation',
'handleBackPress',
'runBackGuard',
'submitting: isSubmitting.value',
'"block-submitting"',
'onBackPress((event) => handleBackPress(event, requestBack));',
'discardConfirmation.dispose();'
)) {
Assert-Contains -Content $content -Expected $required -Message ("{0} missing shared guarded local-preview contract: {1}" -f $entry.Key, $required)
}
Assert-NotContains -Content $content -Unexpected 'finishPage(' -Message ("{0} must not emit a server-success result before a real write succeeds" -f $entry.Key)
foreach ($operation in @('relative-created', 'member-updated', 'relationship-updated')) {
Assert-NotContains -Content $content -Unexpected $operation -Message ("{0} must not retain a pre-API mutation operation" -f $entry.Key)
}
Assert-Matches -Content $content -Pattern '\u5c1a\u672a\u63d0\u4ea4\u670d\u52a1\u5668' -Message ("{0} must disclose that its preview is not submitted" -f $entry.Key)
}
Assert-Contains -Content ($pages['pages/tree/t04-add-relative.vue']) -Expected 'returnTo("T01", { genealogyId: genealogyId.value })' -Message 'T04 local preview must return to T01 without a mutation result'
Assert-Contains -Content ($pages['pages/tree/t05-edit-member.vue']) -Expected 'return goBack();' -Message 'T05 must preserve the existing single T03 route identity when leaving a relative preview'
Assert-NotContains -Content ($pages['pages/tree/t05-edit-member.vue']) -Unexpected 'returnTo("T03"' -Message 'T05 must not replace the host T03 initial personId with its current in-page member'
Assert-Contains -Content ($pages['pages/tree/t06-edit-relationship.vue']) -Expected 'returnTo("T01", { genealogyId: genealogyId.value })' -Message 'T06 local preview must return to T01 without a mutation result'
foreach ($submission in @(
@{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue']; Function = 'submitAdd'; State = 'addState.value' },
@{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue']; Function = 'saveMember'; State = 'editState.value' },
@{ Key = 'T06'; Content = $pages['pages/tree/t06-edit-relationship.vue']; Function = 'saveRelationship'; State = 'relationshipState.value' }
)) {
$body = [regex]::Match(
$submission.Content,
"(?s)const $($submission.Function) = \(\) => \{.*?`n\};"
).Value
if (-not $body) { throw "$($submission.Key) submission function is not statically auditable" }
if (-not $body.Contains('"preview"')) { throw "$($submission.Key) valid submission must stop at local preview" }
if ($body -match '\b(?:returnTo|goBack|finishPage)\s*\(') {
throw "$($submission.Key) local validation must not navigate automatically"
}
}
if (([regex]::Matches($pages['pages/tree/t05-edit-member.vue'], 'baseline\.value = formSnapshot\.value;')).Count -ne 1) {
throw 'T05 local preview must not clear the dirty baseline before a server write exists'
}
$t07 = $pages['pages/tree/t07-member-directory.vue']
foreach ($required in @(
'directory-state--list',
'directory-state--empty',
'directory-state--error',
'adaptive.adaptive-genealogy-list-card',
'directory-card__status',
'listTreeMemberPresentationFixtures',
'const isRestrictedMember =',
'memberMeta(item)',
'memberStatus(item)',
'openPage("T03", { genealogyId: genealogyId.value, personId: String(item.id) }, "T07")'
)) {
Assert-Contains $t07 $required "Missing T07 contract: $required"
}
foreach ($privateTemplateRead in @('{{ item.generationName }}', '{{ item.branch }}', '{{ item.note }}')) {
Assert-NotContains $t07 $privateTemplateRead "T07 must not render a restricted member private field directly: $privateTemplateRead"
}
foreach ($required in @(
'const hasValidContext = computed(() => Boolean(genealogyId.value));',
'!hasValidContext.value ? "error"',
'v-if="hasValidContext"'
)) {
Assert-Contains $t07 $required "T07 must fail closed without genealogy context: $required"
}
$t08 = $pages['pages/tree/t08-member-states.vue']
foreach ($required in @(
'member-status--privacy',
'member-status--deceased',
'member-status--forbidden',
'adaptive.adaptive-tree-panel',
'memberIdentityCopy',
'姓名、世代与家族关系可见',
'goRoot("G01")',
'goBack()'
)) {
Assert-Contains $t08 $required "Missing T08 contract: $required"
}
Assert-NotContains $t08 '{{ member.branch }}' 'T08 must not assume a restricted member branch is visible'
Assert-NotContains $t08 'genealogyContext' 'T08 must not fall back to mutable global genealogy context'
foreach ($required in @(
'const hasValidContext = computed(() => Boolean(genealogyId.value && personId.value));',
'member.value = hasValidContext.value'
)) {
Assert-Contains $t08 $required "T08 must fail closed without its complete route identity: $required"
}
$t06 = $pages['pages/tree/t06-edit-relationship.vue']
foreach ($required in @(
'const hasValidContext = computed(',
'Boolean(genealogyId.value && memberById.value.has(personId.value))',
'relationshipForm.sourceId = hasValidContext.value ? personId.value : "";',
'relationshipState.value = !hasValidContext.value || query.state === "error"'
)) {
Assert-Contains $t06 $required "T06 must fail closed without a valid routed member: $required"
}
Assert-NotContains $t06 ': memberOptions[0].id' 'T06 must never replace a missing routed person with the first fixture member'
$treeOverview = $pages['pages/tree/t01-tree-overview.vue']
foreach ($page in @(
@{ Key = 'T01'; Content = $treeOverview },
@{ Key = 'T03'; Content = $t03 },
@{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue'] },
@{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue'] },
@{ Key = 'T06'; Content = $t06 },
@{ Key = 'T07'; Content = $t07 },
@{ Key = 'T08'; Content = $t08 }
)) {
Assert-Contains $page.Content '@/data/mock.js' "$($page.Key) must consume the shared tree-member fixture owner"
if ($page.Content -match 'import\s*\{[^}]*\btreeMembers\b[^}]*\}\s*from\s*["'']@/data/mock\.js["'']') {
throw "$($page.Key) must consume snapshots instead of the mutable treeMembers store"
}
}
foreach ($localOwner in @(
@{ Key = 'T01'; Content = $treeOverview; Pattern = 'const\s+members\s*=\s*ref\(\[\s*\{' },
@{ Key = 'T03'; Content = $t03; Pattern = 'const\s+memberFixtures\s*=' },
@{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue']; Pattern = 'const\s+memberFixtures\s*=' },
@{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue']; Pattern = 'const\s+memberFixtures\s*=' },
@{ Key = 'T06'; Content = $t06; Pattern = 'const\s+memberOptions\s*=\s*\[' },
@{ Key = 'T07'; Content = $t07; Pattern = 'const\s+members\s*=\s*\[' },
@{ Key = 'T08'; Content = $t08; Pattern = 'const\s+memberFixtures\s*=' }
)) {
if ($localOwner.Content -match $localOwner.Pattern) {
throw "$($localOwner.Key) retains a competing local tree-member fixture owner"
foreach ($forbidden in @('@/data/mock.js', 'setTimeout(')) {
if ($content.Contains($forbidden)) { throw "$key retains local preview owner: $forbidden" }
}
}
foreach ($ownerContract in @(
'export const treeMembers = [',
'export const listTreeMemberFixtures = (genealogyId) =>',
'export const findTreeMemberFixture = (genealogyId, personId) =>',
'relatives: member.relatives.map((relative) => ({ ...relative }))'
)) {
Assert-Contains $mock $ownerContract "Shared tree-member owner missing: $ownerContract"
}
Assert-Matches $api '(?s)import\s*\{[^}]*\btreeMembers\b[^}]*\}\s*from\s*''@/data/mock\.js''' 'utils/api.js must remain the only mutable tree-member store consumer'
foreach ($sourceRoot in @('pages', 'components', 'utils')) {
Get-ChildItem -LiteralPath (Join-Path $root $sourceRoot) -Recurse -File |
Where-Object { $_.Extension -in @('.js', '.vue') -and $_.FullName -ne (Join-Path $root 'utils/api.js') } |
ForEach-Object {
$source = Get-Content -LiteralPath $_.FullName -Raw -Encoding utf8
if ($source -match 'import\s*\{[^}]*\btreeMembers\b[^}]*\}\s*from\s*["'']@/data/mock\.js["'']') {
throw "$($_.FullName) bypasses the tree-member snapshot selectors"
}
}
}
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 $pages.T06 'query.mode !== "rank"' 'T06 must reject non-rank entry modes'
foreach ($entry in @(
@{ Key = 'T01'; Content = $treeOverview; Expected = 'listTreeMemberFixtures(genealogyId.value)' },
@{ Key = 'T03'; Content = $t03; Expected = 'findTreeMemberPresentationFixture(genealogyId.value, normalizedPersonId)' },
@{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue']; Expected = 'findTreeMemberFixture(genealogyId.value, personId.value)' },
@{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue']; Expected = 'findTreeMemberPresentationFixture(genealogyId.value, id)' },
@{ Key = 'T06'; Content = $t06; Expected = 'listTreeMemberFixtures(genealogyId.value)' },
@{ Key = 'T07'; Content = $t07; Expected = 'listTreeMemberPresentationFixtures(genealogyId.value)' },
@{ Key = 'T08'; Content = $t08; Expected = 'findTreeMemberPresentationFixture(genealogyId.value, personId.value)' }
)) {
Assert-Contains $entry.Content $entry.Expected "$($entry.Key) must scope fixture reads by genealogy and member identity"
}
Assert-Contains $pages['pages/tree/t04-add-relative.vue'] 'const hasValidContext = computed(' 'T04 must own an explicit route-context gate'
Assert-Contains $pages['pages/tree/t04-add-relative.vue'] 'isFirstMember.value ? !personId.value : Boolean(currentMember.value)' 'T04 must only allow a missing personId in first-member mode'
Assert-Contains $pages['pages/tree/t05-edit-member.vue'] 'if (!member) return false;' 'T05 must reject unknown members instead of synthesizing an editable identity'
Assert-Matches $pages['pages/tree/t05-edit-member.vue'] '(?s)\["privacy",\s*"forbidden"\]\.includes\(originalMember\.value\.status\).*?"no-permission"' 'T05 deep links must not edit a restricted member without a backend capability'
foreach ($asset in @('t01-state-panel.png', 't07-search-input-frame.png', 'list-slip-frame.png')) {
Assert-Contains $profiles $asset "Adaptive tree profile missing: $asset"
}
Write-Output 'T03-T08-MEMBER-FLOW-CONTRACT PASS NAVIGATION TRAIL LOCAL-PREVIEW'
Write-Output 'T03-T08-MEMBER-FLOW-CONTRACT PASS REMOTE-WRITE'
@@ -0,0 +1,35 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t04-add-relative.vue') -Raw -Encoding UTF8
foreach ($required in @(
'appApi.getPerson(',
'createRequestController',
'isRequestCancelled',
'addRequestController.abort()',
'let loadSequence = 0',
'relationType.value',
'const relationIntents = Object.freeze({',
'appApi.createPerson(',
'appApi.createRelatedPerson(',
'await returnTo("T01", { genealogyId: genealogyId.value })'
)) {
if (-not $page.Contains($required)) {
throw "T04 remote close contract missing: $required"
}
}
foreach ($forbidden in @(
'@/data/mock.js',
'findTreeMemberFixture',
'setTimeout(',
'addState.value = "preview"',
'addState.value = "unavailable"'
)) {
if ($page.Contains($forbidden)) {
throw "T04 must not retain local preview owner: $forbidden"
}
}
Write-Output 'T04_RELATIVE_REMOTE_WRITE_CONTRACT PASS'
@@ -0,0 +1,33 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t05-edit-member.vue') -Raw -Encoding UTF8
foreach ($required in @(
'appApi.getPerson(',
'createRequestController',
'isRequestCancelled',
'editRequestController.abort()',
'let loadSequence = 0',
'appApi.updatePerson(',
'failedAction.value = "save"'
)) {
if (-not $page.Contains($required)) {
throw "T05 remote close contract missing: $required"
}
}
foreach ($forbidden in @(
'@/data/mock.js',
'findTreeMemberPresentationFixture',
'setTimeout(',
'editState.value = "preview"',
'"no-permission"',
'editState.value = "unavailable"'
)) {
if ($page.Contains($forbidden)) {
throw "T05 must not retain local preview owner: $forbidden"
}
}
Write-Output 'T05_MEMBER_REMOTE_WRITE_CONTRACT PASS'
+33
View File
@@ -0,0 +1,33 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t06-edit-relationship.vue') -Raw -Encoding UTF8
foreach ($required in @(
'class="rank-page"',
'appApi.getPerson(',
'createRequestController',
'isRequestCancelled',
'rankRequestController.abort()',
'let loadSequence = 0',
'query.mode !== "rank"',
'rankState.value = "unavailable"'
)) {
if (-not $page.Contains($required)) {
throw "T06 rank remote close contract missing: $required"
}
}
foreach ($forbidden in @(
'@/data/mock.js',
'listTreeMemberFixtures',
'setTimeout(',
'relationshipOptions',
'relationshipState'
)) {
if ($page.Contains($forbidden)) {
throw "T06 must not retain relationship local-preview owner: $forbidden"
}
}
Write-Output 'T06-RANK-REMOTE-CLOSE-CONTRACT PASS'
+2 -54
View File
@@ -90,50 +90,10 @@ class FakeTac {
const host = { innerHTML: "rendered" };
const loadedStyle = { dataset: { jiapuState: "loaded" }, sheet: {} };
const backgroundControl = {
focusCount: 0,
focus() {
this.focusCount += 1;
document.activeElement = this;
},
};
const refreshControl = {
offsetParent: {},
focus() {
document.activeElement = this;
},
};
const closeControl = {
offsetParent: {},
focus() {
document.activeElement = this;
},
};
const dialog = {
offsetParent: {},
listener: null,
focus() {
document.activeElement = this;
},
querySelector(selector) {
return selector === ".tac-tool--refresh" ? refreshControl : null;
},
querySelectorAll() {
return [refreshControl, closeControl];
},
addEventListener(name, listener) {
if (name === "keydown") this.listener = listener;
},
removeEventListener(name, listener) {
if (name === "keydown" && this.listener === listener) this.listener = null;
},
};
const document = {
activeElement: backgroundControl,
head: { appendChild() {} },
querySelector(selector) {
if (selector === "#jiapu-tac-host") return host;
if (selector === "#jiapu-tac-dialog") return dialog;
if (selector.startsWith("link[data-jiapu-tac=")) return loadedStyle;
return null;
},
@@ -228,23 +188,12 @@ const run = async () => {
subject: "13800138000",
};
instance.context = context;
instance.previousFocus = backgroundControl;
instance.generation += 1;
instance.createTac();
assert.strictEqual(latestTac.initialized, true);
assert.strictEqual(typeof latestTac.config.doSendRequest, "function");
assert.strictEqual(document.activeElement, refreshControl, "打开 TAC 后必须把焦点移入对话框");
document.activeElement = closeControl;
let tabPrevented = false;
dialog.listener({
key: "Tab",
shiftKey: false,
preventDefault() {
tabPrevented = true;
},
});
assert.strictEqual(tabPrevented, true, "TAC 末项 Tab 必须被焦点环截获");
assert.strictEqual(document.activeElement, refreshControl);
latestTac.config.options.btnRefreshFun(null, latestTac);
assert.strictEqual(latestTac.reloadCount, 1, "必须保留供应商原生刷新操作");
const firstSuccess = latestTac.config.options.validSuccess;
firstSuccess(
@@ -283,7 +232,6 @@ const run = async () => {
await instance.onContextChange({ visible: false });
assert.strictEqual(instance.tac, null, "隐藏或卸载验证层必须销毁 SDK 实例");
assert.strictEqual(host.innerHTML, "", "隐藏或卸载验证层必须清空宿主节点");
assert.strictEqual(backgroundControl.focusCount, 1, "关闭 TAC 后必须恢复进入前焦点");
console.log("TAC-RENDERJS-RUNTIME-SMOKE PASS");
};
+28 -21
View File
@@ -1,32 +1,39 @@
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$component = Get-Content -LiteralPath (Join-Path $root 'components/TacVerification.vue') -Raw -Encoding UTF8
foreach ($token in @(
'v-show="visible"', 'id="jiapu-tac-dialog"', 'role="dialog"', 'aria-modal="true"',
'tabindex="-1"', 'aria-labelledby="jiapu-tac-title"',
'aria-describedby="jiapu-tac-description"', '@keydown.esc.stop.prevent="requestCancel"',
'class="tac-tool tac-tool--refresh"', 'aria-label="刷新安全验证"',
'class="tac-tool tac-tool--close"', 'aria-label="关闭安全验证"',
'const previousFocus = document.activeElement', 'activateFocusTrap()',
'deactivateFocusTrap(', 'event.key !== "Tab"', 'previousFocus?.focus?.()',
':deep(.slider-bottom .close-btn)', ':deep(.slider-bottom .refresh-btn)',
'min-width: 48px;', 'min-height: 48px;'
'v-show="visible"',
'id="jiapu-tac-host"',
':prop="renderContext"',
':change:prop="tacRenderer.onContextChange"',
'btnCloseFun:',
'btnRefreshFun:',
'this.tac = new window.TAC(config);'
)) {
if (-not $component.Contains($token)) { throw "TAC 外壳无障碍预检缺少:$token" }
if (-not $component.Contains($token)) { throw "Missing native TAC shell token: $token" }
}
$logicalScript = [regex]::Match($component, '(?s)<script>(?<Body>.*?)</script>').Groups['Body'].Value
foreach ($browserOnly in @('document.', 'window.', 'querySelector(')) {
if ($logicalScript.Contains($browserOnly)) {
throw "TAC 逻辑层不得访问浏览器专属对象:$browserOnly"
}
foreach ($forbidden in @(
'tac-panel',
'tac-heading',
'tac-tool',
'slider-bottom .close-btn',
'slider-bottom .refresh-btn',
'logoUrl:',
'i18n:',
'new window.TAC(config, {'
)) {
if ($component.Contains($forbidden)) { throw "TAC overrides vendor rendering: $forbidden" }
}
$toolButtons = [regex]::Matches($component, '(?s)<button\b[^>]*class="[^"]*tac-tool\b')
if ($toolButtons.Count -lt 2) { throw 'TAC 外壳至少要有刷新与关闭两个原生操作' }
if ([regex]::Matches($component, 'class="tac-tool tac-tool--refresh"').Count -ne 1) { throw 'TAC 刷新操作必须有唯一 owner' }
if ([regex]::Matches($component, 'class="tac-tool tac-tool--close"').Count -ne 1) { throw 'TAC 关闭操作必须有唯一 owner' }
$styleStart = $component.IndexOf('<style scoped>')
if ($styleStart -lt 0) { throw 'Missing minimal TAC mount style' }
$style = $component.Substring($styleStart)
$visualTokens = @('background:', 'border:', 'border-radius:', 'box-shadow:', 'color:', 'font-', 'padding:', 'opacity:', 'transition:')
foreach ($forbidden in $visualTokens) {
if ($style.Contains($forbidden)) { throw "TAC mount adds visual styling: $forbidden" }
}
Write-Output 'TAC-SHELL-ACCESSIBILITY-CONTRACT PASS'
Write-Output 'TAC-VENDOR-NATIVE-SHELL-CONTRACT PASS'
+2 -2
View File
@@ -140,8 +140,8 @@ const run = async () => {
"const AUTH_TAC_SCENE = {}; const assertSmsCode = (value) => value;\n",
)
.replace(
/^import \{ GENEALOGY_ACCESS_PRESET \}[^\n]+\r?\n/m,
"const GENEALOGY_ACCESS_PRESET = { MEMBER_ONLY: 'MEMBER_ONLY' };\n",
/^import \{ GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess \}[^\n]+\r?\n/m,
"const GENEALOGY_ACCESS_PRESET = { MEMBER_ONLY: 'MEMBER_ONLY' }; const fromApiGenealogyAccess = () => GENEALOGY_ACCESS_PRESET.MEMBER_ONLY;\n",
)
.replace(
/^import \{ session \}[^\n]+\r?\n/m,
+704 -59
View File
@@ -2,7 +2,6 @@ import {
currentUser,
genealogies,
publicGenealogies,
treeMembers,
notifications,
joinApplications,
listFamilyFeedFixtures,
@@ -352,24 +351,6 @@ const saveLogin = (loginResult) => {
return loginResult
}
const toTreeNode = (person, index = 0) => {
const generation = Number(person.generationNo || person.generation || 1)
return {
...person,
relatives: Array.isArray(person.relatives)
? person.relatives.map((relative) => ({ ...relative }))
: [],
id: person.id || person.personId,
name: person.personName || person.name || '未命名族人',
relation: person.relation || (generation === 1 ? '始祖' : '族人'),
generation,
years: person.years || [person.birthDate, person.deathDate].filter(Boolean).join('—') || '生卒待补',
branch: person.branch || '主支',
x: person.x ?? (20 + (index % 4) * 20),
y: person.y ?? (generation * 31 - 24)
}
}
const lineageTreeError = (message) =>
createRequestError(message, 'LINEAGE_TREE_RESPONSE_INVALID')
@@ -506,9 +487,18 @@ const normalizeLineagePersonDetail = (
}
const name = normalizeLineagePersonText(value.name, '姓名', { required: true })
const generationName = normalizeLineagePersonText(value.generationName, '字辈')
const aliasName = normalizeLineagePersonText(value.aliasName, '别名或曾用名')
const birthDate = normalizeLineagePersonDate(value.birthDate, '出生日期')
const deathDate = normalizeLineagePersonDate(value.deathDate, '逝世日期')
const personStatus = normalizeLineagePersonText(value.personStatus, '人物状态').toUpperCase()
const birthLunar = normalizeLineagePersonText(value.birthLunar, '出生农历')
const deathLunar = normalizeLineagePersonText(value.deathLunar, '逝世农历')
const birthPlace = normalizeLineagePersonText(value.birthPlace, '出生地')
const deathPlace = normalizeLineagePersonText(value.deathPlace, '逝世地')
const burialPlace = normalizeLineagePersonText(value.burialPlace, '安葬地')
const spouseNames = normalizeLineagePersonText(value.spouseNames, '配偶姓名')
const personStatus = normalizeLineagePersonText(value.personStatus, '人物状态')
const biography = normalizeLineagePersonText(value.biography, '生平')
const remark = normalizeLineagePersonText(value.remark, '备注')
const relatives = []
for (const relation of [
{ id: value.fatherId, name: value.fatherName, label: '父亲' },
@@ -530,6 +520,7 @@ const normalizeLineagePersonDetail = (
genealogyId,
genealogyName: normalizeLineagePersonText(value.genealogyName, '家谱名称') || '当前家谱',
name,
aliasName,
generation: value.generation,
generationName,
relation: value.generation === 1 ? '始祖' : '家谱成员',
@@ -538,15 +529,409 @@ const normalizeLineagePersonDetail = (
: '字辈待补',
sex: normalizeLineagePersonText(value.sex, '性别'),
birthDate,
birthLunar,
deathDate,
deathLunar,
years: birthDate || deathDate ? `${birthDate}${deathDate}` : '生卒待补',
birthplace: normalizeLineagePersonText(value.birthPlace, '出生地'),
biography: normalizeLineagePersonText(value.biography, '生平'),
status: ['DECEASED', 'DEAD'].includes(personStatus) ? 'deceased' : 'normal',
birthplace: birthPlace,
deathPlace,
burialPlace,
spouseNames,
personStatus,
biography,
remark,
status: ['DECEASED', 'DEAD'].includes(personStatus.toUpperCase()) ? 'deceased' : 'normal',
relatives
}
}
const normalizeLineagePersonPage = (value, expectedGenealogyId) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw lineagePersonError('成员分页响应不是对象')
}
if (!Array.isArray(value.rows)) {
throw lineagePersonError('成员分页缺少 rows')
}
if (!Number.isSafeInteger(value.total) || value.total < 0) {
throw lineagePersonError('成员分页 total 无效')
}
const rows = value.rows.map((item) =>
normalizeLineagePersonDetail(
item,
expectedGenealogyId,
normalizeLineagePersonIdentity(item?.personId, '人物标识'),
),
)
if (new Set(rows.map((item) => item.id)).size !== rows.length) {
throw lineagePersonError('成员分页包含重复人物标识')
}
return { rows, total: value.total }
}
const normalizeFeedCommentId = (value, label) => {
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
throw createRequestError(`家族动态评论${label}无效`, 'FEED_COMMENT_RESPONSE_INVALID')
}
const normalizeFeedCommentText = (value, label, { required = false } = {}) => {
if (value === undefined || value === null) {
if (required) throw createRequestError(`家族动态评论缺少${label}`, 'FEED_COMMENT_RESPONSE_INVALID')
return ''
}
if (typeof value !== 'string') {
throw createRequestError(`家族动态评论${label}无效`, 'FEED_COMMENT_RESPONSE_INVALID')
}
const normalized = value.trim()
if (required && !normalized) {
throw createRequestError(`家族动态评论缺少${label}`, 'FEED_COMMENT_RESPONSE_INVALID')
}
return normalized
}
const normalizeFeedComments = (value, expectedGenealogyId, expectedFeedId) => {
if (!Array.isArray(value)) {
throw createRequestError('家族动态评论响应不是列表', 'FEED_COMMENT_RESPONSE_INVALID')
}
const comments = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('家族动态评论包含无效条目', 'FEED_COMMENT_RESPONSE_INVALID')
}
const genealogyId = normalizeFeedCommentId(item.genealogyId, '家谱标识')
const feedId = normalizeFeedCommentId(item.feedId, '动态标识')
if (genealogyId !== expectedGenealogyId || feedId !== expectedFeedId) {
throw createRequestError('家族动态评论归属与请求不匹配', 'FEED_COMMENT_RESPONSE_INVALID')
}
return {
id: normalizeFeedCommentId(item.commentId, '标识'),
author: normalizeFeedCommentText(item.appUserNickName, '用户昵称') || '未署名成员',
content: normalizeFeedCommentText(item.commentContent, '评论内容', { required: true }),
time: normalizeFeedCommentText(item.createTime, '创建时间'),
parentCommentId: item.parentCommentId === undefined || item.parentCommentId === null
? null
: normalizeFeedCommentId(item.parentCommentId, '父评论标识'),
replyCount: Number.isSafeInteger(item.replyCount) && item.replyCount >= 0 ? item.replyCount : 0,
}
})
if (new Set(comments.map((item) => item.id)).size !== comments.length) {
throw createRequestError('家族动态评论包含重复标识', 'FEED_COMMENT_RESPONSE_INVALID')
}
return comments
}
const normalizeFeedCommentPayload = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.getPrototypeOf(payload) !== Object.prototype) {
throw new TypeError('家族动态评论请求必须是普通对象')
}
const fields = Object.keys(payload)
if (fields.some((field) => !['parentCommentId', 'commentContent'].includes(field))) {
throw new TypeError('家族动态评论请求包含未声明字段')
}
const commentContent = normalizeFeedCommentText(payload.commentContent, '评论内容', { required: true })
if (Array.from(commentContent).length > 1000) throw new TypeError('家族动态评论不能超过 1000 个字符')
const data = { commentContent }
if (payload.parentCommentId !== undefined && payload.parentCommentId !== null) {
data.parentCommentId = normalizeFeedCommentId(payload.parentCommentId, '父评论标识')
}
return data
}
const normalizeFeedCreatePayload = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.getPrototypeOf(payload) !== Object.prototype) {
throw new TypeError('家族动态请求必须是普通对象')
}
if (Object.keys(payload).some((field) => field !== 'feedContent')) {
throw new TypeError('家族动态请求包含未声明字段')
}
if (typeof payload.feedContent !== 'string' || !payload.feedContent.trim()) {
throw new TypeError('家族动态内容不能为空')
}
return { feedContent: payload.feedContent.trim() }
}
const normalizeArticleCreatePayload = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.getPrototypeOf(payload) !== Object.prototype) {
throw new TypeError('谱文请求必须是普通对象')
}
if (Object.keys(payload).some((field) => !['articleTitle', 'articleContent'].includes(field))) {
throw new TypeError('谱文请求包含未声明字段')
}
const articleTitle = typeof payload.articleTitle === 'string' ? payload.articleTitle.trim() : ''
const articleContent = typeof payload.articleContent === 'string' ? payload.articleContent.trim() : ''
if (!articleTitle) throw new TypeError('谱文标题不能为空')
if (!articleContent) throw new TypeError('谱文正文不能为空')
return { articleTitle, articleContent }
}
const normalizeAlbumCreatePayload = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.getPrototypeOf(payload) !== Object.prototype) {
throw new TypeError('相册请求必须是普通对象')
}
if (Object.keys(payload).some((field) => field !== 'albumName')) {
throw new TypeError('相册请求包含未声明字段')
}
if (typeof payload.albumName !== 'string' || !payload.albumName.trim()) {
throw new TypeError('相册名称不能为空')
}
return { albumName: payload.albumName.trim() }
}
const normalizeRelativeRecordCreatePayload = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.getPrototypeOf(payload) !== Object.prototype) {
throw new TypeError('亲友往来请求必须是普通对象')
}
const allowedFields = ['relativeName', 'relationName', 'eventName', 'eventTime', 'giftAmount', 'recordContent']
if (Object.keys(payload).some((field) => !allowedFields.includes(field))) {
throw new TypeError('亲友往来请求包含未声明字段')
}
const relativeName = typeof payload.relativeName === 'string' ? payload.relativeName.trim() : ''
if (!relativeName) throw new TypeError('亲友姓名不能为空')
const data = { relativeName }
for (const field of ['relationName', 'eventName', 'eventTime', 'recordContent']) {
if (payload[field] === undefined) continue
if (typeof payload[field] !== 'string') throw new TypeError(`亲友往来${field}必须是字符串`)
const value = payload[field].trim()
if (value) data[field] = value
}
if (payload.giftAmount !== undefined && payload.giftAmount !== '' && payload.giftAmount !== null) {
if (typeof payload.giftAmount !== 'number' || !Number.isFinite(payload.giftAmount)) {
throw new TypeError('亲友往来礼金金额必须是有限数字')
}
data.giftAmount = payload.giftAmount
}
return data
}
const normalizeGrowthRecordCreatePayload = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.getPrototypeOf(payload) !== Object.prototype) throw new TypeError('成长记录请求必须是普通对象')
if (Object.keys(payload).some((field) => !['recordTitle', 'recordContent', 'recordDate'].includes(field))) throw new TypeError('成长记录请求包含未声明字段')
const recordTitle = typeof payload.recordTitle === 'string' ? payload.recordTitle.trim() : ''
if (!recordTitle) throw new TypeError('成长记录标题不能为空')
const data = { recordTitle }
for (const field of ['recordContent', 'recordDate']) {
if (payload[field] === undefined) continue
if (typeof payload[field] !== 'string') throw new TypeError(`成长记录${field}必须是字符串`)
const value = payload[field].trim()
if (value) data[field] = value
}
return data
}
const normalizeMemoCreatePayload = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.getPrototypeOf(payload) !== Object.prototype) throw new TypeError('备忘请求必须是普通对象')
if (Object.keys(payload).some((field) => !['memoTitle', 'memoContent', 'remindTime'].includes(field))) throw new TypeError('备忘请求包含未声明字段')
const memoTitle = typeof payload.memoTitle === 'string' ? payload.memoTitle.trim() : ''
if (!memoTitle) throw new TypeError('备忘标题不能为空')
const data = { memoTitle }
for (const field of ['memoContent', 'remindTime']) {
if (payload[field] === undefined) continue
if (typeof payload[field] !== 'string') throw new TypeError(`备忘${field}必须是字符串`)
const value = payload[field].trim()
if (value) data[field] = value
}
return data
}
const normalizeMeritRecordCreatePayload = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.getPrototypeOf(payload) !== Object.prototype) throw new TypeError('功德记录请求必须是普通对象')
const allowedFields = ['donorName', 'meritTitle', 'meritType', 'meritContent', 'amount', 'meritTime']
if (Object.keys(payload).some((field) => !allowedFields.includes(field))) throw new TypeError('功德记录请求包含未声明字段')
const donorName = typeof payload.donorName === 'string' ? payload.donorName.trim() : ''
const meritTitle = typeof payload.meritTitle === 'string' ? payload.meritTitle.trim() : ''
if (!donorName || !meritTitle) throw new TypeError('功德记录捐赠人和标题不能为空')
const data = { donorName, meritTitle }
for (const field of ['meritType', 'meritContent', 'meritTime']) {
if (payload[field] === undefined) continue
if (typeof payload[field] !== 'string') throw new TypeError(`功德记录${field}必须是字符串`)
const value = payload[field].trim()
if (value) data[field] = value
}
if (payload.amount !== undefined && payload.amount !== '' && payload.amount !== null) {
if (typeof payload.amount !== 'number' || !Number.isFinite(payload.amount)) throw new TypeError('功德金额必须是有限数字')
data.amount = payload.amount
}
return data
}
const normalizeGenerationPoemId = (value, label) => {
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
throw createRequestError(`字辈${label}无效`, 'GENERATION_POEM_RESPONSE_INVALID')
}
const normalizeGenerationPoemNumber = (value, label) => {
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) {
const normalized = Number(value)
if (Number.isSafeInteger(normalized)) return normalized
}
throw createRequestError(`字辈${label}无效`, 'GENERATION_POEM_RESPONSE_INVALID')
}
const normalizeGenerationPoemText = (value, label, { required = false, maxLength = null } = {}) => {
if (value === undefined || value === null) {
if (required) throw createRequestError(`字辈缺少${label}`, 'GENERATION_POEM_RESPONSE_INVALID')
return ''
}
if (typeof value !== 'string') throw createRequestError(`字辈${label}无效`, 'GENERATION_POEM_RESPONSE_INVALID')
const normalized = value.trim()
if (required && !normalized) throw createRequestError(`字辈缺少${label}`, 'GENERATION_POEM_RESPONSE_INVALID')
if (maxLength !== null && Array.from(normalized).length > maxLength) {
throw createRequestError(`字辈${label}超出合同长度`, 'GENERATION_POEM_RESPONSE_INVALID')
}
return normalized
}
const normalizeGenerationPoemStatus = (value) => {
if (value === '0' || value === '1') return value
throw createRequestError('字辈状态无效', 'GENERATION_POEM_RESPONSE_INVALID')
}
const normalizeGenerationPoemRows = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) throw createRequestError('字辈响应不是列表', 'GENERATION_POEM_RESPONSE_INVALID')
const rows = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('字辈响应包含无效条目', 'GENERATION_POEM_RESPONSE_INVALID')
}
const genealogyId = normalizeGenerationPoemId(item.genealogyId, '家谱标识')
if (genealogyId !== expectedGenealogyId) {
throw createRequestError('字辈响应家谱标识与请求不匹配', 'GENERATION_POEM_RESPONSE_INVALID')
}
return {
poemId: normalizeGenerationPoemId(item.poemId, '标识'),
genealogyId,
genealogyNo: normalizeGenerationPoemText(item.genealogyNo, '家谱编号'),
genealogyName: normalizeGenerationPoemText(item.genealogyName, '家谱名称'),
generationNo: normalizeGenerationPoemNumber(item.generationNo, '世代'),
generationText: normalizeGenerationPoemText(item.generationText, '文字', { required: true, maxLength: 50 }),
description: normalizeGenerationPoemText(item.description, '说明', { maxLength: 500 }),
sortOrder: item.sortOrder,
status: normalizeGenerationPoemStatus(item.status),
}
})
if (new Set(rows.map((item) => item.poemId)).size !== rows.length) {
throw createRequestError('字辈响应包含重复标识', 'GENERATION_POEM_RESPONSE_INVALID')
}
if (new Set(rows.map((item) => item.generationNo)).size !== rows.length) {
throw createRequestError('字辈响应包含重复世代', 'GENERATION_POEM_RESPONSE_INVALID')
}
return rows.sort((left, right) => left.generationNo - right.generationNo)
}
const normalizeGenerationPoemBatchPayload = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.getPrototypeOf(payload) !== Object.prototype) {
throw new TypeError('字辈批量请求必须是普通对象')
}
if (Object.keys(payload).some((field) => !['poemText', 'disableMissing'].includes(field))) {
throw new TypeError('字辈批量请求包含未声明字段')
}
const poemText = normalizeGenerationPoemText(payload.poemText, '内容', { required: true, maxLength: 26000 })
const data = { poemText }
if (payload.disableMissing !== undefined) {
if (typeof payload.disableMissing !== 'boolean') throw new TypeError('字辈停用策略必须是布尔值')
data.disableMissing = payload.disableMissing
}
return data
}
const normalizeGenerationPoemPreview = (value, expectedGenealogyId) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('字辈批量预览响应不是对象', 'GENERATION_POEM_RESPONSE_INVALID')
}
const genealogyId = normalizeGenerationPoemId(value.genealogyId, '预览家谱标识')
if (genealogyId !== expectedGenealogyId) {
throw createRequestError('字辈预览家谱标识与请求不匹配', 'GENERATION_POEM_RESPONSE_INVALID')
}
const counts = ['createCount', 'updateCount', 'keepCount', 'disableCount']
const preview = { genealogyId, items: Array.isArray(value.items) ? value.items : null }
for (const field of counts) {
if (!Number.isSafeInteger(value[field]) || value[field] < 0) {
throw createRequestError(`字辈预览${field}无效`, 'GENERATION_POEM_RESPONSE_INVALID')
}
preview[field] = value[field]
}
if (preview.items === null) {
throw createRequestError('字辈预览缺少明细列表', 'GENERATION_POEM_RESPONSE_INVALID')
}
return preview
}
const normalizeLineageWritePayload = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.getPrototypeOf(payload) !== Object.prototype) {
throw new TypeError('人物写入请求必须是普通对象')
}
const allowedFields = new Set([
'name',
'aliasName',
'generationName',
'birthDate',
'birthLunar',
'birthPlace',
'deathDate',
'deathLunar',
'deathPlace',
'burialPlace',
'biography',
'remark',
'relationName'
])
for (const field of Object.keys(payload)) {
if (!allowedFields.has(field)) throw new TypeError(`人物写入包含未声明字段:${field}`)
}
const name = normalizeLineagePersonText(payload.name, '姓名', { required: true })
if (name.length > 20) throw new TypeError('人物姓名长度超出当前页面合同')
const data = { name }
for (const [field, label, maxLength] of [
['generationName', '字辈', 12],
['aliasName', '别名或曾用名', null],
['birthLunar', '出生农历', null],
['birthPlace', '出生地', null],
['deathLunar', '逝世农历', null],
['deathPlace', '逝世地', null],
['burialPlace', '安葬地', null],
['biography', '人物简介', 500],
['remark', '备注', null],
['relationName', '关系显示名称', null]
]) {
if (payload[field] === undefined) continue
const value = normalizeLineagePersonText(payload[field], label)
if (maxLength && value.length > maxLength) throw new TypeError(`人物${label}长度超出当前页面合同`)
if (value) data[field] = value
}
for (const [field, label] of [
['birthDate', '出生日期'],
['deathDate', '离世日期']
]) {
if (payload[field] === undefined) continue
const value = normalizeLineagePersonDate(payload[field], label)
if (value) data[field] = value
}
if (data.birthDate && data.deathDate && data.deathDate < data.birthDate) {
throw new TypeError('人物离世日期不能早于出生日期')
}
return data
}
const lineageRelationPath = Object.freeze({
FATHER: 'parents',
MOTHER: 'parents',
SPOUSE: 'spouses',
SIBLING: 'siblings',
SON: 'children',
DAUGHTER: 'children'
})
const requireLineageWriteRequestController = (requestOptions) => {
if (!requestOptions || typeof requestOptions !== 'object' || Array.isArray(requestOptions) || Object.getPrototypeOf(requestOptions) !== Object.prototype) {
throw new TypeError('人物写入请求选项必须是普通对象')
}
const fields = Object.keys(requestOptions)
if (fields.some((field) => field !== 'requestController')) {
throw new TypeError('人物写入请求选项包含未声明字段')
}
return requestOptions.requestController ?? null
}
export const appApi = {
async getCaptchaRequirement({ sceneCode, subject }, requestOptions = {}) {
requireRemoteAuth()
@@ -614,6 +999,32 @@ export const appApi = {
})
}, requestOptions)
},
async changePassword({ oldPasswordHash, newPasswordHash }, requestOptions = {}) {
requireRemoteAuth()
await requestStrict({
url: '/genealogy/app/auth/password',
method: 'PUT',
data: {
oldPassword: assertPasswordHash(oldPasswordHash),
newPassword: assertPasswordHash(newPasswordHash)
}
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async logout(requestOptions = {}) {
if (resolveRuntimeMode() !== 'remote') return null
await requestStrict({
url: '/genealogy/app/auth/logout',
method: 'DELETE'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async submitFeedback(payload, requestOptions = {}) {
const data = normalizeFeedbackPayload(payload)
if (!requestOptions || typeof requestOptions !== 'object' || Array.isArray(requestOptions) || Object.getPrototypeOf(requestOptions) !== Object.prototype) {
@@ -646,10 +1057,9 @@ export const appApi = {
},
async getMyGenealogies(requestOptions = {}) {
if (!hasRemoteConfig()) {
return genealogies.map((item) => ({
...item,
accessRole: item.membership === 'created' ? 'owner' : 'member'
}))
const error = new Error('我的家谱需要真实读取服务,当前本地预览不会伪造家谱列表')
error.code = 'REMOTE_READ_REQUIRED'
throw error
}
const result = await requestStrict({
url: '/genealogy/app/genealogies/mine',
@@ -673,7 +1083,11 @@ export const appApi = {
},
async getOverview(genealogyId, requestOptions = {}) {
const normalizedId = normalizeGenealogyPathId(genealogyId)
if (!hasRemoteConfig()) return this.getGenealogy(normalizedId)
if (!hasRemoteConfig()) {
const error = new Error('家谱概览需要真实读取服务,当前本地预览不会伪造概览数据')
error.code = 'REMOTE_READ_REQUIRED'
throw error
}
const result = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedId}/overview`,
method: 'GET'
@@ -687,58 +1101,289 @@ export const appApi = {
return overview
},
async getTree(genealogyId, requestOptions = {}) {
if (hasRemoteConfig()) {
const normalizedId = normalizeGenealogyPathId(genealogyId)
const tree = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedId}/lineage/tree`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeLineageTree(tree)
if (!hasRemoteConfig()) {
const error = new Error('世系树需要真实读取服务,当前本地预览不会伪造人物节点')
error.code = 'REMOTE_READ_REQUIRED'
throw error
}
return treeMembers
.filter((item) => String(item.genealogyId) === String(genealogyId))
.map(toTreeNode)
const normalizedId = normalizeGenealogyPathId(genealogyId)
const tree = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedId}/lineage/tree`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeLineageTree(tree)
},
async getPerson(genealogyId, personId, requestOptions = {}) {
if (!hasRemoteConfig()) {
const error = new Error('人物详情需要真实读取服务,当前本地预览不会伪造成员资料')
error.code = 'REMOTE_READ_REQUIRED'
throw error
}
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedPersonId = normalizeLineagePersonIdentity(personId, '人物标识')
const result = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons/${normalizedPersonId}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeLineagePersonDetail(result, normalizedGenealogyId, normalizedPersonId)
},
async getPersonPage(genealogyId, { pageNum = 1, pageSize = 10, keyword = '' } = {}, requestOptions = {}) {
if (!hasRemoteConfig()) {
const error = new Error('成员目录需要真实读取服务,当前本地预览不会伪造目录数据')
error.code = 'REMOTE_READ_REQUIRED'
throw error
}
if (!Number.isSafeInteger(pageNum) || pageNum < 1) throw new TypeError('成员目录页码无效')
if (!Number.isSafeInteger(pageSize) || pageSize < 1) throw new TypeError('成员目录页大小无效')
if (typeof keyword !== 'string') throw new TypeError('成员目录关键词无效')
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const result = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons/page`,
method: 'GET',
data: { pageNum, pageSize, ...(keyword.trim() ? { keyword: keyword.trim() } : {}) }
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeLineagePersonPage(result, normalizedGenealogyId)
},
async getFeedComments(genealogyId, feedId, requestOptions = {}) {
if (!hasRemoteConfig()) {
const error = new Error('动态评论需要真实读取服务,当前本地预览不会伪造评论数据')
error.code = 'REMOTE_READ_REQUIRED'
throw error
}
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedFeedId = normalizeFeedCommentId(feedId, '动态标识')
const result = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/${normalizedFeedId}/comments`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeFeedComments(result, normalizedGenealogyId, normalizedFeedId)
},
async createFeedComment(genealogyId, feedId, payload, requestOptions = {}) {
if (!hasRemoteConfig()) {
const error = new Error('动态评论需要真实服务,当前本地预览不会伪造提交成功')
error.code = 'REMOTE_WRITE_REQUIRED'
throw error
}
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedFeedId = normalizeFeedCommentId(feedId, '动态标识')
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/${normalizedFeedId}/comments`,
method: 'POST',
data: normalizeFeedCommentPayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
},
async getGenerationPoems(genealogyId, requestOptions = {}) {
if (!hasRemoteConfig()) {
const error = new Error('字辈列表需要真实读取服务,当前本地预览不会伪造字辈数据')
error.code = 'REMOTE_READ_REQUIRED'
throw error
}
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const result = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/generation-poems`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeGenerationPoemRows(result, normalizedGenealogyId)
},
async getGenerationPoemManagement(genealogyId, requestOptions = {}) {
if (!hasRemoteConfig()) {
const error = new Error('字辈维护列表需要真实读取服务,当前本地预览不会伪造管理权限')
error.code = 'REMOTE_READ_REQUIRED'
throw error
}
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const result = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/generation-poems/management`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeGenerationPoemRows(result, normalizedGenealogyId)
},
async previewGenerationPoemBatch(genealogyId, payload, requestOptions = {}) {
if (!hasRemoteConfig()) {
const error = new Error('字辈批量预览需要真实服务,当前本地预览不会伪造差异结果')
error.code = 'REMOTE_READ_REQUIRED'
throw error
}
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const result = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/generation-poems/batch/preview`,
method: 'POST',
data: normalizeGenerationPoemBatchPayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeGenerationPoemPreview(result, normalizedGenealogyId)
},
async saveGenerationPoemBatch(genealogyId, payload, requestOptions = {}) {
if (!hasRemoteConfig()) {
const error = new Error('字辈批量保存需要真实服务,当前本地预览不会伪造保存成功')
error.code = 'REMOTE_WRITE_REQUIRED'
throw error
}
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/generation-poems/batch/save`,
method: 'POST',
data: normalizeGenerationPoemBatchPayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
},
async createPerson(genealogyId, payload, requestOptions = {}) {
if (hasRemoteConfig()) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedPersonId = normalizeLineagePersonIdentity(personId, '人物标识')
const result = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons/${normalizedPersonId}`,
method: 'GET'
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons`,
method: 'POST',
data: normalizeLineageWritePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
requestController: requireLineageWriteRequestController(requestOptions)
})
return normalizeLineagePersonDetail(result, normalizedGenealogyId, normalizedPersonId)
}
const person = treeMembers.find(
(item) => String(item.id) === String(personId) &&
String(item.genealogyId) === String(genealogyId)
)
return person ? toTreeNode(person) : null
},
async createPerson(genealogyId, payload) {
if (hasRemoteConfig()) {
const person = await request({ url: `/genealogy/app/genealogies/${genealogyId}/lineage/persons`, method: 'POST', data: payload })
return toTreeNode(person)
}
const error = new Error('人物创建接口在本地预览模式不可用,当前内容不会保存')
error.code = 'WRITE_UNAVAILABLE'
throw error
},
async createRelatedPerson(genealogyId, personId, relationType, payload, requestOptions = {}) {
if (!hasRemoteConfig()) {
const error = new Error('人物关系写入接口在本地预览模式不可用,当前内容不会保存')
error.code = 'WRITE_UNAVAILABLE'
throw error
}
const relationPath = lineageRelationPath[relationType]
if (!relationPath) throw new TypeError('人物关系类型不属于当前合同')
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedPersonId = normalizeLineagePersonIdentity(personId, '人物标识')
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons/${normalizedPersonId}/${relationPath}`,
method: 'POST',
data: normalizeLineageWritePayload(payload)
}, {
requestController: requireLineageWriteRequestController(requestOptions)
})
},
async updatePerson(genealogyId, personId, payload, requestOptions = {}) {
if (!hasRemoteConfig()) {
const error = new Error('人物编辑接口在本地预览模式不可用,当前内容不会保存')
error.code = 'WRITE_UNAVAILABLE'
throw error
}
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedPersonId = normalizeLineagePersonIdentity(personId, '人物标识')
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons/${normalizedPersonId}`,
method: 'PUT',
data: normalizeLineageWritePayload(payload)
}, {
requestController: requireLineageWriteRequestController(requestOptions)
})
},
async getFeeds(genealogyId) {
return hasRemoteConfig()
? request({ url: `/genealogy/app/genealogies/${genealogyId}/feeds` })
: listFamilyFeedFixtures(genealogyId)
},
async createFeed(genealogyId, payload) {
if (hasRemoteConfig()) return request({ url: `/genealogy/app/genealogies/${genealogyId}/feeds`, method: 'POST', data: payload })
async createFeed(genealogyId, payload, requestOptions = {}) {
if (hasRemoteConfig()) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds`,
method: 'POST',
data: normalizeFeedCreatePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
}
const error = new Error('动态发布接口尚未接入,当前内容不会保存')
error.code = 'WRITE_UNAVAILABLE'
throw error
},
async createArticle(genealogyId, payload, requestOptions = {}) {
if (hasRemoteConfig()) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/articles`,
method: 'POST',
data: normalizeArticleCreatePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
}
const error = new Error('谱文创建需要真实服务,当前本地预览不会伪造创建成功')
error.code = 'REMOTE_WRITE_REQUIRED'
throw error
},
async createAlbum(genealogyId, payload, requestOptions = {}) {
if (hasRemoteConfig()) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/albums`,
method: 'POST',
data: normalizeAlbumCreatePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
}
const error = new Error('相册创建需要真实服务,当前本地预览不会伪造创建成功')
error.code = 'REMOTE_WRITE_REQUIRED'
throw error
},
async createRelativeRecord(genealogyId, payload, requestOptions = {}) {
if (hasRemoteConfig()) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/relative-records`,
method: 'POST',
data: normalizeRelativeRecordCreatePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
}
const error = new Error('亲友往来创建需要真实服务,当前本地预览不会伪造创建成功')
error.code = 'REMOTE_WRITE_REQUIRED'
throw error
},
async createGrowthRecord(genealogyId, payload, requestOptions = {}) {
if (hasRemoteConfig()) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({ url: `/genealogy/app/genealogies/${normalizedGenealogyId}/growth-records`, method: 'POST', data: normalizeGrowthRecordCreatePayload(payload) }, { requestController: requestOptions.requestController ?? null })
}
const error = new Error('成长记录创建需要真实服务,当前本地预览不会伪造创建成功')
error.code = 'REMOTE_WRITE_REQUIRED'
throw error
},
async createMemo(genealogyId, payload, requestOptions = {}) {
if (hasRemoteConfig()) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({ url: `/genealogy/app/genealogies/${normalizedGenealogyId}/memos`, method: 'POST', data: normalizeMemoCreatePayload(payload) }, { requestController: requestOptions.requestController ?? null })
}
const error = new Error('备忘创建需要真实服务,当前本地预览不会伪造创建成功')
error.code = 'REMOTE_WRITE_REQUIRED'
throw error
},
async createMeritRecord(genealogyId, payload, requestOptions = {}) {
if (hasRemoteConfig()) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({ url: `/genealogy/app/genealogies/${normalizedGenealogyId}/merit-records`, method: 'POST', data: normalizeMeritRecordCreatePayload(payload) }, { requestController: requestOptions.requestController ?? null })
}
const error = new Error('功德记录创建需要真实服务,当前本地预览不会伪造创建成功')
error.code = 'REMOTE_WRITE_REQUIRED'
throw error
},
async getArticles(genealogyId) {
return hasRemoteConfig()
? request({ url: `/genealogy/app/genealogies/${genealogyId}/articles` })
+3 -2
View File
@@ -122,7 +122,7 @@ export const ROUTES = Object.freeze({
parent: "T01",
parentParamMap: { selectedId: "personId" },
requiredParams: ["genealogyId"],
optionalParams: ["personId", "mode"],
optionalParams: ["personId", "mode", "relationType"],
allowedSources: ["T01"],
}),
T05: defineRoute({
@@ -130,7 +130,7 @@ export const ROUTES = Object.freeze({
kind: "flow",
parent: "T03",
requiredParams: ["genealogyId", "personId"],
allowedSources: ["T03"],
allowedSources: ["T01", "T03"],
}),
T06: defineRoute({
path: "/pages/tree/t06-edit-relationship",
@@ -138,6 +138,7 @@ export const ROUTES = Object.freeze({
parent: "T01",
parentParamMap: { selectedId: "personId" },
requiredParams: ["genealogyId", "personId"],
optionalParams: ["mode"],
allowedSources: ["T01"],
}),
T07: defineRoute({