60%
This commit is contained in:
@@ -135,7 +135,7 @@ export default {
|
||||
return {
|
||||
generation: 0,
|
||||
tac: null,
|
||||
context: null,
|
||||
requestContext: null,
|
||||
challenge: null,
|
||||
activeXhr: null,
|
||||
fatalSent: false,
|
||||
@@ -148,7 +148,7 @@ export default {
|
||||
this.generation += 1;
|
||||
const generation = this.generation;
|
||||
this.destroyTac();
|
||||
this.context = nextContext || null;
|
||||
this.requestContext = nextContext || null;
|
||||
this.challenge = null;
|
||||
this.fatalSent = false;
|
||||
this.completionSent = false;
|
||||
@@ -160,10 +160,10 @@ export default {
|
||||
loadScript(TAC_SCRIPT_URL, () => Boolean(window.TAC && window.CaptchaConfig)),
|
||||
]);
|
||||
await loadScript(TAC_ADAPTER_URL, () => Boolean(window.JiapuTacAdapter));
|
||||
if (generation !== this.generation || !this.context || this.context.visible !== true) return;
|
||||
if (generation !== this.generation || !this.requestContext || this.requestContext.visible !== true) return;
|
||||
this.createTac();
|
||||
} catch (error) {
|
||||
if (generation !== this.generation || !this.context || this.context.visible !== true) return;
|
||||
if (generation !== this.generation || !this.requestContext || this.requestContext.visible !== true) return;
|
||||
this.notifyFatal(error);
|
||||
}
|
||||
},
|
||||
@@ -173,17 +173,17 @@ export default {
|
||||
}
|
||||
this.completionSent = false;
|
||||
const generation = this.generation;
|
||||
const requestContext = this.context;
|
||||
const requestContext = this.requestContext;
|
||||
const isCurrent = () =>
|
||||
generation === this.generation &&
|
||||
this.context === requestContext &&
|
||||
this.requestContext === requestContext &&
|
||||
requestContext.visible === true;
|
||||
const config = new window.CaptchaConfig({
|
||||
bindEl: "#jiapu-tac-host",
|
||||
requestCaptchaDataUrl: this.context.challengeUrl,
|
||||
validCaptchaUrl: this.context.verifyUrl,
|
||||
requestCaptchaDataUrl: this.requestContext.challengeUrl,
|
||||
validCaptchaUrl: this.requestContext.verifyUrl,
|
||||
requestHeaders: {
|
||||
clientid: this.context.clientId,
|
||||
clientid: this.requestContext.clientId,
|
||||
},
|
||||
validSuccess: (response, captcha, tac) => {
|
||||
if (!isCurrent() || this.completionSent) return;
|
||||
@@ -238,17 +238,17 @@ export default {
|
||||
beforeRequest(type, request) {
|
||||
if (type === "requestCaptchaData") {
|
||||
request.data = {
|
||||
tenantId: this.context.tenantId,
|
||||
clientId: this.context.clientId,
|
||||
sceneCode: this.context.sceneCode,
|
||||
subject: this.context.subject,
|
||||
tenantId: this.requestContext.tenantId,
|
||||
clientId: this.requestContext.clientId,
|
||||
sceneCode: this.requestContext.sceneCode,
|
||||
subject: this.requestContext.subject,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
if (type === "validCaptcha") {
|
||||
request.data = window.JiapuTacAdapter.buildVerifyBody(
|
||||
request.data,
|
||||
this.context,
|
||||
this.requestContext,
|
||||
this.challenge,
|
||||
);
|
||||
}
|
||||
@@ -257,7 +257,7 @@ export default {
|
||||
afterRequest(type, response) {
|
||||
try {
|
||||
if (type === "requestCaptchaData") {
|
||||
const mapped = window.JiapuTacAdapter.normalizeChallengeResponse(response, this.context);
|
||||
const mapped = window.JiapuTacAdapter.normalizeChallengeResponse(response, this.requestContext);
|
||||
this.challenge = mapped.challenge;
|
||||
this.replaceResponse(response, mapped.sdkResponse);
|
||||
} else if (type === "validCaptcha") {
|
||||
@@ -326,7 +326,7 @@ export default {
|
||||
if (this.fatalSent) return;
|
||||
this.fatalSent = true;
|
||||
this.$ownerInstance.callMethod("handleTacError", {
|
||||
requestId: this.context && this.context.requestId,
|
||||
requestId: this.requestContext && this.requestContext.requestId,
|
||||
message: (error && error.message) || "安全验证暂不可用",
|
||||
});
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
| 核对项 | 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,故不发送 | 页面只消费可明确映射的文本动态请求子集 |
|
||||
| 请求体 | `application/json`:`feedContent:string` 必填且不能为空;`feedType:string` 可选,未传默认 `text`;`mediaOssIds:string` 可选,多个 OSS ID 用英文逗号分隔;`sortOrder:int64` 可选,未传默认 `0`;`status:string` 可选,未传默认正常状态 `0` | 表单收集并提交内容、动态类型、通过上传回执取得的媒体和排序值;`status` 使用服务端已声明的默认值,不暴露“0”之类状态码输入 | 已接线;真实写入仍待人工响应核验 |
|
||||
| 响应字段 | `200 ObjectResult` 只声明通用 `code`、`msg`、`data:object`,未声明新动态 DTO | 仅在严格成功信封后显示“已提交服务端”;不会在 F01 生成本地列表项 | **未完成 / DECLARED_UNVERIFIED**:接线不等于已验证;真实写入保留人工可观察窗口 |
|
||||
|
||||
### F03 动态详情与评论
|
||||
@@ -38,16 +38,16 @@
|
||||
| --- | --- | --- | --- |
|
||||
| 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 新建 | `POST /genealogy/app/genealogies/{genealogyId}/articles`;鉴权、`genealogyId:int64`、`clientid:string` 必填 | `appApi.createArticle` 严格提交标题、摘要、封面上传回执、正文、作者和排序;成功仅表示服务端成功信封,不生成本地文章 | 已接线,等待真实写入响应核验 |
|
||||
| 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,所有写入待人工真实响应核验 |
|
||||
| 新建/修改请求体 | `categoryId:int64` 可选;`articleTitle:string` 必填;`articleSummary:string` 可选;`coverOssId:int64` 可选;`articleContent:string` 必填;`authorName:string`、`sortOrder:int64`、`status:string` 均可选。返回均为通用 `ObjectResult` | 新建页收集可读字段;封面只接收真实上传回执。`categoryId` 需要分类列表条目 DTO,`status` 需要状态字典,二者均不让用户填写内部 ID/码 | 可写字段已接线;分类与状态选择仍 **DECLARED_UNVERIFIED**,编辑仍需可靠 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`;成功仅表示服务端成功信封,不生成本地相册卡片 | 已接线,等待真实写入响应核验;描述、封面、排序、状态无输入来源,故不发送 |
|
||||
| F07 新建相册 | `POST /genealogy/app/genealogies/{genealogyId}/albums`;请求体 `albumName:string` 必填,`albumDesc:string`、`coverOssId:int64`、`sortOrder:int64`、`status:string` 可选;返回通用 `ObjectResult` | 表单提交名称、说明、真实封面上传回执和排序;`status` 缺可读字典,不向用户暴露码值 | 已接线,等待真实写入响应核验 |
|
||||
| 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 回执,不能把本地图片冒充上传成功 |
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
| 页面/动作 | 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 |
|
||||
| G03 创建家谱 | `POST /genealogy/app/genealogies`;`genealogyName`、`surname`、`regionCode` 必填;`ancestralHall/originPlace/addressDetail/coverOssId/intro/visibility/joinMode` 可选。`visibility`:`0` 私密、`1` 公开、`2` 成员可见;`joinMode`:`0` 关闭、`1` 审核、`2` 邀请码 | 表单提供全部可读资料字段;地区为三级级联选择器,封面只由真实上传回执产生,访问规则由可读选项映射为 `visibility/joinMode`。已删除本地家谱/首位人物预览。创建后必须从真实响应取得 `genealogyId` 再创建首位人物;当前没有可恢复查询 owner,不能从泛型 mine 列表按名称猜 ID | **未完成 / MISSING_OPERATION**:两阶段创建结果恢复链未闭合;写入与上传已接线,仍待人工真实响应核验 |
|
||||
| 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**,不发送申请 |
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
| 页面/动作 | 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 /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` 须来自文件上传组件 | 表单已提供并提交当前可安全映射的 `aliasName/generationName/birthDate/birthLunar/birthPlace/deathDate/deathLunar/deathPlace/burialPlace/biography/remark`;非首位成员同时提交关系选择产生的 `relationName`。性别、人物状态缺字典 owner;关联账号、父母、头像与排行需要独立对象选择、上传或原子排序 owner,不能让用户填写内部 ID 或猜码。真实写入未验证。**未完成 / 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` 伪造同辈原子排行调整 |
|
||||
@@ -115,7 +115,7 @@
|
||||
| 页面/动作 | 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**:读取已接线并经静态检查,仍未用真实只读响应核验;没有字典映射时不得生成隐私/受限/纪念文案。 |
|
||||
| T08 成员状态说明 | 人物详情、列表和分页都提供 `personStatus`、`status`;停用人物为 `DELETE /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}`,接口说明为逻辑停用且在正常子女时拒绝停用,返回 `VoidResult` | 已移除 `findTreeMemberPresentationFixture` 和 `privacy/deceased/forbidden` 推断;页面读取人物详情,但在无状态字典时不展示原始 `personStatus` 值,也不提供停用写入。 | **未完成 / DECLARED_UNVERIFIED**:读取已接线并经静态检查,仍未用真实只读响应核验;没有字典映射时不得展示内部码或生成隐私/受限/纪念文案。 |
|
||||
|
||||
## R 记录模块
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
| 页面/动作 | 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 新增/修改 | `POST /genealogy/app/genealogies/{genealogyId}/relative-records`、`PUT .../relative-records/{relativeId}`;body 为 `relativeName:string` 必填,`relationName/eventName/eventTime/giftAmount:number/recordContent/mediaOssIds/sortOrder/status` 可选,返回通用 `ObjectResult` | 创建页提交姓名、关系、事项、时间、金额、备注、真实媒体回执和排序;`status` 无可读字典,不向用户暴露状态码。详情/修改入口因无记录 DTO/ID 来源关闭 | 创建已接线,等待真实写入响应核验;修改仍 **DECLARED_UNVERIFIED** |
|
||||
| R04 删除 | `DELETE .../relative-records/{relativeId}` 已在 Apifox 同一资源目录确认 | 页面明确显示“删除暂未开放” | **未完成 / DECLARED_UNVERIFIED**:不执行删除;存在删除 operation 也不改变其他读写未接线的事实 |
|
||||
|
||||
### R05—R07 礼仪活动与献礼
|
||||
@@ -149,7 +149,7 @@
|
||||
| 页面/动作 | 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** |
|
||||
| R08 新增/修改 | `POST .../growth-records`、`PUT .../growth-records/{recordId}`;新增 body 已实读:`lineagePersonId/recordType/recordContent/recordDate/remindTime/mediaOssIds/sortOrder/status` 可选,`recordTitle:string` 必填 | 从人物档案进入时自动绑定当前人物,表单提交类型、标题、内容、日期、提醒、真实媒体回执和排序;`status` 无可读字典,不向用户暴露状态码。修改没有记录 ID 来源而关闭 | 创建已接线,等待真实写入响应核验;修改 **DECLARED_UNVERIFIED** |
|
||||
| R09 人生事 | 分别以 `life` 与“人生”在 APP 接口目录检索,均未命中独立人生事件资源;当前文档中不能用成长、备忘或人物资料替代 | 页面已明确提示接口未开放且不展示/提交数据 | **未完成 / MISSING_OPERATION**:保持关闭是正确的,不虚构读写链路 |
|
||||
|
||||
### R10 家族备忘、R11 功德记录
|
||||
@@ -157,9 +157,25 @@
|
||||
| 页面/动作 | 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** |
|
||||
| R10 新增/修改 | `POST .../memos`、`PUT .../memos/{memoId}`;body `memoTitle:string` 必填,`memoContent/remindTime/completed/mediaOssIds/sortOrder/status` 可选 | 表单提交标题、日期与时间选择器生成的提醒时间、内容、真实媒体上传回执和排序。`completed/status` 未声明可读值或字典,均不向用户暴露 `0/1` 码。修改缺 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** |
|
||||
| R11 新增/修改 | `POST .../merit-records`;body `donorName:string`、`meritTitle:string` 必填,`meritType/meritContent/amount:number/meritTime/sortOrder/status` 可选;当前 APP 目录未见修改 operation | 表单提交捐赠人、标题、类型、金额、时间、内容和排序;`status` 缺可读字典,不向用户暴露状态码 | 新增已接线,等待真实写入响应核验;编辑 **MISSING_OPERATION** |
|
||||
|
||||
### 2026-07-24 实际服务端回读补充(覆盖上述 R03—R11 的旧“未验证”结论)
|
||||
|
||||
已用当前测试账号在家谱 2080557121112465409 完成真实创建和列表回读;未执行删除、退出、短信或改密操作。
|
||||
|
||||
| 资源 | 实际返回字段(已回读) | 页面闭环 |
|
||||
| --- | --- | --- |
|
||||
| 亲友往来 | relativeId、relativeName、relationName、eventName、eventTime、giftAmount、recordContent、mediaOssIds、sortOrder | R04 创建后由 R03 列表回读 |
|
||||
| 礼仪活动 | ceremonyId、ceremonyType、ceremonyTitle、ceremonyDesc、ceremonyTime、location、giftCount | R07 创建后由 R05 列表回读 |
|
||||
| 成长记录 | recordId、lineagePersonId、lineagePersonName、recordType、recordTitle、recordContent、recordDate、remindTime、mediaOssIds、sortOrder | R08 先读列表,创建后重新读取 |
|
||||
| 家族备忘 | memoId、memoTitle、memoContent、remindTime、completed、mediaOssIds、sortOrder | R10 先读列表,创建后重新读取;completed 未展示为业务状态 |
|
||||
| 功德记录 | meritId、donorName、meritType、meritTitle、meritContent、amount、meritTime、sortOrder | R11 先读列表,创建后重新读取并计算金额合计 |
|
||||
|
||||
以上五组的列表与创建状态均为 VERIFIED。单条详情、修改和删除仍按页面当前入口及测试范围分别保留,不把未测试能力标记为通过。
|
||||
|
||||
世系首位成员创建于同一测试家谱进行了页面提交和合同最小请求复核:POST lineage/persons 的合法最小 body(name、generation=1)均返回 HTTP 200、业务 code 500、消息“发生未知异常,请联系管理员”;随后 GET 世系树仍为空。该项为服务端业务异常,前端未猜测额外字段或伪造创建成功;T04 首位成员失败文案已明确为“首位成员尚未保存”。
|
||||
|
||||
## N 消息通知
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
- 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: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`,未进入产品断言;输出仅保存在本机临时验收目录。
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# 全量页面接口联调交接说明
|
||||
|
||||
更新日期:2026-07-24
|
||||
适用项目:家谱 APP 前端(`jiapuapp`)
|
||||
|
||||
## 一、交接结论
|
||||
|
||||
本轮已按 `pages.json` 对 **52 个已注册页面**做了重新复核:页面可进入、页面调用的接口方法存在、真实服务端可读取的数据已回读验证、没有继续以页面 mock/fixture 冒充服务端结果。
|
||||
|
||||
不能把本轮结论简化成“所有接口均已联调通过”。世系首位成员创建目前由服务端返回业务 `code: 500`,导致依赖真实成员的详情、编辑与关系链路没有可用测试实体,仍是明确阻塞项。
|
||||
|
||||
## 二、本轮已验证的范围
|
||||
|
||||
### 2.1 页面路由与接线
|
||||
|
||||
| 模块 | 页面数 | 浏览器逐页打开 | 结论 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| A 认证 | 3 | 3/3 | 页面入口、表单和会话态下的路由行为已检查;未触发短信、注册、找回密码等敏感写入。 |
|
||||
| G 家谱 | 9 | 9/9 | 家谱列表、创建、概览、搜索、申请、审核、设置、字辈页面均可稳定进入。 |
|
||||
| T 世系 | 7 | 7/7 | 树、成员目录及安全错误态可进入;真实成员读写见“阻塞项”。 |
|
||||
| F 家族 | 10 | 10/10 | 动态、谱文、相册、上传等页面均可进入;真实动态/谱文/相册数据已读回。 |
|
||||
| R 记录 | 11 | 11/11 | 人物、亲友往来、礼仪、成长、备忘、功德等页面均可进入;多类真实创建已回读。 |
|
||||
| N 通知 | 2 | 2/2 | 通知中心可读取;消息详情保持缺少读取 owner 的关闭态。 |
|
||||
| M 我的 | 10 | 10/10 | 资料、帮助、推广、VIP 等页面可进入;敏感安全操作未执行。 |
|
||||
| 合计 | **52** | **52/52** | 路由稳定渲染通过。 |
|
||||
|
||||
页面内共扫描到 71 个 `appApi` 调用,均能在 `utils/api.js` 找到对应方法;页面目录未发现 `data/mock` 或 fixture 导入。
|
||||
|
||||
### 2.2 已做过真实服务端读回的业务
|
||||
|
||||
以下均只使用测试账号和测试家谱数据,未做删除、退出、改密、短信或支付操作。
|
||||
|
||||
| 业务 | 已验证闭环 |
|
||||
| --- | --- |
|
||||
| 家谱创建 | G03 创建后,可在 G01 列表和 G05 概览读回。 |
|
||||
| 家族动态 | F02 创建后,可在 F01 列表、F03 详情读回。 |
|
||||
| 谱文 | F06 创建后,可在 F04 列表、F05 详情读回。 |
|
||||
| 相册 | F07 创建后,可在 F08 相册详情读回。 |
|
||||
| 亲友往来 | R04 创建后,可在 R03 列表读回。 |
|
||||
| 礼仪活动 | R07 创建后,可在 R05 列表读回。 |
|
||||
| 成长记录 | R08 创建后重新读取列表。 |
|
||||
| 家族备忘 | R10 创建后重新读取列表。 |
|
||||
| 功德记录 | R11 创建后重新读取列表。 |
|
||||
| 个人资料、帮助、推广、VIP、通知 | 已完成真实读取页面验证;不输出账号或会话敏感信息。 |
|
||||
|
||||
### 2.3 本轮修正
|
||||
|
||||
- F01/F03 不再把服务端 `feedType=text` 原码直接展示给用户,统一显示为“文字动态”。
|
||||
- 更新了 F 模块业务合同测试,使其校验当前真实 API 读写路径,而非旧 fixture/预览路径。
|
||||
- 新增全页面浏览器审查脚本:`tests/all-page-route-runtime-smoke.js`。
|
||||
|
||||
## 三、明确阻塞:世系首位成员创建
|
||||
|
||||
### 现象
|
||||
|
||||
测试家谱为空世系时,T04 “录入首位成员”已完成前端表单校验与请求组装;使用合法最小请求(姓名、第一世)调用:
|
||||
|
||||
```text
|
||||
POST /genealogy/app/genealogies/{genealogyId}/lineage/persons
|
||||
```
|
||||
|
||||
HTTP 返回成功,但业务响应为 `code: 500`,未创建成员;之后读取世系树仍为空。
|
||||
|
||||
### 影响范围
|
||||
|
||||
- T03 成员档案:没有真实 `personId`,无法完成真实详情读回。
|
||||
- T04 新增亲属:没有首位成员,不能继续建立亲属关系。
|
||||
- T05 编辑成员、T06 调整排行、T08 成员状态:没有真实成员实体,不能完成真实读写验证。
|
||||
- R01/R02 的真实人物详情同样没有可用实体可验证。
|
||||
|
||||
### 需要后端协助确认
|
||||
|
||||
1. 查询上述创建请求对应的服务端异常栈和租户/家谱初始化条件。
|
||||
2. 确认创建首位成员是否还存在 Apifox 未声明的必填字段或前置初始化操作;如有,补充到 APP 接口合同。
|
||||
3. 修复后提供一个可创建或可读取的测试成员,再继续 T03–T08、R01–R02 的真实闭环验证。
|
||||
|
||||
前端不会猜测额外字段,也不会用本地假成员伪造成功。
|
||||
|
||||
## 四、正确关闭的页面/动作
|
||||
|
||||
下列不是漏接,而是当前 APP 接口没有足以支撑该页面真实业务的 owner,或操作超出本轮授权范围:
|
||||
|
||||
| 页面/能力 | 当前处理 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| F10 家族视频 | 明确提示暂未开放 | 仅见删除 operation,未见视频列表、详情、播放、发布等读取/写入合同。 |
|
||||
| R09 人生事件 | 明确提示暂未开放 | APP 目录中无独立人生事件资源。 |
|
||||
| N02 消息详情 | 明确提示暂未开放 | 只有通知列表和标已读,没有单条详情读取 owner。 |
|
||||
| M03 安全概览 | 不展示假账号/设备摘要 | 无安全概览、设备、登录记录读取合同。 |
|
||||
| M05 换绑手机号 | 等待人工 TAC/短信验证 | 缺少可安全消费的当前手机号 DTO 与已核实验证码链路。 |
|
||||
| 删除、退出、改密、支付 | 未执行 | 本轮测试范围禁止敏感操作。 |
|
||||
|
||||
## 五、复测方式
|
||||
|
||||
### 5.1 前置条件
|
||||
|
||||
- 启动本地 Web 预览,默认地址:`http://localhost:5173`。
|
||||
- 使用已登录的测试账号浏览器,Chrome 远程调试端口为实际可用端口。
|
||||
- 设置一个测试家谱 ID;不要使用生产数据。
|
||||
|
||||
### 5.2 全页面路由复核
|
||||
|
||||
为避免一次跑完整 52 页超过终端时限,可分四段执行:
|
||||
|
||||
```powershell
|
||||
$env:CDP_PORT='9777'
|
||||
$env:GENEALOGY_ID='<测试家谱ID>'
|
||||
$env:PAGE_AUDIT_START='0'; $env:PAGE_AUDIT_END='26'; node tests\all-page-route-runtime-smoke.js http://localhost:5173
|
||||
$env:PAGE_AUDIT_START='26'; $env:PAGE_AUDIT_END='34'; node tests\all-page-route-runtime-smoke.js http://localhost:5173
|
||||
$env:PAGE_AUDIT_START='34'; $env:PAGE_AUDIT_END='45'; node tests\all-page-route-runtime-smoke.js http://localhost:5173
|
||||
$env:PAGE_AUDIT_START='45'; $env:PAGE_AUDIT_END='52'; node tests\all-page-route-runtime-smoke.js http://localhost:5173
|
||||
```
|
||||
|
||||
脚本会为有测试实体的页面读取真实数据;成员专属页面在后端首位成员故障未修复前只验证安全错误态,不把这当作真实成员接口通过。
|
||||
|
||||
### 5.3 静态和编译检查
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File tests\compile-audit.ps1
|
||||
powershell -ExecutionPolicy Bypass -File tests\interface-page-mapping-contract.ps1
|
||||
powershell -ExecutionPolicy Bypass -File tests\active-page-business-ownership-contract.ps1
|
||||
powershell -ExecutionPolicy Bypass -File tests\f-business-flow-contract.ps1
|
||||
powershell -ExecutionPolicy Bypass -File tests\r-business-flow-contract.ps1
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 六、继续推进顺序
|
||||
|
||||
1. 后端先解决首位成员创建 `code:500`,或提供可读取的测试成员。
|
||||
2. 用真实成员复测 T03–T08、R01–R02 的读取和允许的写入闭环。
|
||||
3. 对接口已存在但页面因没有稳定 ID 暂未开放的单条详情/编辑能力,拿到响应 DTO 和 ID 后逐项补齐。
|
||||
4. 需要判断视觉问题时,以模拟器为准;浏览器主要用于接口、路由和网络请求验证。
|
||||
|
||||
## 七、交接边界
|
||||
|
||||
- 不应把“页面能打开”或“表单显示成功”写成后端业务成功;必须以服务端返回和后续读取回显为准。
|
||||
- 不应添加 mock 成员、mock 视频、mock 消息详情或本地成功态补齐后端缺口。
|
||||
- 不应在无人值守时执行短信、换绑、改密、退出、删除、审核或支付。
|
||||
- 测试过程中产生的是测试家谱中的测试数据;后续清理需要由具备删除授权的人员单独确认后执行。
|
||||
+34
-100
@@ -1,4 +1,4 @@
|
||||
<!-- 页面编号:A-01;用途:APP 唯一登录入口,承载密码、验证码与微信登录入口。 -->
|
||||
<!-- 页面编号:A-01;用途:APP 唯一登录入口,承载密码与验证码登录。 -->
|
||||
<template>
|
||||
<AuthPageShell class="auth-page login-page">
|
||||
<view class="login-content">
|
||||
@@ -35,7 +35,7 @@
|
||||
</view>
|
||||
|
||||
<view class="form-content">
|
||||
<view class="input-row">
|
||||
<view class="input-row input-row--required">
|
||||
<image
|
||||
class="input-icon"
|
||||
src="/static/assets/modules/auth/transparent/a01-icon-phone-v1.png"
|
||||
@@ -55,7 +55,7 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-if="activeLoginMethod === 'password'" class="input-row">
|
||||
<view v-if="activeLoginMethod === 'password'" class="input-row input-row--required">
|
||||
<image
|
||||
class="input-icon"
|
||||
src="/static/assets/modules/auth/transparent/a01-icon-lock-v1.png"
|
||||
@@ -94,7 +94,7 @@
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-else class="input-row">
|
||||
<view v-else class="input-row input-row--required">
|
||||
<image
|
||||
class="input-icon input-icon--sms"
|
||||
src="/static/assets/modules/auth/transparent/a01-icon-sms-code-v2.png"
|
||||
@@ -155,43 +155,6 @@
|
||||
}}</text>
|
||||
</button>
|
||||
|
||||
<view class="other-login-divider">
|
||||
<view class="divider-line" />
|
||||
<image
|
||||
class="divider-knot"
|
||||
src="/static/assets/foundation/transparent/auth-divider-knot.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>其他登录方式</text>
|
||||
<image
|
||||
class="divider-knot divider-knot--right"
|
||||
src="/static/assets/foundation/transparent/auth-divider-knot.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="divider-line" />
|
||||
</view>
|
||||
|
||||
<button
|
||||
class="auth-plain-button wechat-login"
|
||||
:disabled="submitting || sendingCode || tacVisible"
|
||||
hover-class="button-pressed"
|
||||
@click="prepareWechatLogin"
|
||||
>
|
||||
<image
|
||||
class="button-skin"
|
||||
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="wechat-login__content">
|
||||
<image
|
||||
class="wechat-icon"
|
||||
src="/static/assets/foundation/transparent/auth-wechat.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>微信登录</text>
|
||||
</view>
|
||||
</button>
|
||||
|
||||
<view class="register-entry">
|
||||
<text>还没有账号?</text>
|
||||
<button
|
||||
@@ -281,6 +244,7 @@ import {
|
||||
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
|
||||
import { runtimeConfig } from "@/utils/config.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
import { session } from "@/utils/session.js";
|
||||
import {
|
||||
goRoot,
|
||||
handleBackPress,
|
||||
@@ -324,8 +288,6 @@ onUnload(() => {
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
smsCooldown.dispose();
|
||||
});
|
||||
onShow(() => smsCooldown.sync());
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message;
|
||||
feedbackVisible.value = true;
|
||||
@@ -351,6 +313,23 @@ const enterAuthenticatedRoot = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const restoreAuthenticatedSession = () => {
|
||||
smsCooldown.sync();
|
||||
if (
|
||||
authenticationCommitted.value ||
|
||||
submitting.value ||
|
||||
sendingCode.value ||
|
||||
tacVisible.value ||
|
||||
!session.getToken()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
authenticationCommitted.value = true;
|
||||
void enterAuthenticatedRoot();
|
||||
};
|
||||
|
||||
onShow(restoreAuthenticatedSession);
|
||||
|
||||
const blockBusyAction = () => {
|
||||
if (!sendingCode.value && !submitting.value) return false;
|
||||
showFeedback("请求处理中,请稍候");
|
||||
@@ -643,11 +622,6 @@ const prepareRegister = () => {
|
||||
return openPage("A04", {}, "A01");
|
||||
};
|
||||
|
||||
const prepareWechatLogin = () => {
|
||||
if (!requireAgreement()) return;
|
||||
showFeedback("微信登录功能准备中");
|
||||
};
|
||||
|
||||
const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
</script>
|
||||
|
||||
@@ -729,6 +703,7 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
}
|
||||
|
||||
.input-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
@@ -737,6 +712,16 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
border-bottom: 1rpx solid rgba(182, 116, 43, 0.68);
|
||||
}
|
||||
|
||||
.input-row--required::before {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
left: 0;
|
||||
content: "*";
|
||||
color: #b42318;
|
||||
font-size: 26rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 64rpx;
|
||||
@@ -808,8 +793,7 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.login-submit,
|
||||
.wechat-login {
|
||||
.login-submit {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
@@ -834,56 +818,6 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
letter-spacing: 5rpx;
|
||||
}
|
||||
|
||||
.other-login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
color: #9f6a27;
|
||||
font-size: 28rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
flex: 1;
|
||||
height: 1rpx;
|
||||
margin: 0 8rpx;
|
||||
background: rgba(180, 113, 38, 0.64);
|
||||
}
|
||||
|
||||
.divider-knot {
|
||||
width: 17rpx;
|
||||
height: 17rpx;
|
||||
margin-right: 7rpx;
|
||||
}
|
||||
|
||||
.divider-knot--right {
|
||||
margin: 0 0 0 7rpx;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.wechat-login {
|
||||
min-height: var(--app-touch-min);
|
||||
height: var(--app-touch-min);
|
||||
color: #493323;
|
||||
font-size: 30rpx;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.wechat-login__content {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
grid-area: 1 / 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wechat-icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
margin-right: 14rpx;
|
||||
transform: translateX(-8rpx) scale(1.22);
|
||||
}
|
||||
|
||||
.register-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
:class="{ 'field-block--error': fieldErrors.phone }"
|
||||
>
|
||||
<view class="input-row">
|
||||
<label class="input-label" for="a04-phone">手机号</label>
|
||||
<label class="input-label" for="a04-phone"><text class="required-mark">*</text>手机号</label>
|
||||
<input
|
||||
id="a04-phone"
|
||||
v-model.trim="phone"
|
||||
@@ -51,12 +51,26 @@
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
<view class="field-block">
|
||||
<view class="input-row">
|
||||
<label class="input-label" for="a04-nickname">昵称</label>
|
||||
<input
|
||||
id="a04-nickname"
|
||||
v-model.trim="nickName"
|
||||
class="auth-input"
|
||||
:disabled="submitting || registrationCommitted"
|
||||
placeholder="请输入昵称"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="field-block"
|
||||
:class="{ 'field-block--error': fieldErrors.verificationCode }"
|
||||
>
|
||||
<view class="input-row code-row">
|
||||
<label class="input-label" for="a04-verification-code">验证码</label>
|
||||
<label class="input-label" for="a04-verification-code"><text class="required-mark">*</text>验证码</label>
|
||||
<input
|
||||
id="a04-verification-code"
|
||||
v-model.trim="verificationCode"
|
||||
@@ -89,7 +103,7 @@
|
||||
:class="{ 'field-block--error': fieldErrors.password }"
|
||||
>
|
||||
<view class="input-row">
|
||||
<label class="input-label" for="a04-password">设置密码</label>
|
||||
<label class="input-label" for="a04-password"><text class="required-mark">*</text>设置密码</label>
|
||||
<input
|
||||
id="a04-password"
|
||||
v-model="password"
|
||||
@@ -114,7 +128,7 @@
|
||||
:class="{ 'field-block--error': fieldErrors.confirmPassword }"
|
||||
>
|
||||
<view class="input-row">
|
||||
<label class="input-label" for="a04-confirm-password">确认密码</label>
|
||||
<label class="input-label" for="a04-confirm-password"><text class="required-mark">*</text>确认密码</label>
|
||||
<input
|
||||
id="a04-confirm-password"
|
||||
v-model="confirmPassword"
|
||||
@@ -267,6 +281,7 @@ import {
|
||||
} from "@/utils/validation.js";
|
||||
|
||||
const phone = ref("");
|
||||
const nickName = ref("");
|
||||
const verificationCode = ref("");
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
@@ -292,6 +307,7 @@ const isDirty = computed(() =>
|
||||
!registrationCommitted.value &&
|
||||
Boolean(
|
||||
phone.value ||
|
||||
nickName.value ||
|
||||
verificationCode.value ||
|
||||
password.value ||
|
||||
confirmPassword.value ||
|
||||
@@ -517,6 +533,7 @@ const submitRegister = async () => {
|
||||
await appApi.registerWithPassword(
|
||||
{
|
||||
phone: phone.value,
|
||||
nickName: nickName.value,
|
||||
passwordHash: calcMD5(password.value),
|
||||
smsCode: verificationCode.value,
|
||||
},
|
||||
@@ -626,6 +643,15 @@ const submitRegister = async () => {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: #b42318;
|
||||
font-size: 26rpx;
|
||||
line-height: 1;
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
:class="{ 'field-block--error': fieldErrors.phone }"
|
||||
>
|
||||
<view class="input-row">
|
||||
<label class="input-label" for="a05-phone">手机号</label>
|
||||
<label class="input-label" for="a05-phone"><text class="required-mark">*</text>手机号</label>
|
||||
<input
|
||||
id="a05-phone"
|
||||
v-model.trim="phone"
|
||||
@@ -56,7 +56,7 @@
|
||||
:class="{ 'field-block--error': fieldErrors.verificationCode }"
|
||||
>
|
||||
<view class="input-row code-row">
|
||||
<label class="input-label" for="a05-verification-code">验证码</label>
|
||||
<label class="input-label" for="a05-verification-code"><text class="required-mark">*</text>验证码</label>
|
||||
<input
|
||||
id="a05-verification-code"
|
||||
v-model.trim="verificationCode"
|
||||
@@ -89,7 +89,7 @@
|
||||
:class="{ 'field-block--error': fieldErrors.password }"
|
||||
>
|
||||
<view class="input-row">
|
||||
<label class="input-label" for="a05-password">新密码</label>
|
||||
<label class="input-label" for="a05-password"><text class="required-mark">*</text>新密码</label>
|
||||
<input
|
||||
id="a05-password"
|
||||
v-model="password"
|
||||
@@ -114,7 +114,7 @@
|
||||
:class="{ 'field-block--error': fieldErrors.confirmPassword }"
|
||||
>
|
||||
<view class="input-row">
|
||||
<label class="input-label" for="a05-confirm-password">确认新密码</label>
|
||||
<label class="input-label" for="a05-confirm-password"><text class="required-mark">*</text>确认新密码</label>
|
||||
<input
|
||||
id="a05-confirm-password"
|
||||
v-model="confirmPassword"
|
||||
@@ -559,6 +559,14 @@ const submitReset = async () => {
|
||||
color: #674b35;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: #b42318;
|
||||
font-size: 26rpx;
|
||||
line-height: 1;
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.auth-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
<!-- 页面编号:F-01;用途:家族动态入口。列表 DTO 缺失时不展示 fixture 条目。 -->
|
||||
<template>
|
||||
<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="feed-content">
|
||||
<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 class="feed-state-card">
|
||||
<view class="feed-state-card__copy"><text>{{ stateCopy.title }}</text><text>{{ stateCopy.copy }}</text></view>
|
||||
</view>
|
||||
<view v-if="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>
|
||||
<AppLoading v-if="feedState === 'loading'" text="正在读取家族动态" description="请稍候,正在整理家族近况。" />
|
||||
<view v-else-if="feedState === 'list'" class="feed-list"><view v-for="item in feeds" :key="item.id" class="feed-card" @click="openFeed(item)"><text class="feed-card__publisher">{{ item.publisher }}</text><text class="feed-card__content">{{ item.content }}</text><text class="feed-card__meta">{{ feedTypeLabel(item.type) }} · {{ item.time }}</text></view></view>
|
||||
<view v-else class="feed-state-card"><view class="feed-state-card__copy"><text>{{ stateCopy.title }}</text><text>{{ stateCopy.copy }}</text></view></view>
|
||||
<view class="feed-action" @click="handlePrimaryAction"><text>{{ stateCopy.action }}</text></view>
|
||||
</view>
|
||||
<AppTabbar active="family" />
|
||||
@@ -22,93 +16,29 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, isRequestCancelled } from "@/utils/api.js";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
import { goRoot, openPage } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = ref(false);
|
||||
const feedState = ref("unavailable");
|
||||
const stateCopy = computed(() => {
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
title: "请先选择有效家谱",
|
||||
copy: "家族动态必须归属明确家谱,页面不会展示其他家谱的内容。",
|
||||
action: "返回我的家谱",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "动态列表待后端字段合同",
|
||||
copy: "已找到动态列表 operation,但响应只声明通用对象数组;待后端提供动态 ID、标题、正文、发布人和时间字段后再恢复列表与详情入口。",
|
||||
action: "发布家族动态",
|
||||
};
|
||||
});
|
||||
const shortcuts = [
|
||||
{ key: "articles", label: "谱文" },
|
||||
{ key: "albums", label: "相册" },
|
||||
{ key: "rituals", label: "礼仪" },
|
||||
{ key: "memos", label: "备忘" },
|
||||
{ key: "people", label: "人物录" },
|
||||
{ key: "gifts", label: "贺礼簿" },
|
||||
{ key: "merits", label: "功德录" },
|
||||
{ key: "videos", label: "家族视频" },
|
||||
];
|
||||
|
||||
onLoad((query) => {
|
||||
const hasRouteIdentity = Object.prototype.hasOwnProperty.call(query || {}, "genealogyId");
|
||||
const resolvedGenealogyId = hasRouteIdentity
|
||||
? String(query?.genealogyId || "")
|
||||
: String(genealogyContext.getCurrentGenealogyId() || "");
|
||||
genealogyId.value = resolvedGenealogyId;
|
||||
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";
|
||||
});
|
||||
}
|
||||
});
|
||||
const toPublish = () => hasValidContext.value
|
||||
? openPage("F02", { genealogyId: genealogyId.value }, "F01")
|
||||
: goRoot("G01");
|
||||
const openSection = (key) => {
|
||||
const routes = {
|
||||
articles: "F04",
|
||||
albums: "F07",
|
||||
rituals: "R05",
|
||||
memos: "R10",
|
||||
people: "R01",
|
||||
gifts: "R03",
|
||||
merits: "R11",
|
||||
videos: "F10",
|
||||
};
|
||||
return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");
|
||||
};
|
||||
const handlePrimaryAction = () => hasValidContext.value ? toPublish() : goRoot("G01");
|
||||
const genealogyId = ref(""); const hasValidContext = ref(false); const feedState = ref("loading"); const feeds = ref([]); let active = true;
|
||||
const feedTypeLabel = (type) => String(type || "").trim().toLowerCase() === "text" ? "文字动态" : "家族动态";
|
||||
const shortcuts = [{ key: "articles", label: "谱文" }, { key: "albums", label: "相册" }, { key: "rituals", label: "礼仪" }, { key: "memos", label: "备忘" }, { key: "people", label: "人物录" }, { key: "gifts", label: "贺礼簿" }, { key: "merits", label: "功德录" }, { key: "videos", label: "家族视频" }];
|
||||
const stateCopy = computed(() => !hasValidContext.value ? { title: "请先选择有效家谱", copy: "家族动态必须归属明确家谱。", action: "返回我的家谱" } : feedState.value === "empty" ? { title: "还没有家族动态", copy: "发布第一条动态,记录家族此刻。", action: "发布家族动态" } : feedState.value === "error" ? { title: "动态暂时无法读取", copy: "请稍后重新读取。", action: "重新读取" } : { title: "", copy: "", action: "发布家族动态" });
|
||||
const loadFeeds = async () => { if (!hasValidContext.value) return; feedState.value = "loading"; try { const rows = await appApi.getFeeds(genealogyId.value); if (!active) return; feeds.value = rows; feedState.value = feeds.value.length ? "list" : "empty"; } catch (error) { if (!active || isRequestCancelled(error)) return; feedState.value = "error"; } };
|
||||
onLoad((query) => { const supplied = Object.prototype.hasOwnProperty.call(query || {}, "genealogyId"); genealogyId.value = supplied ? String(query.genealogyId || "") : String(genealogyContext.getCurrentGenealogyId() || ""); hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value); if (hasValidContext.value) void loadFeeds(); else feedState.value = "error"; });
|
||||
onShow(() => { if (hasValidContext.value) void loadFeeds(); });
|
||||
const toPublish = () => hasValidContext.value ? openPage("F02", { genealogyId: genealogyId.value }, "F01") : goRoot("G01");
|
||||
const openFeed = (item) => openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F01");
|
||||
const openSection = (key) => { const routes = { articles: "F04", albums: "F07", rituals: "R05", memos: "R10", people: "R01", gifts: "R03", merits: "R11", videos: "F10" }; return openPage(routes[key], { genealogyId: genealogyId.value }, "F01"); };
|
||||
const handlePrimaryAction = () => feedState.value === "error" ? loadFeeds() : toPublish();
|
||||
</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-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; }
|
||||
.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{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:15rpx}.feed-shortcut{min-height:48px;display:flex;align-items:center;justify-content:center}.feed-shortcut text{color:$ink;font-size:22rpx;font-weight:700}.feed-list{margin-top:22rpx}.feed-card,.feed-state-card{@include adaptive.adaptive-family-letter;box-sizing:border-box;margin-top:16rpx;padding:30rpx}.feed-card text,.feed-state-card text{display:block}.feed-card__publisher{color:$brand-red;font-size:22rpx}.feed-card__content{margin-top:12rpx;color:$ink;font-size:28rpx;line-height:1.55}.feed-card__meta{margin-top:12rpx;color:$ink-muted;font-size:21rpx}.feed-state-card{min-height:230rpx;display:flex;align-items:center;justify-content:center;text-align:center}.feed-state-card text:first-child{color:$ink;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}.feed-action text{display:flex;height:100%;align-items:center;justify-content:center;color:#fff9ed;font-size:24rpx;font-weight:700}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 页面编号:F-02;用途:按 Apifox 已声明的 feedContent 发布家族动态。 -->
|
||||
<!-- 页面编号:F-02;用途:按 Apifox 的完整 AppFamilyFeedBody 创建家族动态。 -->
|
||||
<template>
|
||||
<view class="publish-page" :class="`publish-state--${publishState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
@@ -6,10 +6,31 @@
|
||||
<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>填写需要的内容;留空的字段由服务端按接口默认行为处理。</text>
|
||||
|
||||
<view class="publish-field publish-field--content">
|
||||
<text class="publish-field__label"><text class="required-mark">*</text>动态内容</text>
|
||||
<textarea v-model="form.feedContent" auto-height placeholder="写下想对家人说的话" placeholder-class="publish-placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<view class="publish-field">
|
||||
<text class="publish-field__label">动态类型</text>
|
||||
<input v-model="form.feedType" placeholder="留空时服务端默认为 text" placeholder-class="publish-placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<view class="publish-field publish-field--media">
|
||||
<view>
|
||||
<text class="publish-field__label">动态配图</text>
|
||||
<text class="publish-field__hint">图片选定后会先取得真实上传回执,并在提交动态时关联。</text>
|
||||
</view>
|
||||
<button class="upload-button" :disabled="isUploading || isSubmitting" @click="uploadImage">{{ isUploading ? '上传中…' : '添加图片' }}</button>
|
||||
<text v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`" class="upload-receipt">已上传:{{ receipt.fileName || '图片' }}</text>
|
||||
<text v-if="uploadError" class="publish-error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<view class="publish-field">
|
||||
<text class="publish-field__label">排序值</text>
|
||||
<input v-model="form.sortOrder" type="number" placeholder="留空时服务端默认为 0" placeholder-class="publish-placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<text v-if="submitError" class="publish-error">{{ submitError }}</text>
|
||||
<AppButton block :label="isSubmitting ? '正在提交' : '提交动态'" :disabled="isSubmitting" @click="submit" />
|
||||
<AppButton block :label="isSubmitting ? '正在提交' : '提交动态'" :disabled="isSubmitting || isUploading" @click="submit" />
|
||||
</view>
|
||||
<view v-else class="publish-result">
|
||||
<text>{{ resultCopy.title }}</text>
|
||||
@@ -32,7 +53,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
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";
|
||||
@@ -40,26 +61,33 @@ 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 { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const content = ref("");
|
||||
const form = reactive({ feedContent: "", feedType: "", sortOrder: "" });
|
||||
const mediaReceipts = ref([]);
|
||||
const publishState = ref("form");
|
||||
const isSubmitting = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const submitError = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const requestController = createRequestController();
|
||||
const isDirty = computed(() => Boolean(content.value.trim()));
|
||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||
const isDirty = computed(() => Boolean(
|
||||
form.feedContent.trim() || form.feedType.trim() || form.sortOrder.trim() || mediaReceipts.value.length,
|
||||
));
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const resultCopy = computed(() => ({
|
||||
success: {
|
||||
title: "动态已提交服务端",
|
||||
copy: "服务端已返回成功信封。动态列表仍缺条目 DTO,返回后不会把本地内容伪装成列表项。",
|
||||
copy: "服务端已返回成功信封。返回动态列表后将重新读取服务端数据。",
|
||||
action: "返回家族动态",
|
||||
},
|
||||
error: {
|
||||
title: "动态未提交",
|
||||
copy: "当前文字仍保留在页面中,可返回表单继续核对。",
|
||||
copy: "当前内容仍保留在页面中,可返回表单继续核对。",
|
||||
action: "返回填写",
|
||||
},
|
||||
invalid: {
|
||||
@@ -79,18 +107,40 @@ onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value) publishState.value = "invalid";
|
||||
});
|
||||
|
||||
const uploadImage = async () => {
|
||||
if (isUploading.value || isSubmitting.value) return;
|
||||
isUploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController });
|
||||
mediaReceipts.value = [...mediaReceipts.value, receipt];
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "图片上传失败,请稍后重试。";
|
||||
}
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (isSubmitting.value || !hasValidContext.value) return;
|
||||
const feedContent = content.value.trim();
|
||||
if (!feedContent) {
|
||||
submitError.value = "请先写下动态内容";
|
||||
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
|
||||
if (!form.feedContent.trim()) {
|
||||
submitError.value = "请填写动态内容";
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createFeed(genealogyId.value, { feedContent }, { requestController });
|
||||
content.value = "";
|
||||
await appApi.createFeed(genealogyId.value, {
|
||||
feedContent: form.feedContent,
|
||||
feedType: form.feedType,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
sortOrder: form.sortOrder,
|
||||
}, { requestController });
|
||||
Object.assign(form, { feedContent: "", feedType: "", sortOrder: "" });
|
||||
mediaReceipts.value = [];
|
||||
publishState.value = "success";
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) return;
|
||||
@@ -103,7 +153,7 @@ const submit = async () => {
|
||||
const requestBack = () => runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: isSubmitting.value,
|
||||
submitting: isSubmitting.value || isUploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
@@ -136,8 +186,16 @@ onUnmounted(() => {
|
||||
.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-field { @include adaptive.adaptive-family-field; margin-top: 20rpx; padding: 18rpx 22rpx; }
|
||||
.publish-field__label { display: block; color: $ink; font-size: 25rpx; font-weight: 700; }
|
||||
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
|
||||
.publish-field input, .publish-field textarea { display: block; box-sizing: border-box; width: 100%; margin-top: 12rpx; color: $ink; font-size: 24rpx; }
|
||||
.publish-field input { min-height: 64rpx; }
|
||||
.publish-field--content textarea { min-height: 200rpx; line-height: 1.7; }
|
||||
.publish-field--media { display: grid; gap: 12rpx; }
|
||||
.publish-field__hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
|
||||
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
|
||||
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
||||
.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; }
|
||||
|
||||
+164
-170
@@ -1,232 +1,226 @@
|
||||
<!-- 页面编号:F-03;用途:动态详情与一级评论。动态正文 DTO 未声明时不展示伪字段。 -->
|
||||
<!-- 页面编号:F-03;用途:家族动态详情与一级评论。 -->
|
||||
<template>
|
||||
<view class="feed-detail-page" :class="{ 'feed-state--expired': feedState === 'expired', 'feed-state--error': feedState === 'error', 'feed-state--ready': feedState === 'ready' }">
|
||||
<view class="feed-detail-page" :class="`feed-state--${feedState}`">
|
||||
<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="动态正文的展示字段尚未由后端合同声明。" />
|
||||
</view>
|
||||
<view class="feed-detail-content">
|
||||
<AppLoading v-if="feedState === 'loading'" text="正在读取动态" description="请稍候,正在同步家族动态与评论。" />
|
||||
|
||||
<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__title">动态正文待后端字段合同</text>
|
||||
<text class="feed-article-card__body">已根据当前动态 ID 读取评论。正文内容会在详情响应提供可消费 DTO 后再接入。</text>
|
||||
</view>
|
||||
|
||||
<view class="feed-comments-panel">
|
||||
<view class="feed-comments-heading">
|
||||
<text>家人评论</text><text>{{ feedComments.length }} 条</text>
|
||||
<view v-else-if="feedState === 'ready'" class="feed-detail-body">
|
||||
<view class="feed-card">
|
||||
<view class="feed-card__heading">
|
||||
<text>{{ feedTypeLabel(feed.type) }}</text>
|
||||
<text>{{ feed.time || '未标注时间' }}</text>
|
||||
</view>
|
||||
<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 class="feed-card__content">{{ feed.content }}</text>
|
||||
<view class="feed-card__meta">
|
||||
<text>发布:{{ feed.publisher }}</text>
|
||||
<text>{{ feed.likeCount }} 次点赞 · {{ feed.commentCount }} 条评论</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<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>
|
||||
<text v-else-if="commentNotice" class="feed-comment-notice">{{ commentNotice }}</text>
|
||||
<AppButton block :disabled="commentState === 'submitting'" :label="commentState === 'submitting' ? '正在提交' : '提交评论'" @click="submitComment" />
|
||||
<view class="comment-section">
|
||||
<text class="section-title">评论</text>
|
||||
<AppLoading v-if="commentState === 'loading'" text="正在读取评论" />
|
||||
<view v-else-if="commentState === 'list'" class="comment-list">
|
||||
<view v-for="item in comments" :key="item.id" class="comment-card">
|
||||
<view class="comment-card__heading">
|
||||
<text>{{ item.author }}</text>
|
||||
<text>{{ item.time || '刚刚' }}</text>
|
||||
</view>
|
||||
<text class="comment-card__content">{{ item.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else class="comment-state-copy">{{ commentStateCopy }}</text>
|
||||
|
||||
<view class="comment-editor">
|
||||
<textarea
|
||||
v-model="commentDraft"
|
||||
auto-height
|
||||
maxlength="1000"
|
||||
placeholder="写下你的评论"
|
||||
placeholder-class="comment-editor__placeholder"
|
||||
@input="commentError = ''"
|
||||
/>
|
||||
<text v-if="commentError" class="comment-error">{{ commentError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="isSubmittingComment"
|
||||
:label="isSubmittingComment ? '正在提交' : '发表评论'"
|
||||
@click="submitComment"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<view v-else class="feed-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton :type="feedState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
|
||||
<AppButton type="secondary" block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</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, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, 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";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
import { goBack, handleBackPress, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const feedId = ref("");
|
||||
const feedState = ref("loading");
|
||||
const commentState = ref("idle");
|
||||
const commentState = ref("loading");
|
||||
const feed = ref(null);
|
||||
const comments = ref([]);
|
||||
const commentDraft = ref("");
|
||||
const commentError = ref("");
|
||||
const commentNotice = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const feedComments = ref([]);
|
||||
const feedRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const isDirty = computed(() => Boolean(commentDraft.value.trim()));
|
||||
const isSubmittingComment = ref(false);
|
||||
const controller = createRequestController();
|
||||
let pageActive = true;
|
||||
const feedTypeLabel = (type) => String(type || "").trim().toLowerCase() === "text" ? "文字动态" : "家族动态";
|
||||
|
||||
const hasValidContext = computed(() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(feedId.value),
|
||||
);
|
||||
const stateCopy = computed(() => {
|
||||
if (feedState.value === "error") {
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
title: "评论暂不可用",
|
||||
copy: "评论读取没有得到可消费的服务端响应;页面不会用本地数据替代。",
|
||||
action: "重新读取",
|
||||
title: "动态入口无效",
|
||||
copy: "没有取得当前家谱和动态标识,页面不会展示其他动态。",
|
||||
action: "返回上一页",
|
||||
};
|
||||
}
|
||||
if (feedState.value === "missing") {
|
||||
return {
|
||||
title: "该动态已不存在",
|
||||
copy: "它可能已被发布者删除,或当前账号已不再拥有查看权限。",
|
||||
action: "返回家族动态",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "动态入口无效",
|
||||
copy: "没有取得当前家谱和动态标识,页面不会回退到其他动态。",
|
||||
action: genealogyId.value ? "返回家族动态" : "返回上一页",
|
||||
title: "动态暂时无法读取",
|
||||
copy: "请检查网络后重新读取;本页不会替换为其他动态。",
|
||||
action: "重新读取",
|
||||
};
|
||||
});
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
const commentStateCopy = computed(() => {
|
||||
if (commentState.value === "empty") return "还没有评论,欢迎留下第一句话。";
|
||||
return "评论暂时无法读取,稍后可重新进入本页查看。";
|
||||
});
|
||||
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 loadFeed = async () => {
|
||||
if (!hasValidContext.value) {
|
||||
feedState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
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;
|
||||
const current = await appApi.getFeedDetail(genealogyId.value, feedId.value, { requestController: controller });
|
||||
if (!pageActive) return;
|
||||
feed.value = current;
|
||||
feedState.value = "ready";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error) || sequence !== loadSequence) return false;
|
||||
feedState.value = "error";
|
||||
return false;
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
feedState.value = error?.code === "HTTP_ERROR" && error?.httpStatus === 404 ? "missing" : "error";
|
||||
}
|
||||
};
|
||||
|
||||
const loadComments = async () => {
|
||||
if (!hasValidContext.value || feedState.value !== "ready") return;
|
||||
commentState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getFeedComments(genealogyId.value, feedId.value, { requestController: controller });
|
||||
if (!pageActive) return;
|
||||
comments.value = rows;
|
||||
commentState.value = rows.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
commentState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
await loadFeed();
|
||||
if (feedState.value === "ready") await loadComments();
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
if (isSubmittingComment.value || !hasValidContext.value) return;
|
||||
const commentContent = commentDraft.value.trim();
|
||||
if (!commentContent) {
|
||||
commentError.value = "请填写评论内容";
|
||||
return;
|
||||
}
|
||||
isSubmittingComment.value = true;
|
||||
commentError.value = "";
|
||||
try {
|
||||
await appApi.createFeedComment(genealogyId.value, feedId.value, { commentContent }, { requestController: controller });
|
||||
if (!pageActive) return;
|
||||
commentDraft.value = "";
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
commentError.value = error?.message || "评论提交失败,请稍后重试";
|
||||
} finally {
|
||||
if (pageActive) isSubmittingComment.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
feedId.value = String(query.feedId || "");
|
||||
loadComments();
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
feedId.value = String(query?.feedId || "");
|
||||
void refresh();
|
||||
});
|
||||
onShow(() => {
|
||||
if (hasValidContext.value) void refresh();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
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 = "";
|
||||
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 requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: commentState.value === "submitting",
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
const backToFamily = async () => {
|
||||
if (!genealogyId.value) return goBack();
|
||||
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
|
||||
if (!confirmed) return false;
|
||||
return returnTo("F01", { genealogyId: genealogyId.value });
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (feedState.value === "error") return loadComments();
|
||||
return backToFamily();
|
||||
};
|
||||
const backToFamily = () => hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const requestBack = () => backToFamily();
|
||||
const handleStateAction = () => feedState.value === "error" ? refresh() : backToFamily();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
onUnmounted(() => {
|
||||
++loadSequence;
|
||||
feedRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.feed-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.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: 42rpx 48rpx; }
|
||||
.feed-article-card text, .feed-state-card > text { display: block; }
|
||||
.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, .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-detail-header, .feed-detail-content { z-index: 1; }
|
||||
.feed-detail-content { flex: 1; padding: 18rpx 24rpx 72rpx; }
|
||||
.feed-detail-body { margin-top: 18rpx; }
|
||||
.feed-card, .comment-section, .feed-state-card { @include adaptive.adaptive-family-content; box-sizing: border-box; }
|
||||
.feed-card { padding: 30rpx; }
|
||||
.feed-card__heading, .feed-card__meta, .comment-card__heading { display: flex; justify-content: space-between; gap: 18rpx; }
|
||||
.feed-card__heading text:first-child, .comment-card__heading text:first-child { color: $brand-red; font-size: 23rpx; font-weight: 700; }
|
||||
.feed-card__heading text:last-child, .comment-card__heading text:last-child { color: $ink-muted; font-size: 21rpx; text-align: right; }
|
||||
.feed-card__content { display: block; margin-top: 20rpx; color: $ink; font-size: 29rpx; line-height: 1.7; white-space: pre-wrap; }
|
||||
.feed-card__meta { margin-top: 22rpx; color: $ink-muted; font-size: 21rpx; }
|
||||
.comment-section { margin-top: 18rpx; padding: 28rpx; }
|
||||
.section-title { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
|
||||
.comment-list { margin-top: 18rpx; }
|
||||
.comment-card { padding: 18rpx 0; border-bottom: 1rpx solid rgba(128, 89, 49, .16); }
|
||||
.comment-card__content { display: block; margin-top: 10rpx; color: $ink; font-size: 25rpx; line-height: 1.55; white-space: pre-wrap; }
|
||||
.comment-state-copy { display: block; margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
|
||||
.comment-editor { margin-top: 24rpx; padding-top: 22rpx; border-top: 1rpx solid rgba(128, 89, 49, .18); }
|
||||
.comment-editor textarea { display: block; box-sizing: border-box; width: 100%; min-height: 130rpx; padding: 18rpx; border: 1rpx solid rgba(128, 89, 49, .3); border-radius: 12rpx; color: $ink; font-size: 25rpx; line-height: 1.55; }
|
||||
.comment-editor__placeholder { color: #ab9a86; }
|
||||
.comment-editor .app-button { margin-top: 18rpx; }
|
||||
.comment-error { display: block; margin-top: 10rpx; color: $brand-red; font-size: 22rpx; }
|
||||
.feed-state-card { min-height: 340rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.feed-state-card > text { display: block; }
|
||||
.feed-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
|
||||
.feed-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
|
||||
.feed-state-card .app-button { margin-top: 30rpx; }
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<!-- 页面编号:F-04;用途:谱文入口。列表 item DTO 未声明时不展示本地文章。 -->
|
||||
<!-- 页面编号:F-04;用途:读取并展示当前家谱的谱文列表。 -->
|
||||
<template>
|
||||
<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 class="article-list-content">
|
||||
<view class="article-list-state-card">
|
||||
<view v-if="listState === 'list'" class="article-list-items">
|
||||
<view v-for="item in articles" :key="item.id" class="article-card" @click="openArticle(item)">
|
||||
<text class="article-card__title">{{ item.title }}</text>
|
||||
<text class="article-card__summary">{{ item.summary || item.content }}</text>
|
||||
<text class="article-card__meta">{{ item.author || '家族成员' }} · {{ item.time || '未标注时间' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="article-list-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
@@ -15,20 +22,30 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi } from "@/utils/api.js";
|
||||
import { goBack, openPage } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = ref(false);
|
||||
const listState = ref("unavailable");
|
||||
const listState = ref("loading");
|
||||
const articles = ref([]);
|
||||
const stateCopy = computed(() => hasValidContext.value
|
||||
? {
|
||||
title: "谱文列表待后端字段合同",
|
||||
copy: "列表接口只声明通用对象数组,没有文章 ID、分类、标题、摘要、作者或更新时间字段;页面已停止展示本地文章和本地筛选。",
|
||||
? listState.value === "empty" ? {
|
||||
title: "还没有谱文",
|
||||
copy: "新建第一篇谱文,记录值得传承的故事。",
|
||||
action: "新建谱文",
|
||||
} : listState.value === "error" ? {
|
||||
title: "谱文暂时无法读取",
|
||||
copy: "请检查网络后重新读取。",
|
||||
action: "重新读取",
|
||||
} : {
|
||||
title: "正在读取谱文",
|
||||
copy: "请稍候。",
|
||||
action: "重新读取",
|
||||
}
|
||||
: {
|
||||
title: "谱文入口无效",
|
||||
@@ -39,12 +56,25 @@ const stateCopy = computed(() => hasValidContext.value
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
|
||||
listState.value = hasValidContext.value ? "unavailable" : "invalid";
|
||||
if (hasValidContext.value) void loadArticles(); else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => { if (hasValidContext.value) void loadArticles(); });
|
||||
const loadArticles = async () => {
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getArticles(genealogyId.value);
|
||||
articles.value = rows;
|
||||
listState.value = articles.value.length ? "list" : "empty";
|
||||
} catch { listState.value = "error"; }
|
||||
};
|
||||
const createArticle = () => hasValidContext.value
|
||||
? openPage("F06", { genealogyId: genealogyId.value, mode: "create" }, "F04")
|
||||
: Promise.resolve(false);
|
||||
const handleStateAction = () => hasValidContext.value ? createArticle() : goBack();
|
||||
const openArticle = (item) => openPage("F05", { genealogyId: genealogyId.value, articleId: item.id }, "F04");
|
||||
const handleStateAction = () => {
|
||||
if (!hasValidContext.value) return goBack();
|
||||
return listState.value === "error" ? loadArticles() : createArticle();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -52,6 +82,12 @@ const handleStateAction = () => hasValidContext.value ? createArticle() : goBack
|
||||
.article-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.article-list-header, .article-list-content { z-index: 1; }
|
||||
.article-list-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.article-list-items { margin-top: 24rpx; }
|
||||
.article-card { @include adaptive.adaptive-family-content; display: flex; min-height: 150rpx; flex-direction: column; justify-content: center; box-sizing: border-box; margin-bottom: 16rpx; padding: 28rpx; }
|
||||
.article-card text { display: block; }
|
||||
.article-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
|
||||
.article-card__summary { margin-top: 9rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.45; }
|
||||
.article-card__meta { margin-top: 10rpx; color: $brand-red; font-size: 21rpx; }
|
||||
.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; }
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
<!-- 页面编号:F-05;用途:谱文详情。详情 DTO 未声明时不展示本地正文。 -->
|
||||
<!-- 页面编号:F-05;用途:谱文详情。 -->
|
||||
<template>
|
||||
<view class="article-detail-page">
|
||||
<view class="article-detail-page" :class="`article-state--${articleState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="article-detail-header"><PageHeader title="谱文详情" custom-back @back="backToArticles" /></view>
|
||||
|
||||
<view class="article-detail-content">
|
||||
<view class="article-state-card">
|
||||
<AppLoading v-if="articleState === 'loading'" text="正在读取谱文" description="请稍候,正在同步谱文正文。" />
|
||||
<view v-else-if="articleState === 'ready'" class="article-card">
|
||||
<text v-if="article.category" class="article-card__category">{{ article.category }}</text>
|
||||
<text class="article-card__title">{{ article.title }}</text>
|
||||
<text class="article-card__meta">{{ article.author }} · {{ article.time || '未标注时间' }}</text>
|
||||
<text v-if="article.summary" class="article-card__summary">{{ article.summary }}</text>
|
||||
<view class="article-card__divider" />
|
||||
<text class="article-card__content">{{ article.content || '作者暂未填写正文。' }}</text>
|
||||
<text class="article-card__views">阅读 {{ article.viewCount }} 次</text>
|
||||
</view>
|
||||
<view v-else class="article-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="backToArticles" />
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -15,44 +26,101 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShow, 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const articleId = ref("");
|
||||
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: "返回上一页",
|
||||
},
|
||||
const articleState = ref("loading");
|
||||
const article = ref(null);
|
||||
const controller = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const hasValidContext = computed(() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value),
|
||||
);
|
||||
const stateCopy = computed(() => {
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
title: "谱文入口无效",
|
||||
copy: "没有取得有效家谱或文章标识,页面不会展示其他谱文。",
|
||||
action: "返回上一页",
|
||||
};
|
||||
}
|
||||
if (articleState.value === "missing") {
|
||||
return {
|
||||
title: "该谱文已不存在",
|
||||
copy: "它可能已被作者删除,或当前账号已不再拥有查看权限。",
|
||||
action: "返回谱文列表",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "谱文暂时无法读取",
|
||||
copy: "请检查网络后重新读取;本页不会替换为其他谱文。",
|
||||
action: "重新读取",
|
||||
};
|
||||
});
|
||||
|
||||
const loadArticle = async () => {
|
||||
if (!hasValidContext.value) {
|
||||
articleState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
articleState.value = "loading";
|
||||
try {
|
||||
const current = await appApi.getArticleDetail(genealogyId.value, articleId.value, { requestController: controller });
|
||||
if (!pageActive) return;
|
||||
article.value = current;
|
||||
articleState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
articleState.value = error?.code === "HTTP_ERROR" && error?.httpStatus === 404 ? "missing" : "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
articleId.value = String(query?.articleId || "");
|
||||
void loadArticle();
|
||||
});
|
||||
const backToArticles = () => /^[1-9]\d*$/.test(genealogyId.value)
|
||||
onShow(() => {
|
||||
if (hasValidContext.value) void loadArticle();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
const backToArticles = () => hasValidContext.value
|
||||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () => articleState.value === "error" ? loadArticle() : backToArticles();
|
||||
</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-content { z-index: 1; }
|
||||
.article-detail-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.article-state-card { @include adaptive.adaptive-family-content; width: 100%; min-height: 350rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.article-detail-content { flex: 1; padding: 18rpx 24rpx 72rpx; }
|
||||
.article-card, .article-state-card { @include adaptive.adaptive-family-content; box-sizing: border-box; }
|
||||
.article-card { margin-top: 18rpx; padding: 34rpx 30rpx; }
|
||||
.article-card__category, .article-card__title, .article-card__meta, .article-card__summary, .article-card__content, .article-card__views { display: block; }
|
||||
.article-card__category { color: $brand-red; font-size: 23rpx; font-weight: 700; }
|
||||
.article-card__title { margin-top: 14rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 40rpx; font-weight: 700; line-height: 1.32; }
|
||||
.article-card__meta { margin-top: 16rpx; color: $ink-muted; font-size: 22rpx; }
|
||||
.article-card__summary { margin-top: 22rpx; color: $ink-muted; font-size: 25rpx; line-height: 1.6; }
|
||||
.article-card__divider { height: 1rpx; margin: 26rpx 0; background: rgba(128, 89, 49, .22); }
|
||||
.article-card__content { color: $ink; font-size: 28rpx; line-height: 1.85; white-space: pre-wrap; }
|
||||
.article-card__views { margin-top: 30rpx; color: $ink-muted; font-size: 21rpx; text-align: right; }
|
||||
.article-state-card { 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; }
|
||||
.article-state-card .app-button { margin-top: 28rpx; }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 页面编号:F-06;用途:按已声明 articleTitle/articleContent 创建谱文。 -->
|
||||
<!-- 页面编号:F-06;用途:按 Apifox 的完整 AppArticleBody 创建谱文。 -->
|
||||
<template>
|
||||
<view class="article-editor-page" :class="`article-editor-state--${editorState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
@@ -8,17 +8,43 @@
|
||||
<view class="editor-panel__body">
|
||||
<text class="editor-eyebrow">服务端创建</text>
|
||||
<text class="editor-title">把值得传承的故事写下来</text>
|
||||
<text class="editor-intro">本页只发送 Apifox 已声明且可映射的文章标题与正文。分类需要 `categoryId`,当前没有可用分类 owner,故不展示或猜测分类。</text>
|
||||
<text class="editor-intro">除标题和正文外,其余字段均可按需填写;留空时不提交该字段。</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>
|
||||
<text class="editor-field__label">文章分类</text>
|
||||
<view class="editor-control editor-control--unavailable"><text>服务端暂未提供可选择的分类项</text></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>
|
||||
<text class="editor-field__label"><text class="required-mark">*</text>文章标题</text>
|
||||
<view class="editor-control"><input v-model="form.articleTitle" 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.articleSummary" auto-height placeholder="概括文章内容" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
|
||||
</view>
|
||||
<view class="editor-field editor-field--cover">
|
||||
<view>
|
||||
<text class="editor-field__label">封面图片</text>
|
||||
<text class="editor-field__hint">选择图片后会取得真实上传回执,并作为封面关联。</text>
|
||||
</view>
|
||||
<button class="upload-button" :disabled="uploading || isSubmitting" @click="uploadCover">{{ uploading ? '上传中…' : '选择图片' }}</button>
|
||||
<text v-if="coverFileName" class="upload-receipt">已上传:{{ coverFileName }}</text>
|
||||
<text v-if="uploadError" class="editor-save-error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<view class="editor-field">
|
||||
<text class="editor-field__label"><text class="required-mark">*</text>正文内容</text>
|
||||
<view class="editor-control editor-control--textarea editor-control--article"><textarea v-model="form.articleContent" auto-height placeholder="记录家训、往事或想留给后人的话" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
|
||||
</view>
|
||||
<view class="editor-field">
|
||||
<text class="editor-field__label">作者名称</text>
|
||||
<view class="editor-control"><input v-model="form.authorName" placeholder="请输入作者名称" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
|
||||
</view>
|
||||
<view class="editor-field">
|
||||
<text class="editor-field__label">排序值</text>
|
||||
<view class="editor-control"><input v-model="form.sortOrder" type="number" 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" />
|
||||
<AppButton block :label="isSubmitting ? '正在提交' : '提交谱文'" :disabled="isSubmitting || uploading" @click="submit" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="editor-result-card">
|
||||
@@ -53,28 +79,41 @@ 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 { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const editorState = ref("form");
|
||||
const genealogyId = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
const submitError = ref("");
|
||||
const form = reactive({ title: "", content: "" });
|
||||
const uploadError = ref("");
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const form = reactive({
|
||||
articleTitle: "",
|
||||
articleSummary: "",
|
||||
articleContent: "",
|
||||
authorName: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const requestController = createRequestController();
|
||||
const isDirty = computed(() => Boolean(form.title.trim() || form.content.trim()));
|
||||
const isDirty = computed(() => Boolean(
|
||||
Object.values(form).some((value) => value.trim()) || coverOssId.value,
|
||||
));
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const resultCopy = computed(() => editorState.value === "success"
|
||||
? {
|
||||
eyebrow: "服务端已接受",
|
||||
title: "谱文已提交",
|
||||
copy: "服务端已返回成功信封。谱文列表与详情仍缺展示 DTO,返回后不会生成本地文章卡片。",
|
||||
copy: "服务端已返回成功信封。返回谱文列表后将重新读取服务端数据。",
|
||||
action: "返回谱文列表",
|
||||
}
|
||||
: {
|
||||
eyebrow: "谱文入口无效",
|
||||
title: "无法创建谱文",
|
||||
copy: "当前只有新建操作可映射;编辑需要可靠的文章详情和文章 ID,暂不从 fixture 进入。",
|
||||
copy: "没有取得有效家谱标识,页面不会创建无归属谱文。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
@@ -89,20 +128,37 @@ onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
|
||||
});
|
||||
|
||||
const uploadCover = async () => {
|
||||
if (uploading.value || isSubmitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController });
|
||||
coverOssId.value = toConsumerOssId(receipt.ossId);
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "封面图片上传失败,请稍后重试。";
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (isSubmitting.value || !hasValidContext.value) return;
|
||||
const articleTitle = form.title.trim();
|
||||
const articleContent = form.content.trim();
|
||||
if (!articleTitle || !articleContent) {
|
||||
submitError.value = !articleTitle ? "请填写文章标题" : "请填写正文内容";
|
||||
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
|
||||
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
|
||||
submitError.value = !form.articleTitle.trim() ? "请填写文章标题" : "请填写正文内容";
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createArticle(genealogyId.value, { articleTitle, articleContent }, { requestController });
|
||||
form.title = "";
|
||||
form.content = "";
|
||||
await appApi.createArticle(genealogyId.value, {
|
||||
...form,
|
||||
coverOssId: coverOssId.value,
|
||||
}, { requestController });
|
||||
editorState.value = "success";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "谱文提交失败,请稍后重试。";
|
||||
@@ -113,7 +169,7 @@ const submit = async () => {
|
||||
const requestBack = () => runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: isSubmitting.value,
|
||||
submitting: isSubmitting.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
@@ -140,11 +196,20 @@ onUnmounted(() => {
|
||||
.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; }
|
||||
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
|
||||
.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-control--textarea { min-height: 132rpx; padding: 20rpx 24rpx; }
|
||||
.editor-control--article { min-height: 220rpx; }
|
||||
.editor-control--unavailable { padding: 0 28rpx; color: $ink-muted; font-size: 23rpx; }
|
||||
.editor-control textarea { min-height: 92rpx; line-height: 1.65; }
|
||||
.editor-control--article textarea { min-height: 180rpx; }
|
||||
.editor-field--cover { display: grid; gap: 12rpx; padding: 18rpx 22rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
|
||||
.editor-field--cover .editor-field__label { margin: 0; }
|
||||
.editor-field__hint { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
|
||||
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
|
||||
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
||||
.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; }
|
||||
|
||||
@@ -1,104 +1,105 @@
|
||||
<!-- 页面编号:F-07;用途:相册入口。列表 DTO 缺失时不展示本地相册。 -->
|
||||
<!-- 页面编号:F-07;用途:读取并创建当前家谱的相册。 -->
|
||||
<template>
|
||||
<view class="album-list-page">
|
||||
<ModulePageBackground module="family" />
|
||||
<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 block :label="stateCopy.action" @click="handleStateAction" />
|
||||
<view v-if="!hasValidContext" class="album-state-card"><text>相册入口无效</text><text>没有取得有效家谱标识。</text><AppButton block label="返回上一页" @click="goBack" /></view>
|
||||
<view v-else-if="listState === 'loading'" class="album-state-card"><AppLoading text="正在读取家族相册" /></view>
|
||||
<view v-else-if="listState === 'error'" class="album-state-card"><text>暂时无法读取家族相册</text><text>{{ listError }}</text><AppButton block type="secondary" label="重新加载" @click="loadAlbums" /></view>
|
||||
<view v-else-if="listState === 'empty'" class="album-state-card"><text>还没有相册</text><text>新建第一本相册,整理家族影像。</text><AppButton block label="新建相册" @click="openCreateDialog" /></view>
|
||||
<view v-else class="album-list">
|
||||
<view v-for="item in albums" :key="item.id" class="album-card" @click="openAlbum(item)"><text>{{ item.name }}</text><text v-if="item.description">{{ item.description }}</text><text>{{ item.photoCount }} 张照片</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<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="例如:春节团圆" @input="submitError = ''" />
|
||||
<text v-if="submitError">{{ submitError }}</text>
|
||||
</view>
|
||||
<AppDialog :visible="dialogVisible" eyebrow="新建相册" title="为家人整理一段影像" :confirm-text="isSubmitting ? '正在提交' : '提交相册'" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="submitAlbum" @cancel="closeCreateDialog">
|
||||
<view class="album-dialog-field"><text><text class="required-mark">*</text>相册名称</text><input v-model="form.albumName" placeholder="例如:春节团圆" @input="submitError = ''" /></view>
|
||||
<view class="album-dialog-field"><text>相册说明</text><textarea v-model="form.albumDesc" auto-height placeholder="介绍这组影像" @input="submitError = ''" /></view>
|
||||
<view class="album-dialog-field album-dialog-field--cover"><view><text>封面图片</text><text class="album-field-hint">选择图片后会取得真实上传回执,并作为封面关联。</text></view><button class="upload-button" :disabled="uploading || isSubmitting" @click="uploadCover">{{ uploading ? '上传中…' : '选择图片' }}</button><text v-if="coverFileName" class="upload-receipt">已上传:{{ coverFileName }}</text><text v-if="uploadError" class="album-field-error">{{ uploadError }}</text></view>
|
||||
<view class="album-dialog-field"><text>排序值</text><input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" @input="submitError = ''" /></view>
|
||||
<text v-if="submitError" class="album-field-error">{{ submitError }}</text>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow } 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = ref(false);
|
||||
const albums = ref([]);
|
||||
const listState = ref("loading");
|
||||
const listError = ref("");
|
||||
const dialogVisible = ref(false);
|
||||
const albumNameDraft = ref("");
|
||||
const form = reactive({ albumName: "", albumDesc: "", sortOrder: "" });
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const submitError = ref("");
|
||||
const uploadError = ref("");
|
||||
const uploading = ref(false);
|
||||
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: "新建相册" },
|
||||
);
|
||||
onLoad((query) => {
|
||||
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 controller = createRequestController();
|
||||
let active = true;
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const text = (value) => typeof value === "string" || typeof value === "number" ? String(value).trim() : "";
|
||||
const toAlbum = (item) => {
|
||||
const id = text(item?.albumId);
|
||||
if (!/^[1-9]\d*$/.test(id)) return null;
|
||||
return { id, name: text(item.albumName) || "未命名相册", description: text(item.albumDesc), photoCount: Number.isSafeInteger(item.photoCount) ? item.photoCount : 0 };
|
||||
};
|
||||
const closeCreateDialog = () => {
|
||||
if (!isSubmitting.value) dialogVisible.value = false;
|
||||
const loadAlbums = async () => {
|
||||
if (!hasValidContext.value) return;
|
||||
controller.abort();
|
||||
listState.value = "loading";
|
||||
listError.value = "";
|
||||
try {
|
||||
const rows = await appApi.getAlbums(genealogyId.value, { requestController: controller });
|
||||
if (!active) return;
|
||||
albums.value = rows.map(toAlbum).filter(Boolean);
|
||||
listState.value = albums.value.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
listState.value = "error";
|
||||
listError.value = error?.message || "请稍后重试。";
|
||||
}
|
||||
};
|
||||
const resetDraft = () => { Object.assign(form, { albumName: "", albumDesc: "", sortOrder: "" }); coverOssId.value = null; coverFileName.value = ""; submitError.value = ""; uploadError.value = ""; };
|
||||
const openCreateDialog = () => { if (!hasValidContext.value || isSubmitting.value || uploading.value) return; resetDraft(); dialogVisible.value = true; };
|
||||
const closeCreateDialog = () => { if (!isSubmitting.value && !uploading.value) dialogVisible.value = false; };
|
||||
const uploadCover = async () => {
|
||||
if (uploading.value || isSubmitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try { const receipt = await pickAndUploadImage({ requestController: controller }); coverOssId.value = toConsumerOssId(receipt.ossId); coverFileName.value = receipt.fileName || "封面图片"; }
|
||||
catch (error) { if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "封面图片上传失败,请稍后重试。"; }
|
||||
finally { uploading.value = false; }
|
||||
};
|
||||
const submitAlbum = async () => {
|
||||
if (isSubmitting.value) return;
|
||||
const albumName = albumNameDraft.value.trim();
|
||||
if (!albumName) {
|
||||
submitError.value = "请填写相册名称";
|
||||
return;
|
||||
}
|
||||
if (isSubmitting.value || uploading.value) return;
|
||||
if (!form.albumName.trim()) { submitError.value = "请填写相册名称"; return; }
|
||||
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;
|
||||
}
|
||||
try { await appApi.createAlbum(genealogyId.value, { ...form, coverOssId: coverOssId.value }, { requestController: controller }); dialogVisible.value = false; await loadAlbums(); }
|
||||
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 handleStateAction = () => hasValidContext.value ? openCreateDialog() : goBack();
|
||||
const openAlbum = (item) => openPage("F08", { genealogyId: genealogyId.value, albumId: item.id }, "F07");
|
||||
const requestBack = () => runBackGuard({ transientOpen: dialogVisible.value, submitting: isSubmitting.value || uploading.value, "close-transient": closeCreateDialog, "block-submitting": () => true });
|
||||
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (hasValidContext.value) loadAlbums(); else listState.value = "invalid"; });
|
||||
onShow(() => { if (hasValidContext.value && !dialogVisible.value && listState.value !== "loading") loadAlbums(); });
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => requestController.abort());
|
||||
onUnmounted(() => { active = false; controller.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-content { z-index: 1; }
|
||||
.album-list-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.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; }
|
||||
.album-state-card .app-button { margin-top: 28rpx; }
|
||||
.album-dialog-field { width: 100%; margin: 24rpx 0; text-align: left; }
|
||||
.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; }
|
||||
.album-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }.album-list-header, .album-list-content { z-index: 1; }.album-list-content { padding: 18rpx 24rpx 72rpx; }.album-state-card, .album-card { @include adaptive.adaptive-family-content; }.album-state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }.album-state-card text, .album-card text { display: block; }.album-state-card text:first-child, .album-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }.album-state-card text:nth-child(2), .album-card text:not(:first-child) { margin-top: 12rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }.album-state-card .app-button { margin-top: 28rpx; }.album-list { display: flex; flex-direction: column; gap: 16rpx; }.album-card { padding: 28rpx 30rpx; }.album-dialog-field { width: 100%; margin: 20rpx 0; text-align: left; }.album-dialog-field > text:first-child, .album-dialog-field--cover > view > text:first-child { display: block; color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; }.album-dialog-field input, .album-dialog-field textarea { @include adaptive.adaptive-family-field; box-sizing: border-box; display: block; width: 100%; margin-top: 10rpx; padding: 16rpx 22rpx; color: $ink; font-size: 24rpx; }.album-dialog-field input { min-height: 76rpx; padding-top: 0; padding-bottom: 0; }.album-dialog-field textarea { min-height: 112rpx; line-height: 1.55; }.album-dialog-field--cover { display: grid; gap: 12rpx; padding: 18rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }.album-field-hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }.album-field-error { display: block; margin-top: 8rpx; color: $brand-red; font-size: 21rpx; line-height: 1.45; }
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<!-- 页面编号:F-08;用途:相册照片墙。响应未声明照片展示 DTO 时保持关闭。 -->
|
||||
<!-- 页面编号:F-08;用途:读取当前相册的照片记录。 -->
|
||||
<template>
|
||||
<view class="album-detail-page">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="album-detail-header"><PageHeader title="相册详情" custom-back @back="returnToAlbums" /></view>
|
||||
<view class="album-detail-header"><PageHeader title="相册详情" :action="valid ? '添加' : ''" custom-back @back="returnToAlbums" @action="addPhoto" /></view>
|
||||
<view class="album-detail-content">
|
||||
<view class="album-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="returnToAlbums" />
|
||||
<view v-if="!valid" class="album-state-card"><text>相册入口无效</text><AppButton block label="返回相册列表" @click="returnToAlbums" /></view>
|
||||
<view v-else-if="state === 'loading'" class="album-state-card"><AppLoading text="正在读取相册照片" /></view>
|
||||
<view v-else-if="state === 'error'" class="album-state-card"><text>暂时无法读取相册照片</text><AppButton block type="secondary" label="重新加载" @click="loadPhotos" /></view>
|
||||
<view v-else-if="state === 'empty'" class="album-state-card"><text>还没有照片</text><text>添加照片后会直接读取服务端相册记录。</text><AppButton block label="添加照片" @click="addPhoto" /></view>
|
||||
<view v-else class="photo-list">
|
||||
<view v-for="item in photos" :key="item.id" class="photo-card"><text>{{ item.title }}</text><text v-if="item.description">{{ item.description }}</text><text v-if="item.meta">{{ item.meta }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -15,36 +17,50 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShow, 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 { goBack, returnTo } from "@/utils/navigation.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const albumId = ref("");
|
||||
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 || "");
|
||||
});
|
||||
const returnToAlbums = () => /^[1-9]\d*$/.test(genealogyId.value)
|
||||
? returnTo("F07", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const photos = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
|
||||
const loadPhotos = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getAlbumPhotos(genealogyId.value, albumId.value, { requestController: controller });
|
||||
if (!active) return;
|
||||
photos.value = rows.map((item) => ({
|
||||
id: String(item.photoId || ""),
|
||||
title: String(item.photoTitle || "未命名照片"),
|
||||
description: String(item.photoDesc || ""),
|
||||
meta: [item.photographer, item.shootTime].filter(Boolean).join(" · "),
|
||||
})).filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
state.value = photos.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToAlbums = () => /^[1-9]\d*$/.test(genealogyId.value) ? returnTo("F07", { genealogyId: genealogyId.value }) : goBack();
|
||||
const addPhoto = () => valid.value ? openPage("F09", { genealogyId: genealogyId.value, albumId: albumId.value }, "F08") : Promise.resolve(false);
|
||||
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); albumId.value = String(query?.albumId || ""); if (valid.value) loadPhotos(); });
|
||||
onShow(() => { if (valid.value && state.value !== "loading") loadPhotos(); });
|
||||
onUnload(() => { active = false; controller.abort(); });
|
||||
</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-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; }
|
||||
.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, .photo-card { @include adaptive.adaptive-family-content; }.album-state-card { width: 100%; min-height: 340rpx; padding: 84rpx 44rpx 56rpx; 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; }
|
||||
.photo-list { display: flex; flex-direction: column; gap: 16rpx; }.photo-card { padding: 28rpx 32rpx; }.photo-card text { display: block; }.photo-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }.photo-card text:not(:first-child) { margin-top: 8rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
|
||||
</style>
|
||||
|
||||
@@ -1,48 +1,183 @@
|
||||
<!-- 页面编号:F-09;用途:相册照片写入。没有文件上传与 OSS 回执 owner 时关闭。 -->
|
||||
<!-- 页面编号:F-09;用途:上传真实图片并创建相册照片记录。 -->
|
||||
<template>
|
||||
<view class="media-upload-page">
|
||||
<view class="media-upload-page" :class="`media-state--${pageState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="returnToAlbum" /></view>
|
||||
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="requestBack" /></view>
|
||||
<view class="media-upload-content">
|
||||
<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 v-if="pageState === 'form'" class="media-panel">
|
||||
<text class="media-panel__title">添加一张家族照片</text>
|
||||
<text class="media-panel__note">请先选择图片。保存时只会提交真实上传回执中的文件标识。</text>
|
||||
|
||||
<view class="media-field media-field--upload">
|
||||
<text class="media-field__label"><text class="required-mark">*</text>照片</text>
|
||||
<button class="upload-button" :disabled="uploading || submitting" @click="selectPhoto">{{ uploading ? '上传中…' : (receipt ? '重新选择图片' : '选择图片') }}</button>
|
||||
<text v-if="receipt" class="upload-receipt">已上传:{{ receipt.fileName || '图片' }}</text>
|
||||
</view>
|
||||
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||||
|
||||
<view class="media-field">
|
||||
<text class="media-field__label">照片标题</text>
|
||||
<input v-model="form.photoTitle" maxlength="60" placeholder="例如:祖宅合影" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<view class="media-field media-field--textarea">
|
||||
<text class="media-field__label">照片说明</text>
|
||||
<textarea v-model="form.photoDesc" maxlength="500" auto-height placeholder="补充照片中的人物、场景或故事" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<view class="media-field">
|
||||
<text class="media-field__label">拍摄人</text>
|
||||
<input v-model="form.photographer" maxlength="30" placeholder="请输入拍摄人" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<view class="media-field">
|
||||
<text class="media-field__label">拍摄时间</text>
|
||||
<input v-model="form.shootTime" maxlength="30" placeholder="例如:2026-07-24 10:00:00" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<view class="media-field">
|
||||
<text class="media-field__label">排序值</text>
|
||||
<input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<text v-if="submitError" class="field-error">{{ submitError }}</text>
|
||||
<AppButton block :disabled="uploading || submitting" :label="submitting ? '正在保存…' : '保存照片'" @click="submitPhoto" />
|
||||
</view>
|
||||
|
||||
<view v-else class="media-state-card">
|
||||
<text class="media-state-card__title">{{ stateCopy.title }}</text>
|
||||
<text class="media-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃照片草稿?"
|
||||
message="当前内容尚未保存到服务端,返回后不会保留。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmation.confirm"
|
||||
@cancel="confirmation.cancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } 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 ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const albumId = ref("");
|
||||
const pageState = ref("form");
|
||||
const receipt = ref(null);
|
||||
const uploadError = ref("");
|
||||
const submitError = ref("");
|
||||
const uploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const form = reactive({ photoTitle: "", photoDesc: "", photographer: "", shootTime: "", sortOrder: "" });
|
||||
const controller = createRequestController();
|
||||
const discardVisible = ref(false);
|
||||
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
||||
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
|
||||
const isDirty = computed(() => receipt.value || Object.values(form).some((value) => value.trim()));
|
||||
const stateCopy = computed(() => pageState.value === "success"
|
||||
? { title: "照片已提交服务端", copy: "服务端已返回成功结果。", action: "返回相册" }
|
||||
: { title: "照片入口无效", copy: "没有取得有效家谱或相册标识。", action: "返回上一页" });
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
albumId.value = String(query?.albumId || "");
|
||||
if (!hasValidContext.value) pageState.value = "invalid";
|
||||
});
|
||||
|
||||
const selectPhoto = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
receipt.value = await pickAndUploadImage({ requestController: controller });
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "图片上传失败,请稍后重试";
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitPhoto = async () => {
|
||||
if (uploading.value || submitting.value || !hasValidContext.value) return;
|
||||
if (!receipt.value) {
|
||||
submitError.value = "请先选择并上传照片";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createAlbumPhoto(genealogyId.value, albumId.value, {
|
||||
...form,
|
||||
ossId: toConsumerOssId(receipt.value.ossId),
|
||||
}, { requestController: controller });
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "照片保存失败,请稍后重试";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const returnToAlbum = () => hasValidContext.value
|
||||
? returnTo("F07", { genealogyId: genealogyId.value })
|
||||
? returnTo("F08", { genealogyId: genealogyId.value, albumId: albumId.value })
|
||||
: goBack();
|
||||
const requestBack = () => runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: uploading.value || submitting.value,
|
||||
"close-transient": confirmation.cancel,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
const handleStateAction = () => pageState.value === "success" ? returnToAlbum() : goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
controller.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</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-state-card { @include adaptive.adaptive-family-panel; min-height: 410rpx; box-sizing: border-box; padding: 98rpx 46rpx 58rpx; text-align: center; }
|
||||
.media-upload-content { flex: 1; padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom)); }
|
||||
.media-panel, .media-state-card { @include adaptive.adaptive-family-panel; box-sizing: border-box; }
|
||||
.media-panel { padding: 38rpx 32rpx 42rpx; }
|
||||
.media-panel__title, .media-panel__note, .media-field__label, .field-error { display: block; }
|
||||
.media-panel__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
|
||||
.media-panel__note { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }
|
||||
.media-field { margin-top: 20rpx; }
|
||||
.media-field__label { margin: 0 6rpx 8rpx; color: $ink; font-size: 25rpx; font-weight: 700; }
|
||||
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
|
||||
.media-field input, .media-field textarea { @include adaptive.adaptive-family-field; box-sizing: border-box; display: block; width: 100%; color: $ink; font-size: 24rpx; }
|
||||
.media-field input { min-height: 78rpx; padding: 0 22rpx; }
|
||||
.media-field textarea { min-height: 116rpx; padding: 18rpx 22rpx; line-height: 1.55; }
|
||||
.placeholder { color: #9e8e79; }
|
||||
.media-field--upload { display: grid; gap: 12rpx; padding: 18rpx 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
|
||||
.media-field--upload .media-field__label { margin: 0; }
|
||||
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
|
||||
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
||||
.field-error { margin-top: 10rpx; color: $brand-red; font-size: 22rpx; line-height: 1.45; }
|
||||
.media-panel .app-button { margin-top: 28rpx; }
|
||||
.media-state-card { min-height: 410rpx; 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__title { 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>
|
||||
|
||||
@@ -366,7 +366,7 @@ import {
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
import { handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
|
||||
import { consumeNavigationResult, handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const isLoading = ref(false);
|
||||
const hasError = ref(false);
|
||||
@@ -465,6 +465,16 @@ onLoad((query) => {
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
const result = consumeNavigationResult("G01");
|
||||
if (
|
||||
result?.operation === "genealogy-created" &&
|
||||
result.refresh &&
|
||||
result.entityId
|
||||
) {
|
||||
requestedGenealogyId.value = result.entityId;
|
||||
loadGenealogies();
|
||||
return;
|
||||
}
|
||||
if (skipNextShowRefresh) {
|
||||
skipNextShowRefresh = false;
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,377 @@
|
||||
<!-- 页面编号: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>
|
||||
<!-- 页面编号:G-03;用途:创建家谱。首代人物需在服务端提供可恢复合同后另行录入。 -->
|
||||
<template>
|
||||
<view class="create-page">
|
||||
<GenealogyPageBackground />
|
||||
<PageHeader title="创建家谱" custom-back @back="backToGenealogies" />
|
||||
|
||||
<view class="page-content">
|
||||
<view class="create-card">
|
||||
<text class="create-card__eyebrow">立谱信息</text>
|
||||
<text class="create-card__title">为家族创建一部家谱</text>
|
||||
<text class="create-card__note">创建成功后会回到我的家谱;首代人物可在后续合同开放后再录入。</text>
|
||||
|
||||
<view class="field-row">
|
||||
<text class="field-row__label"><text class="required-mark">*</text>姓氏</text>
|
||||
<input v-model="form.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 class="field-row__label"><text class="required-mark">*</text>谱名</text>
|
||||
<input v-model="form.genealogyName" maxlength="24" placeholder="请输入家谱名称" placeholder-class="placeholder" @input="clearFieldError('genealogyName')" />
|
||||
</view>
|
||||
<text v-if="fieldErrors.genealogyName" class="field-error">{{ fieldErrors.genealogyName }}</text>
|
||||
|
||||
<view class="field-row field-row--selector" @click="openRegionPicker">
|
||||
<text class="field-row__label"><text class="required-mark">*</text>所在地区</text>
|
||||
<text class="region-selector-value" :class="{ 'region-selector-value--placeholder': !selectedRegion }">{{ selectedRegion ? regionPickerTrail.map((item) => item.label).join(' / ') : (regionLoading ? '正在加载地区…' : (regionPickerError || '请选择所在地区')) }}</text>
|
||||
</view>
|
||||
<text v-if="fieldErrors.regionCode" class="field-error">{{ fieldErrors.regionCode }}</text>
|
||||
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">堂号</text>
|
||||
<input v-model="form.ancestralHall" maxlength="12" placeholder="请输入堂号" placeholder-class="placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">所在地</text>
|
||||
<input v-model="form.originPlace" maxlength="30" placeholder="请输入所在地" placeholder-class="placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">详细地址</text>
|
||||
<input v-model="form.addressDetail" maxlength="60" placeholder="请输入详细地址" placeholder-class="placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="intro-field">
|
||||
<text class="intro-field__label">家谱简介</text>
|
||||
<textarea v-model="form.intro" maxlength="200" auto-height placeholder="可记录迁徙、家训或建谱缘由" placeholder-class="placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="cover-field">
|
||||
<view>
|
||||
<text class="cover-field__label">封面图片</text>
|
||||
<text class="cover-field__hint">选择图片后会取得真实上传回执,并在创建家谱时关联。</text>
|
||||
</view>
|
||||
<button class="upload-button" :disabled="isUploading || isSubmitting" @click="uploadCover">{{ isUploading ? '上传中…' : '选择图片' }}</button>
|
||||
<text v-if="coverFileName" class="upload-receipt">已上传:{{ coverFileName }}</text>
|
||||
</view>
|
||||
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||||
|
||||
<view class="access-rule">
|
||||
<text class="access-rule__label">访问规则</text>
|
||||
<view class="access-rule__options">
|
||||
<view
|
||||
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
|
||||
:key="option.value"
|
||||
class="access-rule__option"
|
||||
:class="{ 'access-rule__option--active': form.accessPreset === option.value }"
|
||||
@click="form.accessPreset = option.value"
|
||||
>{{ option.label }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text v-if="submitError" class="submit-error">{{ submitError }}</text>
|
||||
<AppButton block :disabled="isSubmitting || isUploading" :label="isSubmitting ? '正在创建…' : '确认创建家谱'" @click="submitCreate" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="regionPickerOpen" class="region-sheet">
|
||||
<view class="region-sheet__mask" />
|
||||
<view class="region-sheet__panel">
|
||||
<view class="region-sheet__intro">
|
||||
<text class="region-sheet__title">选择地区</text>
|
||||
</view>
|
||||
<view class="region-sheet__picker">
|
||||
<view class="region-sheet__column-headings">
|
||||
<text v-for="label in ['省份', '城市', '区县']" :key="label" class="region-sheet__column-heading">{{ label }}</text>
|
||||
</view>
|
||||
<picker-view
|
||||
class="region-sheet__picker-view"
|
||||
:indicator-style="regionPickerIndicatorStyle"
|
||||
:value="regionPickerIndexes"
|
||||
@change="handleRegionPickerChange"
|
||||
>
|
||||
<picker-view-column
|
||||
v-for="(column, columnIndex) in regionPickerColumns"
|
||||
:key="columnIndex"
|
||||
>
|
||||
<view
|
||||
v-for="(option, optionIndex) in column"
|
||||
:key="option.regionCode"
|
||||
class="region-sheet__picker-item"
|
||||
:class="{ 'region-sheet__picker-item--selected': regionPickerIndexes[columnIndex] === optionIndex }"
|
||||
>{{ option.label }}</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
<view class="region-sheet__footer">
|
||||
<view class="region-sheet__cancel" @click="closeRegionPicker">取消</view>
|
||||
<button class="region-sheet__confirm" @click="confirmRegionSelection">确认选择</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||
import { finishPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const form = reactive({
|
||||
surname: "",
|
||||
genealogyName: "",
|
||||
ancestralHall: "",
|
||||
originPlace: "",
|
||||
addressDetail: "",
|
||||
intro: "",
|
||||
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
|
||||
});
|
||||
const fieldErrors = reactive({ surname: "", genealogyName: "", regionCode: "" });
|
||||
const submitError = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const uploadError = ref("");
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const createController = createRequestController();
|
||||
const regionController = createRequestController();
|
||||
const selectedRegion = ref(null);
|
||||
const regionPickerTrail = ref([]);
|
||||
const regionPickerColumns = ref([]);
|
||||
const regionPickerIndexes = ref([0]);
|
||||
const regionPickerError = ref("");
|
||||
const regionLoading = ref(false);
|
||||
const regionPickerOpen = ref(false);
|
||||
const regionPickerIndicatorStyle = "height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
|
||||
let pageActive = true;
|
||||
|
||||
const clearFieldError = (field) => {
|
||||
fieldErrors[field] = "";
|
||||
submitError.value = "";
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
fieldErrors.surname = form.surname.trim() ? "" : "请填写姓氏";
|
||||
fieldErrors.genealogyName = form.genealogyName.trim() ? "" : "请填写谱名";
|
||||
fieldErrors.regionCode = selectedRegion.value?.regionCode ? "" : "请选择所在地区";
|
||||
return !fieldErrors.surname && !fieldErrors.genealogyName && !fieldErrors.regionCode;
|
||||
};
|
||||
|
||||
const fetchRegionChildren = async (parentCode) => {
|
||||
regionLoading.value = true;
|
||||
regionPickerError.value = "";
|
||||
try {
|
||||
return await appApi.getRegionChildren(parentCode, { requestController: regionController });
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) regionPickerError.value = error?.message || "地区列表加载失败,请重试";
|
||||
return [];
|
||||
} finally {
|
||||
regionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadRegionRoot = async () => {
|
||||
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
|
||||
const roots = await fetchRegionChildren("0");
|
||||
if (!roots.length || regionPickerError.value) return;
|
||||
regionPickerColumns.value = [roots];
|
||||
regionPickerIndexes.value = [0];
|
||||
regionPickerTrail.value = [];
|
||||
await loadPickerColumns();
|
||||
};
|
||||
|
||||
const loadPickerColumns = async (provinceIndex = 0, cityIndex = 0) => {
|
||||
const provinces = regionPickerColumns.value[0] || await fetchRegionChildren("0");
|
||||
const province = provinces[provinceIndex];
|
||||
if (!province) return;
|
||||
|
||||
const cities = await fetchRegionChildren(province.regionCode);
|
||||
const city = cities[cityIndex];
|
||||
if (!city) {
|
||||
regionPickerColumns.value = [provinces];
|
||||
regionPickerIndexes.value = [provinceIndex];
|
||||
return;
|
||||
}
|
||||
|
||||
const districts = await fetchRegionChildren(city.regionCode);
|
||||
if (!districts.length || regionPickerError.value) return;
|
||||
regionPickerColumns.value = [provinces, cities, districts];
|
||||
regionPickerIndexes.value = [provinceIndex, cityIndex, 0];
|
||||
};
|
||||
|
||||
const handleRegionPickerChange = async (event) => {
|
||||
if (regionLoading.value) return;
|
||||
const nextIndexes = (event?.detail?.value || []).map((index) => Number(index) || 0);
|
||||
const indexes = regionPickerIndexes.value;
|
||||
const provinceIndex = nextIndexes[0] || 0;
|
||||
const cityIndex = nextIndexes[1] || 0;
|
||||
const districtIndex = nextIndexes[2] || 0;
|
||||
|
||||
if (provinceIndex !== (indexes[0] || 0)) {
|
||||
await loadPickerColumns(provinceIndex, 0);
|
||||
return;
|
||||
}
|
||||
if (cityIndex !== (indexes[1] || 0)) {
|
||||
await loadPickerColumns(indexes[0] || 0, cityIndex);
|
||||
return;
|
||||
}
|
||||
regionPickerIndexes.value = [indexes[0] || 0, indexes[1] || 0, districtIndex];
|
||||
};
|
||||
|
||||
const openRegionPicker = async () => {
|
||||
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
|
||||
if (!regionPickerColumns.value.length) await loadRegionRoot();
|
||||
if (regionPickerColumns.value.length) {
|
||||
regionPickerOpen.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const closeRegionPicker = () => {
|
||||
regionPickerOpen.value = false;
|
||||
};
|
||||
|
||||
const confirmRegionSelection = () => {
|
||||
const indexes = regionPickerIndexes.value;
|
||||
const trail = [];
|
||||
regionPickerColumns.value.forEach((column, index) => {
|
||||
const option = column[Number(indexes[index])];
|
||||
if (option) trail.push(option);
|
||||
});
|
||||
const region = trail[trail.length - 1];
|
||||
if (!region) return;
|
||||
regionPickerIndexes.value = trail.map((_, index) => Number(indexes[index]) || 0);
|
||||
selectedRegion.value = region;
|
||||
regionPickerTrail.value = trail;
|
||||
fieldErrors.regionCode = "";
|
||||
regionPickerError.value = "";
|
||||
regionPickerOpen.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadRegionRoot();
|
||||
});
|
||||
|
||||
const backToGenealogies = () => {
|
||||
if (!isSubmitting.value && !isUploading.value) return returnTo("G01");
|
||||
};
|
||||
|
||||
const uploadCover = async () => {
|
||||
if (isUploading.value || isSubmitting.value) return;
|
||||
isUploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: createController });
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = toConsumerOssId(receipt.ossId);
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) isUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (isSubmitting.value || isUploading.value || !validate()) return;
|
||||
const access = toApiGenealogyAccess(form.accessPreset);
|
||||
if (!access) {
|
||||
submitError.value = "访问规则无效,请重新选择";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
const created = await appApi.createGenealogy({
|
||||
surname: form.surname,
|
||||
genealogyName: form.genealogyName,
|
||||
regionCode: selectedRegion.value.regionCode,
|
||||
ancestralHall: form.ancestralHall,
|
||||
originPlace: form.originPlace,
|
||||
addressDetail: form.addressDetail,
|
||||
intro: form.intro,
|
||||
coverOssId: coverOssId.value,
|
||||
...access,
|
||||
}, { requestController: createController });
|
||||
if (!pageActive) return;
|
||||
await finishPage("G01", {}, {
|
||||
operation: "genealogy-created",
|
||||
entityId: created.id,
|
||||
refresh: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
submitError.value = error?.message || "创建失败,请稍后重试";
|
||||
} finally {
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
createController.abort();
|
||||
regionController.abort();
|
||||
});
|
||||
</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-content { z-index: 1; padding: 24rpx 32rpx 72rpx; }
|
||||
.create-card { @include adaptive.adaptive-genealogy-state-panel; padding: 48rpx 38rpx 42rpx; }
|
||||
.create-card__eyebrow, .create-card__title, .create-card__note, .field-error, .submit-error { display: block; }
|
||||
.create-card__eyebrow { color: $brand-red; font-size: 26rpx; font-weight: 700; letter-spacing: 3rpx; }
|
||||
.create-card__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 42rpx; font-weight: 700; }
|
||||
.create-card__note { margin-top: 16rpx; color: $ink-muted; font-size: 25rpx; line-height: 1.65; }
|
||||
.field-row { display: flex; min-height: 100rpx; align-items: center; margin-top: 22rpx; padding: 0 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
|
||||
.field-row__label { width: 142rpx; flex: 0 0 auto; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
|
||||
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
|
||||
.field-row input { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; }
|
||||
.field-row--selector { justify-content: space-between; cursor: pointer; }
|
||||
.region-selector-value { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; text-align: right; overflow-wrap: anywhere; }
|
||||
.region-selector-value--placeholder { color: #ab9a86; }
|
||||
.placeholder { color: #ab9a86; }
|
||||
.intro-field { margin-top: 22rpx; padding: 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
|
||||
.intro-field__label { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
|
||||
.intro-field textarea { display: block; width: 100%; min-height: 120rpx; margin-top: 14rpx; color: $ink; font-size: 27rpx; line-height: 1.55; }
|
||||
.cover-field { display: grid; gap: 12rpx; margin-top: 22rpx; padding: 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
|
||||
.cover-field__label { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
|
||||
.cover-field__hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; }
|
||||
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
|
||||
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
||||
.field-error, .submit-error { margin-top: 10rpx; color: $brand-red; font-size: 24rpx; line-height: 1.5; }
|
||||
.access-rule { margin-top: 28rpx; }
|
||||
.access-rule__label { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
|
||||
.access-rule__options { display: flex; margin-top: 16rpx; gap: 16rpx; }
|
||||
.access-rule__option { flex: 1; padding: 20rpx 12rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; color: $ink-muted; font-size: 25rpx; text-align: center; }
|
||||
.access-rule__option--active { border-color: $brand-red; background: rgba(159, 23, 15, .07); color: $brand-red; font-weight: 700; }
|
||||
.create-card .app-button { margin-top: 32rpx; }
|
||||
.region-sheet { position: fixed; z-index: 10; inset: 0; display: flex; align-items: flex-end; }
|
||||
.region-sheet__mask { position: absolute; inset: 0; background: rgba(43, 30, 20, .42); }
|
||||
.region-sheet__panel { position: relative; width: 100%; padding: 22rpx 28rpx calc(24rpx + env(safe-area-inset-bottom)); border-radius: 30rpx 30rpx 0 0; background: #fdf9ef; box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, .2); }
|
||||
.region-sheet__intro { padding: 0 10rpx 16rpx; text-align: center; }
|
||||
.region-sheet__title { display: block; }
|
||||
.region-sheet__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
|
||||
.region-sheet__picker { overflow: hidden; border: 1rpx solid rgba(128, 89, 49, .28); border-radius: 16rpx; background: rgba(255, 252, 245, .8); }
|
||||
.region-sheet__column-headings { display: flex; height: 84rpx; border-bottom: 1rpx solid rgba(128, 89, 49, .18); }
|
||||
.region-sheet__column-heading { box-sizing: border-box; width: 33.333%; padding: 24rpx 12rpx; color: $ink-muted; font-size: 26rpx; text-align: center; }
|
||||
.region-sheet__column-heading + .region-sheet__column-heading { border-left: 1rpx solid rgba(128, 89, 49, .16); }
|
||||
.region-sheet__picker-view { width: 100%; height: 520rpx; }
|
||||
.region-sheet__picker-item { box-sizing: border-box; height: 104rpx; overflow: hidden; padding: 0 6rpx; color: $ink-muted; font-size: 26rpx; line-height: 104rpx; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.region-sheet__picker-item--selected { color: $brand-red; font-weight: 700; }
|
||||
.region-sheet__footer { display: flex; align-items: center; margin-top: 22rpx; gap: 12rpx; }
|
||||
.region-sheet__cancel { min-width: 116rpx; padding: 20rpx 10rpx; color: $ink-muted; font-size: 28rpx; text-align: center; }
|
||||
.region-sheet__confirm { flex: 1; height: 82rpx; margin: 0; padding: 0; border: 0; border-radius: 10rpx; background: $brand-red; color: #fff; font-size: 29rpx; font-weight: 700; line-height: 82rpx; }
|
||||
.region-sheet__confirm::after { display: none; }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,67 @@
|
||||
<!-- 页面编号: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>
|
||||
<template>
|
||||
<view class="search-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"><PageHeader title="搜索家谱" custom-back @back="backToGenealogies" /></view>
|
||||
<view class="page-content">
|
||||
<view class="search-note"><text>公开家谱</text><text>以下结果来自服务端公开家谱列表。</text></view>
|
||||
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取公开家谱" /></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取公开家谱</text><AppButton block type="secondary" label="重新加载" @click="loadGenealogies" /></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"><text>暂未找到公开家谱</text></view>
|
||||
<view v-else class="result-list">
|
||||
<view v-for="item in rows" :key="item.id" class="genealogy-card">
|
||||
<view class="card-heading"><text>{{ item.name }}</text><text v-if="item.surname">{{ item.surname }}氏</text></view>
|
||||
<text v-if="item.location" class="card-meta">{{ item.location }}</text>
|
||||
<text v-if="item.intro" class="card-copy">{{ item.intro }}</text>
|
||||
<view class="card-footer"><text>{{ item.memberCount }} 位成员</text><AppButton :label="item.canManage ? '已在我的家谱' : '申请加入'" :disabled="item.canManage" @click="applyToJoin(item)" /></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { openPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const rows = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const loadGenealogies = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPublicGenealogies({ requestController: controller });
|
||||
if (!active) return;
|
||||
rows.value = result.map((item) => ({
|
||||
id: String(item.genealogyId),
|
||||
name: String(item.genealogyName || "未命名家谱"),
|
||||
surname: String(item.surname || ""),
|
||||
location: String(item.regionFullName || item.regionName || item.originPlace || item.addressDetail || ""),
|
||||
intro: String(item.intro || ""),
|
||||
memberCount: Number.isSafeInteger(item.memberCount) ? item.memberCount : 0,
|
||||
canManage: item.canManage === true
|
||||
})).filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
state.value = rows.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const applyToJoin = (item) => openPage("G08", { genealogyId: item.id, genealogyName: item.name }, "G06");
|
||||
const backToGenealogies = () => returnTo("G01");
|
||||
onLoad(loadGenealogies);
|
||||
onShow(() => { if (state.value !== "loading") loadGenealogies(); });
|
||||
onUnload(() => { active = false; controller.abort(); });
|
||||
</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}.search-note,.state-card,.genealogy-card{@include adaptive.adaptive-genealogy-state-panel}.search-note{display:flex;min-height:112rpx;flex-direction:column;justify-content:center;padding:20rpx 30rpx}.search-note text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:30rpx;font-weight:700}.search-note text:last-child{margin-top:6rpx;color:$ink-muted;font-size:21rpx}.state-card{display:flex;min-height:310rpx;flex-direction:column;align-items:center;justify-content:center;margin-top:18rpx;padding:42rpx;text-align:center;color:$ink;font-size:28rpx}.state-card .app-button{width:100%;margin-top:22rpx}.result-list{display:flex;flex-direction:column;gap:16rpx;margin-top:18rpx}.genealogy-card{padding:28rpx 32rpx}.card-heading{display:flex;align-items:baseline;justify-content:space-between;gap:18rpx}.card-heading text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:31rpx;font-weight:700;overflow-wrap:anywhere}.card-heading text:last-child{flex:0 0 auto;color:$brand-red;font-size:22rpx}.card-meta,.card-copy{display:block;color:$ink-muted;font-size:22rpx;line-height:1.5}.card-meta{margin-top:10rpx}.card-copy{margin-top:8rpx}.card-footer{display:flex;align-items:center;justify-content:space-between;gap:18rpx;margin-top:20rpx;color:$ink-muted;font-size:21rpx}.card-footer .app-button{min-width:180rpx}@media(max-width:340px){.page-content{padding-right:20rpx;padding-left:20rpx}.card-footer{align-items:flex-start;flex-direction:column}.card-footer .app-button{width:100%}}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,146 @@
|
||||
<!-- 页面编号: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>
|
||||
<!-- 页面编号:G-08;用途:按 APP GenealogyJoinApplyBody 提交加入申请。 -->
|
||||
<template>
|
||||
<view class="join-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"><PageHeader title="申请加入家谱" custom-back @back="requestBack" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="!hasValidContext" class="state-card">
|
||||
<text>申请入口无效</text>
|
||||
<text>没有取得可申请的家谱标识。</text>
|
||||
<AppButton block label="返回搜索家谱" @click="backToSearch" />
|
||||
</view>
|
||||
<view v-else-if="state === 'success'" class="state-card">
|
||||
<text>申请已提交</text>
|
||||
<text>服务端已确认本次加入申请,可返回继续查看公开家谱。</text>
|
||||
<AppButton block label="返回搜索家谱" @click="backToSearch" />
|
||||
</view>
|
||||
<view v-else class="join-form">
|
||||
<view class="form-heading">
|
||||
<text>申请加入</text>
|
||||
<text>{{ genealogyName || '当前家谱' }}</text>
|
||||
</view>
|
||||
<view v-for="field in fields" :key="field.key" class="form-field">
|
||||
<text>{{ field.label }}</text>
|
||||
<textarea
|
||||
v-if="field.multiline"
|
||||
v-model.trim="form[field.key]"
|
||||
auto-height
|
||||
:maxlength="field.maxlength"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="state === 'submitting'"
|
||||
@input="error = ''"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model.trim="form[field.key]"
|
||||
:type="field.inputType || 'text'"
|
||||
:maxlength="field.maxlength"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="state === 'submitting'"
|
||||
@input="error = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="error" class="form-error">{{ error }}</text>
|
||||
<AppButton block :disabled="state === 'submitting'" :label="state === 'submitting' ? '正在提交' : '提交申请'" @click="submit" />
|
||||
</view>
|
||||
</view>
|
||||
<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 GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const genealogyName = ref("");
|
||||
const state = ref("form");
|
||||
const error = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const form = reactive({ applicantName: "", phone: "", relationDesc: "", applyReason: "", inviterUserId: "" });
|
||||
const fields = [
|
||||
{ key: "applicantName", label: "申请人姓名", maxlength: 50, placeholder: "填写您的姓名" },
|
||||
{ key: "phone", label: "手机号", inputType: "number", maxlength: 11, placeholder: "填写联系电话" },
|
||||
{ key: "relationDesc", label: "关系说明", maxlength: 100, placeholder: "例如:本族成员" },
|
||||
{ key: "applyReason", label: "申请理由", multiline: true, maxlength: 500, placeholder: "说明申请加入的原因" },
|
||||
{ key: "inviterUserId", label: "邀请人编号", inputType: "number", maxlength: 20, placeholder: "如有邀请人可填写编号" },
|
||||
];
|
||||
const controller = createRequestController();
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const dirty = computed(() => Object.values(form).some((value) => value.trim()));
|
||||
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
||||
const confirmDiscard = confirmation.confirm;
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
genealogyName.value = String(query?.genealogyName || "");
|
||||
});
|
||||
|
||||
const backToSearch = () => returnTo("G06");
|
||||
const submit = async () => {
|
||||
if (state.value === "submitting" || !hasValidContext.value) return;
|
||||
state.value = "submitting";
|
||||
error.value = "";
|
||||
try {
|
||||
await appApi.applyToJoin(genealogyId.value, { ...form }, { requestController: controller });
|
||||
state.value = "success";
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause)) {
|
||||
state.value = "form";
|
||||
error.value = cause?.message || "申请提交失败,请稍后重试。";
|
||||
}
|
||||
}
|
||||
};
|
||||
const requestBack = () => runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value && state.value === "form",
|
||||
submitting: state.value === "submitting",
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => { controller.abort(); confirmation.dispose(); });
|
||||
</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, .join-form { @include adaptive.adaptive-genealogy-state-panel; }
|
||||
.state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.state-card text { display: block; }
|
||||
.state-card text:first-child, .form-heading 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, .join-form .app-button { margin-top: 28rpx; }
|
||||
.join-form { padding: 30rpx; }
|
||||
.form-heading { display: flex; flex-direction: column; gap: 8rpx; padding-bottom: 22rpx; border-bottom: 1rpx solid rgba(128, 89, 49, .24); }
|
||||
.form-heading text:last-child { color: $brand-red; font-size: 24rpx; }
|
||||
.form-field { margin-top: 22rpx; }
|
||||
.form-field > text { display: block; color: $ink; font-size: 24rpx; font-weight: 700; }
|
||||
.form-field input, .form-field textarea { @include adaptive.adaptive-genealogy-form-field; box-sizing: border-box; display: block; width: 100%; margin-top: 10rpx; padding: 16rpx 22rpx; color: $ink; font-size: 24rpx; }
|
||||
.form-field input { min-height: 76rpx; padding-top: 0; padding-bottom: 0; }
|
||||
.form-field textarea { min-height: 120rpx; line-height: 1.55; }
|
||||
.form-error { display: block; margin-top: 16rpx; color: $brand-red; font-size: 22rpx; line-height: 1.45; }
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,49 @@
|
||||
<!-- 页面编号:G-09;用途:我的申请。列表 DTO 未声明时不展示 fixture 申请。 -->
|
||||
<!-- 页面编号:G-09;用途:读取当前账号的加入申请。 -->
|
||||
<template>
|
||||
<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>
|
||||
<view class="applications-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"><PageHeader title="我的申请" custom-back @back="returnToGenealogies" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取我的申请" /></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取我的申请</text><AppButton block type="secondary" label="重新加载" @click="loadApplications" /></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"><text>暂无加入申请</text><text>申请记录会直接从服务端读取。</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view>
|
||||
<view v-else class="state-card"><text>已读取 {{ applications.length }} 条申请</text><text>申请详情接口尚未开放,当前不会用本地数据补造申请内容。</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></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 returnToGenealogies=()=>returnTo("G01");
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const applications = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const loadApplications = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
applications.value = await appApi.getMyJoinApplications({ requestController: controller });
|
||||
if (!active) return;
|
||||
state.value = applications.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToGenealogies = () => returnTo("G01");
|
||||
onLoad(loadApplications);
|
||||
onShow(() => { if (state.value !== "loading") loadApplications(); });
|
||||
onUnload(() => { active = false; controller.abort(); });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.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}
|
||||
|
||||
@@ -1,10 +1,53 @@
|
||||
<!-- 页面编号:G-10;用途:加入申请审核。缺申请列表 DTO/ID 来源时保持关闭。 -->
|
||||
<!-- 页面编号:G-10;用途:读取当前家谱待审核的加入申请。 -->
|
||||
<template>
|
||||
<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>
|
||||
<view class="review-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"><PageHeader title="申请审核" custom-back @back="returnToGenealogies" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card"><text>申请审核入口无效</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view>
|
||||
<view v-else-if="state === 'loading'" class="state-card"><AppLoading text="正在读取待审核申请" /></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取待审核申请</text><AppButton block type="secondary" label="重新加载" @click="loadApplications" /></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"><text>暂无待审核申请</text><text>待审核记录会直接从服务端读取。</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view>
|
||||
<view v-else class="state-card"><text>已读取 {{ applications.length }} 条待审核申请</text><text>审核详情接口尚未开放,当前不会用本地数据补造申请内容或审核结果。</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></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 returnToGenealogies=()=>returnTo("G01");
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const applications = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const loadApplications = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
applications.value = await appApi.getPendingApplications(genealogyId.value, { requestController: controller });
|
||||
if (!active) return;
|
||||
state.value = applications.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToGenealogies = () => returnTo("G01");
|
||||
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (valid.value) loadApplications(); });
|
||||
onShow(() => { if (valid.value && state.value !== "loading") loadApplications(); });
|
||||
onUnload(() => { active = false; controller.abort(); });
|
||||
</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}.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}
|
||||
|
||||
@@ -1,11 +1,393 @@
|
||||
<!-- 页面编号:G-11;用途:家谱设置。缺可靠读取 DTO 与封面上传 owner 时保持关闭。 -->
|
||||
<!-- 页面编号:G-11;用途:按 GenealogyUpdateBody 更新当前家谱。 -->
|
||||
<template>
|
||||
<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>
|
||||
<view class="settings-page" :class="`settings-state--${pageState}`">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"><PageHeader title="家谱设置" custom-back @back="returnToOverview" /></view>
|
||||
|
||||
<view class="page-content">
|
||||
<AppLoading v-if="pageState === 'loading'" text="正在读取家谱设置" description="请稍候,正在同步当前家谱信息。" />
|
||||
|
||||
<view v-else-if="pageState === 'form'" class="settings-card">
|
||||
<text class="settings-card__title">编辑家谱信息</text>
|
||||
<text class="settings-card__note">带红色星号的内容不能为空;其余内容可按需要补充。</text>
|
||||
|
||||
<view class="field-row">
|
||||
<text class="field-row__label"><text class="required-mark">*</text>姓氏</text>
|
||||
<input v-model="form.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 class="field-row__label"><text class="required-mark">*</text>谱名</text>
|
||||
<input v-model="form.genealogyName" maxlength="24" placeholder="请输入家谱名称" placeholder-class="placeholder" @input="clearFieldError('genealogyName')" />
|
||||
</view>
|
||||
<text v-if="fieldErrors.genealogyName" class="field-error">{{ fieldErrors.genealogyName }}</text>
|
||||
|
||||
<view class="field-row field-row--selector" @click="openRegionPicker">
|
||||
<text class="field-row__label"><text class="required-mark">*</text>所在地区</text>
|
||||
<text class="region-selector-value" :class="{ 'region-selector-value--placeholder': !regionDisplay }">{{ regionDisplay || (regionLoading ? '正在加载地区…' : (regionPickerError || '请选择所在地区')) }}</text>
|
||||
</view>
|
||||
<text v-if="fieldErrors.regionCode" class="field-error">{{ fieldErrors.regionCode }}</text>
|
||||
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">堂号</text>
|
||||
<input v-model="form.ancestralHall" maxlength="12" placeholder="请输入堂号" placeholder-class="placeholder" />
|
||||
</view>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">所在地</text>
|
||||
<input v-model="form.originPlace" maxlength="30" placeholder="请输入所在地" placeholder-class="placeholder" />
|
||||
</view>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">详细地址</text>
|
||||
<input v-model="form.addressDetail" maxlength="60" placeholder="请输入详细地址" placeholder-class="placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="intro-field">
|
||||
<text class="intro-field__label">家谱简介</text>
|
||||
<textarea v-model="form.intro" maxlength="200" auto-height placeholder="记录家谱缘由、迁徙或家训" placeholder-class="placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="cover-field">
|
||||
<view>
|
||||
<text class="cover-field__label">封面图片</text>
|
||||
<text class="cover-field__hint">{{ coverOssId ? '当前已关联封面;重新选择后会以新的真实上传回执更新。' : '选择图片后会取得真实上传回执,并在保存时关联。' }}</text>
|
||||
</view>
|
||||
<button class="upload-button" :disabled="isUploading || isSubmitting" @click="uploadCover">{{ isUploading ? '上传中…' : '选择图片' }}</button>
|
||||
<text v-if="coverFileName" class="upload-receipt">已上传:{{ coverFileName }}</text>
|
||||
</view>
|
||||
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||||
|
||||
<view class="access-rule">
|
||||
<text class="access-rule__label">访问规则</text>
|
||||
<view class="access-rule__options">
|
||||
<view
|
||||
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
|
||||
:key="option.value"
|
||||
class="access-rule__option"
|
||||
:class="{ 'access-rule__option--active': form.accessPreset === option.value }"
|
||||
@click="form.accessPreset = option.value"
|
||||
>{{ option.label }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text v-if="submitError" class="submit-error">{{ submitError }}</text>
|
||||
<AppButton block :disabled="isSubmitting || isUploading" :label="isSubmitting ? '正在保存…' : '保存设置'" @click="submitUpdate" />
|
||||
</view>
|
||||
|
||||
<view v-else class="state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="regionPickerOpen" class="region-sheet">
|
||||
<view class="region-sheet__mask" @click="closeRegionPicker" />
|
||||
<view class="region-sheet__panel">
|
||||
<view class="region-sheet__intro"><text class="region-sheet__title">选择地区</text></view>
|
||||
<view class="region-sheet__picker">
|
||||
<view class="region-sheet__column-headings">
|
||||
<text v-for="label in ['省份', '城市', '区县']" :key="label" class="region-sheet__column-heading">{{ label }}</text>
|
||||
</view>
|
||||
<picker-view class="region-sheet__picker-view" :indicator-style="regionPickerIndicatorStyle" :value="regionPickerIndexes" @change="handleRegionPickerChange">
|
||||
<picker-view-column v-for="(column, columnIndex) in regionPickerColumns" :key="columnIndex">
|
||||
<view v-for="(option, optionIndex) in column" :key="option.regionCode" class="region-sheet__picker-item" :class="{ 'region-sheet__picker-item--selected': regionPickerIndexes[columnIndex] === optionIndex }">{{ option.label }}</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
<view class="region-sheet__footer">
|
||||
<view class="region-sheet__cancel" @click="closeRegionPicker">取消</view>
|
||||
<button class="region-sheet__confirm" @click="confirmRegionSelection">确认选择</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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();
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("loading");
|
||||
const form = reactive({
|
||||
surname: "",
|
||||
genealogyName: "",
|
||||
ancestralHall: "",
|
||||
originPlace: "",
|
||||
addressDetail: "",
|
||||
intro: "",
|
||||
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
|
||||
});
|
||||
const fieldErrors = reactive({ surname: "", genealogyName: "", regionCode: "" });
|
||||
const submitError = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const uploadError = ref("");
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const currentRegionCode = ref("");
|
||||
const currentRegionDisplay = ref("");
|
||||
const selectedRegion = ref(null);
|
||||
const regionPickerTrail = ref([]);
|
||||
const regionPickerColumns = ref([]);
|
||||
const regionPickerIndexes = ref([0]);
|
||||
const regionPickerError = ref("");
|
||||
const regionLoading = ref(false);
|
||||
const regionPickerOpen = ref(false);
|
||||
const regionPickerIndicatorStyle = "height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
|
||||
const controller = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const regionDisplay = computed(() =>
|
||||
regionPickerTrail.value.length
|
||||
? regionPickerTrail.value.map((item) => item.label).join(" / ")
|
||||
: currentRegionDisplay.value,
|
||||
);
|
||||
const stateCopy = computed(() => {
|
||||
if (pageState.value === "success") {
|
||||
return { title: "家谱设置已保存", copy: "服务端已返回成功结果。", action: "返回家谱总览" };
|
||||
}
|
||||
if (!hasValidContext.value) {
|
||||
return { title: "家谱入口无效", copy: "没有取得有效家谱标识。", action: "返回上一页" };
|
||||
}
|
||||
return { title: "家谱设置暂时无法读取", copy: "请检查网络后重新读取。", action: "重新读取" };
|
||||
});
|
||||
|
||||
const clearFieldError = (field) => {
|
||||
fieldErrors[field] = "";
|
||||
submitError.value = "";
|
||||
};
|
||||
const validate = () => {
|
||||
fieldErrors.surname = form.surname.trim() ? "" : "请填写姓氏";
|
||||
fieldErrors.genealogyName = form.genealogyName.trim() ? "" : "请填写谱名";
|
||||
fieldErrors.regionCode = (selectedRegion.value?.regionCode || currentRegionCode.value) ? "" : "请选择所在地区";
|
||||
return !fieldErrors.surname && !fieldErrors.genealogyName && !fieldErrors.regionCode;
|
||||
};
|
||||
|
||||
const fetchRegionChildren = async (parentCode) => {
|
||||
regionLoading.value = true;
|
||||
regionPickerError.value = "";
|
||||
try {
|
||||
return await appApi.getRegionChildren(parentCode, { requestController: controller });
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) regionPickerError.value = error?.message || "地区列表加载失败,请重试";
|
||||
return [];
|
||||
} finally {
|
||||
regionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadPickerColumns = async (provinceIndex = 0, cityIndex = 0) => {
|
||||
const provinces = regionPickerColumns.value[0] || await fetchRegionChildren("0");
|
||||
const province = provinces[provinceIndex];
|
||||
if (!province) return;
|
||||
const cities = await fetchRegionChildren(province.regionCode);
|
||||
const city = cities[cityIndex];
|
||||
if (!city) {
|
||||
regionPickerColumns.value = [provinces];
|
||||
regionPickerIndexes.value = [provinceIndex];
|
||||
return;
|
||||
}
|
||||
const districts = await fetchRegionChildren(city.regionCode);
|
||||
if (!districts.length || regionPickerError.value) return;
|
||||
regionPickerColumns.value = [provinces, cities, districts];
|
||||
regionPickerIndexes.value = [provinceIndex, cityIndex, 0];
|
||||
};
|
||||
|
||||
const loadRegionRoot = async () => {
|
||||
if (regionLoading.value) return;
|
||||
const roots = await fetchRegionChildren("0");
|
||||
if (!roots.length || regionPickerError.value) return;
|
||||
regionPickerColumns.value = [roots];
|
||||
regionPickerIndexes.value = [0];
|
||||
await loadPickerColumns();
|
||||
};
|
||||
|
||||
const handleRegionPickerChange = async (event) => {
|
||||
if (regionLoading.value) return;
|
||||
const nextIndexes = (event?.detail?.value || []).map((index) => Number(index) || 0);
|
||||
const indexes = regionPickerIndexes.value;
|
||||
const provinceIndex = nextIndexes[0] || 0;
|
||||
const cityIndex = nextIndexes[1] || 0;
|
||||
const districtIndex = nextIndexes[2] || 0;
|
||||
if (provinceIndex !== (indexes[0] || 0)) {
|
||||
await loadPickerColumns(provinceIndex, 0);
|
||||
return;
|
||||
}
|
||||
if (cityIndex !== (indexes[1] || 0)) {
|
||||
await loadPickerColumns(indexes[0] || 0, cityIndex);
|
||||
return;
|
||||
}
|
||||
regionPickerIndexes.value = [indexes[0] || 0, indexes[1] || 0, districtIndex];
|
||||
};
|
||||
|
||||
const openRegionPicker = async () => {
|
||||
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
|
||||
if (!regionPickerColumns.value.length) await loadRegionRoot();
|
||||
if (regionPickerColumns.value.length) regionPickerOpen.value = true;
|
||||
};
|
||||
const closeRegionPicker = () => { regionPickerOpen.value = false; };
|
||||
const confirmRegionSelection = () => {
|
||||
const trail = regionPickerColumns.value.map((column, index) => column[Number(regionPickerIndexes.value[index])]).filter(Boolean);
|
||||
const region = trail[trail.length - 1];
|
||||
if (!region) return;
|
||||
selectedRegion.value = region;
|
||||
currentRegionCode.value = region.regionCode;
|
||||
regionPickerTrail.value = trail;
|
||||
fieldErrors.regionCode = "";
|
||||
regionPickerError.value = "";
|
||||
regionPickerOpen.value = false;
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
if (!hasValidContext.value) {
|
||||
pageState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
pageState.value = "loading";
|
||||
try {
|
||||
const settings = await appApi.getGenealogySettings(genealogyId.value, { requestController: controller });
|
||||
if (!pageActive) return;
|
||||
Object.assign(form, {
|
||||
surname: settings.surname,
|
||||
genealogyName: settings.genealogyName,
|
||||
ancestralHall: settings.ancestralHall,
|
||||
originPlace: settings.originPlace,
|
||||
addressDetail: settings.addressDetail,
|
||||
intro: settings.intro,
|
||||
accessPreset: settings.accessPreset || GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
|
||||
});
|
||||
currentRegionCode.value = settings.regionCode;
|
||||
currentRegionDisplay.value = settings.regionFullName || settings.regionName;
|
||||
coverOssId.value = settings.coverOssId;
|
||||
pageState.value = "form";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
pageState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const uploadCover = async () => {
|
||||
if (isUploading.value || isSubmitting.value) return;
|
||||
isUploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: controller });
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = toConsumerOssId(receipt.ossId);
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) isUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitUpdate = async () => {
|
||||
if (isSubmitting.value || isUploading.value || !validate()) return;
|
||||
const access = toApiGenealogyAccess(form.accessPreset);
|
||||
if (!access) {
|
||||
submitError.value = "访问规则无效,请重新选择";
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.updateGenealogy(genealogyId.value, {
|
||||
surname: form.surname,
|
||||
genealogyName: form.genealogyName,
|
||||
regionCode: selectedRegion.value?.regionCode || currentRegionCode.value,
|
||||
ancestralHall: form.ancestralHall,
|
||||
originPlace: form.originPlace,
|
||||
addressDetail: form.addressDetail,
|
||||
intro: form.intro,
|
||||
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
|
||||
...access,
|
||||
}, { requestController: controller });
|
||||
if (!pageActive) return;
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
submitError.value = error?.message || "家谱设置保存失败,请稍后重试";
|
||||
} finally {
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const returnToOverview = () => hasValidContext.value ? returnTo("G05", { genealogyId: genealogyId.value }) : goBack();
|
||||
const handleStateAction = () => pageState.value === "success" ? returnToOverview() : loadSettings();
|
||||
|
||||
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); });
|
||||
onMounted(() => { void loadSettings(); });
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
controller.abort();
|
||||
});
|
||||
</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}.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}
|
||||
|
||||
.settings-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.page-header, .page-content { z-index: 1; }
|
||||
.page-content { flex: 1; padding: 24rpx 32rpx 72rpx; }
|
||||
.settings-card, .state-card { @include adaptive.adaptive-genealogy-state-panel; box-sizing: border-box; }
|
||||
.settings-card { padding: 42rpx 38rpx; }
|
||||
.settings-card__title, .settings-card__note, .field-error, .submit-error { display: block; }
|
||||
.settings-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 40rpx; font-weight: 700; }
|
||||
.settings-card__note { margin-top: 12rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.55; }
|
||||
.field-row { display: flex; min-height: 100rpx; align-items: center; margin-top: 22rpx; padding: 0 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
|
||||
.field-row__label { width: 142rpx; flex: 0 0 auto; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
|
||||
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
|
||||
.field-row input { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; }
|
||||
.field-row--selector { justify-content: space-between; cursor: pointer; }
|
||||
.region-selector-value { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; text-align: right; overflow-wrap: anywhere; }
|
||||
.region-selector-value--placeholder, .placeholder { color: #ab9a86; }
|
||||
.intro-field, .cover-field { margin-top: 22rpx; padding: 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
|
||||
.intro-field__label, .cover-field__label { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
|
||||
.intro-field textarea { display: block; width: 100%; min-height: 120rpx; margin-top: 14rpx; color: $ink; font-size: 27rpx; line-height: 1.55; }
|
||||
.cover-field { display: grid; gap: 12rpx; }
|
||||
.cover-field__hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; }
|
||||
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
|
||||
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
||||
.field-error, .submit-error { margin-top: 10rpx; color: $brand-red; font-size: 24rpx; line-height: 1.5; }
|
||||
.access-rule { margin-top: 28rpx; }
|
||||
.access-rule__label { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
|
||||
.access-rule__options { display: flex; margin-top: 16rpx; gap: 16rpx; }
|
||||
.access-rule__option { flex: 1; padding: 20rpx 12rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; color: $ink-muted; font-size: 25rpx; text-align: center; }
|
||||
.access-rule__option--active { border-color: $brand-red; background: rgba(159, 23, 15, .07); color: $brand-red; font-weight: 700; }
|
||||
.settings-card .app-button { margin-top: 32rpx; }
|
||||
.state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.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; }
|
||||
.region-sheet { position: fixed; z-index: 10; inset: 0; display: flex; align-items: flex-end; }
|
||||
.region-sheet__mask { position: absolute; inset: 0; background: rgba(43, 30, 20, .42); }
|
||||
.region-sheet__panel { width: 100%; padding: 22rpx 28rpx calc(24rpx + env(safe-area-inset-bottom)); border-radius: 30rpx 30rpx 0 0; background: #fdf9ef; box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, .2); }
|
||||
.region-sheet__intro { padding: 0 10rpx 16rpx; text-align: center; }
|
||||
.region-sheet__title { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
|
||||
.region-sheet__picker { border: 1rpx solid rgba(128, 89, 49, .28); border-radius: 16rpx; background: rgba(255, 252, 245, .8); }
|
||||
.region-sheet__column-headings { display: flex; height: 84rpx; border-bottom: 1rpx solid rgba(128, 89, 49, .18); }
|
||||
.region-sheet__column-heading { box-sizing: border-box; width: 33.333%; padding: 24rpx 12rpx; color: $ink-muted; font-size: 26rpx; text-align: center; }
|
||||
.region-sheet__column-heading + .region-sheet__column-heading { border-left: 1rpx solid rgba(128, 89, 49, .16); }
|
||||
.region-sheet__picker-view { width: 100%; height: 520rpx; }
|
||||
.region-sheet__picker-item { box-sizing: border-box; height: 104rpx; padding: 0 6rpx; color: $ink-muted; font-size: 26rpx; line-height: 104rpx; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.region-sheet__picker-item--selected { color: $brand-red; font-weight: 700; }
|
||||
.region-sheet__footer { display: flex; align-items: center; margin-top: 22rpx; gap: 12rpx; }
|
||||
.region-sheet__cancel { min-width: 116rpx; padding: 20rpx 10rpx; color: $ink-muted; font-size: 28rpx; text-align: center; }
|
||||
.region-sheet__confirm { flex: 1; height: 82rpx; margin: 0; padding: 0; border: 0; border-radius: 10rpx; background: $brand-red; color: #fff; font-size: 29rpx; font-weight: 700; line-height: 82rpx; }
|
||||
.region-sheet__confirm::after { display: none; }
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
<!-- 页面编号:N-01;用途:通知入口。列表 item DTO 缺失时不展示 fixture 通知。 -->
|
||||
<!-- 页面编号:N-01;用途:读取当前账号的消息通知。 -->
|
||||
<template>
|
||||
<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>
|
||||
<view class="message-page">
|
||||
<ModulePageBackground module="notification" />
|
||||
<view class="page-header"><PageHeader root title="消息中心" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取消息" /></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取消息</text><AppButton block type="secondary" label="重新加载" @click="loadNotifications" /></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"><text>暂无消息</text><text>消息列表会直接从服务端读取。</text><AppButton block label="返回我的" @click="returnToProfile" /></view>
|
||||
<view v-else class="state-card"><text>已读取 {{ notifications.length }} 条消息</text><text>消息详情接口尚未开放,当前不会用本地数据补造正文或已读状态。</text><AppButton block label="返回我的" @click="returnToProfile" /></view>
|
||||
</view>
|
||||
<AppTabbar active="profile" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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");
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { goRoot } from "@/utils/navigation.js";
|
||||
|
||||
const notifications = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const loadNotifications = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
notifications.value = await appApi.getNotifications({ requestController: controller });
|
||||
if (!active) return;
|
||||
state.value = notifications.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToProfile = () => goRoot("M01");
|
||||
onLoad(loadNotifications);
|
||||
onShow(() => { if (state.value !== "loading") loadNotifications(); });
|
||||
onUnload(() => { active = false; controller.abort(); });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.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}
|
||||
|
||||
@@ -1,11 +1,115 @@
|
||||
<!-- 页面编号:M-01;用途:个人中心。资料 DTO 未声明时不展示 mock 用户。 -->
|
||||
<!-- 页面编号:M-01;用途:个人中心,显示已核实的当前用户资料。 -->
|
||||
<template>
|
||||
<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>
|
||||
<view class="profile-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-header"><PageHeader root title="我的" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="loading" class="state-card"><text>正在读取个人资料…</text></view>
|
||||
<view v-else-if="loadError" class="state-card">
|
||||
<text>个人资料暂时无法读取</text>
|
||||
<text>{{ loadError }}</text>
|
||||
<AppButton block label="重新读取" @click="loadProfile" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view class="profile-summary">
|
||||
<view class="profile-summary__avatar"><text>{{ profile.avatar ? "已设置" : "未设置" }}</text></view>
|
||||
<view class="profile-summary__identity">
|
||||
<text>{{ profile.nickName || "未设置昵称" }}</text>
|
||||
<text>{{ profile.realName || "未设置真实姓名" }}</text>
|
||||
<text>{{ profile.phone || "未提供手机号" }}</text>
|
||||
</view>
|
||||
<AppButton class="profile-summary__edit" label="编辑资料" @click="openEdit" />
|
||||
</view>
|
||||
|
||||
<view class="profile-fields">
|
||||
<view class="profile-field"><text>性别</text><text>{{ profile.sex || "未设置" }}</text></view>
|
||||
<view class="profile-field"><text>生日</text><text>{{ birthdayText }}</text></view>
|
||||
<view class="profile-field"><text>邮箱</text><text>{{ profile.email || "未设置" }}</text></view>
|
||||
</view>
|
||||
|
||||
<view class="profile-menu">
|
||||
<AppButton block label="账号与安全" @click="open('M03')" />
|
||||
<AppButton block label="消息中心" @click="open('N01')" />
|
||||
<AppButton block label="帮助中心" @click="open('M06')" />
|
||||
<AppButton block label="意见反馈" @click="open('M07')" />
|
||||
<AppButton block label="应用推广" @click="open('M08')" />
|
||||
<AppButton block label="VIP 服务" @click="open('M09')" />
|
||||
<AppButton block label="关于与设置" @click="open('M10')" />
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
<AppTabbar active="profile" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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");
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { openPage } from "@/utils/navigation.js";
|
||||
|
||||
const profile = reactive({ avatar: null, nickName: "", realName: "", phone: "", sex: "", birthday: "", email: "" });
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const requestController = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const birthdayText = computed(() => /^\d{4}-\d{2}-\d{2}/.test(profile.birthday) ? profile.birthday.slice(0, 10) : "未设置");
|
||||
|
||||
const loadProfile = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
const data = await appApi.getProfile({ requestController });
|
||||
if (!pageActive) return;
|
||||
Object.assign(profile, data);
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
loadError.value = error?.message || "请稍后重试";
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = (route) => openPage(route, {}, "M01");
|
||||
const openEdit = () => open("M02");
|
||||
|
||||
onShow(loadProfile);
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
requestController.abort();
|
||||
});
|
||||
</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}.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}
|
||||
.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, .profile-summary, .profile-fields { @include adaptive.adaptive-family-content; }
|
||||
.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.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; }
|
||||
.profile-summary { display: grid; grid-template-columns: 96rpx minmax(0, 1fr); align-items: center; gap: 18rpx; padding: 30rpx; }
|
||||
.profile-summary__avatar { display: flex; width: 96rpx; height: 96rpx; align-items: center; justify-content: center; border: 1rpx solid rgba(159, 23, 15, .32); border-radius: 50%; background: rgba(159, 23, 15, .06); color: $brand-red; font-size: 19rpx; text-align: center; }
|
||||
.profile-summary__identity { min-width: 0; }
|
||||
.profile-summary__identity text { display: block; overflow-wrap: anywhere; }
|
||||
.profile-summary__identity text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
|
||||
.profile-summary__identity text:nth-child(2), .profile-summary__identity text:last-child { margin-top: 5rpx; color: $ink-muted; font-size: 21rpx; }
|
||||
.profile-summary__edit { grid-column: 1 / -1; margin-top: 6rpx; }
|
||||
.profile-fields { display: flex; flex-direction: column; margin-top: 18rpx; padding: 8rpx 30rpx; }
|
||||
.profile-field { display: flex; min-height: 82rpx; align-items: center; justify-content: space-between; gap: 20rpx; border-bottom: 1rpx solid rgba(181, 137, 63, .3); }
|
||||
.profile-field:last-child { border-bottom: 0; }
|
||||
.profile-field text:first-child { flex: 0 0 auto; color: $ink; font-size: 24rpx; font-weight: 700; }
|
||||
.profile-field text:last-child { min-width: 0; color: $ink-muted; font-size: 22rpx; overflow-wrap: anywhere; text-align: right; }
|
||||
.profile-menu { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14rpx; margin-top: 24rpx; }
|
||||
.profile-menu .app-button { margin: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,199 @@
|
||||
<!-- 页面编号:M-02;用途:编辑资料。读取 DTO 与现有字段不一致时保持关闭。 -->
|
||||
<!-- 页面编号:M-02;用途:编辑已由 Apifox 声明的个人资料字段。 -->
|
||||
<template>
|
||||
<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>
|
||||
<view class="profile-edit-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-header"><PageHeader title="编辑资料" custom-back @back="backToProfile" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="loading" class="state-card"><text>正在读取个人资料…</text></view>
|
||||
<view v-else-if="loadError" class="state-card">
|
||||
<text>个人资料暂时无法读取</text>
|
||||
<text>{{ loadError }}</text>
|
||||
<AppButton block label="重新读取" @click="loadProfile" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view class="form-panel">
|
||||
<view class="avatar-row">
|
||||
<view><text>头像</text><text>{{ avatarMessage }}</text></view>
|
||||
<button class="upload-button" :disabled="uploading || saving" @click="uploadAvatar">{{ uploading ? "上传中…" : "选择图片" }}</button>
|
||||
</view>
|
||||
<text v-if="avatarError" class="field-error">{{ avatarError }}</text>
|
||||
|
||||
<view class="form-row"><text>昵称</text><input v-model.trim="form.nickName" maxlength="30" placeholder="请输入昵称" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
|
||||
<view class="form-row"><text>真实姓名</text><input v-model.trim="form.realName" maxlength="30" placeholder="请输入真实姓名" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
|
||||
<view class="form-row"><text>性别</text><input v-model.trim="form.sex" placeholder="请输入性别" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
|
||||
<view class="form-row">
|
||||
<text>生日</text>
|
||||
<picker mode="date" :value="form.birthday" :disabled="saving || uploading" @change="changeBirthday"><view class="picker-value" :class="{ 'picker-value--placeholder': !form.birthday }">{{ form.birthday || "请选择生日" }}</view></picker>
|
||||
</view>
|
||||
<view class="form-row"><text>邮箱</text><input v-model.trim="form.email" maxlength="100" placeholder="请输入邮箱" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
|
||||
</view>
|
||||
<text class="form-note">资料字段均可按需填写;留空不会提交为修改。</text>
|
||||
<text v-if="saveError" class="save-error">{{ saveError }}</text>
|
||||
<AppButton block :disabled="saving || uploading" :label="saving ? '保存中…' : '保存资料'" @click="saveProfile" />
|
||||
</template>
|
||||
</view>
|
||||
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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");
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||
import { finishPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const form = reactive({ nickName: "", realName: "", sex: "", birthday: "", email: "" });
|
||||
const original = reactive({ nickName: "", realName: "", sex: "", birthday: "", email: "", avatar: null });
|
||||
const avatarId = ref(null);
|
||||
const avatarFileName = ref("");
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const uploading = ref(false);
|
||||
const avatarError = ref("");
|
||||
const saving = ref(false);
|
||||
const saveError = ref("");
|
||||
const feedbackVisible = ref(false);
|
||||
const feedbackMessage = ref("");
|
||||
const requestController = createRequestController();
|
||||
let feedbackTimer = null;
|
||||
let pageActive = true;
|
||||
|
||||
const avatarMessage = computed(() => {
|
||||
if (avatarFileName.value) return `已选择 ${avatarFileName.value}`;
|
||||
return avatarId.value ? "当前头像已设置" : "尚未设置头像";
|
||||
});
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message;
|
||||
feedbackVisible.value = true;
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
feedbackTimer = setTimeout(() => {
|
||||
feedbackVisible.value = false;
|
||||
feedbackTimer = null;
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const toBirthdayDate = (value) => /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : "";
|
||||
|
||||
const loadProfile = async () => {
|
||||
if (loading.value === false && (uploading.value || saving.value)) return;
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
const profile = await appApi.getProfile({ requestController });
|
||||
if (!pageActive) return;
|
||||
Object.assign(form, {
|
||||
nickName: profile.nickName,
|
||||
realName: profile.realName,
|
||||
sex: profile.sex,
|
||||
birthday: toBirthdayDate(profile.birthday),
|
||||
email: profile.email,
|
||||
});
|
||||
Object.assign(original, { ...form, avatar: profile.avatar });
|
||||
avatarId.value = profile.avatar;
|
||||
avatarFileName.value = "";
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) loadError.value = error?.message || "请稍后重试";
|
||||
} finally {
|
||||
if (pageActive) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const changeBirthday = (event) => {
|
||||
form.birthday = event?.detail?.value || "";
|
||||
};
|
||||
|
||||
const uploadAvatar = async () => {
|
||||
if (uploading.value || saving.value) return;
|
||||
uploading.value = true;
|
||||
avatarError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController });
|
||||
if (!pageActive) return;
|
||||
avatarId.value = toConsumerOssId(receipt.ossId);
|
||||
avatarFileName.value = receipt.fileName || "新头像";
|
||||
} catch (error) {
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
avatarError.value = error?.message || "头像上传失败,请稍后重试";
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildUpdatePayload = () => {
|
||||
const data = {};
|
||||
for (const field of ["nickName", "realName", "sex", "email"]) {
|
||||
if (form[field] && form[field] !== original[field]) data[field] = form[field];
|
||||
}
|
||||
if (form.birthday && form.birthday !== original.birthday) data.birthday = `${form.birthday}T00:00:00`;
|
||||
if (avatarId.value && avatarId.value !== original.avatar) data.avatar = avatarId.value;
|
||||
return data;
|
||||
};
|
||||
|
||||
const saveProfile = async () => {
|
||||
if (saving.value || uploading.value) return;
|
||||
const payload = buildUpdatePayload();
|
||||
if (Object.keys(payload).length === 0) {
|
||||
showFeedback("没有需要保存的修改");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
saveError.value = "";
|
||||
try {
|
||||
await appApi.updateProfile(payload, { requestController });
|
||||
if (!pageActive) return;
|
||||
await finishPage("M01", {}, { operation: "profile-updated", refresh: true });
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) saveError.value = error?.message || "保存失败,请稍后重试";
|
||||
} finally {
|
||||
if (pageActive) saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const backToProfile = () => {
|
||||
if (saving.value || uploading.value) return;
|
||||
return returnTo("M01");
|
||||
};
|
||||
|
||||
onLoad(loadProfile);
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
requestController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
});
|
||||
</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-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}
|
||||
.profile-edit-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.page-header, .page-content { z-index: 1; }
|
||||
.page-content { flex: 1; padding: 24rpx 30rpx 72rpx; }
|
||||
.state-card, .form-panel { @include adaptive.adaptive-profile-content; }
|
||||
.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.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; }
|
||||
.form-panel { padding: 16rpx 30rpx 22rpx; }
|
||||
.avatar-row, .form-row { display: grid; grid-template-columns: 142rpx minmax(0, 1fr); min-height: 92rpx; align-items: center; gap: 16rpx; border-bottom: 1rpx solid rgba(181, 137, 63, .42); }
|
||||
.avatar-row > view text { display: block; }
|
||||
.avatar-row > view text:first-child, .form-row > text { color: $ink; font-size: 23rpx; font-weight: 700; }
|
||||
.avatar-row > view text:last-child { margin-top: 4rpx; color: $ink-muted; font-size: 19rpx; overflow-wrap: anywhere; }
|
||||
.upload-button { display: flex; min-height: 64rpx; align-items: center; justify-content: center; border: 0; background: transparent; color: $brand-red; font-size: 22rpx; text-align: right; }
|
||||
.form-row input, .picker-value { width: auto; min-width: 0; min-height: 68rpx; color: $ink; font-size: 23rpx; text-align: right; }
|
||||
.picker-value { display: flex; align-items: center; justify-content: flex-end; }
|
||||
.picker-value--placeholder, .placeholder { color: #ab9a86; }
|
||||
.field-error, .save-error, .form-note { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
|
||||
.field-error, .save-error { color: #b42318; }
|
||||
.field-error { margin-top: 8rpx; text-align: right; }
|
||||
.form-note { margin: 16rpx 4rpx 0; }
|
||||
.save-error { margin: 12rpx 4rpx 0; }
|
||||
.page-content > .app-button { margin-top: 24rpx; }
|
||||
@media (max-width: 340px) { .page-content { padding-right: 22rpx; padding-left: 22rpx; } .form-panel { padding-right: 24rpx; padding-left: 24rpx; } .avatar-row, .form-row { grid-template-columns: 116rpx minmax(0, 1fr); gap: 12rpx; } }
|
||||
</style>
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"><PageHeader title="帮助中心" /></view>
|
||||
<view class="page-content page-layer">
|
||||
<text class="help-source-note">当前展示的是本地使用说明,不代表已读取服务端帮助文章;需要最新支持请提交意见反馈。</text>
|
||||
<text class="help-source-note">{{ sourceNote }}</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>
|
||||
<scroll-view scroll-x class="category-scroll" :show-scrollbar="false"><view class="category-row"><view v-for="category in helpCategories" :key="category.value" class="category-chip" :class="{ active: activeCategory === category.value }" role="button" @click="activeCategory = category.value">{{ category.label }}</view></view></scroll-view>
|
||||
<view v-if="filteredQuestions.length" class="question-list">
|
||||
<view v-for="item in filteredQuestions" :key="item.id" class="question-card">
|
||||
<view class="question-heading" role="button" :aria-label="item.question" @click="toggleQuestion(item.id)"><text>{{ item.question }}</text><text>{{ expandedIds.includes(item.id) ? "收起" : "查看" }}</text></view>
|
||||
@@ -21,28 +21,53 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { openPage } from "@/utils/navigation.js";
|
||||
|
||||
const helpCategories = ["全部", "家谱", "成员", "隐私", "账号"];
|
||||
const activeCategory = ref("全部");
|
||||
const questions = ref([]);
|
||||
const helpState = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const categoryLabels = Object.freeze({ common: "常见问题", member: "成员管理", lineage: "世系关系" });
|
||||
const helpCategories = computed(() => [
|
||||
{ value: "", label: "全部" },
|
||||
...[...new Set(questions.value.map((item) => item.category).filter(Boolean))].map((value) => ({ value, label: categoryLabels[value.toLowerCase()] || value })),
|
||||
]);
|
||||
const activeCategory = ref("");
|
||||
const keyword = ref("");
|
||||
const expandedIds = ref([]);
|
||||
const questions = [
|
||||
{ id: 1, category: "家谱", question: "如何创建一部新家谱?", answer: "进入“我的家谱”,选择新建家谱,按步骤填写姓氏、堂号和地区等基础资料。" },
|
||||
{ id: 2, category: "成员", question: "如何邀请家人共同完善家谱?", answer: "家人可搜索家谱后提交加入申请,由管理员核实关系;邀请码尚未接入,未来校验成功后将直接加入,不产生审核记录。" },
|
||||
{ id: 3, category: "隐私", question: "哪些个人资料会展示给其他成员?", answer: "资料按家谱角色与权限展示;敏感联系方式默认脱敏,后续可在家谱设置中管理权限。" },
|
||||
{ id: 4, category: "账号", question: "忘记密码后怎样恢复账号?", answer: "在登录页选择“忘记密码”,通过绑定手机号验证后设置新密码。" },
|
||||
{ id: 5, category: "家谱", question: "家谱资料填写错了怎么办?", answer: "有编辑权限的成员可以进入对应资料页修改;关键世系关系建议核对后再保存。" },
|
||||
];
|
||||
const sourceNote = computed(() => ({ loading: "正在读取帮助文章…", error: "帮助文章暂时无法读取,请稍后重试。", empty: "暂未收到可展示的帮助文章。", ready: "帮助内容来自服务端,可按分类或关键词查找。" })[helpState.value]);
|
||||
const filteredQuestions = computed(() => {
|
||||
const query = keyword.value.toLowerCase();
|
||||
return questions.filter((item) => (activeCategory.value === "全部" || item.category === activeCategory.value) && (!query || `${item.question}${item.answer}`.toLowerCase().includes(query)));
|
||||
return questions.value.filter((item) => (!activeCategory.value || item.category === activeCategory.value) && (!query || `${item.question}${item.answer}`.toLowerCase().includes(query)));
|
||||
});
|
||||
const toggleQuestion = (id) => { expandedIds.value = expandedIds.value.includes(id) ? expandedIds.value.filter((item) => item !== id) : [...expandedIds.value, id]; };
|
||||
const contactSupport = () => openPage("M07", {}, "M06");
|
||||
const loadHelpArticles = async () => {
|
||||
controller.abort();
|
||||
helpState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getHelpArticles({ requestController: controller });
|
||||
if (!active) return;
|
||||
questions.value = rows.map((item) => ({
|
||||
id: String(item.helpId),
|
||||
category: String(item.helpCategory || "其他"),
|
||||
question: String(item.helpTitle || "未命名帮助文章"),
|
||||
answer: String(item.helpContent || "暂无正文")
|
||||
}));
|
||||
helpState.value = questions.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
helpState.value = "error";
|
||||
}
|
||||
};
|
||||
onLoad(loadHelpArticles);
|
||||
onShow(() => { if (helpState.value !== "loading") loadHelpArticles(); });
|
||||
onUnload(() => { active = false; controller.abort(); });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,46 +1,71 @@
|
||||
<!-- 页面编号:M-08;用途:邀请家人共建家谱。 -->
|
||||
<template>
|
||||
<view class="promotion-page share-state--unavailable">
|
||||
<view class="promotion-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"><PageHeader title="邀请家人" custom-back @back="requestBack" /></view>
|
||||
<view class="page-layer"><PageHeader title="推广中心" custom-back @back="requestBack" /></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="invite-hero">
|
||||
<image src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
|
||||
<text>邀请亲友共建家族记忆</text>
|
||||
<text>邀请码校验成功后直接加入,不会生成入谱审核记录。</text>
|
||||
<text>家谱服务推荐</text>
|
||||
<text>以下内容由服务端配置;不生成邀请码,也不会伪造推广信息。</text>
|
||||
</view>
|
||||
<view v-if="inviteState === 'unavailable'" class="unavailable-card">
|
||||
<text>当前没有可用邀请码</text>
|
||||
<text>当前尚未接入邀请码校验与签发接口,页面不会生成、复制或展示虚假邀请码。</text>
|
||||
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取推广内容" /></view>
|
||||
<view v-else-if="state === 'error'" class="state-card">
|
||||
<text>暂时无法读取推广内容</text><AppButton block type="secondary" label="重新加载" @click="loadPromotions" />
|
||||
</view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"><text>当前没有可展示的推广内容</text></view>
|
||||
<view v-else class="promotion-list">
|
||||
<view v-for="item in promotions" :key="item.id" class="promotion-card">
|
||||
<text class="promotion-title">{{ item.title }}</text>
|
||||
<text v-if="item.platform" class="promotion-platform">{{ item.platform }}</text>
|
||||
<text v-if="item.description" class="promotion-copy">{{ item.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="steps-card"><text>开放条件</text><text>1. 后端提供邀请码签发接口</text><text>2. 明确有效期、使用次数和失效规则</text><text>3. 校验家谱权限与直接加入结果</text></view>
|
||||
<AppButton block type="secondary" label="查看邀请说明" @click="openInviteExplanation" />
|
||||
</view>
|
||||
<AppDialog :visible="explanationVisible" :close-on-mask="false" eyebrow="邀请说明" title="功能尚未接入" message="当前后端接口文档没有邀请码签发与校验合同。完成接口接入前,本页保持不可用,避免家人拿到无法验证的邀请码。" confirm-text="我知道了" @confirm="explanationVisible = false" @close="explanationVisible = false" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress } from "@dcloudio/uni-app";
|
||||
import { onBackPress, 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";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const inviteState = ref("unavailable");
|
||||
const explanationVisible = ref(false);
|
||||
const openInviteExplanation = () => { explanationVisible.value = true; };
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: explanationVisible.value,
|
||||
"close-transient": () => { explanationVisible.value = false; },
|
||||
});
|
||||
const promotions = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const platformLabels = Object.freeze({ app: "APP 应用", pc: "管理后台", wechat: "微信小程序" });
|
||||
const formatPlatform = (value) => platformLabels[String(value || "").trim().toLowerCase()] || String(value || "").trim();
|
||||
const loadPromotions = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getPromotions({ requestController: controller });
|
||||
if (!active) return;
|
||||
promotions.value = rows.map((item) => ({
|
||||
id: String(item.promotionId),
|
||||
title: String(item.promotionTitle || "未命名推广"),
|
||||
description: String(item.promotionDesc || ""),
|
||||
platform: formatPlatform(item.platform)
|
||||
}));
|
||||
state.value = promotions.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const requestBack = () => runBackGuard({});
|
||||
onLoad(loadPromotions);
|
||||
onShow(() => { if (state.value !== "loading") loadPromotions(); });
|
||||
onUnload(() => { active = false; controller.abort(); });
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.promotion-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.invite-hero,.unavailable-card,.steps-card{@include adaptive.adaptive-profile-content}.invite-hero{display:flex;min-height:300rpx;flex-direction:column;align-items:center;justify-content:center;padding:38rpx 48rpx;text-align:center}.invite-hero image{width:90rpx;height:auto;max-height:102rpx;aspect-ratio:90/102}.invite-hero text{display:block}.invite-hero text:nth-child(2){margin-top:12rpx;color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.invite-hero text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.unavailable-card{min-height:190rpx;margin-top:20rpx;padding:40rpx 44rpx;text-align:center}.unavailable-card text{display:block}.unavailable-card text:first-child{color:$brand-red;font-size:28rpx;font-weight:700}.unavailable-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.steps-card{display:flex;min-height:220rpx;flex-direction:column;gap:10rpx;margin-top:20rpx;padding:34rpx 44rpx;color:$ink-muted;font-size:22rpx}.steps-card text:first-child{margin-bottom:4rpx;color:$ink;font-size:27rpx;font-weight:700}.page-content>.app-button{margin-top:26rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
|
||||
.promotion-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.invite-hero,.state-card,.promotion-card{@include adaptive.adaptive-profile-content}.invite-hero{display:flex;min-height:250rpx;flex-direction:column;align-items:center;justify-content:center;padding:38rpx 48rpx;text-align:center}.invite-hero image{width:90rpx;height:102rpx}.invite-hero text{display:block}.invite-hero text:nth-child(2){margin-top:12rpx;color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.invite-hero text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.state-card{display:flex;min-height:210rpx;flex-direction:column;align-items:center;justify-content:center;margin-top:20rpx;padding:38rpx;text-align:center;color:$ink;font-size:26rpx}.state-card .app-button{width:100%;margin-top:22rpx}.promotion-list{display:flex;flex-direction:column;gap:16rpx;margin-top:20rpx}.promotion-card{position:relative;display:flex;min-height:142rpx;flex-direction:column;padding:30rpx 34rpx}.promotion-title{color:$ink;font-size:28rpx;font-weight:700;overflow-wrap:anywhere}.promotion-platform{align-self:flex-start;margin-top:10rpx;padding:3rpx 12rpx;border:1rpx solid rgba(181,46,34,.26);border-radius:999rpx;color:$brand-red;font-size:20rpx;line-height:1.3}.promotion-copy{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55;overflow-wrap:anywhere}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
|
||||
</style>
|
||||
|
||||
@@ -1,45 +1,73 @@
|
||||
<!-- 页面编号:M-09;用途:服务权益与订单记录。 -->
|
||||
<template>
|
||||
<view class="orders-page order-state--unavailable">
|
||||
<view class="orders-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"><PageHeader title="VIP 与订单" custom-back @back="requestBack" /></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="service-card"><text>家谱基础服务</text><text>当前未开通付费服务</text><text>基础家谱浏览、成员资料与家族记录可正常使用。</text></view>
|
||||
<view class="benefit-grid"><view v-for="benefit in serviceBenefits" :key="benefit.title" class="benefit-card"><text>{{ benefit.title }}</text><text>{{ benefit.copy }}</text></view></view>
|
||||
<view class="section-heading"><text>订单记录</text><text>暂不可用</text></view>
|
||||
<view v-if="orderState === 'unavailable'" class="empty-card"><text>订单接口尚未接入</text><text>当前页面不读取查询参数,也不会虚构订单;完成套餐、支付和订单状态合同核对后再开放。</text></view>
|
||||
<AppButton block type="secondary" label="查看服务说明" @click="openServiceNotice" />
|
||||
<view class="service-card"><text>服务套餐</text><text>套餐和订单均从服务端读取;本页不发起支付或创建订单。</text></view>
|
||||
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取服务套餐" /></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取套餐或订单</text><AppButton block type="secondary" label="重新加载" @click="loadVip" /></view>
|
||||
<template v-else>
|
||||
<view v-if="packages.length" class="package-list"><view v-for="item in packages" :key="item.id" class="package-card"><view class="package-heading"><text>{{ item.name }}</text><text v-if="item.price">¥{{ item.price }}</text></view><text v-if="item.description">{{ item.description }}</text><text v-if="item.duration">{{ item.duration }}</text></view></view>
|
||||
<view v-else class="state-card"><text>当前没有可展示的服务套餐</text></view>
|
||||
<view class="section-heading"><text>订单记录</text><text>{{ orders.length }} 条</text></view>
|
||||
<view v-if="orders.length" class="order-list"><view v-for="item in orders" :key="item.id" class="order-card"><text>{{ item.title }}</text><text>{{ item.status }}</text></view></view>
|
||||
<view v-else class="order-empty"><text>暂无订单记录</text></view>
|
||||
</template>
|
||||
</view>
|
||||
<AppDialog :visible="serviceNoticeVisible" :close-on-mask="false" eyebrow="服务说明" title="付费服务尚未开放" message="当前版本不会产生扣费或订单。未来开放前会明确展示价格、权益、续费与退款规则。" confirm-text="我知道了" @confirm="serviceNoticeVisible = false" @close="serviceNoticeVisible = false" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress } from "@dcloudio/uni-app";
|
||||
import { onBackPress, 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";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const serviceBenefits = [
|
||||
{ title: "更大存储", copy: "为家族影像和资料提供更多空间" },
|
||||
{ title: "资料导出", copy: "按规则整理并导出家谱资料" },
|
||||
{ title: "专属服务", copy: "获得家谱整理与使用支持" },
|
||||
];
|
||||
const orderState = ref("unavailable");
|
||||
const serviceNoticeVisible = ref(false);
|
||||
const openServiceNotice = () => { serviceNoticeVisible.value = true; };
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: serviceNoticeVisible.value,
|
||||
"close-transient": () => { serviceNoticeVisible.value = false; },
|
||||
});
|
||||
const packages = ref([]);
|
||||
const orders = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const formatPrice = (value) => Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "";
|
||||
const durationUnits = Object.freeze({ year: "年", month: "个月", day: "天" });
|
||||
const formatDuration = (value, unit) => {
|
||||
if (value === undefined || value === null || value === "") return "";
|
||||
if (Number.isFinite(Number(value)) && Number(value) <= 0) return "永久有效";
|
||||
const normalizedUnit = String(unit || "").trim().toLowerCase();
|
||||
return durationUnits[normalizedUnit] ? `${value} ${durationUnits[normalizedUnit]}` : String(value);
|
||||
};
|
||||
const loadVip = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const packageRows = await appApi.getVipPackages({ requestController: controller });
|
||||
const orderRows = await appApi.getVipOrders({ requestController: controller });
|
||||
if (!active) return;
|
||||
packages.value = packageRows.map((item) => ({
|
||||
id: String(item.packageId), name: String(item.packageName || "未命名套餐"), description: String(item.packageDesc || ""),
|
||||
price: formatPrice(item.price), duration: formatDuration(item.durationValue, item.durationUnit)
|
||||
}));
|
||||
orders.value = orderRows.map((item) => ({
|
||||
id: String(item.orderId || item.id || ""), title: String(item.packageName || item.orderNo || "VIP 订单"), status: String(item.orderStatus || item.status || "" )
|
||||
})).filter((item) => item.id);
|
||||
state.value = "ready";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const requestBack = () => runBackGuard({});
|
||||
onLoad(loadVip);
|
||||
onShow(() => { if (state.value !== "loading") loadVip(); });
|
||||
onUnload(() => { active = false; controller.abort(); });
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.orders-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.service-card,.benefit-card,.empty-card{@include adaptive.adaptive-profile-content}.service-card{min-height:220rpx;padding:42rpx 48rpx;text-align:center}.service-card text{display:block}.service-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.service-card text:nth-child(2){margin-top:10rpx;color:$brand-red;font-size:23rpx;font-weight:700}.service-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.benefit-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12rpx;margin-top:18rpx}.benefit-card{min-height:150rpx;padding:28rpx 18rpx;text-align:center}.benefit-card text{display:block;overflow-wrap:anywhere}.benefit-card text:first-child{color:$ink;font-size:23rpx;font-weight:700}.benefit-card text:last-child{margin-top:8rpx;color:$ink-muted;font-size:19rpx;line-height:1.45}.section-heading{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8rpx 20rpx;margin:28rpx 6rpx 14rpx}.section-heading text:first-child{color:$ink;font-size:27rpx;font-weight:700}.section-heading text:last-child{color:$ink-muted;font-size:21rpx}.empty-card{min-height:190rpx;padding:48rpx 42rpx;text-align:center}.empty-card text{display:block}.empty-card text:first-child{color:$ink;font-size:28rpx;font-weight:700}.empty-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.page-content>.app-button{margin-top:26rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.benefit-grid{grid-template-columns:1fr}.benefit-card{min-height:100rpx}}
|
||||
.orders-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.service-card,.state-card,.package-card,.order-card,.order-empty{@include adaptive.adaptive-profile-content}.service-card{min-height:170rpx;padding:38rpx 44rpx;text-align:center}.service-card text{display:block}.service-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.service-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.state-card{display:flex;min-height:190rpx;flex-direction:column;align-items:center;justify-content:center;margin-top:18rpx;padding:36rpx;text-align:center;color:$ink;font-size:26rpx}.state-card .app-button{width:100%;margin-top:22rpx}.package-list,.order-list{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}.package-card{padding:28rpx 32rpx}.package-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx;color:$ink;font-size:27rpx;font-weight:700}.package-heading text:last-child{flex:0 0 auto;color:$brand-red}.package-card>text{display:block;margin-top:10rpx;color:$ink-muted;font-size:21rpx;line-height:1.5}.section-heading{display:flex;justify-content:space-between;margin:28rpx 6rpx 12rpx;color:$ink;font-size:27rpx;font-weight:700}.section-heading text:last-child{color:$ink-muted;font-size:21rpx;font-weight:400}.order-card{display:flex;justify-content:space-between;gap:18rpx;padding:26rpx 32rpx;color:$ink;font-size:24rpx}.order-card text:last-child{color:$ink-muted}.order-empty{padding:38rpx;text-align:center;color:$ink-muted;font-size:23rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<!-- 页面编号:R-03;用途:亲友往来入口。列表 DTO 缺失时不展示 fixture 记录。 -->
|
||||
<!-- 页面编号:R-03;用途:读取并展示当前家谱的亲友往来记录。 -->
|
||||
<template>
|
||||
<view class="gift-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"><PageHeader title="贺礼簿" :action="hasValidContext ? '新建' : ''" @action="createRelative" /></view>
|
||||
<view class="page-header"><PageHeader title="贺礼簿" :action="valid ? '新建' : ''" custom-back @back="returnToFamily" @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 v-if="!valid" class="state-card"><text>贺礼簿入口无效</text><AppButton block label="返回上一页" @click="returnToFamily" /></view>
|
||||
<view v-else-if="state === 'loading'" class="state-card"><AppLoading text="正在读取亲友往来" /></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取亲友往来</text><AppButton block type="secondary" label="重新加载" @click="loadRecords" /></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"><text>还没有亲友往来记录</text><AppButton block label="新建往来记录" @click="createRelative" /></view>
|
||||
<view v-else class="record-list">
|
||||
<view v-for="item in records" :key="item.id" class="record-card">
|
||||
<view><text>{{ item.name }}</text><text>{{ item.relation }}{{ item.event ? ` · ${item.event}` : '' }}</text><text v-if="item.time || item.content">{{ item.time || item.content }}</text></view>
|
||||
<text v-if="item.amount">¥{{ item.amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -15,36 +20,54 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShow, 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 { goBack, openPage } from "@/utils/navigation.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = ref(false);
|
||||
const stateCopy = computed(() => hasValidContext.value
|
||||
? { title: "贺礼簿待后端字段合同", copy: "往来列表没有声明记录 ID、关系、事项、时间、金额或备注字段;页面已停止展示本地记录。", action: "新建往来记录" }
|
||||
: { title: "贺礼簿入口无效", copy: "没有取得有效家谱标识,页面不会展示其他家谱记录。", action: "返回上一页" },
|
||||
);
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
|
||||
});
|
||||
const createRelative = () => hasValidContext.value
|
||||
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
|
||||
: Promise.resolve(false);
|
||||
const handleStateAction = () => hasValidContext.value ? createRelative() : goBack();
|
||||
const records = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const loadRecords = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getRelativeRecords(genealogyId.value, { requestController: controller });
|
||||
if (!active) return;
|
||||
records.value = rows.map((item) => ({
|
||||
id: String(item.relativeId || ""),
|
||||
name: String(item.relativeName || "未命名亲友"),
|
||||
relation: String(item.relationName || ""),
|
||||
event: String(item.eventName || ""),
|
||||
time: String(item.eventTime || ""),
|
||||
amount: item.giftAmount == null || item.giftAmount === "" ? "" : String(item.giftAmount),
|
||||
content: String(item.recordContent || ""),
|
||||
})).filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
state.value = records.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToFamily = () => valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
|
||||
const createRelative = () => valid.value ? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03") : Promise.resolve(false);
|
||||
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (valid.value) loadRecords(); });
|
||||
onShow(() => { if (valid.value && state.value !== "loading") loadRecords(); });
|
||||
onUnload(() => { active = false; controller.abort(); });
|
||||
</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-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; }
|
||||
.page-header, .page-content { z-index: 1; }.page-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.state-card, .record-card { @include adaptive.adaptive-records-content; }
|
||||
.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; text-align: center; color: $ink; font-size: 28rpx; }.state-card .app-button { width: 100%; margin-top: 24rpx; }
|
||||
.record-list { display: flex; flex-direction: column; gap: 16rpx; }.record-card { display: flex; min-height: 138rpx; align-items: center; justify-content: space-between; gap: 18rpx; padding: 28rpx 32rpx; }.record-card > view { min-width: 0; flex: 1; }.record-card text { display: block; }.record-card > view text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; overflow-wrap: anywhere; }.record-card > view text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }.record-card > text { flex: 0 0 auto; color: $brand-red; font-size: 22rpx; }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 页面编号:R-04;用途:按 Apifox 已声明字段创建亲友往来记录。 -->
|
||||
<!-- 页面编号:R-04;用途:按 Apifox 的完整 AppRelativeRecordBody 创建亲友往来记录。 -->
|
||||
<template>
|
||||
<view class="gift-editor-page" :class="`relative-editor-state--${editorState}`">
|
||||
<ModulePageBackground module="records" />
|
||||
@@ -6,20 +6,24 @@
|
||||
<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>
|
||||
<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 = ''" />
|
||||
<text class="form-copy">除亲友姓名外,其他字段均可按需填写;留空时不提交该字段。</text>
|
||||
<view class="field-row"><text><text class="required-mark">*</text>亲友姓名</text><input v-model="form.relativeName" placeholder="请输入亲友姓名" @input="submitError = ''" /></view>
|
||||
<view class="field-row"><text>关系称谓</text><input v-model="form.relationName" placeholder="请输入关系称谓" @input="submitError = ''" /></view>
|
||||
<view class="field-row"><text>礼仪事项</text><input v-model="form.eventName" placeholder="请输入礼仪事项" @input="submitError = ''" /></view>
|
||||
<view class="field-row"><text>事项时间</text><input v-model="form.eventTime" placeholder="请输入事项时间" @input="submitError = ''" /></view>
|
||||
<view class="field-row"><text>礼金金额</text><input v-model="form.giftAmount" type="digit" placeholder="请输入礼金金额" @input="submitError = ''" /></view>
|
||||
<view class="field-row field-row--textarea"><text>往来备注</text><textarea v-model="form.recordContent" auto-height placeholder="记录往来内容" @input="submitError = ''" /></view>
|
||||
<view class="upload-field">
|
||||
<view><text>相关图片</text><text>图片选定后会先取得真实上传回执,并在提交记录时关联。</text></view>
|
||||
<button class="upload-button" :disabled="isUploading || isSubmitting" @click="uploadImage">{{ isUploading ? '上传中…' : '添加图片' }}</button>
|
||||
<text v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`">已上传:{{ receipt.fileName || '图片' }}</text>
|
||||
<text v-if="uploadError" class="save-error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<view class="field-row"><text>排序值</text><input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" @input="submitError = ''" /></view>
|
||||
<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>{{ resultCopy.title }}</text>
|
||||
<text>{{ resultCopy.copy }}</text>
|
||||
<AppButton block :label="resultCopy.action" @click="handleResultAction" />
|
||||
<AppButton block :disabled="isSubmitting || isUploading" :label="isSubmitting ? '正在提交' : '提交往来记录'" @click="saveRelative" />
|
||||
</view>
|
||||
<view v-else class="state-card"><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" />
|
||||
</view>
|
||||
@@ -34,61 +38,56 @@ 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 { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const editorState = ref("form");
|
||||
const isSubmitting = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
const submitError = ref("");
|
||||
const form = reactive({ relativeName: "", relationName: "", eventName: "", eventTime: "", giftAmount: "", recordContent: "" });
|
||||
const fields = [
|
||||
{ key: "relativeName", label: "亲友姓名" },
|
||||
{ key: "relationName", label: "关系称谓" },
|
||||
{ key: "eventName", label: "礼仪事项" },
|
||||
{ key: "eventTime", label: "事项日期" },
|
||||
{ key: "giftAmount", label: "礼金金额" },
|
||||
{ key: "recordContent", label: "往来备注" },
|
||||
];
|
||||
const uploadError = ref("");
|
||||
const form = reactive({ relativeName: "", relationName: "", eventName: "", eventTime: "", giftAmount: "", recordContent: "", sortOrder: "" });
|
||||
const mediaReceipts = ref([]);
|
||||
const requestController = createRequestController();
|
||||
const isDirty = computed(() => Object.values(form).some((value) => String(value).trim()));
|
||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||
const isDirty = computed(() => Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0);
|
||||
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: "返回上一页" },
|
||||
? { title: "往来记录已提交服务端", copy: "返回贺礼簿后会重新读取服务端记录。", action: "返回贺礼簿" }
|
||||
: { title: "往来记录入口无效", copy: "没有取得有效家谱标识,页面不会创建无归属记录。", action: "返回上一页" },
|
||||
);
|
||||
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 || "");
|
||||
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
|
||||
});
|
||||
const uploadImage = async () => {
|
||||
if (isUploading.value || isSubmitting.value) return;
|
||||
isUploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
mediaReceipts.value = [...mediaReceipts.value, await pickAndUploadImage({ requestController })];
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "图片上传失败,请稍后重试。";
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
}
|
||||
};
|
||||
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;
|
||||
}
|
||||
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
|
||||
if (!form.relativeName.trim()) { 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] = ""; });
|
||||
await appApi.createRelativeRecord(genealogyId.value, { ...form, mediaOssIds: mediaOssIds.value }, { requestController });
|
||||
Object.assign(form, { relativeName: "", relationName: "", eventName: "", eventTime: "", giftAmount: "", recordContent: "", sortOrder: "" });
|
||||
mediaReceipts.value = [];
|
||||
editorState.value = "success";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "往来记录提交失败,请稍后重试。";
|
||||
@@ -96,17 +95,8 @@ const saveRelative = async () => {
|
||||
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();
|
||||
const requestBack = () => runBackGuard({ transientOpen: discardVisible.value, dirty: isDirty.value, submitting: isSubmitting.value || isUploading.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>
|
||||
@@ -114,18 +104,13 @@ onUnmounted(() => { requestController.abort(); discardConfirmation.dispose(); })
|
||||
<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-content { z-index: 1; }
|
||||
.page-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.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; }
|
||||
.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: 142rpx 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, .upload-field > view > text:first-child { color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }
|
||||
.field-row input, .field-row textarea { min-width: 0; color: $ink; font-size: 23rpx; text-align: right; }.field-row--textarea { align-items: start; }.field-row textarea { min-height: 72rpx; text-align: left; }
|
||||
.upload-field { display: grid; gap: 12rpx; margin-top: 14rpx; padding: 18rpx 24rpx; @include adaptive.adaptive-records-field; }.upload-field > view > text { display: block; }.upload-field > view > text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
|
||||
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-field > text { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
||||
.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>
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
<!-- 页面编号:R-05;用途:礼仪活动入口。活动列表 operation 未声明。 -->
|
||||
<template>
|
||||
<view class="ritual-list-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<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 class="page-header"><PageHeader title="礼仪活动" :action="valid ? '新建' : ''" custom-back @back="returnToFamily" @action="createCeremony" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card"><text>礼仪活动入口无效</text><AppButton block label="返回上一页" @click="returnToFamily" /></view>
|
||||
<view v-else-if="state === 'loading'" class="state-card"><AppLoading text="正在读取礼仪活动" /></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取礼仪活动</text><AppButton block type="secondary" label="重新加载" @click="loadCeremonies" /></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"><text>还没有礼仪活动</text><AppButton block label="新建礼仪活动" @click="createCeremony" /></view>
|
||||
<view v-else class="ceremony-list"><view v-for="item in ceremonies" :key="item.id" class="ceremony-card" @click="openCeremony(item)"><view><text>{{ item.title }}</text><text>{{ item.type }}{{ item.time ? ` · ${item.time}` : '' }}</text><text v-if="item.description">{{ item.description }}</text></view><text>{{ item.giftCount }} 笔献礼</text></view></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShow, 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 { 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();
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref(""); const ceremonies = ref([]); const state = ref("loading"); const controller = createRequestController(); let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const loadCeremonies = async () => { if (!valid.value) return; controller.abort(); state.value = "loading"; try { const rows = await appApi.getCeremonies(genealogyId.value, { requestController: controller }); if (!active) return; ceremonies.value = rows.map((item) => ({ id: String(item.ceremonyId), title: String(item.ceremonyTitle || "未命名活动"), type: String(item.ceremonyType || ""), time: String(item.ceremonyTime || ""), description: String(item.ceremonyDesc || ""), giftCount: Number.isSafeInteger(item.giftCount) ? item.giftCount : 0 })).filter((item) => /^[1-9]\d*$/.test(item.id)); state.value = ceremonies.value.length ? "ready" : "empty"; } catch (error) { if (!active || isRequestCancelled(error)) return; state.value = "error"; } };
|
||||
const returnToFamily = () => valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
|
||||
const createCeremony = () => valid.value ? openPage("R07", { genealogyId: genealogyId.value, mode: "create" }, "R05") : Promise.resolve(false);
|
||||
const openCeremony = (item) => openPage("R06", { genealogyId: genealogyId.value, ceremonyId: item.id }, "R05");
|
||||
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (valid.value) loadCeremonies(); });
|
||||
onShow(() => { if (valid.value && state.value !== "loading") loadCeremonies(); });
|
||||
onUnload(() => { active = false; controller.abort(); });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.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; }
|
||||
.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,.ceremony-card{@include adaptive.adaptive-records-content}.state-card{display:flex;min-height:320rpx;flex-direction:column;align-items:center;justify-content:center;padding:42rpx;text-align:center;color:$ink;font-size:28rpx}.state-card .app-button{width:100%;margin-top:24rpx}.ceremony-list{display:flex;flex-direction:column;gap:16rpx}.ceremony-card{display:flex;min-height:138rpx;align-items:center;justify-content:space-between;gap:18rpx;padding:28rpx 32rpx}.ceremony-card>view{min-width:0;flex:1}.ceremony-card text{display:block}.ceremony-card>view text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:30rpx;font-weight:700;overflow-wrap:anywhere}.ceremony-card>view text:not(:first-child){margin-top:7rpx;color:$ink-muted;font-size:21rpx;line-height:1.45;overflow-wrap:anywhere}.ceremony-card>text{flex:0 0 auto;color:$brand-red;font-size:21rpx}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
<!-- 页面编号:R-06;用途:礼仪详情。详情响应无展示 DTO 时保持关闭。 -->
|
||||
<template>
|
||||
<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>
|
||||
<view class="ritual-detail-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="礼仪详情" custom-back @back="returnToList" /></view><view class="page-content"><view v-if="!valid" class="state-card"><text>礼仪入口无效</text><AppButton block label="返回上一页" @click="returnToList" /></view><view v-else-if="state === 'loading'" class="state-card"><AppLoading text="正在读取礼仪详情" /></view><view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取礼仪详情</text><AppButton block type="secondary" label="重新加载" @click="loadDetail" /></view><view v-else class="detail-card"><text>{{ detail.title }}</text><text>{{ detail.type }}{{ detail.time ? ` · ${detail.time}` : '' }}</text><text v-if="detail.location">地点:{{ detail.location }}</text><text v-if="detail.description">{{ detail.description }}</text><text>已有 {{ detail.giftCount }} 笔献礼</text></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 { 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();
|
||||
import { computed, ref } from "vue"; 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
const genealogyId=ref(""); const ceremonyId=ref(""); const state=ref("loading"); const detail=ref({title:"",type:"",time:"",location:"",description:"",giftCount:0}); const controller=createRequestController(); let active=true; const valid=computed(()=>/^[1-9]\d*$/.test(genealogyId.value)&&/^[1-9]\d*$/.test(ceremonyId.value));
|
||||
const loadDetail=async()=>{if(!valid.value)return;controller.abort();state.value="loading";try{const item=await appApi.getCeremonyDetail(genealogyId.value,ceremonyId.value,{requestController:controller});if(!active)return;detail.value={title:String(item.ceremonyTitle||"未命名活动"),type:String(item.ceremonyType||""),time:String(item.ceremonyTime||""),location:String(item.location||item.locationAddress||""),description:String(item.ceremonyDesc||""),giftCount:Number.isSafeInteger(item.giftCount)?item.giftCount:0};state.value="ready";}catch(error){if(!active||isRequestCancelled(error))return;state.value="error";}};
|
||||
onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");ceremonyId.value=String(query?.ceremonyId||"");if(valid.value)loadDetail();else state.value="invalid";});onUnload(()=>{active=false;controller.abort();});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-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; }
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;.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,.detail-card{@include adaptive.adaptive-records-content}.state-card{display:flex;min-height:320rpx;flex-direction:column;align-items:center;justify-content:center;padding:42rpx;text-align:center;color:$ink;font-size:28rpx}.state-card .app-button{width:100%;margin-top:24rpx}.detail-card{display:flex;min-height:320rpx;flex-direction:column;padding:42rpx}.detail-card text{display:block}.detail-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:36rpx;font-weight:700}.detail-card text:not(:first-child){margin-top:14rpx;color:$ink-muted;font-size:23rpx;line-height:1.6}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,142 @@
|
||||
<!-- 页面编号:R-07;用途:礼仪活动创建/编辑。创建 owner 缺失,编辑无可靠详情来源。 -->
|
||||
<!-- 页面编号:R-07;用途:创建礼仪活动。 -->
|
||||
<template>
|
||||
<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>
|
||||
<view class="ritual-editor-page" :class="`ritual-state--${pageState}`">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"><PageHeader title="新建礼仪活动" custom-back @back="requestBack" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="pageState === 'form'" class="editor-card">
|
||||
<text class="editor-card__title">记录一场家族礼仪</text>
|
||||
<text class="editor-card__note">带红色星号的内容不能为空;其余内容可按需要补充。</text>
|
||||
|
||||
<view class="field-row">
|
||||
<text class="field-row__label"><text class="required-mark">*</text>活动类型</text>
|
||||
<input v-model="form.ceremonyType" maxlength="30" placeholder="例如:祭祖、家宴" placeholder-class="placeholder" @input="clearError" />
|
||||
</view>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label"><text class="required-mark">*</text>活动标题</text>
|
||||
<input v-model="form.ceremonyTitle" maxlength="60" placeholder="请输入活动标题" placeholder-class="placeholder" @input="clearError" />
|
||||
</view>
|
||||
<view class="textarea-field">
|
||||
<text class="textarea-field__label">活动说明</text>
|
||||
<textarea v-model="form.ceremonyDesc" maxlength="500" auto-height placeholder="补充活动安排或说明" placeholder-class="placeholder" @input="clearError" />
|
||||
</view>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">活动时间</text>
|
||||
<input v-model="form.ceremonyTime" maxlength="30" placeholder="例如:2026-07-24 10:00:00" placeholder-class="placeholder" @input="clearError" />
|
||||
</view>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">地点</text>
|
||||
<input v-model="form.location" maxlength="60" placeholder="请输入地点" placeholder-class="placeholder" @input="clearError" />
|
||||
</view>
|
||||
<view class="cover-field">
|
||||
<view>
|
||||
<text class="cover-field__label">封面图片</text>
|
||||
<text class="cover-field__hint">选择图片后会取得真实上传回执,并在保存时关联。</text>
|
||||
</view>
|
||||
<button class="upload-button" :disabled="uploading || submitting" @click="uploadCover">{{ uploading ? '上传中…' : '选择图片' }}</button>
|
||||
<text v-if="coverFileName" class="upload-receipt">已上传:{{ coverFileName }}</text>
|
||||
</view>
|
||||
<text v-if="uploadError" class="form-error">{{ uploadError }}</text>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">排序值</text>
|
||||
<input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" placeholder-class="placeholder" @input="clearError" />
|
||||
</view>
|
||||
<text v-if="submitError" class="form-error">{{ submitError }}</text>
|
||||
<AppButton block :disabled="uploading || submitting" :label="submitting ? '正在保存…' : '保存活动'" @click="submit" />
|
||||
</view>
|
||||
<view v-else class="state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog :visible="discardVisible" title="放弃活动草稿?" message="当前内容尚未保存到服务端,返回后不会保留。" confirm-text="放弃并返回" cancel-text="继续填写" show-cancel :close-on-mask="false" @confirm="confirmation.confirm" @cancel="confirmation.cancel" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } 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 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 || ""); });
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("form");
|
||||
const form = reactive({ ceremonyType: "", ceremonyTitle: "", ceremonyDesc: "", ceremonyTime: "", location: "", sortOrder: "" });
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const uploadError = ref("");
|
||||
const submitError = ref("");
|
||||
const uploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const controller = createRequestController();
|
||||
const discardVisible = ref(false);
|
||||
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
||||
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isDirty = computed(() => coverOssId.value || Object.values(form).some((value) => value.trim()));
|
||||
const stateCopy = computed(() => pageState.value === "success"
|
||||
? { title: "礼仪活动已提交服务端", copy: "服务端已返回成功结果。", action: "返回礼仪活动" }
|
||||
: { title: "礼仪活动入口无效", copy: "没有取得有效家谱标识。", action: "返回上一页" });
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value || query?.mode !== "create") pageState.value = "invalid";
|
||||
});
|
||||
const clearError = () => { submitError.value = ""; };
|
||||
const uploadCover = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: controller });
|
||||
coverOssId.value = toConsumerOssId(receipt.ossId);
|
||||
coverFileName.value = receipt.fileName || "活动封面";
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submit = async () => {
|
||||
if (uploading.value || submitting.value || !hasValidContext.value) return;
|
||||
if (!form.ceremonyType.trim() || !form.ceremonyTitle.trim()) {
|
||||
submitError.value = !form.ceremonyType.trim() ? "请填写活动类型" : "请填写活动标题";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createCeremony(genealogyId.value, { ...form, ...(coverOssId.value ? { coverOssId: coverOssId.value } : {}) }, { requestController: controller });
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "礼仪活动保存失败,请稍后重试";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
const returnToList = () => hasValidContext.value ? returnTo("R05", { genealogyId: genealogyId.value }) : goBack();
|
||||
const requestBack = () => runBackGuard({ transientOpen: discardVisible.value, dirty: isDirty.value, submitting: uploading.value || submitting.value, "close-transient": confirmation.cancel, "block-submitting": () => true, "confirm-discard": confirmation.request });
|
||||
const handleStateAction = () => pageState.value === "success" ? returnToList() : goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => { controller.abort(); confirmation.dispose(); });
|
||||
</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; }.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; }
|
||||
.ritual-editor-page { display:flex; min-height:100vh; flex-direction:column; background:$paper; }
|
||||
.page-header,.page-content { z-index:1; }.page-content { flex:1; padding:22rpx 28rpx 72rpx; }
|
||||
.editor-card,.state-card { @include adaptive.adaptive-records-content; box-sizing:border-box; }
|
||||
.editor-card { padding:38rpx 32rpx 42rpx; }.editor-card__title,.editor-card__note,.field-row__label,.textarea-field__label,.form-error { display:block; }
|
||||
.editor-card__title { color:$ink; font-family:STKaiti,KaiTi,serif; font-size:38rpx; font-weight:700; }.editor-card__note { margin-top:10rpx; color:$ink-muted; font-size:23rpx; line-height:1.55; }
|
||||
.field-row { display:flex; min-height:92rpx; align-items:center; margin-top:20rpx; padding:0 18rpx; border:1rpx solid rgba(128,89,49,.34); border-radius:12rpx; background:rgba(255,252,245,.7); }.field-row__label { width:150rpx; flex:0 0 auto; color:$ink; font-size:26rpx; font-weight:700; }.field-row input { min-width:0; flex:1; color:$ink; font-size:25rpx; }.required-mark { display:inline-block; margin-right:4rpx; color:$brand-red; font-size:26rpx; transform:translateY(-3rpx); }.placeholder { color:#9e8e79; }
|
||||
.textarea-field,.cover-field { margin-top:20rpx; padding:18rpx; border:1rpx solid rgba(128,89,49,.34); border-radius:12rpx; background:rgba(255,252,245,.7); }.textarea-field__label,.cover-field__label { color:$ink; font-size:26rpx; font-weight:700; }.textarea-field textarea { display:block; width:100%; min-height:122rpx; margin-top:12rpx; color:$ink; font-size:25rpx; line-height:1.55; }
|
||||
.cover-field { display:grid; gap:12rpx; }.cover-field__hint { display:block; margin-top:5rpx; color:$ink-muted; font-size:20rpx; line-height:1.45; }.upload-button { justify-self:start; min-height:60rpx; margin:0; padding:0 20rpx; border:1rpx solid rgba(159,23,15,.42); border-radius:8rpx; background:transparent; color:$brand-red; font-size:22rpx; }.upload-receipt { color:$ink-muted; font-size:21rpx; overflow-wrap:anywhere; }.form-error { margin-top:10rpx; color:$brand-red; font-size:22rpx; line-height:1.45; }.editor-card .app-button { margin-top:28rpx; }
|
||||
.state-card { min-height:340rpx; padding:78rpx 52rpx 50rpx; 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; }
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,156 @@
|
||||
<!-- 页面编号:R-08;用途:成长记录创建。列表 DTO 未声明,创建仅提交可映射字段。 -->
|
||||
<!-- 页面编号:R-08;用途:读取、新建当前家谱的成长记录。 -->
|
||||
<template>
|
||||
<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>
|
||||
<view class="record-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader title="成长日志" :action="valid && view === 'list' ? '新建' : ''" custom-back @back="requestBack" @action="openCreate" />
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>新建成长记录</text>
|
||||
<text class="form-copy">红色 * 为必填项,其余内容可按需补充。</text>
|
||||
<view class="field field--context"><text>关联人物</text><view><text>{{ personName }}</text><text>{{ personId ? '已自动关联' : '未关联人物' }}</text></view></view>
|
||||
<view class="field"><text>记录类型</text><input v-model="form.recordType" placeholder="请输入记录类型" @input="error = ''" /></view>
|
||||
<view class="field"><text><text class="required-mark">*</text>记录标题</text><input v-model="form.recordTitle" placeholder="请输入记录标题" @input="error = ''" /></view>
|
||||
<view class="field field--textarea"><text>记录内容</text><textarea v-model="form.recordContent" auto-height placeholder="记录成长片段" @input="error = ''" /></view>
|
||||
<view class="field"><text>记录时间</text><input v-model="form.recordDate" placeholder="请输入记录时间" @input="error = ''" /></view>
|
||||
<view class="field"><text>提醒时间</text><input v-model="form.remindTime" placeholder="请输入提醒时间" @input="error = ''" /></view>
|
||||
<view class="upload-field">
|
||||
<view><text>相关图片</text><text>图片选定后会先取得真实上传回执,并在提交记录时关联。</text></view>
|
||||
<button class="upload-button" :disabled="uploading || submitting" @click="uploadImage">{{ uploading ? '上传中…' : '添加图片' }}</button>
|
||||
<text v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`">已上传:{{ receipt.fileName || '图片' }}</text>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<view class="field"><text>排序值</text><input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" @input="error = ''" /></view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"><AppButton type="secondary" label="取消" @click="cancelCreate" /><AppButton :disabled="submitting || uploading" :label="submitting ? '正在提交' : '提交成长记录'" @click="submit" /></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"><text>成长日志入口无效</text><AppButton block label="返回上一页" @click="requestBack" /></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"><AppLoading text="正在读取成长记录" /></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"><text>暂时无法读取成长记录</text><AppButton block type="secondary" label="重新加载" @click="loadRecords" /></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"><text>还没有成长记录</text><AppButton block label="新建成长记录" @click="openCreate" /></view>
|
||||
<view v-else class="record-list">
|
||||
<text v-if="saveNotice" class="save-notice">成长记录已提交,并已从服务端重新读取。</text>
|
||||
<view v-for="item in records" :key="item.id" class="record-card">
|
||||
<view><text>{{ item.title }}</text><text>{{ item.type || item.personName }}{{ item.date ? ` · ${item.date}` : '' }}</text><text v-if="item.content">{{ item.content }}</text></view>
|
||||
</view>
|
||||
</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 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(); });
|
||||
import { onBackPress, onLoad, onShow } 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const personName = ref("当前家谱");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const records = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const form = reactive({ lineagePersonId: "", recordType: "", recordTitle: "", recordContent: "", recordDate: "", remindTime: "", sortOrder: "" });
|
||||
const mediaReceipts = ref([]);
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||
const dirty = computed(() => Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0);
|
||||
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
||||
const confirmDiscard = () => { confirmation.confirm(); resetForm(); view.value = "list"; };
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, { lineagePersonId: personId.value, recordType: "", recordTitle: "", recordContent: "", recordDate: "", remindTime: "", sortOrder: "" });
|
||||
mediaReceipts.value = [];
|
||||
error.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const loadRecords = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getGrowthRecords(genealogyId.value, { requestController: controller });
|
||||
if (!active) return;
|
||||
records.value = rows.map((item) => ({
|
||||
id: String(item.recordId || ""),
|
||||
title: String(item.recordTitle || "未命名记录"),
|
||||
type: String(item.recordType || ""),
|
||||
personName: String(item.lineagePersonName || "未关联人物"),
|
||||
date: String(item.recordDate || item.remindTime || ""),
|
||||
content: String(item.recordContent || ""),
|
||||
})).filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
listState.value = records.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!active || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => { if (!valid.value) return; saveNotice.value = false; resetForm(); view.value = "form"; };
|
||||
const cancelCreate = () => { resetForm(); view.value = "list"; };
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try { mediaReceipts.value = [...mediaReceipts.value, await pickAndUploadImage({ requestController: controller })]; }
|
||||
catch (cause) { if (!isImagePickCancelled(cause) && !isRequestCancelled(cause)) uploadError.value = cause?.message || "图片上传失败,请稍后重试。"; }
|
||||
finally { uploading.value = false; }
|
||||
};
|
||||
const submit = async () => {
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
if (!form.recordTitle.trim()) { error.value = "请填写记录标题"; return; }
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await appApi.createGrowthRecord(genealogyId.value, { ...form, mediaOssIds: mediaOssIds.value }, { requestController: controller });
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadRecords();
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause)) error.value = cause?.message || "成长记录提交失败,请稍后重试。";
|
||||
} finally { submitting.value = false; }
|
||||
};
|
||||
const requestBack = () => view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({ transientOpen: discardVisible.value, dirty: dirty.value, submitting: submitting.value || uploading.value, "close-transient": cancelDiscard, "block-submitting": () => true, "confirm-discard": confirmation.request });
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
personId.value = /^[1-9]\d*$/.test(String(query?.personId || "")) ? String(query.personId) : "";
|
||||
personName.value = String(query?.personName || (personId.value ? "当前查看的人物" : "当前家谱"));
|
||||
resetForm();
|
||||
if (valid.value) loadRecords();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => { if (valid.value && view.value === "list" && listState.value !== "loading") loadRecords(); });
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => { active = false; controller.abort(); confirmation.dispose(); });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.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}
|
||||
.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, .record-card { @include adaptive.adaptive-records-content; }.form-card { box-sizing: border-box; padding: 46rpx; }.form-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; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }.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; }
|
||||
.field--context > view { @include adaptive.adaptive-records-field; display: flex; min-height: 76rpx; align-items: center; justify-content: space-between; padding: 16rpx 22rpx; box-sizing: border-box; }.field--context > view > text:first-child { color: $ink; font-size: 23rpx; }.field--context > view > text:last-child { color: $ink-muted; font-size: 21rpx; }
|
||||
.upload-field { display: grid; gap: 12rpx; margin-top: 16rpx; padding: 18rpx 22rpx; @include adaptive.adaptive-records-field; }.upload-field > view > text { display: block; }.upload-field > view > text:first-child { color: $ink; font-size: 23rpx; font-weight: 700; }.upload-field > view > text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-field > text, .error { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }.error { display: block; margin-top: 12rpx; color: $brand-red; font-size: 22rpx; }
|
||||
.form-actions { display: grid; grid-template-columns: 1fr 1.45fr; gap: 16rpx; margin-top: 20rpx; }.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; color: $ink; font-size: 28rpx; text-align: center; }.state-card .app-button { width: 100%; margin-top: 24rpx; }.record-list { display: flex; flex-direction: column; gap: 16rpx; }.save-notice { display: block; color: $brand-red; font-size: 22rpx; }.record-card { min-height: 128rpx; padding: 28rpx 32rpx; }.record-card text { display: block; }.record-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }.record-card text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,131 @@
|
||||
<!-- 页面编号:R-10;用途:家族备忘创建。列表 DTO 缺失时不展示 fixture。 -->
|
||||
<!-- 页面编号:R-10;用途:读取、新建当前家谱的家族备忘。 -->
|
||||
<template>
|
||||
<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>
|
||||
<view class="memo-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"><PageHeader title="家族备忘" :action="valid && view === 'list' ? '新建' : ''" custom-back @back="requestBack" @action="openCreate" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>新建家族备忘</text>
|
||||
<text class="form-copy">红色 * 为必填项,其余内容可按需补充。</text>
|
||||
<view class="field"><text><text class="required-mark">*</text>备忘标题</text><input v-model="form.memoTitle" maxlength="40" placeholder="请输入备忘标题" @input="error = ''" /></view>
|
||||
<picker mode="date" :value="form.remindDate" @change="selectRemindDate"><view class="field field--picker"><text>提醒日期</text><text>{{ form.remindDate || '请选择' }}</text></view></picker>
|
||||
<picker mode="time" :value="form.remindClock" @change="selectRemindClock"><view class="field field--picker"><text>提醒时间</text><text>{{ form.remindClock || '请选择' }}</text></view></picker>
|
||||
<view class="field field--textarea"><text>备忘内容</text><textarea v-model="form.memoContent" auto-height maxlength="1200" placeholder="记录需要提醒的事情" @input="error = ''" /></view>
|
||||
<view class="upload-field">
|
||||
<view><text>相关图片</text><text>图片选定后会先取得真实上传回执,并在提交备忘时关联。</text></view>
|
||||
<button class="upload-button" :disabled="uploading || submitting" @click="uploadImage">{{ uploading ? '上传中…' : '添加图片' }}</button>
|
||||
<text v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`">已上传:{{ receipt.fileName || '图片' }}</text>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<view class="field"><text>排序值</text><input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" @input="error = ''" /></view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"><AppButton type="secondary" label="取消" @click="cancelCreate" /><AppButton :disabled="submitting || uploading" :label="submitting ? '正在提交' : '提交备忘'" @click="submit" /></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"><text>家族备忘入口无效</text><AppButton block label="返回上一页" @click="requestBack" /></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"><AppLoading text="正在读取家族备忘" /></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"><text>暂时无法读取家族备忘</text><AppButton block type="secondary" label="重新加载" @click="loadMemos" /></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"><text>还没有家族备忘</text><AppButton block label="新建备忘" @click="openCreate" /></view>
|
||||
<view v-else class="memo-list">
|
||||
<text v-if="saveNotice" class="save-notice">备忘已提交,并已从服务端重新读取。</text>
|
||||
<view v-for="item in memos" :key="item.id" class="memo-card"><view><text>{{ item.title }}</text><text v-if="item.remindTime">{{ item.remindTime }}</text><text v-if="item.content">{{ item.content }}</text></view></view>
|
||||
</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 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();});
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow } 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const memos = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const form = reactive({ memoTitle: "", remindDate: "", remindClock: "", memoContent: "", sortOrder: "" });
|
||||
const mediaReceipts = ref([]);
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const remindTime = computed(() => form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "");
|
||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||
const dirty = computed(() => Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0);
|
||||
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
||||
const confirmDiscard = () => { confirmation.confirm(); resetForm(); view.value = "list"; };
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => { Object.assign(form, { memoTitle: "", remindDate: "", remindClock: "", memoContent: "", sortOrder: "" }); mediaReceipts.value = []; error.value = ""; uploadError.value = ""; };
|
||||
const loadMemos = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getMemos(genealogyId.value, { requestController: controller });
|
||||
if (!active) return;
|
||||
memos.value = rows.map((item) => ({ id: String(item.memoId || ""), title: String(item.memoTitle || "未命名备忘"), remindTime: String(item.remindTime || ""), content: String(item.memoContent || "") })).filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
listState.value = memos.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!active || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => { if (!valid.value) return; saveNotice.value = false; resetForm(); view.value = "form"; };
|
||||
const cancelCreate = () => { resetForm(); view.value = "list"; };
|
||||
const selectRemindDate = (event) => { form.remindDate = event.detail.value || ""; };
|
||||
const selectRemindClock = (event) => { form.remindClock = event.detail.value || ""; };
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try { mediaReceipts.value = [...mediaReceipts.value, await pickAndUploadImage({ requestController: controller })]; }
|
||||
catch (cause) { if (!isImagePickCancelled(cause) && !isRequestCancelled(cause)) uploadError.value = cause?.message || "图片上传失败,请稍后重试。"; }
|
||||
finally { uploading.value = false; }
|
||||
};
|
||||
const submit = async () => {
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
if (!form.memoTitle.trim()) { error.value = "请填写备忘标题"; return; }
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await appApi.createMemo(genealogyId.value, { memoTitle: form.memoTitle, remindTime: remindTime.value, memoContent: form.memoContent, mediaOssIds: mediaOssIds.value, sortOrder: form.sortOrder }, { requestController: controller });
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadMemos();
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause)) error.value = cause?.message || "备忘提交失败,请稍后重试。";
|
||||
} finally { submitting.value = false; }
|
||||
};
|
||||
const requestBack = () => view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({ transientOpen: discardVisible.value, dirty: dirty.value, submitting: submitting.value || uploading.value, "close-transient": cancelDiscard, "block-submitting": () => true, "confirm-discard": confirmation.request });
|
||||
|
||||
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (valid.value) loadMemos(); else listState.value = "invalid"; });
|
||||
onShow(() => { if (valid.value && view.value === "list" && listState.value !== "loading") loadMemos(); });
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => { active = false; 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-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}
|
||||
.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, .memo-card { @include adaptive.adaptive-records-content; }.form-card { box-sizing: border-box; padding: 46rpx; }.form-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 { display: grid; grid-template-columns: 142rpx 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 > text, .upload-field > view > text:first-child { color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }.field input, .field > text:last-child { min-width: 0; color: $ink; font-size: 23rpx; text-align: right; }.field--textarea { align-items: start; }.field textarea { min-height: 116rpx; color: $ink; font-size: 23rpx; line-height: 1.55; }
|
||||
.upload-field { display: grid; gap: 12rpx; margin-top: 14rpx; padding: 18rpx 24rpx; @include adaptive.adaptive-records-field; }.upload-field > view > text { display: block; }.upload-field > view > text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-field > text { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }.error { display: block; margin-top: 12rpx; color: $brand-red; font-size: 22rpx; }
|
||||
.form-actions { display: grid; grid-template-columns: 1fr 1.45fr; gap: 16rpx; margin-top: 20rpx; }.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; color: $ink; font-size: 28rpx; text-align: center; }.state-card .app-button { width: 100%; margin-top: 24rpx; }.memo-list { display: flex; flex-direction: column; gap: 16rpx; }.save-notice { display: block; color: $brand-red; font-size: 22rpx; }.memo-card { min-height: 128rpx; padding: 28rpx 32rpx; }.memo-card text { display: block; }.memo-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }.memo-card text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,127 @@
|
||||
<!-- 页面编号:R-11;用途:功德记录创建。列表 DTO 缺失时不展示 fixture。 -->
|
||||
<!-- 页面编号:R-11;用途:读取、新建当前家谱的功德记录。 -->
|
||||
<template>
|
||||
<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>
|
||||
<view class="merit-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"><PageHeader title="功德记录" :action="valid && view === 'list' ? '新建' : ''" custom-back @back="requestBack" @action="openCreate" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>新建功德记录</text>
|
||||
<text class="form-copy">红色 * 为必填项,其余内容可按需补充。</text>
|
||||
<view v-for="field in fields" :key="field.key" class="field"><text><text v-if="field.required" class="required-mark">*</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.inputType || 'text'" :placeholder="field.placeholder || `请输入${field.label}`" @input="error = ''" /></view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"><AppButton type="secondary" label="取消" @click="cancelCreate" /><AppButton :disabled="submitting" :label="submitting ? '正在提交' : '提交功德记录'" @click="submit" /></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"><text>功德记录入口无效</text><AppButton block label="返回上一页" @click="requestBack" /></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"><AppLoading text="正在读取功德记录" /></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"><text>暂时无法读取功德记录</text><AppButton block type="secondary" label="重新加载" @click="loadMerits" /></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"><text>还没有功德记录</text><AppButton block label="新建功德记录" @click="openCreate" /></view>
|
||||
<view v-else class="merit-list">
|
||||
<text v-if="saveNotice" class="save-notice">功德记录已提交,并已从服务端重新读取。</text>
|
||||
<text class="merit-summary">共 {{ merits.length }} 笔,金额合计 ¥{{ totalAmount }}</text>
|
||||
<view v-for="item in merits" :key="item.id" class="merit-card"><view><text>{{ item.title }}</text><text>{{ item.donor }}{{ item.type ? ` · ${item.type}` : '' }}</text><text v-if="item.time || item.content">{{ item.time || item.content }}</text></view><text>¥{{ item.amount }}</text></view>
|
||||
</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 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();});
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow } 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const merits = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const error = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const form = reactive({ donor: "", title: "", type: "", amount: "", time: "", content: "", sortOrder: "" });
|
||||
const fields = [
|
||||
{ key: "donor", label: "捐赠人", required: true },
|
||||
{ key: "title", label: "功德标题", required: true },
|
||||
{ key: "type", label: "功德类型" },
|
||||
{ key: "amount", inputType: "digit", label: "金额" },
|
||||
{ key: "time", label: "记录时间" },
|
||||
{ key: "content", label: "记录内容" },
|
||||
{ key: "sortOrder", label: "排序值", inputType: "number", placeholder: "数值越小越靠前" },
|
||||
];
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const dirty = computed(() => Object.values(form).some((value) => String(value).trim()));
|
||||
const totalAmount = computed(() => merits.value.reduce((total, item) => total + item.amount, 0).toFixed(2));
|
||||
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
||||
const confirmDiscard = () => { confirmation.confirm(); resetForm(); view.value = "list"; };
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => { Object.assign(form, { donor: "", title: "", type: "", amount: "", time: "", content: "", sortOrder: "" }); error.value = ""; };
|
||||
const loadMerits = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getMeritRecords(genealogyId.value, { requestController: controller });
|
||||
if (!active) return;
|
||||
merits.value = rows.map((item) => ({
|
||||
id: String(item.meritId || ""),
|
||||
donor: String(item.donorName || "未署名"),
|
||||
title: String(item.meritTitle || "未命名功德"),
|
||||
type: String(item.meritType || ""),
|
||||
amount: Number.isFinite(Number(item.amount)) ? Number(item.amount) : 0,
|
||||
time: String(item.meritTime || ""),
|
||||
content: String(item.meritContent || ""),
|
||||
})).filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
listState.value = merits.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!active || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => { if (!valid.value) return; saveNotice.value = false; resetForm(); view.value = "form"; };
|
||||
const cancelCreate = () => { resetForm(); view.value = "list"; };
|
||||
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, sortOrder: form.sortOrder, ...(amountText ? { amount: Number(amountText) } : {}) }, { requestController: controller });
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadMerits();
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause)) error.value = cause?.message || "功德记录提交失败,请稍后重试。";
|
||||
} finally { submitting.value = false; }
|
||||
};
|
||||
const requestBack = () => view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({ transientOpen: discardVisible.value, dirty: dirty.value, submitting: submitting.value, "close-transient": cancelDiscard, "block-submitting": () => true, "confirm-discard": confirmation.request });
|
||||
|
||||
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (valid.value) loadMerits(); else listState.value = "invalid"; });
|
||||
onShow(() => { if (valid.value && view.value === "list" && listState.value !== "loading") loadMerits(); });
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => { active = false; 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-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}
|
||||
.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, .merit-card { @include adaptive.adaptive-records-content; }.form-card { box-sizing: border-box; padding: 46rpx; }.form-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; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }.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-actions { display: grid; grid-template-columns: 1fr 1.45fr; gap: 16rpx; margin-top: 20rpx; }.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; color: $ink; font-size: 28rpx; text-align: center; }.state-card .app-button { width: 100%; margin-top: 24rpx; }.merit-list { display: flex; flex-direction: column; gap: 16rpx; }.save-notice { display: block; color: $brand-red; font-size: 22rpx; }.merit-summary { display: block; color: $ink-muted; font-size: 22rpx; }.merit-card { display: flex; min-height: 138rpx; align-items: center; justify-content: space-between; gap: 18rpx; padding: 28rpx 32rpx; }.merit-card > view { min-width: 0; flex: 1; }.merit-card text { display: block; }.merit-card > view text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; overflow-wrap: anywhere; }.merit-card > view text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }.merit-card > text { flex: 0 0 auto; color: $brand-red; font-size: 22rpx; }
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text>姓名</text>
|
||||
<text><text class="required-mark">*</text>姓名</text>
|
||||
<input
|
||||
v-model="addForm.name"
|
||||
maxlength="20"
|
||||
@@ -44,25 +44,70 @@
|
||||
@change="selectRelation"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>与本人关系</text>
|
||||
<text><text class="required-mark">*</text>与本人关系</text>
|
||||
<text>{{ addForm.relation || "请选择亲属关系" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text v-if="fieldErrors.relation" class="field-error">{{ fieldErrors.relation }}</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text>别名</text>
|
||||
<input v-model="addForm.aliasName" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>字辈</text>
|
||||
<input v-model="addForm.generationName" maxlength="12" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
|
||||
<picker mode="date" :value="addForm.birthDate" @change="selectBirthDate">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>出生日期</text><text>{{ addForm.birthDate || "选填" }}</text>
|
||||
<text>出生日期</text><text>{{ addForm.birthDate || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>出生农历</text>
|
||||
<input v-model="addForm.birthLunar" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>出生地</text>
|
||||
<input v-model="addForm.birthPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
|
||||
<picker mode="date" :value="addForm.deathDate" @change="selectDeathDate">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>逝世日期</text><text>{{ addForm.deathDate || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>逝世农历</text>
|
||||
<input v-model="addForm.deathLunar" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>逝世地</text>
|
||||
<input v-model="addForm.deathPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>安葬地</text>
|
||||
<input v-model="addForm.burialPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="form-field form-field--summary">
|
||||
<text>简要说明</text>
|
||||
<text>人物简介</text>
|
||||
<textarea
|
||||
v-model="addForm.summary"
|
||||
v-model="addForm.biography"
|
||||
auto-height
|
||||
maxlength="500"
|
||||
placeholder="按家谱记载填写"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--summary">
|
||||
<text>备注</text>
|
||||
<textarea
|
||||
v-model="addForm.remark"
|
||||
auto-height
|
||||
maxlength="200"
|
||||
placeholder="选填:字辈、祖居地或身份说明"
|
||||
placeholder="补充说明"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
@@ -136,7 +181,21 @@ const relationIntents = Object.freeze({
|
||||
DAUGHTER: { label: "女儿" },
|
||||
});
|
||||
const genericRelationTypes = Object.freeze(["FATHER", "MOTHER", "SPOUSE", "SIBLING", "SON", "DAUGHTER"]);
|
||||
const addForm = reactive({ name: "", relation: "", birthDate: "", summary: "" });
|
||||
const addForm = reactive({
|
||||
name: "",
|
||||
relation: "",
|
||||
aliasName: "",
|
||||
generationName: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthPlace: "",
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
biography: "",
|
||||
remark: "",
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", relation: "" });
|
||||
|
||||
const isFirstMember = computed(() => mode.value === "first");
|
||||
@@ -161,13 +220,13 @@ const formCopy = computed(() =>
|
||||
: "先确认新成员与当前成员的关系,再填写可核实的身份信息。",
|
||||
);
|
||||
const formNote = computed(() =>
|
||||
"本次只提交姓名、出生日期和人物简介;性别编码、头像和同辈排行不在当前写入范围。",
|
||||
"红色 * 为必填项,其余内容可按家谱记载补充。",
|
||||
);
|
||||
const resultCopy = computed(() => {
|
||||
if (hasValidContext.value) {
|
||||
return {
|
||||
eyebrow: "保存失败",
|
||||
title: `${relationLabel.value}尚未保存`,
|
||||
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}尚未保存`,
|
||||
copy: errorMessage.value || "服务未确认本次写入,请检查填写后重试。",
|
||||
action: "返回修改",
|
||||
};
|
||||
@@ -255,6 +314,7 @@ const selectRelation = (event) => {
|
||||
clearError("relation");
|
||||
};
|
||||
const selectBirthDate = (event) => { addForm.birthDate = event.detail.value || ""; };
|
||||
const selectDeathDate = (event) => { addForm.deathDate = event.detail.value || ""; };
|
||||
const validateAddForm = () => {
|
||||
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
|
||||
fieldErrors.relation = isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系";
|
||||
@@ -266,8 +326,18 @@ const submitAdd = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
name: addForm.name,
|
||||
aliasName: addForm.aliasName,
|
||||
generationName: addForm.generationName,
|
||||
birthDate: addForm.birthDate,
|
||||
biography: addForm.summary,
|
||||
birthLunar: addForm.birthLunar,
|
||||
birthPlace: addForm.birthPlace,
|
||||
deathDate: addForm.deathDate,
|
||||
deathLunar: addForm.deathLunar,
|
||||
deathPlace: addForm.deathPlace,
|
||||
burialPlace: addForm.burialPlace,
|
||||
biography: addForm.biography,
|
||||
remark: addForm.remark,
|
||||
...(isFirstMember.value ? { generation: 1 } : {}),
|
||||
...(isFirstMember.value ? {} : { relationName: relationLabel.value }),
|
||||
};
|
||||
if (isFirstMember.value) {
|
||||
@@ -278,7 +348,21 @@ const submitAdd = async () => {
|
||||
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)];
|
||||
await appApi.createRelatedPerson(genealogyId.value, personId.value, type, payload, { requestController: addRequestController });
|
||||
}
|
||||
Object.assign(addForm, { name: "", relation: "", birthDate: "", summary: "" });
|
||||
Object.assign(addForm, {
|
||||
name: "",
|
||||
relation: "",
|
||||
aliasName: "",
|
||||
generationName: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthPlace: "",
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
biography: "",
|
||||
remark: "",
|
||||
});
|
||||
await returnTo("T01", { genealogyId: genealogyId.value });
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) return;
|
||||
@@ -310,6 +394,7 @@ const returnToTree = async () => {
|
||||
.member-context, .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; }
|
||||
.member-context text:first-child, .form-field > text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
|
||||
.member-context text:last-child, .form-field > text:last-child, .form-field input, .form-field textarea { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; }
|
||||
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 22rpx; line-height: 1; transform: translateY(-2rpx); }
|
||||
.form-field textarea { width: auto; min-height: 54rpx; text-align: left; }
|
||||
.form-field--summary { align-items: start; }
|
||||
.form-placeholder { color: #a79884; }
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="directory-page__header"><PageHeader title="成员目录" /></view>
|
||||
<view v-if="hasValidContext" class="directory-context">
|
||||
<text class="directory-context__name">汤氏家谱</text>
|
||||
<text class="directory-context__name">当前家谱</text>
|
||||
<text class="directory-context__meta"
|
||||
>已加载 {{ members.length }} / {{ total }} 位成员</text
|
||||
>
|
||||
|
||||
@@ -71,10 +71,10 @@ const states = {
|
||||
},
|
||||
available: {
|
||||
eyebrow: "人物状态",
|
||||
title: "当前状态已由服务端返回",
|
||||
copy: "页面只展示人物详情中的原始状态值,不把未声明的字典值解释成隐私、纪念或访问限制。",
|
||||
title: "暂无法展示状态说明",
|
||||
copy: "服务端返回了状态字段,但未提供可读名称或说明;本页不会向家人展示内部状态值。",
|
||||
guideTitle: "当前合同范围",
|
||||
guides: ["人物状态值来自 personStatus 字段", "状态展示文案需要后端提供字典 owner", "当前页不执行停用或任何状态写入"],
|
||||
guides: ["状态由服务端维护", "状态展示需要后端提供可读字典", "当前页不执行停用或任何状态写入"],
|
||||
action: "返回成员档案",
|
||||
},
|
||||
error: {
|
||||
@@ -91,8 +91,7 @@ const pageTitle = computed(() => "成员状态");
|
||||
const hasValidContext = computed(() => Boolean(genealogyId.value && personId.value));
|
||||
const memberIdentityCopy = computed(() => {
|
||||
if (!member.value) return "";
|
||||
const status = member.value.personStatus || "未填写";
|
||||
return `${member.value.name} · 第 ${member.value.generation} 世 · 状态值 ${status}`;
|
||||
return `${member.value.name} · 第 ${member.value.generation} 世`;
|
||||
});
|
||||
|
||||
const loadMember = async () => {
|
||||
|
||||
@@ -158,10 +158,8 @@ foreach ($asset in @(
|
||||
'brand-seal.png',
|
||||
'a01-vnext-divider-v1.png',
|
||||
'a01-scroll-primary-v3.png',
|
||||
'a01-scroll-secondary-v3.png',
|
||||
'a01-scroll-toast-v3.png',
|
||||
'a01-scroll-dialog-v3.png',
|
||||
'auth-wechat.png',
|
||||
'a01-icon-phone-v1.png',
|
||||
'a01-icon-lock-v1.png',
|
||||
'a01-icon-eye-open-v1.png',
|
||||
@@ -189,8 +187,8 @@ foreach ($obsoleteVisualAsset in @(
|
||||
}
|
||||
|
||||
$buttonSkinTags = [regex]::Matches($entry, '<image\s+class="button-skin"[^>]+>')
|
||||
if ($buttonSkinTags.Count -ne 2) {
|
||||
throw "A01 must have exactly two visible login button skins, found $($buttonSkinTags.Count)."
|
||||
if ($buttonSkinTags.Count -ne 1) {
|
||||
throw "A01 must have exactly one supported login button skin, found $($buttonSkinTags.Count)."
|
||||
}
|
||||
foreach ($buttonSkinTag in $buttonSkinTags) {
|
||||
Assert-Contains -Content $buttonSkinTag.Value -Expected 'mode="aspectFit"' -Message 'A01 button skins must use uniform aspectFit rendering.'
|
||||
@@ -207,7 +205,6 @@ $requiredCopy = @(
|
||||
'6aqM6K+B56CB',
|
||||
'5b+Y6K6w5a+G56CB',
|
||||
'6I635Y+W6aqM6K+B56CB',
|
||||
'5b6u5L+h55m75b2V',
|
||||
'6L+Y5rKh5pyJ6LSm5Y+377yf',
|
||||
'5rOo5YaM6LSm5Y+3',
|
||||
'44CK55So5oi35Y2P6K6u44CL',
|
||||
|
||||
@@ -16,15 +16,21 @@ const pageScript = match[1].replace(
|
||||
"",
|
||||
);
|
||||
|
||||
const createHarnessFactory = (goRootResult = true, goRootError = null) => new Function(
|
||||
const createHarnessFactory = (
|
||||
goRootResult = true,
|
||||
goRootError = null,
|
||||
initialSessionToken = "",
|
||||
) => new Function(
|
||||
"goRootResult",
|
||||
"goRootError",
|
||||
"initialSessionToken",
|
||||
`
|
||||
"use strict";
|
||||
const calls = [];
|
||||
const ref = (value) => ({ value });
|
||||
const onBackPress = () => {};
|
||||
const onShow = (callback) => callback();
|
||||
const showCallbacks = [];
|
||||
const onShow = (callback) => showCallbacks.push(callback);
|
||||
const onUnload = () => {};
|
||||
const AuthPageShell = {};
|
||||
const AppToast = {};
|
||||
@@ -96,6 +102,9 @@ const createHarnessFactory = (goRootResult = true, goRootError = null) => new Fu
|
||||
tenantId: "000000",
|
||||
};
|
||||
const calcMD5 = (value) => "md5:" + value;
|
||||
const session = {
|
||||
getToken: () => initialSessionToken,
|
||||
};
|
||||
const goRoot = async (pageId) => {
|
||||
calls.push({ type: "go-root", pageId });
|
||||
if (goRootError) throw goRootError;
|
||||
@@ -124,17 +133,21 @@ const createHarnessFactory = (goRootResult = true, goRootError = null) => new Fu
|
||||
submitLogin,
|
||||
completeTac,
|
||||
closeTac,
|
||||
async triggerShow() {
|
||||
for (const callback of showCallbacks) await callback();
|
||||
},
|
||||
setPendingTacAction(value) {
|
||||
pendingTacAction = value;
|
||||
},
|
||||
};
|
||||
`,
|
||||
)(goRootResult, goRootError);
|
||||
)(goRootResult, goRootError, initialSessionToken);
|
||||
|
||||
const createHarness = (options = {}) =>
|
||||
createHarnessFactory(
|
||||
options.goRootResult ?? true,
|
||||
options.goRootError ?? null,
|
||||
options.initialSessionToken ?? "",
|
||||
);
|
||||
|
||||
const countCalls = (harness, type) =>
|
||||
@@ -159,6 +172,16 @@ const completePasswordTac = (harness) =>
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
const restored = createHarness({ initialSessionToken: "persisted-token" });
|
||||
await restored.triggerShow();
|
||||
assert.deepStrictEqual(
|
||||
restored.calls.map((call) => call.type),
|
||||
["go-root"],
|
||||
"冷启动时已有会话必须直接进入家谱根页",
|
||||
);
|
||||
assert.strictEqual(restored.calls[0].pageId, "G01");
|
||||
assert.strictEqual(restored.authenticationCommitted.value, true);
|
||||
|
||||
const success = createHarness();
|
||||
await preparePassword(success);
|
||||
assert.strictEqual(countCalls(success, "password-login"), 0);
|
||||
|
||||
@@ -34,6 +34,9 @@ foreach ($required in @(
|
||||
'a02-agreement-unchecked.png',
|
||||
'a02-agreement-checked.png',
|
||||
'v-model.trim="phone"',
|
||||
'v-model.trim="nickName"',
|
||||
'for="a04-nickname"',
|
||||
'class="required-mark"',
|
||||
'v-model.trim="verificationCode"',
|
||||
'v-model="password"',
|
||||
'v-model="confirmPassword"',
|
||||
|
||||
@@ -112,7 +112,7 @@ const run = async () => {
|
||||
|
||||
await valueOf(send, `(() => {
|
||||
const inputs = document.querySelectorAll('.auth-input input')
|
||||
const values = ['13800138000', '1234', 'demo-password', 'demo-password']
|
||||
const values = ['13800138000', '联调昵称', '1234', 'demo-password', 'demo-password']
|
||||
inputs.forEach((input, index) => {
|
||||
input.value = values[index]
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
@@ -1,77 +1,36 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$activePaths = @($pages.pages | ForEach-Object { "$($_.path).vue" })
|
||||
$moduleConsumers = @()
|
||||
$remoteBusinessOwners = @(
|
||||
'pages/auth/a01-entry.vue',
|
||||
'pages/auth/a04-register.vue',
|
||||
'pages/auth/a05-reset-password.vue',
|
||||
'pages/genealogy/g01-my-genealogies.vue',
|
||||
'pages/genealogy/g05-genealogy-overview.vue',
|
||||
'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/profile/m04-change-password.vue',
|
||||
'pages/profile/m07-feedback.vue',
|
||||
'pages/profile/m10-about-settings.vue'
|
||||
)
|
||||
|
||||
foreach ($relativePath in $activePaths) {
|
||||
$fullPath = Join-Path $root $relativePath
|
||||
if (-not (Test-Path -LiteralPath $fullPath)) { throw "Missing active page: $relativePath" }
|
||||
$source = Get-Content -LiteralPath $fullPath -Raw -Encoding UTF8
|
||||
if ($source -match '<ModulePage(?:\s|/|>)|import\s+ModulePage\s+from') { $moduleConsumers += $relativePath }
|
||||
$usesRemoteBusiness = $source -match '@/utils/api\.js|\bappApi\b'
|
||||
if ($usesRemoteBusiness -and $relativePath -notin $remoteBusinessOwners) {
|
||||
throw "$relativePath must remain local-design only until its own tested interface batch"
|
||||
if ($source -match '<ModulePage(?:\s|/|>)|import\s+ModulePage\s+from') {
|
||||
throw "Active page must own its business content instead of ModulePage: $relativePath"
|
||||
}
|
||||
if (-not $usesRemoteBusiness -and $relativePath -in $remoteBusinessOwners) {
|
||||
throw "$relativePath lost its owned remote business interface"
|
||||
if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') {
|
||||
throw "$relativePath must use project feedback components"
|
||||
}
|
||||
if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') { throw "$relativePath must use project feedback components" }
|
||||
}
|
||||
|
||||
if ($moduleConsumers.Count -gt 0) {
|
||||
throw "Active pages must own business content instead of ModulePage: $($moduleConsumers -join ', ')"
|
||||
$recordContracts = [ordered]@{
|
||||
'pages/records/r03-gift-list.vue' = @('appApi.getRelativeRecords', 'appApi')
|
||||
'pages/records/r04-gift-editor.vue' = @('appApi.createRelativeRecord', 'pickAndUploadImage')
|
||||
'pages/records/r05-ritual-list.vue' = @('appApi.getCeremonies', 'appApi')
|
||||
'pages/records/r06-ritual-detail.vue' = @('appApi.getCeremonyDetail', 'appApi')
|
||||
'pages/records/r07-ritual-editor.vue' = @('appApi.createCeremony', 'appApi')
|
||||
'pages/records/r08-growth-journal.vue' = @('appApi.getGrowthRecords', 'appApi.createGrowthRecord')
|
||||
'pages/records/r10-memo-list.vue' = @('appApi.getMemos', 'appApi.createMemo')
|
||||
'pages/records/r11-merit-records.vue' = @('appApi.getMeritRecords', 'appApi.createMeritRecord')
|
||||
}
|
||||
|
||||
$contracts = [ordered]@{
|
||||
'pages/family/f03-feed-detail.vue' = @('feedComments', 'commentDraft', 'submitComment', 'feed-state--expired')
|
||||
'pages/family/f04-article-list.vue' = @('articleCategories', 'filteredArticles', 'openArticle', 'createArticle')
|
||||
'pages/family/f05-article-detail.vue' = @('articleParagraphs', 'disabled label=', 'article-state--expired', 'backToArticles')
|
||||
'pages/family/f07-album-list.vue' = @('albums', 'openAlbum', 'createAlbum', 'album-state--empty')
|
||||
'pages/records/r03-gift-list.vue' = @('relativeRecords', 'openRelative', 'createRelativePreview', 'relative-state--empty')
|
||||
'pages/records/r04-gift-editor.vue' = @('relativeForm', 'validateRelative', 'localRelativePreview', 'relativeId')
|
||||
'pages/records/r05-ritual-list.vue' = @('ceremonies', 'openCeremony', 'createCeremonyPreview', 'ceremony-state--empty')
|
||||
'pages/records/r06-ritual-detail.vue' = @('ceremonyDetail', 'invitees', 'editCeremony', 'ceremony-state--expired')
|
||||
'pages/records/r07-ritual-editor.vue' = @('ceremonyForm', 'validateCeremony', 'localCeremonyPreview', 'ceremonyId')
|
||||
'pages/records/r08-growth-journal.vue' = @('growthRecords', 'recordGrowth', 'localGrowthPreview', 'timeline-state--empty')
|
||||
'pages/records/r09-life-events.vue' = @('人生事件接口尚未开放', 'serviceState', 'requestBack')
|
||||
'pages/records/r10-memo-list.vue' = @('memos', 'createMemoPreview', 'localMemoPreview', 'memo-state--empty')
|
||||
'pages/records/r11-merit-records.vue' = @('meritRecords', 'createMeritPreview', 'localMeritPreview', 'totalContribution')
|
||||
'pages/notification/n02-message-detail.vue' = @('findNotificationFixture', 'noticeDetail.unread', 'markAsRead', 'openNoticeTarget', 'notice-state--expired')
|
||||
'pages/profile/m02-edit-profile.vue' = @('profileForm', 'chooseAvatar', 'validateProfile', 'saveProfile')
|
||||
'pages/profile/m03-security-settings.vue' = @('securityItems', 'openSecurityItem', 'device-state--limited', 'currentUser.phone', 'checkSecurity')
|
||||
'pages/profile/m04-change-password.vue' = @('passwordForm', 'validatePassword', 'togglePassword', 'savePassword')
|
||||
'pages/profile/m05-change-phone.vue' = @('phoneForm', 'sendCode', 'validatePhone', 'phone-state--saving', 'savePhone')
|
||||
'pages/profile/m06-help-center.vue' = @('helpCategories', 'filteredQuestions', 'toggleQuestion', 'contactSupport')
|
||||
'pages/profile/m07-feedback.vue' = @('feedbackForm', 'feedbackTypes', 'validateFeedback', 'submitFeedback')
|
||||
'pages/profile/m08-promotion.vue' = @('inviteState', 'openInviteExplanation', 'explanationVisible', 'share-state--unavailable')
|
||||
'pages/profile/m09-vip-orders.vue' = @('serviceBenefits', 'orderState', 'order-state--unavailable', 'openServiceNotice')
|
||||
'pages/profile/m10-about-settings.vue' = @('agreementItems', 'openAgreement', 'confirmLogout', 'appVersion')
|
||||
}
|
||||
|
||||
foreach ($entry in $contracts.GetEnumerator()) {
|
||||
foreach ($entry in $recordContracts.GetEnumerator()) {
|
||||
$source = Get-Content -LiteralPath (Join-Path $root $entry.Key) -Raw -Encoding UTF8
|
||||
foreach ($token in $entry.Value) {
|
||||
if (-not $source.Contains($token)) { throw "$($entry.Key) missing page-owned contract: $token" }
|
||||
}
|
||||
foreach ($shared in @('ModulePageBackground', 'PageHeader')) {
|
||||
if (-not $source.Contains($shared)) { throw "$($entry.Key) must consume shared visual primitive: $shared" }
|
||||
if (-not $source.Contains($token)) { throw "$($entry.Key) missing active interface owner: $token" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const origin = process.argv[2] || "http://localhost:5173";
|
||||
const cdpPort = process.env.CDP_PORT || "9222";
|
||||
const genealogyId = process.env.GENEALOGY_ID || "2080557121112465409";
|
||||
const feedId = process.env.FEED_ID || "2080572776100487169";
|
||||
const articleId = process.env.ARTICLE_ID || "2080573188207632386";
|
||||
const albumId = process.env.ALBUM_ID || "2080573946172891138";
|
||||
const captureDirectory = path.join(__dirname, "..", "tmp", "all-page-audit");
|
||||
const routeStart = Number(process.env.PAGE_AUDIT_START || 0);
|
||||
const routeEnd = Number(process.env.PAGE_AUDIT_END || 0);
|
||||
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
const pageExpectations = {
|
||||
"pages/auth/a01-entry": { title: "", noReload: true, skipTitle: true },
|
||||
"pages/auth/a04-register": { title: "注册账号" },
|
||||
"pages/auth/a05-reset-password": { title: "重设密码" },
|
||||
"pages/genealogy/g01-my-genealogies": { title: "我的家谱", capture: "01-genealogies.png" },
|
||||
"pages/genealogy/g03-create-genealogy": { title: "创建家谱", capture: "02-create-genealogy.png" },
|
||||
"pages/genealogy/g05-genealogy-overview": { title: "家谱总览", query: `genealogyId=${genealogyId}` },
|
||||
"pages/genealogy/g06-search-genealogies": { title: "搜索家谱" },
|
||||
"pages/genealogy/g08-join-application": { title: "申请加入", query: `genealogyId=${genealogyId}` },
|
||||
"pages/genealogy/g09-my-applications": { title: "我的申请" },
|
||||
"pages/genealogy/g10-application-review": { title: "申请审核", query: `genealogyId=${genealogyId}` },
|
||||
"pages/genealogy/g11-genealogy-settings": { title: "家谱设置", query: `genealogyId=${genealogyId}` },
|
||||
"pages/genealogy/g12-generation-poems": { title: "字辈诗", query: `genealogyId=${genealogyId}` },
|
||||
"pages/tree/t01-tree-overview": { title: "世系树", query: `genealogyId=${genealogyId}`, capture: "03-tree.png" },
|
||||
"pages/tree/t03-member-profile": { title: "成员档案", query: `genealogyId=${genealogyId}&state=error` },
|
||||
"pages/tree/t04-add-relative": { title: "录入首位成员", query: `genealogyId=${genealogyId}&mode=first` },
|
||||
"pages/tree/t05-edit-member": { title: "编辑成员", query: `genealogyId=${genealogyId}&state=error` },
|
||||
"pages/tree/t06-edit-relationship": { title: "调整排行", query: `genealogyId=${genealogyId}&mode=rank&state=error` },
|
||||
"pages/tree/t07-member-directory": { title: "成员目录", query: `genealogyId=${genealogyId}` },
|
||||
"pages/tree/t08-member-states": { title: "成员状态", query: `genealogyId=${genealogyId}&state=error` },
|
||||
"pages/family/f01-family-feed": { title: "家族动态", query: `genealogyId=${genealogyId}`, capture: "04-feed.png" },
|
||||
"pages/family/f02-publish-feed": { title: "发布动态", query: `genealogyId=${genealogyId}` },
|
||||
"pages/family/f03-feed-detail": { title: "动态详情", query: `genealogyId=${genealogyId}&feedId=${feedId}` },
|
||||
"pages/family/f04-article-list": { title: "谱文", query: `genealogyId=${genealogyId}` },
|
||||
"pages/family/f05-article-detail": { title: "谱文详情", query: `genealogyId=${genealogyId}&articleId=${articleId}` },
|
||||
"pages/family/f06-article-editor": { title: "新建谱文", query: `genealogyId=${genealogyId}` },
|
||||
"pages/family/f07-album-list": { title: "家族相册", query: `genealogyId=${genealogyId}` },
|
||||
"pages/family/f08-album-detail": { title: "相册详情", query: `genealogyId=${genealogyId}&albumId=${albumId}` },
|
||||
"pages/family/f09-media-upload": { title: "添加照片", query: `genealogyId=${genealogyId}&albumId=${albumId}` },
|
||||
"pages/family/f10-video-list": { title: "家族视频", query: `genealogyId=${genealogyId}` },
|
||||
"pages/records/r01-people-list": { title: "人物录", query: `genealogyId=${genealogyId}` },
|
||||
"pages/records/r02-person-detail": { title: "人物详情", query: `genealogyId=${genealogyId}&state=error` },
|
||||
"pages/records/r03-gift-list": { title: "亲友往来", query: `genealogyId=${genealogyId}`, capture: "05-gifts.png" },
|
||||
"pages/records/r04-gift-editor": { title: "新建往来记录", query: `genealogyId=${genealogyId}&mode=create` },
|
||||
"pages/records/r05-ritual-list": { title: "礼仪活动", query: `genealogyId=${genealogyId}` },
|
||||
"pages/records/r06-ritual-detail": { title: "礼仪详情", query: `genealogyId=${genealogyId}&ceremonyId=0` },
|
||||
"pages/records/r07-ritual-editor": { title: "新建礼仪活动", query: `genealogyId=${genealogyId}&mode=create` },
|
||||
"pages/records/r08-growth-journal": { title: "成长记录", query: `genealogyId=${genealogyId}` },
|
||||
"pages/records/r09-life-events": { title: "人生事件", query: `genealogyId=${genealogyId}` },
|
||||
"pages/records/r10-memo-list": { title: "家族备忘", query: `genealogyId=${genealogyId}` },
|
||||
"pages/records/r11-merit-records": { title: "功德记录", query: `genealogyId=${genealogyId}` },
|
||||
"pages/notification/n01-message-center": { title: "消息中心", capture: "06-messages.png" },
|
||||
"pages/notification/n02-message-detail": { title: "消息详情" },
|
||||
"pages/profile/m01-profile-home": { title: "我的", capture: "07-profile.png" },
|
||||
"pages/profile/m02-edit-profile": { title: "编辑资料" },
|
||||
"pages/profile/m03-security-settings": { title: "账号与安全" },
|
||||
"pages/profile/m04-change-password": { title: "修改密码" },
|
||||
"pages/profile/m05-change-phone": { title: "换绑手机号" },
|
||||
"pages/profile/m06-help-center": { title: "帮助中心" },
|
||||
"pages/profile/m07-feedback": { title: "意见反馈" },
|
||||
"pages/profile/m08-promotion": { title: "推广中心" },
|
||||
"pages/profile/m09-vip-orders": { title: "VIP 与订单" },
|
||||
"pages/profile/m10-about-settings": { title: "关于家谱" },
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
const targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json();
|
||||
const page = targets.find((candidate) => candidate.type === "page" && candidate.url.startsWith(`${origin}/`));
|
||||
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`);
|
||||
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener("open", resolve, { once: true });
|
||||
socket.addEventListener("error", reject, { once: true });
|
||||
});
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result);
|
||||
});
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
id += 1;
|
||||
pending.set(id, { resolve, reject });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
return { socket, send };
|
||||
};
|
||||
|
||||
const evaluate = async (send, expression) => (await send("Runtime.evaluate", {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
})).result?.value;
|
||||
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (await evaluate(send, expression)) return;
|
||||
await wait(100);
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
const open = async (send, route, noReload) => {
|
||||
const url = `${origin}/#/${route}${pageExpectations[route].query ? `?${pageExpectations[route].query}` : ""}`;
|
||||
await send("Page.navigate", { url });
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `navigation failed: ${route}`);
|
||||
if (!noReload) {
|
||||
const previousTimeOrigin = await evaluate(send, "performance.timeOrigin");
|
||||
await send("Page.reload");
|
||||
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `reload failed: ${route}`);
|
||||
}
|
||||
await waitFor(send, "document.body && document.body.innerText.length > 0", `page did not render: ${route}`);
|
||||
await wait(700);
|
||||
};
|
||||
|
||||
const capture = async (send, filename) => {
|
||||
fs.mkdirSync(captureDirectory, { recursive: true });
|
||||
const image = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
|
||||
fs.writeFileSync(path.join(captureDirectory, filename), Buffer.from(image.data, "base64"));
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
const allRoutes = require("../pages.json").pages.map((page) => page.path);
|
||||
assert.deepStrictEqual(allRoutes.sort(), Object.keys(pageExpectations).sort(), "route coverage drifted from pages.json");
|
||||
const routes = routeEnd > routeStart ? allRoutes.slice(routeStart, routeEnd) : allRoutes;
|
||||
const { socket, send } = await connect();
|
||||
const results = [];
|
||||
try {
|
||||
await send("Page.enable");
|
||||
await send("Runtime.enable");
|
||||
for (const route of routes) {
|
||||
const expectation = pageExpectations[route];
|
||||
try {
|
||||
await open(send, route, expectation.noReload);
|
||||
const state = await evaluate(send, `(() => {
|
||||
const text = document.body.innerText || "";
|
||||
return {
|
||||
titlePresent: text.includes(${JSON.stringify(expectation.title)}),
|
||||
loading: Boolean(document.querySelector('.app-loading, .uni-loading, .loading-spinner')),
|
||||
textLength: text.length,
|
||||
textStart: text.slice(0, 120),
|
||||
hash: location.hash,
|
||||
};
|
||||
})()`);
|
||||
if (!expectation.skipTitle) assert.strictEqual(state.titlePresent, true, `${route} title missing: ${state.textStart}`);
|
||||
assert.strictEqual(state.loading, false, `${route} remained in a loading state`);
|
||||
if (expectation.capture) await capture(send, expectation.capture);
|
||||
results.push({ route, state: "PASS" });
|
||||
} catch (error) {
|
||||
results.push({ route, state: "FAIL", reason: error.message });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
for (const result of results) {
|
||||
process.stdout.write(`${result.state} ${result.route}${result.reason ? ` — ${result.reason}` : ""}\n`);
|
||||
}
|
||||
const failures = results.filter((result) => result.state === "FAIL");
|
||||
if (failures.length) throw new Error(`${failures.length}/${results.length} routed pages failed to reach a stable expected state`);
|
||||
process.stdout.write(`ALL-PAGE-ROUTE-RUNTIME-SMOKE PASS (${results.length} pages)\n`);
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -70,7 +70,7 @@ if ($tabButtons.Count -ne 2) { throw 'A01 两个登录方式必须都是原生 t
|
||||
if (@($tabButtons | Where-Object { $_.Value.Contains(':aria-selected=') }).Count -ne 2) { throw 'A01 两个 tab 都必须声明选中状态' }
|
||||
foreach ($class in @(
|
||||
'login-tab', 'password-toggle', 'get-code', 'forgot-password', 'login-submit',
|
||||
'wechat-login', 'register-link', 'agreement-toggle', 'agreement-link'
|
||||
'register-link', 'agreement-toggle', 'agreement-link'
|
||||
)) { Require-ButtonClass $a01 $class 'A01' }
|
||||
if (@($a01Buttons | Where-Object { $_.Value -match 'class="[^"]*\bagreement-link\b' }).Count -ne 2) { throw 'A01 两份协议必须各自可聚焦' }
|
||||
foreach ($name in @('手机号', '登录密码', '短信验证码')) {
|
||||
|
||||
@@ -11,6 +11,8 @@ const run = async () => {
|
||||
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
|
||||
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
|
||||
const savedTokens = [];
|
||||
const clearedSessions = [];
|
||||
const relaunchedRoutes = [];
|
||||
const prelude = `
|
||||
const currentUser = {};
|
||||
const genealogies = [];
|
||||
@@ -45,14 +47,21 @@ const run = async () => {
|
||||
const session = {
|
||||
getToken: () => "",
|
||||
saveToken: (token) => globalThis.__savedTokens.push(token),
|
||||
clear: () => globalThis.__clearedSessions.push("cleared"),
|
||||
};
|
||||
`;
|
||||
|
||||
globalThis.__savedTokens = savedTokens;
|
||||
globalThis.__clearedSessions = clearedSessions;
|
||||
globalThis.__relaunchedRoutes = relaunchedRoutes;
|
||||
const requests = [];
|
||||
let nextResponse = null;
|
||||
let holdResponse = false;
|
||||
globalThis.uni = {
|
||||
reLaunch(options) {
|
||||
globalThis.__relaunchedRoutes.push(options.url);
|
||||
options.complete?.();
|
||||
},
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
const task = {
|
||||
@@ -104,6 +113,16 @@ const run = async () => {
|
||||
respond({ statusCode: 200, data: { code: 200 } });
|
||||
assert.strictEqual(await sendSms(), null, "RVoid 未声明 data 必填,省略 data 仍必须解析为 null");
|
||||
assert.strictEqual(requests.at(-1).header.clientid, "client-1");
|
||||
assert.strictEqual(requests.at(-1).header.tenantId, "000000");
|
||||
|
||||
// 已鉴权读取收到业务 401 时,只清理失效的本地会话;请求仍向调用方失败返回。
|
||||
respond({ statusCode: 200, data: { code: 401, msg: "认证失败", data: null } });
|
||||
await assert.rejects(
|
||||
appApi.getProfile(),
|
||||
(error) => error.code === "BUSINESS_ERROR" && error.businessCode === 401,
|
||||
);
|
||||
assert.deepStrictEqual(clearedSessions, ["cleared"]);
|
||||
assert.deepStrictEqual(relaunchedRoutes, ["/pages/auth/a01-entry"]);
|
||||
|
||||
respond({ statusCode: 200, data: null });
|
||||
await assert.rejects(
|
||||
@@ -156,6 +175,51 @@ const run = async () => {
|
||||
});
|
||||
assert.deepStrictEqual(savedTokens, ["token-1", "token-password"]);
|
||||
|
||||
// 登录接口不携带既有会话,错误凭据不能借由 401 清理其他页面的本地会话。
|
||||
respond({ statusCode: 200, data: { code: 401, msg: "密码错误", data: null } });
|
||||
await assert.rejects(
|
||||
appApi.loginWithPassword({
|
||||
phone: "13800138000",
|
||||
passwordHash: "a".repeat(32),
|
||||
}),
|
||||
(error) => error.code === "BUSINESS_ERROR" && error.businessCode === 401,
|
||||
);
|
||||
assert.deepStrictEqual(clearedSessions, ["cleared"]);
|
||||
assert.deepStrictEqual(relaunchedRoutes, ["/pages/auth/a01-entry"]);
|
||||
|
||||
respond({
|
||||
statusCode: 200,
|
||||
data: { code: 200, msg: "操作成功", data: { access_token: "token-register" } },
|
||||
});
|
||||
const registration = await appApi.registerWithPassword({
|
||||
phone: "13800138000",
|
||||
nickName: " 联调昵称 ",
|
||||
passwordHash: "a".repeat(32),
|
||||
smsCode: "1234",
|
||||
});
|
||||
assert.strictEqual(registration.access_token, "token-register");
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
clientId: "client-1",
|
||||
tenantId: "000000",
|
||||
grantType: "password",
|
||||
phone: "13800138000",
|
||||
password: "a".repeat(32),
|
||||
smsCode: "1234",
|
||||
nickName: "联调昵称",
|
||||
});
|
||||
|
||||
respond({
|
||||
statusCode: 200,
|
||||
data: { code: 200, msg: "操作成功", data: { access_token: "token-register-empty" } },
|
||||
});
|
||||
await appApi.registerWithPassword({
|
||||
phone: "13800138000",
|
||||
nickName: " ",
|
||||
passwordHash: "a".repeat(32),
|
||||
smsCode: "1234",
|
||||
});
|
||||
assert.strictEqual(Object.hasOwn(requests.at(-1).data, "nickName"), false);
|
||||
|
||||
holdResponse = true;
|
||||
const requestController = createRequestController();
|
||||
const cancelled = appApi.sendSmsCode(
|
||||
@@ -175,6 +239,8 @@ const run = async () => {
|
||||
|
||||
delete globalThis.uni;
|
||||
delete globalThis.__savedTokens;
|
||||
delete globalThis.__clearedSessions;
|
||||
delete globalThis.__relaunchedRoutes;
|
||||
process.stdout.write("AUTH-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ foreach ($token in @('lang="renderjs"', './static/tac/css/tac.css', './static/ta
|
||||
Require-Text -Content $component -Text $token -Label 'TacVerification'
|
||||
}
|
||||
Reject-Text -Content $component -Text 'config.doSendRequest = this.sendStrictRequest' -Label '失去 renderjs 实例上下文的传输函数'
|
||||
$staleGuard = 'if (generation !== this.generation || !this.context || this.context.visible !== true) return;'
|
||||
$staleGuard = 'if (generation !== this.generation || !this.requestContext || this.requestContext.visible !== true) return;'
|
||||
if ([regex]::Matches($component, [regex]::Escape($staleGuard)).Count -lt 2) {
|
||||
throw 'TacVerification 必须在资源加载成功与失败两条分支都拒绝过期代次'
|
||||
}
|
||||
|
||||
@@ -34,8 +34,10 @@ foreach ($page in $pages.pages) {
|
||||
}
|
||||
}
|
||||
|
||||
$manifest = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'manifest.json') | ConvertFrom-Json
|
||||
if ($manifest.vueVersion -eq '3' -and -not (Test-Path -LiteralPath (Join-Path $root 'index.html'))) {
|
||||
$manifestPath = Join-Path $root 'manifest.json'
|
||||
$manifestVueVersion = & node.exe -e 'process.stdout.write(String(require(process.argv[1]).vueVersion||String()))' $manifestPath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'manifest.json 不是有效 JSON' }
|
||||
if ($manifestVueVersion -eq '3' -and -not (Test-Path -LiteralPath (Join-Path $root 'index.html'))) {
|
||||
$errors.Add('Vue 3 project is missing index.html.')
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ $ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
function Read-Utf8([string]$relativePath) {
|
||||
return Get-Content -LiteralPath (Join-Path $root $relativePath) -Raw -Encoding UTF8
|
||||
Get-Content -LiteralPath (Join-Path $root $relativePath) -Raw -Encoding UTF8
|
||||
}
|
||||
|
||||
function Assert-Contains([string]$source, [string]$expected, [string]$page) {
|
||||
@@ -12,39 +12,29 @@ function Assert-Contains([string]$source, [string]$expected, [string]$page) {
|
||||
|
||||
$contracts = [ordered]@{
|
||||
'pages/family/f03-feed-detail.vue' = @(
|
||||
'feedComments', 'commentDraft', 'submitComment', 'feed-state--expired',
|
||||
'genealogyId', 'feedId', 'comment-state--validating', 'comment-state--preview',
|
||||
'findFamilyFeedFixture', 'returnTo("F01", { genealogyId: genealogyId.value })'
|
||||
'feedState', 'appApi.getFeedDetail', 'appApi.getFeedComments', 'appApi.createFeedComment',
|
||||
'feedTypeLabel(feed.type)', 'returnTo("F01", { genealogyId: genealogyId.value })'
|
||||
)
|
||||
'pages/family/f04-article-list.vue' = @(
|
||||
'articleCategories', 'filteredArticles', 'openArticle', 'createArticle',
|
||||
'article-list-state--loading', 'article-list-state--empty', 'article-list-state--error',
|
||||
'listFamilyArticleFixtures', 'openPage(', 'genealogyId: genealogyId.value'
|
||||
'listState', 'appApi.getArticles', 'openArticle', 'createArticle',
|
||||
'openPage("F05"', 'openPage("F06"'
|
||||
)
|
||||
'pages/family/f05-article-detail.vue' = @(
|
||||
'articleParagraphs', 'disabled label=', 'article-state--expired', 'backToArticles',
|
||||
'articleId', 'article-state--privacy', 'article-state--error',
|
||||
'findFamilyArticleFixture', 'returnTo("F04", { genealogyId: genealogyId.value })'
|
||||
'articleState', 'appApi.getArticleDetail', 'article-state--${articleState}',
|
||||
'returnTo("F04", { genealogyId: genealogyId.value })'
|
||||
)
|
||||
'pages/family/f07-album-list.vue' = @(
|
||||
'albums', 'openAlbum', 'createAlbum', 'album-state--empty',
|
||||
'album-list-state--loading', 'album-list-state--error', 'albumNameDraft',
|
||||
'listFamilyAlbumFixtures', 'localAlbumPreview', 'openPage('
|
||||
'listState', 'appApi.getAlbums', 'appApi.createAlbum', 'openAlbum',
|
||||
'pickAndUploadImage', 'openPage("F08"'
|
||||
)
|
||||
}
|
||||
|
||||
foreach ($entry in $contracts.GetEnumerator()) {
|
||||
$source = Read-Utf8 $entry.Key
|
||||
foreach ($anchor in $entry.Value) { Assert-Contains $source $anchor $entry.Key }
|
||||
foreach ($required in @('ModulePageBackground', 'PageHeader', 'AppButton', 'AppLoading')) {
|
||||
Assert-Contains $source $required $entry.Key
|
||||
foreach ($forbidden in @('listFamilyArticleFixtures', 'findFamilyArticleFixture', 'listFamilyAlbumFixtures', 'localAlbumPreview', 'feed-detail-contract-note', 'data/mock')) {
|
||||
if ($source.Contains($forbidden)) { throw "$($entry.Key) retains retired F business-flow implementation: $forbidden" }
|
||||
}
|
||||
foreach ($forbidden in @('import ModulePage from', 'uni.showToast', 'uni.showModal', 'uni.navigateTo', 'uni.redirectTo', 'uni.reLaunch', '/pages/', 'finishPage(')) {
|
||||
if ($source.Contains($forbidden)) { throw "$($entry.Key) retains forbidden implementation: $forbidden" }
|
||||
}
|
||||
if ($source -match '<ModulePage(?:\s|/|>)') { throw "$($entry.Key) retains forbidden ModulePage owner" }
|
||||
if ($source -match '(?im)(?<![-\w])position\s*:') { throw "$($entry.Key) ordinary content must not use position" }
|
||||
if ($source -match '(?im)overflow\s*:\s*hidden') { throw "$($entry.Key) must not clip data-driven content" }
|
||||
}
|
||||
|
||||
Write-Output 'F-BUSINESS-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -82,13 +82,10 @@ const run = async () => {
|
||||
await click(send, ".article-card");
|
||||
await waitFor(send, "location.hash.includes('/pages/family/f05-article-detail?genealogyId=1001&articleId=101')", "F04 card did not open composite-identity F05");
|
||||
|
||||
await open(send, "/pages/family/f03-feed-detail?genealogyId=1001&feedId=1", ".feed-comment-form textarea");
|
||||
const before = await valueOf(send, "document.querySelectorAll('.feed-comment-card').length");
|
||||
await setInput(send, ".feed-comment-form textarea", "愿家人岁岁平安,常聚常新。");
|
||||
await click(send, ".feed-comment-form .app-button");
|
||||
await waitFor(send, "Boolean(document.querySelector('.comment-state--preview'))", "F03 did not enter comment preview");
|
||||
if ((await valueOf(send, "document.querySelectorAll('.feed-comment-card').length")) !== before) throw new Error("F03 appended an unsubmitted comment");
|
||||
if ((await valueOf(send, "document.querySelector('.feed-comment-form textarea').value")) !== "愿家人岁岁平安,常聚常新。") throw new Error("F03 cleared the unsubmitted comment draft");
|
||||
await open(send, "/pages/family/f03-feed-detail?genealogyId=1001&feedId=1", ".feed-detail-contract-note");
|
||||
if (!(await valueOf(send, "document.querySelector('.feed-detail-contract-note')?.textContent.includes('不读取宽接口')"))) {
|
||||
throw new Error("F03 did not disclose its contract-safe unavailable state");
|
||||
}
|
||||
|
||||
await open(send, "/pages/family/f07-album-list?genealogyId=1001", ".album-list > .app-button");
|
||||
const albumCount = await valueOf(send, "document.querySelectorAll('.album-card').length");
|
||||
|
||||
@@ -1,61 +1,45 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Read-Utf8([string]$Path) {
|
||||
[System.IO.File]::ReadAllText(
|
||||
(Join-Path (Join-Path $PSScriptRoot '..') $Path),
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
}
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/family/f06-article-editor.vue') -Raw -Encoding UTF8
|
||||
|
||||
function Assert-Contains {
|
||||
param([string]$Content, [string]$Expected, [string]$Message)
|
||||
if (-not $Content.Contains($Expected)) { throw $Message }
|
||||
function Assert-Contains([string]$expected, [string]$message) {
|
||||
if (-not $page.Contains($expected)) { throw $message }
|
||||
}
|
||||
|
||||
$page = Read-Utf8 'pages/family/f06-article-editor.vue'
|
||||
|
||||
foreach ($expected in @(
|
||||
'class="article-editor-page"',
|
||||
'article-editor-state--${editorState}',
|
||||
'<textarea',
|
||||
'v-model="form.title"',
|
||||
'v-model="form.category"',
|
||||
'v-model="form.content"',
|
||||
'auto-height',
|
||||
'fieldErrors.title',
|
||||
'fieldErrors.category',
|
||||
'fieldErrors.content',
|
||||
'const editorState = ref("form");',
|
||||
'const allowedStates = new Set([',
|
||||
'"draft"',
|
||||
'"loading"',
|
||||
'"validation"',
|
||||
'"error"',
|
||||
'"preview"',
|
||||
'const isSubmitting = ref(false);',
|
||||
'if (isSubmitting.value || !hasValidContext.value) return;',
|
||||
'isSubmitting.value = true;',
|
||||
'editorState.value = submitSnapshot ? "preview" : "error";',
|
||||
':disabled="isSubmitting"',
|
||||
'findFamilyArticleFixture',
|
||||
'appApi.createArticle(',
|
||||
'v-model="form.articleTitle"',
|
||||
'v-model="form.articleSummary"',
|
||||
'v-model="form.articleContent"',
|
||||
'v-model="form.authorName"',
|
||||
'v-model="form.sortOrder"',
|
||||
'pickAndUploadImage',
|
||||
'toConsumerOssId',
|
||||
'coverOssId: coverOssId.value',
|
||||
'class="required-mark"',
|
||||
'editor-control--unavailable',
|
||||
'createDiscardConfirmation',
|
||||
'runBackGuard',
|
||||
'onUnload(() => {',
|
||||
'onUnmounted(() =>',
|
||||
'ModulePageBackground',
|
||||
'AppLoading',
|
||||
'AppDialog',
|
||||
'AppButton',
|
||||
'@include adaptive.adaptive-family-panel;',
|
||||
'@include adaptive.adaptive-family-field;'
|
||||
)) {
|
||||
Assert-Contains $page $expected "F06 dedicated editor contract missing: $expected"
|
||||
Assert-Contains $expected "F06 article editor contract missing: $expected"
|
||||
}
|
||||
|
||||
foreach ($forbidden in @('<template><ModulePage', 'import ModulePage from', 'page-id="f06"', '@/utils/api.js', 'uni.showToast', 'uni.showModal', 'class="editor-panel__skin"', '"saving"', '"success"', 'finishPage(')) {
|
||||
if ($page.Contains($forbidden)) { throw "F06 retains forbidden dependency: $forbidden" }
|
||||
if ($page -match 'v-model="form\.(categoryId|status)"') {
|
||||
throw 'F06 must not ask users to enter category or status codes'
|
||||
}
|
||||
if ($page -match 'placeholder="[^"]*(分类 ID|状态码|例如:0)') {
|
||||
throw 'F06 must not expose raw category or status code inputs'
|
||||
}
|
||||
|
||||
if ($page -match 'border-image-slice\s*:') { throw 'F06 must consume border-image geometry from the adaptive profile owner' }
|
||||
|
||||
if ($page -match 'position\s*:') { throw 'F06 must keep editor content in document flow' }
|
||||
|
||||
Write-Output 'F06-ARTICLE-EDITOR-CONTRACT PASS'
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/family/f06-article-editor.vue') -Raw -Encoding UTF8
|
||||
|
||||
foreach ($token in @(
|
||||
'genealogyId.value = String(query.genealogyId || "");',
|
||||
'articleId.value = String(query.articleId || "");',
|
||||
'editorMode.value = String(query.mode || "");',
|
||||
'(editorMode.value === "create" && !articleId.value)',
|
||||
'(editorMode.value === "edit" && Boolean(article))',
|
||||
'findFamilyArticleFixture(genealogyId.value, articleId.value)',
|
||||
'returnTo("F05", {',
|
||||
'returnTo("F04", { genealogyId: genealogyId.value })'
|
||||
'genealogyId.value = String(query?.genealogyId || "");',
|
||||
'query?.mode !== "create"',
|
||||
'const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));',
|
||||
'returnTo("F04", { genealogyId: genealogyId.value })',
|
||||
'appApi.createArticle(',
|
||||
'createRequestController',
|
||||
'isRequestCancelled'
|
||||
)) {
|
||||
if (-not $page.Contains($token)) { throw "F06 editor context missing: $token" }
|
||||
}
|
||||
foreach ($forbidden in @('simulateSaveFailure', 'form.title.trim() === "保存失败"', 'uni.redirectTo', '/pages/')) {
|
||||
if ($page.Contains($forbidden)) { throw "F06 must not retain a context or navigation bypass: $forbidden" }
|
||||
foreach ($forbidden in @('findFamilyArticleFixture', 'form.category', 'form.status', 'uni.redirectTo', '/pages/')) {
|
||||
if ($page.Contains($forbidden)) { throw "F06 must not retain a context, code-input, or navigation bypass: $forbidden" }
|
||||
}
|
||||
Write-Output 'F06-EDITOR-CONTEXT-CONTRACT PASS'
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const toDataModuleUrl = (source) =>
|
||||
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
|
||||
const run = async () => {
|
||||
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
|
||||
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
|
||||
const prelude = `
|
||||
const currentUser = {};
|
||||
const genealogies = [];
|
||||
const publicGenealogies = [];
|
||||
const treeMembers = [];
|
||||
const notifications = [];
|
||||
const joinApplications = [];
|
||||
const listFamilyFeedFixtures = () => [];
|
||||
const listFamilyArticleFixtures = () => [];
|
||||
const listFamilyAlbumFixtures = () => [];
|
||||
const listCeremonyFixtures = () => [];
|
||||
const listGrowthRecordFixtures = () => [];
|
||||
const listNotificationFixtures = () => [];
|
||||
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
|
||||
const hasRemoteConfig = () => true;
|
||||
const resolveRuntimeMode = () => "remote";
|
||||
const AUTH_TAC_SCENE = Object.freeze({});
|
||||
const assertSmsCode = (value) => value;
|
||||
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
|
||||
const fromApiGenealogyAccess = () => null;
|
||||
const session = { getToken: () => "session-1", saveToken() {} };
|
||||
`;
|
||||
|
||||
const requests = [];
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: {} } }));
|
||||
return { abort() {} };
|
||||
},
|
||||
};
|
||||
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||
|
||||
await appApi.createFeed("1001", {
|
||||
feedType: " photo ",
|
||||
feedContent: " family memory ",
|
||||
mediaOssIds: "2060000000000000001,2060000000000000002",
|
||||
sortOrder: "0",
|
||||
status: " 0 ",
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
feedType: "photo",
|
||||
feedContent: "family memory",
|
||||
mediaOssIds: "2060000000000000001,2060000000000000002",
|
||||
sortOrder: 0,
|
||||
status: "0",
|
||||
});
|
||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/feeds");
|
||||
|
||||
await appApi.createFeed("1001", { feedContent: "only required" });
|
||||
assert.deepStrictEqual(requests.at(-1).data, { feedContent: "only required" });
|
||||
await assert.rejects(
|
||||
appApi.createFeed("1001", { feedContent: "bad media", mediaOssIds: "1, 2" }),
|
||||
/mediaOssIds/,
|
||||
);
|
||||
|
||||
await appApi.createArticle("1001", {
|
||||
categoryId: "900040001",
|
||||
articleTitle: " title ",
|
||||
articleSummary: " summary ",
|
||||
coverOssId: 900001,
|
||||
articleContent: " body ",
|
||||
authorName: " author ",
|
||||
sortOrder: "1",
|
||||
status: " 0 ",
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
categoryId: 900040001,
|
||||
articleTitle: "title",
|
||||
articleSummary: "summary",
|
||||
coverOssId: 900001,
|
||||
articleContent: "body",
|
||||
authorName: "author",
|
||||
sortOrder: 1,
|
||||
status: "0",
|
||||
});
|
||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/articles");
|
||||
|
||||
await appApi.createAlbum("1001", {
|
||||
albumName: " old photos ",
|
||||
albumDesc: " family album ",
|
||||
coverOssId: 900001,
|
||||
sortOrder: "1",
|
||||
status: " 0 ",
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
albumName: "old photos",
|
||||
albumDesc: "family album",
|
||||
coverOssId: 900001,
|
||||
sortOrder: 1,
|
||||
status: "0",
|
||||
});
|
||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/albums");
|
||||
await assert.rejects(
|
||||
appApi.createAlbum("1001", { albumName: "bad id", coverOssId: "9007199254740992" }),
|
||||
/coverOssId/,
|
||||
);
|
||||
|
||||
delete globalThis.uni;
|
||||
process.stdout.write("FAMILY-CREATE-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
function Read-Utf8([string]$relativePath) {
|
||||
Get-Content -Raw -Encoding UTF8 (Join-Path $root $relativePath)
|
||||
}
|
||||
function Assert-Contains([string]$text, [string]$token, [string]$message) {
|
||||
if (-not $text.Contains($token)) { throw $message }
|
||||
}
|
||||
|
||||
$f02 = Read-Utf8 'pages/family/f02-publish-feed.vue'
|
||||
foreach ($token in @('form.feedType', 'mediaReceipts', 'mediaOssIds', 'form.sortOrder', 'pickAndUploadImage', 'class="required-mark"')) {
|
||||
Assert-Contains $f02 $token "F02 is missing full feed field owner: $token"
|
||||
}
|
||||
|
||||
$f06 = Read-Utf8 'pages/family/f06-article-editor.vue'
|
||||
foreach ($token in @('form.articleSummary', 'coverOssId', 'form.authorName', 'form.sortOrder', 'pickAndUploadImage', 'toConsumerOssId', 'class="required-mark"')) {
|
||||
Assert-Contains $f06 $token "F06 is missing full article field owner: $token"
|
||||
}
|
||||
if ($f06 -match 'v-model="(?:form\.)?coverOssId"') { throw 'F06 must not expose a raw cover OSS ID input' }
|
||||
|
||||
$f07 = Read-Utf8 'pages/family/f07-album-list.vue'
|
||||
foreach ($token in @('form.albumDesc', 'coverOssId', 'form.sortOrder', 'pickAndUploadImage', 'toConsumerOssId', 'class="required-mark"')) {
|
||||
Assert-Contains $f07 $token "F07 is missing full album field owner: $token"
|
||||
}
|
||||
if ($f07 -match 'v-model="(?:form\.)?coverOssId"') { throw 'F07 must not expose a raw cover OSS ID input' }
|
||||
|
||||
foreach ($page in @($f02, $f06, $f07)) {
|
||||
if ($page -match 'v-model="form\.(status|categoryId)"') { throw 'Family create forms must not ask users to enter status or category codes' }
|
||||
if ($page -match 'placeholder="[^"]*(分类 ID|状态码|例如:0)') { throw 'Family create forms must not expose raw code placeholders' }
|
||||
}
|
||||
|
||||
$fullWidthOptionalLabel = [string]([char]0xff08) + [char]0x9009 + [char]0x586b + [char]0xff09
|
||||
$asciiOptionalLabel = '(' + [char]0x9009 + [char]0x586b + ')'
|
||||
foreach ($page in @($f02, $f06, $f07)) {
|
||||
if ($page.Contains($fullWidthOptionalLabel) -or $page.Contains($asciiOptionalLabel)) { throw 'Family create forms must not label optional fields as optional' }
|
||||
}
|
||||
|
||||
Write-Output 'FAMILY-CREATE-PAGES-CONTRACT PASS'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const toDataModuleUrl = (source) =>
|
||||
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
|
||||
const run = async () => {
|
||||
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
|
||||
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
|
||||
const prelude = `
|
||||
const currentUser = {};
|
||||
const genealogies = [];
|
||||
const publicGenealogies = [];
|
||||
const treeMembers = [];
|
||||
const notifications = [];
|
||||
const joinApplications = [];
|
||||
const listFamilyFeedFixtures = () => [];
|
||||
const listFamilyArticleFixtures = () => [];
|
||||
const listFamilyAlbumFixtures = () => [];
|
||||
const listCeremonyFixtures = () => [];
|
||||
const listGrowthRecordFixtures = () => [];
|
||||
const listNotificationFixtures = () => [];
|
||||
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
|
||||
const hasRemoteConfig = () => true;
|
||||
const resolveRuntimeMode = () => "remote";
|
||||
const AUTH_TAC_SCENE = Object.freeze({});
|
||||
const assertSmsCode = (value) => value;
|
||||
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
|
||||
const fromApiGenealogyAccess = () => null;
|
||||
const session = { getToken: () => "session-1", saveToken() {} };
|
||||
`;
|
||||
|
||||
const requests = [];
|
||||
let response;
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
queueMicrotask(() => options.success(response));
|
||||
return { abort() {} };
|
||||
},
|
||||
};
|
||||
const uploads = [];
|
||||
const singleUploads = [];
|
||||
globalThis.plus = {
|
||||
uploader: {
|
||||
createUpload(url, options, callback) {
|
||||
const upload = {
|
||||
headers: {},
|
||||
files: [],
|
||||
setRequestHeader(key, value) { this.headers[key] = value; },
|
||||
addFile(filePath, fileOptions) { this.files.push({ filePath, fileOptions }); },
|
||||
start() { queueMicrotask(() => callback({ responseText: JSON.stringify({ code: 200, data: null }) }, 200)); },
|
||||
abort() {},
|
||||
};
|
||||
uploads.push({ url, options, upload });
|
||||
return upload;
|
||||
},
|
||||
},
|
||||
};
|
||||
globalThis.uni.uploadFile = (options) => {
|
||||
singleUploads.push(options);
|
||||
queueMicrotask(() => options.success({
|
||||
statusCode: 200,
|
||||
data: JSON.stringify({ code: 200, data: { ossId: "900002", url: "https://oss.example/single.png", thumbnailUrl: "", fileName: "single.png" } }),
|
||||
}));
|
||||
return { abort() {} };
|
||||
};
|
||||
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||
const basePayload = {
|
||||
uploadId: "upload-1",
|
||||
fileName: "avatar.png",
|
||||
fileMd5: "a".repeat(32),
|
||||
totalSize: 1024,
|
||||
totalChunks: 1,
|
||||
chunkSize: 1024,
|
||||
contentType: "image/png",
|
||||
};
|
||||
const completePayload = {
|
||||
uploadId: basePayload.uploadId,
|
||||
fileName: basePayload.fileName,
|
||||
fileMd5: basePayload.fileMd5,
|
||||
totalSize: basePayload.totalSize,
|
||||
totalChunks: basePayload.totalChunks,
|
||||
};
|
||||
|
||||
response = { statusCode: 200, data: { code: 200, data: { uploadId: "upload-1", instant: true, ossId: 900001 } } };
|
||||
assert.deepStrictEqual(await appApi.initializeResumableUpload(basePayload), {
|
||||
uploadId: "upload-1",
|
||||
instant: true,
|
||||
ossId: "900001",
|
||||
url: "",
|
||||
fileName: "",
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, basePayload);
|
||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/init");
|
||||
|
||||
await appApi.uploadResumableChunk({
|
||||
uploadId: "upload-1",
|
||||
chunkIndex: 0,
|
||||
chunkMd5: "b".repeat(32),
|
||||
filePath: "/storage/emulated/0/avatar.png",
|
||||
});
|
||||
assert.strictEqual(uploads.length, 1);
|
||||
assert.strictEqual(uploads[0].url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/chunk?uploadId=upload-1&chunkIndex=0&chunkMd5=" + "b".repeat(32));
|
||||
assert.deepStrictEqual(uploads[0].upload.headers, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" });
|
||||
assert.deepStrictEqual(uploads[0].upload.files, [{ filePath: "/storage/emulated/0/avatar.png", fileOptions: { key: "file" } }]);
|
||||
|
||||
response = { statusCode: 200, data: { code: 200, data: { ossId: "900001", url: "https://oss.example/avatar.png", thumbnailUrl: "", fileName: "avatar.png" } } };
|
||||
assert.deepStrictEqual(await appApi.completeResumableUpload(completePayload), {
|
||||
ossId: "900001",
|
||||
url: "https://oss.example/avatar.png",
|
||||
thumbnailUrl: "",
|
||||
fileName: "avatar.png",
|
||||
});
|
||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/complete");
|
||||
assert.deepStrictEqual(await appApi.uploadSingleFile({ filePath: "/storage/emulated/0/single.png" }), {
|
||||
ossId: "900002",
|
||||
url: "https://oss.example/single.png",
|
||||
thumbnailUrl: "",
|
||||
fileName: "single.png",
|
||||
});
|
||||
assert.strictEqual(singleUploads.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/upload");
|
||||
assert.strictEqual(singleUploads.at(-1).name, "file");
|
||||
assert.strictEqual(singleUploads.at(-1).filePath, "/storage/emulated/0/single.png");
|
||||
assert.deepStrictEqual(singleUploads.at(-1).header, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" });
|
||||
|
||||
response = { statusCode: 200, data: { code: 200, data: null } };
|
||||
await appApi.createFileReference({
|
||||
bizType: "family_feed",
|
||||
bizName: "family feed image",
|
||||
bizTable: "gen_family_feed",
|
||||
bizId: "900013001",
|
||||
bizField: "media_oss_ids",
|
||||
ossId: "900002",
|
||||
usageScene: "feed_image",
|
||||
});
|
||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/reference");
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
bizType: "family_feed",
|
||||
bizName: "family feed image",
|
||||
bizTable: "gen_family_feed",
|
||||
bizId: 900013001,
|
||||
bizField: "media_oss_ids",
|
||||
ossId: 900002,
|
||||
usageScene: "feed_image",
|
||||
});
|
||||
await appApi.deleteFileReference({ bizTable: "gen_family_feed", bizId: "900013001", bizField: "media_oss_ids" });
|
||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/reference?bizTable=gen_family_feed&bizId=900013001&bizField=media_oss_ids");
|
||||
await assert.rejects(
|
||||
appApi.initializeResumableUpload({ ...basePayload, totalSize: 0 }),
|
||||
/totalSize/,
|
||||
);
|
||||
await assert.rejects(
|
||||
appApi.uploadResumableChunk({ uploadId: "upload-1", chunkIndex: -1, chunkMd5: "b".repeat(32), filePath: "/x" }),
|
||||
/chunkIndex/,
|
||||
);
|
||||
|
||||
delete globalThis.plus;
|
||||
delete globalThis.uni;
|
||||
process.stdout.write("FILE-UPLOAD-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('sexOptions', 'mode="selector"', 'mode="date"', 'ancestorForm.sex', 'auto-height')) {
|
||||
if (-not $page.Contains($token)) { throw "G03 ancestor semantic field missing: $token" }
|
||||
foreach ($token in @('sexOptions', 'mode="selector"', 'mode="date"', 'ancestorForm', 'createLocalGenealogyPreview')) {
|
||||
if ($page.Contains($token)) { throw "G03 must not retain uncommitted first-ancestor flow: $token" }
|
||||
}
|
||||
if (-not $page.Contains('class="create-card__note"')) { throw 'G03 must retain a create-result guidance note' }
|
||||
Write-Output 'G03-ANCESTOR-SEMANTIC-FIELDS-CONTRACT PASS'
|
||||
|
||||
@@ -13,48 +13,49 @@ function Read-RequiredFile {
|
||||
return Get-Content -Raw -Encoding UTF8 -LiteralPath $path
|
||||
}
|
||||
|
||||
function Assert-Contains {
|
||||
param([string]$Content, [string]$Label, [string]$Expected)
|
||||
if (-not $Content.Contains($Expected)) {
|
||||
$script:issues.Add("$Label missing: $Expected")
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-DoesNotContain {
|
||||
param([string]$Content, [string]$Label, [string]$Forbidden)
|
||||
if ($Content.Contains($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'
|
||||
$flowContract = Read-RequiredFile 'tests/g03-create-flow-contract.ps1'
|
||||
$flowRuntime = Read-RequiredFile 'tests/g03-create-flow-runtime-smoke.js'
|
||||
|
||||
# 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 ($forbidden in @(
|
||||
'createGenealogyBootstrapCoordinator',
|
||||
'genealogy-bootstrap-operations',
|
||||
'Idempotency-Key',
|
||||
'/genealogy/app/region/search',
|
||||
'requestStrict(',
|
||||
'appApi.'
|
||||
foreach ($required in @(
|
||||
'appApi.createGenealogy',
|
||||
'finishPage("G01", {}, {',
|
||||
'entityId: created.id',
|
||||
'requestController: createController'
|
||||
)) {
|
||||
Assert-DoesNotContain $page 'G03 page' $forbidden
|
||||
if (-not $page.Contains($required)) { $issues.Add("G03 real create page missing: $required") }
|
||||
}
|
||||
foreach ($required in @(
|
||||
"url: '/genealogy/app/genealogies'",
|
||||
'normalizeCreatedGenealogy',
|
||||
'requestStrict({'
|
||||
)) {
|
||||
if (-not $api.Contains($required)) { $issues.Add("G03 real create API missing: $required") }
|
||||
}
|
||||
foreach ($required in @(
|
||||
'G03-CREATE-FLOW-CONTRACT PASS',
|
||||
'G03-CREATE-FLOW-RUNTIME-SMOKE PASS'
|
||||
)) {
|
||||
$source = if ($required -like '*RUNTIME*') { $flowRuntime } else { $flowContract }
|
||||
if (-not $source.Contains($required)) { $issues.Add("G03 verification missing: $required") }
|
||||
}
|
||||
foreach ($forbidden in @(
|
||||
'createLocalGenealogyPreview',
|
||||
'updateLocalGenealogyPreview',
|
||||
'removeLocalGenealogyPreview',
|
||||
'local-created-',
|
||||
'setTimeout(',
|
||||
'genealogies.unshift(created)'
|
||||
)) {
|
||||
if ($page.Contains($forbidden) -or $api.Contains($forbidden)) {
|
||||
$issues.Add("G03 must not retain a local preview path: $forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
if ($issues.Count -gt 0) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const toDataModuleUrl = (source) =>
|
||||
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
|
||||
const run = async () => {
|
||||
// 该测试只替身网络层:验证真实创建路径发出的 wire,不产生远端写入。
|
||||
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
|
||||
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
|
||||
const prelude = `
|
||||
const currentUser = {};
|
||||
const genealogies = [];
|
||||
const publicGenealogies = [];
|
||||
const treeMembers = [];
|
||||
const notifications = [];
|
||||
const joinApplications = [];
|
||||
const listFamilyFeedFixtures = () => [];
|
||||
const listFamilyArticleFixtures = () => [];
|
||||
const listFamilyAlbumFixtures = () => [];
|
||||
const listCeremonyFixtures = () => [];
|
||||
const listGrowthRecordFixtures = () => [];
|
||||
const listNotificationFixtures = () => [];
|
||||
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
|
||||
const hasRemoteConfig = () => true;
|
||||
const resolveRuntimeMode = () => "remote";
|
||||
const AUTH_TAC_SCENE = Object.freeze({});
|
||||
const assertSmsCode = (value) => value;
|
||||
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
|
||||
const fromApiGenealogyAccess = () => null;
|
||||
const session = { getToken: () => "session-1", saveToken() {} };
|
||||
`;
|
||||
|
||||
const requests = [];
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: { genealogyId: "1001" } } }));
|
||||
return { abort() {} };
|
||||
},
|
||||
};
|
||||
|
||||
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||
await appApi.createGenealogy({
|
||||
genealogyName: "王氏家谱",
|
||||
surname: "王",
|
||||
regionCode: "110101",
|
||||
});
|
||||
|
||||
const request = requests.at(-1);
|
||||
assert.strictEqual(request.method, "POST");
|
||||
assert.strictEqual(request.url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies");
|
||||
assert.deepStrictEqual(request.header, {
|
||||
clientid: "client-1",
|
||||
tenantId: "000000",
|
||||
Authorization: "Bearer session-1",
|
||||
});
|
||||
assert.deepStrictEqual(request.data, {
|
||||
genealogyName: "王氏家谱",
|
||||
surname: "王",
|
||||
regionCode: "110101",
|
||||
});
|
||||
|
||||
delete globalThis.uni;
|
||||
process.stdout.write("G03-CREATE-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,84 +1,76 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Assert-Contains {
|
||||
param([string]$Content, [string]$Expected, [string]$Message)
|
||||
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
|
||||
}
|
||||
|
||||
function Assert-NoCssSurface {
|
||||
param([string]$Content, [string]$ClassName)
|
||||
foreach ($property in @('border', 'border-radius')) {
|
||||
$pattern = "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:"
|
||||
if ($Content -match $pattern) { throw "G03 must not construct .$ClassName with CSS $property" }
|
||||
}
|
||||
}
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
|
||||
$paths = @($pages.pages.path)
|
||||
$g03 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
|
||||
$profiles = Get-Content -LiteralPath (Join-Path $root 'styles/adaptive-frame-profiles.scss') -Raw -Encoding utf8
|
||||
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding utf8
|
||||
$g04 = Join-Path $root 'pages/genealogy/g04-first-ancestor.vue'
|
||||
$panelPath = Join-Path $root 'static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png'
|
||||
|
||||
if (-not ($paths -contains 'pages/genealogy/g03-create-genealogy')) { throw 'G03 route missing' }
|
||||
if ($paths -contains 'pages/genealogy/g04-first-ancestor') { throw 'G04 route must be removed; first-ancestor is a G03 state' }
|
||||
if (Test-Path -LiteralPath $g04) { throw 'G04 page file must be deleted; first-ancestor is a G03 state' }
|
||||
if ($paths -contains 'pages/genealogy/g04-first-ancestor') { throw 'G04 route must remain removed' }
|
||||
if (Test-Path -LiteralPath $g04) { throw 'G04 page file must remain removed' }
|
||||
|
||||
foreach ($required in @(
|
||||
'const currentStep = ref("create");',
|
||||
'const createState = ref("form");',
|
||||
'const ancestorState = ref("form");',
|
||||
'const fieldErrors = reactive({',
|
||||
'const duplicateReminderVisible = ref(false);',
|
||||
'class="duplicate-reminder-layer"',
|
||||
'if (isSubmitting.value) return;',
|
||||
'adaptive.adaptive-genealogy-state-panel',
|
||||
'a01-scroll-primary-v3.png',
|
||||
'create-flow-panel',
|
||||
'flow-primary-action',
|
||||
'import PageHeader from "@/components/PageHeader.vue";',
|
||||
'<PageHeader',
|
||||
'custom-back',
|
||||
'@back="requestBack"'
|
||||
'v-model="form.surname"',
|
||||
'v-model="form.genealogyName"',
|
||||
'getRegionChildren',
|
||||
'selectedRegion',
|
||||
'regionPickerOpen',
|
||||
'picker-view',
|
||||
'picker-view-column',
|
||||
'handleRegionPickerChange',
|
||||
'regionPickerIndicatorStyle',
|
||||
'region-sheet__picker-view',
|
||||
'confirmRegionSelection',
|
||||
'regionPickerColumns',
|
||||
'v-model="form.originPlace"',
|
||||
'v-model="form.addressDetail"',
|
||||
'v-model="form.intro"',
|
||||
'pickAndUploadImage',
|
||||
'toConsumerOssId',
|
||||
'coverOssId.value',
|
||||
'GENEALOGY_ACCESS_PRESET_OPTIONS',
|
||||
'toApiGenealogyAccess',
|
||||
'appApi.createGenealogy',
|
||||
'finishPage("G01", {}, {',
|
||||
'operation: "genealogy-created"',
|
||||
'createRequestController()',
|
||||
'createController.abort()'
|
||||
)) {
|
||||
Assert-Contains -Content $g03 -Expected $required -Message "Missing G03 flow contract: $required"
|
||||
if (-not $g03.Contains($required)) { throw "G03 real-create contract missing: $required" }
|
||||
}
|
||||
foreach ($required in @(
|
||||
'GENEALOGY_ACCESS_PRESET',
|
||||
if ($g03 -match 'v-model="(?:form\.)?regionCode"') { throw 'G03 must not expose a raw region code input' }
|
||||
if ($g03 -match 'v-model="(?:form\.)?coverOssId"') { throw 'G03 must not expose a raw cover OSS ID input' }
|
||||
if ($g03.Contains('AppDialog')) { throw 'G03 region selector must not use the generic dialog card wall' }
|
||||
if ($g03.Contains('继续选择')) { throw 'G03 region selector must not repeat a continuation label on every option' }
|
||||
|
||||
foreach ($forbidden in @(
|
||||
'createLocalGenealogyPreview',
|
||||
'updateLocalGenealogyPreview',
|
||||
'updateLocalGenealogyPreviewAncestor'
|
||||
'removeLocalGenealogyPreview'
|
||||
'removeLocalGenealogyPreview',
|
||||
'local-created-',
|
||||
'create-flow-panel',
|
||||
'flow-success-dialog',
|
||||
'录入首代人物',
|
||||
'Date.now()',
|
||||
'goRegionParent',
|
||||
'region-picker__back',
|
||||
'region-picker-sheet'
|
||||
)) {
|
||||
Assert-Contains -Content $g03 -Expected $required -Message "G03 missing shared genealogy contract: $required"
|
||||
}
|
||||
foreach ($forbidden in @('GENEALOGY_VISIBILITY', 'createForm.visibility', 'SEARCHABLE')) {
|
||||
if ($g03.Contains($forbidden)) { throw "G03 must not retain the old or parallel access contract: $forbidden" }
|
||||
}
|
||||
Assert-Contains -Content $profiles -Expected 'g01-empty-panel-frame.png' -Message 'Adaptive genealogy panel profile must own the G03 panel asset'
|
||||
|
||||
foreach ($forbidden in @("from '@/utils/api.js'", 'appApi.', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet', '/pages/tree/t01-tree-overview?genealogyId=', 'class="flow-header"', 'flow-header__back', 'root-header-cinnabar.jpg')) {
|
||||
if ($g03 -match [regex]::Escape($forbidden)) { throw "G03 retains forbidden implementation: $forbidden" }
|
||||
if ($g03.Contains($forbidden)) { throw "G03 must not retain speculative flow code: $forbidden" }
|
||||
}
|
||||
|
||||
foreach ($className in @('create-flow-panel', 'flow-primary-action')) {
|
||||
Assert-NoCssSurface -Content $g03 -ClassName $className
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $panelPath)) { throw 'G03 requires the accepted genealogy paper panel bitmap' }
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$panel = [System.Drawing.Bitmap]::FromFile($panelPath)
|
||||
try {
|
||||
$corners = @(
|
||||
$panel.GetPixel(0, 0).A,
|
||||
$panel.GetPixel(($panel.Width - 1), 0).A,
|
||||
$panel.GetPixel(0, ($panel.Height - 1)).A,
|
||||
$panel.GetPixel(($panel.Width - 1), ($panel.Height - 1)).A
|
||||
)
|
||||
if (($corners | Where-Object { $_ -ne 0 }).Count -ne 0) { throw 'G03 paper panel outer corners must remain transparent' }
|
||||
} finally {
|
||||
$panel.Dispose()
|
||||
foreach ($required in @(
|
||||
"url: '/genealogy/app/genealogies'",
|
||||
"method: 'POST'",
|
||||
'normalizeGenealogyCreatePayload',
|
||||
'normalizeCreatedGenealogy',
|
||||
'coverOssId',
|
||||
'REMOTE_WRITE_REQUIRED'
|
||||
)) {
|
||||
if (-not $api.Contains($required)) { throw "G03 API contract missing: $required" }
|
||||
}
|
||||
if ($api.Contains('genealogies.unshift(created)')) { throw 'G03 API must not create a local preview record' }
|
||||
|
||||
Write-Output 'G03-CREATE-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -1,179 +1,97 @@
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
const connect = async () => {
|
||||
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
|
||||
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
|
||||
const pages = await (await fetch("http://127.0.0.1:9222/json/list")).json();
|
||||
const page = pages.find((item) => item.type === "page" && item.url.startsWith("http://localhost:5173"));
|
||||
if (!page) throw new Error("Chrome debugging has no localhost:5173 page");
|
||||
|
||||
const socket = new WebSocket(page.webSocketDebuggerUrl)
|
||||
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener('open', resolve, { once: true })
|
||||
socket.addEventListener('error', reject, { once: true })
|
||||
})
|
||||
socket.addEventListener("open", resolve, { once: true });
|
||||
socket.addEventListener("error", reject, { once: true });
|
||||
});
|
||||
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
const exceptions = []
|
||||
socket.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
|
||||
const request = pending.get(message.id)
|
||||
if (!request) return
|
||||
pending.delete(message.id)
|
||||
if (message.error) request.reject(new Error(message.error.message))
|
||||
else request.resolve(message.result)
|
||||
})
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const exceptions = [];
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.method === "Runtime.exceptionThrown") exceptions.push(message.params.exceptionDetails.text);
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
if (message.error) request.reject(new Error(message.error.message));
|
||||
else request.resolve(message.result);
|
||||
});
|
||||
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
id += 1
|
||||
pending.set(id, { resolve, reject })
|
||||
socket.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
|
||||
return { socket, send, exceptions }
|
||||
}
|
||||
id += 1;
|
||||
pending.set(id, { resolve, reject });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
return { socket, send, exceptions };
|
||||
};
|
||||
|
||||
const valueOf = async (send, expression) => {
|
||||
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
|
||||
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
|
||||
return result.result?.value
|
||||
}
|
||||
const result = await send("Runtime.evaluate", { expression, returnByValue: true });
|
||||
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
|
||||
return result.result?.value;
|
||||
};
|
||||
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
if (await valueOf(send, expression)) return
|
||||
await sleep(100)
|
||||
if (await valueOf(send, expression)) return;
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
const setInput = async (send, index, value) => {
|
||||
await waitFor(
|
||||
send,
|
||||
`Boolean(document.querySelectorAll('.flow-fields input')[${index}])`,
|
||||
`G03 input ${index} did not render`
|
||||
)
|
||||
const expression = `(() => {
|
||||
const input = document.querySelectorAll('.flow-fields input')[${index}]
|
||||
if (!input) return false
|
||||
input.value = ${JSON.stringify(value)}
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
return true
|
||||
})()`
|
||||
if (!await valueOf(send, expression)) throw new Error(`Could not fill G03 input ${index}`)
|
||||
}
|
||||
const origin = process.argv[2] || "http://localhost:5173";
|
||||
|
||||
const origin = process.argv[2] || 'http://localhost:5173'
|
||||
const g03Path = '/pages/genealogy/g03-create-genealogy'
|
||||
let navigationId = 0
|
||||
|
||||
const openG03 = async (send, query = '') => {
|
||||
navigationId += 1
|
||||
const url = `${origin}/?g03Audit=${navigationId}#${g03Path}${query}`
|
||||
await send('Page.navigate', { url })
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `G03 navigation failed: ${query || 'default'}`)
|
||||
}
|
||||
|
||||
const openDefaultG03 = async (send) => {
|
||||
await openG03(send)
|
||||
await waitFor(send, "Boolean(document.querySelector('.create-flow-panel .flow-rule'))", 'Default G03 creation step did not render')
|
||||
}
|
||||
|
||||
const advanceToAncestor = async (send, name = '自动验证汤氏家谱') => {
|
||||
await setInput(send, 0, '汤')
|
||||
await setInput(send, 1, name)
|
||||
await setInput(send, 2, '敦本堂')
|
||||
await setInput(send, 3, '河南·洛阳')
|
||||
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.duplicate-reminder-layer'))", 'G03 did not show the duplicate reminder')
|
||||
await valueOf(send, `(() => {
|
||||
window.__g03SubmitTimerCount = 0
|
||||
window.__g03OriginalSetTimeout = window.setTimeout
|
||||
window.setTimeout = (callback, delay, ...args) => {
|
||||
if (delay === 320) window.__g03SubmitTimerCount += 1
|
||||
return window.__g03OriginalSetTimeout(callback, delay, ...args)
|
||||
}
|
||||
return true
|
||||
})()`)
|
||||
await valueOf(send, "(() => { const action = document.querySelector('.duplicate-reminder__confirm'); action.click(); action.click(); return true })()")
|
||||
await setInput(send, 1, '失败')
|
||||
await waitFor(send, "Boolean(document.querySelector('.intro-field'))", 'G03 did not advance to the same-page ancestor step')
|
||||
const timerCount = await valueOf(send, 'window.__g03SubmitTimerCount')
|
||||
await valueOf(send, 'window.setTimeout = window.__g03OriginalSetTimeout; delete window.__g03OriginalSetTimeout')
|
||||
if (timerCount !== 1) throw new Error(`G03 duplicate confirmation created ${timerCount} submit timers`)
|
||||
if (await valueOf(send, "location.hash.includes('step=ancestor')")) {
|
||||
throw new Error('G03 leaked its internal ancestor step into the route')
|
||||
}
|
||||
}
|
||||
const openG03 = async (send, suffix = "") => {
|
||||
const url = `${origin}/?g03CreateAudit=${Date.now()}#${"/pages/genealogy/g03-create-genealogy"}${suffix}`;
|
||||
await send("Page.navigate", { url });
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, "G03 navigation failed");
|
||||
await waitFor(send, "Boolean(document.querySelector('.create-card'))", "G03 create form did not render");
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send, exceptions } = await connect()
|
||||
|
||||
const { socket, send, exceptions } = await connect();
|
||||
try {
|
||||
await send('Page.enable')
|
||||
await send('Runtime.enable')
|
||||
await send("Page.enable");
|
||||
await send("Runtime.enable");
|
||||
await openG03(send);
|
||||
|
||||
// 步骤一:空表单、创建失败和短暂提交态都由真实控件触发,避免旧截图助手伪装成功能测试。
|
||||
await openDefaultG03(send)
|
||||
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
|
||||
await waitFor(send, "document.querySelectorAll('.field-error').length === 3", 'G03 create step did not report all required fields')
|
||||
await setInput(send, 0, '汤')
|
||||
await setInput(send, 1, '失败')
|
||||
await setInput(send, 3, '河南·洛阳')
|
||||
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.duplicate-reminder-layer'))", 'G03 failure case did not show the duplicate reminder')
|
||||
await valueOf(send, "document.querySelector('.duplicate-reminder__confirm').click()")
|
||||
await waitFor(send, "document.querySelector('.flow-primary-action__copy')?.textContent.includes('正在创建')", 'G03 create step did not expose its submitting state')
|
||||
await waitFor(send, "Boolean(document.querySelector('.flow-error'))", 'G03 simulated create failure did not render')
|
||||
|
||||
// 步骤二:只允许从同页第一步进入首代人物,重复确认不能产生第二个提交定时器。
|
||||
await openDefaultG03(send)
|
||||
await advanceToAncestor(send, '首代失败验证家谱')
|
||||
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.field-error'))", 'G03 ancestor step did not validate the required name')
|
||||
await setInput(send, 0, '失败')
|
||||
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
|
||||
await setInput(send, 0, '提交后改名')
|
||||
await waitFor(send, "document.querySelector('.flow-primary-action__copy')?.textContent.includes('正在保存')", 'G03 ancestor step did not expose its submitting state')
|
||||
await waitFor(send, "Boolean(document.querySelector('.flow-error'))", 'G03 simulated ancestor failure did not render')
|
||||
|
||||
// 步骤三:完整成功路径仍需从立谱、重复提醒、首代保存一路进入家谱总览。
|
||||
await openDefaultG03(send)
|
||||
await advanceToAncestor(send)
|
||||
await setInput(send, 0, '汤始祖')
|
||||
await sleep(100)
|
||||
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
|
||||
await setInput(send, 0, '失败')
|
||||
await waitFor(send, "Boolean(document.querySelector('.flow-success-layer'))", 'Saving the first ancestor did not show the custom success result')
|
||||
await valueOf(send, "document.querySelector('.flow-success-dialog__action').click()")
|
||||
await waitFor(send, "location.href.includes('/pages/genealogy/g05-genealogy-overview?genealogyId=local-created-')", 'Saving the first ancestor did not open a unique local G05 preview')
|
||||
await waitFor(send, "Boolean(document.querySelector('.overview-public'))", 'G03 local preview did not render in G05')
|
||||
const previewText = await valueOf(send, "document.querySelector('.overview-public')?.textContent")
|
||||
for (const expected of ['自动验证汤氏家谱', '汤氏', '敦本堂', '河南·洛阳', '汤始祖', '仅成员可见']) {
|
||||
if (!previewText.includes(expected)) throw new Error(`G03 local preview lost submitted field: ${expected}`)
|
||||
}
|
||||
|
||||
await openDefaultG03(send)
|
||||
if (await valueOf(send, "Boolean(document.querySelector('.intro-field'))")) {
|
||||
throw new Error('Default G03 must not render the ancestor step')
|
||||
const text = await valueOf(send, "document.querySelector('.create-card')?.textContent || ''");
|
||||
for (const required of ["立谱信息", "所在地区", "确认创建家谱"]) {
|
||||
if (!text.includes(required)) throw new Error(`G03 create form missing: ${required}`);
|
||||
}
|
||||
const inputCount = await valueOf(send, "document.querySelectorAll('.create-card input').length");
|
||||
if (inputCount !== 5) throw new Error(`G03 expected five text inputs plus region and upload selectors, got ${inputCount}`);
|
||||
const regionSelector = await valueOf(send, "Boolean(document.querySelector('.field-row--selector'))");
|
||||
if (!regionSelector) throw new Error("G03 region selector did not render");
|
||||
const uploadControl = await valueOf(send, "Boolean(document.querySelector('.upload-button'))");
|
||||
if (!uploadControl) throw new Error("G03 cover upload control did not render");
|
||||
const introControl = await valueOf(send, "Boolean(document.querySelector('.create-card textarea'))");
|
||||
if (!introControl) throw new Error("G03 optional intro field did not render");
|
||||
const fakeControl = await valueOf(send, "Boolean(document.querySelector('.flow-success-dialog'))");
|
||||
if (fakeControl) throw new Error("G03 must not expose the retired local bootstrap flow");
|
||||
|
||||
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
|
||||
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
|
||||
await openDefaultG03(send)
|
||||
const width = await valueOf(send, 'document.documentElement.scrollWidth')
|
||||
if (width > size.width + 1) throw new Error(`G03 has horizontal overflow at ${size.width}x${size.height}`)
|
||||
await send("Emulation.setDeviceMetricsOverride", { ...size, deviceScaleFactor: 1, mobile: true });
|
||||
await openG03(send, `&width=${size.width}`);
|
||||
const scrollWidth = await valueOf(send, "document.documentElement.scrollWidth");
|
||||
if (scrollWidth > size.width + 1) throw new Error(`G03 create form has horizontal overflow at ${size.width}x${size.height}`);
|
||||
}
|
||||
if (exceptions.length) throw new Error(`G03 raised browser exceptions: ${exceptions.join('; ')}`)
|
||||
|
||||
process.stdout.write('G03-CREATE-FLOW-RUNTIME-SMOKE PASS\n')
|
||||
if (exceptions.length) throw new Error(`G03 raised browser exceptions: ${exceptions.join("; ")}`);
|
||||
process.stdout.write("G03-CREATE-FLOW-RUNTIME-SMOKE PASS\n");
|
||||
} finally {
|
||||
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
|
||||
socket.close()
|
||||
try { await send("Emulation.clearDeviceMetricsOverride"); } catch (_) {}
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
|
||||
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$positions = [regex]::Matches([regex]::Replace($style, '(?s)/\*.*?\*/', ''), '(?m)\bposition\s*:\s*([^;]+);')
|
||||
if ($positions.Count -ne 1 -or $positions[0].Groups[1].Value.Trim() -ne 'fixed') {
|
||||
throw "G03 must retain only its combined fixed dialog layer rule; found $($positions.Count) position declarations"
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'@include adaptive.adaptive-genealogy-state-panel;',
|
||||
'background: url("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png")',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "G03 is missing real asset background: $required" } }
|
||||
foreach ($forbidden in @('flow-header', 'root-header-cinnabar.jpg')) {
|
||||
if ($page -match [regex]::Escape($forbidden)) { throw "G03 must not retain private header token: $forbidden" }
|
||||
'.create-card',
|
||||
'.field-row',
|
||||
'.region-sheet',
|
||||
'.access-rule__option--active',
|
||||
'.create-card .app-button'
|
||||
)) {
|
||||
if ($page -notmatch [regex]::Escape($required)) { throw "G03 create form missing: $required" }
|
||||
}
|
||||
foreach ($forbidden in @('flow-header', 'root-header-cinnabar.jpg', 'duplicate-reminder', 'flow-success-dialog')) {
|
||||
if ($page -match [regex]::Escape($forbidden)) { throw "G03 must not retain retired flow token: $forbidden" }
|
||||
}
|
||||
Write-Output 'G03-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -8,16 +8,12 @@ function Assert-Match {
|
||||
if ($Content -notmatch $Pattern) { throw $Message }
|
||||
}
|
||||
|
||||
if ($g03 -match '<text>返回</text>') { throw 'G03 header must use only the approved image back arrow' }
|
||||
Assert-Match -Content $g03 -Pattern '(?s)<PageHeader\s+:title="isAncestorStep \? ''\u5F55\u5165\u9996\u4EE3\u4EBA\u7269'' : ''\u521B\u5EFA\u5BB6\u8C31''"\s+custom-back\s+@back="requestBack"\s*/>' -Message 'G03 must use PageHeader with its dynamic title and guarded back handler'
|
||||
if ($g03 -match '<text>返回</text>') { throw 'G03 header must use the shared PageHeader back control' }
|
||||
Assert-Match -Content $g03 -Pattern '(?s)<PageHeader\s+title="[^"]+"\s+custom-back\s+@back="backToGenealogies"\s*/>' -Message 'G03 must use PageHeader with its guarded back handler'
|
||||
if ($g03 -match 'flow-header|flow-header__back|flow-header__title') { throw 'G03 must not retain a private header implementation' }
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.flow-note,\s*\.flow-rule__note\s*\{[^}]*font-size:\s*25rpx;' -Message 'G03 guidance copy must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.flow-rule__option\s*\{[^}]*font-size:\s*23rpx;' -Message 'G03 visibility options must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.field-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line-height:\s*34rpx;' -Message 'G03 field errors must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.flow-error\s*\{[^}]*font-size:\s*25rpx;[^}]*line-height:\s*36rpx;' -Message 'G03 submit errors must use the approved readable size'
|
||||
|
||||
foreach ($asset in @('a01-scroll-dialog-v3.png', 'a01-scroll-primary-v3.png', 'a01-scroll-secondary-v3.png')) {
|
||||
if ($g03 -notmatch [regex]::Escape($asset)) { throw "G03 must retain approved shared asset: $asset" }
|
||||
}
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.create-card__note\s*\{[^}]*font-size:\s*25rpx;' -Message 'G03 guidance copy must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.access-rule__option\s*\{[^}]*font-size:\s*25rpx;' -Message 'G03 visibility options must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.field-error,\s*\.submit-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line-height:\s*1\.5;' -Message 'G03 validation errors must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.create-card\s*\.app-button\s*\{[^}]*margin-top:\s*32rpx;' -Message 'G03 primary action spacing must remain stable'
|
||||
|
||||
Write-Output 'G03-VISUAL-STATES-CONTRACT PASS'
|
||||
|
||||
@@ -2,11 +2,13 @@ $ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$search = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g06-search-genealogies.vue') -Raw -Encoding UTF8
|
||||
$join = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g08-join-application.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('genealogyName:', 'previous:', 'encodeURIComponent(item.name)')) {
|
||||
if ($search.Contains($token)) { throw "G06 must not propagate retired context: $token" }
|
||||
foreach ($required in @('genealogyId: item.id', 'genealogyName: item.name')) {
|
||||
if (-not $search.Contains($required)) { throw "G06 must pass application context: $required" }
|
||||
}
|
||||
foreach ($token in @('previousRelation', 'query.genealogyName', 'query.previous', 'decodeURIComponent', 'genealogyPreview')) {
|
||||
if ($join.Contains($token)) { throw "G08 must not consume retired context: $token" }
|
||||
foreach ($required in @('query?.genealogyId', 'query?.genealogyName', 'appApi.applyToJoin')) {
|
||||
if (-not $join.Contains($required)) { throw "G08 must consume real application context: $required" }
|
||||
}
|
||||
foreach ($retired in @('previousRelation', 'query.previous', 'decodeURIComponent', 'findGenealogyFixture', 'genealogyPreview')) {
|
||||
if ($join.Contains($retired)) { throw "G08 must not consume retired context: $retired" }
|
||||
}
|
||||
if (-not $join.Contains('findGenealogyFixture')) { throw 'G08 must read display identity from the shared genealogy fixture owner' }
|
||||
Write-Output 'G06-G08-CONTEXT-CONTRACT PASS'
|
||||
|
||||
@@ -5,10 +5,14 @@ $style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$positions = [regex]::Matches([regex]::Replace($style, '(?s)/\*.*?\*/', ''), '(?m)\bposition\s*:\s*([^;]+);')
|
||||
if ($positions.Count -ne 0) { throw "G08 join flow must use document flow; found $($positions.Count) position declarations" }
|
||||
foreach ($required in @(
|
||||
'<GenealogyPageBackground />',
|
||||
'@include adaptive.adaptive-genealogy-state-panel;',
|
||||
'@include adaptive.adaptive-genealogy-form-field;',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "G08 is missing real asset background: $required" } }
|
||||
if ($page -match 'class="join-panel__skin"') { throw 'G08 must not retain the decorative panel image layer' }
|
||||
if ($page -notmatch '(?s)<textarea[^>]*auto-height') { throw 'G08 application message must grow from its text data' }
|
||||
'appApi.applyToJoin',
|
||||
'createRequestController',
|
||||
'auto-height'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "G08 is missing: $required" } }
|
||||
foreach ($retired in @('findGenealogyFixture', 'join-panel__skin', 'uni.showToast', 'uni.showModal')) {
|
||||
if ($page -match [regex]::Escape($retired)) { throw "G08 must not retain retired local-preview owner: $retired" }
|
||||
}
|
||||
Write-Output 'G08-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -5,94 +5,33 @@ function Assert-Contains {
|
||||
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
|
||||
}
|
||||
|
||||
function Assert-NoCssSurface {
|
||||
param([string]$Content, [string]$ClassName)
|
||||
foreach ($property in @('border', 'border-radius')) {
|
||||
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") {
|
||||
throw "Application flow must not construct .$ClassName with CSS $property"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$g08 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g08-join-application.vue') -Raw -Encoding utf8
|
||||
$g09 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g09-my-applications.vue') -Raw -Encoding utf8
|
||||
$g10 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g10-application-review.vue') -Raw -Encoding utf8
|
||||
$profiles = Get-Content -LiteralPath (Join-Path $root 'styles/adaptive-frame-profiles.scss') -Raw -Encoding utf8
|
||||
|
||||
foreach ($page in @($g08, $g09, $g10)) {
|
||||
if ($page -match '<ModulePage') { throw 'G08-G10 must not retain the generic ModulePage shell' }
|
||||
Assert-Contains $page '<GenealogyPageBackground />' 'G08-G10 must use the accepted shared genealogy background'
|
||||
if ($page -match "@/utils/api\.js|\bappApi\b") { throw 'G08-G10 design phase must not connect the API layer' }
|
||||
Assert-Contains $page '<GenealogyPageBackground />' 'G08-G10 must use the shared genealogy background'
|
||||
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($page -match [regex]::Escape($nativeUi)) { throw "G08-G10 must not use native UniApp UI: $nativeUi" }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'const joinState = ref("form")',
|
||||
'join-state--form',
|
||||
'join-state--success',
|
||||
'join-state--error',
|
||||
'adaptive.adaptive-genealogy-state-panel',
|
||||
'adaptive.adaptive-genealogy-form-field',
|
||||
'a01-scroll-primary-v3.png',
|
||||
'findGenealogyFixture',
|
||||
'query.genealogyId',
|
||||
'const source = ref("search")',
|
||||
'const sourceContract = computed(() =>',
|
||||
'const fieldErrors = reactive({'
|
||||
)) { Assert-Contains $g08 $required "Missing G08 join contract: $required" }
|
||||
foreach ($assetName in @('g01-empty-panel-frame.png', 'g-form-field-frame.png')) {
|
||||
Assert-Contains $profiles $assetName "Adaptive genealogy profile must own: $assetName"
|
||||
foreach ($required in @('appApi.applyToJoin', 'createRequestController', 'applicantName', 'phone', 'relationDesc', 'applyReason', 'inviterUserId')) {
|
||||
Assert-Contains $g08 $required "G08 must submit the APP join-application contract field: $required"
|
||||
}
|
||||
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($g08 -match [regex]::Escape($nativeUi)) { throw "G08 must not use native UniApp UI: $nativeUi" }
|
||||
}
|
||||
if ($g08 -notmatch '(?s)\.join-field-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line-height:\s*34rpx;') {
|
||||
throw 'G08 inline validation must remain readable over the illustrated panel'
|
||||
foreach ($retired in @('findGenealogyFixture', 'joinSamples', 'LOCAL_WITHDRAWN', 'applicationSamples')) {
|
||||
if ($g08 -match [regex]::Escape($retired)) { throw "G08 must not retain local application fixture state: $retired" }
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'const applicationState = ref("loading")',
|
||||
'application-state--list',
|
||||
'application-state--empty',
|
||||
'application-state--error',
|
||||
'adaptive.adaptive-genealogy-list-card',
|
||||
'"PENDING"',
|
||||
'"APPROVED"',
|
||||
'"REJECTED"',
|
||||
'"LOCAL_WITHDRAWN"',
|
||||
'const applicationSamples',
|
||||
'const actionFor = (item) =>',
|
||||
'const withdrawTarget = ref(null)',
|
||||
'<AppDialog'
|
||||
)) { Assert-Contains $g09 $required "Missing G09 application contract: $required" }
|
||||
Assert-Contains $profiles 'list-slip-frame.png' 'Adaptive genealogy list profile must own the G09 card asset'
|
||||
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($g09 -match [regex]::Escape($nativeUi)) { throw "G09 must not use native UniApp UI: $nativeUi" }
|
||||
foreach ($required in @('appApi.getMyJoinApplications', 'createRequestController', 'AppLoading', 'applications.value.length')) {
|
||||
Assert-Contains $g09 $required "G09 must read real application records: $required"
|
||||
}
|
||||
if ($g09 -match 'applicationSamples|findGenealogyFixture') { throw 'G09 must not display local application fixtures' }
|
||||
|
||||
foreach ($required in @(
|
||||
'const reviewState = ref("loading")',
|
||||
'review-state--list',
|
||||
'review-state--empty',
|
||||
'review-state--error',
|
||||
'adaptive.adaptive-genealogy-list-card',
|
||||
'a01-scroll-primary-v3.png',
|
||||
'a01-scroll-secondary-v3.png',
|
||||
'const reviewSamples',
|
||||
'const confirmation = ref(null)',
|
||||
'const helpVisible = ref(false)',
|
||||
'<AppDialog',
|
||||
'review-state--no-permission'
|
||||
)) { Assert-Contains $g10 $required "Missing G10 review contract: $required" }
|
||||
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($g10 -match [regex]::Escape($nativeUi)) { throw "G10 must not use native UniApp UI: $nativeUi" }
|
||||
foreach ($required in @('appApi.getPendingApplications', 'createRequestController', 'AppLoading', 'applications.value.length')) {
|
||||
Assert-Contains $g10 $required "G10 must read real pending applications: $required"
|
||||
}
|
||||
|
||||
foreach ($className in @('join-panel', 'join-action', 'application-card', 'review-action')) {
|
||||
foreach ($page in @($g08, $g09, $g10)) { Assert-NoCssSurface $page $className }
|
||||
}
|
||||
|
||||
$statusAsset = Join-Path $root 'static/assets/modules/genealogy/transparent/list-slip-frame.png'
|
||||
if (-not (Test-Path -LiteralPath $statusAsset)) { throw 'G09-G10 cards and application empty states require list-slip-frame.png' }
|
||||
if ($g10 -match 'reviewSamples|findGenealogyFixture|auditApplication') { throw 'G10 must not invent audit semantics or local review fixtures' }
|
||||
|
||||
Write-Output 'G08-G10-APPLICATION-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
const origin = process.argv[2] || 'http://localhost:5173'
|
||||
const debugPort = process.env.CDP_PORT || '9222'
|
||||
const genealogyId = process.env.G10_GENEALOGY_ID || ''
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
|
||||
const connect = async () => {
|
||||
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const pages = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json()
|
||||
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
|
||||
if (!page) throw new Error(`Chrome debugging has no ${origin} application page`)
|
||||
const socket = new WebSocket(page.webSocketDebuggerUrl)
|
||||
@@ -34,133 +36,56 @@ const valueOf = async (send, expression) => {
|
||||
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
|
||||
return result.result?.value
|
||||
}
|
||||
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (await valueOf(send, expression)) return
|
||||
await sleep(100)
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
let auditId = 0
|
||||
|
||||
const open = async (send, route, query, selector) => {
|
||||
auditId += 1
|
||||
const url = `${origin}/?g0810Audit=${auditId}#${route}${query}`
|
||||
const url = `${origin}/#${route}${query}`
|
||||
await send('Page.navigate', { url })
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `${route} navigation failed`)
|
||||
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `${route}${query} did not render`)
|
||||
}
|
||||
|
||||
const fillJoinForm = (send, realName, relation) => valueOf(send, `(() => {
|
||||
const inputs = Array.from(document.querySelectorAll('.join-field input'))
|
||||
if (inputs.length < 2) return false
|
||||
const values = [${JSON.stringify(realName)}, ${JSON.stringify(relation)}]
|
||||
inputs.forEach((input, index) => {
|
||||
input.value = values[index]
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
return true
|
||||
})()`)
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send, exceptions } = await connect()
|
||||
try {
|
||||
await send('Page.enable')
|
||||
await send('Runtime.enable')
|
||||
await open(send, '/pages/genealogy/g08-join-application', '?genealogyId=2001', '.join-state--form')
|
||||
await waitFor(send, "document.querySelectorAll('.join-field input').length >= 2", 'G08 real input controls did not render')
|
||||
const prepared = await fillJoinForm(send, '汤明远', '某某某堂侄')
|
||||
if (!prepared) throw new Error('G08 form controls could not be prepared')
|
||||
await valueOf(send, "document.querySelector('.join-action')?.click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.join-state--success'))", 'G08 real form submission did not reach the same-page success state')
|
||||
if (!(await valueOf(send, "document.body.textContent.includes('申请尚未提交服务器')"))) throw new Error('G08 local search preview claimed a server submission')
|
||||
await valueOf(send, "document.querySelector('.join-result .join-action').click()")
|
||||
await waitFor(send, "location.href.includes('/pages/genealogy/g09-my-applications')", 'G08 local search preview did not continue to G09 without a result')
|
||||
|
||||
for (const genealogyId of ['1001', '1002', '2003', 'unknown']) {
|
||||
await open(send, '/pages/genealogy/g08-join-application', `?role=guest&state=success&source=search&genealogyId=${genealogyId}`, '.join-state--ineligible')
|
||||
if (await valueOf(send, "Boolean(document.querySelector('.join-form'))")) throw new Error(`G08 exposed an application form for ineligible genealogy ${genealogyId}`)
|
||||
await open(send, '/pages/genealogy/g08-join-application', '?genealogyId=2001&genealogyName=%E6%B5%8B%E8%AF%95%E5%AE%B6%E8%B0%B1', '.join-form')
|
||||
if ((await valueOf(send, "document.querySelectorAll('.form-field input, .form-field textarea').length")) !== 5) {
|
||||
throw new Error('G08 did not render every APP join-application field')
|
||||
}
|
||||
if (await valueOf(send, "document.querySelector('.form-field .required-mark') !== null")) {
|
||||
throw new Error('G08 marked an optional APP field as required')
|
||||
}
|
||||
|
||||
// 步骤一:失败输入必须经过真实提交态进入错误结果,并能返回原表单重试。
|
||||
await open(send, '/pages/genealogy/g08-join-application', '?source=search&genealogyId=2001', '.join-state--form')
|
||||
if (!await fillJoinForm(send, '失败', '某某某堂侄')) throw new Error('G08 failure controls could not be prepared')
|
||||
await valueOf(send, "document.querySelector('.join-action').click()")
|
||||
await waitFor(send, "document.querySelector('.join-action')?.textContent.includes('正在校验')", 'G08 did not expose its submitting state')
|
||||
await waitFor(send, "Boolean(document.querySelector('.join-state--error'))", 'G08 simulated submission failure did not render')
|
||||
await valueOf(send, "document.querySelector('.join-result .join-action').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.join-state--form'))", 'G08 failure retry did not restore the form')
|
||||
await open(send, '/pages/genealogy/g09-my-applications', '', '.applications-page')
|
||||
await waitFor(send, "!document.querySelector('.app-loading')", 'G09 application read did not settle')
|
||||
if (!await valueOf(send, "Boolean(document.querySelector('.state-card, .application-list'))")) {
|
||||
throw new Error('G09 did not render a settled real-read state')
|
||||
}
|
||||
|
||||
await open(send, '/pages/genealogy/g08-join-application', '?source=invite&genealogyId=2001', '.join-state--form')
|
||||
await valueOf(send, "document.querySelector('.join-action').click()")
|
||||
await waitFor(send, "document.querySelectorAll('.join-field-error').length === 2", 'G08 invite source did not show two inline required errors')
|
||||
const invitePrepared = await fillJoinForm(send, '汤明远', '某某某堂侄')
|
||||
if (!invitePrepared) throw new Error('G08 invite controls could not be prepared')
|
||||
await sleep(100)
|
||||
await valueOf(send, "document.querySelector('.join-action').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.join-state--success'))", 'G08 invite source did not reach success')
|
||||
if (!(await valueOf(send, "document.body.textContent.includes('不会选中或加入这部家谱')"))) throw new Error('G08 invite preview claimed a durable membership change')
|
||||
await valueOf(send, "document.querySelector('.join-result .join-action').click()")
|
||||
await waitFor(send, "location.href.includes('/pages/genealogy/g01-my-genealogies') && !location.href.includes('genealogyId=2001')", 'G08 invite preview selected a genealogy without backend success')
|
||||
|
||||
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
|
||||
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
|
||||
await open(send, '/pages/genealogy/g08-join-application', '?source=search&genealogyId=2001', '.join-state--form')
|
||||
const width = await valueOf(send, 'document.documentElement.scrollWidth')
|
||||
if (width > size.width + 1) {
|
||||
const offenders = await valueOf(send, `Array.from(document.querySelectorAll('*')).map((node) => {
|
||||
const rect = node.getBoundingClientRect()
|
||||
return { tag: node.tagName, className: String(node.className || ''), left: rect.left, right: rect.right, width: rect.width }
|
||||
}).filter((item) => item.left < -1 || item.right > innerWidth + 1).slice(0, 12)`)
|
||||
throw new Error(`G08 has horizontal overflow at ${size.width}x${size.height}: ${JSON.stringify(offenders)}`)
|
||||
if (genealogyId) {
|
||||
await open(send, '/pages/genealogy/g10-application-review', `?genealogyId=${encodeURIComponent(genealogyId)}`, '.review-page')
|
||||
await waitFor(send, "!document.querySelector('.app-loading')", 'G10 pending-application read did not settle')
|
||||
if (!await valueOf(send, "Boolean(document.querySelector('.state-card, .application-list'))")) {
|
||||
throw new Error('G10 did not render a settled real-read state')
|
||||
}
|
||||
} else {
|
||||
await open(send, '/pages/genealogy/g10-application-review', '', '.state-card')
|
||||
if (!await valueOf(send, "document.body.textContent.includes('审核入口无效')")) {
|
||||
throw new Error('G10 invalid-context state did not render')
|
||||
}
|
||||
}
|
||||
await open(send, '/pages/genealogy/g09-my-applications', '', '.application-state--list')
|
||||
if ((await valueOf(send, "document.querySelectorAll('.application-card__action').length")) !== 3) throw new Error('G09 list did not render the three status-specific actions')
|
||||
await valueOf(send, "document.querySelector('.application-card__action').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", 'G09 pending action did not open custom withdrawal confirmation')
|
||||
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:last-child').click()")
|
||||
await waitFor(send, "document.querySelector('.application-card__status')?.textContent.includes('本地撤回预览')", 'G09 withdrawal did not use a local-only status')
|
||||
if (!(await valueOf(send, "document.querySelector('.application-card')?.textContent.includes('尚未提交服务器')"))) throw new Error('G09 local withdrawal claimed a durable server result')
|
||||
await valueOf(send, "document.querySelector('.application-card__status--rejected')?.closest('.application-card')?.querySelector('.application-card__action')?.click()")
|
||||
await waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?genealogyId=2004&source=search&sourceKey=G09')", 'G09 rejected action did not reopen G08 with the canonical contract')
|
||||
await open(send, '/pages/genealogy/g09-my-applications', '?state=empty', '.application-state--empty')
|
||||
await open(send, '/pages/genealogy/g10-application-review', '?genealogyId=1001', '.review-state--list')
|
||||
await valueOf(send, "document.querySelector('.review-actions .app-button:nth-child(2)').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", 'G10 approve action did not open custom confirmation')
|
||||
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:last-child').click()")
|
||||
await waitFor(send, "document.querySelector('.application-card__status')?.textContent.includes('已通过')", 'G10 approval did not update the card')
|
||||
await waitFor(send, "Boolean(document.querySelector('.review-feedback'))", 'G10 approval did not show custom feedback')
|
||||
if (!(await valueOf(send, "document.querySelector('.review-feedback')?.textContent.includes('尚未提交服务器')"))) throw new Error('G10 local approval claimed a durable server result')
|
||||
await valueOf(send, "document.querySelector('.header-action').click()")
|
||||
await waitFor(send, "document.querySelector('.app-dialog__title')?.textContent.includes('审核说明')", 'G10 help did not use the custom dialog')
|
||||
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button').click()")
|
||||
|
||||
// 步骤二:拒绝必须先聚焦关联错误,再在填写原因后完成,不能沿用旧截图助手的空原因假路径。
|
||||
await open(send, '/pages/genealogy/g10-application-review', '?genealogyId=1001', '.review-state--list')
|
||||
await valueOf(send, "document.querySelector('.review-actions .app-button:first-child').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.rejection-field textarea'))", 'G10 reject action did not open its reason field')
|
||||
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:last-child').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('#g10-rejection-error'))", 'G10 empty rejection reason did not show the associated error')
|
||||
await waitFor(send, "document.activeElement === document.querySelector('.rejection-field textarea') || Boolean(document.activeElement?.closest('#g10-rejection-reason'))", 'G10 rejection validation did not focus the reason field')
|
||||
const reasonPrepared = await valueOf(send, `(() => {
|
||||
const textarea = document.querySelector('.rejection-field textarea')
|
||||
if (!textarea) return false
|
||||
textarea.value = '请补充可核验的亲属关系材料'
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
return true
|
||||
})()`)
|
||||
if (!reasonPrepared) throw new Error('G10 rejection reason could not be prepared')
|
||||
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:last-child').click()")
|
||||
await waitFor(send, "document.querySelector('.application-card__status')?.textContent.includes('已拒绝')", 'G10 rejection did not update the card')
|
||||
await waitFor(send, "Boolean(document.querySelector('.review-feedback'))", 'G10 rejection did not show custom feedback')
|
||||
await open(send, '/pages/genealogy/g10-application-review', '?state=empty&genealogyId=1001', '.review-state--empty')
|
||||
await open(send, '/pages/genealogy/g10-application-review', '?role=owner&state=list&genealogyId=1002', '.review-state--no-permission')
|
||||
if (await valueOf(send, "Boolean(document.querySelector('.application-card'))")) throw new Error('G10 trusted a forged owner role for a non-owner genealogy')
|
||||
await open(send, '/pages/genealogy/g10-application-review', '?state=list&genealogyId=2001', '.review-state--no-permission')
|
||||
if (exceptions.length) throw new Error(`G08-G10 raised browser exceptions: ${exceptions.join('; ')}`)
|
||||
process.stdout.write('G08-G10-APPLICATION-FLOW-RUNTIME-SMOKE PASS\n')
|
||||
} finally {
|
||||
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,25 @@ foreach ($forbidden in @('relationType: relationType.value', 'sex: addForm.gende
|
||||
if ($t04.Contains($forbidden)) { throw "T04 must not guess unsupported wire field: $forbidden" }
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'v-model="addForm.aliasName"',
|
||||
'v-model="addForm.generationName"',
|
||||
'v-model="addForm.birthLunar"',
|
||||
'v-model="addForm.birthPlace"',
|
||||
'v-model="addForm.deathLunar"',
|
||||
'v-model="addForm.deathPlace"',
|
||||
'v-model="addForm.burialPlace"',
|
||||
'v-model="addForm.biography"',
|
||||
'v-model="addForm.remark"',
|
||||
'aliasName: addForm.aliasName',
|
||||
'generationName: addForm.generationName',
|
||||
'biography: addForm.biography',
|
||||
'remark: addForm.remark',
|
||||
'...(isFirstMember.value ? { generation: 1 } : {})'
|
||||
)) {
|
||||
if (-not $t04.Contains($required)) { throw "T04 safe optional field missing: $required" }
|
||||
}
|
||||
|
||||
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" }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const run = async () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, "../utils/md5.js"), "utf8");
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
const { calcMD5Bytes } = await import(moduleUrl);
|
||||
for (const value of [Buffer.from([]), Buffer.from([0, 1, 127, 128, 255]), Buffer.from("家谱图片", "utf8")]) {
|
||||
const expected = crypto.createHash("md5").update(value).digest("hex");
|
||||
assert.strictEqual(calcMD5Bytes(value), expected);
|
||||
assert.strictEqual(calcMD5Bytes(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)), expected);
|
||||
}
|
||||
assert.throws(() => calcMD5Bytes("not-bytes"), /ArrayBuffer/);
|
||||
process.stdout.write("MD5-BYTES-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -428,10 +428,10 @@ foreach ($entry in @(
|
||||
}
|
||||
|
||||
Assert-Contains -Content $f01 -Expected 'goRoot("F01", { genealogyId: resolvedGenealogyId })' -Message 'F01 从全局解析家谱后必须先规范化根页 URL'
|
||||
Assert-Contains -Content $f01 -Expected 'listFamilyFeedFixtures(resolvedGenealogyId)' -Message 'F01 必须读取共享家族动态 owner'
|
||||
Assert-Contains -Content $f01 -Expected '动态列表待后端字段合同' -Message 'F01 必须明确动态列表读取仍被字段合同阻塞'
|
||||
Assert-NotContains -Content $f01 -Unexpected 'listFamilyFeedFixtures' -Message 'F01 不得把本地动态夹具伪装成服务端列表'
|
||||
foreach ($mapping in @(
|
||||
'openPage("F02", { genealogyId: genealogyId.value }, "F01")',
|
||||
'genealogyId: genealogyId.value, feedId: String(item.id)',
|
||||
'return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");'
|
||||
)) {
|
||||
Assert-Contains -Content $f01 -Expected $mapping -Message "F01 缺少保留家谱身份的规范入口:$mapping"
|
||||
@@ -439,7 +439,6 @@ foreach ($mapping in @(
|
||||
|
||||
foreach ($form in @(
|
||||
@{ Key = 'F02'; Content = $f02 },
|
||||
@{ Key = 'F03'; Content = $f03 },
|
||||
@{ Key = 'F06'; Content = $f06 },
|
||||
@{ Key = 'F09'; Content = $f09 }
|
||||
)) {
|
||||
@@ -452,17 +451,13 @@ foreach ($form in @(
|
||||
|
||||
Assert-Contains -Content $f02 -Expected '尚未提交服务器' -Message 'F02 只能生成明确未提交的本地预览'
|
||||
Assert-Contains -Content $f02 -Expected 'returnTo("F01", { genealogyId: genealogyId.value })' -Message 'F02 必须无结果返回同一家谱 F01'
|
||||
Assert-Contains -Content $f03 -Expected 'findFamilyFeedFixture(genealogyId.value, feedId.value)' -Message 'F03 必须按复合身份精确查询动态'
|
||||
Assert-Contains -Content $f03 -Expected '草稿已保留' -Message 'F03 评论校验后必须保留草稿并声明未提交'
|
||||
foreach ($forbidden in @('feedComments.value.push', 'commentDraft.value = ""', '评论已发送')) {
|
||||
Assert-NotContains -Content $f03 -Unexpected $forbidden -Message "F03 不得把评论草稿伪装成已发送:$forbidden"
|
||||
Assert-Contains -Content $f03 -Expected 'feed-detail-contract-note' -Message 'F03 必须明确详情与评论读取仍被合同阻塞'
|
||||
Assert-Contains -Content $f03 -Expected 'returnTo("F01", { genealogyId: genealogyId.value })' -Message 'F03 必须安全返回同一家谱 F01'
|
||||
foreach ($forbidden in @('appApi.', 'findFamilyFeedFixture', 'feedComments', 'commentDraft', 'submitComment')) {
|
||||
Assert-NotContains -Content $f03 -Unexpected $forbidden -Message "F03 后端门禁未关闭前不得保留宽接口或本地评论路径:$forbidden"
|
||||
}
|
||||
|
||||
$f01CanonicalIndex = $f01.IndexOf('if (!hasRouteIdentity)')
|
||||
$f01ActivationIndex = $f01.IndexOf('hasValidContext.value = isAccessible;')
|
||||
if ($f01CanonicalIndex -lt 0 -or $f01ActivationIndex -le $f01CanonicalIndex) {
|
||||
throw 'F01 必须在缺参根 URL 规范化分支之后才开放发布与业务快捷入口'
|
||||
}
|
||||
Assert-Contains -Content $f01 -Expected 'feedState.value = hasValidContext.value ? "unavailable" : "error";' -Message 'F01 必须在解析家谱上下文后进入明确的不可用或错误状态'
|
||||
|
||||
Assert-Contains -Content $f04 -Expected 'listFamilyArticleFixtures(genealogyId.value)' -Message 'F04 必须读取同一家谱的共享谱文 owner'
|
||||
Assert-Contains -Content $f04 -Expected ':action="hasValidContext ? ''新建'' : ''''"' -Message 'F04 无效家谱状态不得暴露新建入口'
|
||||
|
||||
@@ -253,6 +253,34 @@ process.exitCode = 1;
|
||||
`${routeKey} 生产合同不得保留未落地写接口的成功结果`,
|
||||
);
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
productionRoutes.G01.resultOperations,
|
||||
["genealogy-created"],
|
||||
"G01 必须只接受 G03 已落地的真实创建结果",
|
||||
);
|
||||
{
|
||||
const targetPage = createPage(productionRoutes, "G01");
|
||||
const productionHarness = await createHarness({
|
||||
routes: productionRoutes,
|
||||
stack: [targetPage, createPage(productionRoutes, "G03")],
|
||||
});
|
||||
const result = {
|
||||
operation: "genealogy-created",
|
||||
entityId: "genealogy-created-by-api",
|
||||
refresh: true,
|
||||
};
|
||||
assert.strictEqual(
|
||||
await productionHarness.navigation.finishPage("G01", {}, result),
|
||||
true,
|
||||
"G03 真实创建成功后必须能回流 G01",
|
||||
);
|
||||
assert.strictEqual(productionHarness.calls[0].method, "navigateBack");
|
||||
assert.deepStrictEqual(
|
||||
productionHarness.navigation.consumeNavigationResult("G01"),
|
||||
result,
|
||||
"G01 必须收到真实创建完成结果",
|
||||
);
|
||||
}
|
||||
{
|
||||
const rejectionCases = [
|
||||
{
|
||||
|
||||
@@ -68,7 +68,12 @@ const fs = require("fs");
|
||||
assert.deepStrictEqual(Array.from(ROOT_ROUTE_KEYS), discoveredRoots, "ROOT_ROUTE_KEYS 必须精确列出 parent=null 的路由");
|
||||
assert.strictEqual(ROUTES.A01.kind, "auth-root", "A01 必须是认证根语义");
|
||||
assert.deepStrictEqual(Array.from(ROUTES.G03.optionalParams), [], "G03 不得保留未消费的 genealogyId 或 step 参数");
|
||||
for (const routeKey of ["G01", "G05", "G09", "T01"]) {
|
||||
assert.deepStrictEqual(
|
||||
Array.from(ROUTES.G01.resultOperations),
|
||||
["genealogy-created"],
|
||||
"G01 必须接受 G03 真实创建完成结果",
|
||||
);
|
||||
for (const routeKey of ["G05", "G09", "T01"]) {
|
||||
assert.deepStrictEqual(Array.from(ROUTES[routeKey].resultOperations), [], `${routeKey} 不得预留没有真实 API 生产者的结果能力`);
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const toDataModuleUrl = (source) =>
|
||||
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
|
||||
const run = async () => {
|
||||
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
|
||||
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
|
||||
const prelude = `
|
||||
const currentUser = {};
|
||||
const genealogies = [];
|
||||
const publicGenealogies = [];
|
||||
const treeMembers = [];
|
||||
const notifications = [];
|
||||
const joinApplications = [];
|
||||
const listFamilyFeedFixtures = () => [];
|
||||
const listFamilyArticleFixtures = () => [];
|
||||
const listFamilyAlbumFixtures = () => [];
|
||||
const listCeremonyFixtures = () => [];
|
||||
const listGrowthRecordFixtures = () => [];
|
||||
const listNotificationFixtures = () => [];
|
||||
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
|
||||
const hasRemoteConfig = () => true;
|
||||
const resolveRuntimeMode = () => "remote";
|
||||
const AUTH_TAC_SCENE = Object.freeze({});
|
||||
const assertSmsCode = (value) => value;
|
||||
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
|
||||
const fromApiGenealogyAccess = () => null;
|
||||
const session = { getToken: () => "session-1", saveToken() {} };
|
||||
`;
|
||||
|
||||
const requests = [];
|
||||
let response;
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
queueMicrotask(() => options.success(response));
|
||||
return { abort() {} };
|
||||
},
|
||||
};
|
||||
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||
const profile = {
|
||||
userId: 101,
|
||||
tenantId: "000000",
|
||||
userNo: "U-101",
|
||||
phone: "13800138000",
|
||||
nickName: "家谱用户",
|
||||
realName: "王小明",
|
||||
avatar: 900001,
|
||||
sex: "男",
|
||||
birthday: "1990-01-02T00:00:00",
|
||||
email: "ming@example.com",
|
||||
registerSource: "APP",
|
||||
status: "0",
|
||||
};
|
||||
|
||||
response = { statusCode: 200, data: { code: 200, data: profile } };
|
||||
assert.deepStrictEqual(await appApi.getProfile(), profile);
|
||||
const profileRequest = requests.at(-1);
|
||||
assert.strictEqual(profileRequest.url, "https://backend-api.ddxcjp.cn/genealogy/app/auth/profile");
|
||||
assert.strictEqual(profileRequest.method, "GET");
|
||||
assert.strictEqual(profileRequest.timeout, 15000);
|
||||
assert.deepStrictEqual(profileRequest.header, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" });
|
||||
|
||||
response = { statusCode: 200, data: { code: 200, data: profile } };
|
||||
await appApi.updateProfile({
|
||||
nickName: " 新昵称 ",
|
||||
realName: "王小明",
|
||||
avatar: 900001,
|
||||
sex: "女",
|
||||
birthday: "1991-02-03T00:00:00+08:00",
|
||||
email: "new@example.com",
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
nickName: "新昵称",
|
||||
realName: "王小明",
|
||||
avatar: 900001,
|
||||
sex: "女",
|
||||
birthday: "1991-02-03T00:00:00+08:00",
|
||||
email: "new@example.com",
|
||||
});
|
||||
await assert.rejects(appApi.updateProfile({}), /至少需要一个/);
|
||||
await assert.rejects(appApi.updateProfile({ avatar: Number.MAX_SAFE_INTEGER + 1 }), /安全整数/);
|
||||
await assert.rejects(appApi.updateProfile({ birthday: "1991-02-03" }), /ISO/);
|
||||
await assert.rejects(appApi.updateProfile({ email: "bad-email" }), /email/);
|
||||
|
||||
response = { statusCode: 200, data: { code: 200, data: { ...profile, avatar: 0 } } };
|
||||
assert.strictEqual((await appApi.getProfile()).avatar, null, "未设置头像的 0 必须归一为 null");
|
||||
response = { statusCode: 200, data: { code: 200, data: { ...profile, avatar: -1 } } };
|
||||
assert.strictEqual((await appApi.getProfile()).avatar, null, "未设置头像的负值哨兵必须归一为 null");
|
||||
|
||||
delete globalThis.uni;
|
||||
process.stdout.write("PROFILE-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Read-ProjectFile {
|
||||
param([string]$RelativePath)
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root $RelativePath)
|
||||
}
|
||||
|
||||
function Require-Text {
|
||||
param([string]$Content, [string]$Text, [string]$Label)
|
||||
if (-not $Content.Contains($Text)) { throw "$Label missing: $Text" }
|
||||
}
|
||||
|
||||
function Forbid-Text {
|
||||
param([string]$Content, [string]$Text, [string]$Label)
|
||||
if ($Content.Contains($Text)) { throw "$Label must not retain: $Text" }
|
||||
}
|
||||
|
||||
$m01 = Read-ProjectFile 'pages/profile/m01-profile-home.vue'
|
||||
$m02 = Read-ProjectFile 'pages/profile/m02-edit-profile.vue'
|
||||
|
||||
foreach ($stale in @('fixture', 'currentUser', 'state-card__copy')) {
|
||||
Forbid-Text $m01 $stale 'M01'
|
||||
Forbid-Text $m02 $stale 'M02'
|
||||
}
|
||||
|
||||
foreach ($required in @('appApi.getProfile', 'onShow(loadProfile)', 'profile.nickName', 'profile.realName', 'profile.phone', 'profile.sex', 'profile.birthday', 'profile.email')) {
|
||||
Require-Text $m01 $required 'M01'
|
||||
}
|
||||
foreach ($required in @('appApi.getProfile', 'appApi.updateProfile', 'pickAndUploadImage', 'form.nickName', 'form.realName', 'form.sex', 'form.birthday', 'form.email', 'avatarId', 'buildUpdatePayload')) {
|
||||
Require-Text $m02 $required 'M02'
|
||||
}
|
||||
foreach ($forbidden in @('avatarOssId', 'coverOssId', 'fileId')) {
|
||||
Forbid-Text $m02 $forbidden 'M02'
|
||||
}
|
||||
|
||||
Write-Output 'PROFILE-PAGES-CONTRACT PASS'
|
||||
@@ -1,140 +1,54 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
function Read-Page([string]$path) { Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding UTF8 }
|
||||
|
||||
$contracts = [ordered]@{
|
||||
'pages/records/r03-gift-list.vue' = @('listRelativeRecordFixtures','relativeRecords','openRelative','createRelativePreview','relative-state--empty','openPage(','"R04",','relativeId')
|
||||
'pages/records/r04-gift-editor.vue' = @('findRelativeRecordFixture','relativeForm','validateRelative','localRelativePreview','relativeId','relativeName','eventName','giftAmount','requestBack')
|
||||
'pages/records/r05-ritual-list.vue' = @('listCeremonyFixtures','ceremonies','openCeremony','createCeremonyPreview','ceremony-state--empty','openPage(','"R06",','"R07",')
|
||||
'pages/records/r06-ritual-detail.vue' = @('findCeremonyFixture','listTreeMemberPresentationFixtures','ceremonyDetail','invitees','inviteeUserId','memberOptions','editCeremony','ceremonyId','openPage(','"R07",','ceremony-state--expired')
|
||||
'pages/records/r07-ritual-editor.vue' = @('findCeremonyFixture','ceremonyForm','validateCeremony','localCeremonyPreview','ceremonyId','ceremonyType','ceremonyTitle','requestBack')
|
||||
'pages/records/r08-growth-journal.vue' = @('listGrowthRecordFixtures','findTreeMemberPresentationFixture','growthRecords','localGrowthPreview','recordGrowth','requestBack','timeline-state--empty')
|
||||
'pages/records/r09-life-events.vue' = @('findTreeMemberPresentationFixture','人生事件接口尚未开放','serviceState','requestBack')
|
||||
'pages/records/r10-memo-list.vue' = @('listMemoFixtures','memos','createMemoPreview','localMemoPreview','memoTitle','requestBack','memo-state--empty')
|
||||
'pages/records/r11-merit-records.vue' = @('listMeritRecordFixtures','meritRecords','createMeritPreview','localMeritPreview','donorName','meritTitle','totalContribution','requestBack')
|
||||
'pages/records/r03-gift-list.vue' = @('appApi.getRelativeRecords', 'relativeId', 'openPage("R04"', 'record-list')
|
||||
'pages/records/r04-gift-editor.vue' = @('appApi.createRelativeRecord', 'relativeName', 'mediaOssIds', 'pickAndUploadImage')
|
||||
'pages/records/r05-ritual-list.vue' = @('appApi.getCeremonies', 'ceremonyId', 'openPage("R06"', 'openPage("R07"')
|
||||
'pages/records/r06-ritual-detail.vue' = @('appApi.getCeremonyDetail', 'ceremonyId')
|
||||
'pages/records/r07-ritual-editor.vue' = @('appApi.createCeremony', 'ceremonyType', 'ceremonyTitle')
|
||||
'pages/records/r08-growth-journal.vue' = @('appApi.getGrowthRecords', 'appApi.createGrowthRecord', 'recordId', 'recordTitle', 'view.value = "list"')
|
||||
'pages/records/r09-life-events.vue' = @('hasValidContext', 'returnToRecords')
|
||||
'pages/records/r10-memo-list.vue' = @('appApi.getMemos', 'appApi.createMemo', 'memoId', 'memoTitle', 'view.value = "list"')
|
||||
'pages/records/r11-merit-records.vue' = @('appApi.getMeritRecords', 'appApi.createMeritRecord', 'meritId', 'meritTitle', 'totalAmount')
|
||||
}
|
||||
|
||||
foreach ($entry in $contracts.GetEnumerator()) {
|
||||
$source = Read-Page $entry.Key
|
||||
foreach ($token in $entry.Value) {
|
||||
if (-not $source.Contains($token)) { throw "$($entry.Key) missing R contract: $token" }
|
||||
if (-not $source.Contains($token)) { throw "$($entry.Key) missing current record contract: $token" }
|
||||
}
|
||||
foreach ($shared in @('ModulePageBackground','PageHeader','AppButton')) {
|
||||
foreach ($shared in @('ModulePageBackground', 'PageHeader', 'AppButton')) {
|
||||
if (-not $source.Contains($shared)) { throw "$($entry.Key) missing shared primitive: $shared" }
|
||||
}
|
||||
foreach ($forbidden in @(
|
||||
'uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch',
|
||||
'/pages/records/', 'saveResult', 'finishPage(', 'Date.now()', '.unshift(',
|
||||
'giftId', 'ritualId', 'personName=', '已保存', '已删除'
|
||||
)) {
|
||||
if ($source.Contains($forbidden)) { throw "$($entry.Key) retains forbidden fake-write or old-route contract: $forbidden" }
|
||||
foreach ($forbidden in @('Fixture', 'localGrowthPreview', 'localMemoPreview', 'localMeritPreview', 'Date.now()', 'uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch')) {
|
||||
if ($source.Contains($forbidden)) { throw "$($entry.Key) retains retired local or direct-navigation contract: $forbidden" }
|
||||
}
|
||||
if ($source -match '<ModulePage(?:\s|/|>)|import\s+ModulePage\s+from') { throw "$($entry.Key) must own its business page" }
|
||||
if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') { throw "$($entry.Key) must use project feedback primitives" }
|
||||
if ($source -match '(?im)(?<![-\w])position\s*:|overflow\s*:\s*hidden') { throw "$($entry.Key) contains forbidden document-flow declaration" }
|
||||
}
|
||||
|
||||
foreach ($pageAndTarget in @(
|
||||
@{ Path = 'pages/records/r04-gift-editor.vue'; Target = 'R03' },
|
||||
@{ Path = 'pages/records/r06-ritual-detail.vue'; Target = 'R05' },
|
||||
@{ Path = 'pages/records/r07-ritual-editor.vue'; Target = 'R05' }
|
||||
)) {
|
||||
$source = Read-Page $pageAndTarget.Path
|
||||
$safeReturnPattern = '(?s)genealogyId\.value\s*\?\s*returnTo\("{0}".*?:\s*goBack\(\)' -f $pageAndTarget.Target
|
||||
if ($source -notmatch $safeReturnPattern) {
|
||||
throw "$($pageAndTarget.Path) invalid-entry CTA must fall back to goBack without genealogyId"
|
||||
}
|
||||
}
|
||||
|
||||
$r03 = Read-Page 'pages/records/r03-gift-list.vue'
|
||||
$r04 = Read-Page 'pages/records/r04-gift-editor.vue'
|
||||
$r06 = Read-Page 'pages/records/r06-ritual-detail.vue'
|
||||
foreach ($forbiddenCurrency in @('¥', '¥')) {
|
||||
if ($r03.Contains($forbiddenCurrency) -or $r04.Contains($forbiddenCurrency)) {
|
||||
throw "R03/R04 must not invent a currency symbol absent from the backend contract: $forbiddenCurrency"
|
||||
}
|
||||
foreach ($optionalRelativeField in @('relationName', 'eventName', 'eventTime', 'giftAmount', 'recordContent', 'mediaOssIds', 'sortOrder')) {
|
||||
if ($r04 -match "if\s*\(!form\.$optionalRelativeField") { throw "R04 must not make optional backend field required: $optionalRelativeField" }
|
||||
}
|
||||
foreach ($optionalRelativeField in @('relationName', 'eventName', 'eventTime')) {
|
||||
if ($r04 -match "relativeErrors\.$optionalRelativeField\s*=\s*relativeForm\.$optionalRelativeField\.trim\(\)") {
|
||||
throw "R04 must not make optional backend field required: $optionalRelativeField"
|
||||
}
|
||||
}
|
||||
foreach ($invalidAmountRule in @('amount >= 0', 'String(Number(relativeForm.giftAmount))')) {
|
||||
if ($r04.Contains($invalidAmountRule)) {
|
||||
throw "R04 must preserve optional numeric amount semantics without inventing a boundary or string body: $invalidAmountRule"
|
||||
}
|
||||
}
|
||||
foreach ($inventedInvitationField in @('inviteStatusLabel', 'userId:', 'name:', 'relation:')) {
|
||||
if ($r06.Contains($inventedInvitationField)) {
|
||||
throw "R06 must join inviteeUserId to scoped member options and must not retain invented invitation fields: $inventedInvitationField"
|
||||
}
|
||||
}
|
||||
foreach ($previewGuard in @(
|
||||
@{ Path = 'pages/records/r04-gift-editor.vue'; Pattern = 'editorState\.value\s*===\s*"preview"' },
|
||||
@{ Path = 'pages/records/r07-ritual-editor.vue'; Pattern = 'editorState\.value\s*===\s*"preview"' },
|
||||
@{ Path = 'pages/records/r08-growth-journal.vue'; Pattern = 'dirty:\s*Boolean\(localGrowthPreview\.value\)' },
|
||||
@{ Path = 'pages/records/r10-memo-list.vue'; Pattern = 'dirty:\s*Boolean\(localMemoPreview\.value\)' },
|
||||
@{ Path = 'pages/records/r11-merit-records.vue'; Pattern = 'dirty:\s*Boolean\(localMeritPreview\.value\)' }
|
||||
)) {
|
||||
$source = Read-Page $previewGuard.Path
|
||||
if ($source -notmatch $previewGuard.Pattern) {
|
||||
throw "$($previewGuard.Path) must guard page back while a local preview remains unsaved"
|
||||
}
|
||||
}
|
||||
foreach ($dialogPage in @(
|
||||
'pages/records/r04-gift-editor.vue',
|
||||
'pages/records/r07-ritual-editor.vue',
|
||||
'pages/records/r08-growth-journal.vue',
|
||||
'pages/records/r10-memo-list.vue',
|
||||
'pages/records/r11-merit-records.vue'
|
||||
)) {
|
||||
$source = Read-Page $dialogPage
|
||||
$dialogs = [regex]::Matches($source, '(?s)<AppDialog\b.*?>')
|
||||
if ($dialogs.Count -eq 0 -or ($dialogs | Where-Object { $_.Value -notmatch ':close-on-mask="false"' })) {
|
||||
throw "$dialogPage dialogs must ignore mask taps"
|
||||
}
|
||||
}
|
||||
$r11 = Read-Page 'pages/records/r11-merit-records.vue'
|
||||
foreach ($amountContract in @(
|
||||
'type="digit"',
|
||||
'Number.isFinite(Number(amountInput))',
|
||||
'amount: amountInput ? Number(amountInput) : null',
|
||||
'金额数值(单位待确认)',
|
||||
'localMeritPreview.meritType'
|
||||
)) {
|
||||
if (-not $r11.Contains($amountContract)) {
|
||||
throw "R11 optional numeric amount or preview visibility contract missing: $amountContract"
|
||||
}
|
||||
|
||||
foreach ($page in @('pages/records/r08-growth-journal.vue', 'pages/records/r10-memo-list.vue', 'pages/records/r11-merit-records.vue')) {
|
||||
$source = Read-Page $page
|
||||
if ($source -notmatch 'onShow\(') { throw "$page must reread the service when returning to its list" }
|
||||
if ($source -notmatch 'await\s+load') { throw "$page must read back after a successful write" }
|
||||
}
|
||||
|
||||
$r10 = Read-Page 'pages/records/r10-memo-list.vue'
|
||||
foreach ($forbidden in @('toggleMemo', 'memo.done =', '点击标记完成', '点击恢复待办')) {
|
||||
if ($r10.Contains($forbidden)) { throw "R10 must not fake completed-state mutation: $forbidden" }
|
||||
}
|
||||
$r09 = Read-Page 'pages/records/r09-life-events.vue'
|
||||
foreach ($forbidden in @('lifeEvents', 'createLifeEvent', 'saveLifeEvent', 'growth-records', 'LIFE_EVENT')) {
|
||||
if ($r09.Contains($forbidden)) { throw "R09 must remain hard closed without a backend contract: $forbidden" }
|
||||
foreach ($forbidden in @('toggleMemo', 'memo.done =')) {
|
||||
if ($r10.Contains($forbidden)) { throw "R10 must not invent a completed-state mutation: $forbidden" }
|
||||
}
|
||||
|
||||
$runtimeSources = @(
|
||||
'tests/r-business-flow-runtime-smoke.js',
|
||||
'tests/r02-person-detail-runtime-smoke.js',
|
||||
'tests/r02-background-runtime-smoke.js',
|
||||
'tests/module-page-runtime-smoke.js',
|
||||
'tests/module-series-responsive-runtime-smoke.js'
|
||||
) | ForEach-Object { Read-Page $_ }
|
||||
$runtimeText = $runtimeSources -join "`n"
|
||||
foreach ($retired in @('giftId', 'ritualId', 'count=50', 'r02-person-detail?personId=1', 'r08-growth-journal",".timeline-card')) {
|
||||
if ($runtimeText.Contains($retired)) { throw "R runtime smoke retains retired route or fake-volume contract: $retired" }
|
||||
}
|
||||
foreach ($requiredRuntimeFact in @(
|
||||
'genealogyId=1001&mode=view&relativeId=301&sourceKey=R03',
|
||||
'R08 local preview must not enter the official timeline',
|
||||
'R11 local preview must not change the official count',
|
||||
'personId=103',
|
||||
'preview back guard missing'
|
||||
)) {
|
||||
if (-not $runtimeText.Contains($requiredRuntimeFact)) { throw "R runtime smoke misses current contract: $requiredRuntimeFact" }
|
||||
$r09 = Read-Page 'pages/records/r09-life-events.vue'
|
||||
foreach ($forbidden in @('lifeEvents', 'createLifeEvent', 'saveLifeEvent', 'growth-records', 'LIFE_EVENT')) {
|
||||
if ($r09.Contains($forbidden)) { throw "R09 must remain closed without a backend contract: $forbidden" }
|
||||
}
|
||||
|
||||
Write-Output 'R-BUSINESS-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const origin = process.argv[2] || "http://localhost:5173";
|
||||
const cdpPort = process.env.CDP_PORT || "9222";
|
||||
const genealogyId = process.env.GENEALOGY_ID || "2080557121112465409";
|
||||
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
const connect = async () => {
|
||||
const pages = await (await fetch("http://127.0.0.1:9222/json/list")).json();
|
||||
const page = pages.find(
|
||||
(candidate) => candidate.type === "page" && candidate.url.startsWith(`${origin}/`),
|
||||
);
|
||||
const pages = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json();
|
||||
const page = pages.find((candidate) => candidate.type === "page" && candidate.url.startsWith(`${origin}/`));
|
||||
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`);
|
||||
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener("open", resolve, { once: true });
|
||||
socket.addEventListener("error", reject, { once: true });
|
||||
});
|
||||
await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); socket.addEventListener("error", reject, { once: true }); });
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
socket.addEventListener("message", (event) => {
|
||||
@@ -22,145 +20,68 @@ const connect = async () => {
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
message.error
|
||||
? request.reject(new Error(message.error.message))
|
||||
: request.resolve(message.result);
|
||||
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result);
|
||||
});
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
id += 1;
|
||||
pending.set(id, { resolve, reject });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
id += 1;
|
||||
pending.set(id, { resolve, reject });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
return { socket, send };
|
||||
};
|
||||
|
||||
const valueOf = async (send, expression) =>
|
||||
(await send("Runtime.evaluate", { expression, returnByValue: true })).result?.value;
|
||||
const valueOf = async (send, expression) => (await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })).result?.value;
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
if (await valueOf(send, expression)) return;
|
||||
await sleep(100);
|
||||
await wait(100);
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
let auditId = 0;
|
||||
const open = async (send, route, selector) => {
|
||||
auditId += 1;
|
||||
const url = `${origin}/?rBusiness=${auditId}#${route}`;
|
||||
const open = async (send, route) => {
|
||||
const url = `${origin}/#${route}`;
|
||||
await send("Page.navigate", { url });
|
||||
await waitFor(send, `location.href===${JSON.stringify(url)}`, `navigation failed: ${route}`);
|
||||
await waitFor(
|
||||
send,
|
||||
`Boolean(document.querySelector(${JSON.stringify(selector)}))`,
|
||||
`missing ${selector}: ${route}`,
|
||||
);
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `navigation failed: ${route}`);
|
||||
const previousTimeOrigin = await valueOf(send, "performance.timeOrigin");
|
||||
await send("Page.reload");
|
||||
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `reload failed: ${route}`);
|
||||
await waitFor(send, "document.body.innerText.length > 0", `page did not render: ${route}`);
|
||||
};
|
||||
const click = (send, selector) =>
|
||||
valueOf(send, `document.querySelector(${JSON.stringify(selector)})?.click()`);
|
||||
const inputAt = (send, selector, index, value) =>
|
||||
valueOf(
|
||||
send,
|
||||
`(() => { const input=document.querySelectorAll(${JSON.stringify(selector)})[${index}]; input.value=${JSON.stringify(value)}; input.dispatchEvent(new Event('input',{bubbles:true})); return input.value; })()`,
|
||||
);
|
||||
|
||||
const routes = [
|
||||
["/pages/records/r03-gift-list?genealogyId=1001", ".record-card"],
|
||||
["/pages/records/r04-gift-editor?genealogyId=1001&mode=create", ".form-card"],
|
||||
["/pages/records/r05-ritual-list?genealogyId=1001", ".record-card"],
|
||||
["/pages/records/r06-ritual-detail?genealogyId=1001&ceremonyId=501", ".detail-card"],
|
||||
["/pages/records/r07-ritual-editor?genealogyId=1001&mode=create", ".form-card"],
|
||||
["/pages/records/r08-growth-journal?genealogyId=1001&personId=101", ".timeline-card"],
|
||||
["/pages/records/r09-life-events?genealogyId=1001&personId=101", ".service-state--unavailable"],
|
||||
["/pages/records/r10-memo-list?genealogyId=1001", ".memo-card"],
|
||||
["/pages/records/r11-merit-records?genealogyId=1001", ".merit-card"],
|
||||
];
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send } = await connect();
|
||||
try {
|
||||
await send("Page.enable");
|
||||
await send("Runtime.enable");
|
||||
for (const size of [
|
||||
{ width: 320, height: 568 },
|
||||
{ width: 412, height: 915 },
|
||||
]) {
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
...size,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: true,
|
||||
screenWidth: size.width,
|
||||
screenHeight: size.height,
|
||||
});
|
||||
for (const [route, selector] of routes) {
|
||||
await open(send, route, selector);
|
||||
const documentWidth = await valueOf(send, "document.documentElement.scrollWidth");
|
||||
assert(documentWidth <= size.width + 1, `${route} overflows at ${size.width}`);
|
||||
}
|
||||
const readbacks = [
|
||||
["/pages/records/r03-gift-list?genealogyId=" + genealogyId, "\u63a5\u53e3\u8054\u8c03\u9a8c\u8bc1\u4eb2\u53cb"],
|
||||
["/pages/records/r05-ritual-list?genealogyId=" + genealogyId, "\u63a5\u53e3\u8054\u8c03\u9a8c\u8bc1\u793c\u4eea\u6d3b\u52a8"],
|
||||
["/pages/records/r08-growth-journal?genealogyId=" + genealogyId, "\u63a5\u53e3\u8054\u8c03\u9a8c\u8bc1\u6210\u957f\u8bb0\u5f55"],
|
||||
["/pages/records/r10-memo-list?genealogyId=" + genealogyId, "\u63a5\u53e3\u8054\u8c03\u9a8c\u8bc1\u5907\u5fd8"],
|
||||
["/pages/records/r11-merit-records?genealogyId=" + genealogyId, "\u63a5\u53e3\u8054\u8c03\u9a8c\u8bc1\u529f\u5fb7"],
|
||||
];
|
||||
for (const [route, text] of readbacks) {
|
||||
await open(send, route);
|
||||
await waitFor(send, `document.body.innerText.includes(${JSON.stringify(text)})`, `service readback missing: ${route}`);
|
||||
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.state-card .app-loading'))"), false, `page stuck loading: ${route}`);
|
||||
}
|
||||
|
||||
await open(send, "/pages/records/r08-growth-journal?genealogyId=1002&personId=101", ".timeline-state--invalid");
|
||||
await open(send, "/pages/records/r09-life-events", ".service-state--invalid");
|
||||
await open(send, "/pages/records/r08-growth-journal?genealogyId=" + genealogyId);
|
||||
await valueOf(send, "document.querySelector('.header-action')?.click()");
|
||||
await waitFor(send, "Boolean(document.querySelector('.form-card'))", "R08 create form did not open");
|
||||
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.required-mark').length"), 1, "R08 required marker drifted");
|
||||
|
||||
await open(send, "/pages/records/r03-gift-list?genealogyId=1001", ".record-card");
|
||||
await click(send, ".record-card");
|
||||
await waitFor(
|
||||
send,
|
||||
"location.hash.includes('genealogyId=1001&mode=view&relativeId=301&sourceKey=R03')",
|
||||
"R03 did not open the scoped R04 record",
|
||||
);
|
||||
await open(send, "/pages/records/r10-memo-list?genealogyId=" + genealogyId);
|
||||
await valueOf(send, "document.querySelector('.header-action')?.click()");
|
||||
await waitFor(send, "Boolean(document.querySelector('.form-card'))", "R10 create form did not open");
|
||||
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.required-mark').length"), 1, "R10 required marker drifted");
|
||||
|
||||
await open(send, "/pages/records/r05-ritual-list?genealogyId=1001", ".record-card");
|
||||
await click(send, ".record-card");
|
||||
await waitFor(
|
||||
send,
|
||||
"location.hash.includes('genealogyId=1001&ceremonyId=501&sourceKey=R05')",
|
||||
"R05 did not open the scoped R06 ceremony",
|
||||
);
|
||||
|
||||
await open(send, "/pages/records/r08-growth-journal?genealogyId=1001&personId=101", ".timeline-card");
|
||||
const growthCount = await valueOf(send, "document.querySelectorAll('.timeline-card').length");
|
||||
await click(send, ".header-action");
|
||||
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", "R08 editor missing");
|
||||
await inputAt(send, ".dialog-form input", 0, "第一次远行");
|
||||
await click(send, ".app-dialog__actions .app-button:last-child");
|
||||
await waitFor(
|
||||
send,
|
||||
"document.querySelector('.preview-card')?.innerText.includes('第一次远行')",
|
||||
"R08 local preview missing",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await valueOf(send, "document.querySelectorAll('.timeline-card').length"),
|
||||
growthCount,
|
||||
"R08 local preview must not enter the official timeline",
|
||||
);
|
||||
|
||||
await open(send, "/pages/records/r11-merit-records?genealogyId=1001", ".merit-card");
|
||||
const meritCount = await valueOf(send, "document.querySelectorAll('.merit-card').length");
|
||||
await click(send, ".header-action");
|
||||
await inputAt(send, ".dialog-form input", 0, "整理旧谱");
|
||||
await inputAt(send, ".dialog-form input", 1, "汤文清");
|
||||
await click(send, ".app-dialog__actions .app-button:last-child");
|
||||
await waitFor(
|
||||
send,
|
||||
"document.querySelector('.preview-card')?.innerText.includes('整理旧谱')",
|
||||
"R11 local preview missing",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await valueOf(send, "document.querySelectorAll('.merit-card').length"),
|
||||
meritCount,
|
||||
"R11 local preview must not change the official count",
|
||||
);
|
||||
await open(send, "/pages/records/r11-merit-records?genealogyId=" + genealogyId);
|
||||
await valueOf(send, "document.querySelector('.header-action')?.click()");
|
||||
await waitFor(send, "Boolean(document.querySelector('.form-card'))", "R11 create form did not open");
|
||||
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.required-mark').length"), 2, "R11 required marker drifted");
|
||||
process.stdout.write("R-BUSINESS-FLOW-RUNTIME-SMOKE PASS\n");
|
||||
} finally {
|
||||
try {
|
||||
await send("Emulation.clearDeviceMetricsOverride");
|
||||
} catch (_) {}
|
||||
socket.close();
|
||||
}
|
||||
} finally { socket.close(); }
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1); });
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const toDataModuleUrl = (source) => `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
|
||||
const run = async () => {
|
||||
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
|
||||
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
|
||||
const prelude = `
|
||||
const currentUser = {}; const genealogies = []; const publicGenealogies = []; const treeMembers = []; const notifications = []; const joinApplications = [];
|
||||
const listFamilyFeedFixtures = () => []; const listFamilyArticleFixtures = () => []; const listFamilyAlbumFixtures = () => []; const listCeremonyFixtures = () => []; const listGrowthRecordFixtures = () => []; const listNotificationFixtures = () => [];
|
||||
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
|
||||
const hasRemoteConfig = () => true; const resolveRuntimeMode = () => "remote"; const AUTH_TAC_SCENE = Object.freeze({}); const assertSmsCode = (value) => value;
|
||||
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" }); const fromApiGenealogyAccess = () => null; const session = { getToken: () => "session-1", saveToken() {} };
|
||||
`;
|
||||
const requests = [];
|
||||
globalThis.uni = { request(options) { requests.push(options); queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: [{ regionCode: "11", label: "Beijing", parentCode: "0", leaf: false, regionLevel: 1 }] } })); return { abort() {} }; } };
|
||||
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||
assert.deepStrictEqual(await appApi.getRegionChildren(), [{ regionCode: "11", label: "Beijing", parentCode: "0", leaf: false, regionLevel: 1 }]);
|
||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/region/children");
|
||||
assert.strictEqual(requests.at(-1).method, "GET");
|
||||
assert.deepStrictEqual(requests.at(-1).data, { parentCode: "0" });
|
||||
await assert.rejects(appApi.getRegionChildren(" "), /地区父级/);
|
||||
delete globalThis.uni;
|
||||
process.stdout.write("REGION-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1); });
|
||||
@@ -0,0 +1,64 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const run = async () => {
|
||||
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/resumable-image-upload.js"), "utf8");
|
||||
const source = rawSource
|
||||
.replace('import { appApi } from "@/utils/api.js";', "const appApi = globalThis.__appApi;")
|
||||
.replace('import { calcMD5Bytes } from "@/utils/md5.js";', "const calcMD5Bytes = () => \"a\".repeat(32);");
|
||||
globalThis.__appApi = {
|
||||
initializeResumableUpload: async () => ({
|
||||
instant: true,
|
||||
ossId: "2060000000000000001",
|
||||
url: "https://files.example/image.jpg",
|
||||
fileName: "image.jpg",
|
||||
}),
|
||||
};
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
const { pickAndUploadImage, toConsumerOssId } = await import(moduleUrl);
|
||||
|
||||
assert.strictEqual(toConsumerOssId("900001"), 900001);
|
||||
assert.throws(() => toConsumerOssId("0"), (error) => error.code === "OSS_ID_INVALID");
|
||||
assert.throws(() => toConsumerOssId("9007199254740992"), (error) => error.code === "OSS_ID_UNSAFE");
|
||||
await assert.rejects(pickAndUploadImage(), (error) => error.code === "IMAGE_PICK_UNAVAILABLE");
|
||||
|
||||
globalThis.plus = {
|
||||
gallery: {
|
||||
pick(success) {
|
||||
success("/tmp/image.jpg");
|
||||
},
|
||||
},
|
||||
io: {
|
||||
resolveLocalFileSystemURL(_path, success) {
|
||||
success({
|
||||
file(done) {
|
||||
done({ name: "image.jpg", size: 4, type: "image/jpeg" });
|
||||
},
|
||||
});
|
||||
},
|
||||
FileReader: class {
|
||||
readAsArrayBuffer() {
|
||||
this.result = new Uint8Array([1, 2, 3, 4]).buffer;
|
||||
this.onloadend({ target: { result: this.result } });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.deepStrictEqual(await pickAndUploadImage(), {
|
||||
ossId: "2060000000000000001",
|
||||
url: "https://files.example/image.jpg",
|
||||
thumbnailUrl: "",
|
||||
fileName: "image.jpg",
|
||||
});
|
||||
delete globalThis.plus;
|
||||
delete globalThis.__appApi;
|
||||
process.stdout.write("RESUMABLE-IMAGE-UPLOAD-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -8,12 +8,12 @@ function Assert-Match([string]$Pattern, [string]$Message) {
|
||||
}
|
||||
|
||||
Assert-Match 'class="directory-context"' 'T07 must show the current genealogy context.'
|
||||
Assert-Match '\u6c64\u6c0f\u5bb6\u8c31' 'T07 baseline must identify the current genealogy in the H5 mock state.'
|
||||
Assert-Match '\u5f53\u524d\u5bb6\u8c31' 'T07 must not render a stale mock genealogy name.'
|
||||
Assert-Match '''directory-state--loading'': directoryState === ''loading''' 'T07 loading state must have an explicit root class.'
|
||||
Assert-Match 'v-if="directoryState === ''list'' \|\| directoryState === ''empty''"' 'T07 search must remain available for list and empty states only.'
|
||||
Assert-Match 'class="directory-search__action"[^>]*role="button"[^>]*aria-label="\u67e5\u627e\u6210\u5458"' 'T07 search action must expose button semantics and an accessible label.'
|
||||
Assert-Match '<AppButton\s+v-if="directoryState === ''error''"[^>]+type="secondary"[^>]+:label="hasValidContext \? ''\u91cd\u65b0\u67e5\u770b'' : ''\u8fd4\u56de\u4e0a\u4e00\u9875''"[^>]+@click="retryDirectory"' 'T07 error state must provide context-safe recovery.'
|
||||
Assert-Match '(?s)const retryDirectory = \(\) => \{\s*if \(!hasValidContext\.value\) return goBack\(\);\s*directoryState\.value = "list";\s*\}' 'T07 retry action must only restore a valid directory context.'
|
||||
Assert-Match '(?s)const retryDirectory = \(\) => \{\s*if \(!hasValidContext\.value\) return goBack\(\);\s*pageNum\.value = 1;\s*return loadMembers\(\);\s*\}' 'T07 retry action must reload a valid directory context.'
|
||||
Assert-Match '(?s)\.directory-page\s*\{[^}]*overflow-y:\s*auto;' 'T07 must allow natural vertical scrolling.'
|
||||
if ($page -match '(?s)\.directory-page\s*\{[^}]*overflow-x:\s*hidden;') { throw 'T07 must not hide horizontal layout failures at the page root.' }
|
||||
Assert-Match '(?s)\.directory-search\s*\{[^}]*min-height:\s*44px;' 'T07 search field must preserve a 44 CSS px minimum touch height at every viewport.'
|
||||
|
||||
@@ -187,7 +187,7 @@ const run = async () => {
|
||||
sceneCode: "APP_REGISTER",
|
||||
subject: "13800138000",
|
||||
};
|
||||
instance.context = context;
|
||||
instance.requestContext = context;
|
||||
instance.generation += 1;
|
||||
instance.createTac();
|
||||
assert.strictEqual(latestTac.initialized, true);
|
||||
@@ -214,7 +214,7 @@ const run = async () => {
|
||||
|
||||
const staleSuccess = firstSuccess;
|
||||
instance.generation += 1;
|
||||
instance.context = { ...context, requestId: "request-2" };
|
||||
instance.requestContext = { ...context, requestId: "request-2" };
|
||||
staleSuccess({ data: { validToken: "stale", expireSeconds: 300 } }, null, latestTac);
|
||||
assert.strictEqual(ownerCalls.length, 1, "换轮后的陈旧回调不得污染新页面状态");
|
||||
|
||||
@@ -228,7 +228,7 @@ const run = async () => {
|
||||
"关闭按钮重复回调只能提交一次取消",
|
||||
);
|
||||
|
||||
instance.context = { ...context, requestId: "request-3" };
|
||||
instance.requestContext = { ...context, requestId: "request-3" };
|
||||
await instance.onContextChange({ visible: false });
|
||||
assert.strictEqual(instance.tac, null, "隐藏或卸载验证层必须销毁 SDK 实例");
|
||||
assert.strictEqual(host.innerHTML, "", "隐藏或卸载验证层必须清空宿主节点");
|
||||
|
||||
+1738
-107
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,14 @@ const toWords = (value) => {
|
||||
return words
|
||||
}
|
||||
|
||||
const toWordsFromBytes = (bytes) => {
|
||||
const words = []
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
words[index >> 2] |= bytes[index] << ((index % 4) * 8)
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
const toHex = (words) => {
|
||||
const alphabet = '0123456789abcdef'
|
||||
let output = ''
|
||||
@@ -70,3 +78,16 @@ export const calcMD5 = (value) => {
|
||||
const utf8Value = unescape(encodeURIComponent(String(value)))
|
||||
return toHex(coreMd5(toWords(utf8Value), utf8Value.length * 8))
|
||||
}
|
||||
|
||||
export const calcMD5Bytes = (value) => {
|
||||
const bytes = value instanceof ArrayBuffer
|
||||
? new Uint8Array(value)
|
||||
: ArrayBuffer.isView(value)
|
||||
? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
||||
: null
|
||||
if (!bytes) throw new TypeError('MD5 字节输入必须是 ArrayBuffer 或 TypedArray')
|
||||
if (bytes.length > 0x1fffffff) {
|
||||
throw new RangeError('当前 MD5 实现不支持超过 512MB 的单文件')
|
||||
}
|
||||
return toHex(coreMd5(toWordsFromBytes(bytes), bytes.length * 8))
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export const ROUTES = Object.freeze({
|
||||
kind: "root",
|
||||
parent: null,
|
||||
optionalParams: ["genealogyId"],
|
||||
resultOperations: ["genealogy-created"],
|
||||
}),
|
||||
G03: defineRoute({
|
||||
path: "/pages/genealogy/g03-create-genealogy",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { appApi } from "@/utils/api.js";
|
||||
import { calcMD5Bytes } from "@/utils/md5.js";
|
||||
|
||||
const createUploadError = (message, code) => {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
};
|
||||
|
||||
export const isImagePickCancelled = (error) => error?.code === "IMAGE_PICK_CANCELLED";
|
||||
|
||||
const pickNativeImage = () => new Promise((resolve, reject) => {
|
||||
if (!globalThis.plus?.gallery?.pick) {
|
||||
reject(createUploadError("当前运行环境不支持从相册选择图片", "IMAGE_PICK_UNAVAILABLE"));
|
||||
return;
|
||||
}
|
||||
globalThis.plus.gallery.pick(
|
||||
(filePath) => resolve(filePath),
|
||||
() => reject(createUploadError("已取消选择图片", "IMAGE_PICK_CANCELLED")),
|
||||
{ filter: "image", multiple: false },
|
||||
);
|
||||
});
|
||||
|
||||
const readNativeImage = (filePath) => new Promise((resolve, reject) => {
|
||||
if (!globalThis.plus?.io?.resolveLocalFileSystemURL || !globalThis.plus?.io?.FileReader) {
|
||||
reject(createUploadError("当前运行环境不支持读取图片", "IMAGE_READ_UNAVAILABLE"));
|
||||
return;
|
||||
}
|
||||
globalThis.plus.io.resolveLocalFileSystemURL(
|
||||
filePath,
|
||||
(entry) => entry.file(
|
||||
(file) => {
|
||||
const reader = new globalThis.plus.io.FileReader();
|
||||
reader.onloadend = (event) => {
|
||||
const data = event?.target?.result ?? reader.result;
|
||||
if (!(data instanceof ArrayBuffer)) {
|
||||
reject(createUploadError("读取图片数据失败", "IMAGE_READ_INVALID"));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
filePath,
|
||||
fileName: file.name || "image",
|
||||
size: file.size,
|
||||
contentType: file.type || "image/*",
|
||||
data,
|
||||
});
|
||||
};
|
||||
reader.onerror = () => reject(createUploadError("读取图片数据失败", "IMAGE_READ_FAILED"));
|
||||
reader.readAsArrayBuffer(file);
|
||||
},
|
||||
() => reject(createUploadError("读取图片信息失败", "IMAGE_READ_FAILED")),
|
||||
),
|
||||
() => reject(createUploadError("无法读取所选图片", "IMAGE_READ_FAILED")),
|
||||
);
|
||||
});
|
||||
|
||||
const createUploadId = () => {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
||||
return `app-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
|
||||
export const toConsumerOssId = (ossId) => {
|
||||
if (typeof ossId !== "string" || !/^[1-9]\d*$/.test(ossId)) {
|
||||
throw createUploadError("文件服务返回的文件 ID 无效", "OSS_ID_INVALID");
|
||||
}
|
||||
const numericId = Number(ossId);
|
||||
if (!Number.isSafeInteger(numericId)) {
|
||||
throw createUploadError(
|
||||
"文件已上传,但服务返回的文件 ID 超出 APP 安全整数范围;需由后端统一文件 ID 合同后才能关联业务数据",
|
||||
"OSS_ID_UNSAFE",
|
||||
);
|
||||
}
|
||||
return numericId;
|
||||
};
|
||||
|
||||
const normalizeUploadReceiptOssId = (ossId) => {
|
||||
if (typeof ossId !== "string" || !/^[1-9]\d*$/.test(ossId)) {
|
||||
throw createUploadError("文件服务返回的文件 ID 无效", "OSS_ID_INVALID");
|
||||
}
|
||||
return ossId;
|
||||
};
|
||||
|
||||
const toUploadReceipt = ({ ossId, url = "", thumbnailUrl = "", fileName = "" }) => ({
|
||||
// complete 的接口合同是字符串;先原样保留,避免把 int64 提前截断。
|
||||
ossId: normalizeUploadReceiptOssId(ossId),
|
||||
url,
|
||||
thumbnailUrl,
|
||||
fileName,
|
||||
});
|
||||
|
||||
export const pickAndUploadImage = async ({ requestController = null } = {}) => {
|
||||
const filePath = await pickNativeImage();
|
||||
const file = await readNativeImage(filePath);
|
||||
if (!Number.isSafeInteger(file.size) || file.size <= 0) {
|
||||
throw createUploadError("所选图片大小无效", "IMAGE_SIZE_INVALID");
|
||||
}
|
||||
const fileMd5 = calcMD5Bytes(file.data);
|
||||
const uploadId = createUploadId();
|
||||
const initPayload = {
|
||||
uploadId,
|
||||
fileName: file.fileName,
|
||||
fileMd5,
|
||||
totalSize: file.size,
|
||||
totalChunks: 1,
|
||||
chunkSize: file.size,
|
||||
contentType: file.contentType,
|
||||
};
|
||||
const initialized = await appApi.initializeResumableUpload(initPayload, { requestController });
|
||||
if (initialized.instant) return toUploadReceipt(initialized);
|
||||
await appApi.uploadResumableChunk({
|
||||
uploadId: initialized.uploadId,
|
||||
chunkIndex: 0,
|
||||
chunkMd5: fileMd5,
|
||||
filePath: file.filePath,
|
||||
}, { requestController });
|
||||
const completed = await appApi.completeResumableUpload({
|
||||
uploadId: initialized.uploadId,
|
||||
fileName: file.fileName,
|
||||
fileMd5,
|
||||
totalSize: file.size,
|
||||
totalChunks: 1,
|
||||
}, { requestController });
|
||||
return toUploadReceipt(completed);
|
||||
};
|
||||
Reference in New Issue
Block a user