feat: 优化账户切换时的页面状态一致性,增加登录回跳校验和课程阅读新逻辑
- 增加账户状态监听定时器,页面切换时自动校验账户归属,避免跨账户操作 - 新增登录回跳路径校验函数,限制仅允许指定页面回跳,防止非法跳转 - 课程页改为整篇连续阅读,底部仅一次标记已读,兼容历史记录 - 完善助手、对比页、资料填写页等多处的账户状态校验逻辑,防止错版数据提交 - 优化账户资料请求逻辑,增加版本校验,避免旧请求覆盖新数据
This commit is contained in:
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "8173db70",
|
||||||
|
"configHash": "a5b0c40d",
|
||||||
|
"lockfileHash": "e3b0c442",
|
||||||
|
"browserHash": "ce36863f",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ $projectPath = (Get-Location).Path
|
|||||||
|
|
||||||
## 当前前端交互
|
## 当前前端交互
|
||||||
|
|
||||||
个人与企业评估不提供草稿功能:填写页与确认页仅通过内存传递本次输入,离开流程、刷新或退出账户后丢弃;已接入正式提交及历史列表/详情接口,可修改原评估并增加输入版本。选图仅供本机预览,证件资料上传和数据查询尚未接入。消息、已读状态及课程章节进度已接服务器,首页同真实未读数;体验签到仍保存在当前设备。
|
个人与企业评估不提供草稿功能:填写页与确认页仅通过内存传递本次输入,离开流程、刷新或退出账户后丢弃;已接入正式提交及历史列表/详情接口,可修改原评估并增加输入版本。选图仅供本机预览,证件资料上传和数据查询尚未接入。消息已读及课程阅读记录已接服务器,首页同真实未读数;2026-10-07课程页改为整篇连续阅读,底部仅一次标记课程已读,兼容已有记录,本轮源码未发行。体验签到仍保存在当前设备。
|
||||||
|
|
||||||
个人中心顶部的头像、昵称、手机号区域统一进入修改资料;账户安全独立提供修改密码、验证码重设密码及换绑手机号。昵称和头像缓存按稳定userId隔离,手机号仅保留为内部兼容键。头像已接入后端上传及持久化,限制 500KB 以内的 JPG / PNG / WebP,服务端验证真实图片并重新编码,保存时校验附件归属,不修改融资档案的实名信息。密码登录、原密码改密及退出已接入Redis会话;短信验证和换绑尚未接入,不在本机模拟成功,也不保存密码、验证码或其散列。安全接口成功后清除当前会话并要求重新登录;验证码计时仅在服务端发送成功后启动。
|
个人中心顶部的头像、昵称、手机号区域统一进入修改资料;账户安全独立提供修改密码、验证码重设密码及换绑手机号。昵称和头像缓存按稳定userId隔离,手机号仅保留为内部兼容键。头像已接入后端上传及持久化,限制 500KB 以内的 JPG / PNG / WebP,服务端验证真实图片并重新编码,保存时校验附件归属,不修改融资档案的实名信息。密码登录、原密码改密及退出已接入Redis会话;短信验证和换绑尚未接入,不在本机模拟成功,也不保存密码、验证码或其散列。安全接口成功后清除当前会话并要求重新登录;验证码计时仅在服务端发送成功后启动。
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -541,7 +541,7 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
|
|||||||
| 22 | packages/content/news/news | 资讯分类与列表 | CONTENT-002 |
|
| 22 | packages/content/news/news | 资讯分类与列表 | CONTENT-002 |
|
||||||
| 23 | packages/content/article/article | 新闻、协议和说明内容 | CONTENT-001、CONTENT-002、FILE-003 |
|
| 23 | packages/content/article/article | 新闻、协议和说明内容 | CONTENT-001、CONTENT-002、FILE-003 |
|
||||||
| 24 | packages/content/classroom/classroom | 课程分类和推荐 | CONTENT-002 |
|
| 24 | packages/content/classroom/classroom | 课程分类和推荐 | CONTENT-002 |
|
||||||
| 25 | packages/content/lesson/lesson | 图文章节、学习状态 | CONTENT-001、CONTENT-003 |
|
| 25 | packages/content/lesson/lesson | 整篇连续阅读、课程已读状态 | CONTENT-001、CONTENT-003 |
|
||||||
| 26 | packages/finance/compare/compare | 同次评估2—3产品冻结结果对比、补选及独立重试 | PRODUCT-005 |
|
| 26 | packages/finance/compare/compare | 同次评估2—3产品冻结结果对比、补选及独立重试 | PRODUCT-005 |
|
||||||
| 27 | packages/finance/next-step/next-step | 材料准备、已存报告、资料优化及同类型新评估、上下文AI与首页 | REPORT-006 |
|
| 27 | packages/finance/next-step/next-step | 材料准备、已存报告、资料优化及同类型新评估、上下文AI与首页 | REPORT-006 |
|
||||||
|
|
||||||
@@ -813,7 +813,7 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
|
|||||||
**目标:** 后台发布的内容、图文课程和FAQ可被前端检索阅读,进度跨设备一致。 **阶段:** M3。**剩余:** 4–6人日。
|
**目标:** 后台发布的内容、图文课程和FAQ可被前端检索阅读,进度跨设备一致。 **阶段:** M3。**剩余:** 4–6人日。
|
||||||
|
|
||||||
- **现状:** 资讯/课程/搜索/首页真实接口、云端进度及发布后台已正式上线;FAQ服务端源码已完成但尚未导入/部署,真机/多设备待验收,见0.39。
|
- **现状:** 资讯/课程/搜索/首页真实接口、云端进度及发布后台已正式上线;FAQ服务端源码已完成但尚未导入/部署,真机/多设备待验收,见0.39。
|
||||||
- **前端:** 新闻、文章、课堂、课程页接真实分页/详情;处理404/下架,搜索竞态和章节完成状态。
|
- **前端:** 新闻、文章、课堂、课程页接真实分页/详情;处理404/下架和搜索竞态。2026-10-07起课程整篇连续阅读,去掉章节目录/折叠/逐节完成/进度条,底部仅一次标记课程已读;状态读取不阻止正文,历史记录与旧接口兼容,详见开发进度0.77。
|
||||||
- **后端:** 发布/下架、富文本清洗、分类与首页推荐、搜索和FAQ;稳定章节ID与进度幂等。
|
- **后端:** 发布/下架、富文本清洗、分类与首页推荐、搜索和FAQ;稳定章节ID与进度幂等。
|
||||||
- **数据:** 复用fa_acticle/fa_finance;章节数据结构化;用户章节完成关系独立存储;不引入视频和付费课程。
|
- **数据:** 复用fa_acticle/fa_finance;章节数据结构化;用户章节完成关系独立存储;不引入视频和付费课程。
|
||||||
- **接口:** /content/articles、/content/lessons、/content/lessons/:id/progress、/content/search、/help/faqs。 具体方法和字段见第5章;匹配分页/详情、公共产品详情已实现,公共产品列表仍计划新增。
|
- **接口:** /content/articles、/content/lessons、/content/lessons/:id/progress、/content/search、/help/faqs。 具体方法和字段见第5章;匹配分页/详情、公共产品详情已实现,公共产品列表仍计划新增。
|
||||||
|
|||||||
+216
-23
@@ -1,6 +1,199 @@
|
|||||||
# AI 融资平台开发进度
|
# AI 融资平台开发进度
|
||||||
|
|
||||||
基线日期:2026-09-22。文档版本:1.52。反馈与消息后端生产发布见0.62节,反馈与消息配套发布准备见0.61节,最新反馈恢复与消息组合检查见0.60节,反馈回复与站内消息联动见0.59节,会员反馈原键恢复见0.58节,小程序既有发布状态及后台反馈处理见0.57节,首页隔离回归见0.56节,后端发布见0.55节;此前章节保留阶段记录。技术方案和接口说明见[开发文档](./开发文档.md),发布材料见[反馈消息发布清单](./反馈消息发布清单.md)。本台账现有63项任务、27页面,编号与开发文档保持一致。
|
基线日期:2026-09-22。文档版本:1.67。课程整篇阅读与单一已读操作见0.77节,CONTENT-002/003/004内容、课程进度与搜索组合开发检查见0.76节,AUTH-003/004账户资料、头像及会话撤销后端隔离HTTP开发检查见0.75节,AUTH-003/004账户资料、改密及退出组合开发检查见0.74节,ASSESS-004评估新建/修改后端隔离HTTP开发检查见0.73节,ASSESS-004资料填写、确认提交与档案回显组合开发检查见0.72节,PRODUCT-004匹配读取与产品解释后端隔离开发检查见0.71节,PRODUCT-004/005匹配详情与对比历史版本组合开发检查见0.70节,CHAT-004后端消息幂等、并发额度与历史上下文隔离开发检查见0.69节,CHAT-004历史版本上下文与异常会话组合开发检查见0.68节,REPORT-006行动页与报告可用性组合开发检查见0.67节,REPORT-005微信PDF下载与导出边界开发检查见0.66节,REPORT-004历史报告与重评组合开发检查见0.65节,REPORT-003报告生成与持久化开发检查见0.64节,REPORT-002异步任务与补偿链开发检查见0.63节,反馈与消息后端生产发布见0.62节,反馈与消息配套发布准备见0.61节,最新反馈恢复与消息组合检查见0.60节,反馈回复与站内消息联动见0.59节,会员反馈原键恢复见0.58节,小程序既有发布状态及后台反馈处理见0.57节,首页隔离回归见0.56节,后端发布见0.55节;此前章节保留阶段记录。技术方案和接口说明见[开发文档](./开发文档.md),发布材料见[反馈消息发布清单](./反馈消息发布清单.md)。本台账现有63项任务、27页面,编号与开发文档保持一致。
|
||||||
|
|
||||||
|
### 0.77 2026-10-07 课程整篇阅读与单一已读操作
|
||||||
|
|
||||||
|
用户明确要求课程不分章节,简化阅读。`packages/content/lesson/lesson.uvue` 改为类似文章的连续正文,全部内容直接展示,只保留自然段落的小标题;去掉目录、折叠、编号、逐节按钮、开始/继续学习按钮、章节计数及进度条。白底阅读排版,底部仅一个“我已读完”操作,完成后显示“已读完”。匿名可直接阅读,登录只在标记已读时需要;状态读取慢或失败不阻止正文展示。
|
||||||
|
|
||||||
|
复用已上线的内容结构、章节版本与进度接口,仅前端课程页源码修改,无数据库迁移或后端接口变更。单次已读操作依次补齐未完成记录,全部成功后才显示已读;老部分记录保留,途中失败重载服务器状态再补未完成部分,避免假成功。最多30个既有段落请求,不新增后台任务。每次发送前/响应后核对账户、版本和页面代次,换账户或离页停止剩余请求,保留双击防护。后台的稳定段落ID属于兼容存储,用户界面不再提供章节学习流程。
|
||||||
|
|
||||||
|
Chrome H5 `http://localhost:5175` 手机390×844、小屏360×740、桌面1280×900最终57项、53次受控业务请求(15次合成PUT及1次合成登录POST)通过。覆盖连续正文/全部文本/无章节控件与文案、滚动到文末、一次完成/重复零写/刷新恢复、历史部分完成、慢状态可阅读且禁止写、失败状态及部分保存重试、双击仅一组、版本冲突停止、立即及保存中换账户、离页停止、匿名零进度GET/密码登录回跳、小屏按钮适配;身份、非空、无遮罩、无横溢、段落不重叠、封面及三截图通过,pageerror=0、未定义API=0,console仅刻意503/409。首轮截图图片等待为空集合误通过,补可见封面背景条件后最终57项通过,无业务修复或依赖安装。
|
||||||
|
|
||||||
|
HBuilderX5.26最终Android08:36:34、微信08:37:08,27页面编译通过。初始CLI提示HBuilderX未打开,正常启动后编译成功;新5175预览初编译等待后运行。Browser plugin not available,复用已有Playwright CLI;共享layout既有H5类型警告保留。安全证据ROOT `C:/Users/JUN/AppData/Local/Temp/airz-reading-20261007/` 内 `verification.json`、`reading-mobile.png`、`reading-end-mobile.png`、`reading-desktop.png` 及两平台最终日志。临时脚本/CLI日志含无服务权限合成会话,不整体分享。浏览器存储/cookies/routes清空并关闭专用reading会话;新5175/PID15480保留供查看。本轮开始时5173/5174/旧5175均无监听,未停止其他服务。后端只读,不读正式.env,不启动数据库/Redis/worker,不连生产或调用真实短信/模型。
|
||||||
|
|
||||||
|
仅课程页及四份相关说明更新,两仓库既有未提交保持,63任务/27页面与任务状态不变。课程“用户分章节阅读/逐节完成”需求由本轮整篇阅读/课程级已读取代,历史章节仅记录兼容实现;未发布、提交、推送或正式验收。真机/跨设备/真实后端联调未覆盖。下一项仍为内容、课程已读和搜索后端隔离HTTP检查,按当前整篇阅读语义检查历史记录兼容、幂等、版本变化、账户归属及失败恢复,不重新引入章节交互。
|
||||||
|
|
||||||
|
### 0.76 2026-10-06 CONTENT-002/003/004 内容、课程进度与搜索组合开发检查
|
||||||
|
|
||||||
|
本轮受控浏览器复现并修复六类问题:资讯详情接受另一ID、进度接受重复/外来章节、分页接受错误页码、旧课程页用新账户提交、无操作换账户仍显示旧进度,以及完成回执接受不同课程版本。修改 `services/content.uts`、`packages/content/lesson/lesson.uvue`、`news/news.uvue`、`classroom/classroom.uvue`、`search/search.uvue` 五个前端文件,后端业务源码只读。
|
||||||
|
|
||||||
|
共享解析校验对象、ID、文本、章节、课程场景/数量/版本及分页契约,详情核对请求归属和完整正文;保留列表/首页摘要的空正文/章节及后端跳过坏存储行时不足20条的合法响应。学习进度核对课程ID、版本及唯一章节,页面进一步核对章节属于当前课程;完成回执须包含所选章节并保持版本。课程可见期每秒核对账户Token,操作前及异步回包也核对账户/代次;换账户清旧进度和展开状态并刷新,离页停止检查。重复完成、加载/保存期间及隐藏页不提交;迟到401不清新会话。三类分页拒绝重复追加,失败保留已有列表和原页码供重试,刷新清分页错误;搜索补可见性守卫。
|
||||||
|
|
||||||
|
Chrome H5 `http://localhost:5175` 手机390×844、桌面1280×900最终123项、107次受控业务请求(9次合成PUT/登录POST)通过。覆盖23种坏服务响应、4种非法输入零请求、内容推荐及聚合首页共享解析、资讯下架/重试/分页/分类竞争、课程分页/场景竞争/详情归属、重复完成/双击/失败刷新重试、错版/外来章节、立即及无操作换账户/新Token/迟到读取/401/离页回包、匿名登录回跳,以及搜索关键词/类型竞争、429/重复分页/详情跳转/空/断网恢复/清空/建议。分类检查同时核对所选场景和新课程标题。六项原缺陷复测均false;pageerror=0、未定义API=0,console仅刻意404/503/401/429及net::ERR_FAILED。身份、非空、无遮罩、无横溢、图片及七截图通过,全部外部请求拦截,无真实后端写入。
|
||||||
|
|
||||||
|
HBuilderX5.26最终Android21:27:44、微信21:34:02,27页面编译通过。Android UTS前向引用采用既有回调约定;旧5174返回旧共享模块,另启5175并补齐临时HX_APP_ROOT环境标识后加载当前export。测试初期uni-button禁用断言改为组件属性并核对实际零请求,登录定位/协议勾选、匿名零GET、新Token刷新及场景页面证据补齐;早期中断结果不计最终数。Browser plugin not available,使用缓存Playwright CLI,无新增依赖;共享layout既有H5类型警告保留。两仓库diff及五源码/两台账UTF-8无BOM、主要行尾约定检查通过。
|
||||||
|
|
||||||
|
ROOT `C:/Users/JUN/AppData/Local/Temp/airz-content-combo-20261006/`,安全证据为 `verification.json`、`repro-after.json`、七张页面截图、`android-final.log`、`weixin-final.log`。临时脚本/CLI日志含无服务器权限合成会话,不整体分享。浏览器存储/cookies/routes清空,专用浏览器关闭;当前源码预览5175/PID68580保留供查看,旧5174/PID76784及另一项目5173保留,下轮核对归属再复用或停止。未启动数据库/Redis/后端/worker,未读正式.env、连接生产或调用真实短信/模型。两仓库既有未提交及前端 `%SystemDrive%/`、`scripts/`、`.vite/` 保留。本轮未正式验收、部署、提交、推送或重新发行小程序;63任务/27页面不变,任务状态保持,真机/跨设备及真实后端联调未覆盖,新增源码仍未配套发布。
|
||||||
|
|
||||||
|
下一开发入口:CONTENT-002/003/004后端内容、课程进度与搜索隔离HTTP检查。`D:/AAdesk/202609/airz/tests/content_catalog.php` 已要求显式bootstrap但固定3309且未复用report_test_bootstrap,不直接重启旧环境;先统一隔离守卫、随机本机HTTP继承及精确清理,再另建本机库/Redis。覆盖发布/下架/坏存储行、列表/详情/首页/搜索契约、鉴权/账户隔离/重复完成、章节重排保持与正文更新失效、版本冲突及事务/并发失败,不自行正式验收、接真实供应商、调用模型或沿用历史生产授权发布。
|
||||||
|
|
||||||
|
### 0.75 2026-10-06 AUTH-003/004 账户资料、头像及会话撤销后端隔离HTTP开发检查
|
||||||
|
|
||||||
|
后端 `D:/AAdesk/202609/airz/tests/api_profile.php`、`tests/api_session.php` 改用显式 `--bootstrap=<path>`,缺参在框架初始化前拒绝,复用report_test_bootstrap的隔离ROOT、本机库/Redis及不可用模型地址守卫。新增 `tests/account_http_test.php`、`tests/account_http_router.php`,随机本机HTTP、并发子进程及头像存储继承同一隔离环境;故障模式仅从runtime内本次作业文件读取。按本次合成账户、Token、评估、附件、触发器与文件精确清理,清理成功才输出PASS,异常返回非零。本轮只修改四个测试文件,前后端业务源码未修改。
|
||||||
|
|
||||||
|
资料专项268项、76次真实本机HTTP请求通过:鉴权/跨账户/非法JSON与大小/未知字段/no-store、昵称2–20字符及独立会话持久化,昵称和头像操作不改变个人/企业最新评估资料或历史输入日志。覆盖图片缺失/伪造/GIF/500KB/4096边长/400万像素及重编码后超限拒绝,PNG/JPEG/WebP重新编码为PNG、去尾随内容及安全元数据;上传不自动绑定,附件归属/类别/storage/路径/实体文件校验、avatarId省略保持和null恢复默认。数据库触发器验证头像入库失败删除已写文件、资料更新失败昵称/头像一起回滚;目录阻塞与移除故障后重试通过。
|
||||||
|
|
||||||
|
会话专项281项、69次真实本机HTTP请求通过:密码/JSON/Bearer边界、撤销/过期Token、禁用账户、独立会话、旧MD5+salt仅成功登录升级。退出驱动故障503保留Token供重试,成功只撤当前会话;非法/错误旧密码、数据库与Redis撤销故障保留原密码,成功改密明确reLoginRequired并撤销本账户全部Token,另一账户保持。晚期改密Hook失败回滚密码但已撤Token,旧密码重新登录可恢复;登录Hook失败回滚数据库并清新Token。8/32位密码边界通过,MySQL行锁与双PHP进程验证三轮登录/改密竞争后旧密码不会留下有效Token,以及两个同旧密码改密恰好一个成功。短信/auth返回503,账户短信/换绑无路由404,不产生假成功。合计549项/145次本机HTTP,不记作正式验收。
|
||||||
|
|
||||||
|
全新ROOT `C:/Users/JUN/AppData/Local/Temp/airz-account-backend-20261006-1791290960616/`,MySQL5.7.26仅127.0.0.1:3323、Redis5.0.14.1仅127.0.0.1:6403/DB15,模型锁为127.0.0.1:1并关闭,无worker。26表由schema初始化,未读正式.env、连接生产或调用真实短信/模型,无新增依赖或迁移。初期MySQL启动参数、合成ID类型、测试Token导入及空JSON数组/对象预期已修正;收尾当前PHP Redis扩展没有shutdown方法,改用已有redis-cli正常关闭。中断结果不计最终通过数。最终七业务表、故障触发器、Redis键及头像目录均0,测试库已DROP,专用HTTP/MySQL/Redis停止,3323/6403无监听。四文件PHP7.4 lint、两个缺bootstrap拒绝、两仓库diff及变更文本UTF-8无BOM/LF行尾检查通过。
|
||||||
|
|
||||||
|
安全证据为ROOT内 `verification.json`、`cleanup.json`、`services.json`,最终日志为 `profile-final.out`/`profile-final.err`、`session-final.out`/`session-final.err`,最终stderr均空;临时bootstrap/runtime/工具文件不整体分享或直接复用,下次另建隔离环境。前端未变,沿用0.74最终浏览器及微信/Android编译证据,不重复。5174/PID76784预览已核对归属并保留,另一项目5173保持;两仓库既有未提交及前端 `%SystemDrive%/`、`scripts/`、`.vite/` 保留。本轮未正式验收、部署、提交、推送或重新发行小程序。63任务/27页面不变,AUTH-003/004仍开发中,完整登录/换绑受短信供应商阻塞,真机/跨设备及正式验收待补;此前新增业务源码仍未配套发布。
|
||||||
|
|
||||||
|
下一开发入口:CONTENT-002/003/004内容、课程进度与搜索组合开发检查。核对列表/详情ID与下架状态、课程进度按账户及章节版本隔离、重复完成/失败重试/迟到回包、搜索关键词/分类分页与旧响应竞争。先用受控API响应和现有H5预览;如需后端HTTP,先核对脚本显式隔离入口并另建本机环境。不重复本轮账户专项、自行接供应商、正式验收、真实短信/模型调用或沿用历史生产授权发布。
|
||||||
|
|
||||||
|
|
||||||
|
### 0.74 2026-10-06 AUTH-003/004 账户资料、改密及退出组合开发检查
|
||||||
|
|
||||||
|
本轮复现并修复同手机号换Token后仍提交旧页资料、个人中心保留旧账户内容、旧退出确认框退出新账户、离页改密回包主动跳转,以及保存后迟到GET覆盖新昵称缓存五类问题。修改账户服务、个人中心、编辑资料、安全、改密、换绑及验证码组件七个文件;业务状态仍以服务端为准,昵称与评估实名独立,未增加页面或数据表。
|
||||||
|
|
||||||
|
页面操作、弹窗/选图回调、异步回包及可见期核对Token、userId与请求代次;换账户清理旧内容或重新进入入口,离页停止定时检查。资料响应校验userId、手机号掩码和字段类型,头像必须返回有效附件编号及非空地址,改密/换绑须明确reLoginRequired;成功资料写入使旧GET失效,失败保存保留输入以便重试。改密成功后即使原页面已隐藏,也只清理原账户被撤销的本机会话,不跳转隐藏页面或影响新账户;密码/验证码离页清空。退出确认和回包绑定原页面与账户,失败保留会话、可重试,当前账户缓存及内存评估流程清理沿用既有基础设施,未确认签到/反馈命令按既有约定保留。
|
||||||
|
|
||||||
|
Chrome H5 `http://localhost:5174` 手机390×844、桌面1280×900最终103项、48次受控业务请求(21次合成写请求)通过。覆盖坏资料/错归属/掩码/头像/改密回执、密码与双验证码校验、同手机号Token变化、迟到GET与编辑/保存竞争、昵称不改评估实名、保存失败重试、头像大小/过期选图/坏回执/预览/默认头像/离页回包、个人中心自动换账户、旧评估菜单及退出确认、退出失败重试/迟到回包/缓存清理、改密失败/确认/显示控制/切验证方式/成功重登录/离页清密/迟到401、短信未开放/目标号码变化/离页不倒计时或提示成功、换绑未开放和安全页会话过期。页面身份/非空/无遮罩/横溢及六截图通过,pageerror=0、未定义API=0;console仅刻意注入的401/409/422/503。全部外部请求拦截,不把合成改密回执当作真实服务端全会话撤销证据。
|
||||||
|
|
||||||
|
HBuilderX5.26最终Android20:38:52、微信20:39:33,27页面编译通过。初期脚本的返回页预期、uni-input事件等待、同名占位文字及隐藏组件定位问题已修正,早期中断结果不计最终通过数。Browser plugin not available,使用已有Playwright CLI,无新增依赖;共享layout既有H5类型警告保留。两仓库diff检查、七文件及两台账UTF-8无BOM/既有主要行尾约定通过。测试脚本、日志和截图放在 `C:/Users/JUN/AppData/Local/Temp/airz-auth003004-20261006/`,安全证据为 `verification.json`、六张截图、`android-final.log`、`weixin-final.log`;脚本/CLI日志含无服务器权限的合成会话,不整体分享。存储/cookies/routes已清空,验证浏览器关闭。本轮预览服务5174/PID76784保留供查看,启动命令属于本项目、输出为ROOT/h5;下一会话须核对归属再复用或停止。另一项目5173/PID68536保持。
|
||||||
|
|
||||||
|
后端只读核对Session/AccountProfile契约和路由;未读取正式.env、启动数据库/Redis/worker、连接生产或调用真实短信/模型。当前只实现原密码改密;账户短信及手机号换绑无正式路由,保持未开放,完整AUTH-004仍受短信供应商阻塞。用户此前小程序发布状态、前端 `%SystemDrive%/`、`scripts/`、`.vite/` 及两仓库所有既有未提交保留。63任务/27页面不变,AUTH-003/004仍开发中,本轮未正式验收、部署、提交、推送或重新发行小程序;新增业务源码尚未配套发布,真机/真实渠道/跨设备未覆盖。
|
||||||
|
|
||||||
|
下一开发入口:AUTH-003/004账户资料、头像及会话撤销后端隔离HTTP检查。旧 `D:/AAdesk/202609/airz/tests/api_profile.php`、`tests/api_session.php` 在参数检查前初始化默认环境且HTTP固定127.0.0.1,不得直接运行;先适配显式隔离bootstrap、随机本机HTTP继承、独立头像存储和精确清理,再另建本机库/Redis。验证鉴权/越权、昵称与评估资料独立、头像附件归属/文件检查、持久化及事务失败、退出当前会话、改密全部会话撤销和未开放短信拒绝。不自行接供应商、调用真实短信或模型、正式验收、部署或重新发行小程序。
|
||||||
|
|
||||||
|
### 0.73 2026-10-06 ASSESS-004 评估新建/修改后端隔离HTTP开发检查
|
||||||
|
|
||||||
|
`D:/AAdesk/202609/airz/tests/api_assessments.php` 改用显式 `--bootstrap=<path>`,在框架初始化前拒绝缺参;复用report_test_bootstrap本机库/Redis、独立ROOT及不可用模型地址守卫。夹具采用inputVersion=3与当前AssessmentStore,删除评估编号等同资料主键、旧加密身份及资料表保存任务状态的过时假设。新增 `tests/assessment_http_router.php`,随机本机HTTP服务继承同一bootstrap/随机队列,分析开关只从隔离runtime文件读取,不通过业务请求注入。双进程同样继承入口、队列和runtime内作业文件,用stdin闸门验证MySQL事务竞争;按本次账户、产品、队列、故障触发器和临时文件精确清理,清理成功后才输出PASS,异常返回非零。
|
||||||
|
|
||||||
|
专项514项、158次真实本机HTTP请求通过。覆盖匿名/撤销Token/跨账户拒绝、成功及业务拒绝no-store、JSON对象/大小、金额两位元字符串与上下限、12.345678万元对应123456.78元、企业0经营月和0.01元收入、非法v1/v2/格式/额外字段/附件授权/信用卡一致性、只读脱敏与本人完整回填、取消需求字段不返回。验证新建独立会话但每用户每类型仅一份最新资料、修改读取本评估而非另一独立记录、个人和企业资料互不覆盖、原键回执不回写最新资料、同键改内容/错作用域冲突、非法或过时baseRevisionId及类型修改拒绝、修改产生新版本和历史输入不变。双进程同键新建/修改各只生成一条输入,异键同基准修改仅一方成功,另一方REVISION_CONFLICT且无额外输入。
|
||||||
|
|
||||||
|
数据库触发器注入资料更新失败,真实HTTP500时新建会话/版本日志及修改指针/最新资料完整回滚,故障移除后原键可安全提交。分析启用时HTTP提交保存实际queued任务,处理中拒绝新修改,同键重放不增加预留;显式假客户端完成个人首次成功、重评失败和原键重试成功,旧报告在处理中/失败可读,新成功才发布新版本,旧完整快照不变、迟到旧任务零调用。企业成功及失败重评也保留已发布报告与最新提交收入。23版历史分页、独立评估分页、类型/状态过滤、错版本/非法数组/错误方法及软删除原键拒绝通过。专项只执行5次显式假调用,不启动消费worker或真实模型;既有 `tests/assessment_revisions.php` 58项通过,合计572项,不记作真实语义或正式业务验收。
|
||||||
|
|
||||||
|
全新ROOT `C:/Users/JUN/AppData/Local/Temp/airz-assessment-backend-20261006-1791288138879/`,MySQL5.7.26仅127.0.0.1:3322、Redis5.0.14.1仅127.0.0.1:6402/DB15,模型地址锁为127.0.0.1:1。26张表由schema初始化,PDF依赖/字体仅复制现有锁定版本,未安装软件;未读正式.env、连接正式库/Redis/worker或调用真实模型。初期合成用户名超长、MySQL启动参数及ThinkPHP执行测试DDL的未缓冲查询问题已修正,故障DDL使用独立PDO,早期未完成结果不计入最终数。最终六业务表、故障触发器和Redis键均0,测试库已DROP,专用HTTP/MySQL/Redis停止,3322/6402无监听。两测试文件PHP7.4 lint、缺bootstrap拒绝、两仓库diff及变更文本UTF-8无BOM检查通过。
|
||||||
|
|
||||||
|
安全证据为ROOT内 `verification.json`,最终日志为 `assessment-final.out`、`assessment-final.err`、`revisions-final.out`、`revisions-final.err`;bootstrap/runtime/工具文件含合成配置,不整体分享或直接复用。前端及后端业务源码未变,不重复平台编译或浏览器检查,沿用0.72最终证据。两仓库全部既有未提交、前端 `%SystemDrive%/`、`scripts/`、`.vite/` 保留,本轮无提交/推送/部署或小程序发行。63任务/27页面不变,ASSESS-004仍开发中,真实渠道、真机和正式验收未覆盖,此前新增业务源码尚未配套发布。
|
||||||
|
|
||||||
|
下一开发入口为AUTH-003/004账户资料、改密及退出组合开发检查:核对个人中心与编辑资料的账户隔离/迟到响应、昵称不覆盖评估实名、资料保存失败重试、改密和退出的会话撤销及旧页清理;短信/换绑未开放状态不产生假成功。先使用受控响应,若检查后端HTTP须先核对脚本隔离入口,另建本机环境。不要重复评估/匹配/助手专项、自行正式验收、真实短信/模型调用或沿用历史发布授权部署。
|
||||||
|
|
||||||
|
### 0.72 2026-10-06 ASSESS-004 资料填写、确认提交与档案回显组合开发检查
|
||||||
|
|
||||||
|
复现并修复五类问题:修改回填缺失revisionId仍被接受、修改提交接受另一评估的回执、确认页换账户后仍发送旧资料、离页后迟到提交回执跳转分析页、档案页换账户后继续显示旧资料。`packages/finance/services/records.uts` 核对评估/版本编号、类型、状态、版本序号、已发布版本及分页归属/游标;修改回填须有元单位与完整对象,修改回执须属于原评估、状态accepted且产生不同于baseRevisionId的新版本。详情和完整度保持显式版本,坏页不推进分页。新建、修改继续分开幂等作用域,同内容重试保留原键。
|
||||||
|
|
||||||
|
填写、确认、档案三页增加可见期账户检查与离页停止;操作前及异步响应后校验账户/请求代次,换账户清空旧表单和档案,迟到401不清新会话。选图和操作菜单回调也核对代次;档案重新加载后重启账户检查,等待版本历史结束后再次核对详情,避免清空后访问空详情。个人/企业新建仍为空白独立评估,修改和历史档案修改入口读取本评估最新完整资料,通过baseRevisionId创建新版本。
|
||||||
|
|
||||||
|
Chrome H5 `http://localhost:5174` 手机390×844、桌面1280×900最终96项通过,72次受控业务请求、13次合成POST。覆盖服务缺版本/错评估/非法状态/错回执/分页、个人及企业新建提交与修改、零经营月数/零负债、12.345678万元准确转换为123456.78元、同键重放/改内容换键、附件授权未开放拒绝、确认与档案脱敏回显、完整度错版局部重试、重评失败保留旧成功结果入口、历史版本详情及最新资料修改、无操作/立即操作的账户变化、重载守卫恢复、离页与换账户迟到回包。页面身份/非空/无框架遮罩/无横溢及四张截图通过,pageerror=0,console仅刻意注入的401,无未定义API请求。全部外部请求拦截,无实际后端或模型调用;失败保留报告的真实持久化沿用0.65后端证据,本轮只验证页面入口,不把合成回包当作正式验收。
|
||||||
|
|
||||||
|
HBuilderX5.26最终源码Android18:29:34、微信18:30:24编译成功,63任务/27页面不变。首次Android检查发现Number(string)不兼容Kotlin,改为parseInt后通过。脚本字段定位、锁定类型/自定义按钮状态断言、立即操作与自动等待的竞争、只读夹具脱敏及预览加载超时已修正;更换专用浏览器并使用DOM就绪等待后完整检查通过,早期未完成结果不计入最终通过数。Browser plugin not available,复用缓存Playwright CLI run-code --filename,无新增依赖;共享layout既有H5类型警告保留。源码diff检查及变更文本UTF-8无BOM检查通过,保留仓库既有行尾约定。
|
||||||
|
|
||||||
|
仓库外ROOT `C:/Users/JUN/AppData/Local/Temp/airz-assess004-20261006/`,安全结果为 `verification.json`,截图为 `confirm-mobile.png`、`archive-mobile.png`、`confirm-desktop.png`、`archive-desktop.png`。临时 `repro.js`、`verify.js` 和CLI日志含无服务器权限的合成会话,不整体分享或直接复用。浏览器存储/cookies/routes清空,两个专用浏览器均关闭;核对命令归属后停止本轮5174/PID76016,端口无监听,另一项目5173/PID68536保留。未启动数据库、Redis、后端或worker,未读取正式.env、连接生产或调用真实模型;后端仅只读核对接口/脚本。两仓库既有未提交和前端 `%SystemDrive%/`、`scripts/`、`.vite/` 保留,无提交/推送/部署或小程序发行。
|
||||||
|
|
||||||
|
ASSESS-004仍开发中,真机、Safari/微信内H5、真实渠道及正式验收未覆盖;本轮源码与此前新增业务源码尚未配套发布。下一开发入口为评估新建/修改后端隔离HTTP检查:旧 `D:/AAdesk/202609/airz/tests/api_assessments.php` 在检查参数前初始化默认环境,夹具也未声明inputVersion=3,不得直接运行;先适配显式隔离bootstrap、当前版本存储、子进程/本机HTTP继承及精确清理,再另建本机库/Redis和假模型。`tests/assessment_revisions.php` 已接显式report_test_bootstrap,可作为版本行为回归入口。不要重复匹配/助手专项、自行正式验收、真实模型调用或沿用历史发布授权部署。
|
||||||
|
|
||||||
|
### 0.71 2026-10-06 PRODUCT-004 匹配读取与产品解释后端隔离开发检查
|
||||||
|
|
||||||
|
后端 `tests/match_records.php`、`tests/product_explanations.php` 改用显式 `--bootstrap=<path>`,复用 `report_test_bootstrap.php` 的隔离ROOT、本机数据库/Redis和不可用模型地址守卫;缺参时在框架初始化前拒绝。新增 `tests/match_test_fixtures.php`,采用当前inputVersion=3与AssessmentStore,故障夹具同时更新已发布版本日志及最新结果镜像,避免沿用只改match_result的旧存储假设。演示产品仅在空隔离目录表中导入,并发子进程继承同一bootstrap、随机队列和作业文件;新增 `tests/match_http_router.php` 将本机HTTP接到相同隔离环境。测试失败或清理异常返回非零,清理成功后才输出PASS,按本次账户、产品和队列精确清理。
|
||||||
|
|
||||||
|
匹配专项238项、101次真实本机HTTP请求通过。覆盖Bearer/越权/撤销Token、参数与方法、no-store、三态分组/分页/游标、错评估和错版本、损坏快照及当前目录故障;个人/企业重评处理中与失败时默认继续读取旧成功版,新版成功后默认切到新版,旧游标仍能继续读取原快照,历史解释只读且拒绝新调用。核对读取及被拒绝的历史解释不改变输入、最新资料、日志或消息,不预留模型调用。修正零候选夹具:临时为“普惠基础融资贷”增加必不满足的收入规则,得到completed状态的0 eligible/3 ineligible/1 unknown,与未运行及v1旧版明确区分;finally恢复原产品规则。HTTP同键解释请求重放只保留一次预留。
|
||||||
|
|
||||||
|
产品解释专项52项通过,覆盖双进程同键预留、重复消费者不重复调用、成功缓存、解释更新不破坏游标/其他冻结字段/报告、身份资料不进入模型、reasonId重排及非法输出拒绝、失败同键不重试/新键人工重试和三次上限、过期租约、迟到回复仅补用量、持久待办、unknown三态保持、共享每日次数及删除记录拒绝。两项专项合计290项,使用假模型,不将本机HTTP与假模型结果记作真实语义或正式业务验收。本轮仅修正测试脚本/夹具,后端业务源码和前端业务源码未修改。
|
||||||
|
|
||||||
|
隔离ROOT `C:/Users/JUN/AppData/Local/Temp/airz-match-backend-20261006-1791279449016/`,MySQL仅127.0.0.1:3321、Redis仅127.0.0.1:6401/DB15,模型地址锁为127.0.0.1:1;未读取正式.env、连接生产数据库/Redis/worker或调用真实模型。最终隔离账户、产品、会话及日志均0,Redis DB15无键,专用HTTP与MySQL/Redis已停止;收尾再次核对3321/6401无监听。四个本轮PHP文件7.4语法检查、两个缺bootstrap拒绝检查、两仓库diff检查及35个变更文本UTF-8无BOM检查通过。安全结果为ROOT内 `verification.json`,最终日志为 `match-final.out`、`explain-final.out`;早期结果日志不作为最终通过数,临时bootstrap/runtime/工具文件不整体分享或直接复用。
|
||||||
|
|
||||||
|
前端源码未变,不重复浏览器及微信/Android编译,沿用0.70平台证据。63任务/27页面不变,PRODUCT-004开发中、PRODUCT-005待验收;真实产品、跨浏览器/真机、生产容量及正式验收仍未覆盖。两仓库既有未提交及前端 `%SystemDrive%/`、`scripts/` 保留,本轮未提交/推送/部署或重新发行小程序,此前新增业务源码仍未配套发布。下一开发入口为ASSESS-004资料填写、确认提交与档案回显组合检查,关注个人/企业新建空白与修改最新资料、重评失败保留历史、金额精度、同键重放、账户变化和迟到回包;继续使用受控响应及显式隔离环境,不重复本轮匹配专项或自行正式验收、真实模型调用及生产发布。
|
||||||
|
|
||||||
|
### 0.70 2026-10-06 PRODUCT-004/005 匹配详情与对比历史版本组合开发检查
|
||||||
|
|
||||||
|
复现并修复四类问题:列表追加不同快照分页、未指定版本入口刷新切到新版、账户切换后发出旧产品解释请求、账户切换后仍能进入旧对比详情。匹配服务核对有效revisionId/resultVersion、产品编号与版本、三态、当前可用性、分组/重复ID/分页游标/统计及快照元数据;详情和解释复用冻结记录校验,公共产品独立核对编号与版本。列表分页校验前页快照/统计及重复行,失败保留首屏和原游标,新增局部重试;对比锁定首个成功快照,单列失败与重试不更换锚点。
|
||||||
|
|
||||||
|
三个页面补发起前账户核对、可见期账户守卫及离页/迟到响应清理。换账户清空旧选择和资料,旧401不清新会话。首次读取成功后固定revisionId并绑定选择缓存,H5地址保留版本、分组和实际选择,刷新、移除、补选、切栏保持同版。登录增加resumeMatch白名单,恢复匹配/详情/对比的版本、选择、产品、栏目及来源;历史产品解释只读,失败对比不以公共产品补齐。
|
||||||
|
|
||||||
|
Chrome H5 `http://localhost:5173`,390×844与1280×900共97项通过、104次受控请求。覆盖服务错版/错产品/无版本/缺快照/非法状态/错分组/游标,v1未执行与completed零候选、零利率、下架/unknown与冻结额度/材料、显式旧版及新版默认回包、跨组三项选择、补选/移除/刷新/切栏、404/503单列重试、分页503/错快照/重复行局部重试、换组迟到分页、账户变化/迟到401/立即旧动作/无操作清空、三个合成登录回跳、非法参数、解释原键重试/错快照轮询/查询恢复/次数上限和离页迟到详情。全部外部请求拦截,6次合成POST为3次解释及3次登录,无真实模型或后端连接;重评发布行为沿用0.65后端证据,本轮不生成真实重评任务。页面身份/非空/无遮罩/无横溢及两截图通过,pageerror=0,console仅刻意401/404/503。初期脚本require、正则分组、重复查询参数和按钮文案定位错误已修正,不计通过数。
|
||||||
|
|
||||||
|
HBuilderX5.26最终源码微信17:18:58、Android17:19:47编译成功,63任务/27页面不变;共享layout既有H5类型警告保留。Browser plugin not available,使用缓存Playwright CLI run-code --filename,无新增依赖。仓库外安全证据 `C:/Users/JUN/AppData/Local/Temp/airz-product-combo-verification.json`、`airz-product-combo-mobile.png`、`airz-product-combo-desktop.png`;临时脚本 `airz-product-combo-repro.js`、`airz-product-combo-qa.js` 和CLI日志含无服务权限的合成会话,不整体分享。浏览器存储/cookies/routes清空并关闭;本轮localhost预览停止,另一个17:12:56启动的127.0.0.1:5173 Vite进程并非本轮命令,保留。未启动数据库/Redis/后端/worker,未读默认.env、连接正式环境或修改后端源码;全部既有未提交及未跟踪工作保留,无新增未跟踪Vite缓存。
|
||||||
|
|
||||||
|
PRODUCT-004仍开发中、PRODUCT-005待验收,真实产品、跨浏览器/真机和正式验收未覆盖;本轮不提交/推送/部署或小程序发行。此前REPORT-002/003/004后端、档案修复、REPORT-005微信PDF、REPORT-006、CHAT-004及本轮源码仍未配套发布。下一入口为匹配只读与产品解释后端的当前存储/历史版本隔离检查:`tests/match_records.php`、`tests/product_explanations.php` 在参数检查前初始化默认环境,仍有inputVersion=2及直接改match_result夹具;先改显式隔离bootstrap、当前AssessmentStore、子进程继承/HTTP路由及精确清理,再另建本机库/Redis并使用假模型,不能直接运行旧脚本或沿用历史发布授权。
|
||||||
|
|
||||||
|
### 0.69 2026-10-06 CHAT-004 后端消息幂等、并发额度与历史上下文隔离开发检查
|
||||||
|
|
||||||
|
`D:/AAdesk/202609/airz/tests/assistant_chats.php` 改为显式隔离 `--bootstrap=<path>`,复用 `report_test_bootstrap.php` 的本机库/Redis/模型地址守卫;缺少入口时在框架初始化前拒绝。夹具采用当前inputVersion=3及AssessmentStore版本存储,并发子进程继承同一隔离入口、队列和显式作业文件。新增 `tests/assistant_http_router.php` 只允许本机专用标记,补PATH_INFO并接相同隔离配置;失败及清理错误返回非零,PASS在清理成功后输出。测试只删除本次账户、产品和随机队列,不修改默认.env。
|
||||||
|
|
||||||
|
助手专项165项通过,覆盖双进程同键创建/发送/失败重试、不同键串行、重复消费者仅一次假调用、原回执重放不重复预留、暂停/额度耗尽后成功回执仍可确认、助手/产品解释共享评估剩余额度、失败及软删除日志仍占当日次数、有效/过期租约、迟到用量、历史分页、脱敏及金额守卫。个人重评处理中、失败、重试成功后,旧显式/无指定版本会话继续引用冻结收入和产品;产品改名/下架不改旧上下文,新会话读取新版。失效会员及删除会话的待办不进入模型。18次真实本机HTTP请求检查Bearer、越权、回执丢失重放、同键改文409、额外字段422、错误方法按既有精确路由404、历史预览/恢复/冲突、非法数组版本404、撤销Token401及全部no-store;读取历史不预留调用。
|
||||||
|
|
||||||
|
组合检查复现并修复一处发布事务缺陷:`application/common/library/AssessmentStore.php` 原来在旧报告无queued/running解释时仍重写旧输入日志,同一秒的无变化UPDATE返回0,被误判为保存失败并最终标AI_INTERNAL。现在只在确实关闭旧解释时更新旧快照,无待关闭解释保留整条旧版本行;原锁定读取、结构校验、真实写故障回滚和待关闭解释日志要求保持。本轮增加旧日志同秒及逐列不变检查;报告持久化75项、评估版本58项、历史报告215项回归通过,合计513项,包含四处SQL写故障回滚及旧queued/running解释同事务关闭。三个本轮PHP文件7.4 lint、两仓库diff和UTF-8无BOM检查通过。
|
||||||
|
|
||||||
|
全新ROOT `C:/Users/JUN/AppData/Local/Temp/airz-assistant-backend-20261006-1791275687879/`,MySQL5.7.26仅127.0.0.1:3310随机库 `airz_product_test_1791275687879`、Redis5.0.14.1仅127.0.0.1:6398/DB15;无默认.env、正式3306/6379、线上库或生产worker连接,真实模型0,供应商地址锁为不可用的127.0.0.1:1。初始MySQL回环认证、旧夹具断言及临时ROOT缺PDF依赖导致的失败已修正,未计入通过数;PDF使用复制的现有锁定运行依赖/字体,无安装或模板变更。收尾六业务表0、故障触发器0、Redis键0,库已DROP,MySQL/Redis正常停止、专用PHP HTTP停止,3310/6398/8098/5173无监听。安全证据为ROOT内 `verification.json`;结果日志为 `assistant-result.log`、`persistence-result.log`、`revisions-result.log`、`history-result.log`。其余临时runtime/工具文件保留,不整体分享或复用bootstrap,后续另建隔离环境。
|
||||||
|
|
||||||
|
CHAT-004保持开发中,CHAT-001—003待验收,63任务/27页面不变;真实语义、供应商费用对账、生产容量和真机未在本轮验证。前端源码未变,不重复微信/Android编译或浏览器检查,沿用0.68平台证据。两仓库全部既有工作保留,本轮未提交/推送/部署或小程序发行,新增后端修复尚未上线。下一开发入口为PRODUCT-004/005匹配列表、产品详情与对比的历史/重评版本组合检查,关注分页/补选/刷新/列失败重试、当前可用性与冻结值、账户变化及迟到回包,不重复本轮助手专项或自行正式验收、真实模型调用及生产发布。
|
||||||
|
|
||||||
|
### 0.68 2026-10-06 CHAT-004 历史版本上下文与异常会话组合开发检查
|
||||||
|
|
||||||
|
受控回包复现并修复五类问题:预览、历史恢复和创建会话接受错版上下文,发送接受另一请求的回执,以及账户切换后立即发送时旧草稿先携新Token发出。`packages/finance/services/assistant.uts` 统一核对评估、产品列表、显式版本、产品展示ID及版本有效性,创建时校验会话编号;没有显式绑定的历史入口仍恢复会话原冻结上下文。发送回执核对requestId、原文和尝试序号,拒绝旧尝试,允许原键重放返回已推进的尝试。`packages/finance/assistant/assistant.uvue` 在发送请求前核对页面代次和账户,立即切换账户后首次发送、已有会话发送及旧请求重试均不会发出。
|
||||||
|
|
||||||
|
Chrome H5 `http://localhost:5173` 手机390×844、桌面1280×900共62项通过,81次受控API请求、33次合成POST。覆盖普通/报告/产品上下文、错版/错评估/错产品/缺版本/非法会话、显式历史绑定与无绑定恢复、发送/刷新/来源返回、错回执及原键重放、429/503、断网回执丢失、排队阻止重复发送、轮询完成、确认失败后递增尝试和重试上限、历史分页、账户变化及迟到上下文/401、立即换账户发送与重试、合成登录恢复和非法参数。全部API在浏览器内拦截,无未定义请求或实际后端连接;本轮额度检查仅证明错误提示及原键恢复,不作为后端配额并发证据。
|
||||||
|
|
||||||
|
页面身份、非空、无框架遮罩、手机/桌面无横向溢出及截图通过;pageerror=0,console仅刻意429/503/401及net::ERR_FAILED。微信16:23:43、Android16:25:31 HBuilderX5.26最终源码编译成功,63任务/27页面不变,共享layout既有H5类型警告保留。Browser plugin not available,使用缓存Playwright CLI的 `run-code --filename`,未安装新依赖。
|
||||||
|
|
||||||
|
证据为仓库外 `C:/Users/JUN/AppData/Local/Temp/airz-chat004-result.json`、`airz-chat004-mobile.png`、`airz-chat004-desktop.png`,最终脚本为 `airz-chat004-qa.js`;初始复现脚本为 `airz-chat004-repro.js` 与 `airz-chat004-switch-repro.js`。结果JSON不含凭据,临时脚本/CLI日志含无服务器权限的合成会话,不整体分享。浏览器存储、cookies和拦截路由已清理,跳转about:blank后关闭专用浏览器,5173预览停止;本轮Vite生成的两个未跟踪缓存文件已清除。未启动数据库/Redis/后端/worker,未读取默认.env、连接生产或调用真实模型;后端源码只读,保留两仓库及前端 `%SystemDrive%/`、`scripts/` 全部既有工作,无提交/推送/部署或小程序发行。
|
||||||
|
|
||||||
|
CHAT-004保持开发中,CHAT-001—003仍待验收;真实语义、真机/跨浏览器及生产容量未在本轮验证。下一开发子项为助手后端消息幂等、并发额度与历史上下文的隔离检查,先将旧 `tests/assistant_chats.php` 改为显式隔离bootstrap,适配当前inputVersion=3/版本存储并核对并发子进程和精确清理,再在新建本机数据库/Redis及假模型中检查;不得直接运行旧脚本加载默认.env。前轮REPORT-002/003/004后端、档案修复、REPORT-005微信PDF、本轮及REPORT-006前端源码尚未配套发布,不自行正式验收、真实模型调用或复用历史发布授权。
|
||||||
|
|
||||||
|
### 0.67 2026-10-06 REPORT-006 行动页与报告可用性组合开发检查
|
||||||
|
|
||||||
|
本轮修复 `packages/finance/next-step/next-step.uvue` 两处展示问题:行动页补报告版本及最新提交正在评估、失败保留上次成功结果或已有新版的提示;v1旧版报告及v2未完成产品匹配时,入口改为“查看准备说明”,材料区明确暂不提供产品材料,不引导选择不存在的匹配产品。版本和状态直接消费已保存报告,不重算历史。既有产品冻结版本、当前可用性、账户变化与迟到响应守卫保持。
|
||||||
|
|
||||||
|
Chrome H5在 `http://localhost:5173` 使用全量API受控合成响应,手机390×844、桌面1280×900共38项通过。覆盖个人/企业、显式历史版本、重评处理中/失败、v1兼容、匹配未就绪、可用产品冻结材料、产品下架/状态未知/条件不符/待核实、错版/错快照拒绝、无报告/404/409、账户切换及迟到响应。实际点击报告和AI入口保留revisionId=72,AI上下文请求同版;优化资料回填当前评估最新提交,不新建评估或自动提交;合成密码登录后恢复assessmentId/productId/section/revisionId,非法参数不发业务请求。全部业务请求仅GET与一次合成登录POST,无评估/会话创建、真实后端连接或模型调用。
|
||||||
|
|
||||||
|
页面身份、非空、无框架遮罩、手机/桌面无横溢及截图通过;pageerror=0,console仅刻意注入的404/409资源错误,无未定义请求。Browser plugin not available,使用缓存Playwright CLI `run-code --filename`。测试前期的长参数空输出、直接修改同页hash未触发onLoad、uni按钮角色及助手组合标题定位问题已修正,不计作业务通过。微信15:57:28、Android15:58:55 HBuilderX5.26本地编译成功,63任务/27页面不变;共享layout既有H5类型警告未改。
|
||||||
|
|
||||||
|
临时测试代码与证据均在仓库外:`C:/Users/JUN/AppData/Local/Temp/airz-report006-boundaries.js`、`airz-report006-verification.json`、`airz-report006-20261006-processing-mobile.png`、`airz-report006-20261006-actions-desktop.png`。本轮生成的CLI日志/快照移至 `C:/Users/JUN/AppData/Local/Temp/airz-report006-cli-artifacts/`。浏览器存储/路由已清理,专用浏览器关闭,5173预览停止;没有启动数据库/Redis/后端/worker,不读取默认.env、不连接生产。本轮仅行动页及两份台账新增改动,保留全部既有工作;未提交/推送/部署、未重新发行小程序。
|
||||||
|
|
||||||
|
REPORT-006仍待验收,真机、跨浏览器、真实产品材料/办理渠道及正式业务验收未覆盖。下一开发入口为CHAT-004历史版本上下文与异常会话组合检查,优先核对预览/创建/历史恢复绑定、账户变化与迟到响应、失败重试/额度错误;继续使用隔离数据和假模型,不自行真实调用、发布或正式验收。前轮REPORT-002/003/004后端及REPORT-005微信PDF源码尚未配套发布。
|
||||||
|
|
||||||
|
### 0.66 2026-10-06 REPORT-005 微信PDF下载与导出边界开发检查
|
||||||
|
|
||||||
|
用户反馈小程序导出提示“当前客户端暂未开放PDF下载”,原因是共享服务此前只开放H5。`packages/finance/services/report-export.uts` 新增MP-WEIXIN分支,经 `uni.downloadFile` 携带Bearer请求头和显式revisionId下载,再以 `uni.openDocument` 打开PDF并显示保存/转发菜单;报告页与下一步行动页共用入口,无新增页面或后端数据库改动。令牌不写URL/文件名;仅使用临时文件,预览期间保留,返回小程序或下次导出时清理,删除失败保留路径重试。其他原生端及微信内H5的既有提示保持。
|
||||||
|
|
||||||
|
下载边界包含HTTPS、评估/版本编号、HTTP状态、PDF MIME、头尾标志、未压缩Content-Length一致性及20 MiB上限;小程序通过响应头、下载进度和实际文件大小检查超限,PDF只读取5字节头及最多1024字节尾。下载中取消立即拒绝并abort,迟到文件清理;旧401不清新账户,换账户不打开旧报告。系统预览接管后忽略组件销毁的cancel,避免删除正在预览的文件;下载域名、网络、超时、服务端JSON与预览失败分别提示。H5同步增加显式版本及未压缩文件长度校验,并处理空响应。
|
||||||
|
|
||||||
|
新增 `scripts/test-report-export.cjs`,运行实际HBuilderX微信编译产物、注入隔离uni/API,无网络或数据库访问。执行 `D:/Develop/nvm/nodejs/node.exe scripts/test-report-export.cjs` 前须先编译MP-WEIXIN;38项通过,覆盖历史版本/鉴权、401和迟到回包、JSON/HTML/损坏PDF、三层超限及恰好20 MiB、取消、账户变化、预览/文件系统失败和临时文件清理重试。微信14:41:30、Android14:36:23 HBuilderX5.26编译成功;脚本语法及diff检查通过。
|
||||||
|
|
||||||
|
Chrome Playwright手机390×844/桌面1280×900实际报告页6项与H5服务边界17项通过;503错误保留报告,重试触发一次实际浏览器下载,两次导出请求保持鉴权及版本。边界采用浏览器内fake fetch,无实际网络;全部页面API为受控合成响应。pageerror=0、正常准备阶段console为空,业务console仅刻意503;首次裸UTS导入是测试工具路径错误,改为 `?import` 后17项通过。页面非空、身份/无框架错误浮层/桌面无横溢通过,截图已核对。Browser plugin not available,使用现有缓存Playwright;本轮PDF为头尾标志夹具,不证明PDF内容可渲染,分页证据沿用0.65与6.21。
|
||||||
|
|
||||||
|
ROOT `C:/Users/JUN/AppData/Local/Temp/airz-pdf-weixin-1791268574971/` 的 `verification.json` 为无凭据证据,截图为 `h5-download-mobile.png`、`h5-download-desktop.png`。浏览器存储清空、路由拦截撤销、专用浏览器关闭,前台5173预览已停止;未启动数据库/Redis/后端,不读取默认.env、不连接线上、不调用模型。临时脚本/日志含无服务器权限的合成会话,不整体分享。保留两仓库既有改动及前端未跟踪 `%SystemDrive%/`,无提交/推送/部署或正式验收。
|
||||||
|
|
||||||
|
发布前需在微信后台将 `https://airz.hqling.cloud` 加入downloadFile合法域名,已有request合法域名不能替代;本轮未修改微信后台。用户需用新增源码重新编译发行小程序,当前已发布版本不会自动获得支持。官方接口依据:[downloadFile](https://developers.weixin.qq.com/miniprogram/dev/api/network/download/wx.downloadFile.html)、[openDocument](https://developers.weixin.qq.com/miniprogram/dev/api/file/wx.openDocument.html)。微信系统预览、保存/转发、真机域名校验及生产生成并发/容量未验证,REPORT-005仍待验收。下一开发入口为REPORT-006行动页与报告可用性的组合检查,不自行正式验收或复用历史发布授权。
|
||||||
|
|
||||||
|
### 0.65 2026-10-06 REPORT-004 历史报告与重评组合开发检查
|
||||||
|
|
||||||
|
完成个人/企业的初版成功、修改排队、模型失败、显式重试、新版成功和旧版显式读取组合检查。重评复用评估记录并增加输入版本;新版本未成功时默认报告、匹配和PDF仍使用上次成功版本,指定未完成版本返回409。新结果发布后历史输入/事实/匹配不重算,产品改名、额度变化、版本更新和下架不改冻结参数;历史解释只读,旧分页游标仍可用于原版,不能用于新版。保留REPORT-002/003及两仓库全部既有未提交改动,本轮不正式验收、部署或重复发布小程序。
|
||||||
|
|
||||||
|
发现并修复三处版本边界问题。后端 `application/api/controller/v1/Assessments.php` 保留 `revisionId` 原始类型交给统一归属/版本校验,数组参数从500改为404/REVISION_NOT_FOUND,越权仍优先404/ASSESSMENT_NOT_FOUND。前端 `packages/finance/services/records.uts` 的档案详情补评估ID/显式版本校验,档案完整度补显式版本校验;受控错版回包不会显示另一版收入或完整度,详情重新加载及完整度局部重试可恢复正确资料。
|
||||||
|
|
||||||
|
新增后端 `tests/report_history.php`、`tests/report_history_http_router.php`,沿用显式隔离 `report_test_bootstrap.php`。专项215项通过,覆盖五类读取端点的匿名/越权/跨记录版本/非法数组与0版本、no-store、只读/无模型预留、真实PDF完整二进制与私有响应头、v1兼容、导出引擎不可用503和Token撤销401。`tests/assessment_revisions.php` 适配显式bootstrap和并发子进程,精确清理本次产品/队列,58项版本集成通过;合计273项PHP检查,四个本轮PHP文件7.4 lint及两仓库diff检查通过。
|
||||||
|
|
||||||
|
Chrome Playwright手机390×844/桌面1280×900共32项、82次隔离GET请求通过,包含失败/处理中查看旧报告、三栏切换、本人历史收入、完整匹配列表/产品详情、错版拒绝与重试、新版默认读取及旧版深链、历史解释只读和三次实际PDF下载。pageerror和console error/warn均0,截图无横溢/遮挡;浏览器全部API转发本机8098,三类错版仅用受控响应注入。Browser plugin not available,复用已有缓存Playwright,无新增依赖。13份PDF(含浏览器下载)52项提取文字检查通过,核对收入、提交序号、冻结名称及身份脱敏;个人历史8页、企业新版3页经Poppler渲染核对中文/分页,未改变导出模板。微信10:08:29、Android10:08:53 HBuilderX5.26本地编译成功,27页/63任务不变;无真机、跨浏览器或正式模型验收。
|
||||||
|
|
||||||
|
本轮ROOT `C:/Users/JUN/AppData/Local/Temp/airz-report004-20261006-1791251466341/`,MySQL5.7.26在127.0.0.1:3310随机库 `airz_product_test_1791251466341`、Redis5.0.14.1在6398/DB15。未加载默认.env、未连接正式3306/6379/线上/生产worker,模型外呼0。收尾六张业务表0、故障触发器0、Redis键0,测试库已DROP;MySQL/Redis正常停止,8098 PHP停止,浏览器存储清空并关闭。后台常驻H5预览启动被自动审批以 `blocked by policy` 拒绝,无更具体原因,未重试或绕过,5173预览也已停止。安全证据为ROOT内 `verification.json`、`cleanup-result.json`、`ui-result.json`、`pdf-verification.json`;临时fixture/浏览器日志可能含已撤销的合成Token,不整体共享或重新启动bootstrap,后续另建隔离环境。
|
||||||
|
|
||||||
|
REPORT-004整体仍开发中,真实产品、外部渠道、业务人工签收及移动端正式验收状态不变。本轮源码未提交/推送/部署,小程序已发布的版本不包含本轮三处修复。下一开发入口为REPORT-005 PDF导出边界与容量检查,优先异常二进制、超限、取消/离页、登录状态变化和分页可读性,不自行正式验收或发布。
|
||||||
|
|
||||||
|
### 0.64 2026-10-06 REPORT-003 报告生成与持久化开发检查
|
||||||
|
|
||||||
|
后端 `Assessment::complete()` 与 `AssessmentStore::saveResult()` 加固报告发布事务:报告、匹配数组、当前输入快照、历史解释关闭、发布指针和 `result_version` 一起保存;写入失败抛错并全部回滚。发布前校验当前/历史日志归属、版本号、状态及匹配结构;历史 queued/running 解释必须有合法任务ID和对应未完成日志,否则安全失败。`AnalysisTasks` 不再重复写报告版本,迟到任务只能补记原用量,不能覆盖新结果。保留此前REPORT-002调度改动和全部既有未提交工作。
|
||||||
|
|
||||||
|
新增后端 `tests/report_persistence.php` 与显式隔离入口 `tests/report_test_bootstrap.php`。专项75项通过:历史v1与无目标标记v2不补算新报告;v2重试复用输入、执行策略、产品目录及哈希;最新资料、产品名称/版本/硬规则、准备度策略和当前开关变化不影响旧结果;eligible/ineligible/unknown统计与null rank/score正确落库;四类损坏快照在调用模型前失败;会话字段、历史输入、当前输入和发布指针四处真实SQL故障均完整回滚;旧queued/running解释日志缺失回滚;正常重评关闭旧解释;新任务先成功后旧任务迟到不覆盖。
|
||||||
|
|
||||||
|
旧回归 `assessment_readiness.php`、`product_matching_tasks.php` 和测试HTTP路由改为显式bootstrap及当前 `AssessmentStore` 状态读取。`readiness_fixtures.php` 仅在隔离测试中重建历史v2输入与需求策略,生产新建仍只接受inputVersion=3;未放开旧采集字段。准备度/报告58项(含随机端口HTTP鉴权、归属、no-store、v1兼容)、演示产品匹配19项、任务调度36项通过,连同专项共188项;10个相关PHP文件7.4 lint及diff检查通过。常规假模型调用,不发生收费模型请求;不重复平台编译或移动端验收。
|
||||||
|
|
||||||
|
本轮全新隔离ROOT为 `C:/Users/JUN/AppData/Local/Temp/airz-report003-20261006-1791249639741/`;MySQL5.7.26仅监听 `127.0.0.1:3310/airz_product_test_1791249639742`,从schema初始化26表;独立Redis5.0.14.1在6398/DB15、随机队列与专用Token前缀,模型地址锁定不可用的本机端口。未加载默认.env、未连接正式3306/6379、线上数据库或生产worker。收尾六张业务表为0、故障触发器为0、Redis键为0;测试库已DROP,MySQL/Redis已正常停止。无凭据证据为ROOT内 `verification.json`;临时目录保留,不整体共享或重新启动bootstrap,后续检查另建隔离环境。
|
||||||
|
|
||||||
|
自填报告、冻结策略与演示产品支线已实现,本次完成REPORT-003持久化的本地开发检查;完整任务仍阻塞于真实产品、外部查询、人工业务规则签收与生产容量/守护,不标整体验收完成。小程序已由用户发布,本轮未重复发行;未提交、推送、部署或正式验收。下一开发入口为REPORT-004历史报告、重评失败保留旧结果、匹配/PDF版本一致性组合检查。
|
||||||
|
|
||||||
|
### 0.63 2026-10-06 REPORT-002 异步任务与补偿链开发检查
|
||||||
|
|
||||||
|
后端 `application/common/library/AnalysisTasks.php` 加固任务ID、租约和补偿边界:非法或缺失 `task_id` 不进入 Redis、不被 `run()` 执行;运行中任务缺失/损坏租约或任务ID时明确失败为 `AI_TASK_INTERRUPTED`;失败日志只按当前合法任务ID精确更新,避免脏值误写历史 assistant 调用;有效未来租约保持 processing/running。queued 漏投仍由数据库扫描补投,不增加 `attempt_count`;Redis 暂时不可用时保留已接受任务,恢复后可补投;迟到结果继续只记录原任务用量,不能覆盖新任务。
|
||||||
|
|
||||||
|
`tests/analysis_tasks.php` 改为必须传入显式隔离 `--bootstrap`,子进程重试/消费沿用同一配置,夹具使用当前 `fa_ai_chat`/`AssessmentStore` 架构和 `inputVersion=3`。为把本轮范围限定在调度与补偿链,测试显式沿用v1报告结果格式;不代表v2报告语义或真实模型验收。
|
||||||
|
|
||||||
|
验证结果:PHP 7.4 lint(两个文件)通过;在 `C:/Users/JUN/AppData/Local/Temp/airz-analysis-report002-20261006/` 的独立 MySQL `127.0.0.1:3310/airz_product_test_2026100602`、Redis `127.0.0.1:6398`/DB15 环境运行,模型关闭、只用假客户端,`PASS: 36 analysis task checks.`。覆盖并发重试/消费者、过期与缺失租约、非法任务ID、迟到结果、用量保留、Redis 断开补投和旧日志保护。回归后仅停止本轮隔离 MySQL/Redis;未连接正式 `.env`、3306/6379、线上数据库或生产 worker。
|
||||||
|
|
||||||
|
本轮为本地开发检查,未正式验收、部署、提交、推送或发布;用户已发布的小程序状态不因本轮改变。REPORT-002整体仍按任务台账保持开发中,下一依赖项为REPORT-003报告生成与持久化,需继续区分v2策略/产品规则和真实业务验收边界。
|
||||||
|
|
||||||
### 0.62 2026-10-05 反馈与消息后端生产发布
|
### 0.62 2026-10-05 反馈与消息后端生产发布
|
||||||
|
|
||||||
@@ -673,14 +866,14 @@ P0 表示核心流程、安全、首发外部能力或发布门槛;P1 表示
|
|||||||
| 产品详情 | packages/finance/product/product | PRODUCT-004 | 已接原型04/05、历史理由/参数、逐条DeepSeek解释与失败重试;08咨询待接 |
|
| 产品详情 | packages/finance/product/product | PRODUCT-004 | 已接原型04/05、历史理由/参数、逐条DeepSeek解释与失败重试;08咨询待接 |
|
||||||
| 产品对比 | packages/finance/compare/compare | PRODUCT-005 | 2—3项冻结对比本地联调通过,真实产品与平台待验收 |
|
| 产品对比 | packages/finance/compare/compare | PRODUCT-005 | 2—3项冻结对比本地联调通过,真实产品与平台待验收 |
|
||||||
| 下一步行动 | packages/finance/next-step/next-step | REPORT-006 | 四项行动与上下文/返回本地联调通过,真实产品及平台待验收 |
|
| 下一步行动 | packages/finance/next-step/next-step | REPORT-006 | 四项行动与上下文/返回本地联调通过,真实产品及平台待验收 |
|
||||||
| AI 助手 | packages/finance/assistant/assistant | CHAT-004 | 演示边界与三轮金额专项通过;真机/跨平台待验收 |
|
| AI 助手 | packages/finance/assistant/assistant | CHAT-004 | 演示边界、金额专项及0.68上下文/异常会话62项通过;真机/跨平台待验收 |
|
||||||
| 融资档案 | packages/finance/archive/archive | ASSESS-004、REPORT-004 | 本人历史分页、脱敏只读详情、刷新恢复 |
|
| 融资档案 | packages/finance/archive/archive | ASSESS-004、REPORT-004 | 本人历史分页、脱敏只读详情、刷新恢复 |
|
||||||
| 启动引导 | packages/account/index/index | BASE-003、AUTH-002 | 原型/本机引导 |
|
| 启动引导 | packages/account/index/index | BASE-003、AUTH-002 | 原型/本机引导 |
|
||||||
| 登录 | packages/account/login/login | AUTH-002、LOGIN-004 | 未完成真实认证 |
|
| 登录 | packages/account/login/login | AUTH-002、LOGIN-004 | 未完成真实认证 |
|
||||||
| 个人中心 | packages/account/profile/profile | AUTH-003、POINT-004、MSG-003 | 服务端昵称头像/入口 |
|
| 个人中心 | packages/account/profile/profile | AUTH-003、POINT-004、MSG-003 | 服务端昵称头像/入口 |
|
||||||
| 修改资料 | packages/account/edit-profile/edit-profile | AUTH-003 | 昵称/头像已接服务端持久化 |
|
| 修改资料 | packages/account/edit-profile/edit-profile | AUTH-003 | 昵称/头像已接服务端持久化 |
|
||||||
| 账户安全 | packages/account/security/security | AUTH-004 | 入口与客户端校验 |
|
| 账户安全 | packages/account/security/security | AUTH-004 | 入口与客户端校验 |
|
||||||
| 修改密码 | packages/account/password/password | AUTH-004 | 真实服务未接通,不模拟成功 |
|
| 修改密码 | packages/account/password/password | AUTH-004 | 原密码改密已接通;短信未开放 |
|
||||||
| 换绑手机 | packages/account/change-phone/change-phone | AUTH-004 | 真实服务未接通,不模拟成功 |
|
| 换绑手机 | packages/account/change-phone/change-phone | AUTH-004 | 真实服务未接通,不模拟成功 |
|
||||||
| 积分中心 | packages/account/points/points | POINT-004 | 服务端余额/规则/任务/流水及签到原键确认,隔离联调通过、未发行 |
|
| 积分中心 | packages/account/points/points | POINT-004 | 服务端余额/规则/任务/流水及签到原键确认,隔离联调通过、未发行 |
|
||||||
| 消息中心 | packages/account/messages/messages | MSG-004 | 服务端分页/已读与原版本跳转 |
|
| 消息中心 | packages/account/messages/messages | MSG-004 | 服务端分页/已读与原版本跳转 |
|
||||||
@@ -689,7 +882,7 @@ P0 表示核心流程、安全、首发外部能力或发布门槛;P1 表示
|
|||||||
| 新闻列表 | packages/content/news/news | CONTENT-002 | 真实分类/分页接口 |
|
| 新闻列表 | packages/content/news/news | CONTENT-002 | 真实分类/分页接口 |
|
||||||
| 文章详情 | packages/content/article/article | CONTENT-002 | 真实详情/不可用状态 |
|
| 文章详情 | packages/content/article/article | CONTENT-002 | 真实详情/不可用状态 |
|
||||||
| 融资课堂 | packages/content/classroom/classroom | CONTENT-002、CONTENT-003 | 真实分类/分页接口 |
|
| 融资课堂 | packages/content/classroom/classroom | CONTENT-002、CONTENT-003 | 真实分类/分页接口 |
|
||||||
| 课程详情 | packages/content/lesson/lesson | CONTENT-003 | 服务端会员进度/版本保护 |
|
| 课程详情 | packages/content/lesson/lesson | CONTENT-003 | 整篇连续阅读/课程已读/版本保护 |
|
||||||
|
|
||||||
## 3. 里程碑与依赖顺序
|
## 3. 里程碑与依赖顺序
|
||||||
|
|
||||||
@@ -744,8 +937,8 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
|
|||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| AUTH-001 | 全栈开发(待指定) | 发送成功才倒计时;验证码过期、复用、跨用途和频繁发送均拒绝。 | 未验收;无真实业务验收证据 | 2026-09-22 |
|
| AUTH-001 | 全栈开发(待指定) | 发送成功才倒计时;验证码过期、复用、跨用途和频繁发送均拒绝。 | 未验收;无真实业务验收证据 | 2026-09-22 |
|
||||||
| AUTH-002 | 全栈开发(待指定) | 真实账户登录成功;登录失败无假 Token;登出撤销当前 Token;旧散列兼容。 | 已实施子集和验证见第0节;完整任务未验收 | 2026-09-22 |
|
| AUTH-002 | 全栈开发(待指定) | 真实账户登录成功;登录失败无假 Token;登出撤销当前 Token;旧散列兼容。 | 已实施子集和验证见第0节;完整任务未验收 | 2026-09-22 |
|
||||||
| AUTH-003 | 全栈开发(待指定) | 不同设备资料一致;昵称不覆盖评估实名;头像不依赖临时路径。 | 本地14项HTTP及昵称/头像持久化交互通过;正式环境跨设备验收待补 | 2026-09-22 |
|
| AUTH-003 | 全栈开发(待指定) | 不同设备资料一致;昵称不覆盖评估实名;头像不依赖临时路径。 | 0.75后端资料268项/76次HTTP及0.74受控H5组合103项/微信Android编译通过;真机/跨设备/正式验收待补 | 2026-10-06 |
|
||||||
| AUTH-004 | 全栈开发(待指定) | 跨账户或单个验证码不能换绑;修改后旧 Token 全失效;新密码8–32位且含字母和数字。 | 已实施子集和验证见第0节;完整任务未验收 | 2026-09-22 |
|
| AUTH-004 | 全栈开发(待指定) | 跨账户或单个验证码不能换绑;修改后旧 Token 全失效;新密码8–32位且含字母和数字。 | 0.75会话281项/69次HTTP含全Token撤销/故障回滚/并发及0.74前端检查通过;供应商双挑战/真机/正式验收待补 | 2026-10-06 |
|
||||||
|
|
||||||
直接阻塞:AUTH-001 → EXT-SMS。
|
直接阻塞:AUTH-001 → EXT-SMS。
|
||||||
|
|
||||||
@@ -798,14 +991,14 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
|
|||||||
| ASSESS-001 | 评估字段与增量模型 | 统一现有字典并补融资需求四字段、inputVersion及增量列;用途字典/必填规则按专项契约确认,保留附件和授权映射。 | P0 | BASE-001、DEC-DEMAND | 1–2 | 开发中 |
|
| ASSESS-001 | 评估字段与增量模型 | 统一现有字典并补融资需求四字段、inputVersion及增量列;用途字典/必填规则按专项契约确认,保留附件和授权映射。 | P0 | BASE-001、DEC-DEMAND | 1–2 | 开发中 |
|
||||||
| ASSESS-002 | 正式评估记录与历史 | 本人历史分页/只读详情及用户隔离;补v1/v2需求回显与旧未采集提示,无草稿保存或续填。 | P0 | ASSESS-001、AUTH-002 | 1–2 | 待验收 |
|
| ASSESS-002 | 正式评估记录与历史 | 本人历史分页/只读详情及用户隔离;补v1/v2需求回显与旧未采集提示,无草稿保存或续填。 | P0 | ASSESS-001、AUTH-002 | 1–2 | 待验收 |
|
||||||
| ASSESS-003 | 提交与生命周期API | 保留提交201及重试202幂等;补inputVersion切换、新需求校验、输入字典冻结、同键全字段比较和旧请求重放兼容。 | P0 | ASSESS-002、FILE-004 | 1.5–2 | 开发中 |
|
| ASSESS-003 | 提交与生命周期API | 保留提交201及重试202幂等;补inputVersion切换、新需求校验、输入字典冻结、同键全字段比较和旧请求重放兼容。 | P0 | ASSESS-002、FILE-004 | 1.5–2 | 开发中 |
|
||||||
| ASSESS-004 | 评估全页面联调 | 评估/确认/分析/报告/档案贯穿assessmentId;新增资金需求分组和只读展示,保留金额、清理与并发回归。 | P0 | ASSESS-003 | 1.5–2 | 开发中 |
|
| ASSESS-004 | 评估全页面联调 | 评估/确认/分析/报告/档案贯穿assessmentId与revisionId;空白新建、最新资料修改、金额精度、幂等及账户/迟到响应组合联调。 | P0 | ASSESS-003 | 1.5–2 | 开发中 |
|
||||||
|
|
||||||
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
|
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| ASSESS-001 | 全栈开发(待指定) | 现有与v2字段映射明确,元字符串/整数月/用途条件校验;历史新字段NULL,不新建评估表。 | 需求四字段/版本/用途字典及迁移已实现;金额/期限/other/信用卡一致性含于76项检查;私有附件与正式授权仍待接入 | 2026-09-24 |
|
| ASSESS-001 | 全栈开发(待指定) | 现有与v2字段映射明确,元字符串/整数月/用途条件校验;历史新字段NULL,不新建评估表。 | 需求四字段/版本/用途字典及迁移已实现;金额/期限/other/信用卡一致性含于76项检查;私有附件与正式授权仍待接入 | 2026-09-24 |
|
||||||
| ASSESS-002 | 全栈开发(待指定) | 同用户可多次评估;越权拒绝;历史v1不回填需求、不重算,v2显示冻结需求且不可编辑。 | v1未采集/v2冻结需求只读回显、HTTP越权检查及个人/企业H5刷新通过;待业务验收,旧54项仅作历史证据 | 2026-09-24 |
|
| ASSESS-002 | 全栈开发(待指定) | 同用户可多次评估;越权拒绝;历史v1不回填需求、不重算,v2显示冻结需求且不可编辑。 | v1未采集/v2冻结需求只读回显、HTTP越权检查及个人/企业H5刷新通过;待业务验收,旧54项仅作历史证据 | 2026-09-24 |
|
||||||
| ASSESS-003 | 全栈开发(待指定) | 同键同内容原回执,合法需求或版本变化409、非法格式422;最低新建版本2或用途停用不阻断原键回放;旧重试返回原taskId。 | 76项覆盖新建最低2、旧v1键/旧用途回放、全字段冲突、并发唯一记录、重试原回执和输入快照不变;附件授权待接 | 2026-09-24 |
|
| ASSESS-003 | 全栈开发(待指定) | 同键同内容原回执,合法需求或版本变化409、非法格式422;最低新建版本2或用途停用不阻断原键回放;旧重试返回原taskId。 | 76项覆盖新建最低2、旧v1键/旧用途回放、全字段冲突、并发唯一记录、重试原回执和输入快照不变;附件授权待接 | 2026-09-24 |
|
||||||
| ASSESS-004 | 全栈开发(待指定) | 个人/企业需求均能提交及回显;12.345678万元=123456.78元;无草稿,历史不串账户,处理中无假报告。 | H5个人12.345678万元及企业0.000001万元正确提交/刷新;确认编辑、other清理、字典失败恢复、刷新清空通过;真实渠道待验收 | 2026-09-24 |
|
| ASSESS-004 | 全栈开发(待指定) | 个人/企业新建空白,修改所选评估最新资料并保存新版本;12.345678万元=123456.78元;无草稿,历史不串账户,迟到响应不恢复旧资料。 | 0.73后端514项/158次本机HTTP及版本58项通过;0.72受控H5 96项及微信/Android27页面编译通过,真机/真实渠道及正式验收待完成 | 2026-10-06 |
|
||||||
|
|
||||||
直接外部/业务阻塞:无;仍须满足前置任务。
|
直接外部/业务阻塞:无;仍须满足前置任务。
|
||||||
|
|
||||||
@@ -846,8 +1039,8 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
|
|||||||
| PRODUCT-001 | 全栈开发(待指定) | 发布规则与人工样例可审阅;非法字段/操作符阻止发布;fail优先于unknown,匹配指标不作为审批概率。 | 首版字段/操作符/来源门槛已形成规则契约;64项内存测试;真实产品发布依据与业务样例待验收 | 2026-09-24 |
|
| PRODUCT-001 | 全栈开发(待指定) | 发布规则与人工样例可审阅;非法字段/操作符阻止发布;fail优先于unknown,匹配指标不作为审批概率。 | 首版字段/操作符/来源门槛已形成规则契约;64项内存测试;真实产品发布依据与业务样例待验收 | 2026-09-24 |
|
||||||
| PRODUCT-002 | 全栈开发(待指定) | 同输入/快照结果稳定;未知不当满足,配置错误不归咎用户;需求不拆额、不改期;未运行与零候选区分。 | 目录冻结/排序/理由已接正式worker;51项内存和19项任务测试通过;真实产品金标准、规模/生产验收仍待完成 | 2026-09-24 |
|
| PRODUCT-002 | 全栈开发(待指定) | 同输入/快照结果稳定;未知不当满足,配置错误不归咎用户;需求不拆额、不改期;未运行与零候选区分。 | 目录冻结/排序/理由已接正式worker;51项内存和19项任务测试通过;真实产品金标准、规模/生产验收仍待完成 | 2026-09-24 |
|
||||||
| PRODUCT-003 | 全栈开发(待指定) | 产品用途/地区/额度/期限有真实依据;规则可配置并启停,版本变化留依据;公共详情无个人分数。 | 结构化编辑、来源/用途、只读预检、编辑冲突、上架/下架及公共列表本地联调通过,见0.32;真实资料/运营核对/发布验收待完成,6条演示产品不替代真实资料 | 2026-09-30 |
|
| PRODUCT-003 | 全栈开发(待指定) | 产品用途/地区/额度/期限有真实依据;规则可配置并启停,版本变化留依据;公共详情无个人分数。 | 结构化编辑、来源/用途、只读预检、编辑冲突、上架/下架及公共列表本地联调通过,见0.32;真实资料/运营核对/发布验收待完成,6条演示产品不替代真实资料 | 2026-09-30 |
|
||||||
| PRODUCT-004 | 全栈开发(待指定) | 每条解释可追溯且不凑三正三反;当前产品变化不改历史;跨账户拒绝;unavailable/零候选/failed不混淆。 | 原型04/05和逐条DeepSeek解释本地完成;51项任务/输出检查、70项HTTP/快照检查与浏览器验证见6.18;真实机构资料及平台验收待完成 | 2026-09-28 |
|
| PRODUCT-004 | 全栈开发(待指定) | 每条解释可追溯且不凑三正三反;当前产品变化不改历史;跨账户拒绝;unavailable/零候选/failed不混淆。 | 0.71当前存储匹配238项/101次本机HTTP及解释52项通过;0.70历史版本H5受控97项及微信/Android编译通过,真实产品/平台待验收 | 2026-10-06 |
|
||||||
| PRODUCT-005 | 全栈开发(待指定) | 不跨评估/混用现价;缺值不同于0;手机横向表格可读,单项失败明确;不重跑模型。 | compare页面及6.16本地编译/真实读取与异常注入证据;真实机构产品和微信内H5/真机待验收 | 2026-09-28 |
|
| PRODUCT-005 | 全栈开发(待指定) | 不跨评估/混用现价;缺值不同于0;手机横向表格可读,单项失败明确;不重跑模型。 | 0.70跨组补选/移除/刷新/单列失败重试/账户变化受控检查及编译通过;6.16历史证据保留,真实机构产品及微信内H5/真机待验收 | 2026-10-06 |
|
||||||
|
|
||||||
剩余门槛:DEC-MATCH开发口径已采用既有契约,真实产品规则/金标准业务签收待完成;PRODUCT-003真实发布仍依赖DEC-PRODUCT。纯规则、后台模型预检及字段编译已实施,未解除整模块验收依赖。
|
剩余门槛:DEC-MATCH开发口径已采用既有契约,真实产品规则/金标准业务签收待完成;PRODUCT-003真实发布仍依赖DEC-PRODUCT。纯规则、后台模型预检及字段编译已实施,未解除整模块验收依赖。
|
||||||
|
|
||||||
@@ -861,17 +1054,17 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
|
|||||||
| REPORT-002 | 异步任务与补偿 | 保留Redis派发、补投、租约和taskId隔离;沿用提交时input_snapshot,一次性补入execution_snapshot并在重试时复用,支持跨部署v1/v2任务。 | P0 | ASSESS-003、REPORT-001 | 2.5–4 | 开发中 |
|
| REPORT-002 | 异步任务与补偿 | 保留Redis派发、补投、租约和taskId隔离;沿用提交时input_snapshot,一次性补入execution_snapshot并在重试时复用,支持跨部署v1/v2任务。 | P0 | ASSESS-003、REPORT-001 | 2.5–4 | 开发中 |
|
||||||
| REPORT-003 | 报告生成与持久化 | 保留v1自填报告;补v2需求、完整度/准备等级、规则证据及定性AI区块;版本快照和正式结果原子落库。 | P0 | REPORT-002、DATA-004、PRODUCT-002、PRODUCT-003、DEC-MATCH、DEC-READINESS | 2–3 | 阻塞 |
|
| REPORT-003 | 报告生成与持久化 | 保留v1自填报告;补v2需求、完整度/准备等级、规则证据及定性AI区块;版本快照和正式结果原子落库。 | P0 | REPORT-002、DATA-004、PRODUCT-002、PRODUCT-003、DEC-MATCH、DEC-READINESS | 2–3 | 阻塞 |
|
||||||
| REPORT-004 | 分析报告历史联调 | 保留轮询/重试和v1历史;接v2需求、完整度、准备等级、三态匹配及证据解释,不显示无规则分数/雷达。 | P0 | REPORT-003、PRODUCT-004、ASSESS-004 | 2–3 | 开发中 |
|
| REPORT-004 | 分析报告历史联调 | 保留轮询/重试和v1历史;接v2需求、完整度、准备等级、三态匹配及证据解释,不显示无规则分数/雷达。 | P0 | REPORT-003、PRODUCT-004、ASSESS-004 | 2–3 | 开发中 |
|
||||||
| REPORT-005 | 报告PDF导出 | 鉴权流式下载、受控中文模板、报告与产品冻结快照、H5保存与失败提示。 | P1 | REPORT-004 | 2.5–4 | 待验收 |
|
| REPORT-005 | 报告PDF导出 | 鉴权流式下载、受控中文模板、报告与产品冻结快照、H5保存、微信下载预览及失败提示。 | P1 | REPORT-004 | 2.5–4 | 待验收 |
|
||||||
| REPORT-006 | 下一步行动 | 新增next-step页,准备材料、查看已存报告、优化资料、新评估、咨询及首页导航。 | P1 | PRODUCT-004、REPORT-004、CHAT-003 | 1–2 | 待验收 |
|
| REPORT-006 | 下一步行动 | 新增next-step页,准备材料、查看已存报告、优化资料、新评估、咨询及首页导航。 | P1 | PRODUCT-004、REPORT-004、CHAT-003 | 1–2 | 待验收 |
|
||||||
|
|
||||||
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
|
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| REPORT-001 | 全栈开发(待指定) | 模型可配置;密钥不下发;失败与使用量可追踪;数值事实不被模型改写。 | 两个指定模型真实连通;v2修正后Flash3例/Pro1例格式及语义检查通过;合成输入隐私过滤,调用tokens已记录;商业预算/生产稳定性待验收 | 2026-09-24 |
|
| REPORT-001 | 全栈开发(待指定) | 模型可配置;密钥不下发;失败与使用量可追踪;数值事实不被模型改写。 | 两个指定模型真实连通;v2修正后Flash3例/Pro1例格式及语义检查通过;合成输入隐私过滤,调用tokens已记录;商业预算/生产稳定性待验收 | 2026-09-24 |
|
||||||
| REPORT-002 | 全栈开发(待指定) | 漏投/中断可恢复或明确失败;旧任务不可回写;输入/目标结果/策略/产品快照重试不隐式变化。 | 54项报告检查覆盖目标版本冻结、超时重试快照不变、旧v2输入仍v1、损坏快照先于模型失败;产品快照/生产守护未完成,旧29项任务证据保留 | 2026-09-24 |
|
| REPORT-002 | 全栈开发(待指定) | 漏投/中断可恢复或明确失败;旧任务不可回写;输入/目标结果/策略/产品快照重试不隐式变化。 | 54项报告检查覆盖目标版本冻结、超时重试快照不变、旧v2输入仍v1、损坏快照先于模型失败;产品快照/生产守护未完成,旧29项任务证据保留 | 2026-09-24 |
|
||||||
| REPORT-003 | 全栈开发(待指定) | 完整度按发布字段集复算,等级有依据且非审批;null rank/score按状态验证;格式错误不成功,产品/规则更新不改旧报告。 | v2自填支线已实现;本轮58项回归及Flash3例/Pro1例真实样例通过;真实产品/外部查询、生产与业务人工签收待完成 | 2026-09-24 |
|
| REPORT-003 | 全栈开发(待指定) | 完整度按发布字段集复算,等级有依据且非审批;null rank/score按状态验证;格式错误不成功,产品/规则更新不改旧报告。 | 自填/演示产品支线已实现;0.64隔离持久化75项及周边113项通过;历史Flash3例/Pro1例样例证据保留;真实产品/外部查询、生产与业务人工签收待完成 | 2026-10-06 |
|
||||||
| REPORT-004 | 全栈开发(待指定) | 个人/企业真实链路按版本展示;未发布无分或等级,100%不等于授信;旧报告不重算;新复评新assessmentId。 | 6.17三栏/局部重试/旧版/权限及生命周期验证;真实产品与外部渠道仍待验收 | 2026-09-28 |
|
| REPORT-004 | 全栈开发(待指定) | 个人/企业链路按版本展示;未发布无分或等级,100%不等于授信;旧报告不重算;修改重评复用记录新增输入版本,新建评估另建记录。 | 0.65历史/重评215项及版本58项、H5组合32项通过,修复非法版本500及档案两处错版回包;真实产品/外部渠道及正式验收未完成 | 2026-10-06 |
|
||||||
| REPORT-005 | 全栈开发(待指定) | 本人成功报告可导出;金额/理由与历史一致;不重跑模型/泄露身份;中文分页可读,JSON错误不保存成PDF。 | 鉴权接口/H5导出已实施,29项PDF+20项HTTP+24项浏览器检查通过;跨浏览器/真机和生产容量待验收,见6.21 | 2026-09-28 |
|
| REPORT-005 | 全栈开发(待指定) | 本人成功报告可导出;金额/理由与历史一致;不重跑模型/泄露身份;中文分页可读,JSON错误不保存成PDF。 | 0.66微信下载/预览源码及38项产物检查、H5页面6项/边界17项和平台编译通过;0.65的13文件52项内容证据保留;downloadFile域名需配置并重新发行,真机/跨浏览器及生产容量待验收,见6.21 | 2026-10-06 |
|
||||||
| REPORT-006 | 全栈开发(待指定) | 根据判定及当前可用性开放行动;无银行申请提交;新评估不改历史、无草稿;上下文和返回正确。 | 27页已注册;38项H5流程/边界检查、Android/微信编译通过,真实产品与平台待验收,见6.20 | 2026-09-28 |
|
| REPORT-006 | 全栈开发(待指定) | 根据判定及当前可用性开放行动;无银行申请提交;新评估不改历史、无草稿;上下文和返回正确。 | 0.67历史版本/报告可用性H5组合38项及Android/微信编译通过;补版本/重评状态提示与无匹配降级;真实产品材料/渠道及平台待验收,历史基础见6.20 | 2026-10-06 |
|
||||||
|
|
||||||
剩余依赖:REPORT-001商业预算/生产额度待DEC-AI;REPORT-003完整报告仍依赖DATA、PRODUCT、DEC-MATCH;DEC-READINESS首版开发策略已发布,人工业务验收待完成。模型真实权限已验证。
|
剩余依赖:REPORT-001商业预算/生产额度待DEC-AI;REPORT-003完整报告仍依赖DATA、PRODUCT、DEC-MATCH;DEC-READINESS首版开发策略已发布,人工业务验收待完成。模型真实权限已验证。
|
||||||
|
|
||||||
@@ -888,10 +1081,10 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
|
|||||||
|
|
||||||
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
|
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| CHAT-001 | 全栈开发(待指定) | 用户只能读自身会话;评估会话与普通会话分离;重进恢复历史。 | 本地76项助手回归通过,H5前轮已联调;真实13例及金额专项通过边界见6.24,真机待验收 | 2026-09-29 |
|
| CHAT-001 | 全栈开发(待指定) | 用户只能读自身会话;评估会话与普通会话分离;重进恢复历史。 | 0.69当前存储下后端165项及0.68 H5受控62项通过;真实13例边界见6.24,真机待验收 | 2026-10-06 |
|
||||||
| CHAT-002 | 全栈开发(待指定) | 同请求不重复建消息或记本地消耗;错误回复不标成功;不会发送无关证件资料。 | 本地76项助手回归通过,H5前轮已联调;真实13例及金额专项通过边界见6.24,真机待验收 | 2026-09-29 |
|
| CHAT-002 | 全栈开发(待指定) | 同请求不重复建消息或记本地消耗;错误回复不标成功;不会发送无关证件资料。 | 0.69双进程幂等/消费/重试/共享额度及脱敏检查通过;真实13例边界见6.24,生产容量待验收 | 2026-10-06 |
|
||||||
| CHAT-003 | 全栈开发(待指定) | 切换会话不串上下文;超时保留可重试消息;重进页面能继续。 | 本地76项助手回归通过,H5前轮已联调;真实13例及金额专项通过边界见6.24,真机待验收 | 2026-09-29 |
|
| CHAT-003 | 全栈开发(待指定) | 切换会话不串上下文;超时保留可重试消息;重进页面能继续。 | 0.68 H5受控62项及微信/Android编译通过;既有后端/真实13例边界见6.24,真机待验收 | 2026-10-06 |
|
||||||
| CHAT-004 | 全栈开发(待指定) | 配额并发不超扣;供应商计费不明可对账;仅真实成功回复显示完成。 | 并发额度/超时/迟到/重试本地通过;真实13例、金额专项通过及未验收边界见6.24,容量待验收 | 2026-09-29 |
|
| CHAT-004 | 全栈开发(待指定) | 配额并发不超扣;供应商计费不明可对账;仅真实成功回复显示完成。 | 0.69后端165项/共享发布回归348项及0.68 H562项通过;真实13例边界见6.24,费用对账/容量/真机待验收 | 2026-10-06 |
|
||||||
|
|
||||||
直接外部/业务阻塞:无;仍须满足前置任务。
|
直接外部/业务阻塞:无;仍须满足前置任务。
|
||||||
|
|
||||||
@@ -903,15 +1096,15 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
|
|||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| CONTENT-001 | 内容模型与发布后台 | 补齐文章/课程结构、稳定章节ID、分类、封面/摘要、发布状态、FAQ类型及协议入口。 | P1 | BASE-001 | 1–1.5 | 开发中 |
|
| CONTENT-001 | 内容模型与发布后台 | 补齐文章/课程结构、稳定章节ID、分类、封面/摘要、发布状态、FAQ类型及协议入口。 | P1 | BASE-001 | 1–1.5 | 开发中 |
|
||||||
| CONTENT-002 | 列表详情与首页推荐 | 新闻/文章/课堂/详情真实API、分页、富文本清洗、缺失内容与首页推荐接入。 | P1 | CONTENT-001、BASE-004 | 1–1.5 | 待验收 |
|
| CONTENT-002 | 列表详情与首页推荐 | 新闻/文章/课堂/详情真实API、分页、富文本清洗、缺失内容与首页推荐接入。 | P1 | CONTENT-001、BASE-004 | 1–1.5 | 待验收 |
|
||||||
| CONTENT-003 | 云端课程进度 | 按用户/课程/章节记录完成,章节版本兼容、跨设备恢复和本机缓存策略。 | P1 | CONTENT-002、AUTH-002 | 1–1.5 | 待验收 |
|
| CONTENT-003 | 云端课程已读 | 整篇连续阅读、一次标记课程已读,兼容历史记录与内容版本,跨设备恢复。 | P1 | CONTENT-002、AUTH-002 | 1–1.5 | 待验收 |
|
||||||
| CONTENT-004 | 搜索与FAQ联调 | 真实全文字段检索、分类/分页、请求竞态、FAQ读取、无结果和断网恢复。 | P1 | CONTENT-002 | 1–1.5 | 开发中 |
|
| CONTENT-004 | 搜索与FAQ联调 | 真实全文字段检索、分类/分页、请求竞态、FAQ读取、无结果和断网恢复。 | P1 | CONTENT-002 | 1–1.5 | 开发中 |
|
||||||
|
|
||||||
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
|
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| CONTENT-001 | 全栈开发(待指定) | 后台可发布/下架;章节重排不换ID;草稿不可公开;FAQ可维护。 | 40项数据库/13项后台检查、服务器验收通过;FAQ服务端与后台源码完成,尚未导入/部署,见0.39 | 2026-10-01 |
|
| CONTENT-001 | 全栈开发(待指定) | 后台可发布/下架;章节重排不换ID;草稿不可公开;FAQ可维护。 | 40项数据库/13项后台检查、服务器验收通过;FAQ服务端与后台源码完成,尚未导入/部署,见0.39 | 2026-10-01 |
|
||||||
| CONTENT-002 | 全栈开发(待指定) | 首页/列表/详情ID一致;无模拟兜底内容;下架详情有明确不可用状态。 | 81项内容HTTP/页面及公网接口通过,微信/Android编译通过;真机待验收 | 2026-10-01 |
|
| CONTENT-002 | 全栈开发(待指定) | 首页/列表/详情ID一致;无模拟兜底内容;下架详情有明确不可用状态。 | 0.76受控H5组合123项及微信/Android编译通过,详情归属/分页/下架重试已补;真实后端组合/真机待检查,未正式验收 | 2026-10-06 |
|
||||||
| CONTENT-003 | 全栈开发(待指定) | 用户隔离;重复完成幂等;章节排序变化不串进度;跨设备一致。 | 版本冲突/重排/正文变化及会员权限检查通过;多设备待验收 | 2026-10-01 |
|
| CONTENT-003 | 全栈开发(待指定) | 整篇直接阅读;单一课程已读操作;账户隔离、失败可重试及跨设备一致。 | 0.77连续阅读/已读57项及微信Android编译通过;历史存储兼容,真实后端组合/真机/多设备待补 | 2026-10-07 |
|
||||||
| CONTENT-004 | 全栈开发(待指定) | 旧搜索响应不覆盖新关键词;返回ID可开详情;FAQ与实际业务行为一致。 | 分类/多词/分页/竞态/空错态通过,搜索正式部署;FAQ服务端与前端接入源码完成,尚未导入/部署 | 2026-10-01 |
|
| CONTENT-004 | 全栈开发(待指定) | 旧搜索响应不覆盖新关键词;返回ID可开详情;FAQ与实际业务行为一致。 | 0.76补关键词/分类竞争、分页错包/重复/429/断网恢复;搜索此前已部署,本轮修复未发布,FAQ接入源码仍未导入/部署 | 2026-10-06 |
|
||||||
|
|
||||||
直接外部/业务阻塞:无;仍须满足前置任务。
|
直接外部/业务阻塞:无;仍须满足前置任务。
|
||||||
|
|
||||||
|
|||||||
+170
-1
@@ -1,6 +1,175 @@
|
|||||||
# AI融资项目新会话交接
|
# AI融资项目新会话交接
|
||||||
|
|
||||||
记录日期:2026-10-05。本文件为接续入口;当前存储与流程以[评估版本与最新资料](评估版本与最新资料.md)、[开发进度](开发进度.md)0.62节(反馈与消息后端生产发布)、0.61节(反馈与消息配套发布准备)、0.60节(反馈恢复与消息组合检查)、0.59节(反馈回复通知联动)、0.58节(会员反馈原键恢复)、0.57节(后台反馈处理及既有小程序发布状态)、0.55节(后端发布)及[开发文档](开发文档.md)v1.46为准。历史章节中的域名、部署及“待开发”以最新记录为准。
|
记录日期:2026-10-07。本文件为接续入口;课程最新交互以[开发进度](开发进度.md)0.77节及下方课程简化检查点为准,整篇连续阅读、仅一次标记已读。其余当前存储与流程以[评估版本与最新资料](评估版本与最新资料.md)、[开发进度](开发进度.md)0.76节(CONTENT-002/003/004内容、课程进度与搜索组合开发检查)、0.75节(AUTH-003/004账户资料、头像及会话撤销后端隔离HTTP开发检查)、0.74节(AUTH-003/004账户资料、改密及退出组合开发检查)、0.73节(ASSESS-004评估新建/修改后端隔离HTTP开发检查)、0.72节(ASSESS-004资料填写、确认提交与档案回显组合开发检查)、0.71节(PRODUCT-004匹配读取与产品解释后端隔离开发检查)、0.70节(PRODUCT-004/005匹配详情与对比历史版本组合开发检查)、0.69节(CHAT-004后端幂等/额度/历史隔离开发检查)、0.68节(CHAT-004历史版本上下文与异常会话组合开发检查)、0.67节(REPORT-006行动页与报告可用性组合开发检查)、0.66节(REPORT-005微信PDF与导出边界开发检查)、0.65节(REPORT-004历史报告与重评组合开发检查)、0.64节(REPORT-003报告生成与持久化开发检查)、0.63节(REPORT-002异步任务与补偿链开发检查)、0.62节(反馈与消息后端生产发布)、0.61节(反馈与消息配套发布准备)、0.60节(反馈恢复与消息组合检查)、0.59节(反馈回复通知联动)、0.58节(会员反馈原键恢复)、0.57节(后台反馈处理及既有小程序发布状态)、0.55节(后端发布)及[开发文档](开发文档.md)v1.46为准。历史章节中的域名、部署及“待开发”以最新记录为准。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-07(课程简化)
|
||||||
|
|
||||||
|
用户要求课程不分章节,减少复杂度。已将 `packages/content/lesson/lesson.uvue` 改为整篇连续阅读,所有正文直接展示,只保留自然小标题;移除目录/折叠/编号/逐节完成/开始继续学习/进度条和节数。底部仅“我已读完”,已读后禁用并显示“已读完”。匿名直接阅读,点击标记时登录并回跳;已读状态加载慢或失败不阻止正文。
|
||||||
|
|
||||||
|
- 仅课程页业务源码修改,后台数据结构、已上线API及历史记录兼容,无后端变更或迁移。一次操作依次补齐旧接口未完成段落,全部成功才显示课程已读;途中失败重载服务器状态后仅补未完成部分。逐请求前后核对账户/版本/页面代次,换账户/离页停止余下请求,双击不重复。后台稳定ID属于存储兼容,不重新引入用户章节流程。
|
||||||
|
- Chrome手机390×844/360×740、桌面1280×900最终57项/53业务请求/15合成PUT及1登录POST通过:整篇文本/无章节控件/滚动到末尾/单一已读、重复零写与刷新恢复、老部分记录、慢/失败状态仍阅读、部分保存失败恢复、双击、409停止、立即/保存中换账户、离页、匿名零GET与密码登录回跳、小屏适配。身份/非空/无遮罩/横溢/段落顺序/封面/三截图通过,pageerror=0、未定义API=0,console仅刻意503/409;全部外部API拦截。首轮截图图片等待误判空集合,补可见封面背景条件后最终通过。
|
||||||
|
- HBuilderX5.26 Android08:36:34、微信08:37:08,27页面编译成功。初始CLI未打开HBuilderX,正常启动后成功;新5175初编译等待后运行。Browser plugin not available,沿用缓存Playwright CLI,无新增依赖;共享layout既有H5类型警告保留。业务页及四说明UTF-8无BOM/主要行尾、两仓库diff通过;保留既有未提交。
|
||||||
|
- ROOT `C:/Users/JUN/AppData/Local/Temp/airz-reading-20261007/`,安全证据 `verification.json`、`reading-mobile.png`、`reading-end-mobile.png`、`reading-desktop.png`、`android-final.log`、`weixin-final.log`;临时脚本/CLI日志含合成会话,不整体分享。存储/cookies/routes清空、专用reading浏览器关闭,当前源码预览5175/PID15480保留。本轮开始5173/5174/旧5175无监听,未停止其他服务。未读正式.env/连生产/启数据库Redisworker/真实短信模型,后端只读,无发布/提交/推送/正式验收。
|
||||||
|
- 台账1.67/0.77,63任务/27页面及状态保持;下一项仍为内容、课程已读与搜索后端隔离HTTP检查。当前需求覆盖0.76及更早用户章节学习描述;检查历史记录、幂等、内容版本、账户归属、失败恢复即可,不恢复章节目录或逐节完成。后台 `tests/content_catalog.php` 显式bootstrap但固定3309,先统一隔离守卫/随机HTTP/精确清理再另建本机环境。真机/跨设备/真实后端及正式验收待补,用户自行发布小程序,本轮源码未发行,不沿用历史生产授权。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(CONTENT-002/003/004)
|
||||||
|
|
||||||
|
本轮完成内容、课程进度与搜索组合开发检查,复现并修复六类问题。保留两仓库全部既有工作和用户既有小程序发布状态,未正式验收、部署、提交或推送。
|
||||||
|
|
||||||
|
- 五源码文件:`services/content.uts`、`packages/content/lesson/lesson.uvue`、`news/news.uvue`、`classroom/classroom.uvue`、`search/search.uvue`。共享解析校验详情ID/完整正文、课程章节/场景/数量/版本、分页页码/大小/总数/hasMore和同页唯一ID;保留合法列表/首页摘要及后端过滤坏行的不足20条响应。进度核对课程/版本/唯一章节及当前章节归属,完成回执须包含所选章节并保持版本。分页追加拒绝重复,错误保留旧列表和页码;搜索补可见性守卫。
|
||||||
|
- 课程可见期每秒核对Token,操作/回包核对账户及代次,立即或无操作换账户清旧进度/展开状态并重新读取。离页停止检查;重复完成、加载/保存中或隐藏页不提交,迟到401不清新会话。原六缺陷为错详情ID、重复/外来进度、错误页码、旧页向新账户写进度、换账户闲置旧进度、不同版本完成回执;修复后六项复测均false。
|
||||||
|
- Chrome手机390×844、桌面1280×900最终123项/107次全量拦截业务请求/9次合成PUT或登录POST通过,涵盖坏响应/非法参数、两条首页共享解析、下架重试/分页/分类竞争、重复完成/失败重试/版本/章节/账户/迟到响应/离页、匿名登录回跳,以及搜索关键词/类型竞争/分页429与重复/详情/空/断网恢复/清空/建议。场景检查含选择状态与新课程标题证据;身份/非空/无遮罩/横溢/图片及七截图通过,pageerror=0、未定义API=0,console仅刻意404/503/401/429和net::ERR_FAILED。无真实后端、短信或模型请求。
|
||||||
|
- HBuilderX5.26最终Android21:27:44、微信21:34:02,27页面编译通过。UTS前向引用沿用回调;旧5174返回旧共享模块,另启5175补临时HX_APP_ROOT后确认新export。测试初期自定义uni-button禁用断言、登录定位/协议勾选及弱请求计数已修正,中断结果不计最终数。Browser plugin not available,复用缓存Playwright CLI,无新增依赖;共享layout既有H5类型警告保留。两仓库diff、五源码及两台账UTF-8无BOM/主要行尾约定通过。
|
||||||
|
- ROOT `C:/Users/JUN/AppData/Local/Temp/airz-content-combo-20261006/`,安全证据为 `verification.json`、`repro-after.json`、七张截图、`android-final.log`、`weixin-final.log`。临时脚本/CLI日志含无服务器权限合成会话,不整体分享。浏览器存储/cookies/routes清空,专用content-combo会话关闭;当前源码H5预览5175/PID68580保留,旧5174/PID76784及另一项目5173保留,后续核对归属再复用/停止。未启动数据库/Redis/后端/worker,未读正式.env或连接生产,后端只读;前端 `%SystemDrive%/`、`scripts/`、`.vite/` 及两仓库既有未提交保持。
|
||||||
|
- 台账1.66/0.76,63任务/27页面不变;CONTENT-002/003待验收、CONTENT-004开发中保持。真实后端组合、真机/跨设备及正式验收未覆盖,此前及本轮新增源码仍未配套发布,不沿用历史生产授权。
|
||||||
|
- 下一开发入口:CONTENT-002/003/004后端内容、课程进度与搜索隔离HTTP检查。`D:/AAdesk/202609/airz/tests/content_catalog.php` 已要求显式bootstrap但固定3309且未复用report_test_bootstrap;先统一隔离守卫、随机本机HTTP继承及精确清理,再另建本机库/Redis。检查发布/下架/坏行、列表/详情/首页/搜索契约、鉴权/账户隔离/重复完成、章节重排保持/正文改变失效、版本冲突及事务/并发失败。不重复前端组合、自行正式验收、接供应商、调用真实短信/模型、部署或重新发行小程序。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(AUTH-003/004后端)
|
||||||
|
|
||||||
|
本轮完成账户资料、头像及会话撤销后端隔离HTTP开发检查,仅修改四个后端测试文件,前后端业务源码未修改。两仓库全部既有工作及用户既有小程序发布状态保持,未正式验收、部署、提交或推送。
|
||||||
|
|
||||||
|
- `D:/AAdesk/202609/airz/tests/api_profile.php`、`tests/api_session.php` 接显式 `--bootstrap=<path>`,缺参在框架初始化前拒绝;新增 `tests/account_http_test.php`、`tests/account_http_router.php`,随机本机HTTP和双进程继承相同隔离bootstrap/ROOT,头像写入独立临时目录。故障模式只从本次runtime文件读取,按归属精确清理,清理成功才PASS,异常非零。
|
||||||
|
- 资料268项/76次真实本机HTTP通过:鉴权/越权/JSON/未知字段/no-store、昵称边界与持久化、个人及企业评估资料/历史日志字节保持、伪图片/GIF/原始和重编码大小/尺寸/像素拒绝,PNG/JPEG/WebP归一PNG与安全元数据、附件归属/路径/文件、上传不自动绑定、省略保持/null默认。触发器验证入库失败文件清理及昵称头像事务回滚,目录故障和移除故障后重试通过。
|
||||||
|
- 会话281项/69次真实本机HTTP通过:入参/Bearer/过期/禁用、旧散列升级、退出故障保留与成功只撤当前Token、改密输入/旧密码/DB/Redis失败回滚、成功撤全部本账户会话且另一账户保持、晚Hook失败关闭及重新登录、登录Hook新Token清理、8/32位边界。双PHP及用户行锁验证三轮登录/改密竞争后旧密码无有效Token、同旧密码双改密仅一成功;短信503和未路由换绑404均无假成功。合计549项/145请求,属于开发检查。
|
||||||
|
- ROOT `C:/Users/JUN/AppData/Local/Temp/airz-account-backend-20261006-1791290960616/`,MySQL5.7.26仅127.0.0.1:3323、Redis5.0.14.1仅127.0.0.1:6403/DB15,模型127.0.0.1:1且关闭,无worker。26表由schema初始化,无正式.env/生产连接/真实短信或模型/新增依赖。初期启动参数、ID类型、Token导入与空JSON预期已修正;收尾扩展不支持shutdown,使用已有redis-cli正常关闭,中断结果不计最终数。七业务表/故障触发器/Redis键/头像目录均0,测试库已DROP,专用HTTP/MySQL/Redis停止,3323/6403无监听。四PHP lint、两缺bootstrap拒绝、两仓库diff/UTF-8无BOM/LF通过。
|
||||||
|
- 安全证据:ROOT内 `verification.json`、`cleanup.json`、`services.json`、`profile-final.out`/`profile-final.err`、`session-final.out`/`session-final.err`,最终stderr空。临时bootstrap/runtime/工具文件不整体分享或直接复用。前端未变,沿用0.74浏览器/微信/Android编译证据;5174/PID76784预览核对归属后保留,另一项目5173保持。前端 `%SystemDrive%/`、`scripts/`、`.vite/` 及两仓库既有未提交保留。
|
||||||
|
- 台账1.65/0.75,63任务/27页面不变,AUTH-003/004仍开发中;完整登录/换绑受短信供应商阻塞,真机/跨设备/正式验收未覆盖。此前新增业务源码仍未配套发布,不沿用历史发布授权。
|
||||||
|
- 下一开发入口:CONTENT-002/003/004内容、课程进度与搜索组合开发检查。入口为 `packages/content/services/content.uts`、`services/search.uts` 和 `news/news.uvue`、`article/article.uvue`、`classroom/classroom.uvue`、`lesson/lesson.uvue`、`search/search.uvue`。核对列表/详情与下架状态、章节版本和账户隔离、重复完成/失败重试/迟到回包、搜索关键词/分类分页与旧响应竞争;先用受控API响应与现有H5预览,如需后端HTTP先适配显式隔离入口并另建本机环境。不要重复账户专项、自行接供应商、正式验收、真实短信/模型调用、部署或重新发行小程序。
|
||||||
|
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(AUTH-003/004)
|
||||||
|
|
||||||
|
本轮完成账户资料、改密及退出组合开发检查,修复五类已复现问题。两仓库全部既有工作及用户既有小程序发布状态保持,未正式验收、部署、提交或推送。
|
||||||
|
|
||||||
|
- 七个源码文件:`packages/account/services/account.uts`、`profile/profile.uvue`、`edit-profile/edit-profile.uvue`、`security/security.uvue`、`password/password.uvue`、`change-phone/change-phone.uvue`、`components/SecurityCode.uvue`。修复同手机号新Token提交旧页资料、个人中心保留旧账户内容、旧退出确认误退新账户、离页改密回包主动跳转、保存后迟到GET覆盖新昵称缓存。页面及弹窗/选图/回包校验账户和代次,可见期定时检查、离页停止;资料/头像/改密回执校验归属与完整性,写入使旧读取失效。昵称继续独立于评估实名。改密成功清理原账户本机会话但隐藏页不跳转;密码/验证码离页清空,短信目标变化/离页使旧发送响应失效,退出失败可重试,未确认签到/反馈命令仍按既有约定保留。
|
||||||
|
- Chrome手机390×844、桌面1280×900最终103项/48次全量拦截业务请求/21次合成写请求通过。覆盖资料/头像/改密坏回执、账户切换/旧菜单/迟到读取保存与上传、昵称独立、资料失败重试、头像大小/过期选图/默认头像、退出确认/失败重试/会话与缓存清理、改密确认/可见性/切模式/成功与离页/迟到401、短信与换绑未开放及短信目标变化、安全页过期。pageerror=0、未定义API=0,console仅刻意401/409/422/503,身份/非空/无遮罩/横溢及六截图通过。无实际后端/短信/模型请求,合成改密回执不证明真实服务端全Token撤销。
|
||||||
|
- HBuilderX5.26最终Android20:38:52、微信20:39:33,27页面编译通过。脚本初期返回页预期、uni-input事件等待、同名占位文字及隐藏组件定位已修正,早期中断结果不计入最终数。Browser plugin not available,复用缓存Playwright CLI,无新增依赖;共享layout既有H5类型警告保留。两仓库diff、七文件及两台账UTF-8无BOM/主要行尾约定通过。
|
||||||
|
- ROOT `C:/Users/JUN/AppData/Local/Temp/airz-auth003004-20261006/`。安全证据:`verification.json`、`profile-mobile.png`、`phone-mobile.png`、`edit-mobile.png`、`password-mobile.png`、`edit-desktop.png`、`password-desktop.png`、`android-final.log`、`weixin-final.log`。脚本/CLI日志含无服务器权限合成会话,不整体分享。存储/cookies/routes清空、验证浏览器已关闭。本轮本地预览 `http://localhost:5174/#/packages/account/profile/profile`,5174/PID76784保留,Node命令属于本项目,输出ROOT/h5;下次先核对归属再复用或停止。另一项目5173/PID68536保持。后端只读,无正式.env/生产连接/数据库/Redis/worker或真实短信/模型。
|
||||||
|
- 台账1.64/0.74,63任务/27页面不变,AUTH-003/004仍开发中。原密码改密已接通,账户短信/换绑没有正式路由,完整任务仍受供应商阻塞。真机/真实渠道/跨设备及正式验收未覆盖;此前及本轮新增源码仍未配套发布,不沿用历史发布授权。保留前端 `%SystemDrive%/`、`scripts/`、`.vite/` 及两仓库既有未提交。
|
||||||
|
- 下一开发入口:AUTH-003/004账户资料、头像及会话撤销后端隔离HTTP检查。旧 `D:/AAdesk/202609/airz/tests/api_profile.php`、`tests/api_session.php` 参数检查前初始化默认环境、HTTP固定127.0.0.1,不得直接运行;先适配显式隔离bootstrap、随机本机HTTP继承、独立头像目录及精确清理,再另建本机库/Redis。验证归属/字段/持久化/头像文件、昵称不改评估实名、事务失败、退出当前会话/改密全部会话撤销、未开放短信拒绝。不要自行接供应商、真实短信或模型调用、正式验收、部署或重新发行小程序。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(ASSESS-004后端)
|
||||||
|
|
||||||
|
本轮完成评估新建、修改接口的后端隔离HTTP开发检查,修正测试入口及过时夹具;前后端业务源码未修改。保留两仓库全部既有工作和用户既有小程序发布状态,未正式验收、部署、提交或推送。
|
||||||
|
|
||||||
|
- `D:/AAdesk/202609/airz/tests/api_assessments.php` 接显式 `--bootstrap=<path>`,缺参在框架初始化前拒绝,复用report_test_bootstrap。采用inputVersion=3/AssessmentStore,删除旧资料主键/加密身份/任务落资料表假设;新增 `tests/assessment_http_router.php`,随机本机HTTP及双进程继承同一bootstrap/队列,分析开关只从隔离runtime读取。按本次账户/产品/队列/作业文件/故障触发器精确清理,清理成功才输出PASS,异常非零。
|
||||||
|
- HTTP专项514项/158次请求通过:匿名/撤销Token/越权/no-store/非法输入及JSON大小、元金额精度/零数与未知、只读脱敏/完整回填、个人/企业独立新建和各一份最新资料、修改本评估最新版本、原键重放不回写最新资料、错内容/作用域/基准/类型冲突、旧版不变、同键新建/修改双进程各一输入、异键修改一成功一REVISION_CONFLICT。触发器注入资料更新失败验证新建/修改完整事务回滚,移除故障后原键可提交;处理中拒绝修改,同键不新增调用,个人/企业重评失败保留旧成功报告,重试成功才发布新版,迟到旧任务零调用;23版分页/独立记录分页/过滤/软删除拒绝通过。仅5次显式假调用,无真实模型/消费worker。版本专项58项通过,合计572项,不记作正式业务验收。
|
||||||
|
- ROOT `C:/Users/JUN/AppData/Local/Temp/airz-assessment-backend-20261006-1791288138879/`,MySQL5.7.26仅127.0.0.1:3322、Redis5.0.14.1仅127.0.0.1:6402/DB15,模型127.0.0.1:1。26表由schema初始化,PDF复制现有锁定依赖/字体,无安装或正式.env/生产连接。初期合成用户名长度、MySQL启动参数及ThinkPHP测试DDL未缓冲查询问题已修正,DDL改用独立PDO,早期未完成结果不计最终数。六业务表/故障触发器/Redis键均0,测试库已DROP,专用HTTP/MySQL/Redis停止,3322/6402无监听。两文件PHP7.4 lint、缺bootstrap拒绝、两仓库diff/UTF-8无BOM通过。
|
||||||
|
- 安全证据ROOT内 `verification.json`,最终 `assessment-final.out`/`assessment-final.err`、`revisions-final.out`/`revisions-final.err`;临时bootstrap/runtime/工具文件不整体分享或直接复用,下次另建隔离环境。前端业务源码未变,不重复浏览器/平台编译,沿用0.72最终证据。`%SystemDrive%/`、`scripts/`、`.vite/` 及两仓库既有未提交保持。
|
||||||
|
- 台账1.63/0.73,63任务/27页面不变,ASSESS-004仍开发中;真实渠道、真机及正式验收未覆盖。此前新增业务源码仍未配套发布,不沿用历史生产授权。
|
||||||
|
- 下一开发入口:AUTH-003/004账户资料、改密及退出组合开发检查。核对个人中心/编辑资料账户隔离和迟到响应、昵称不覆盖评估实名、保存失败重试、改密/退出会话撤销与旧页清理;短信/换绑未开放不产生假成功。优先受控响应,如需后端HTTP先核对脚本隔离入口,另建本机库/Redis。不重复评估/匹配/助手专项、自行真实短信或模型调用、正式验收、部署或重新发行小程序。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(ASSESS-004)
|
||||||
|
|
||||||
|
本轮完成资料填写、确认提交与档案回显组合开发检查,修复五类已复现问题。保留两仓库全部既有工作及用户此前小程序发布状态,新增源码尚未发行;本轮未正式验收、部署、提交或推送。
|
||||||
|
|
||||||
|
- 四个源码文件:`packages/finance/services/records.uts` 核对评估/版本/状态/回执/分页归属,修改回填须有有效版本与完整元单位资料,修改回执须属于原评估且生成新版本;`confirm/confirm.uvue` 补账户/可见性/请求代次守卫;`assessment/assessment.uvue`、`archive/archive.uvue` 补可见期账户检查和离页停止,旧操作及选图回调守卫。档案重新加载后重启检查,版本请求结束后再次核对详情,避免换账户清空后访问空详情。
|
||||||
|
- Chrome手机390×844、桌面1280×900最终96项/72次受控业务请求/13次合成POST通过。覆盖个人/企业空白新建与最新资料修改、历史档案修改仍读最新、零经营月数/零负债、12.345678万元=123456.78元、原键重放/改内容换键、坏回执/版本/分页、附件授权拒绝、脱敏/完整度局部重试、失败保留旧成功结果入口、账户立即操作/无操作清空/重载及离页/迟到401。pageerror=0,console仅刻意401,未定义API=0;身份/非空/无遮罩/横溢/四截图通过。全部外部请求拦截,无实际后端或模型调用;真实失败持久化沿用0.65证据,不把本轮页面检查当正式验收。
|
||||||
|
- HBuilderX5.26最终Android18:29:34、微信18:30:24,27页面编译成功。Number(string)不兼容Kotlin改为parseInt;初期脚本字段定位、锁定类型/自定义按钮断言、立即操作自动等待竞争、只读夹具脱敏及预览加载超时已修正,早期未完成结果不计入最终通过数。Browser plugin not available,缓存Playwright CLI,无新增依赖;共享layout既有H5类型警告保留。diff及变更文本UTF-8无BOM检查通过,保留行尾约定。
|
||||||
|
- ROOT `C:/Users/JUN/AppData/Local/Temp/airz-assess004-20261006/`,安全证据为 `verification.json` 与 `confirm-mobile.png`、`archive-mobile.png`、`confirm-desktop.png`、`archive-desktop.png`。`repro.js`、`verify.js` 和CLI日志含无服务器权限合成会话,不整体分享或直接复用。存储/cookies/routes清空,专用 `assess004`、`assess004-final` 浏览器已关闭,本轮5174/PID76016停止且无监听;另一项目5173/PID68536保留。未启动数据库/Redis/后端/worker,未读正式.env/连接生产/调用真实模型;后端仅只读。前端 `%SystemDrive%/`、`scripts/`、`.vite/` 及两仓库既有未提交工作保留。
|
||||||
|
- 台账1.62/0.72,63任务/27页面不变,ASSESS-004仍开发中。真机、Safari/微信内H5、真实渠道及正式验收未覆盖;此前新增业务源码及本轮四文件仍未配套发布,不沿用历史发布授权。
|
||||||
|
- 下一开发入口:评估新建/修改后端隔离HTTP检查。旧 `D:/AAdesk/202609/airz/tests/api_assessments.php` 在参数检查前初始化默认环境,夹具未声明inputVersion=3,不得直接运行;先适配显式隔离bootstrap、当前版本存储、子进程/本机HTTP继承及精确清理,再另建本机库/Redis和假模型。`tests/assessment_revisions.php` 已接显式report_test_bootstrap,可作版本行为回归入口。不要重复匹配/助手专项、自行正式验收、真实模型调用、部署或重新发行小程序。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(PRODUCT-004后端)
|
||||||
|
|
||||||
|
本轮完成匹配读取与产品解释在当前存储/历史版本下的后端隔离开发检查,仅修改测试脚本与夹具,未修改前后端业务源码。两仓库全部既有工作及用户既有小程序发布状态保持,本轮未正式验收、部署、提交或推送。
|
||||||
|
|
||||||
|
- `D:/AAdesk/202609/airz/tests/match_records.php`、`tests/product_explanations.php` 接显式 `--bootstrap=<path>`,缺参时在框架初始化前退出;新增 `tests/match_test_fixtures.php` 复用inputVersion=3/AssessmentStore,故障注入同时更新已发布版本日志和最新镜像。双进程和新增 `tests/match_http_router.php` 继承隔离入口及随机队列,HTTP仅本机;精确清理本次账户、产品、队列,清理成功才输出PASS,异常非零。
|
||||||
|
- 匹配238项/101次真实本机HTTP、产品解释52项,共290项通过。覆盖三态/分页/游标/鉴权/越权/非法参数/no-store/损坏快照/目录故障、个人及企业重评处理中/失败保留旧版、新版成功默认切换/旧游标继续原版、历史解释只读及无调用预留、同键HTTP重放只预留一次。解释覆盖双进程幂等、重复消费/缓存、冻结值与游标不变、脱敏/非法输出/reasonId重排、原键重放/新键重试/三次上限、租约/迟到用量/每日次数及删除记录。
|
||||||
|
- 零候选旧夹具不适用v3资料模式,已临时给“普惠基础融资贷”加入必不满足的收入规则,验证completed的0 eligible/3 ineligible/1 unknown并finally恢复原配置。仅修正测试假设,无后端业务缺陷修复;早期未通过结果不计入最终通过数。四文件PHP7.4 lint、两缺bootstrap拒绝检查、两仓库diff及35个变更文本UTF-8无BOM检查通过。前端未变,不重复平台编译/浏览器检查,沿用0.70最终证据。
|
||||||
|
- ROOT `C:/Users/JUN/AppData/Local/Temp/airz-match-backend-20261006-1791279449016/`,MySQL仅127.0.0.1:3321、Redis仅127.0.0.1:6401/DB15、模型地址127.0.0.1:1。隔离账户/产品/会话/日志均0、Redis DB15无键,专用HTTP及MySQL/Redis已停止,收尾再次核对3321/6401无监听。未读正式.env、连接生产数据库/Redis/worker或调用真实模型。安全证据为ROOT内 `verification.json`、最终 `match-final.out`/`explain-final.out`;临时bootstrap/runtime/工具文件不整体分享或直接复用,下次另建隔离环境。
|
||||||
|
- 台账1.61/0.71,63任务/27页面不变,PRODUCT-004仍开发中、PRODUCT-005待验收。真实产品、跨浏览器/真机、生产容量和正式验收未覆盖;此前REPORT-002/003/004后端、档案修复、REPORT-005微信PDF、REPORT-006、CHAT-004及0.70前端源码仍未配套发布,不沿用历史发布授权。前端既有 `%SystemDrive%/`、`scripts/` 和两仓库未提交工作保留。
|
||||||
|
- 下一开发入口:ASSESS-004资料填写、确认提交与档案回显组合检查,核对个人/企业新建空白和修改最新资料、重评失败保留旧报告、金额精度、同键重放、账户变化和迟到回包。优先受控API响应;如需后端写夹具,先检查对应脚本隔离入口,另建本机库/Redis及假模型。不要重复匹配/助手专项、自行真实模型调用、正式验收、部署或重新发行小程序。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(PRODUCT-004/005)
|
||||||
|
|
||||||
|
本轮完成匹配列表、产品详情、对比的历史版本组合开发检查。保留两仓库全部既有工作,小程序既有发布状态保持,本轮未正式验收、部署、提交或推送。
|
||||||
|
|
||||||
|
- 修复已复现的错快照分页追加、默认入口刷新换版、换账户后旧产品解释请求/对比详情跳转。`matches.uts` 校验版本/产品快照/分组/重复ID/游标/状态/统计;解释与详情复用冻结记录核对,公共产品独立核对。列表分页失败保留首屏与原游标局部重试,对比锁定首次成功快照;三个页面操作前/可见期/迟到回包均核对账户并清旧资料。H5地址固定首次成功revisionId、分组和实际选择;选择缓存绑定版本。`compare.uts`、登录页增加resumeMatch白名单,三个入口登录后恢复同版产品/选择/栏目/来源;历史解释保持只读。
|
||||||
|
- Chrome手机390×844、桌面1280×900共97项通过、104次全量拦截受控请求,6次合成POST(3解释、3登录)。覆盖服务坏响应、v1/零候选/零利率、冻结额度/材料与下架/unknown、旧版及新版默认回包、跨组选满三项/移除/补选/刷新/切栏、单列404/503/错快照重试、分页503/错快照/重复行原游标重试、换组迟到/换账户迟到401/立即旧动作/无操作清空、登录恢复/非法参数、解释原键重试/错快照轮询/查询恢复/次数上限及离页迟到详情。pageerror=0,console仅刻意401/404/503;身份/非空/无遮罩/横溢/截图通过。未生成真实重评任务,重评发布行为沿用0.65后端证据,不把合成回包当作真实业务验收。
|
||||||
|
- HBuilderX5.26最终微信17:18:58、Android17:19:47编译成功,63任务/27页面不变。Browser plugin not available,缓存Playwright CLI run-code --filename,无新依赖;共享layout既有H5类型警告保留。测试脚本的require/正则分组/重复参数/按钮文案定位问题已修正,不计通过数。
|
||||||
|
- 仓库外安全证据 `C:/Users/JUN/AppData/Local/Temp/airz-product-combo-verification.json`、`airz-product-combo-mobile.png`、`airz-product-combo-desktop.png`;临时repro/qa脚本和CLI日志含无服务权限的合成会话,不整体分享。存储/cookies/routes清空、专用浏览器关闭,本轮localhost预览停止;另一17:12:56启动的127.0.0.1:5173 Vite进程并非本轮启动命令,保留。无数据库/Redis/后端/worker/正式.env/正式连接/真实模型调用,后端源码只读。既有 `%SystemDrive%/`、`scripts/` 保留,无本轮新增未跟踪Vite缓存。
|
||||||
|
- 台账1.60/0.70,PRODUCT-004开发中、PRODUCT-005待验收。真实产品、跨浏览器/真机与正式验收未覆盖;前轮REPORT-002/003/004后端、档案修复、REPORT-005微信PDF、REPORT-006、CHAT-004及本轮源码仍未配套发布,不沿用历史发布授权。
|
||||||
|
- 下一开发入口:匹配只读与产品解释后端的当前存储/历史版本隔离检查。`D:/AAdesk/202609/airz/tests/match_records.php`、`tests/product_explanations.php` 在校验参数前初始化默认环境,仍用inputVersion=2和直接修改match_result的旧夹具;先适配显式隔离bootstrap、当前AssessmentStore、子进程与HTTP路由继承及精确清理,再新建本机库/Redis和假模型。不要直接运行旧脚本、重跑助手专项、自行真实模型调用/正式验收/部署。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(CHAT-004后端)
|
||||||
|
|
||||||
|
本轮完成助手消息幂等、并发额度与历史上下文隔离开发检查,保留两仓库全部既有工作。前端源码未变,小程序已发布状态保持,本轮未正式验收、部署、提交或推送。
|
||||||
|
|
||||||
|
- 后端 `tests/assistant_chats.php` 改为显式 `--bootstrap=<path>`,复用report_test_bootstrap守卫,在框架初始化前拒绝缺参;夹具采用inputVersion=3/AssessmentStore,双进程继承隔离入口、随机队列和作业文件。新增 `tests/assistant_http_router.php` 只接受本机专用标记和相同bootstrap,补PATH_INFO。失败/清理异常返回非零,清理成功才输出PASS,精确删除本次账户/产品/队列。
|
||||||
|
- 专项165项通过:双进程同键创建/发送/失败重试、不同键互斥、重复消费、成功重放不重复预留、暂停/额度耗尽重放、助手/产品解释竞争评估剩余额度、失败和软删除日志保留额度、租约/迟到用量/分页/脱敏/金额。个人重评处理中/失败/新版成功均保留旧会话冻结收入和产品,新会话取新版本;改名/下架不改旧上下文,失效会员/删除会话的待办假调用0。18次真实本机HTTP覆盖鉴权、越权、原键重放/改文、历史预览/恢复/错版、非法数组版本、撤销Token及no-store;错误方法按既有精确路由404,无调用预留。
|
||||||
|
- 复现并修复 `application/common/library/AssessmentStore.php` 一处问题:旧报告无待关闭解释时重写旧日志,同秒无变化UPDATE返回0被误判失败并标AI_INTERNAL。现在仅关闭旧queued/running解释时更新旧快照,其他情况保留旧行;真实故障回滚及缺失解释日志拒绝保持。报告持久化75、版本58、历史报告215项通过,加专项合计513;三个PHP7.4 lint、两仓库diff/UTF-8无BOM通过。前端未变,不重复平台编译/浏览器检查,沿用0.68最终证据。
|
||||||
|
- 全新ROOT `C:/Users/JUN/AppData/Local/Temp/airz-assistant-backend-20261006-1791275687879/`;MySQL5.7.26仅127.0.0.1:3310/airz_product_test_1791275687879,Redis5.0.14.1仅127.0.0.1:6398/DB15,无正式.env/3306/6379/线上库/生产worker,真实模型0。初期回环认证、旧夹具断言及ROOT缺PDF依赖问题已修正,不计通过数;PDF复制现有锁定依赖/字体,无安装或模板修改。六业务表0、故障触发器0、Redis键0,库已DROP、MySQL/Redis及专用PHP正常停止,3310/6398/8098/5173无监听。
|
||||||
|
- 安全证据为ROOT内 `verification.json`,四结果日志为 `assistant-result.log`、`persistence-result.log`、`revisions-result.log`、`history-result.log`;临时runtime/工具文件保留,不整体分享或直接复用bootstrap,下次另建隔离环境。前端 `%SystemDrive%/`、`scripts/` 及既有改动保留,本轮新增后端修复尚未上线。
|
||||||
|
- 台账1.59/0.69,63任务/27页面不变,CHAT-004仍开发中,CHAT-001—003待验收。真实语义、供应商费用对账、生产容量及真机未覆盖;不将假模型结果记作正式验收。
|
||||||
|
- 下一开发入口:PRODUCT-004/005匹配列表、产品详情及对比的历史/重评版本组合检查,重点分页/补选/刷新/单列失败重试、当前可用性与冻结值、账户变化及迟到回包。不要重复助手专项、自行启用真实模型、正式验收或沿用旧发布授权部署;前轮REPORT-002/003/004后端、档案修复、REPORT-005微信PDF、REPORT-006及0.68前端源码也仍未配套发布。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(CHAT-004)
|
||||||
|
|
||||||
|
本轮完成历史版本上下文与异常会话的组合开发检查,保留两仓库全部既有工作。小程序此前已由用户发布,本轮新增源码未发行;暂不正式验收、部署、提交或推送。
|
||||||
|
|
||||||
|
- 修复五类问题:预览、历史恢复、创建接受错版,发送接受错误请求回执,账户切换后立即发送旧问题。`packages/finance/services/assistant.uts` 增加评估/产品/显式版本/展示ID及版本有效性校验、创建会话编号校验、发送requestId/正文/尝试序号校验;无绑定历史入口仍恢复原冻结上下文,同键重放允许更高尝试序号。`packages/finance/assistant/assistant.uvue` 发送前校验账户与页面代次,首次/已有会话发送及旧请求重试均阻止换账户后的旧内容外发。
|
||||||
|
- Chrome手机390×844/桌面1280×900共62项通过,81次受控API请求、33次合成POST:上下文预览/创建/恢复、显式/无绑定历史、错版/回执、原键重放、429/503、断网、轮询、失败重试上限、历史分页、换账户/迟到401、立即换账户发送/重试、合成登录恢复及非法参数。页面身份/非空/无遮罩/横溢/截图通过,pageerror=0,console仅刻意429/503/401及net::ERR_FAILED;未定义请求0。全部API被拦截,本轮额度检查是提示/恢复检查,不证明后端并发额度。
|
||||||
|
- 微信16:23:43、Android16:25:31 HBuilderX5.26最终源码编译成功,63任务/27页面不变。Browser plugin not available,使用缓存Playwright CLI的run-code --filename;共享layout既有H5类型警告保留,无新增依赖。
|
||||||
|
- 仓库外证据为TEMP内 `airz-chat004-result.json`、`airz-chat004-mobile.png`、`airz-chat004-desktop.png`,最终脚本 `airz-chat004-qa.js`,初始复现脚本 `airz-chat004-repro.js` / `airz-chat004-switch-repro.js`。结果JSON无凭据,临时脚本/CLI日志含无服务权限的合成会话,不整体分享。存储/cookies/拦截路由清空,about:blank后关闭专用浏览器,5173停止;本轮生成的两个未跟踪Vite缓存文件已清除。未启动数据库/Redis/后端/worker,未读默认.env或连接生产,真实模型0;后端源码只读,既有 `%SystemDrive%/`、`scripts/` 保留。
|
||||||
|
- CHAT-004仍开发中,CHAT-001—003待验收,台账1.58/0.68;真实语义、真机/跨浏览器及生产容量未覆盖。前轮REPORT-002/003/004后端、档案修复、REPORT-005微信PDF、REPORT-006及本轮源码仍未配套发布。
|
||||||
|
- 下一开发入口:助手后端消息幂等、并发额度和历史上下文的隔离检查。旧 `D:/AAdesk/202609/airz/tests/assistant_chats.php` 会在校验参数前初始化默认环境,且夹具仍沿用旧inputVersion=2;先适配显式隔离bootstrap、当前版本存储、并发子进程与精确清理,再使用新建本机库/Redis及假模型,不直接运行旧脚本,不自行真实模型调用、正式验收或沿用历史授权发布。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(REPORT-006)
|
||||||
|
|
||||||
|
本轮完成行动页与报告可用性组合开发检查,保留两仓库全部既有工作。小程序此前已由用户发布,本轮源码未发行;暂不正式验收、部署、提交或推送。
|
||||||
|
|
||||||
|
- 修改 `packages/finance/next-step/next-step.uvue`:增加报告版本及重评处理中/失败/已有新版提示;v1旧版和v2无产品匹配结果时将入口改为“查看准备说明”,明确暂不提供产品材料。既有冻结版本与产品当前可用性守卫继续生效,不重算历史。
|
||||||
|
- Chrome手机390×844/桌面1280×900共38项通过:个人/企业历史与当前版、处理中/失败、v1/无匹配、冻结材料、下架/未知/不符/待核实、错版/错快照、无报告/404/409、账户切换/迟到响应及合成登录恢复。报告与AI入口/请求保留同版revisionId=72;优化进入editId=45并回填最新资料,无新建/自动提交。页面非空、身份/遮罩/横溢/截图通过,pageerror=0、console仅刻意404/409,无未定义请求。全部API为受控合成响应,仅GET及一次合成登录POST,不连接实际后端或调用模型。
|
||||||
|
- 微信15:57:28、Android15:58:55 HBuilderX5.26编译成功;63任务/27页面不变。Browser plugin not available,使用缓存Playwright CLI的run-code --filename;前期测试长参数/同页hash/onLoad/locator问题已修正,不计入业务通过。共享layout既有H5类型警告保留。
|
||||||
|
- 证据为TEMP内 `airz-report006-verification.json`、`airz-report006-20261006-processing-mobile.png`、`airz-report006-20261006-actions-desktop.png`;临时脚本 `airz-report006-boundaries.js`,本轮CLI日志/快照移至 `airz-report006-cli-artifacts/`。含无服务权限的合成会话,不整体分享;浏览器存储/路由清空、专用浏览器关闭、5173停止。未启动数据库/Redis/后端/worker,未读取默认.env或连接生产;既有 `%SystemDrive%/` 和 `scripts/` 保留。
|
||||||
|
- REPORT-006待验收状态保持,台账1.57/0.67。真机、跨浏览器、真实产品材料/办理渠道及正式业务验收未覆盖;前轮REPORT-002/003/004后端、档案修复、REPORT-005微信PDF及本轮源码仍未配套发布。
|
||||||
|
- 下一开发入口:CHAT-004历史版本上下文与异常会话组合检查,核对预览/创建/历史恢复绑定、账户变化/迟到响应及失败重试/额度错误。继续使用隔离数据和假模型,不自行正式验收、收费模型调用或复用历史发布授权。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(REPORT-005)
|
||||||
|
|
||||||
|
本轮修复用户反馈的小程序“当前客户端暂未开放PDF下载”,并完成导出边界开发检查。保留两仓库及REPORT-002/003/004全部未提交工作;小程序此前已由用户发布,本轮新增源码尚未发行,暂不正式验收、提交/推送或部署。
|
||||||
|
|
||||||
|
- 前端 `packages/finance/services/report-export.uts` 新增MP-WEIXIN分支:HTTPS下载、本账户Bearer请求头、显式revisionId、PDF MIME/头尾/长度/20 MiB校验,下载后打开系统PDF预览并显示保存/转发菜单。下载中取消abort且清理迟到文件,旧401不清新账户、账户变化不打开旧文件;预览接管后保留临时文件,返回小程序或下次导出清理,删除失败重试。不调用saveFile,不把Token放URL或文件名;报告和行动页共用组件。
|
||||||
|
- H5补显式版本编号、未压缩Content-Length与实际长度一致性及空响应防护。其他原生端/微信内H5的既有提示保持;本轮无后端/数据库变更。
|
||||||
|
- 新增 `scripts/test-report-export.cjs`,先用HBuilderX编译微信,再执行 `D:/Develop/nvm/nodejs/node.exe scripts/test-report-export.cjs`;实际微信产物38项隔离API检查通过,无网络/数据库。微信14:41:30、Android14:36:23编译成功,脚本语法及diff检查通过。
|
||||||
|
- Chrome手机390×844/桌面1280×900,实际报告页6项、H5服务边界17项通过;503保留报告并重试实际下载,鉴权/版本绑定及错误文件不下载。全部页面API为合成响应,边界fake fetch,无真实后端联调;pageerror=0、准备阶段console为空,业务console仅刻意503。首次裸UTS导入为测试路径错误,改 `?import` 后通过。Browser plugin not available,复用缓存Playwright。本轮PDF仅头尾标志夹具,未验证实际内容渲染;中文分页证据沿用0.65与6.21,不重复宣称本轮真机验证。
|
||||||
|
- ROOT `C:/Users/JUN/AppData/Local/Temp/airz-pdf-weixin-1791268574971/`:无凭据 `verification.json` 及截图 `h5-download-mobile.png`、`h5-download-desktop.png`。浏览器存储清空/route撤销/关闭,前台5173预览停止;未启动数据库、Redis、后端或worker,未读取默认.env、连接线上或调用模型。临时工具文件包含无服务器权限合成会话,不整体分享;前端未跟踪 `%SystemDrive%/` 继续保留。
|
||||||
|
- 发布前在微信后台单独配置downloadFile合法域名 `https://airz.hqling.cloud`,request合法域名不能替代;本轮未修改后台,用户需重新编译发行小程序。微信系统预览、保存/转发、真机域名及生产并发/容量仍未验证。REPORT-005待验收状态保持,当前台账1.56/0.66,63任务/27页面不变。
|
||||||
|
- 下一开发入口为REPORT-006行动页与报告可用性组合检查,关注处理中/失败/历史版本、行动入口的版本上下文和账户变化;不重复实施微信PDF,不自行真实模型调用、正式验收或沿用历史授权部署。前轮REPORT-002/003/004后端及档案修复也仍未上线。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(REPORT-004)
|
||||||
|
|
||||||
|
本轮完成历史报告与重评组合开发检查,保留两仓库及REPORT-002/003全部未提交工作。小程序已由用户发布,暂不正式验收、不重复发行;本轮没有生产部署、提交或推送。
|
||||||
|
|
||||||
|
- 后端修复 `D:/AAdesk/202609/airz/application/api/controller/v1/Assessments.php` 的 `revisionId` 原始类型读取,非法数组统一返回404/REVISION_NOT_FOUND,不再由框架强转导致500;越权仍优先404/ASSESSMENT_NOT_FOUND。
|
||||||
|
- 前端修复 `packages/finance/services/records.uts`:档案详情核对评估ID和显式版本,完整度核对显式版本。实际受控错版回包已复现并修复,错误不会展示另一版收入/完整度,重新加载和局部重试恢复正确资料。
|
||||||
|
- 新增后端 `tests/report_history.php`、`tests/report_history_http_router.php`,必须显式隔离bootstrap;个人/企业初版成功、重评排队/失败/重试/新版成功、旧版显式读取、报告/匹配/PDF同版、冻结产品不变、历史解释只读、游标版本、v1兼容、匿名/越权/非法版本、引擎503与撤销Token401共215项通过。`assessment_revisions.php` 适配显式bootstrap、并发子进程及精确清理,58项通过,合计273项PHP检查;四个本轮PHP文件7.4 lint与diff检查通过。
|
||||||
|
- Chrome手机390×844/桌面1280×900共32项、82次隔离GET请求通过,全部API转发本机8098,三类错版用受控响应注入。三栏/完整匹配列表/详情、失败与处理中旧报告、新版默认与历史深链、只读解释和三次PDF下载通过;pageerror/console error/warn均0。13文件PDF收入/序号/冻结名称/身份脱敏52项检查通过,个人历史8页、企业新版3页Poppler渲染核对;未改导出模板。微信10:08:29、Android10:08:53 HBuilderX5.26本地编译成功,27页/63任务不变。Browser plugin not available,沿用缓存Playwright;真机/跨浏览器/正式模型未验证。
|
||||||
|
- 隔离ROOT `C:/Users/JUN/AppData/Local/Temp/airz-report004-20261006-1791251466341/`;MySQL5.7.26在3310随机库 `airz_product_test_1791251466341`、Redis5.0.14.1在6398/DB15。未加载正式.env、未连正式3306/6379/线上/生产worker,模型外呼0。六业务表0、故障触发器0、Redis键0,库已DROP,MySQL/Redis正常停止,8098 PHP停止,浏览器存储清空并关闭。后台常驻H5预览启动被自动审批 `blocked by policy` 拒绝,未提供更具体原因,未重试/绕过;5173也已停止。
|
||||||
|
- 安全证据:ROOT内 `verification.json`、`cleanup-result.json`、`ui-result.json`、`pdf-verification.json`;截图 `archive-mismatch-fixed-mobile.png`、`historical-matches-desktop.png`、`historical-personal-product.png` 等。fixture和浏览器日志可能含已撤销的合成Token,不整体共享或重启bootstrap,后续另建隔离环境;前端未跟踪 `%SystemDrive%/` 继续保留。
|
||||||
|
- REPORT-004整体仍开发中,本轮三处修复尚未上线;真实产品、外部查询、人工业务签收及正式验收状态不变。下一开发项为REPORT-005 PDF导出边界与容量检查,优先异常二进制、超限、取消/离页、登录状态变化和分页可读性;不要重复REPORT-004组合检查、自行真实模型调用或沿用历史授权部署。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(REPORT-003)
|
||||||
|
|
||||||
|
本轮继续完成报告持久化开发检查,保留前轮REPORT-002及当前两仓库全部未提交工作。小程序已由用户发布,暂不正式验收、重复发行、部署、提交或推送。
|
||||||
|
|
||||||
|
- 后端实现:`D:/AAdesk/202609/airz/application/common/library/Assessment.php`、`AssessmentStore.php` 与 `AnalysisTasks.php` 将报告、匹配、当前/历史输入快照、旧解释关闭、发布指针与result_version同事务保存;真实写故障全部回滚,缺失旧解释日志安全失败,迟到结果只记原用量。新建门槛仍inputVersion=3,v1报告与旧冻结v2继续兼容。
|
||||||
|
- 新增 `tests/report_persistence.php`(75项)与 `tests/report_test_bootstrap.php`(必须显式隔离bootstrap,限制本机测试库、非正式端口及禁用的模型地址)。覆盖版本兼容、快照/模型事实复用、产品硬规则和当前配置变化、三态/null持久化、损坏哈希、四处真实SQL故障回滚、旧queued/running解释日志缺失、正常重评关闭旧解释及迟到任务保护。
|
||||||
|
- 旧回归已适配当前存储:`assessment_readiness.php`、`product_matching_tasks.php`、`assessment_demand_router.php` 显式隔离加载;`readiness_fixtures.php` 在测试内构造历史v2需求及策略,不通过生产接口新建v2。准备度/报告58项、匹配19项、任务36项,加专项共188项通过;10个PHP文件7.4 lint与diff检查通过,模型外呼0。
|
||||||
|
- 全新ROOT `C:/Users/JUN/AppData/Local/Temp/airz-report003-20261006-1791249639741/`,MySQL5.7.26在3310随机库 `airz_product_test_1791249639742`、Redis5.0.14.1在6398/DB15;未加载正式.env、未连正式3306/6379或线上/生产worker。六业务表已为0、故障触发器0、Redis键0,测试库已DROP,两个测试服务已正常停止;安全证据为ROOT内 `verification.json`。临时目录保留,不整体共享或直接重启bootstrap;下一轮另建隔离环境。
|
||||||
|
- 状态:自填报告/演示产品支线已实现,REPORT-003持久化本地检查完成;完整任务仍阻塞于真实产品、外部查询、人工业务规则签收及生产容量/守护,不能标整体验收完成。前端源码本轮未变,不重复小程序或Android编译。
|
||||||
|
- 下一开发项:REPORT-004历史报告与重评组合流程,优先检查重评处理中/失败保留旧结果、本人历史版本与匹配/PDF版本一致性,再按问题做最小修复。不要重复实施REPORT-003或运行旧版默认配置脚本,不自行启用真实模型或安排正式验收/生产发布。
|
||||||
|
|
||||||
|
## 新会话接续检查点:2026-10-06(REPORT-002)
|
||||||
|
|
||||||
|
本轮继续开发后端异步任务与补偿链,暂不正式验收、部署、提交或推送;小程序已由用户自行发布,本轮不重复处理移动端发行。
|
||||||
|
|
||||||
|
- 后端改动:`D:/AAdesk/202609/airz/application/common/library/AnalysisTasks.php` 加强合法 `task_id`、租约和补偿判断。非法/缺失任务ID不投递、不执行;异常运行任务明确失败为 `AI_TASK_INTERRUPTED`;失败日志按当前合法任务ID精确更新;未来有效租约不被恢复器误失败;queued 漏投仍补投且不增加尝试次数;迟到结果只补记原调用用量。
|
||||||
|
- 测试改动:`D:/AAdesk/202609/airz/tests/analysis_tasks.php` 必须传入显式隔离 `--bootstrap`,并改用当前 `fa_ai_chat`/`AssessmentStore` 读取状态;夹具使用 `inputVersion=3`,本轮测试显式把报告目标设为v1,只检查调度、租约、重试、Redis和日志边界。
|
||||||
|
- 验证:两个PHP文件7.4 lint通过;独立隔离 MySQL `127.0.0.1:3310/airz_product_test_2026100602` 与 Redis `127.0.0.1:6398`/DB15 中,模型关闭、假客户端回归 `PASS: 36 analysis task checks.`。覆盖并发重试/消费者、过期/缺失租约、非法任务ID、迟到结果、Redis断开补投和历史日志保护。回归后已停止3310/6398隔离服务。
|
||||||
|
- 边界:未读取或连接默认 `.env`、正式3306/6379、线上数据库或生产worker;未做真实模型、v2报告语义、生产守护/容量验收。临时ROOT含测试数据和日志,不整体共享或直接复用。
|
||||||
|
- 当前状态:REPORT-002整体仍开发中,新增代码和测试均未提交/推送;下一开发项优先为REPORT-003报告生成与持久化,继续保留v1历史兼容并单独验证v2策略/产品快照。
|
||||||
|
|
||||||
## 新会话接续检查点:2026-10-05
|
## 新会话接续检查点:2026-10-05
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
<view class="page" :style="pageStyle()"><AppHeader title="更换手机号" />
|
<view class="page" :style="pageStyle()"><AppHeader title="更换手机号" />
|
||||||
<scroll-view scroll-y :style="contentStyle(96, false)"><view class="account-form-content" :style="bottomStyle(40)">
|
<scroll-view scroll-y :style="contentStyle(96, false)"><view class="account-form-content" :style="bottomStyle(40)">
|
||||||
<text class="form-title">验证并绑定新手机号</text><text class="form-description">请分别验证当前号码和新号码,确保账户由您本人操作。</text>
|
<text class="form-title">验证并绑定新手机号</text><text class="form-description">请分别验证当前号码和新号码,确保账户由您本人操作。</text>
|
||||||
<view class="form-card"><text class="form-label">当前手机号 {{ maskPhone(phone) }}</text><SecurityCode v-model="currentCode" :disabled="submitting" purpose="phone-current" @error="codeError" @sent="errorMessage = ''" /></view>
|
<view class="form-card"><text class="form-label">当前手机号 {{ maskPhone(phone) }}</text><SecurityCode v-model="currentCode" :disabled="submitting" :active="pageVisible" :ownerToken="ownerToken" purpose="phone-current" @error="codeError" @sent="errorMessage = ''" /></view>
|
||||||
<view class="form-card"><text class="form-label">新手机号</text><input v-model="newPhone" class="account-input new-phone" :disabled="submitting" type="number" maxlength="11" placeholder="请输入新的 11 位手机号" /><SecurityCode v-model="newCode" :disabled="submitting || !validPhone(newPhone) || newPhone == phone" purpose="phone-new" :phone="newPhone" @error="codeError" @sent="errorMessage = ''" /></view>
|
<view class="form-card"><text class="form-label">新手机号</text><input v-model="newPhone" class="account-input new-phone" :disabled="submitting" type="number" maxlength="11" placeholder="请输入新的 11 位手机号" /><SecurityCode v-model="newCode" :disabled="submitting || !validPhone(newPhone) || newPhone == phone" :active="pageVisible" :ownerToken="ownerToken" purpose="phone-new" :phone="newPhone" @error="codeError" @sent="errorMessage = ''" /></view>
|
||||||
<text class="form-hint">换绑成功后需要使用新手机号登录。若原号码已无法接收短信,请通过官方客服核验身份后处理。</text>
|
<text class="form-hint">换绑成功后需要使用新手机号登录。若原号码已无法接收短信,请通过官方客服核验身份后处理。</text>
|
||||||
<text v-if="errorMessage.length > 0" class="form-error">{{ errorMessage }}</text>
|
<text v-if="errorMessage.length > 0" class="form-error">{{ errorMessage }}</text>
|
||||||
<button class="primary-button form-submit" :disabled="submitting" @click="submit">{{ submitting ? '提交中…' : '确认更换' }}</button>
|
<button class="primary-button form-submit" :disabled="submitting" @click="submit">{{ submitting ? '提交中…' : '确认更换' }}</button>
|
||||||
@@ -18,20 +18,35 @@
|
|||||||
import { getAccountProfile, maskPhone, validPhone, changeAccountPhone } from '@/packages/account/services/account.uts'
|
import { getAccountProfile, maskPhone, validPhone, changeAccountPhone } from '@/packages/account/services/account.uts'
|
||||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||||
const phone = ref(''); const newPhone = ref(''); const currentCode = ref(''); const newCode = ref(''); const submitting = ref(false); const errorMessage = ref('')
|
const phone = ref(''); const newPhone = ref(''); const currentCode = ref(''); const newCode = ref(''); const submitting = ref(false); const errorMessage = ref('')
|
||||||
onLoad(() => { if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }; phone.value = getAccountProfile().phone })
|
const ownerToken = ref(''); const pageVisible = ref(false)
|
||||||
const codeError = (message: string) => { errorMessage.value = message }
|
let ownerId = ''; let generation = 0; let accountTimer = 0
|
||||||
|
const clearSecrets = () => { currentCode.value = ''; newCode.value = ''; newPhone.value = '' }
|
||||||
|
const stopAccountTimer = () => { clearInterval(accountTimer); accountTimer = 0 }
|
||||||
|
const sameOwner = (): boolean => {
|
||||||
|
if (!pageVisible.value) return false
|
||||||
|
const session = getSession()
|
||||||
|
if (ownerToken.value.length > 0 && session?.getString('token') == ownerToken.value && session?.getString('userId') == ownerId) return true
|
||||||
|
generation++; clearSecrets(); phone.value = ''; errorMessage.value = ''; stopAccountTimer()
|
||||||
|
uni.redirectTo({ url: session == null ? '/packages/account/login/login' : '/packages/account/security/security' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
onLoad(() => { const session = getSession(); ownerToken.value = session?.getString('token') ?? ''; ownerId = session?.getString('userId') ?? ''; phone.value = getAccountProfile().phone })
|
||||||
|
onShow(() => { pageVisible.value = true; generation++; stopAccountTimer(); if (sameOwner()) accountTimer = setInterval(() => { sameOwner() }, 1000) })
|
||||||
|
const codeError = (message: string) => { if (sameOwner()) errorMessage.value = message }
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (submitting.value) return
|
if (!sameOwner() || submitting.value) return
|
||||||
submitting.value = true; errorMessage.value = ''
|
submitting.value = true; errorMessage.value = ''
|
||||||
|
const view = generation; const token = ownerToken.value; const userId = ownerId
|
||||||
try {
|
try {
|
||||||
if (getAccountProfile().phone != phone.value) throw new Error('登录状态已变化,请重新进入页面')
|
|
||||||
await changeAccountPhone(newPhone.value, currentCode.value, newCode.value)
|
await changeAccountPhone(newPhone.value, currentCode.value, newCode.value)
|
||||||
currentCode.value = ''; newCode.value = ''; clearSession(); uni.reLaunch({ url: '/packages/account/login/login' }); uni.showToast({ title: '手机号已更换,请重新登录', icon: 'none' })
|
if (getSession()?.getString('token') != token || getSession()?.getString('userId') != userId) return
|
||||||
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '更换失败,请稍后重试' }
|
clearSecrets(); clearSession()
|
||||||
|
if (view == generation && pageVisible.value) { uni.reLaunch({ url: '/packages/account/login/login' }); uni.showToast({ title: '手机号已更换,请重新登录', icon: 'none' }) }
|
||||||
|
} catch (error) { if (view == generation && sameOwner()) errorMessage.value = error instanceof Error ? error.message : '更换失败,请稍后重试' }
|
||||||
finally { submitting.value = false }
|
finally { submitting.value = false }
|
||||||
}
|
}
|
||||||
onHide(() => { currentCode.value = ''; newCode.value = '' })
|
onHide(() => { pageVisible.value = false; generation++; stopAccountTimer(); clearSecrets(); errorMessage.value = '' })
|
||||||
onUnload(() => { currentCode.value = ''; newCode.value = '' })
|
onUnload(() => { pageVisible.value = false; generation++; stopAccountTimer(); clearSecrets(); phone.value = '' })
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
@import '../styles/forms.css';
|
@import '../styles/forms.css';
|
||||||
|
|||||||
@@ -3,23 +3,29 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { requestSecurityCode } from '@/packages/account/services/account.uts'
|
import { requestSecurityCode } from '@/packages/account/services/account.uts'
|
||||||
const props = defineProps({ modelValue: { type: String, default: '' }, purpose: { type: String, default: 'password' }, phone: { type: String, default: '' }, disabled: { type: Boolean, default: false } })
|
import { getSession } from '@/services/api.uts'
|
||||||
|
const props = defineProps({ modelValue: { type: String, default: '' }, purpose: { type: String, default: 'password' }, phone: { type: String, default: '' }, disabled: { type: Boolean, default: false }, active: { type: Boolean, default: true }, ownerToken: { type: String, default: '' } })
|
||||||
const emit = defineEmits(['update:modelValue', 'error', 'sent'])
|
const emit = defineEmits(['update:modelValue', 'error', 'sent'])
|
||||||
const sending = ref(false); const countdown = ref(0)
|
const sending = ref(false); const countdown = ref(0)
|
||||||
let timer = 0; let version = 0; let expiresAt = 0
|
let timer = 0; let version = 0; let expiresAt = 0
|
||||||
const reset = () => { version++; clearInterval(timer); countdown.value = 0; sending.value = false }
|
const reset = () => { version++; clearInterval(timer); countdown.value = 0; sending.value = false }
|
||||||
const targetPhone = computed((): string => props.phone)
|
const targetPhone = computed((): string => props.phone)
|
||||||
watch(targetPhone, () => { reset(); emit('update:modelValue', '') })
|
watch(targetPhone, () => { reset(); emit('update:modelValue', '') })
|
||||||
|
watch((): boolean => props.active, () => reset())
|
||||||
|
watch((): string => props.ownerToken, () => { reset(); emit('update:modelValue', '') })
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
if (props.disabled || sending.value || countdown.value > 0) return
|
if (!props.active || props.disabled || sending.value || countdown.value > 0) return
|
||||||
|
const token = props.ownerToken
|
||||||
|
if (token.length == 0 || getSession()?.getString('token') != token) { emit('error', '登录状态已变化,请重新进入页面'); return }
|
||||||
const current = ++version; sending.value = true
|
const current = ++version; sending.value = true
|
||||||
|
const valid = (): boolean => current == version && props.active && props.ownerToken == token && getSession()?.getString('token') == token
|
||||||
try {
|
try {
|
||||||
await requestSecurityCode(props.purpose, props.phone)
|
await requestSecurityCode(props.purpose, props.phone)
|
||||||
if (current != version) return
|
if (!valid()) return
|
||||||
expiresAt = Date.now() + 60000; countdown.value = 60
|
expiresAt = Date.now() + 60000; countdown.value = 60
|
||||||
timer = setInterval(() => { countdown.value = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)); if (countdown.value == 0) clearInterval(timer) }, 1000)
|
timer = setInterval(() => { countdown.value = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)); if (countdown.value == 0) clearInterval(timer) }, 1000)
|
||||||
emit('sent'); uni.showToast({ title: '验证码已发送', icon: 'none' })
|
emit('sent'); uni.showToast({ title: '验证码已发送', icon: 'none' })
|
||||||
} catch (error) { if (current == version) emit('error', error instanceof Error ? error.message : '发送失败,请稍后重试') }
|
} catch (error) { if (valid()) emit('error', error instanceof Error ? error.message : '发送失败,请稍后重试') }
|
||||||
finally { if (current == version) sending.value = false }
|
finally { if (current == version) sending.value = false }
|
||||||
}
|
}
|
||||||
onUnmounted(() => reset())
|
onUnmounted(() => reset())
|
||||||
|
|||||||
@@ -20,59 +20,82 @@
|
|||||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||||
const nickname = ref(''); const avatar = ref(''); const phone = ref(''); const saving = ref(false); const choosing = ref(false); const errorMessage = ref('')
|
const nickname = ref(''); const avatar = ref(''); const phone = ref(''); const saving = ref(false); const choosing = ref(false); const errorMessage = ref('')
|
||||||
const avatarId = ref('')
|
const avatarId = ref('')
|
||||||
let originalName = ''; let originalAvatar = ''; let alive = true
|
let originalName = ''; let originalAvatar = ''; let ownerToken = ''; let ownerId = ''; let pageVisible = false; let generation = 0; let profileRequest = 0; let accountTimer = 0
|
||||||
const dirty = (): boolean => nickname.value.trim() != originalName || avatar.value != originalAvatar
|
const stopAccountTimer = () => { clearInterval(accountTimer); accountTimer = 0 }
|
||||||
|
const clearView = () => { nickname.value = ''; avatar.value = ''; phone.value = ''; avatarId.value = ''; originalName = ''; originalAvatar = ''; errorMessage.value = ''; saving.value = false; choosing.value = false }
|
||||||
|
const sameOwner = (): boolean => {
|
||||||
|
if (!pageVisible) return false
|
||||||
|
const session = getSession()
|
||||||
|
if (ownerToken.length > 0 && session?.getString('token') == ownerToken && session?.getString('userId') == ownerId) return true
|
||||||
|
generation++; profileRequest++; clearView(); stopAccountTimer()
|
||||||
|
uni.redirectTo({ url: session == null ? '/packages/account/login/login' : '/packages/account/profile/profile' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const dirty = (): boolean => nickname.value.trim() != originalName || avatar.value != originalAvatar || avatarId.value.length > 0
|
||||||
onLoad(() => {
|
onLoad(() => {
|
||||||
if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
|
const session = getSession(); ownerToken = session?.getString('token') ?? ''; ownerId = session?.getString('userId') ?? ''
|
||||||
|
})
|
||||||
|
onShow(() => {
|
||||||
|
pageVisible = true; generation++; stopAccountTimer()
|
||||||
|
if (!sameOwner()) return
|
||||||
|
accountTimer = setInterval(() => { sameOwner() }, 1000)
|
||||||
|
if (dirty() || saving.value) return
|
||||||
const profile = getAccountProfile(); nickname.value = profile.nickname; avatar.value = profile.avatar; phone.value = profile.phone
|
const profile = getAccountProfile(); nickname.value = profile.nickname; avatar.value = profile.avatar; phone.value = profile.phone
|
||||||
originalName = profile.nickname; originalAvatar = profile.avatar
|
originalName = profile.nickname; originalAvatar = profile.avatar
|
||||||
|
const request = ++profileRequest; const view = generation
|
||||||
// 服务端资料优先;请求返回时已开始编辑则不覆盖用户输入。
|
// 服务端资料优先;请求返回时已开始编辑则不覆盖用户输入。
|
||||||
fetchAccountProfile().then((fresh) => {
|
fetchAccountProfile().then((fresh) => {
|
||||||
if (!alive || dirty()) return
|
if (request != profileRequest || view != generation || !sameOwner() || dirty()) return
|
||||||
nickname.value = fresh.nickname; avatar.value = fresh.avatar; phone.value = fresh.phone
|
nickname.value = fresh.nickname; avatar.value = fresh.avatar; phone.value = fresh.phone
|
||||||
originalName = fresh.nickname; originalAvatar = fresh.avatar
|
originalName = fresh.nickname; originalAvatar = fresh.avatar
|
||||||
}).catch((error) => { if (alive) errorMessage.value = error instanceof Error ? error.message : '资料加载失败,请重试' })
|
}).catch((error) => { if (request == profileRequest && view == generation && sameOwner()) errorMessage.value = error instanceof Error ? error.message : '资料加载失败,请重试' })
|
||||||
})
|
})
|
||||||
const leave = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else uni.redirectTo({ url: '/packages/account/profile/profile' }) }
|
const leave = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else uni.redirectTo({ url: '/packages/account/profile/profile' }) }
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (saving.value) return
|
if (!sameOwner() || saving.value || choosing.value) return
|
||||||
if (!dirty()) { leave(); return }
|
if (!dirty()) { leave(); return }
|
||||||
uni.showModal({ title: '放弃修改?', content: '当前修改尚未保存。', confirmText: '放弃修改', success: (result) => { if (result.confirm) { originalName = nickname.value.trim(); originalAvatar = avatar.value; leave() } } })
|
const view = generation
|
||||||
|
uni.showModal({ title: '放弃修改?', content: '当前修改尚未保存。', confirmText: '放弃修改', success: (result) => { if (result.confirm && view == generation && sameOwner()) leave() } })
|
||||||
}
|
}
|
||||||
onBackPress((event): boolean => { if (event.from == 'navigateBack') return false; if (saving.value || dirty()) { back(); return true }; return false })
|
onBackPress((event): boolean => { if (event.from == 'navigateBack') return false; if (saving.value || dirty()) { back(); return true }; return false })
|
||||||
const loadAvatar = async (path: string) => {
|
const loadAvatar = async (path: string, view: number) => {
|
||||||
|
if (view != generation || !sameOwner()) return
|
||||||
choosing.value = true; errorMessage.value = ''
|
choosing.value = true; errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
const result = await uploadAccountAvatar(path)
|
const result = await uploadAccountAvatar(path)
|
||||||
if (alive) { avatar.value = result['avatarUrl'] as string; avatarId.value = result['attachmentId'] as string }
|
if (view == generation && sameOwner()) { avatar.value = result['avatarUrl'] as string; avatarId.value = result['attachmentId'] as string }
|
||||||
}
|
}
|
||||||
catch (error) { if (alive) errorMessage.value = error instanceof Error ? error.message : '图片读取失败' }
|
catch (error) { if (view == generation && sameOwner()) errorMessage.value = error instanceof Error ? error.message : '图片读取失败' }
|
||||||
finally { if (alive) choosing.value = false }
|
finally { choosing.value = false }
|
||||||
}
|
}
|
||||||
const chooseAvatar = () => {
|
const chooseAvatar = () => {
|
||||||
if (saving.value || choosing.value) return
|
if (!sameOwner() || saving.value || choosing.value) return
|
||||||
|
const view = generation
|
||||||
uni.showActionSheet({ itemList: ['从相册选择', '恢复默认头像'], success: (action) => {
|
uni.showActionSheet({ itemList: ['从相册选择', '恢复默认头像'], success: (action) => {
|
||||||
|
if (view != generation || !sameOwner()) return
|
||||||
if (action.tapIndex == 1) { avatar.value = ''; avatarId.value = 'default'; return }
|
if (action.tapIndex == 1) { avatar.value = ''; avatarId.value = 'default'; return }
|
||||||
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album'], success: (result) => {
|
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album'], success: (result) => {
|
||||||
if (result.tempFilePaths.length == 0) return
|
if (view != generation || !sameOwner() || result.tempFilePaths.length == 0) return
|
||||||
if (result.tempFiles[0].size > 512000) { errorMessage.value = '请选择 500KB 以内的图片'; return }
|
if (result.tempFiles[0].size > 512000) { errorMessage.value = '请选择 500KB 以内的图片'; return }
|
||||||
loadAvatar(result.tempFilePaths[0])
|
loadAvatar(result.tempFilePaths[0], view)
|
||||||
}, fail: (error) => { if (!error.errMsg.includes('cancel')) errorMessage.value = '无法打开相册,请检查权限后重试' } })
|
}, fail: (error) => { if (view == generation && sameOwner() && !error.errMsg.includes('cancel')) errorMessage.value = '无法打开相册,请检查权限后重试' } })
|
||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
const openPhone = () => { if (!saving.value && !choosing.value) uni.navigateTo({ url: '/packages/account/change-phone/change-phone' }) }
|
const openPhone = () => { if (sameOwner() && !saving.value && !choosing.value) uni.navigateTo({ url: '/packages/account/change-phone/change-phone' }) }
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (saving.value || choosing.value) return
|
if (!sameOwner() || saving.value || choosing.value) return
|
||||||
|
const view = generation; profileRequest++
|
||||||
errorMessage.value = ''; saving.value = true
|
errorMessage.value = ''; saving.value = true
|
||||||
try {
|
try {
|
||||||
if (getAccountProfile().phone != phone.value) throw new Error('登录状态已变化,请重新进入页面')
|
|
||||||
await saveAccountProfile(nickname.value, avatarId.value)
|
await saveAccountProfile(nickname.value, avatarId.value)
|
||||||
originalName = nickname.value.trim(); originalAvatar = avatar.value
|
if (view != generation || !sameOwner()) return
|
||||||
|
originalName = nickname.value.trim(); originalAvatar = avatar.value; avatarId.value = ''
|
||||||
uni.showToast({ title: '资料已保存', icon: 'success' }); leave()
|
uni.showToast({ title: '资料已保存', icon: 'success' }); leave()
|
||||||
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '保存失败,请稍后重试' }
|
} catch (error) { if (view == generation && sameOwner()) errorMessage.value = error instanceof Error ? error.message : '保存失败,请稍后重试' }
|
||||||
finally { saving.value = false }
|
finally { saving.value = false }
|
||||||
}
|
}
|
||||||
onUnload(() => { alive = false })
|
onHide(() => { pageVisible = false; generation++; profileRequest++; stopAccountTimer() })
|
||||||
|
onUnload(() => { pageVisible = false; generation++; profileRequest++; stopAccountTimer(); clearView() })
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.edit-content { padding: 28rpx 30rpx 40rpx; }
|
.edit-content { padding: 28rpx 30rpx 40rpx; }
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
import { assistantReturnPath } from '@/services/assistant-return.uts'
|
import { assistantReturnPath } from '@/services/assistant-return.uts'
|
||||||
import { nextStepRoute } from '@/services/next-step-route.uts'
|
import { nextStepRoute } from '@/services/next-step-route.uts'
|
||||||
import { reportRoute } from '@/services/report-route.uts'
|
import { reportRoute } from '@/services/report-route.uts'
|
||||||
|
import { matchReturnPath } from '@/packages/finance/services/compare.uts'
|
||||||
let afterLoginUrl = '/pages/home/home'
|
let afterLoginUrl = '/pages/home/home'
|
||||||
onLoad((options) => {
|
onLoad((options) => {
|
||||||
consumeInitialLaunch()
|
consumeInitialLaunch()
|
||||||
@@ -50,6 +51,9 @@
|
|||||||
} else if (options?.['resumeReport'] == '1') {
|
} else if (options?.['resumeReport'] == '1') {
|
||||||
const target = reportRoute(options?.['assessmentId'] ?? '', options?.['tab'] ?? 'overview', options?.['revisionId'] ?? '')
|
const target = reportRoute(options?.['assessmentId'] ?? '', options?.['tab'] ?? 'overview', options?.['revisionId'] ?? '')
|
||||||
if (target.length > 0) afterLoginUrl = target
|
if (target.length > 0) afterLoginUrl = target
|
||||||
|
} else if (options?.['resumeMatch'] == '1') {
|
||||||
|
const target = matchReturnPath(options?.['matchPage'] ?? '', options?.['assessmentId'] ?? '', options?.['productIds'] ?? '', options?.['revisionId'] ?? '', options?.['productId'] ?? '', options?.['group'] ?? 'eligible', options?.['tab'] ?? 'explanation', options?.['from'] ?? '')
|
||||||
|
if (target.length > 0) afterLoginUrl = target
|
||||||
} else if (options?.['resumePoints'] == '1') {
|
} else if (options?.['resumePoints'] == '1') {
|
||||||
afterLoginUrl = '/packages/account/points/points'
|
afterLoginUrl = '/packages/account/points/points'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
<text class="form-description">{{ mode == 'password' ? '为保障账户安全,请先验证当前密码。' : '验证码将发送至绑定手机号 ' + maskPhone(phone) }}</text>
|
<text class="form-description">{{ mode == 'password' ? '为保障账户安全,请先验证当前密码。' : '验证码将发送至绑定手机号 ' + maskPhone(phone) }}</text>
|
||||||
<view class="form-card">
|
<view class="form-card">
|
||||||
<template v-if="mode == 'password'"><text class="form-label">当前密码</text><input v-model="oldPassword" class="account-input old-password" :disabled="submitting" :password="!visible" maxlength="64" placeholder="请输入当前密码" /></template>
|
<template v-if="mode == 'password'"><text class="form-label">当前密码</text><input v-model="oldPassword" class="account-input old-password" :disabled="submitting" :password="!visible" maxlength="64" placeholder="请输入当前密码" /></template>
|
||||||
<template v-else><text class="form-label">短信验证码</text><SecurityCode v-model="code" :disabled="submitting" purpose="password" @error="codeError" @sent="errorMessage = ''" /></template>
|
<template v-else><text class="form-label">短信验证码</text><SecurityCode v-model="code" :disabled="submitting" :active="pageVisible" :ownerToken="ownerToken" purpose="password" @error="codeError" @sent="errorMessage = ''" /></template>
|
||||||
<text class="form-label">新密码</text><input v-model="newPassword" class="account-input new-password" :disabled="submitting" :password="!visible" maxlength="32" placeholder="8–32 位,包含字母和数字" />
|
<text class="form-label">新密码</text><input v-model="newPassword" class="account-input new-password" :disabled="submitting" :password="!visible" maxlength="32" placeholder="8–32 位,包含字母和数字" />
|
||||||
<text class="form-label">确认新密码</text><input v-model="confirmation" class="account-input confirm-password" :disabled="submitting" :password="!visible" maxlength="32" placeholder="请再次输入新密码" confirm-type="done" @confirm="submit" />
|
<text class="form-label">确认新密码</text><input v-model="confirmation" class="account-input confirm-password" :disabled="submitting" :password="!visible" maxlength="32" placeholder="请再次输入新密码" confirm-type="done" @confirm="submit" />
|
||||||
<text class="visibility-control" @click="visible = !visible">{{ visible ? '隐藏密码' : '显示密码' }}</text>
|
<text class="visibility-control" @click="toggleVisibility">{{ visible ? '隐藏密码' : '显示密码' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="form-hint">密码可包含英文符号,不支持空格。修改成功后请使用新密码重新登录。</text>
|
<text class="form-hint">密码可包含英文符号,不支持空格。修改成功后请使用新密码重新登录。</text>
|
||||||
<text v-if="errorMessage.length > 0" class="form-error">{{ errorMessage }}</text>
|
<text v-if="errorMessage.length > 0" class="form-error">{{ errorMessage }}</text>
|
||||||
@@ -25,27 +25,43 @@
|
|||||||
import { getAccountProfile, maskPhone, validNewPassword, validCode, changeAccountPassword } from '@/packages/account/services/account.uts'
|
import { getAccountProfile, maskPhone, validNewPassword, validCode, changeAccountPassword } from '@/packages/account/services/account.uts'
|
||||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||||
const mode = ref('password'); const phone = ref(''); const oldPassword = ref(''); const newPassword = ref(''); const confirmation = ref(''); const code = ref(''); const visible = ref(false); const submitting = ref(false); const errorMessage = ref('')
|
const mode = ref('password'); const phone = ref(''); const oldPassword = ref(''); const newPassword = ref(''); const confirmation = ref(''); const code = ref(''); const visible = ref(false); const submitting = ref(false); const errorMessage = ref('')
|
||||||
onLoad((options) => { if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }; phone.value = getAccountProfile().phone; if (options != null && options['mode'] == 'sms') mode.value = 'sms' })
|
const ownerToken = ref(''); const pageVisible = ref(false)
|
||||||
|
let ownerId = ''; let generation = 0; let accountTimer = 0
|
||||||
|
onLoad((options) => { const session = getSession(); ownerToken.value = session?.getString('token') ?? ''; ownerId = session?.getString('userId') ?? ''; phone.value = getAccountProfile().phone; if (options != null && options['mode'] == 'sms') mode.value = 'sms' })
|
||||||
const clearSecrets = () => { oldPassword.value = ''; newPassword.value = ''; confirmation.value = ''; code.value = ''; visible.value = false }
|
const clearSecrets = () => { oldPassword.value = ''; newPassword.value = ''; confirmation.value = ''; code.value = ''; visible.value = false }
|
||||||
const switchMode = () => { if (submitting.value) return; clearSecrets(); errorMessage.value = ''; mode.value = mode.value == 'password' ? 'sms' : 'password' }
|
const stopAccountTimer = () => { clearInterval(accountTimer); accountTimer = 0 }
|
||||||
const codeError = (message: string) => { errorMessage.value = message }
|
const sameOwner = (): boolean => {
|
||||||
|
if (!pageVisible.value) return false
|
||||||
|
const session = getSession()
|
||||||
|
if (ownerToken.value.length > 0 && session?.getString('token') == ownerToken.value && session?.getString('userId') == ownerId) return true
|
||||||
|
generation++; clearSecrets(); phone.value = ''; errorMessage.value = ''; stopAccountTimer()
|
||||||
|
uni.redirectTo({ url: session == null ? '/packages/account/login/login' : '/packages/account/security/security' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const switchMode = () => { if (!sameOwner() || submitting.value) return; clearSecrets(); errorMessage.value = ''; mode.value = mode.value == 'password' ? 'sms' : 'password' }
|
||||||
|
const toggleVisibility = () => { if (sameOwner()) visible.value = !visible.value }
|
||||||
|
const codeError = (message: string) => { if (sameOwner()) errorMessage.value = message }
|
||||||
|
onShow(() => { pageVisible.value = true; generation++; stopAccountTimer(); if (sameOwner()) accountTimer = setInterval(() => { sameOwner() }, 1000) })
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (submitting.value) return
|
if (!sameOwner() || submitting.value) return
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
if (mode.value == 'password' && oldPassword.value.length == 0) { errorMessage.value = '请输入当前密码'; return }
|
if (mode.value == 'password' && oldPassword.value.length == 0) { errorMessage.value = '请输入当前密码'; return }
|
||||||
if (mode.value == 'sms' && !validCode(code.value)) { errorMessage.value = '请输入 6 位数字验证码'; return }
|
if (mode.value == 'sms' && !validCode(code.value)) { errorMessage.value = '请输入 6 位数字验证码'; return }
|
||||||
if (!validNewPassword(newPassword.value)) { errorMessage.value = '新密码需为 8–32 位,包含字母和数字,可使用符号'; return }
|
if (!validNewPassword(newPassword.value)) { errorMessage.value = '新密码需为 8–32 位,包含字母和数字,可使用符号'; return }
|
||||||
if (newPassword.value != confirmation.value) { errorMessage.value = '两次输入的新密码不一致'; return }
|
if (newPassword.value != confirmation.value) { errorMessage.value = '两次输入的新密码不一致'; return }
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
|
const view = generation; const token = ownerToken.value; const userId = ownerId
|
||||||
try {
|
try {
|
||||||
if (getAccountProfile().phone != phone.value) throw new Error('登录状态已变化,请重新进入页面')
|
|
||||||
await changeAccountPassword(mode.value, oldPassword.value, code.value, newPassword.value)
|
await changeAccountPassword(mode.value, oldPassword.value, code.value, newPassword.value)
|
||||||
clearSecrets(); clearSession(); uni.reLaunch({ url: '/packages/account/login/login' }); uni.showToast({ title: '密码已修改,请重新登录', icon: 'none' })
|
// 服务端已撤销原账户会话;离页仍清理该会话,但不能影响新账户或跳转隐藏页面。
|
||||||
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '修改失败,请稍后重试' }
|
if (getSession()?.getString('token') != token || getSession()?.getString('userId') != userId) return
|
||||||
|
clearSecrets(); clearSession()
|
||||||
|
if (view == generation && pageVisible.value) { uni.reLaunch({ url: '/packages/account/login/login' }); uni.showToast({ title: '密码已修改,请重新登录', icon: 'none' }) }
|
||||||
|
} catch (error) { if (view == generation && sameOwner()) errorMessage.value = error instanceof Error ? error.message : '修改失败,请稍后重试' }
|
||||||
finally { submitting.value = false }
|
finally { submitting.value = false }
|
||||||
}
|
}
|
||||||
onHide(() => clearSecrets())
|
onHide(() => { pageVisible.value = false; generation++; stopAccountTimer(); clearSecrets(); errorMessage.value = '' })
|
||||||
onUnload(() => clearSecrets())
|
onUnload(() => { pageVisible.value = false; generation++; stopAccountTimer(); clearSecrets(); phone.value = '' })
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
@import '../styles/forms.css';
|
@import '../styles/forms.css';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<view class="profile-card" @click="openAccount"><view class="avatar"><image class="profile-avatar" :src="avatar.length > 0 ? avatar : DEFAULT_AVATAR" mode="aspectFill" @error="avatar = ''" /></view><view class="profile-main"><text class="profile-name">{{ displayName }}</text><text class="profile-phone">{{ loggedIn ? maskedPhone : '登录后管理您的融资信息' }}</text></view><text v-if="!loggedIn" class="account-state">未登录</text><AppIcon v-if="loggedIn" name="chevron-right" :size="28" /></view>
|
<view class="profile-card" @click="openAccount"><view class="avatar"><image class="profile-avatar" :src="avatar.length > 0 ? avatar : DEFAULT_AVATAR" mode="aspectFill" @error="avatar = ''" /></view><view class="profile-main"><text class="profile-name">{{ displayName }}</text><text class="profile-phone">{{ loggedIn ? maskedPhone : '登录后管理您的融资信息' }}</text></view><text v-if="!loggedIn" class="account-state">未登录</text><AppIcon v-if="loggedIn" name="chevron-right" :size="28" /></view>
|
||||||
<view class="stat-row"><view class="stat-item" @click="startAssessment"><AppIcon name="edit-file" :size="42" /><text class="stat-label">开始评估</text></view><view class="stat-item" @click="openReport"><AppIcon name="file-text" :size="42" /><text class="stat-label">{{ reportLoading ? '正在查找…' : '最近报告' }}</text></view><view class="stat-item" @click="go('/packages/account/points/points')"><AppIcon name="points" :size="42" /><text class="stat-label">积分中心</text></view></view>
|
<view class="stat-row"><view class="stat-item" @click="startAssessment"><AppIcon name="edit-file" :size="42" /><text class="stat-label">开始评估</text></view><view class="stat-item" @click="openReport"><AppIcon name="file-text" :size="42" /><text class="stat-label">{{ reportLoading ? '正在查找…' : '最近报告' }}</text></view><view class="stat-item" @click="go('/packages/account/points/points')"><AppIcon name="points" :size="42" /><text class="stat-label">积分中心</text></view></view>
|
||||||
<view class="menu-list"><view class="menu-row" v-for="item in menus" :key="item.name" @click="open(item.key)"><AppIcon :name="item.icon" :size="46" /><text class="menu-name">{{ item.name }}</text><text v-if="item.note.length > 0" class="menu-note">{{ item.note }}</text><AppIcon name="chevron-right" :size="28" /></view></view>
|
<view class="menu-list"><view class="menu-row" v-for="item in menus" :key="item.name" @click="open(item.key)"><AppIcon :name="item.icon" :size="46" /><text class="menu-name">{{ item.name }}</text><text v-if="item.note.length > 0" class="menu-note">{{ item.note }}</text><AppIcon name="chevron-right" :size="28" /></view></view>
|
||||||
<button class="logout" @click="logout">{{ loggedIn ? '退出登录' : '登录 / 注册' }}</button>
|
<button class="logout" :disabled="loggingOut" @click="logout">{{ loggingOut ? '退出中…' : (loggedIn ? '退出登录' : '登录 / 注册') }}</button>
|
||||||
<text class="version-note">AI融资 · 让融资更简单</text>
|
<text class="version-note">AI融资 · 让融资更简单</text>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
@@ -34,29 +34,41 @@
|
|||||||
const maskedPhone = ref('')
|
const maskedPhone = ref('')
|
||||||
const avatar = ref('')
|
const avatar = ref('')
|
||||||
const reportLoading = ref(false)
|
const reportLoading = ref(false)
|
||||||
let profileVisible = false; let reportRequest = 0
|
const loggingOut = ref(false)
|
||||||
onShow(() => {
|
let profileVisible = false; let reportRequest = 0; let generation = 0; let accountTimer = 0; let ownerToken = ''; let ownerId = ''
|
||||||
profileVisible = true
|
const stopAccountTimer = () => { clearInterval(accountTimer); accountTimer = 0 }
|
||||||
|
const syncProfile = () => {
|
||||||
|
const request = ++generation; reportRequest++; reportLoading.value = false; loggingOut.value = false
|
||||||
const session = getSession()
|
const session = getSession()
|
||||||
|
ownerToken = session?.getString('token') ?? ''; ownerId = session?.getString('userId') ?? ''
|
||||||
loggedIn.value = session != null
|
loggedIn.value = session != null
|
||||||
const profile = getAccountProfile()
|
const profile = getAccountProfile()
|
||||||
maskedPhone.value = maskPhone(profile.phone); avatar.value = loggedIn.value ? profile.avatar : ''
|
maskedPhone.value = maskPhone(profile.phone); avatar.value = loggedIn.value ? profile.avatar : ''
|
||||||
displayName.value = loggedIn.value ? profile.nickname : '登录 / 注册'
|
displayName.value = loggedIn.value ? profile.nickname : '登录 / 注册'
|
||||||
if (loggedIn.value) fetchAccountProfile().then((fresh) => {
|
if (loggedIn.value) fetchAccountProfile().then((fresh) => {
|
||||||
|
if (!profileVisible || request != generation || getSession()?.getString('token') != ownerToken || getSession()?.getString('userId') != ownerId) return
|
||||||
displayName.value = fresh.nickname; avatar.value = fresh.avatar; maskedPhone.value = maskPhone(fresh.phone)
|
displayName.value = fresh.nickname; avatar.value = fresh.avatar; maskedPhone.value = maskPhone(fresh.phone)
|
||||||
}).catch(() => { /* 读取失败保留已归属当前账户的缓存,不显示其他账户资料。 */ })
|
}).catch(() => { /* 读取失败保留已归属当前账户的缓存,不显示其他账户资料。 */ })
|
||||||
})
|
}
|
||||||
|
const sameOwner = (): boolean => {
|
||||||
|
if (!profileVisible) return false
|
||||||
|
const session = getSession()
|
||||||
|
if ((session?.getString('token') ?? '') == ownerToken && (session?.getString('userId') ?? '') == ownerId) return true
|
||||||
|
syncProfile(); return false
|
||||||
|
}
|
||||||
|
onShow(() => { profileVisible = true; syncProfile(); stopAccountTimer(); accountTimer = setInterval(() => { sameOwner() }, 1000) })
|
||||||
const go = (url: string) => { uni.navigateTo({ url }) }
|
const go = (url: string) => { uni.navigateTo({ url }) }
|
||||||
const startAssessment = () => {
|
const startAssessment = () => {
|
||||||
|
if (!sameOwner()) return
|
||||||
const session = getSession()
|
const session = getSession()
|
||||||
if (session == null) { go('/packages/account/login/login'); return }
|
if (session == null) { go('/packages/account/login/login'); return }
|
||||||
const token = session.getString('token') ?? ''
|
const view = generation
|
||||||
uni.showActionSheet({ itemList: ['个人评估', '企业评估'], success: (result) => {
|
uni.showActionSheet({ itemList: ['个人评估', '企业评估'], success: (result) => {
|
||||||
if (profileVisible && getSession()?.getString('token') == token) go('/packages/finance/entry/entry?type=' + (result.tapIndex == 0 ? 'personal' : 'enterprise'))
|
if (view == generation && sameOwner()) go('/packages/finance/entry/entry?type=' + (result.tapIndex == 0 ? 'personal' : 'enterprise'))
|
||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
const openReport = async () => {
|
const openReport = async () => {
|
||||||
if (reportLoading.value) return
|
if (!sameOwner() || reportLoading.value) return
|
||||||
const session = getSession()
|
const session = getSession()
|
||||||
if (session == null) { go('/packages/account/login/login'); return }
|
if (session == null) { go('/packages/account/login/login'); return }
|
||||||
const token = session.getString('token') ?? ''; const request = ++reportRequest
|
const token = session.getString('token') ?? ''; const request = ++reportRequest
|
||||||
@@ -84,22 +96,29 @@
|
|||||||
} finally { if (request == reportRequest) reportLoading.value = false }
|
} finally { if (request == reportRequest) reportLoading.value = false }
|
||||||
}
|
}
|
||||||
// 离页或退出后,旧查找结果及弹窗回调不得把用户带到前一个账户的评估。
|
// 离页或退出后,旧查找结果及弹窗回调不得把用户带到前一个账户的评估。
|
||||||
onHide(() => { profileVisible = false; reportRequest++; reportLoading.value = false })
|
const hideProfile = () => { profileVisible = false; generation++; reportRequest++; reportLoading.value = false; stopAccountTimer(); displayName.value = '登录 / 注册'; avatar.value = ''; maskedPhone.value = ''; loggedIn.value = false }
|
||||||
onUnload(() => { profileVisible = false; reportRequest++; reportLoading.value = false })
|
onHide(() => hideProfile())
|
||||||
|
onUnload(() => hideProfile())
|
||||||
const open = (key: string) => {
|
const open = (key: string) => {
|
||||||
|
if (!sameOwner()) return
|
||||||
if (key == 'archive') go('/packages/finance/archive/archive')
|
if (key == 'archive') go('/packages/finance/archive/archive')
|
||||||
else if (key == 'help') go('/packages/content/help/help')
|
else if (key == 'help') go('/packages/content/help/help')
|
||||||
else if (key == 'security') go(loggedIn.value ? '/packages/account/security/security' : '/packages/account/login/login')
|
else if (key == 'security') go(loggedIn.value ? '/packages/account/security/security' : '/packages/account/login/login')
|
||||||
else if (key == 'privacy') uni.showActionSheet({ itemList: ['查看隐私说明', '管理资料与授权'], success: (result) => { if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { go('/packages/finance/archive/archive') } } })
|
else if (key == 'privacy') { const view = generation; uni.showActionSheet({ itemList: ['查看隐私说明', '管理资料与授权'], success: (result) => { if (view != generation || !sameOwner()) return; if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { go('/packages/finance/archive/archive') } } }) }
|
||||||
else if (key == 'about') uni.showModal({ title: '关于 AI融资', content: '版本 V1.0.0\n帮助您整理融资信息、了解融资知识与融资方案。评估结果仅供参考,不构成授信承诺。', showCancel: false, confirmText: '我知道了' })
|
else if (key == 'about') uni.showModal({ title: '关于 AI融资', content: '版本 V1.0.0\n帮助您整理融资信息、了解融资知识与融资方案。评估结果仅供参考,不构成授信承诺。', showCancel: false, confirmText: '我知道了' })
|
||||||
}
|
}
|
||||||
const openAccount = () => { go(loggedIn.value ? '/packages/account/edit-profile/edit-profile' : '/packages/account/login/login') }
|
const openAccount = () => { if (sameOwner()) go(loggedIn.value ? '/packages/account/edit-profile/edit-profile' : '/packages/account/login/login') }
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
|
if (!sameOwner() || loggingOut.value) return
|
||||||
if (!loggedIn.value) { go('/packages/account/login/login'); return }
|
if (!loggedIn.value) { go('/packages/account/login/login'); return }
|
||||||
uni.showModal({ title: '退出登录', content: '将清除本机账户缓存和未提交的填写内容,已提交的评估记录不受影响。', confirmText: '退出登录', success: (result) => { if (result.confirm) {
|
const view = generation; const token = ownerToken; const userId = ownerId
|
||||||
logoutAccount().then(() => { uni.reLaunch({ url: '/packages/account/login/login' }) })
|
uni.showModal({ title: '退出登录', content: '将清除本机账户缓存和未提交的填写内容,已提交的评估记录不受影响。', confirmText: '退出登录', success: (result) => {
|
||||||
.catch(() => { uni.showToast({ title: '退出失败,请检查网络后重试', icon: 'none' }) })
|
if (!result.confirm || view != generation || !sameOwner() || loggingOut.value) return
|
||||||
} }, fail: (error) => { console.error('logout confirmation failed', error); uni.showToast({ title: '暂时无法打开确认框,请重试', icon: 'none' }) } })
|
loggingOut.value = true
|
||||||
|
logoutAccount().then(() => { if (profileVisible && view == generation && getSession() == null) uni.reLaunch({ url: '/packages/account/login/login' }) })
|
||||||
|
.catch(() => { if (profileVisible && view == generation && getSession()?.getString('token') == token && getSession()?.getString('userId') == userId) uni.showToast({ title: '退出失败,请检查网络后重试', icon: 'none' }) })
|
||||||
|
.finally(() => { if (view == generation) loggingOut.value = false })
|
||||||
|
}, fail: () => { if (view == generation && sameOwner()) uni.showToast({ title: '暂时无法打开确认框,请重试', icon: 'none' }) } })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,20 @@
|
|||||||
import { getAccountProfile, maskPhone } from '@/packages/account/services/account.uts'
|
import { getAccountProfile, maskPhone } from '@/packages/account/services/account.uts'
|
||||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||||
const phone = ref('')
|
const phone = ref('')
|
||||||
onShow(() => { if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }; phone.value = getAccountProfile().phone })
|
let ownerToken = ''; let ownerId = ''; let pageVisible = false; let accountTimer = 0
|
||||||
const go = (path: string) => { uni.navigateTo({ url: '/packages/account/' + path }) }
|
const stopAccountTimer = () => { clearInterval(accountTimer); accountTimer = 0 }
|
||||||
|
const sameOwner = (): boolean => {
|
||||||
|
if (!pageVisible) return false
|
||||||
|
const session = getSession()
|
||||||
|
if (ownerToken.length > 0 && session?.getString('token') == ownerToken && session?.getString('userId') == ownerId) return true
|
||||||
|
phone.value = ''; stopAccountTimer()
|
||||||
|
uni.redirectTo({ url: session == null ? '/packages/account/login/login' : '/packages/account/profile/profile' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
onShow(() => { pageVisible = true; const session = getSession(); ownerToken = session?.getString('token') ?? ''; ownerId = session?.getString('userId') ?? ''; stopAccountTimer(); if (!sameOwner()) return; phone.value = getAccountProfile().phone; accountTimer = setInterval(() => { sameOwner() }, 1000) })
|
||||||
|
onHide(() => { pageVisible = false; stopAccountTimer(); phone.value = '' })
|
||||||
|
onUnload(() => { pageVisible = false; stopAccountTimer(); phone.value = '' })
|
||||||
|
const go = (path: string) => { if (sameOwner()) uni.navigateTo({ url: '/packages/account/' + path }) }
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.security-content { padding: 30rpx; }
|
.security-content { padding: 30rpx; }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { draftText } from '@/services/assessment.uts'
|
|||||||
|
|
||||||
export type AccountProfile = { nickname: string, avatar: string, phone: string }
|
export type AccountProfile = { nickname: string, avatar: string, phone: string }
|
||||||
export const DEFAULT_AVATAR = '/packages/account/static/default-avatar.png'
|
export const DEFAULT_AVATAR = '/packages/account/static/default-avatar.png'
|
||||||
|
let profileWriteVersion = 0
|
||||||
const profileKey = (phone: string): string => 'finance.account.profile:' + phone
|
const profileKey = (phone: string): string => 'finance.account.profile:' + phone
|
||||||
export const maskPhone = (phone: string): string => phone.length == 11 ? phone.substring(0, 3) + '****' + phone.substring(7) : phone
|
export const maskPhone = (phone: string): string => phone.length == 11 ? phone.substring(0, 3) + '****' + phone.substring(7) : phone
|
||||||
export const validPhone = (phone: string): boolean => /^1[3-9][0-9]{9}$/.test(phone)
|
export const validPhone = (phone: string): boolean => /^1[3-9][0-9]{9}$/.test(phone)
|
||||||
@@ -17,7 +18,7 @@ export function requireAccount(): UTSJSONObject {
|
|||||||
}
|
}
|
||||||
export function sameAccount(session: UTSJSONObject): boolean {
|
export function sameAccount(session: UTSJSONObject): boolean {
|
||||||
const current = getSession()
|
const current = getSession()
|
||||||
return current != null && draftText(current, 'token') == draftText(session, 'token') && draftText(current, 'phone') == draftText(session, 'phone')
|
return current != null && draftText(current, 'token') == draftText(session, 'token') && draftText(current, 'userId') == draftText(session, 'userId') && draftText(current, 'phone') == draftText(session, 'phone')
|
||||||
}
|
}
|
||||||
export function getAccountProfile(): AccountProfile {
|
export function getAccountProfile(): AccountProfile {
|
||||||
const session = getSession()
|
const session = getSession()
|
||||||
@@ -37,19 +38,26 @@ async function accountRequest(path: string, method: RequestMethod, payload: UTSJ
|
|||||||
requireAccount()
|
requireAccount()
|
||||||
return await requestJson(path, method, payload)
|
return await requestJson(path, method, payload)
|
||||||
}
|
}
|
||||||
function cacheProfile(result: UTSJSONObject, phone: string): AccountProfile {
|
function cacheProfile(result: UTSJSONObject, session: UTSJSONObject): AccountProfile {
|
||||||
const profile: AccountProfile = { phone, nickname: draftText(result, 'nickname'), avatar: draftText(result, 'avatarUrl') }
|
const phone = draftText(session, 'phone'); const name = result.getString('nickname')
|
||||||
|
const avatar = result['avatarUrl']
|
||||||
|
if (draftText(result, 'userId') != draftText(session, 'userId') || name == null || name.trim().length < 2 || name.length > 20
|
||||||
|
|| (avatar != null && typeof avatar != 'string') || draftText(result, 'phoneMasked') != maskPhone(phone)) throw new Error('账户资料响应不完整或归属不符,请重试')
|
||||||
|
const profile: AccountProfile = { phone, nickname: name, avatar: draftText(result, 'avatarUrl') }
|
||||||
setUserStorage(profileKey(phone), { nickname: profile.nickname, avatar: profile.avatar })
|
setUserStorage(profileKey(phone), { nickname: profile.nickname, avatar: profile.avatar })
|
||||||
return profile
|
return profile
|
||||||
}
|
}
|
||||||
export async function fetchAccountProfile(): Promise<AccountProfile> {
|
export async function fetchAccountProfile(): Promise<AccountProfile> {
|
||||||
const session = requireAccount()
|
const session = requireAccount()
|
||||||
|
const version = profileWriteVersion
|
||||||
const result = await accountRequest('/account/profile', 'GET', {})
|
const result = await accountRequest('/account/profile', 'GET', {})
|
||||||
if (!sameAccount(session)) throw new Error('登录状态已变化,请重新进入页面')
|
if (!sameAccount(session)) throw new Error('登录状态已变化,请重新进入页面')
|
||||||
return cacheProfile(result, draftText(session, 'phone'))
|
// 保存完成前发出的旧读取不能回写昵称或头像缓存。
|
||||||
|
if (version != profileWriteVersion) throw new Error('账户资料已更新,请重新读取')
|
||||||
|
return cacheProfile(result, session)
|
||||||
}
|
}
|
||||||
export async function saveAccountProfile(nickname: string, avatarId: string): Promise<void> {
|
export async function saveAccountProfile(nickname: string, avatarId: string): Promise<void> {
|
||||||
const session = requireAccount(); const phone = draftText(session, 'phone')
|
const session = requireAccount()
|
||||||
const name = nickname.trim()
|
const name = nickname.trim()
|
||||||
if (name.length < 2 || name.length > 20) throw new Error('昵称请输入 2–20 个字符')
|
if (name.length < 2 || name.length > 20) throw new Error('昵称请输入 2–20 个字符')
|
||||||
const payload: UTSJSONObject = { nickname: name }
|
const payload: UTSJSONObject = { nickname: name }
|
||||||
@@ -57,12 +65,16 @@ export async function saveAccountProfile(nickname: string, avatarId: string): Pr
|
|||||||
else if (avatarId.length > 0) payload['avatarId'] = avatarId
|
else if (avatarId.length > 0) payload['avatarId'] = avatarId
|
||||||
const result = await accountRequest('/account/profile', 'PUT', payload)
|
const result = await accountRequest('/account/profile', 'PUT', payload)
|
||||||
if (!sameAccount(session)) throw new Error('登录状态已变化,请重新进入页面')
|
if (!sameAccount(session)) throw new Error('登录状态已变化,请重新进入页面')
|
||||||
cacheProfile(result, phone)
|
profileWriteVersion++
|
||||||
|
cacheProfile(result, session)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadAccountAvatar(path: string): Promise<UTSJSONObject> {
|
export async function uploadAccountAvatar(path: string): Promise<UTSJSONObject> {
|
||||||
requireAccount()
|
const session = requireAccount()
|
||||||
return await uploadJson('/account/avatar', path)
|
const result = await uploadJson('/account/avatar', path)
|
||||||
|
if (!sameAccount(session)) throw new Error('登录状态已变化,请重新进入页面')
|
||||||
|
if (!/^[1-9][0-9]{0,9}$/.test(draftText(result, 'attachmentId')) || draftText(result, 'avatarUrl').length == 0) throw new Error('头像上传响应不完整,请重试')
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
export async function requestSecurityCode(purpose: string, newPhone: string): Promise<void> {
|
export async function requestSecurityCode(purpose: string, newPhone: string): Promise<void> {
|
||||||
if (!['password', 'phone-current', 'phone-new'].includes(purpose)) throw new Error('验证码用途无效')
|
if (!['password', 'phone-current', 'phone-new'].includes(purpose)) throw new Error('验证码用途无效')
|
||||||
@@ -75,12 +87,14 @@ export async function changeAccountPassword(mode: string, oldPassword: string, c
|
|||||||
if (oldPassword.length == 0) throw new Error('请输入当前密码')
|
if (oldPassword.length == 0) throw new Error('请输入当前密码')
|
||||||
if (oldPassword == newPassword) throw new Error('新密码不能与当前密码相同')
|
if (oldPassword == newPassword) throw new Error('新密码不能与当前密码相同')
|
||||||
} else if (mode != 'sms' || !validCode(code)) throw new Error('请输入 6 位数字验证码')
|
} else if (mode != 'sms' || !validCode(code)) throw new Error('请输入 6 位数字验证码')
|
||||||
await accountRequest('/account/password', 'PUT', { verification: mode, oldPassword: mode == 'password' ? oldPassword : '', code: mode == 'sms' ? code : '', newPassword })
|
const result = await accountRequest('/account/password', 'PUT', { verification: mode, oldPassword: mode == 'password' ? oldPassword : '', code: mode == 'sms' ? code : '', newPassword })
|
||||||
|
if (result['reLoginRequired'] != true) throw new Error('密码修改响应不完整,请重试')
|
||||||
}
|
}
|
||||||
export async function changeAccountPhone(newPhone: string, currentCode: string, newCode: string): Promise<void> {
|
export async function changeAccountPhone(newPhone: string, currentCode: string, newCode: string): Promise<void> {
|
||||||
const session = requireAccount()
|
const session = requireAccount()
|
||||||
if (!validPhone(newPhone)) throw new Error('请输入正确的新手机号')
|
if (!validPhone(newPhone)) throw new Error('请输入正确的新手机号')
|
||||||
if (newPhone == draftText(session, 'phone')) throw new Error('新手机号不能与当前手机号相同')
|
if (newPhone == draftText(session, 'phone')) throw new Error('新手机号不能与当前手机号相同')
|
||||||
if (!validCode(currentCode) || !validCode(newCode)) throw new Error('请输入两组 6 位数字验证码')
|
if (!validCode(currentCode) || !validCode(newCode)) throw new Error('请输入两组 6 位数字验证码')
|
||||||
await accountRequest('/account/phone', 'PUT', { newPhone, currentCode, newCode })
|
const result = await accountRequest('/account/phone', 'PUT', { newPhone, currentCode, newCode })
|
||||||
|
if (result['reLoginRequired'] != true) throw new Error('手机号修改响应不完整,请重试')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { usePageLayout } from '@/services/layout.uts'
|
import { usePageLayout } from '@/services/layout.uts'
|
||||||
import { fetchLessons, FinanceLesson } from '@/services/content.uts'
|
import { fetchLessons, FinanceLesson, validateContentAppend } from '@/services/content.uts'
|
||||||
import AppHeader from '@/components/AppHeader.uvue'
|
import AppHeader from '@/components/AppHeader.uvue'
|
||||||
import AppIcon from '@/components/AppIcon.uvue'
|
import AppIcon from '@/components/AppIcon.uvue'
|
||||||
const { contentStyle, pageStyle } = usePageLayout()
|
const { contentStyle, pageStyle } = usePageLayout()
|
||||||
@@ -51,10 +51,11 @@ async function load(append: boolean) {
|
|||||||
if (!visible || loading.value || loadingMore.value || (append && !hasMore.value)) return
|
if (!visible || loading.value || loadingMore.value || (append && !hasMore.value)) return
|
||||||
const request = append ? generation : ++generation
|
const request = append ? generation : ++generation
|
||||||
if (append) { loadingMore.value = true; moreError.value = '' }
|
if (append) { loadingMore.value = true; moreError.value = '' }
|
||||||
else { loading.value = true; error.value = ''; lessons.value = []; total.value = 0; hasMore.value = false; page = 1 }
|
else { loading.value = true; error.value = ''; moreError.value = ''; lessons.value = []; total.value = 0; hasMore.value = false; page = 1 }
|
||||||
try {
|
try {
|
||||||
const result = await fetchLessons(activeTopic.value, append ? page + 1 : 1)
|
const result = await fetchLessons(activeTopic.value, append ? page + 1 : 1)
|
||||||
if (!visible || request != generation) return
|
if (!visible || request != generation) return
|
||||||
|
if (append) validateContentAppend(lessons.value.map((item: FinanceLesson): string => item.id), result.items.map((item: FinanceLesson): string => item.id))
|
||||||
lessons.value = append ? lessons.value.concat(result.items) : result.items
|
lessons.value = append ? lessons.value.concat(result.items) : result.items
|
||||||
total.value = result.total; hasMore.value = result.hasMore
|
total.value = result.total; hasMore.value = result.hasMore
|
||||||
if (append) page++
|
if (append) page++
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page" :style="pageStyle()">
|
<view class="page page-white" :style="pageStyle()">
|
||||||
<AppHeader title="课程详情" />
|
<AppHeader title="课程详情" />
|
||||||
<scroll-view scroll-y :scroll-into-view="scrollTarget" :style="contentStyle(96, false)">
|
<scroll-view scroll-y :style="contentStyle(96, false)">
|
||||||
<view v-if="loading" class="empty-content"><text class="empty-copy">正在加载课程…</text></view>
|
<view v-if="loading" class="empty-content"><text class="empty-copy">正在加载课程…</text></view>
|
||||||
<view v-else-if="error.length > 0" class="empty-content"><text class="empty-title">{{ error }}</text><button class="primary-button empty-button" @click="reload">重新加载</button></view>
|
<view v-else-if="error.length > 0" class="empty-content"><text class="empty-title">{{ error }}</text><button class="primary-button empty-button" @click="reload">重新加载</button></view>
|
||||||
<view v-else-if="available" class="lesson-detail-content">
|
<view v-else-if="available" class="lesson-detail-content">
|
||||||
<image class="lesson-cover" :src="lesson.image" mode="aspectFill"></image>
|
<image class="lesson-cover" :src="lesson.image" mode="aspectFill"></image>
|
||||||
<text class="lesson-detail-title">{{ lesson.title }}</text>
|
<text class="lesson-detail-title">{{ lesson.title }}</text>
|
||||||
<view class="lesson-detail-meta"><text class="lesson-tag">{{ lesson.tag }}</text><text class="lesson-duration">{{ lesson.duration }} · {{ lesson.chapters.length }} 节图文</text></view>
|
<view class="lesson-detail-meta"><text class="lesson-tag">{{ lesson.tag }}</text><text class="lesson-duration">{{ lesson.duration }}阅读</text></view>
|
||||||
<text class="lesson-intro">{{ lesson.intro }}</text>
|
<text class="lesson-intro">{{ lesson.intro }}</text>
|
||||||
<view class="progress-head"><text class="progress-label">学习进度</text><text class="progress-value">已完成 {{ completedCount }}/{{ lesson.chapters.length }} 节</text></view>
|
<view v-for="item in lesson.chapters" :key="item.id" class="reading-section">
|
||||||
<view class="lesson-progress"><view class="progress-fill" :style="'width:' + progress + '%;'"></view></view>
|
<text class="reading-heading">{{ item.title }}</text>
|
||||||
<view v-if="progressError.length > 0" class="progress-error"><text>{{ progressError }}</text><text class="progress-retry" @click="reload">刷新课程</text></view>
|
<text class="reading-body">{{ item.body }}</text>
|
||||||
<button class="primary-button start-learning" @click="start">{{ completedCount == 0 ? '开始学习' : (completedCount == lesson.chapters.length ? '回顾课程' : '继续学习') }}</button>
|
|
||||||
<text class="chapter-heading">课程目录</text>
|
|
||||||
<view class="chapter-card" v-for="(item, index) in lesson.chapters" :key="item.id" :id="'chapter-' + index">
|
|
||||||
<view class="chapter-row" @click="toggleChapter(index)">
|
|
||||||
<view :class="completed[index] ? 'chapter-index completed-index' : 'chapter-index'"><text :class="completed[index] ? 'index-text completed-text' : 'index-text'">{{ index + 1 }}</text></view>
|
|
||||||
<view class="chapter-copy"><text class="chapter-title">{{ item.title }}</text><text :class="completed[index] ? 'chapter-state completed-state' : 'chapter-state'">{{ completed[index] ? '已完成 · 可再次阅读' : '点击展开阅读' }}</text></view>
|
|
||||||
<AppIcon name="chevron-right" :size="28" :class="activeIndex == index ? 'chapter-arrow open-arrow' : 'chapter-arrow'" muted />
|
|
||||||
</view>
|
|
||||||
<view v-if="activeIndex == index" class="chapter-content">
|
|
||||||
<text class="chapter-body">{{ item.body }}</text>
|
|
||||||
<button :class="completed[index] ? 'chapter-done chapter-already-done' : 'chapter-done'" :disabled="completed[index] || saving" @click="completeChapter(index)">{{ completed[index] ? '本节已完成' : (saving ? '正在保存…' : (loggedIn ? '我已读完,完成本节' : '登录后记录学习进度')) }}</button>
|
|
||||||
</view>
|
</view>
|
||||||
|
<view class="reading-footer">
|
||||||
|
<text class="reading-note">内容仅供信息参考,具体申请条件以相关机构官方说明为准。</text>
|
||||||
|
<view v-if="progressError.length > 0" class="progress-error"><text>{{ progressError }}</text><text class="progress-retry" @click="reload">重新加载</text></view>
|
||||||
|
<button :class="isRead ? 'read-button already-read' : 'read-button'" :disabled="isRead || saving || progressLoading" @click="markRead">{{ isRead ? '已读完' : (saving ? '正在保存…' : (progressLoading ? '正在读取状态…' : (loggedIn ? '我已读完' : '登录后标记已读'))) }}</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="empty-content"><text class="empty-title">暂未找到这门课程</text><text class="empty-copy">返回融资课堂,选择其他课程。</text><button class="primary-button empty-button" @click="goClassroom">返回课堂</button></view>
|
<view v-else class="empty-content"><text class="empty-title">暂未找到这门课程</text><text class="empty-copy">返回融资课堂,选择其他课程。</text><button class="primary-button empty-button" @click="goClassroom">返回课堂</button></view>
|
||||||
@@ -34,105 +27,97 @@
|
|||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { getSession } from '@/services/api.uts'
|
import { getSession } from '@/services/api.uts'
|
||||||
import { usePageLayout } from '@/services/layout.uts'
|
import { usePageLayout } from '@/services/layout.uts'
|
||||||
import { fetchLesson, fetchLessonProgress, completeLessonChapter, emptyLesson, FinanceLesson } from '@/services/content.uts'
|
import { fetchLesson, fetchLessonProgress, completeLessonChapter, emptyLesson, FinanceLesson, validateLessonState } from '@/services/content.uts'
|
||||||
import AppHeader from '@/components/AppHeader.uvue'
|
import AppHeader from '@/components/AppHeader.uvue'
|
||||||
import AppIcon from '@/components/AppIcon.uvue'
|
|
||||||
const { contentStyle, pageStyle } = usePageLayout()
|
const { contentStyle, pageStyle } = usePageLayout()
|
||||||
const lesson = ref<FinanceLesson>(emptyLesson()); const available = ref(false); const loading = ref(false); const error = ref('')
|
const lesson = ref<FinanceLesson>(emptyLesson()); const available = ref(false); const loading = ref(false); const error = ref('')
|
||||||
const completedIds = ref<string[]>([]); const activeIndex = ref(-1); const scrollTarget = ref('')
|
const completedIds = ref<string[]>([])
|
||||||
const progressError = ref(''); const loggedIn = ref(false); const saving = ref(false)
|
const progressError = ref(''); const loggedIn = ref(false); const saving = ref(false); const progressLoading = ref(false)
|
||||||
let id = ''; let visible = false; let generation = 0
|
let id = ''; let visible = false; let generation = 0; let owner = ''; let accountTimer = 0
|
||||||
let reloadAfterAccountChange: () => void = () => {}
|
let reloadAfterAccountChange: () => void = () => {}
|
||||||
const tokenNow = (): string => getSession()?.getString('token') ?? ''
|
const tokenNow = (): string => getSession()?.getString('token') ?? ''
|
||||||
|
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
|
||||||
|
function ensureAccount(): boolean {
|
||||||
|
if (!visible) return false
|
||||||
|
if (tokenNow() == owner) return true
|
||||||
|
// 旧课程页不能用新账户提交完成记录;可见期也及时移除旧账户进度。
|
||||||
|
completedIds.value = []; saving.value = false; reloadAfterAccountChange()
|
||||||
|
return false
|
||||||
|
}
|
||||||
function current(request: number, token: string): boolean {
|
function current(request: number, token: string): boolean {
|
||||||
if (!visible || request != generation) return false
|
if (!visible || request != generation) return false
|
||||||
if (tokenNow() == token) return true
|
if (tokenNow() == token) return true
|
||||||
// 学习内容公开,但完成记录只属于当前账户;切换后立即清除旧进度。
|
ensureAccount()
|
||||||
generation++; completedIds.value = []; saving.value = false; reloadAfterAccountChange()
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
async function reload() {
|
async function reload() {
|
||||||
|
if (!visible) return
|
||||||
const request = ++generation; const token = tokenNow()
|
const request = ++generation; const token = tokenNow()
|
||||||
|
owner = token; stopAccountTimer(); accountTimer = setInterval(() => { ensureAccount() }, 1000)
|
||||||
loading.value = true; available.value = false; error.value = ''; progressError.value = ''; completedIds.value = []; saving.value = false
|
loading.value = true; available.value = false; error.value = ''; progressError.value = ''; completedIds.value = []; saving.value = false
|
||||||
loggedIn.value = token.length > 0
|
loggedIn.value = token.length > 0; progressLoading.value = false
|
||||||
try {
|
try {
|
||||||
const result = await fetchLesson(id)
|
const result = await fetchLesson(id)
|
||||||
if (!current(request, token)) return
|
if (!current(request, token)) return
|
||||||
lesson.value = result; activeIndex.value = -1; available.value = true
|
lesson.value = result; available.value = true; loading.value = false
|
||||||
if (token.length > 0) {
|
if (token.length > 0) {
|
||||||
|
progressLoading.value = true
|
||||||
try {
|
try {
|
||||||
const state = await fetchLessonProgress(id)
|
const state = await fetchLessonProgress(id)
|
||||||
if (!current(request, token)) return
|
if (!current(request, token)) return
|
||||||
if (state.lessonVersion != result.lessonVersion) { progressError.value = '课程已更新,请刷新后继续学习'; return }
|
validateLessonState(result, state)
|
||||||
completedIds.value = state.completedChapterIds
|
completedIds.value = state.completedChapterIds
|
||||||
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '进度加载失败,请重试' }
|
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '进度加载失败,请重试' }
|
||||||
}
|
}
|
||||||
} catch (err) { if (current(request, token)) error.value = err instanceof Error ? err.message : '课程加载失败,请重试' }
|
} catch (err) { if (current(request, token)) error.value = err instanceof Error ? err.message : '课程加载失败,请重试' }
|
||||||
finally { if (request == generation) loading.value = false }
|
finally { if (request == generation) { loading.value = false; progressLoading.value = false } }
|
||||||
}
|
}
|
||||||
reloadAfterAccountChange = () => { reload() }
|
reloadAfterAccountChange = () => { reload() }
|
||||||
const completed = computed((): boolean[] => lesson.value.chapters.map((item): boolean => completedIds.value.includes(item.id)))
|
const isRead = computed((): boolean => lesson.value.chapters.length > 0 && lesson.value.chapters.every((item): boolean => completedIds.value.includes(item.id)))
|
||||||
const completedCount = computed((): number => completedIds.value.length)
|
const markRead = async () => {
|
||||||
const progress = computed((): number => lesson.value.chapters.length > 0 ? Math.round(completedCount.value * 100 / lesson.value.chapters.length) : 0)
|
if (!ensureAccount() || !available.value || loading.value || progressLoading.value || saving.value || isRead.value) return
|
||||||
const toggleChapter = (index: number) => { activeIndex.value = activeIndex.value == index ? -1 : index }
|
|
||||||
const completeChapter = async (index: number) => {
|
|
||||||
if (!available.value || saving.value || index < 0 || index >= lesson.value.chapters.length) return
|
|
||||||
const token = tokenNow()
|
const token = tokenNow()
|
||||||
if (token.length == 0) { uni.navigateTo({ url: '/packages/account/login/login?resumeLesson=1&lessonId=' + id }); return }
|
if (token.length == 0) { uni.navigateTo({ url: '/packages/account/login/login?resumeLesson=1&lessonId=' + id }); return }
|
||||||
if (progressError.value.length > 0) { uni.showToast({ title: '请先刷新课程和进度', icon: 'none' }); return }
|
if (progressError.value.length > 0) { uni.showToast({ title: '请先重新加载已读状态', icon: 'none' }); return }
|
||||||
const request = generation; saving.value = true; progressError.value = ''
|
const request = generation; saving.value = true; progressError.value = ''
|
||||||
try {
|
try {
|
||||||
const state = await completeLessonChapter(id, lesson.value.chapters[index].id, lesson.value.lessonVersion)
|
// 沿用已上线的记录接口;一次操作补齐未完成段落,失败后可从已有记录继续。
|
||||||
if (current(request, token)) completedIds.value = state.completedChapterIds
|
const content = lesson.value
|
||||||
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '进度保存失败,请重试' }
|
for (let index = 0; index < content.chapters.length; index++) {
|
||||||
|
if (!current(request, token)) return
|
||||||
|
if (completedIds.value.includes(content.chapters[index].id)) continue
|
||||||
|
const state = await completeLessonChapter(id, content.chapters[index].id, content.lessonVersion)
|
||||||
|
if (!current(request, token)) return
|
||||||
|
validateLessonState(content, state); completedIds.value = state.completedChapterIds
|
||||||
|
}
|
||||||
|
if (!isRead.value) throw new Error('已读状态保存不完整,请重新加载后重试')
|
||||||
|
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '已读状态保存失败,请重试' }
|
||||||
finally { if (request == generation) saving.value = false }
|
finally { if (request == generation) saving.value = false }
|
||||||
}
|
}
|
||||||
const start = () => {
|
|
||||||
let nextIndex = completed.value.findIndex((value: boolean): boolean => !value)
|
|
||||||
if (nextIndex < 0) nextIndex = 0
|
|
||||||
activeIndex.value = nextIndex; scrollTarget.value = ''
|
|
||||||
nextTick(() => { scrollTarget.value = 'chapter-' + nextIndex })
|
|
||||||
}
|
|
||||||
const goClassroom = () => { uni.redirectTo({ url: '/packages/content/classroom/classroom' }) }
|
const goClassroom = () => { uni.redirectTo({ url: '/packages/content/classroom/classroom' }) }
|
||||||
onLoad((options) => { id = options?.['id'] ?? '' })
|
onLoad((options) => { id = options?.['id'] ?? '' })
|
||||||
onShow(() => { visible = true; reload() })
|
onShow(() => { visible = true; reload() })
|
||||||
onHide(() => { visible = false; generation++; completedIds.value = []; saving.value = false })
|
onHide(() => { visible = false; stopAccountTimer(); generation++; completedIds.value = []; saving.value = false; progressLoading.value = false })
|
||||||
onUnload(() => { visible = false; generation++; completedIds.value = [] })
|
onUnload(() => { visible = false; stopAccountTimer(); generation++; completedIds.value = []; saving.value = false; progressLoading.value = false })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.progress-error { color: #b64d35; font-size: 24rpx; line-height: 40rpx; margin-top: 16rpx; }
|
.progress-error { color: #b64d35; font-size: 24rpx; line-height: 40rpx; margin-top: 16rpx; }
|
||||||
.progress-retry { color: #0868f7; padding: 14rpx 0; }
|
.progress-retry { color: #0868f7; padding: 14rpx 0; }
|
||||||
.lesson-detail-content { padding: 24rpx 28rpx 48rpx; }
|
.lesson-detail-content { padding: 24rpx 30rpx 64rpx; }
|
||||||
.lesson-cover { width: 100%; height: 260rpx; border-radius: 18rpx; }
|
.lesson-cover { width: 100%; height: 260rpx; border-radius: 18rpx; }
|
||||||
.lesson-detail-title { color: #172640; font-size: 36rpx; line-height: 50rpx; font-weight: 700; margin-top: 24rpx; }
|
.lesson-detail-title { color: #172640; font-size: 36rpx; line-height: 50rpx; font-weight: 700; margin-top: 24rpx; }
|
||||||
.lesson-detail-meta { flex-direction: row; justify-content: space-between; align-items: center; margin-top: 14rpx; }
|
.lesson-detail-meta { flex-direction: row; justify-content: space-between; align-items: center; margin-top: 14rpx; }
|
||||||
.lesson-tag { color: #0868f7; font-size: 24rpx; }
|
.lesson-tag { color: #0868f7; font-size: 24rpx; }
|
||||||
.lesson-duration { color: #66758a; font-size: 23rpx; }
|
.lesson-duration { color: #66758a; font-size: 23rpx; }
|
||||||
.lesson-intro { color: #66758a; font-size: 26rpx; line-height: 44rpx; margin-top: 22rpx; }
|
.lesson-intro { color: #66758a; font-size: 26rpx; line-height: 44rpx; margin-top: 22rpx; }
|
||||||
.progress-head { flex-direction: row; align-items: center; justify-content: space-between; margin-top: 26rpx; }
|
.reading-section { margin-top: 32rpx; }
|
||||||
.progress-label { color: #172640; font-size: 24rpx; font-weight: 600; }
|
.reading-heading { color: #172640; font-size: 30rpx; line-height: 46rpx; font-weight: 600; }
|
||||||
.progress-value { color: #0868f7; font-size: 23rpx; }
|
.reading-body { color: #4d5e75; font-size: 28rpx; line-height: 52rpx; margin-top: 12rpx; }
|
||||||
.lesson-progress { height: 10rpx; margin-top: 12rpx; border-radius: 6rpx; background-color: #e1eafa; overflow: hidden; }
|
.reading-footer { border-top: 1rpx solid #e8eef6; padding-top: 24rpx; margin-top: 40rpx; }
|
||||||
.progress-fill { height: 100%; border-radius: 6rpx; background-color: #0868f7; }
|
.reading-note { color: #66758a; font-size: 22rpx; line-height: 36rpx; }
|
||||||
.start-learning { margin-top: 24rpx; }
|
.read-button { margin-top: 28rpx; width: 100%; height: 88rpx; line-height: 88rpx; border-radius: 12rpx; background-color: #eaf2ff; color: #0868f7; font-size: 28rpx; font-weight: 600; }
|
||||||
.chapter-heading { color: #172640; font-size: 30rpx; font-weight: 700; margin-top: 32rpx; margin-bottom: 14rpx; }
|
.already-read { color: #168454; background-color: #edf8f1; }
|
||||||
.chapter-card { border: 1rpx solid #e5ecf5; background-color: #ffffff; border-radius: 16rpx; margin-bottom: 14rpx; overflow: hidden; }
|
|
||||||
.chapter-row { min-height: 116rpx; padding: 20rpx; flex-direction: row; align-items: center; }
|
|
||||||
.chapter-index { width: 48rpx; height: 48rpx; border-radius: 14rpx; background-color: #eaf2ff; align-items: center; justify-content: center; margin-right: 16rpx; flex-shrink: 0; }
|
|
||||||
.index-text { color: #0868f7; font-size: 24rpx; font-weight: 700; }
|
|
||||||
.completed-index { background-color: #eaf7ef; }
|
|
||||||
.completed-text { color: #168454; }
|
|
||||||
.chapter-copy { flex: 1; padding-right: 8rpx; }
|
|
||||||
.chapter-title { color: #172640; font-size: 28rpx; line-height: 40rpx; font-weight: 600; }
|
|
||||||
.chapter-state { color: #66758a; font-size: 22rpx; margin-top: 6rpx; }
|
|
||||||
.completed-state { color: #168454; }
|
|
||||||
.chapter-arrow { transform: rotate(90deg); }
|
|
||||||
.open-arrow { transform: rotate(-90deg); }
|
|
||||||
.chapter-content { padding: 0 22rpx 24rpx; }
|
|
||||||
.chapter-body { color: #4d5e75; font-size: 27rpx; line-height: 48rpx; border-top: 1rpx solid #e8eef6; padding-top: 22rpx; }
|
|
||||||
.chapter-done { margin-top: 24rpx; width: 100%; height: 80rpx; line-height: 80rpx; border-radius: 12rpx; background-color: #eaf2ff; color: #0868f7; font-size: 26rpx; font-weight: 600; }
|
|
||||||
.chapter-already-done { color: #168454; background-color: #edf8f1; }
|
|
||||||
.empty-content { padding: 100rpx 40rpx; align-items: center; }
|
.empty-content { padding: 100rpx 40rpx; align-items: center; }
|
||||||
.empty-title { color: #172640; font-size: 32rpx; font-weight: 700; }
|
.empty-title { color: #172640; font-size: 32rpx; font-weight: 700; }
|
||||||
.empty-copy { color: #66758a; font-size: 26rpx; margin-top: 20rpx; }
|
.empty-copy { color: #66758a; font-size: 26rpx; margin-top: 20rpx; }
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { usePageLayout } from '@/services/layout.uts'
|
import { usePageLayout } from '@/services/layout.uts'
|
||||||
import { fetchArticles, emptyArticle, NewsArticle } from '@/services/content.uts'
|
import { fetchArticles, emptyArticle, NewsArticle, validateContentAppend } from '@/services/content.uts'
|
||||||
import AppHeader from '@/components/AppHeader.uvue'
|
import AppHeader from '@/components/AppHeader.uvue'
|
||||||
import BottomNav from '@/components/BottomNav.uvue'
|
import BottomNav from '@/components/BottomNav.uvue'
|
||||||
const { contentStyle, pageStyle } = usePageLayout()
|
const { contentStyle, pageStyle } = usePageLayout()
|
||||||
@@ -44,11 +44,12 @@ async function load(append: boolean) {
|
|||||||
if (!visible || loading.value || loadingMore.value || (append && !hasMore.value)) return
|
if (!visible || loading.value || loadingMore.value || (append && !hasMore.value)) return
|
||||||
const request = append ? generation : ++generation
|
const request = append ? generation : ++generation
|
||||||
if (append) { loadingMore.value = true; moreError.value = '' }
|
if (append) { loadingMore.value = true; moreError.value = '' }
|
||||||
else { loading.value = true; error.value = ''; articles.value = []; total.value = 0; hasMore.value = false; page = 1 }
|
else { loading.value = true; error.value = ''; moreError.value = ''; articles.value = []; total.value = 0; hasMore.value = false; page = 1 }
|
||||||
try {
|
try {
|
||||||
const result = await fetchArticles(tab.value == '推荐' ? '' : tab.value, append ? page + 1 : 1)
|
const result = await fetchArticles(tab.value == '推荐' ? '' : tab.value, append ? page + 1 : 1)
|
||||||
// 换分类或离页后,旧列表响应不能覆盖新选择。
|
// 换分类或离页后,旧列表响应不能覆盖新选择。
|
||||||
if (!visible || request != generation) return
|
if (!visible || request != generation) return
|
||||||
|
if (append) validateContentAppend(articles.value.map((item: NewsArticle): string => item.id), result.items.map((item: NewsArticle): string => item.id))
|
||||||
articles.value = append ? articles.value.concat(result.items) : result.items
|
articles.value = append ? articles.value.concat(result.items) : result.items
|
||||||
total.value = result.total; hasMore.value = result.hasMore
|
total.value = result.total; hasMore.value = result.hasMore
|
||||||
if (append) page++
|
if (append) page++
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
import AppHeader from '@/components/AppHeader.uvue'
|
import AppHeader from '@/components/AppHeader.uvue'
|
||||||
import { usePageLayout } from '@/services/layout.uts'
|
import { usePageLayout } from '@/services/layout.uts'
|
||||||
import { searchContent, ContentSearchResult } from '@/packages/content/services/search.uts'
|
import { searchContent, ContentSearchResult } from '@/packages/content/services/search.uts'
|
||||||
|
import { validateContentAppend } from '@/services/content.uts'
|
||||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||||
type SearchTab = { id: string, label: string }
|
type SearchTab = { id: string, label: string }
|
||||||
const tabs: SearchTab[] = [{ id: 'all', label: '全部' }, { id: 'news', label: '新闻' }, { id: 'lesson', label: '融资课堂' }]
|
const tabs: SearchTab[] = [{ id: 'all', label: '全部' }, { id: 'news', label: '新闻' }, { id: 'lesson', label: '融资课堂' }]
|
||||||
@@ -53,9 +54,11 @@
|
|||||||
const total = ref(0); const hasMore = ref(false); const loadingMore = ref(false); const moreError = ref('')
|
const total = ref(0); const hasMore = ref(false); const loadingMore = ref(false); const moreError = ref('')
|
||||||
let page = 1
|
let page = 1
|
||||||
let requestVersion = 0
|
let requestVersion = 0
|
||||||
|
let visible = false
|
||||||
const resetResults = () => { requestVersion++; results.value = []; total.value = 0; hasMore.value = false; loadingMore.value = false; moreError.value = ''; searched.value = false; loading.value = false; errorMessage.value = ''; listVersion.value++ }
|
const resetResults = () => { requestVersion++; results.value = []; total.value = 0; hasMore.value = false; loadingMore.value = false; moreError.value = ''; searched.value = false; loading.value = false; errorMessage.value = ''; listVersion.value++ }
|
||||||
const clearSearch = () => { keyword.value = ''; resetResults() }
|
const clearSearch = () => { keyword.value = ''; resetResults() }
|
||||||
async function load(append: boolean) {
|
async function load(append: boolean) {
|
||||||
|
if (!visible) return
|
||||||
if (append && (loading.value || loadingMore.value || !hasMore.value)) return
|
if (append && (loading.value || loadingMore.value || !hasMore.value)) return
|
||||||
keyword.value = keyword.value.trim()
|
keyword.value = keyword.value.trim()
|
||||||
if (keyword.value.length == 0) { clearSearch(); return }
|
if (keyword.value.length == 0) { clearSearch(); return }
|
||||||
@@ -66,12 +69,13 @@
|
|||||||
try {
|
try {
|
||||||
const result = await searchContent(keyword.value, activeType.value, append ? page + 1 : 1)
|
const result = await searchContent(keyword.value, activeType.value, append ? page + 1 : 1)
|
||||||
// 输入、切换分类或退出页面后,忽略旧请求,避免结果被覆盖。
|
// 输入、切换分类或退出页面后,忽略旧请求,避免结果被覆盖。
|
||||||
if (currentRequest != requestVersion) return
|
if (!visible || currentRequest != requestVersion) return
|
||||||
|
if (append) validateContentAppend(results.value.map((item: ContentSearchResult): string => item.type + ':' + item.id), result.items.map((item: ContentSearchResult): string => item.type + ':' + item.id))
|
||||||
results.value = append ? results.value.concat(result.items) : result.items
|
results.value = append ? results.value.concat(result.items) : result.items
|
||||||
total.value = result.total; hasMore.value = result.hasMore
|
total.value = result.total; hasMore.value = result.hasMore
|
||||||
if (append) page++
|
if (append) page++
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (currentRequest == requestVersion) {
|
if (visible && currentRequest == requestVersion) {
|
||||||
const message = err instanceof Error ? err.message : '请检查网络连接后重试'
|
const message = err instanceof Error ? err.message : '请检查网络连接后重试'
|
||||||
if (append) moreError.value = message; else errorMessage.value = message
|
if (append) moreError.value = message; else errorMessage.value = message
|
||||||
}
|
}
|
||||||
@@ -85,9 +89,9 @@
|
|||||||
const route = item.type == 'news' ? '/packages/content/article/article' : '/packages/content/lesson/lesson'
|
const route = item.type == 'news' ? '/packages/content/article/article' : '/packages/content/lesson/lesson'
|
||||||
uni.navigateTo({ url: route + '?id=' + encodeURIComponent(item.id) })
|
uni.navigateTo({ url: route + '?id=' + encodeURIComponent(item.id) })
|
||||||
}
|
}
|
||||||
onShow(() => { if (searched.value) search() })
|
onShow(() => { visible = true; if (searched.value) search() })
|
||||||
onHide(() => { requestVersion++; loading.value = false; loadingMore.value = false })
|
onHide(() => { visible = false; requestVersion++; loading.value = false; loadingMore.value = false })
|
||||||
onUnload(() => { requestVersion++ })
|
onUnload(() => { visible = false; requestVersion++ })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
const loading = ref(false); const error = ref(''); const listError = ref(''); const listLoading = ref(false); const hasMore = ref(false)
|
const loading = ref(false); const error = ref(''); const listError = ref(''); const listLoading = ref(false); const hasMore = ref(false)
|
||||||
const completeness = ref<number | null>(null); const completenessFailed = ref(false)
|
const completeness = ref<number | null>(null); const completenessFailed = ref(false)
|
||||||
const completenessMessage = ref('提交评估后,查看对应报告的资料完整度。')
|
const completenessMessage = ref('提交评估后,查看对应报告的资料完整度。')
|
||||||
let cursor = ''; let generation = 0; let reportRequest = 0; let visible = false; let routeError = ''; let routeId = ''; let owner = ''
|
let cursor = ''; let generation = 0; let reportRequest = 0; let visible = false; let routeError = ''; let routeId = ''; let owner = ''; let accountTimer = 0
|
||||||
const sections = computed((): ArchiveSection[] => detail.value != null ? detail.value!.sections : [
|
const sections = computed((): ArchiveSection[] => detail.value != null ? detail.value!.sections : [
|
||||||
{ id: 'basic', icon: 'file-text', title: '基本信息', note: type.value == 'enterprise' ? '企业与联系人信息' : '身份与联系方式', fields: [], provided: 0 },
|
{ id: 'basic', icon: 'file-text', title: '基本信息', note: type.value == 'enterprise' ? '企业与联系人信息' : '身份与联系方式', fields: [], provided: 0 },
|
||||||
{ id: 'income', icon: 'income', title: type.value == 'enterprise' ? '经营信息' : '职业与收入', note: type.value == 'enterprise' ? '经营行业、月数与营收' : '职业、收入与缴纳情况', fields: [], provided: 0 },
|
{ id: 'income', icon: 'income', title: type.value == 'enterprise' ? '经营信息' : '职业与收入', note: type.value == 'enterprise' ? '经营行业、月数与营收' : '职业、收入与缴纳情况', fields: [], provided: 0 },
|
||||||
@@ -98,14 +98,25 @@
|
|||||||
completenessMessage.value = '提交评估后,查看对应报告的资料完整度。'
|
completenessMessage.value = '提交评估后,查看对应报告的资料完整度。'
|
||||||
error.value = ''; listError.value = ''; listLoading.value = false
|
error.value = ''; listError.value = ''; listLoading.value = false
|
||||||
}
|
}
|
||||||
|
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
|
||||||
|
const invalidateAccount = () => {
|
||||||
|
const session = getSession()
|
||||||
|
generation++; clearView(); loading.value = false; selectedId.value = routeId; historyOpen.value = false; owner = session?.getString('token') ?? ''; stopAccountTimer()
|
||||||
|
if (session == null) uni.redirectTo({ url: '/packages/account/login/login' })
|
||||||
|
else error.value = '登录状态已变化,请重新加载档案。'
|
||||||
|
}
|
||||||
|
const startAccountTimer = () => {
|
||||||
|
stopAccountTimer()
|
||||||
|
if (owner.length > 0) accountTimer = setInterval(() => {
|
||||||
|
if (visible && (getSession()?.getString('token') ?? '') != owner) invalidateAccount()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
const active = (request: number, token: string): boolean => {
|
const active = (request: number, token: string): boolean => {
|
||||||
if (!visible || request != generation) return false
|
if (!visible || request != generation) return false
|
||||||
const session = getSession()
|
const session = getSession()
|
||||||
if (session?.getString('token') == token) return true
|
if (session?.getString('token') == token) return true
|
||||||
// 包括报告/历史分页返回401的情况:立即移除已显示资料,不只忽略迟到响应。
|
// 包括报告/历史分页返回401的情况:立即移除已显示资料,不只忽略迟到响应。
|
||||||
generation++; clearView(); loading.value = false; selectedId.value = routeId
|
invalidateAccount()
|
||||||
if (session == null) uni.redirectTo({ url: '/packages/account/login/login' })
|
|
||||||
else error.value = '登录状态已变化,请重新加载档案。'
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const readCompleteness = async (id: string, request: number, token: string) => {
|
const readCompleteness = async (id: string, request: number, token: string) => {
|
||||||
@@ -140,14 +151,14 @@
|
|||||||
} catch (err) { if (active(request, token)) versionsError.value = err instanceof Error ? err.message : '版本历史读取失败' }
|
} catch (err) { if (active(request, token)) versionsError.value = err instanceof Error ? err.message : '版本历史读取失败' }
|
||||||
finally { if (active(request, token)) versionsLoading.value = false }
|
finally { if (active(request, token)) versionsLoading.value = false }
|
||||||
}
|
}
|
||||||
const moreVersions = () => { if (!versionsLoading.value) readVersions(versions.value.length > 0, generation, owner) }
|
const moreVersions = () => { if (active(generation, owner) && !versionsLoading.value) readVersions(versions.value.length > 0, generation, owner) }
|
||||||
const chooseVersion = (id: string) => { uni.redirectTo({ url: '/packages/finance/archive/archive?assessmentId=' + selectedId.value + '&revisionId=' + id }) }
|
const chooseVersion = (id: string) => { if (active(generation, owner) && detail.value != null) uni.redirectTo({ url: '/packages/finance/archive/archive?assessmentId=' + selectedId.value + '&revisionId=' + id }) }
|
||||||
const editAssessment = () => { if (detail.value != null) uni.navigateTo({ url: '/packages/finance/assessment/assessment?editId=' + selectedId.value }) }
|
const editAssessment = () => { if (active(generation, owner) && detail.value != null) uni.navigateTo({ url: '/packages/finance/assessment/assessment?editId=' + selectedId.value }) }
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
const request = ++generation; clearView(); loading.value = true
|
const request = ++generation; clearView(); loading.value = true
|
||||||
const session = getSession()
|
const session = getSession()
|
||||||
if (session == null) { loading.value = false; uni.redirectTo({ url: '/packages/account/login/login' }); return }
|
if (session == null) { loading.value = false; uni.redirectTo({ url: '/packages/account/login/login' }); return }
|
||||||
const token = session.getString('token') ?? ''; owner = token
|
const token = session.getString('token') ?? ''; owner = token; startAccountTimer()
|
||||||
if (routeError.length > 0) { error.value = routeError; loading.value = false; completenessMessage.value = '请先选择有效的评估记录。'; return }
|
if (routeError.length > 0) { error.value = routeError; loading.value = false; completenessMessage.value = '请先选择有效的评估记录。'; return }
|
||||||
try {
|
try {
|
||||||
// 显式ID先校验本人详情;失败不能自动切到最新记录,掩盖越权或已删除状态。
|
// 显式ID先校验本人详情;失败不能自动切到最新记录,掩盖越权或已删除状态。
|
||||||
@@ -172,6 +183,7 @@
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
if (detail.value != null) {
|
if (detail.value != null) {
|
||||||
await readVersions(false, request, token)
|
await readVersions(false, request, token)
|
||||||
|
if (!active(request, token) || detail.value == null) return
|
||||||
if (detail.value!.record.status == 'succeeded') await readCompleteness(detail.value!.record.id, request, token)
|
if (detail.value!.record.status == 'succeeded') await readCompleteness(detail.value!.record.id, request, token)
|
||||||
else completenessMessage.value = detail.value!.record.status == 'failed' ? '本次分析未完成,暂没有报告完整度;已提交资料已保留。' : '本次分析尚未完成,报告生成后可查看完整度。'
|
else completenessMessage.value = detail.value!.record.status == 'failed' ? '本次分析未完成,暂没有报告完整度;已提交资料已保留。' : '本次分析尚未完成,报告生成后可查看完整度。'
|
||||||
}
|
}
|
||||||
@@ -179,18 +191,18 @@
|
|||||||
if (active(request, token)) { error.value = err instanceof Error ? err.message : '档案读取失败,请重试'; completenessMessage.value = '档案暂不可读取,请重新加载或选择其他记录。' }
|
if (active(request, token)) { error.value = err instanceof Error ? err.message : '档案读取失败,请重试'; completenessMessage.value = '档案暂不可读取,请重新加载或选择其他记录。' }
|
||||||
} finally { if (active(request, token)) loading.value = false }
|
} finally { if (active(request, token)) loading.value = false }
|
||||||
}
|
}
|
||||||
const retryList = () => { if (!listLoading.value) readList(false, generation, owner) }
|
const retryList = () => { if (active(generation, owner) && !listLoading.value) readList(false, generation, owner) }
|
||||||
const loadMore = () => { if (!listLoading.value && hasMore.value) readList(true, generation, owner) }
|
const loadMore = () => { if (active(generation, owner) && !listLoading.value && hasMore.value) readList(true, generation, owner) }
|
||||||
const retryCompleteness = () => { if (detail.value != null && detail.value!.record.status == 'succeeded') readCompleteness(detail.value!.record.id, generation, owner) }
|
const retryCompleteness = () => { if (active(generation, owner) && detail.value != null && detail.value!.record.status == 'succeeded') readCompleteness(detail.value!.record.id, generation, owner) }
|
||||||
const toggleSection = (id: string) => { if (detail.value != null) expanded.value = expanded.value == id ? '' : id }
|
const toggleSection = (id: string) => { if (active(generation, owner) && detail.value != null) expanded.value = expanded.value == id ? '' : id }
|
||||||
// 类型与显式历史选择留在路由里,刷新重新鉴权后仍查看同一记录;不保存草稿或资料缓存。
|
// 类型与显式历史选择留在路由里,刷新重新鉴权后仍查看同一记录;不保存草稿或资料缓存。
|
||||||
const switchType = (next: string) => { if (next != type.value) uni.redirectTo({ url: '/packages/finance/archive/archive?type=' + next }) }
|
const switchType = (next: string) => { if (next != type.value) uni.redirectTo({ url: '/packages/finance/archive/archive?type=' + next }) }
|
||||||
const chooseRecord = (id: string) => { if (id == selectedId.value) { historyOpen.value = false; return }; uni.redirectTo({ url: '/packages/finance/archive/archive?type=' + type.value + '&assessmentId=' + encodeURIComponent(id) }) }
|
const chooseRecord = (id: string) => { if (!active(generation, owner)) return; if (id == selectedId.value) { historyOpen.value = false; return }; uni.redirectTo({ url: '/packages/finance/archive/archive?type=' + type.value + '&assessmentId=' + encodeURIComponent(id) }) }
|
||||||
const showLatest = () => { uni.redirectTo({ url: '/packages/finance/archive/archive?type=' + type.value }) }
|
const showLatest = () => { uni.redirectTo({ url: '/packages/finance/archive/archive?type=' + type.value }) }
|
||||||
const start = () => { clearAssessmentFlow(); uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + type.value }) }
|
const start = () => { clearAssessmentFlow(); uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + type.value }) }
|
||||||
const openPreviousReport = () => { if (detail.value != null) uni.navigateTo({ url: '/packages/finance/report/report?assessmentId=' + selectedId.value + '&revisionId=' + detail.value!.record.publishedRevisionId }) }
|
const openPreviousReport = () => { if (active(generation, owner) && detail.value != null) uni.navigateTo({ url: '/packages/finance/report/report?assessmentId=' + selectedId.value + '&revisionId=' + detail.value!.record.publishedRevisionId }) }
|
||||||
const openAnalysis = () => {
|
const openAnalysis = () => {
|
||||||
if (detail.value == null) return
|
if (!active(generation, owner) || detail.value == null) return
|
||||||
const page = detail.value!.record.status == 'succeeded' ? 'report/report' : 'analyzing/analyzing'
|
const page = detail.value!.record.status == 'succeeded' ? 'report/report' : 'analyzing/analyzing'
|
||||||
uni.navigateTo({ url: '/packages/finance/' + page + '?assessmentId=' + encodeURIComponent(detail.value!.record.id) + (page == 'report/report' ? '&revisionId=' + detail.value!.record.revisionId : '') })
|
uni.navigateTo({ url: '/packages/finance/' + page + '?assessmentId=' + encodeURIComponent(detail.value!.record.id) + (page == 'report/report' ? '&revisionId=' + detail.value!.record.revisionId : '') })
|
||||||
}
|
}
|
||||||
@@ -202,13 +214,13 @@
|
|||||||
if (options?.['assessmentId'] != null && !/^[1-9][0-9]{0,9}$/.test(routeId)) routeError = '评估编号无效,请重新选择历史记录。'
|
if (options?.['assessmentId'] != null && !/^[1-9][0-9]{0,9}$/.test(routeId)) routeError = '评估编号无效,请重新选择历史记录。'
|
||||||
})
|
})
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
visible = true
|
visible = true; stopAccountTimer()
|
||||||
if (getSession()?.getString('token') != owner) { selectedId.value = routeId; historyOpen.value = false }
|
if (getSession()?.getString('token') != owner) { selectedId.value = routeId; historyOpen.value = false }
|
||||||
refresh()
|
refresh()
|
||||||
})
|
})
|
||||||
// 离开即清空敏感内容;账户切换、快速换档和迟到响应均不能恢复旧账户资料。
|
// 离开即清空敏感内容;账户切换、快速换档和迟到响应均不能恢复旧账户资料。
|
||||||
onHide(() => { visible = false; generation++; clearView(); loading.value = false })
|
onHide(() => { visible = false; stopAccountTimer(); generation++; clearView(); loading.value = false })
|
||||||
onUnload(() => { visible = false; generation++; clearView() })
|
onUnload(() => { visible = false; stopAccountTimer(); generation++; clearView() })
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.archive-content { padding: 26rpx 30rpx 70rpx; }
|
.archive-content { padding: 26rpx 30rpx 70rpx; }
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
const step = ref(0)
|
const step = ref(0)
|
||||||
const assessmentType = ref('personal')
|
const assessmentType = ref('personal')
|
||||||
const form = ref<AssessmentForm>(emptyAssessmentForm())
|
const form = ref<AssessmentForm>(emptyAssessmentForm())
|
||||||
let ownerToken = ''; let goingToConfirm = false
|
let ownerToken = ''; let goingToConfirm = false; let visible = false; let accountTimer = 0
|
||||||
const sameOwner = (): boolean => { const session = getSession(); return session != null && session['token'] == ownerToken }
|
const sameOwner = (): boolean => { const session = getSession(); return session != null && session['token'] == ownerToken }
|
||||||
const customCity = ref('')
|
const customCity = ref('')
|
||||||
const cityChanged = (city: string) => { form.value.city = city; if (city != '其他城市') customCity.value = '' }
|
const cityChanged = (city: string) => { form.value.city = city; if (city != '其他城市') customCity.value = '' }
|
||||||
@@ -88,6 +88,24 @@
|
|||||||
const titles = ['基本信息', '经营 / 收入信息', '资产 / 负债信息', '资料上传 / 授权']
|
const titles = ['基本信息', '经营 / 收入信息', '资产 / 负债信息', '资料上传 / 授权']
|
||||||
const uploadNames = computed((): string[] => assessmentType.value == 'enterprise' ? ['营业执照', '银行流水', '经营证明'] : ['身份证正面', '身份证反面', '银行卡', '收入证明'])
|
const uploadNames = computed((): string[] => assessmentType.value == 'enterprise' ? ['营业执照', '银行流水', '经营证明'] : ['身份证正面', '身份证反面', '银行卡', '收入证明'])
|
||||||
let initialized = false
|
let initialized = false
|
||||||
|
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
|
||||||
|
const ensureOwner = (): boolean => {
|
||||||
|
if (!visible) return false
|
||||||
|
if (sameOwner()) return true
|
||||||
|
const session = getSession()
|
||||||
|
ownerToken = session?.getString('token') ?? ''
|
||||||
|
editGeneration++; initialized = false; stopAccountTimer(); clearAssessmentFlow()
|
||||||
|
form.value = emptyAssessmentForm(); documents.value = []; customCity.value = ''; step.value = 0
|
||||||
|
documentConsent.value = false; dataConsent.value = false; authorizations.value.forEach((item) => { item.selected = false })
|
||||||
|
baseRevisionId = ''; editLoading.value = false; editError.value = '账户已变化,请返回重新填写。'
|
||||||
|
if (session == null) uni.redirectTo({ url: '/packages/account/login/login' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const startAccountTimer = () => {
|
||||||
|
stopAccountTimer()
|
||||||
|
if (ownerToken.length == 0) return
|
||||||
|
accountTimer = setInterval(() => { if (visible && !sameOwner()) ensureOwner() }, 1000)
|
||||||
|
}
|
||||||
const rememberInput = () => {
|
const rememberInput = () => {
|
||||||
if (!initialized || !sameOwner()) return
|
if (!initialized || !sameOwner()) return
|
||||||
const source = form.value
|
const source = form.value
|
||||||
@@ -110,15 +128,15 @@
|
|||||||
initialized = true
|
initialized = true
|
||||||
rememberInput()
|
rememberInput()
|
||||||
}
|
}
|
||||||
const switchType = (type: string) => { if (editId.value.length > 0 || assessmentType.value == type) return; rememberInput(); restore(type); step.value = 0; rememberInput() }
|
const switchType = (type: string) => { if (!ensureOwner() || editId.value.length > 0 || assessmentType.value == type) return; rememberInput(); restore(type); step.value = 0; rememberInput() }
|
||||||
const loadEdit = async (type: string) => {
|
const loadEdit = async (type: string) => {
|
||||||
const ticket = ++editGeneration; editLoading.value = true
|
const ticket = ++editGeneration; editLoading.value = true
|
||||||
try {
|
try {
|
||||||
const saved = await fetchAssessmentEdit(editId.value)
|
const saved = await fetchAssessmentEdit(editId.value)
|
||||||
if (ticket != editGeneration || !sameOwner()) return
|
if (ticket != editGeneration || !ensureOwner()) return
|
||||||
baseRevisionId = saved.getString('baseRevisionId') ?? ''
|
baseRevisionId = saved.getString('baseRevisionId') ?? ''
|
||||||
writeAssessmentFlow(ownerToken, saved); restore(saved.getString('type') ?? type)
|
writeAssessmentFlow(ownerToken, saved); restore(saved.getString('type') ?? type)
|
||||||
} catch (err) { if (ticket == editGeneration && sameOwner()) editError.value = err instanceof Error ? err.message : '资料读取失败,请返回重试。' }
|
} catch (err) { if (ticket == editGeneration && ensureOwner()) editError.value = err instanceof Error ? err.message : '资料读取失败,请返回重试。' }
|
||||||
finally { if (ticket == editGeneration) editLoading.value = false }
|
finally { if (ticket == editGeneration) editLoading.value = false }
|
||||||
}
|
}
|
||||||
onLoad((options) => {
|
onLoad((options) => {
|
||||||
@@ -134,13 +152,16 @@
|
|||||||
loadEdit(type)
|
loadEdit(type)
|
||||||
})
|
})
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
|
visible = true; stopAccountTimer()
|
||||||
goingToConfirm = false
|
goingToConfirm = false
|
||||||
if (initialized && !sameOwner()) { initialized = false; form.value = emptyAssessmentForm(); clearAssessmentFlow(); uni.redirectTo({ url: '/packages/account/login/login' }) }
|
if (initialized && !sameOwner()) ensureOwner()
|
||||||
const input = getAssessmentInput(assessmentType.value)
|
const input = getAssessmentInput(assessmentType.value)
|
||||||
if (initialized && input != null && typeof input['step'] == 'number') step.value = Math.max(0, Math.min(3, input['step'] as number))
|
if (initialized && input != null && typeof input['step'] == 'number') step.value = Math.max(0, Math.min(3, input['step'] as number))
|
||||||
|
startAccountTimer()
|
||||||
})
|
})
|
||||||
// 离开填写流程即丢弃未提交内容;进入确认页时只在内存传递。
|
// 离开填写流程即丢弃未提交内容;进入确认页时只在内存传递。
|
||||||
onHide(() => {
|
onHide(() => {
|
||||||
|
visible = false; stopAccountTimer()
|
||||||
if (!goingToConfirm) {
|
if (!goingToConfirm) {
|
||||||
editGeneration++; if (editId.value.length > 0) editError.value = '已离开修改流程,请返回档案重新进入。'
|
editGeneration++; if (editId.value.length > 0) editError.value = '已离开修改流程,请返回档案重新进入。'
|
||||||
// 离页清空资料但不切换步骤,避免卸载时又挂载另一组原生输入组件。
|
// 离页清空资料但不切换步骤,避免卸载时又挂载另一组原生输入组件。
|
||||||
@@ -149,7 +170,7 @@
|
|||||||
authorizations.value.forEach((item) => { item.selected = false })
|
authorizations.value.forEach((item) => { item.selected = false })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onUnload(() => { editGeneration++; initialized = false; clearAssessmentFlow() })
|
onUnload(() => { visible = false; stopAccountTimer(); editGeneration++; initialized = false; clearAssessmentFlow() })
|
||||||
// 页内前后步骤只维护当前内存状态,返回入口后不保留填写内容。
|
// 页内前后步骤只维护当前内存状态,返回入口后不保留填写内容。
|
||||||
onBackPress((): boolean => { rememberInput(); if (step.value > 0) { step.value--; rememberInput(); return true }; return false })
|
onBackPress((): boolean => { rememberInput(); if (step.value > 0) { step.value--; rememberInput(); return true }; return false })
|
||||||
const previous = () => { rememberInput(); if (step.value > 0) step.value--; rememberInput() }
|
const previous = () => { rememberInput(); if (step.value > 0) step.value--; rememberInput() }
|
||||||
@@ -160,17 +181,22 @@
|
|||||||
uni.showToast({ title: name + '已失效,请重新选择', icon: 'none' })
|
uni.showToast({ title: name + '已失效,请重新选择', icon: 'none' })
|
||||||
}
|
}
|
||||||
const chooseDocument = (name: string) => {
|
const chooseDocument = (name: string) => {
|
||||||
|
if (!ensureOwner()) return
|
||||||
|
const ticket = editGeneration
|
||||||
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (result) => {
|
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (result) => {
|
||||||
if (result.tempFilePaths.length == 0) return
|
if (ticket != editGeneration || !ensureOwner() || result.tempFilePaths.length == 0) return
|
||||||
documents.value = documents.value.filter((file) => file.name != name)
|
documents.value = documents.value.filter((file) => file.name != name)
|
||||||
const document: SelectedDocument = { name, path: result.tempFilePaths[0] }
|
const document: SelectedDocument = { name, path: result.tempFilePaths[0] }
|
||||||
documents.value.push(document); rememberInput()
|
documents.value.push(document); rememberInput()
|
||||||
}, fail: (error) => { if (!error.errMsg.includes('cancel')) uni.showToast({ title: '选择失败,请重试或检查相册权限', icon: 'none' }) } })
|
}, fail: (error) => { if (ticket == editGeneration && ensureOwner() && !error.errMsg.includes('cancel')) uni.showToast({ title: '选择失败,请重试或检查相册权限', icon: 'none' }) } })
|
||||||
}
|
}
|
||||||
const documentAction = (name: string) => {
|
const documentAction = (name: string) => {
|
||||||
|
if (!ensureOwner()) return
|
||||||
|
const ticket = editGeneration
|
||||||
const path = documentPath(name)
|
const path = documentPath(name)
|
||||||
if (path.length == 0) { chooseDocument(name); return }
|
if (path.length == 0) { chooseDocument(name); return }
|
||||||
uni.showActionSheet({ itemList: ['预览图片', '重新选择', '移除资料'], success: (result) => {
|
uni.showActionSheet({ itemList: ['预览图片', '重新选择', '移除资料'], success: (result) => {
|
||||||
|
if (ticket != editGeneration || !ensureOwner()) return
|
||||||
if (result.tapIndex == 0) uni.previewImage({ urls: [path], fail: () => { uni.showToast({ title: '本地图片已失效,请重新选择', icon: 'none' }) } })
|
if (result.tapIndex == 0) uni.previewImage({ urls: [path], fail: () => { uni.showToast({ title: '本地图片已失效,请重新选择', icon: 'none' }) } })
|
||||||
else if (result.tapIndex == 1) chooseDocument(name)
|
else if (result.tapIndex == 1) chooseDocument(name)
|
||||||
else if (result.tapIndex == 2) { documents.value = documents.value.filter((file) => file.name != name); rememberInput() }
|
else if (result.tapIndex == 2) { documents.value = documents.value.filter((file) => file.name != name); rememberInput() }
|
||||||
@@ -196,7 +222,7 @@
|
|||||||
if (message.length == 0) return true
|
if (message.length == 0) return true
|
||||||
uni.showToast({ title: message, icon: 'none' }); return false
|
uni.showToast({ title: message, icon: 'none' }); return false
|
||||||
}
|
}
|
||||||
const next = () => { if (editLoading.value || editError.value.length > 0 || !sameOwner()) return; if (!validateStep()) return; if (step.value < 3) { step.value++; rememberInput() } else { rememberInput(); goingToConfirm = true; uni.navigateTo({ url: '/packages/finance/confirm/confirm', fail: () => { goingToConfirm = false } }) } }
|
const next = () => { if (!ensureOwner() || editLoading.value || editError.value.length > 0) return; if (!validateStep()) return; if (step.value < 3) { step.value++; rememberInput() } else { rememberInput(); goingToConfirm = true; uni.navigateTo({ url: '/packages/finance/confirm/confirm', fail: () => { goingToConfirm = false } }) } }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -136,6 +136,8 @@
|
|||||||
}
|
}
|
||||||
const transmit = async () => {
|
const transmit = async () => {
|
||||||
if (sending.value || context.value == null) return
|
if (sending.value || context.value == null) return
|
||||||
|
// 发请求前核对账户,避免轮询尚未发现切换时将旧草稿带新Token发送。
|
||||||
|
if (!current(generation, scope)) return
|
||||||
const ticket = generation; const token = scope; sending.value = true; sendError.value = ''; uncertain.value = true
|
const ticket = generation; const token = scope; sending.value = true; sendError.value = ''; uncertain.value = true
|
||||||
try {
|
try {
|
||||||
if (chatId.value.length == 0) { const chat = await createAssistant(assessmentId.value, productIds.value, createKey, revisionId.value); if (!current(ticket, token)) return; chatId.value = chat.id; updateUrl() }
|
if (chatId.value.length == 0) { const chat = await createAssistant(assessmentId.value, productIds.value, createKey, revisionId.value); if (!current(ticket, token)) return; chatId.value = chat.id; updateUrl() }
|
||||||
|
|||||||
@@ -76,6 +76,21 @@
|
|||||||
// 首屏并排展示短参数;长证据保留原宽表,展开后读取完整内容,不截断判断依据。
|
// 首屏并排展示短参数;长证据保留原宽表,展开后读取完整内容,不截断判断依据。
|
||||||
const overviewRows: CompareRow[] = [{ id: 'state', label: '匹配状态', height: 0 }, { id: 'amount', label: '额度范围', height: 0 }, { id: 'term', label: '期限范围', height: 0 }, { id: 'rate', label: '年化利率', height: 0 }]
|
const overviewRows: CompareRow[] = [{ id: 'state', label: '匹配状态', height: 0 }, { id: 'amount', label: '额度范围', height: 0 }, { id: 'term', label: '期限范围', height: 0 }, { id: 'rate', label: '年化利率', height: 0 }]
|
||||||
let visible = false; let generation = 0
|
let visible = false; let generation = 0
|
||||||
|
let owner = ''; let accountTimer = 0; let frozenDetail: MatchDetail | null = null
|
||||||
|
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
|
||||||
|
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMatch=1&matchPage=compare' + compareQuery(assessmentId.value, ids.value, revisionId.value).replace('?', '&') }) }
|
||||||
|
const current = (request: number, token: string): boolean => {
|
||||||
|
if (!visible || request != generation) return false
|
||||||
|
if (getSession()?.getString('token') == token) return true
|
||||||
|
generation++; columns.value = []; frozenDetail = null; detailsOpen.value = false; stopAccountTimer()
|
||||||
|
if (getSession() == null) login(); else { ids.value = []; routeError.value = '账户已切换,请返回本次匹配重新读取。' }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const updateUrl = () => {
|
||||||
|
// #ifdef WEB
|
||||||
|
history.replaceState(history.state, '', location.pathname + location.search + '#/packages/finance/compare/compare' + compareQuery(assessmentId.value, ids.value, revisionId.value))
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
const rows: CompareRow[] = [
|
const rows: CompareRow[] = [
|
||||||
{ id: 'availability', label: '当前状态', height: 160 },
|
{ id: 'availability', label: '当前状态', height: 160 },
|
||||||
{ id: 'amount', label: '额度范围', height: 120 }, { id: 'term', label: '期限范围', height: 120 }, { id: 'rate', label: '年化利率', height: 120 },
|
{ id: 'amount', label: '额度范围', height: 120 }, { id: 'term', label: '期限范围', height: 120 }, { id: 'rate', label: '年化利率', height: 120 },
|
||||||
@@ -121,40 +136,41 @@
|
|||||||
return '未提供'
|
return '未提供'
|
||||||
}
|
}
|
||||||
const loadColumn = async (id: string) => {
|
const loadColumn = async (id: string) => {
|
||||||
|
if (!current(generation, owner)) return
|
||||||
const column = columns.value.find((c: CompareColumn): boolean => c.id == id)
|
const column = columns.value.find((c: CompareColumn): boolean => c.id == id)
|
||||||
if (column == null || column.loading) return
|
if (column == null || column.loading) return
|
||||||
const session = getSession()
|
const session = getSession()
|
||||||
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
|
if (session == null) { login(); return }
|
||||||
const token = session.getString('token') ?? ''; const request = generation
|
const token = session.getString('token') ?? ''; const request = generation
|
||||||
column.loading = true; column.error = ''; column.data = null
|
column.loading = true; column.error = ''; column.data = null
|
||||||
try {
|
try {
|
||||||
const result = await fetchMatchDetailRecord(assessmentId.value, id, revisionId.value)
|
const result = await fetchMatchDetailRecord(assessmentId.value, id, revisionId.value)
|
||||||
if (visible && request == generation && getSession()?.getString('token') == token) { if (revisionId.value.length == 0) revisionId.value = result.revisionId; if (revisionId.value != result.revisionId) throw new Error('评估版本已变化,请重新加载对比。'); column.data = result }
|
if (!current(request, token)) return
|
||||||
|
if (revisionId.value.length > 0 && revisionId.value != result.revisionId) throw new Error('评估版本已变化,请重新加载对比。')
|
||||||
|
if (frozenDetail != null && !sameMatchSnapshot(frozenDetail!, result)) throw new Error('冻结结果版本不一致,请重试或返回重新选择。')
|
||||||
|
if (frozenDetail == null) frozenDetail = result
|
||||||
|
revisionId.value = result.revisionId; column.data = result
|
||||||
|
saveCompareSelection(assessmentId.value, ids.value, revisionId.value); updateUrl()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!visible || request != generation) return
|
if (current(request, token)) column.error = err instanceof Error ? err.message : '本列读取失败,请重试。'
|
||||||
if (getSession()?.getString('token') != token) {
|
|
||||||
generation++; columns.value = []
|
|
||||||
if (getSession() == null) uni.redirectTo({ url: '/packages/account/login/login' })
|
|
||||||
else routeError.value = '账户已切换,请返回本次匹配重新读取。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
column.error = err instanceof Error ? err.message : '本列读取失败,请重试。'
|
|
||||||
}
|
}
|
||||||
finally { if (request == generation) column.loading = false }
|
finally { if (request == generation) column.loading = false }
|
||||||
}
|
}
|
||||||
const toMatches = () => {
|
const toMatches = () => {
|
||||||
|
if (owner.length > 0 && routeError.value.length == 0 && !current(generation, owner)) return
|
||||||
if (!validRecordId(assessmentId.value)) { uni.redirectTo({ url: '/packages/finance/archive/archive' }); return }
|
if (!validRecordId(assessmentId.value)) { uni.redirectTo({ url: '/packages/finance/archive/archive' }); return }
|
||||||
uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, ids.value, revisionId.value) })
|
uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, ids.value, revisionId.value) })
|
||||||
}
|
}
|
||||||
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else toMatches() }
|
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else toMatches() }
|
||||||
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, ids.value, 'compare', '', revisionId.value) }) }
|
const consult = () => { if (current(generation, owner) && complete.value) uni.navigateTo({ url: assistantRoute(assessmentId.value, ids.value, 'compare', '', revisionId.value) }); else if (routeError.value.length == 0) uni.showToast({ title: '请先读取完整对比后再咨询AI', icon: 'none' }) }
|
||||||
const remove = (id: string) => {
|
const remove = (id: string) => {
|
||||||
|
if (!current(generation, owner)) return
|
||||||
const next = ids.value.filter((value: string): boolean => value != id)
|
const next = ids.value.filter((value: string): boolean => value != id)
|
||||||
saveCompareSelection(assessmentId.value, next, revisionId.value)
|
saveCompareSelection(assessmentId.value, next, revisionId.value)
|
||||||
// 用新路由替换当前页,使刷新/分享恢复实际选择,而不是已移除的旧ID。
|
// 用新路由替换当前页,使刷新/分享恢复实际选择,而不是已移除的旧ID。
|
||||||
uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, next, revisionId.value) })
|
uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, next, revisionId.value) })
|
||||||
}
|
}
|
||||||
const detail = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, ids.value, revisionId.value) + '&productId=' + id + '&tab=explanation&from=compare' }) }
|
const detail = (id: string) => { if (current(generation, owner)) uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, ids.value, revisionId.value) + '&productId=' + id + '&tab=explanation&from=compare' }) }
|
||||||
onLoad((options) => {
|
onLoad((options) => {
|
||||||
revisionId.value = options?.['revisionId'] ?? '';
|
revisionId.value = options?.['revisionId'] ?? '';
|
||||||
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError.value = '版本编号无效,请返回重新选择。'
|
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError.value = '版本编号无效,请返回重新选择。'
|
||||||
@@ -164,15 +180,17 @@
|
|||||||
catch (err) { routeError.value = err instanceof Error ? err.message : '对比参数无效' }
|
catch (err) { routeError.value = err instanceof Error ? err.message : '对比参数无效' }
|
||||||
})
|
})
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
visible = true; generation++
|
visible = true; generation++; stopAccountTimer(); frozenDetail = null
|
||||||
if (routeError.value.length > 0) return
|
if (routeError.value.length > 0) return
|
||||||
if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
|
if (getSession() == null) { login(); return }
|
||||||
|
owner = getSession()?.getString('token') ?? ''
|
||||||
|
accountTimer = setInterval(() => { current(generation, owner) }, 1000)
|
||||||
// 依URL顺序固定列位;仅最多三个详情请求并行,单列失败不影响其他列。
|
// 依URL顺序固定列位;仅最多三个详情请求并行,单列失败不影响其他列。
|
||||||
columns.value = ids.value.map((id: string): CompareColumn => { return { id, loading: false, error: '', data: null } })
|
columns.value = ids.value.map((id: string): CompareColumn => { return { id, loading: false, error: '', data: null } })
|
||||||
ids.value.forEach((id: string) => { loadColumn(id) })
|
ids.value.forEach((id: string) => { loadColumn(id) })
|
||||||
})
|
})
|
||||||
onHide(() => { visible = false; generation++; columns.value = [] })
|
onHide(() => { visible = false; stopAccountTimer(); generation++; columns.value = []; frozenDetail = null })
|
||||||
onUnload(() => { visible = false; generation++; columns.value = [] })
|
onUnload(() => { visible = false; stopAccountTimer(); generation++; columns.value = []; frozenDetail = null })
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.overview-table { border: 1rpx solid #dfe8f3; border-radius: 14rpx; overflow: hidden; background-color: #fff; }
|
.overview-table { border: 1rpx solid #dfe8f3; border-radius: 14rpx; overflow: hidden; background-color: #fff; }
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
|
|
||||||
import { wanAmountValid } from '@/services/money.uts'
|
import { wanAmountValid } from '@/services/money.uts'
|
||||||
|
import { getSession } from '@/services/api.uts'
|
||||||
import { usePageLayout } from '@/services/layout.uts'
|
import { usePageLayout } from '@/services/layout.uts'
|
||||||
import AppHeader from '@/components/AppHeader.uvue'
|
import AppHeader from '@/components/AppHeader.uvue'
|
||||||
import { submitAssessment } from '@/packages/finance/services/api.uts'
|
import { submitAssessment } from '@/packages/finance/services/api.uts'
|
||||||
@@ -30,6 +31,17 @@
|
|||||||
const assessmentType = ref('personal')
|
const assessmentType = ref('personal')
|
||||||
const sections = ref<InfoSection[]>([])
|
const sections = ref<InfoSection[]>([])
|
||||||
const payload = ref<UTSJSONObject>({ source: 'mobile', confirmed: false })
|
const payload = ref<UTSJSONObject>({ source: 'mobile', confirmed: false })
|
||||||
|
let visible = false; let generation = 0; let ownerToken = ''; let accountTimer = 0
|
||||||
|
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
|
||||||
|
const clearView = () => { hasInput.value = false; sections.value = []; payload.value = { source: 'mobile', confirmed: false }; submitting.value = false }
|
||||||
|
const sameOwner = (): boolean => {
|
||||||
|
if (!visible) return false
|
||||||
|
if (ownerToken.length > 0 && getSession()?.getString('token') == ownerToken) return true
|
||||||
|
generation++; clearView(); clearAssessmentFlow(); stopAccountTimer()
|
||||||
|
if (getSession() == null) uni.redirectTo({ url: '/packages/account/login/login' })
|
||||||
|
else uni.showToast({ title: '账户已变化,请重新填写。', icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
const mask = (value: string, left: number, right: number): string => { if (value.length <= left + right) return value; return value.substring(0, left) + '****' + value.substring(value.length - right) }
|
const mask = (value: string, left: number, right: number): string => { if (value.length <= left + right) return value; return value.substring(0, left) + '****' + value.substring(value.length - right) }
|
||||||
const displayValue = (saved: UTSJSONObject, key: string): string => { const value = draftText(saved, key); return value.length > 0 ? value : '未填写' }
|
const displayValue = (saved: UTSJSONObject, key: string): string => { const value = draftText(saved, key); return value.length > 0 ? value : '未填写' }
|
||||||
const appendRow = (rows: InfoRow[], label: string, value: string) => { const row: InfoRow = { label, value }; rows.push(row) }
|
const appendRow = (rows: InfoRow[], label: string, value: string) => { const row: InfoRow = { label, value }; rows.push(row) }
|
||||||
@@ -37,7 +49,7 @@
|
|||||||
const loadInput = () => {
|
const loadInput = () => {
|
||||||
assessmentType.value = activeAssessmentType()
|
assessmentType.value = activeAssessmentType()
|
||||||
const saved = getAssessmentInput(assessmentType.value)
|
const saved = getAssessmentInput(assessmentType.value)
|
||||||
if (saved == null) { hasInput.value = false; sections.value = []; return }
|
if (saved == null) { clearView(); return }
|
||||||
hasInput.value = true
|
hasInput.value = true
|
||||||
const enterprise = assessmentType.value == 'enterprise'
|
const enterprise = assessmentType.value == 'enterprise'
|
||||||
const basicRows: InfoRow[] = []
|
const basicRows: InfoRow[] = []
|
||||||
@@ -79,12 +91,19 @@
|
|||||||
]
|
]
|
||||||
payload.value = { ...saved, source: 'mobile', confirmed: true }
|
payload.value = { ...saved, source: 'mobile', confirmed: true }
|
||||||
}
|
}
|
||||||
onShow(() => loadInput())
|
onShow(() => {
|
||||||
|
visible = true; generation++; ownerToken = getSession()?.getString('token') ?? ''
|
||||||
|
loadInput(); stopAccountTimer()
|
||||||
|
if (sameOwner()) accountTimer = setInterval(() => { sameOwner() }, 1000)
|
||||||
|
})
|
||||||
|
onHide(() => { visible = false; generation++; stopAccountTimer(); clearView() })
|
||||||
onUnload(() => {
|
onUnload(() => {
|
||||||
|
visible = false; generation++; stopAccountTimer(); clearView()
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
if (!pages.some((page): boolean => page.route == 'packages/finance/assessment/assessment')) clearAssessmentFlow()
|
if (!pages.some((page): boolean => page.route == 'packages/finance/assessment/assessment')) clearAssessmentFlow()
|
||||||
})
|
})
|
||||||
const edit = (step: number) => {
|
const edit = (step: number) => {
|
||||||
|
if (!sameOwner()) return
|
||||||
const input = getAssessmentInput(assessmentType.value)
|
const input = getAssessmentInput(assessmentType.value)
|
||||||
if (input != null) input['step'] = step
|
if (input != null) input['step'] = step
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
@@ -92,6 +111,7 @@
|
|||||||
else uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + assessmentType.value })
|
else uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + assessmentType.value })
|
||||||
}
|
}
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
if (!sameOwner()) return
|
||||||
if (!hasInput.value) { edit(0); return }
|
if (!hasInput.value) { edit(0); return }
|
||||||
if (submitting.value) return
|
if (submitting.value) return
|
||||||
const saved = payload.value
|
const saved = payload.value
|
||||||
@@ -105,14 +125,16 @@
|
|||||||
const assetError = assetValidationMessage(formFromInput(saved))
|
const assetError = assetValidationMessage(formFromInput(saved))
|
||||||
if (assetError.length > 0) { uni.showToast({ title: assetError, icon: 'none' }); edit(2); return }
|
if (assetError.length > 0) { uni.showToast({ title: assetError, icon: 'none' }); edit(2); return }
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
|
const request = ++generation
|
||||||
try {
|
try {
|
||||||
const id = await submitAssessment(payload.value)
|
const id = await submitAssessment(payload.value)
|
||||||
|
if (request != generation || !sameOwner()) return
|
||||||
clearAssessmentFlow()
|
clearAssessmentFlow()
|
||||||
// 清空本次填写流程和页面栈;修改须重新读取最新版本,防止返回旧表单重复提交。
|
// 清空本次填写流程和页面栈;修改须重新读取最新版本,防止返回旧表单重复提交。
|
||||||
uni.reLaunch({ url: '/packages/finance/analyzing/analyzing?assessmentId=' + encodeURIComponent(id) })
|
uni.reLaunch({ url: '/packages/finance/analyzing/analyzing?assessmentId=' + encodeURIComponent(id) })
|
||||||
}
|
}
|
||||||
catch (error) { uni.showToast({ title: error instanceof Error ? error.message : '提交失败,请重试', icon: 'none' }) }
|
catch (error) { if (request == generation && sameOwner()) uni.showToast({ title: error instanceof Error ? error.message : '提交失败,请重试', icon: 'none' }) }
|
||||||
finally { submitting.value = false }
|
finally { if (request == generation && visible) submitting.value = false }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<view v-if="error.length == 0 && assessmentId.length > 0" class="compare-bar"><text class="compare-caption">已选 {{ selectedIds.length }} / 3 项,可跨分组选择</text><button class="compare-button" :disabled="selectedIds.length < 2" @click="openCompare">对比所选产品</button></view>
|
<view v-if="error.length == 0 && assessmentId.length > 0" class="compare-bar"><text class="compare-caption">已选 {{ selectedIds.length }} / 3 项,可跨分组选择</text><button class="compare-button" :disabled="selectedIds.length < 2" @click="openCompare">对比所选产品</button></view>
|
||||||
<text v-if="loading && matches.length == 0" class="state-copy">正在读取本次匹配结果…</text>
|
<text v-if="loading && matches.length == 0" class="state-copy">正在读取本次匹配结果…</text>
|
||||||
<view v-if="error.length > 0" class="state-panel"><text class="state-title">暂时无法读取</text><text class="muted">{{ error }}</text><button class="secondary" @click="retry">重新加载</button><text class="link" @click="history">返回融资档案</text></view>
|
<view v-if="error.length > 0" class="state-panel"><text class="state-title">暂时无法读取</text><text class="muted">{{ error }}</text><button class="secondary" @click="retry">重新加载</button><text class="link" @click="history">返回融资档案</text></view>
|
||||||
|
<view v-if="pageError.length > 0" class="state-panel"><text class="muted">{{ pageError }}</text><button class="secondary" :disabled="loading" @click="loadMore">重试加载更多</button></view>
|
||||||
<view v-if="!loading && error.length == 0 && matches.length == 0" class="state-panel"><text class="state-title">{{ status == 'unavailable' ? '本次未执行匹配' : (group == 'eligible' ? '暂无符合已知条件的产品' : (group == 'ineligible' ? '暂无条件不符的产品' : '暂无待核实的产品')) }}</text><text class="muted">{{ status == 'unavailable' ? '历史结果不会自动补算。可从融资档案发起新的评估。' : '可切换分组查看其他结果,或返回报告了解资料准备情况。' }}</text><text class="link" @click="report">查看本次报告</text></view>
|
<view v-if="!loading && error.length == 0 && matches.length == 0" class="state-panel"><text class="state-title">{{ status == 'unavailable' ? '本次未执行匹配' : (group == 'eligible' ? '暂无符合已知条件的产品' : (group == 'ineligible' ? '暂无条件不符的产品' : '暂无待核实的产品')) }}</text><text class="muted">{{ status == 'unavailable' ? '历史结果不会自动补算。可从融资档案发起新的评估。' : '可切换分组查看其他结果,或返回报告了解资料准备情况。' }}</text><text class="link" @click="report">查看本次报告</text></view>
|
||||||
<view v-for="item in matches" :key="item.id" class="card" @click="openProduct(item.id)">
|
<view v-for="item in matches" :key="item.id" class="card" @click="openProduct(item.id)">
|
||||||
<view class="card-head"><view class="mark"><text class="mark-text">融</text></view><view class="identity"><text class="name">{{ item.product.name }}</text><text class="bank">{{ item.product.bank }} · {{ item.product.objects }}</text></view></view>
|
<view class="card-head"><view class="mark"><text class="mark-text">融</text></view><view class="identity"><text class="name">{{ item.product.name }}</text><text class="bank">{{ item.product.bank }} · {{ item.product.objects }}</text></view></view>
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
<view class="card-foot"><text class="version">评估时版本 v{{ item.product.version }}</text><text class="detail-link">查看依据与详情 ›</text></view>
|
<view class="card-foot"><text class="version">评估时版本 v{{ item.product.version }}</text><text class="detail-link">查看依据与详情 ›</text></view>
|
||||||
<button class="choose-button" @click.stop="toggleCompare(item.id)">{{ selectedIds.includes(item.id) ? '已加入对比 · 点击移除' : '加入对比' }}</button>
|
<button class="choose-button" @click.stop="toggleCompare(item.id)">{{ selectedIds.includes(item.id) ? '已加入对比 · 点击移除' : '加入对比' }}</button>
|
||||||
</view>
|
</view>
|
||||||
<button v-if="hasMore" class="secondary" :disabled="loading" @click="loadMore">{{ loading ? '正在加载…' : '查看更多' }}</button>
|
<button v-if="hasMore && pageError.length == 0" class="secondary" :disabled="loading" @click="loadMore">{{ loading ? '正在加载…' : '查看更多' }}</button>
|
||||||
<text v-if="matches.length > 0 && !hasMore" class="end-note">已显示本组全部结果 · 顺序不代表产品优劣</text>
|
<text v-if="matches.length > 0 && !hasMore" class="end-note">已显示本组全部结果 · 顺序不代表产品优劣</text>
|
||||||
<text class="link" @click="report">返回本次报告</text>
|
<text class="link" @click="report">返回本次报告</text>
|
||||||
</template>
|
</template>
|
||||||
@@ -38,56 +39,78 @@
|
|||||||
import AppHeader from '@/components/AppHeader.uvue'
|
import AppHeader from '@/components/AppHeader.uvue'
|
||||||
import { usePageLayout } from '@/services/layout.uts'
|
import { usePageLayout } from '@/services/layout.uts'
|
||||||
import { getSession } from '@/services/api.uts'
|
import { getSession } from '@/services/api.uts'
|
||||||
import { MatchView, MatchReason, fetchMatchPage, validRecordId } from '@/packages/finance/services/matches.uts'
|
import { MatchView, MatchReason, MatchPage, fetchMatchPage, validRecordId } from '@/packages/finance/services/matches.uts'
|
||||||
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery } from '@/packages/finance/services/compare.uts'
|
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery, matchReturnPath } from '@/packages/finance/services/compare.uts'
|
||||||
const { pageStyle, contentStyle } = usePageLayout()
|
const { pageStyle, contentStyle } = usePageLayout()
|
||||||
type MatchGroup = { id: string, label: string }
|
type MatchGroup = { id: string, label: string }
|
||||||
const groups: MatchGroup[] = [{ id: 'eligible', label: '符合条件' }, { id: 'ineligible', label: '条件不符' }, { id: 'unknown', label: '待核实' }]
|
const groups: MatchGroup[] = [{ id: 'eligible', label: '符合条件' }, { id: 'ineligible', label: '条件不符' }, { id: 'unknown', label: '待核实' }]
|
||||||
const revisionId = ref(''); const assessmentId = ref(''); const group = ref('eligible'); const matches = ref<MatchView[]>([])
|
const revisionId = ref(''); const assessmentId = ref(''); const group = ref('eligible'); const matches = ref<MatchView[]>([])
|
||||||
const selectedIds = ref<string[]>([])
|
const selectedIds = ref<string[]>([])
|
||||||
const pageActive = ref(false)
|
const pageActive = ref(false)
|
||||||
const loading = ref(false); const error = ref(''); const message = ref(''); const status = ref('')
|
const loading = ref(false); const error = ref(''); const pageError = ref(''); const message = ref(''); const status = ref('')
|
||||||
const eligible = ref('—'); const ineligible = ref('—'); const unknown = ref('—'); const hasMore = ref(false)
|
const eligible = ref('—'); const ineligible = ref('—'); const unknown = ref('—'); const hasMore = ref(false)
|
||||||
let cursor = ''; let generation = 0; let visible = false; let routeError = ''; let owner = ''
|
let cursor = ''; let generation = 0; let visible = false; let routeError = ''; let owner = ''
|
||||||
|
let firstPage: MatchPage | null = null; let accountTimer = 0
|
||||||
const count = (id: string): string => id == 'eligible' ? eligible.value : (id == 'ineligible' ? ineligible.value : unknown.value)
|
const count = (id: string): string => id == 'eligible' ? eligible.value : (id == 'ineligible' ? ineligible.value : unknown.value)
|
||||||
const preview = (item: MatchView): MatchReason[] => item.state == 'ineligible' ? item.blocking.slice(0, 3) : (item.state == 'unknown' ? item.cautions.slice(0, 3) : item.positive.slice(0, 3))
|
const preview = (item: MatchView): MatchReason[] => item.state == 'ineligible' ? item.blocking.slice(0, 3) : (item.state == 'unknown' ? item.cautions.slice(0, 3) : item.positive.slice(0, 3))
|
||||||
const clear = () => { matches.value = []; message.value = ''; status.value = ''; hasMore.value = false; cursor = ''; eligible.value = '—'; ineligible.value = '—'; unknown.value = '—' }
|
const clear = () => { matches.value = []; message.value = ''; status.value = ''; pageError.value = ''; hasMore.value = false; cursor = ''; firstPage = null; eligible.value = '—'; ineligible.value = '—'; unknown.value = '—' }
|
||||||
|
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
|
||||||
|
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMatch=1&matchPage=matches' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value).replace('?', '&') + '&group=' + group.value }) }
|
||||||
|
const current = (request: number, token: string): boolean => {
|
||||||
|
if (!visible || request != generation) return false
|
||||||
|
if (getSession()?.getString('token') == token) return true
|
||||||
|
generation++; clear(); loading.value = false; stopAccountTimer()
|
||||||
|
if (getSession() == null) login(); else error.value = '账户已切换,请重新加载本人匹配。'
|
||||||
|
selectedIds.value = []
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const updateUrl = () => {
|
||||||
|
// #ifdef WEB
|
||||||
|
window.history.replaceState(window.history.state, '', location.pathname + location.search + '#' + matchReturnPath('matches', assessmentId.value, selectedIds.value.join(','), revisionId.value, '', group.value))
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
const load = async (append: boolean) => {
|
const load = async (append: boolean) => {
|
||||||
const request = ++generation
|
const request = ++generation
|
||||||
if (!append) clear()
|
if (!append) { stopAccountTimer(); clear() }
|
||||||
error.value = ''
|
error.value = ''; pageError.value = ''
|
||||||
if (routeError.length > 0) { error.value = routeError; loading.value = false; return }
|
if (routeError.length > 0) { error.value = routeError; loading.value = false; return }
|
||||||
if (assessmentId.value.length == 0) { loading.value = false; return }
|
if (assessmentId.value.length == 0) { loading.value = false; return }
|
||||||
const session = getSession()
|
const session = getSession()
|
||||||
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
|
if (session == null) { loading.value = false; login(); return }
|
||||||
const token = session.getString('token') ?? ''; owner = token; loading.value = true
|
const token = session.getString('token') ?? ''; if (owner.length > 0 && owner != token) selectedIds.value = []; owner = token; loading.value = true
|
||||||
|
if (accountTimer == 0) accountTimer = setInterval(() => { current(generation, owner) }, 1000)
|
||||||
try {
|
try {
|
||||||
const result = await fetchMatchPage(assessmentId.value, group.value, append ? cursor : '', revisionId.value)
|
const result = await fetchMatchPage(assessmentId.value, group.value, append ? cursor : '', revisionId.value)
|
||||||
if (!visible || request != generation || getSession()?.getString('token') != token) return
|
if (!current(request, token)) return
|
||||||
|
// 分页只追加同一冻结结果,失败时保留已读行与原游标供局部重试。
|
||||||
|
if (append && (firstPage == null || firstPage!.assessmentId != result.assessmentId || firstPage!.revisionId != result.revisionId || firstPage!.resultVersion != result.resultVersion || firstPage!.snapshotId != result.snapshotId || firstPage!.ruleVersion != result.ruleVersion || firstPage!.orderingVersion != result.orderingVersion || firstPage!.status != result.status || firstPage!.eligible != result.eligible || firstPage!.ineligible != result.ineligible || firstPage!.unknown != result.unknown || result.items.some((item: MatchView): boolean => matches.value.some((old: MatchView): boolean => old.id == item.id)) || (result.hasMore && result.cursor == cursor))) throw new Error('分页与已读取的冻结结果不一致,请重试或重新加载。')
|
||||||
revisionId.value = result.revisionId
|
revisionId.value = result.revisionId
|
||||||
|
if (!append) firstPage = result
|
||||||
|
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value); updateUrl()
|
||||||
matches.value = append ? matches.value.concat(result.items) : result.items
|
matches.value = append ? matches.value.concat(result.items) : result.items
|
||||||
message.value = result.message; status.value = result.status; eligible.value = result.eligible; ineligible.value = result.ineligible; unknown.value = result.unknown
|
message.value = result.message; status.value = result.status; eligible.value = result.eligible; ineligible.value = result.ineligible; unknown.value = result.unknown
|
||||||
cursor = result.cursor; hasMore.value = result.hasMore
|
cursor = result.cursor; hasMore.value = result.hasMore
|
||||||
} catch (err) { if (visible && request == generation) error.value = err instanceof Error ? err.message : '匹配结果读取失败' }
|
} catch (err) { if (current(request, token)) { const message = err instanceof Error ? err.message : '匹配结果读取失败'; if (append) pageError.value = message; else error.value = message } }
|
||||||
finally { if (request == generation) loading.value = false }
|
finally { if (request == generation) loading.value = false }
|
||||||
}
|
}
|
||||||
const selectGroup = (id: string) => { if (id == group.value) return; group.value = id; load(false) }
|
const selectGroup = (id: string) => { if (!current(generation, owner) || id == group.value) return; group.value = id; load(false) }
|
||||||
const retry = () => { load(false) }
|
const retry = () => { load(false) }
|
||||||
const loadMore = () => { if (!loading.value && hasMore.value) load(true) }
|
const loadMore = () => { if (current(generation, owner) && !loading.value && hasMore.value) load(true) }
|
||||||
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
|
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
|
||||||
const start = () => { uni.navigateTo({ url: '/packages/finance/entry/entry' }) }
|
const start = () => { uni.navigateTo({ url: '/packages/finance/entry/entry' }) }
|
||||||
const report = () => { if (validRecordId(assessmentId.value)) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') }); else history() }
|
const report = () => { if (owner.length > 0 && !current(generation, owner)) return; if (validRecordId(assessmentId.value)) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') }); else history() }
|
||||||
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else if (assessmentId.value.length > 0) report(); else history() }
|
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else if (assessmentId.value.length > 0) report(); else history() }
|
||||||
const toggleCompare = (id: string) => {
|
const toggleCompare = (id: string) => {
|
||||||
|
if (!current(generation, owner) || !matches.value.some((item: MatchView): boolean => item.id == id)) return
|
||||||
if (selectedIds.value.includes(id)) selectedIds.value = selectedIds.value.filter((value: string): boolean => value != id)
|
if (selectedIds.value.includes(id)) selectedIds.value = selectedIds.value.filter((value: string): boolean => value != id)
|
||||||
else {
|
else {
|
||||||
if (selectedIds.value.length >= 3) { uni.showToast({ title: '最多选择3个产品,请先移除一项', icon: 'none' }); return }
|
if (selectedIds.value.length >= 3) { uni.showToast({ title: '最多选择3个产品,请先移除一项', icon: 'none' }); return }
|
||||||
selectedIds.value.push(id)
|
selectedIds.value.push(id)
|
||||||
}
|
}
|
||||||
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value)
|
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value); updateUrl()
|
||||||
}
|
}
|
||||||
const openCompare = () => { if (selectedIds.value.length >= 2) uni.navigateTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }) }
|
const openCompare = () => { if (current(generation, owner) && selectedIds.value.length >= 2) uni.navigateTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }) }
|
||||||
const openProduct = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) + '&productId=' + id + '&tab=explanation&from=matches' }) }
|
const openProduct = (id: string) => { if (current(generation, owner) && matches.value.some((item: MatchView): boolean => item.id == id)) uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) + '&productId=' + id + '&tab=explanation&from=matches' }) }
|
||||||
onLoad((options) => {
|
onLoad((options) => {
|
||||||
revisionId.value = options?.['revisionId'] ?? '';
|
revisionId.value = options?.['revisionId'] ?? '';
|
||||||
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError = '版本编号无效,请返回重新选择。'
|
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError = '版本编号无效,请返回重新选择。'
|
||||||
@@ -103,8 +126,8 @@
|
|||||||
})
|
})
|
||||||
onShow(() => { visible = true; pageActive.value = true; selectedIds.value = compareSelection(assessmentId.value, revisionId.value); if (getSession()?.getString('token') != owner) clear(); load(false) })
|
onShow(() => { visible = true; pageActive.value = true; selectedIds.value = compareSelection(assessmentId.value, revisionId.value); if (getSession()?.getString('token') != owner) clear(); load(false) })
|
||||||
// 先关闭内容渲染再清空资料,避免卸载途中因空数组重新挂载状态节点。
|
// 先关闭内容渲染再清空资料,避免卸载途中因空数组重新挂载状态节点。
|
||||||
onHide(() => { visible = false; pageActive.value = false; generation++; clear(); loading.value = false; error.value = '' })
|
onHide(() => { visible = false; pageActive.value = false; stopAccountTimer(); generation++; clear(); loading.value = false; error.value = '' })
|
||||||
onUnload(() => { visible = false; pageActive.value = false; generation++; clear() })
|
onUnload(() => { visible = false; pageActive.value = false; stopAccountTimer(); generation++; clear() })
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.compare-bar { background-color: #fff; border-radius: 16rpx; padding: 20rpx; margin-bottom: 22rpx; }
|
.compare-bar { background-color: #fff; border-radius: 16rpx; padding: 20rpx; margin-bottom: 22rpx; }
|
||||||
|
|||||||
@@ -6,17 +6,18 @@
|
|||||||
<text v-if="loading" class="body">正在读取本次报告与产品状态…</text>
|
<text v-if="loading" class="body">正在读取本次报告与产品状态…</text>
|
||||||
<view v-if="error.length > 0" class="panel"><text class="heading">暂时无法查看行动建议</text><text class="body">{{ error }}</text><button v-if="routeError.length == 0" class="secondary" @click="load">重新加载</button><text class="link" @click="history">前往融资档案 ›</text></view>
|
<view v-if="error.length > 0" class="panel"><text class="heading">暂时无法查看行动建议</text><text class="body">{{ error }}</text><button v-if="routeError.length == 0" class="secondary" @click="load">重新加载</button><text class="link" @click="history">前往融资档案 ›</text></view>
|
||||||
<template v-if="report != null && !loading">
|
<template v-if="report != null && !loading">
|
||||||
<text class="eyebrow">评估记录 #{{ assessmentId }} · {{ assessmentType == 'enterprise' ? '企业' : '个人' }}评估</text>
|
<text class="eyebrow">评估记录 #{{ assessmentId }} · {{ assessmentType == 'enterprise' ? '企业' : '个人' }}评估 · 第 {{ report!.revisionNo }} 版报告</text>
|
||||||
|
<text v-if="report!.isPreviousResult" class="revision-notice">当前查看第 {{ report!.revisionNo }} 版结果。最新提交{{ report!.currentStatus == 'processing' ? '正在评估,成功后更新结果。' : (report!.currentStatus == 'failed' ? '评估失败,已保留上次成功结果。' : '已有新版本,可从档案查看。') }}</text>
|
||||||
<text class="intro">根据本次报告,安排您的下一步</text>
|
<text class="intro">根据本次报告,安排您的下一步</text>
|
||||||
<view class="actions">
|
<view class="actions">
|
||||||
<view class="action-row" @click="prepare"><image class="action-icon" src="/packages/finance/static/action-prepare.svg" /><view class="action-copy"><text class="action-title">{{ productId.length > 0 && !canPrepare ? '核对产品条件' : '准备申请' }}</text><text class="action-note">{{ productId.length == 0 ? '选择本次匹配产品,查看材料准备说明' : '核对产品条件,了解所需材料' }}</text></view><text class="chevron">›</text></view>
|
<view class="action-row" @click="prepare"><image class="action-icon" src="/packages/finance/static/action-prepare.svg" /><view class="action-copy"><text class="action-title">{{ report!.matching.status != 'completed' ? '查看准备说明' : (productId.length > 0 && !canPrepare ? '核对产品条件' : '准备申请') }}</text><text class="action-note">{{ report!.matching.status != 'completed' ? '本报告没有产品匹配结果,可查看准备说明' : (productId.length == 0 ? '选择本次匹配产品,查看材料准备说明' : '核对产品条件,了解所需材料') }}</text></view><text class="chevron">›</text></view>
|
||||||
<view class="action-row" @click="openReport"><image class="action-icon" src="/packages/finance/static/action-report.svg" /><view class="action-copy"><text class="action-title">查看已保存报告</text><text class="action-note">报告已保存至融资档案,可随时查看</text></view><text class="chevron">›</text></view>
|
<view class="action-row" @click="openReport"><image class="action-icon" src="/packages/finance/static/action-report.svg" /><view class="action-copy"><text class="action-title">查看已保存报告</text><text class="action-note">报告已保存至融资档案,可随时查看</text></view><text class="chevron">›</text></view>
|
||||||
<view class="action-row" @click="optimize"><image class="action-icon" src="/packages/finance/static/action-improve.svg" /><view class="action-copy"><text class="action-title">优化资料</text><text class="action-note">核对待补充与待核实项,修改后重新评估</text></view><text class="chevron">›</text></view>
|
<view class="action-row" @click="optimize"><image class="action-icon" src="/packages/finance/static/action-improve.svg" /><view class="action-copy"><text class="action-title">优化资料</text><text class="action-note">核对待补充与待核实项,修改后重新评估</text></view><text class="chevron">›</text></view>
|
||||||
<view class="action-row last" @click="consult"><image class="action-icon" src="/packages/finance/static/action-chat.svg" /><view class="action-copy"><text class="action-title">咨询AI</text><text class="action-note">结合本次报告,为您解释准备方向</text></view><text class="chevron">›</text></view>
|
<view class="action-row last" @click="consult"><image class="action-icon" src="/packages/finance/static/action-chat.svg" /><view class="action-copy"><text class="action-title">咨询AI</text><text class="action-note">结合本次报告,为您解释准备方向</text></view><text class="chevron">›</text></view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="section == 'prepare'" id="prepare-section" class="panel">
|
<view v-if="section == 'prepare'" id="prepare-section" class="panel">
|
||||||
<text class="heading">材料准备</text>
|
<text class="heading">材料准备</text>
|
||||||
<text v-if="productId.length == 0" class="body">请先选择本次匹配中的产品,再查看对应材料。本页不会自动替您选择产品。</text>
|
<text v-if="productId.length == 0" class="body">{{ report!.matching.status == 'completed' ? '请先选择本次匹配中的产品,再查看对应材料。本页不会自动替您选择产品。' : '本报告没有可读取的产品匹配结果,暂不提供产品材料。您仍可查看报告、优化资料或咨询AI。' }}</text>
|
||||||
<template v-else-if="matchError.length > 0"><text class="body">{{ matchError }}</text><button class="secondary" @click="load">重新核对产品</button></template>
|
<template v-else-if="matchError.length > 0"><text class="body">{{ matchError }}</text><button class="secondary" @click="load">重新核对产品</button></template>
|
||||||
<template v-else-if="match != null">
|
<template v-else-if="match != null">
|
||||||
<text class="product-title">{{ match!.product.name }}</text><text class="note">{{ match!.product.bank }} · 评估时版本 v{{ match!.product.version }}</text>
|
<text class="product-title">{{ match!.product.name }}</text><text class="note">{{ match!.product.bank }} · 评估时版本 v{{ match!.product.version }}</text>
|
||||||
@@ -151,6 +152,7 @@
|
|||||||
.page { background-color: #fff; }
|
.page { background-color: #fff; }
|
||||||
.content { padding: 30rpx 36rpx 64rpx; }
|
.content { padding: 30rpx 36rpx 64rpx; }
|
||||||
.eyebrow { font-size: 22rpx; color: #91a0b2; }
|
.eyebrow { font-size: 22rpx; color: #91a0b2; }
|
||||||
|
.revision-notice { font-size: 24rpx; color: #9a753d; line-height: 38rpx; margin-top: 16rpx; }
|
||||||
.intro { font-size: 27rpx; color: #536882; margin-top: 16rpx; line-height: 42rpx; }
|
.intro { font-size: 27rpx; color: #536882; margin-top: 16rpx; line-height: 42rpx; }
|
||||||
.actions { margin-top: 26rpx; }
|
.actions { margin-top: 26rpx; }
|
||||||
.action-row { flex-direction: row; align-items: center; padding: 30rpx 0; border-bottom: 1rpx solid #eef2f7; }
|
.action-row { flex-direction: row; align-items: center; padding: 30rpx 0; border-bottom: 1rpx solid #eef2f7; }
|
||||||
|
|||||||
@@ -84,8 +84,8 @@
|
|||||||
import AppHeader from '@/components/AppHeader.uvue'
|
import AppHeader from '@/components/AppHeader.uvue'
|
||||||
import { usePageLayout } from '@/services/layout.uts'
|
import { usePageLayout } from '@/services/layout.uts'
|
||||||
import { getSession } from '@/services/api.uts'
|
import { getSession } from '@/services/api.uts'
|
||||||
import { MatchView, MatchReason, ProductView, fetchMatchDetailRecord, fetchPublicProduct, validRecordId, requestProductExplanation } from '@/packages/finance/services/matches.uts'
|
import { MatchView, MatchReason, MatchDetail, ProductView, fetchMatchDetailRecord, fetchPublicProduct, validRecordId, requestProductExplanation } from '@/packages/finance/services/matches.uts'
|
||||||
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery } from '@/packages/finance/services/compare.uts'
|
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery, sameMatchSnapshot, matchReturnPath } from '@/packages/finance/services/compare.uts'
|
||||||
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
|
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
|
||||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||||
const revisionId = ref(''); const assessmentId = ref(''); const productId = ref(''); const tab = ref('details')
|
const revisionId = ref(''); const assessmentId = ref(''); const productId = ref(''); const tab = ref('details')
|
||||||
@@ -96,6 +96,7 @@
|
|||||||
const aiBusy = ref(false); const aiError = ref(''); const pollStopped = ref(false)
|
const aiBusy = ref(false); const aiError = ref(''); const pollStopped = ref(false)
|
||||||
let timer: number = 0; let pollCount = 0; let requestKey = ''
|
let timer: number = 0; let pollCount = 0; let requestKey = ''
|
||||||
let routeError = ''; let visible = false; let generation = 0
|
let routeError = ''; let visible = false; let generation = 0
|
||||||
|
let owner = ''; let accountTimer = 0; let frozenDetail: MatchDetail | null = null
|
||||||
const positiveVisible = computed((): MatchReason[] => match.value == null ? [] : (expanded.value ? match.value!.positive : match.value!.positive.slice(0, 3)))
|
const positiveVisible = computed((): MatchReason[] => match.value == null ? [] : (expanded.value ? match.value!.positive : match.value!.positive.slice(0, 3)))
|
||||||
const attention = computed((): MatchReason[] => match.value == null ? [] : match.value!.blocking.concat(match.value!.cautions))
|
const attention = computed((): MatchReason[] => match.value == null ? [] : match.value!.blocking.concat(match.value!.cautions))
|
||||||
const attentionVisible = computed((): MatchReason[] => attentionExpanded.value ? attention.value : attention.value.slice(0, 3))
|
const attentionVisible = computed((): MatchReason[] => attentionExpanded.value ? attention.value : attention.value.slice(0, 3))
|
||||||
@@ -104,10 +105,23 @@
|
|||||||
// 缩短重复的规则模板标题;核验限制仍保留在逐条依据和页尾,不改判定含义。
|
// 缩短重复的规则模板标题;核验限制仍保留在逐条依据和页尾,不改判定含义。
|
||||||
const reasonTitle = (reason: MatchReason): string => reason.text.replace('(资料尚需核验)。', '').replace('本产品已配置条件', '产品条件')
|
const reasonTitle = (reason: MatchReason): string => reason.text.replace('(资料尚需核验)。', '').replace('本产品已配置条件', '产品条件')
|
||||||
function stopPoll(): void { if (timer != 0) clearTimeout(timer); timer = 0 }
|
function stopPoll(): void { if (timer != 0) clearTimeout(timer); timer = 0 }
|
||||||
const current = (request: number, token: string): boolean => visible && generation == request && getSession()?.getString('token') == token
|
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
|
||||||
|
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMatch=1&matchPage=product&assessmentId=' + assessmentId.value + '&revisionId=' + revisionId.value + '&productIds=' + selectedIds.value.join(',') + '&productId=' + productId.value + '&tab=' + tab.value + '&from=' + from }) }
|
||||||
|
const current = (request: number, token: string): boolean => {
|
||||||
|
if (!visible || generation != request) return false
|
||||||
|
if (getSession()?.getString('token') == token) return true
|
||||||
|
generation++; stopPoll(); stopAccountTimer(); product.value = null; match.value = null; frozenDetail = null; openReason.value = ''; requestKey = ''; loading.value = false; aiBusy.value = false; aiError.value = ''
|
||||||
|
if (getSession() == null) login(); else error.value = '账户已切换,请重新加载本人产品资料。'
|
||||||
|
selectedIds.value = []
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const ready = (): boolean => current(generation, owner) && product.value != null && !loading.value
|
||||||
|
const updateUrl = () => {
|
||||||
|
// #ifdef WEB
|
||||||
|
window.history.replaceState(window.history.state, '', location.pathname + location.search + '#' + matchReturnPath('product', assessmentId.value, selectedIds.value.join(','), revisionId.value, productId.value, 'eligible', tab.value, from))
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
const explanationError = (request: number, token: string, message: string) => {
|
const explanationError = (request: number, token: string, message: string) => {
|
||||||
if (!visible || generation != request) return
|
|
||||||
if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
|
|
||||||
if (current(request, token)) aiError.value = message
|
if (current(request, token)) aiError.value = message
|
||||||
}
|
}
|
||||||
let pollAction: () => void = () => {}
|
let pollAction: () => void = () => {}
|
||||||
@@ -118,21 +132,22 @@
|
|||||||
timer = setTimeout(() => { timer = 0; pollAction() }, 3000)
|
timer = setTimeout(() => { timer = 0; pollAction() }, 3000)
|
||||||
}
|
}
|
||||||
async function refreshExplanation(): Promise<void> {
|
async function refreshExplanation(): Promise<void> {
|
||||||
if (aiBusy.value || match.value == null) return
|
if (!ready() || aiBusy.value || match.value == null) return
|
||||||
const request = generation; const token = getSession()?.getString('token') ?? ''
|
const request = generation; const token = owner
|
||||||
aiError.value = ''; aiBusy.value = true
|
aiError.value = ''; aiBusy.value = true
|
||||||
try {
|
try {
|
||||||
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value, revisionId.value)
|
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value, revisionId.value)
|
||||||
const result = detail.item
|
if (!current(request, token)) return
|
||||||
if (current(request, token)) { revisionId.value = detail.revisionId; match.value = result; schedulePoll() }
|
if (frozenDetail == null || !sameMatchSnapshot(frozenDetail!, detail)) throw new Error('解释状态与本次冻结结果不一致,请重新加载。')
|
||||||
|
match.value = detail.item; schedulePoll()
|
||||||
} catch (err) { explanationError(request, token, err instanceof Error ? err.message : '解释状态读取失败,请重试')
|
} catch (err) { explanationError(request, token, err instanceof Error ? err.message : '解释状态读取失败,请重试')
|
||||||
} finally { if (current(request, token)) aiBusy.value = false }
|
} finally { if (current(request, token)) aiBusy.value = false }
|
||||||
}
|
}
|
||||||
pollAction = () => { refreshExplanation() }
|
pollAction = () => { refreshExplanation() }
|
||||||
const checkExplanation = () => { pollCount = 0; pollStopped.value = false; refreshExplanation() }
|
const checkExplanation = () => { pollCount = 0; pollStopped.value = false; refreshExplanation() }
|
||||||
const generateExplanation = async () => {
|
const generateExplanation = async () => {
|
||||||
if (aiBusy.value || match.value == null || aiPending.value || match.value!.aiStatus == 'completed') return
|
if (!ready() || aiBusy.value || match.value == null || !match.value!.aiCanRequest || (match.value!.aiStatus == 'failed' && !match.value!.aiCanRetry) || aiPending.value || match.value!.aiStatus == 'completed') return
|
||||||
const request = generation; const token = getSession()?.getString('token') ?? ''
|
const request = generation; const token = owner
|
||||||
// 网络结果不确定时保留同一个幂等键,只有收到明确失败状态后才开始新的尝试。
|
// 网络结果不确定时保留同一个幂等键,只有收到明确失败状态后才开始新的尝试。
|
||||||
if (requestKey.length == 0) requestKey = 'explain_' + Date.now().toString() + '_' + Math.random().toString(36).slice(2, 12)
|
if (requestKey.length == 0) requestKey = 'explain_' + Date.now().toString() + '_' + Math.random().toString(36).slice(2, 12)
|
||||||
aiBusy.value = true; aiError.value = ''; pollCount = 0; pollStopped.value = false
|
aiBusy.value = true; aiError.value = ''; pollCount = 0; pollStopped.value = false
|
||||||
@@ -143,40 +158,41 @@
|
|||||||
} finally { if (current(request, token)) aiBusy.value = false }
|
} finally { if (current(request, token)) aiBusy.value = false }
|
||||||
}
|
}
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
stopPoll(); pollCount = 0; pollStopped.value = false; aiError.value = ''
|
stopPoll(); stopAccountTimer(); pollCount = 0; pollStopped.value = false; aiError.value = ''; aiBusy.value = false
|
||||||
const request = ++generation; product.value = null; match.value = null; error.value = ''; expanded.value = false
|
const request = ++generation; product.value = null; match.value = null; frozenDetail = null; error.value = ''; expanded.value = false; loading.value = false
|
||||||
if (routeError.length > 0) { error.value = routeError; return }
|
if (routeError.length > 0) { error.value = routeError; return }
|
||||||
const session = getSession()
|
const session = getSession()
|
||||||
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
|
if (session == null) { login(); return }
|
||||||
const token = session.getString('token') ?? ''; loading.value = true
|
const token = session.getString('token') ?? ''; if (owner.length > 0 && owner != token) { selectedIds.value = []; requestKey = '' }; owner = token; loading.value = true
|
||||||
|
accountTimer = setInterval(() => { current(generation, owner) }, 1000)
|
||||||
try {
|
try {
|
||||||
// 带评估编号始终读取冻结结果;404/越权/失败不能降级为公共资料。
|
// 带评估编号始终读取冻结结果;404/越权/失败不能降级为公共资料。
|
||||||
if (assessmentId.value.length > 0) {
|
if (assessmentId.value.length > 0) {
|
||||||
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value, revisionId.value)
|
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value, revisionId.value)
|
||||||
const result = detail.item
|
const result = detail.item
|
||||||
if (current(request, token)) { revisionId.value = detail.revisionId; match.value = result; product.value = result.product; schedulePoll() }
|
if (current(request, token)) { revisionId.value = detail.revisionId; frozenDetail = detail; match.value = result; product.value = result.product; saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value); updateUrl(); schedulePoll() }
|
||||||
} else {
|
} else {
|
||||||
const result = await fetchPublicProduct(productId.value)
|
const result = await fetchPublicProduct(productId.value)
|
||||||
if (current(request, token)) product.value = result
|
if (current(request, token)) product.value = result
|
||||||
}
|
}
|
||||||
} catch (err) { if (visible && generation == request) error.value = err instanceof Error ? err.message : '产品读取失败' }
|
} catch (err) { if (current(request, token)) error.value = err instanceof Error ? err.message : '产品读取失败' }
|
||||||
finally { if (generation == request) loading.value = false }
|
finally { if (generation == request) loading.value = false }
|
||||||
}
|
}
|
||||||
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
|
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
|
||||||
// 仅携带选择ID;助手会重新校验归属并读取冻结事实,进入页面不会调用模型。
|
// 仅携带选择ID;助手会重新校验归属并读取冻结事实,进入页面不会调用模型。
|
||||||
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, [productId.value], 'product', '', revisionId.value) }) }
|
const consult = () => { if (ready() && match.value != null) uni.navigateTo({ url: assistantRoute(assessmentId.value, [productId.value], 'product', '', revisionId.value) }) }
|
||||||
function toMatches(): void { if (assessmentId.value.length > 0) uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }); else history() }
|
function toMatches(): void { if (owner.length > 0 && !current(generation, owner)) return; if (assessmentId.value.length > 0) uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }); else history() }
|
||||||
// 替换当前路由保留栏目、来源与对比选择,刷新后仍停留在同一栏。
|
// 替换当前路由保留栏目、来源与对比选择,刷新后仍停留在同一栏。
|
||||||
const switchTab = (value: string) => {
|
const switchTab = (value: string) => {
|
||||||
if (value == tab.value) return
|
if (!ready() || value == tab.value) return
|
||||||
const query = assessmentId.value.length > 0 ? compareQuery(assessmentId.value, selectedIds.value, revisionId.value) + '&' : '?'
|
const query = assessmentId.value.length > 0 ? compareQuery(assessmentId.value, selectedIds.value, revisionId.value) + '&' : '?'
|
||||||
uni.redirectTo({ url: '/packages/finance/product/product' + query + 'productId=' + productId.value + '&tab=' + value + '&from=' + from })
|
uni.redirectTo({ url: '/packages/finance/product/product' + query + 'productId=' + productId.value + '&tab=' + value + '&from=' + from })
|
||||||
}
|
}
|
||||||
// 下一步页重新核对当前可用性,避免沿用产品详情进入时的旧状态。
|
// 下一步页重新核对当前可用性,避免沿用产品详情进入时的旧状态。
|
||||||
const prepare = () => { if (canPrepare.value) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productId=' + productId.value + '§ion=prepare' }) }
|
const prepare = () => { if (ready() && canPrepare.value) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productId=' + productId.value + '§ion=prepare' }) }
|
||||||
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else if (from == 'compare') uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }); else toMatches() }
|
const back = () => { if (owner.length > 0 && !current(generation, owner)) return; if (getCurrentPages().length > 1) uni.navigateBack(); else if (from == 'compare') uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }); else toMatches() }
|
||||||
const openCompare = () => {
|
const openCompare = () => {
|
||||||
if (match.value == null) return
|
if (!ready() || match.value == null) return
|
||||||
if (!selectedIds.value.includes(productId.value)) {
|
if (!selectedIds.value.includes(productId.value)) {
|
||||||
if (selectedIds.value.length >= 3) { uni.showToast({ title: '已选满3项,请在对比页调整选择', icon: 'none' }); return }
|
if (selectedIds.value.length >= 3) { uni.showToast({ title: '已选满3项,请在对比页调整选择', icon: 'none' }); return }
|
||||||
selectedIds.value.push(productId.value)
|
selectedIds.value.push(productId.value)
|
||||||
@@ -199,10 +215,11 @@
|
|||||||
try { saveCompareSelection(assessmentId.value, parseCompareIds(options!['productIds']!), revisionId.value) }
|
try { saveCompareSelection(assessmentId.value, parseCompareIds(options!['productIds']!), revisionId.value) }
|
||||||
catch (err) { routeError = err instanceof Error ? err.message : '对比参数无效' }
|
catch (err) { routeError = err instanceof Error ? err.message : '对比参数无效' }
|
||||||
}
|
}
|
||||||
|
if (matchReturnPath('product', assessmentId.value, options?.['productIds'] ?? '', revisionId.value, productId.value, 'eligible', tab.value, from).length == 0) routeError = '产品页面参数无效,请返回重新选择。'
|
||||||
})
|
})
|
||||||
onShow(() => { visible = true; pageActive.value = true; selectedIds.value = compareSelection(assessmentId.value, revisionId.value); load() })
|
onShow(() => { visible = true; pageActive.value = true; selectedIds.value = compareSelection(assessmentId.value, revisionId.value); load() })
|
||||||
onHide(() => { pageActive.value = false; visible = false; stopPoll(); generation++; product.value = null; match.value = null; error.value = ''; aiError.value = ''; aiBusy.value = false; loading.value = false; openReason.value = '' })
|
onHide(() => { pageActive.value = false; visible = false; stopPoll(); stopAccountTimer(); generation++; product.value = null; match.value = null; frozenDetail = null; error.value = ''; aiError.value = ''; aiBusy.value = false; loading.value = false; openReason.value = '' })
|
||||||
onUnload(() => { pageActive.value = false; visible = false; stopPoll(); generation++; product.value = null; match.value = null; requestKey = '' })
|
onUnload(() => { pageActive.value = false; visible = false; stopPoll(); stopAccountTimer(); generation++; product.value = null; match.value = null; frozenDetail = null; requestKey = '' })
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.product-page { background-color: #fff; }
|
.product-page { background-color: #fff; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { revisionParams } from './records.uts'
|
import { revisionParams } from './records.uts'
|
||||||
|
import { validRecordId } from './matches.uts'
|
||||||
import { requestJson } from '@/services/api.uts'
|
import { requestJson } from '@/services/api.uts'
|
||||||
export type AssistantProduct = { id: string, name: string, state: string, stateLabel: string, demo: boolean }
|
export type AssistantProduct = { id: string, name: string, state: string, stateLabel: string, demo: boolean }
|
||||||
export type AssistantContext = { revisionId: string, assessmentId: string, productIds: string[], title: string, products: AssistantProduct[], questions: string[], notice: string }
|
export type AssistantContext = { revisionId: string, assessmentId: string, productIds: string[], title: string, products: AssistantProduct[], questions: string[], notice: string }
|
||||||
@@ -13,20 +14,41 @@ function contextView(data: UTSJSONObject): AssistantContext {
|
|||||||
products: (data['products'] as UTSJSONObject[]).map((p: UTSJSONObject): AssistantProduct => ({ id: text(p, 'id'), name: text(p, 'name'), state: text(p, 'state'), stateLabel: text(p, 'stateLabel'), demo: p.getBoolean('isDemo') ?? false })),
|
products: (data['products'] as UTSJSONObject[]).map((p: UTSJSONObject): AssistantProduct => ({ id: text(p, 'id'), name: text(p, 'name'), state: text(p, 'state'), stateLabel: text(p, 'stateLabel'), demo: p.getBoolean('isDemo') ?? false })),
|
||||||
questions: data['questions'] as string[], notice: text(data, 'notice') }
|
questions: data['questions'] as string[], notice: text(data, 'notice') }
|
||||||
}
|
}
|
||||||
function chatView(data: UTSJSONObject): AssistantChat { return { id: text(data, 'chatId'), title: text(data, 'title'), context: contextView(data['context'] as UTSJSONObject) } }
|
function chatView(data: UTSJSONObject): AssistantChat {
|
||||||
|
const id = text(data, 'chatId')
|
||||||
|
if (!validRecordId(id)) throw new Error('返回的会话编号无效,请重新加载。')
|
||||||
|
return { id, title: text(data, 'title'), context: contextView(data['context'] as UTSJSONObject) }
|
||||||
|
}
|
||||||
|
// 回包仍须与本次选择一致;未指定版本时允许服务端解析成功版本,不跟随错版回包改绑定。
|
||||||
|
function checkContext(context: AssistantContext, assessmentId: string, ids: string[], revisionId: string): void {
|
||||||
|
if (context.assessmentId != assessmentId || context.productIds.join(',') != ids.join(',') || (revisionId.length > 0 && context.revisionId != revisionId)) throw new Error('返回的对话上下文与本次评估、产品或版本不一致,请重新加载。')
|
||||||
|
if ((assessmentId.length > 0 && !validRecordId(context.revisionId)) || (assessmentId.length == 0 && context.revisionId.length > 0) || context.products.map((item: AssistantProduct): string => item.id).join(',') != ids.join(',')) throw new Error('返回的对话上下文不完整,请重新加载。')
|
||||||
|
}
|
||||||
function turnView(data: UTSJSONObject): AssistantTurn { return { id: text(data, 'id'), requestId: text(data, 'requestId'), message: text(data, 'message'), reply: text(data, 'reply'), status: text(data, 'status'), attempt: data.getNumber('attempt') ?? 0, canRetry: data.getBoolean('canRetry') ?? false } }
|
function turnView(data: UTSJSONObject): AssistantTurn { return { id: text(data, 'id'), requestId: text(data, 'requestId'), message: text(data, 'message'), reply: text(data, 'reply'), status: text(data, 'status'), attempt: data.getNumber('attempt') ?? 0, canRetry: data.getBoolean('canRetry') ?? false } }
|
||||||
function contextQuery(assessmentId: string, ids: string[], revisionId: string): UTSJSONObject { return { ...revisionParams(revisionId), contextAssessmentId: assessmentId, contextProductIds: ids.join(',') } }
|
function contextQuery(assessmentId: string, ids: string[], revisionId: string): UTSJSONObject { return { ...revisionParams(revisionId), contextAssessmentId: assessmentId, contextProductIds: ids.join(',') } }
|
||||||
export function assistantKey(): string { return 'chat_' + Date.now().toString() + '_' + Math.floor(Math.random() * 1000000000).toString() }
|
export function assistantKey(): string { return 'chat_' + Date.now().toString() + '_' + Math.floor(Math.random() * 1000000000).toString() }
|
||||||
export function assistantRoute(assessmentId: string, ids: string[], from: string, chatId: string = '', revisionId: string = ''): string {
|
export function assistantRoute(assessmentId: string, ids: string[], from: string, chatId: string = '', revisionId: string = ''): string {
|
||||||
return '/packages/finance/assistant/assistant?from=' + from + (assessmentId.length > 0 ? '&assessmentId=' + assessmentId + '&productIds=' + ids.join(',') : '') + (chatId.length > 0 ? '&chatId=' + chatId : '') + (revisionId.length > 0 ? '&revisionId=' + revisionId : '')
|
return '/packages/finance/assistant/assistant?from=' + from + (assessmentId.length > 0 ? '&assessmentId=' + assessmentId + '&productIds=' + ids.join(',') : '') + (chatId.length > 0 ? '&chatId=' + chatId : '') + (revisionId.length > 0 ? '&revisionId=' + revisionId : '')
|
||||||
}
|
}
|
||||||
export async function previewAssistant(assessmentId: string, ids: string[], revisionId: string = ''): Promise<AssistantContext> { return contextView(await requestJson('/assistant/context', 'GET', contextQuery(assessmentId, ids, revisionId))) }
|
export async function previewAssistant(assessmentId: string, ids: string[], revisionId: string = ''): Promise<AssistantContext> {
|
||||||
|
const result = contextView(await requestJson('/assistant/context', 'GET', contextQuery(assessmentId, ids, revisionId)))
|
||||||
|
checkContext(result, assessmentId, ids, revisionId)
|
||||||
|
return result
|
||||||
|
}
|
||||||
export async function loadAssistant(chatId: string, assessmentId: string, ids: string[], checkBinding: boolean, revisionId: string = ''): Promise<AssistantChat> {
|
export async function loadAssistant(chatId: string, assessmentId: string, ids: string[], checkBinding: boolean, revisionId: string = ''): Promise<AssistantChat> {
|
||||||
const result = chatView(await requestJson('/assistant/chats/' + chatId, 'GET', checkBinding ? contextQuery(assessmentId, ids, revisionId) : new UTSJSONObject()))
|
const result = chatView(await requestJson('/assistant/chats/' + chatId, 'GET', checkBinding ? contextQuery(assessmentId, ids, revisionId) : new UTSJSONObject()))
|
||||||
if (result.id != chatId) throw new Error('返回的会话不一致,请重新加载。')
|
if (result.id != chatId) throw new Error('返回的会话不一致,请重新加载。')
|
||||||
|
if (checkBinding) checkContext(result.context, assessmentId, ids, revisionId)
|
||||||
|
else {
|
||||||
|
checkContext(result.context, result.context.assessmentId, result.context.productIds, revisionId)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
export async function createAssistant(assessmentId: string, ids: string[], requestId: string, revisionId: string = ''): Promise<AssistantChat> {
|
||||||
|
const result = chatView(await requestJson('/assistant/chats', 'POST', { ...revisionParams(revisionId), contextAssessmentId: assessmentId.length > 0 ? assessmentId : null, contextProductIds: ids, requestId }))
|
||||||
|
checkContext(result.context, assessmentId, ids, revisionId)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
export async function createAssistant(assessmentId: string, ids: string[], requestId: string, revisionId: string = ''): Promise<AssistantChat> { return chatView(await requestJson('/assistant/chats', 'POST', { ...revisionParams(revisionId), contextAssessmentId: assessmentId.length > 0 ? assessmentId : null, contextProductIds: ids, requestId })) }
|
|
||||||
export async function assistantMessages(chatId: string, cursor: string): Promise<AssistantPage> {
|
export async function assistantMessages(chatId: string, cursor: string): Promise<AssistantPage> {
|
||||||
const data = await requestJson('/assistant/chats/' + chatId + '/messages', 'GET', { limit: '20', cursor })
|
const data = await requestJson('/assistant/chats/' + chatId + '/messages', 'GET', { limit: '20', cursor })
|
||||||
if (text(data, 'chatId') != chatId) throw new Error('消息不属于本次会话。')
|
if (text(data, 'chatId') != chatId) throw new Error('消息不属于本次会话。')
|
||||||
@@ -35,7 +57,10 @@ export async function assistantMessages(chatId: string, cursor: string): Promise
|
|||||||
export async function submitAssistant(chatId: string, requestId: string, message: string, attempt: number): Promise<AssistantTurn> {
|
export async function submitAssistant(chatId: string, requestId: string, message: string, attempt: number): Promise<AssistantTurn> {
|
||||||
const data = await requestJson('/assistant/chats/' + chatId + '/messages', 'POST', { requestId, message, retryAttempt: attempt })
|
const data = await requestJson('/assistant/chats/' + chatId + '/messages', 'POST', { requestId, message, retryAttempt: attempt })
|
||||||
if (text(data, 'chatId') != chatId) throw new Error('回复不属于本次会话。')
|
if (text(data, 'chatId') != chatId) throw new Error('回复不属于本次会话。')
|
||||||
return turnView(data['item'] as UTSJSONObject)
|
const item = turnView(data['item'] as UTSJSONObject)
|
||||||
|
// 同键重放可能返回已推进的尝试;旧尝试或另一条问题不能确认本次发送成功。
|
||||||
|
if (item.requestId != requestId || item.message != message || item.attempt < attempt) throw new Error('发送回执与本次问题不一致,请重试本次发送。')
|
||||||
|
return item
|
||||||
}
|
}
|
||||||
export async function assistantHistory(cursor: string): Promise<AssistantHistoryPage> {
|
export async function assistantHistory(cursor: string): Promise<AssistantHistoryPage> {
|
||||||
const data = await requestJson('/assistant/chats', 'GET', { limit: '20', cursor })
|
const data = await requestJson('/assistant/chats', 'GET', { limit: '20', cursor })
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ export function saveCompareSelection(assessmentId: string, ids: string[], revisi
|
|||||||
export function compareQuery(assessmentId: string, ids: string[], revisionId: string = ''): string {
|
export function compareQuery(assessmentId: string, ids: string[], revisionId: string = ''): string {
|
||||||
return '?assessmentId=' + encodeURIComponent(assessmentId) + '&productIds=' + ids.join(',') + (revisionId.length > 0 ? '&revisionId=' + revisionId : '')
|
return '?assessmentId=' + encodeURIComponent(assessmentId) + '&productIds=' + ids.join(',') + (revisionId.length > 0 ? '&revisionId=' + revisionId : '')
|
||||||
}
|
}
|
||||||
|
// 登录回跳只接受已声明页面与编号,不接受任意URL。
|
||||||
|
export function matchReturnPath(page: string, assessmentId: string, rawIds: string, revisionId: string, productId: string = '', group: string = 'eligible', tab: string = 'explanation', from: string = ''): string {
|
||||||
|
if (!['matches','product','compare'].includes(page) || (revisionId.length > 0 && !validRecordId(revisionId))) return ''
|
||||||
|
if (assessmentId.length > 0 ? !validRecordId(assessmentId) : page != 'product') return ''
|
||||||
|
let ids: string[] = []
|
||||||
|
try { ids = parseCompareIds(rawIds) } catch (err) { return '' }
|
||||||
|
if (assessmentId.length == 0 && (revisionId.length > 0 || ids.length > 0)) return ''
|
||||||
|
if (page == 'matches' && !['eligible','ineligible','unknown'].includes(group)) return ''
|
||||||
|
if (page == 'product' && (!validRecordId(productId) || !['explanation','details','conditions'].includes(tab) || (assessmentId.length == 0 && tab == 'explanation') || !['','matches','product','compare','report','next-step','archive'].includes(from))) return ''
|
||||||
|
const query = assessmentId.length > 0 ? compareQuery(assessmentId, ids, revisionId) : '?'
|
||||||
|
if (page == 'matches') return '/packages/finance/matches/matches' + query + '&group=' + group
|
||||||
|
if (page == 'compare') return '/packages/finance/compare/compare' + query
|
||||||
|
return '/packages/finance/product/product' + query + (assessmentId.length > 0 ? '&' : '') + 'productId=' + productId + '&tab=' + tab + '&from=' + from
|
||||||
|
}
|
||||||
export function sameMatchSnapshot(a: MatchDetail, b: MatchDetail): boolean {
|
export function sameMatchSnapshot(a: MatchDetail, b: MatchDetail): boolean {
|
||||||
return a.assessmentId == b.assessmentId && a.revisionId == b.revisionId && a.resultVersion == b.resultVersion && a.snapshotId == b.snapshotId && a.ruleVersion == b.ruleVersion && a.orderingVersion == b.orderingVersion
|
return a.assessmentId == b.assessmentId && a.revisionId == b.revisionId && a.resultVersion == b.resultVersion && a.snapshotId == b.snapshotId && a.ruleVersion == b.ruleVersion && a.orderingVersion == b.orderingVersion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ function productView(data: UTSJSONObject, publicMode: boolean): ProductView {
|
|||||||
source: text(source, 'publisher') + ' · ' + text(source, 'title'), sourceRef: text(source, 'reference').startsWith('demo:airz-') ? '产品规则档案' : text(source, 'reference'), checkedOn: text(source, 'checkedOn'), conditions }
|
source: text(source, 'publisher') + ' · ' + text(source, 'title'), sourceRef: text(source, 'reference').startsWith('demo:airz-') ? '产品规则档案' : text(source, 'reference'), checkedOn: text(source, 'checkedOn'), conditions }
|
||||||
}
|
}
|
||||||
function matchView(data: UTSJSONObject): MatchView {
|
function matchView(data: UTSJSONObject): MatchView {
|
||||||
|
const snapshot = data['productSnapshot'] as UTSJSONObject
|
||||||
|
const productId = text(data, 'productId'); const version = data.getNumber('productVersion') ?? 0
|
||||||
|
if (!validRecordId(productId) || text(snapshot, 'productId') != productId || version < 1 || Math.floor(version) != version || snapshot.getNumber('productVersion') != version || !['eligible','ineligible','unknown'].includes(text(data, 'eligibility')) || !['available','unavailable','unknown'].includes(text(data, 'currentAvailability'))) throw new Error('产品冻结资料不完整,请重新加载。')
|
||||||
const evidence = data['evidence'] as UTSJSONObject[]
|
const evidence = data['evidence'] as UTSJSONObject[]
|
||||||
const ai = data['aiExplanation'] as UTSJSONObject
|
const ai = data['aiExplanation'] as UTSJSONObject
|
||||||
const explanations = (ai['reasonExplanations'] as UTSJSONObject[] | null) ?? []
|
const explanations = (ai['reasonExplanations'] as UTSJSONObject[] | null) ?? []
|
||||||
@@ -78,32 +81,46 @@ function matchView(data: UTSJSONObject): MatchView {
|
|||||||
}
|
}
|
||||||
export async function requestProductExplanation(id: string, productId: string, requestId: string, revisionId: string = ''): Promise<MatchView> {
|
export async function requestProductExplanation(id: string, productId: string, requestId: string, revisionId: string = ''): Promise<MatchView> {
|
||||||
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId) + '/explanation', 'POST', { ...revisionParams(revisionId), requestId })
|
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId) + '/explanation', 'POST', { ...revisionParams(revisionId), requestId })
|
||||||
const item = matchView(data['item'] as UTSJSONObject)
|
return detailRecord(data, id, productId, revisionId).item
|
||||||
if ((revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id || item.id != productId) throw new Error('AI解释与本次产品不一致,请重新加载。')
|
|
||||||
return item
|
|
||||||
}
|
}
|
||||||
export async function fetchMatchPage(id: string, group: string, cursor: string, revisionId: string = ''): Promise<MatchPage> {
|
export async function fetchMatchPage(id: string, group: string, cursor: string, revisionId: string = ''): Promise<MatchPage> {
|
||||||
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches', 'GET', { ...revisionParams(revisionId), eligibility: group, limit: '3', cursor })
|
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches', 'GET', { ...revisionParams(revisionId), eligibility: group, limit: '3', cursor })
|
||||||
const matching = data['matching'] as UTSJSONObject; const status = text(matching, 'status')
|
const matching = data['matching'] as UTSJSONObject; const status = text(matching, 'status')
|
||||||
if ((revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id) throw new Error('匹配结果与本次评估不一致,请重新加载。')
|
const resolvedRevision = text(data, 'revisionId'); const version = data.getNumber('resultVersion') ?? 0
|
||||||
|
if (!validRecordId(resolvedRevision) || (revisionId.length > 0 && resolvedRevision != revisionId) || text(data, 'assessmentId') != id || version < 1 || Math.floor(version) != version) throw new Error('匹配结果与本次评估不一致,请重新加载。')
|
||||||
|
const items = (data['items'] as UTSJSONObject[]).map((v: UTSJSONObject): MatchView => matchView(v))
|
||||||
|
const hasMore = data.getBoolean('hasMore') ?? false; const nextCursor = text(data, 'nextCursor')
|
||||||
|
const ids: string[] = []
|
||||||
|
items.forEach((item: MatchView) => { if (item.state != group || ids.includes(item.id)) throw new Error('匹配分组或产品编号不一致,请重新加载。'); ids.push(item.id) })
|
||||||
|
if (!['completed','unavailable'].includes(status) || items.length > 3 || (hasMore && (items.length == 0 || nextCursor.length == 0)) || (!hasMore && nextCursor.length > 0) || (status == 'unavailable' && (items.length > 0 || hasMore))) throw new Error('匹配分页状态不完整,请重新加载。')
|
||||||
|
if (status == 'completed') {
|
||||||
|
if (text(matching, 'catalogSnapshotId').length == 0 || text(matching, 'ruleVersion').length == 0 || text(matching, 'orderingPolicyVersion').length == 0) throw new Error('匹配快照版本不完整,请重新加载。')
|
||||||
|
const countKeys = ['candidateCount','ineligibleCount','unknownCount']
|
||||||
|
countKeys.forEach((key: string) => { const value = matching.getNumber(key); if (value == null || value < 0 || Math.floor(value) != value) throw new Error('匹配统计不完整,请重新加载。') })
|
||||||
|
}
|
||||||
const reason = text(matching, 'reasonCode')
|
const reason = text(matching, 'reasonCode')
|
||||||
const count = (key: string): string => { const v = matching.getNumber(key); return v == null ? '—' : v.toString() }
|
const count = (key: string): string => { const v = matching.getNumber(key); return v == null ? '—' : v.toString() }
|
||||||
const message = status == 'completed' ? '按本次评估时的产品条件判断,展示顺序不代表优劣。' : (reason == 'LEGACY_INPUT' ? '旧版报告未执行产品匹配,可重新评估获取新的结果。' : (reason == 'MATCH_RULES_UNPUBLISHED' ? '本次评估尚未启用产品匹配规则。' : '本次评估没有可用产品目录,未执行产品匹配。'))
|
const message = status == 'completed' ? '按本次评估时的产品条件判断,展示顺序不代表优劣。' : (reason == 'LEGACY_INPUT' ? '旧版报告未执行产品匹配,可重新评估获取新的结果。' : (reason == 'MATCH_RULES_UNPUBLISHED' ? '本次评估尚未启用产品匹配规则。' : '本次评估没有可用产品目录,未执行产品匹配。'))
|
||||||
return { assessmentId: id, revisionId: text(data, 'revisionId'), resultVersion: data.getNumber('resultVersion') ?? 0, snapshotId: text(matching, 'catalogSnapshotId'), ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), status, message, eligible: count('candidateCount'), ineligible: count('ineligibleCount'), unknown: count('unknownCount'), items: (data['items'] as UTSJSONObject[]).map((v: UTSJSONObject): MatchView => matchView(v)), hasMore: data.getBoolean('hasMore') ?? false, cursor: text(data, 'nextCursor') }
|
return { assessmentId: id, revisionId: resolvedRevision, resultVersion: version, snapshotId: text(matching, 'catalogSnapshotId'), ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), status, message, eligible: count('candidateCount'), ineligible: count('ineligibleCount'), unknown: count('unknownCount'), items, hasMore, cursor: nextCursor }
|
||||||
}
|
}
|
||||||
export async function fetchMatchDetail(id: string, productId: string, revisionId: string = ''): Promise<MatchView> {
|
export async function fetchMatchDetail(id: string, productId: string, revisionId: string = ''): Promise<MatchView> {
|
||||||
return (await fetchMatchDetailRecord(id, productId, revisionId)).item
|
return (await fetchMatchDetailRecord(id, productId, revisionId)).item
|
||||||
}
|
}
|
||||||
export async function fetchMatchDetailRecord(id: string, productId: string, revisionId: string = ''): Promise<MatchDetail> {
|
export async function fetchMatchDetailRecord(id: string, productId: string, revisionId: string = ''): Promise<MatchDetail> {
|
||||||
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId), 'GET', revisionParams(revisionId))
|
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId), 'GET', revisionParams(revisionId))
|
||||||
|
return detailRecord(data, id, productId, revisionId)
|
||||||
|
}
|
||||||
|
function detailRecord(data: UTSJSONObject, id: string, productId: string, revisionId: string): MatchDetail {
|
||||||
const matching = data['matching'] as UTSJSONObject
|
const matching = data['matching'] as UTSJSONObject
|
||||||
const item = matchView(data['item'] as UTSJSONObject)
|
const item = matchView(data['item'] as UTSJSONObject)
|
||||||
const version = data.getNumber('resultVersion') ?? 0
|
const version = data.getNumber('resultVersion') ?? 0
|
||||||
const snapshotId = text(matching, 'catalogSnapshotId')
|
const snapshotId = text(matching, 'catalogSnapshotId')
|
||||||
// URL只表达选择意图;读取后再次检查归属和冻结版本,不能用公共资料补齐失败列。
|
// URL只表达选择意图;读取后再次检查归属和冻结版本,不能用公共资料补齐失败列。
|
||||||
if ((revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id || item.id != productId || item.product.id != productId || version < 1 || snapshotId.length == 0 || text(matching, 'status') != 'completed') throw new Error('产品不属于本次冻结匹配结果,请返回重新选择。')
|
if (!validRecordId(text(data, 'revisionId')) || (revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id || item.id != productId || item.product.id != productId || version < 1 || Math.floor(version) != version || snapshotId.length == 0 || text(matching, 'ruleVersion').length == 0 || text(matching, 'orderingPolicyVersion').length == 0 || text(matching, 'status') != 'completed') throw new Error('产品不属于本次冻结匹配结果,请返回重新选择。')
|
||||||
return { assessmentId: id, revisionId: text(data, 'revisionId'), resultVersion: version, snapshotId, ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), item }
|
return { assessmentId: id, revisionId: text(data, 'revisionId'), resultVersion: version, snapshotId, ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), item }
|
||||||
}
|
}
|
||||||
export async function fetchPublicProduct(id: string): Promise<ProductView> {
|
export async function fetchPublicProduct(id: string): Promise<ProductView> {
|
||||||
return productView(await requestJson('/products/' + encodeURIComponent(id), 'GET', new UTSJSONObject()), true)
|
const result = productView(await requestJson('/products/' + encodeURIComponent(id), 'GET', new UTSJSONObject()), true)
|
||||||
|
if (result.id != id || result.version < 1 || Math.floor(result.version) != result.version) throw new Error('产品资料与所选产品不一致,请重新加载。')
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type ArchiveCompleteness = { percent: number | null, description: string
|
|||||||
export async function fetchArchiveCompleteness(id: string, revisionId: string = ''): Promise<ArchiveCompleteness> {
|
export async function fetchArchiveCompleteness(id: string, revisionId: string = ''): Promise<ArchiveCompleteness> {
|
||||||
const report = await requestJson('/assessments/' + encodeURIComponent(id) + '/report', 'GET', revisionParams(revisionId))
|
const report = await requestJson('/assessments/' + encodeURIComponent(id) + '/report', 'GET', revisionParams(revisionId))
|
||||||
if (report.getString('assessmentId') != id) throw new Error('报告与当前档案不一致')
|
if (report.getString('assessmentId') != id) throw new Error('报告与当前档案不一致')
|
||||||
|
if (revisionId.length > 0 && report.getString('revisionId') != revisionId) throw new Error('报告版本不一致,请重新选择。')
|
||||||
if (report.getNumber('resultVersion') == 1) return { percent: null, description: '这份旧版报告未计算完整度,已提交资料仍可查看。' }
|
if (report.getNumber('resultVersion') == 1) return { percent: null, description: '这份旧版报告未计算完整度,已提交资料仍可查看。' }
|
||||||
if (report.getNumber('resultVersion') != 2) throw new Error('报告格式暂不支持')
|
if (report.getNumber('resultVersion') != 2) throw new Error('报告格式暂不支持')
|
||||||
const completeness = report['completeness'] as UTSJSONObject | null
|
const completeness = report['completeness'] as UTSJSONObject | null
|
||||||
@@ -23,7 +24,14 @@ export async function fetchArchiveCompleteness(id: string, revisionId: string =
|
|||||||
return { percent, description: '反映本次评估时的资料填写情况,不代表信用评分或审批结果。' }
|
return { percent, description: '反映本次评估时的资料填写情况,不代表信用评分或审批结果。' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordFrom(item: UTSJSONObject): AssessmentRecord {
|
function validAssessmentId(value: string): boolean { return /^[1-9][0-9]{0,9}$/.test(value) }
|
||||||
|
function recordFrom(item: UTSJSONObject, id: string = '', revisionId: string = ''): AssessmentRecord {
|
||||||
|
const recordId = draftText(item, 'assessmentId'); const revision = draftText(item, 'revisionId')
|
||||||
|
const revisionNo = item.getNumber('revisionNo')
|
||||||
|
if (!validAssessmentId(recordId) || !validAssessmentId(revision) || (id.length > 0 && recordId != id) || (revisionId.length > 0 && revision != revisionId)
|
||||||
|
|| !['personal', 'enterprise'].includes(draftText(item, 'type')) || !['processing', 'failed', 'succeeded'].includes(draftText(item, 'status'))
|
||||||
|
|| revisionNo == null || !Number.isInteger(revisionNo) || revisionNo < 1
|
||||||
|
|| (item.getBoolean('hasReport') == true && !validAssessmentId(draftText(item, 'publishedRevisionId')))) throw new Error('评估记录或版本不一致,请重新加载。')
|
||||||
const submitted = draftText(item, 'submittedAt')
|
const submitted = draftText(item, 'submittedAt')
|
||||||
const date = new Date(submitted)
|
const date = new Date(submitted)
|
||||||
const pad = (value: number): string => value < 10 ? '0' + value.toString() : value.toString()
|
const pad = (value: number): string => value < 10 ? '0' + value.toString() : value.toString()
|
||||||
@@ -126,6 +134,9 @@ export async function createAssessment(payload: UTSJSONObject): Promise<string>
|
|||||||
const documents = payload['documents'] as UTSJSONObject[] | null
|
const documents = payload['documents'] as UTSJSONObject[] | null
|
||||||
const grants = payload['authorizations'] as string[] | null
|
const grants = payload['authorizations'] as string[] | null
|
||||||
if ((documents != null && documents.length > 0) || (grants != null && grants.length > 0)) throw new Error('资料上传与外部查询授权尚未开放,请移除资料及授权选择后提交')
|
if ((documents != null && documents.length > 0) || (grants != null && grants.length > 0)) throw new Error('资料上传与外部查询授权尚未开放,请移除资料及授权选择后提交')
|
||||||
|
const editId = draftText(payload, 'editAssessmentId'); const baseRevision = draftText(payload, 'baseRevisionId')
|
||||||
|
if (editId.length > 0 && (!validAssessmentId(editId) || !validAssessmentId(baseRevision))) throw new Error('修改版本无效,请返回档案重新读取。')
|
||||||
|
if (!['personal', 'enterprise'].includes(draftText(payload, 'type'))) throw new Error('评估类型无效,请重新填写。')
|
||||||
const values = assessmentApiPayload(payload)
|
const values = assessmentApiPayload(payload)
|
||||||
const form = new UTSJSONObject()
|
const form = new UTSJSONObject()
|
||||||
const textKeys = ['name', 'idCard', 'phone', 'city', 'occupation', 'company', 'enterpriseName', 'businessCode']
|
const textKeys = ['name', 'idCard', 'phone', 'city', 'occupation', 'company', 'enterpriseName', 'businessCode']
|
||||||
@@ -150,14 +161,15 @@ export async function createAssessment(payload: UTSJSONObject): Promise<string>
|
|||||||
form['cityCode'] = cityIndex < 0 ? null : cityCodes[cityIndex]
|
form['cityCode'] = cityIndex < 0 ? null : cityCodes[cityIndex]
|
||||||
// 输入v3恢复原资料表单,不提交已取消的资金需求字段;报告仍使用v2展示结构。
|
// 输入v3恢复原资料表单,不提交已取消的资金需求字段;报告仍使用v2展示结构。
|
||||||
const body: UTSJSONObject = { inputVersion: 3, type: values['type'], moneyUnit: 'yuan', form, documents: [] as UTSJSONObject[], consentIds: [] as string[] }
|
const body: UTSJSONObject = { inputVersion: 3, type: values['type'], moneyUnit: 'yuan', form, documents: [] as UTSJSONObject[], consentIds: [] as string[] }
|
||||||
const editId = draftText(payload, 'editAssessmentId')
|
if (editId.length > 0) body['baseRevisionId'] = baseRevision
|
||||||
if (editId.length > 0) body['baseRevisionId'] = draftText(payload, 'baseRevisionId')
|
|
||||||
// 新建和修改使用不同幂等作用域,回执丢失时安全重放原提交。
|
// 新建和修改使用不同幂等作用域,回执丢失时安全重放原提交。
|
||||||
body['requestId'] = assessmentRequestKey(session['token'] as string, editId + ':' + JSON.stringify(body))
|
body['requestId'] = assessmentRequestKey(session['token'] as string, editId + ':' + JSON.stringify(body))
|
||||||
const path = editId.length > 0 ? '/assessments/' + encodeURIComponent(editId) + '/revisions' : '/assessments'
|
const path = editId.length > 0 ? '/assessments/' + encodeURIComponent(editId) + '/revisions' : '/assessments'
|
||||||
const result = await requestJson(path, 'POST', body)
|
const result = await requestJson(path, 'POST', body)
|
||||||
const id = draftText(result, 'assessmentId')
|
const id = draftText(result, 'assessmentId')
|
||||||
if (id.length == 0) throw new Error('提交回执不完整,请重试')
|
const revision = draftText(result, 'revisionId')
|
||||||
|
if (!validAssessmentId(id) || !validAssessmentId(revision) || result.getString('submissionStatus') != 'accepted'
|
||||||
|
|| (editId.length > 0 && (id != editId || revision == baseRevision))) throw new Error('提交回执不一致,请使用原提交重试。')
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,16 +178,34 @@ export async function fetchAssessments(cursor: string, type: string = ''): Promi
|
|||||||
// 按主体在服务端过滤后分页,不能只过滤当前页而漏掉更早的企业/个人档案。
|
// 按主体在服务端过滤后分页,不能只过滤当前页而漏掉更早的企业/个人档案。
|
||||||
if (type.length > 0) params['type'] = type
|
if (type.length > 0) params['type'] = type
|
||||||
const result = await requestJson('/assessments', 'GET', params)
|
const result = await requestJson('/assessments', 'GET', params)
|
||||||
const items = result['items'] as UTSJSONObject[] | null
|
return assessmentPageFrom(result, cursor, type)
|
||||||
return { items: items == null ? [] : items.map((item: UTSJSONObject): AssessmentRecord => recordFrom(item)), hasMore: result['hasMore'] == true, nextCursor: draftText(result, 'nextCursor') }
|
}
|
||||||
|
|
||||||
|
// 记录和版本分页必须属于当前选择,坏页不能推进游标或拼入另一评估。
|
||||||
|
function assessmentPageFrom(result: UTSJSONObject, cursor: string, type: string = '', id: string = ''): AssessmentPage {
|
||||||
|
const rawItems = result['items'] as UTSJSONObject[] | null
|
||||||
|
const more = result.getBoolean('hasMore'); const next = draftText(result, 'nextCursor')
|
||||||
|
if (rawItems == null || !Array.isArray(rawItems) || more == null) throw new Error('历史分页格式不完整,请重试。')
|
||||||
|
const items: AssessmentRecord[] = []; let previous = cursor
|
||||||
|
rawItems.forEach((item: UTSJSONObject) => {
|
||||||
|
const record = recordFrom(item, id)
|
||||||
|
const key = id.length > 0 ? record.revisionId : record.id
|
||||||
|
if ((type.length > 0 && record.type != type) || (previous.length > 0 && parseInt(key) >= parseInt(previous))) throw new Error('历史分页记录不一致,请重试。')
|
||||||
|
previous = key; items.push(record)
|
||||||
|
})
|
||||||
|
if ((more && (items.length == 0 || !validAssessmentId(next) || next != previous)) || (!more && next.length > 0)) throw new Error('历史分页游标不一致,请重试。')
|
||||||
|
return { items, hasMore: more, nextCursor: next }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAssessmentStatus(id: string): Promise<AssessmentRecord> {
|
export async function fetchAssessmentStatus(id: string): Promise<AssessmentRecord> {
|
||||||
return recordFrom(await requestJson('/assessments/' + encodeURIComponent(id) + '/status', 'GET', {}))
|
return recordFrom(await requestJson('/assessments/' + encodeURIComponent(id) + '/status', 'GET', {}), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAssessmentDetail(id: string, revisionId: string = ''): Promise<AssessmentDetail> {
|
export async function fetchAssessmentDetail(id: string, revisionId: string = ''): Promise<AssessmentDetail> {
|
||||||
const result = await requestJson('/assessments/' + encodeURIComponent(id), 'GET', revisionParams(revisionId))
|
const result = await requestJson('/assessments/' + encodeURIComponent(id), 'GET', revisionParams(revisionId))
|
||||||
|
if (result.getString('assessmentId') != id) throw new Error('评估详情与当前档案不一致')
|
||||||
|
if (revisionId.length > 0 && result.getString('revisionId') != revisionId) throw new Error('评估详情版本不一致,请重新选择。')
|
||||||
|
const record = recordFrom(result, id, revisionId)
|
||||||
const form = result['form'] as UTSJSONObject | null
|
const form = result['form'] as UTSJSONObject | null
|
||||||
if (form == null) throw new Error('评估详情不完整')
|
if (form == null) throw new Error('评估详情不完整')
|
||||||
const fields: AssessmentField[] = []
|
const fields: AssessmentField[] = []
|
||||||
@@ -209,19 +239,21 @@ export async function fetchAssessmentDetail(id: string, revisionId: string = '')
|
|||||||
// “无”及数值0都是已填写,不能用真值判断遗漏零负债、零经营月数。
|
// “无”及数值0都是已填写,不能用真值判断遗漏零负债、零经营月数。
|
||||||
addField(incomeKeys.includes(key) ? 1 : (assetKeys.includes(key) ? 2 : 0), labels[index], value, raw != null && value.length > 0)
|
addField(incomeKeys.includes(key) ? 1 : (assetKeys.includes(key) ? 2 : 0), labels[index], value, raw != null && value.length > 0)
|
||||||
})
|
})
|
||||||
return { record: recordFrom(result), fields, sections }
|
return { record, fields, sections }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 版本编号显式随请求传递,避免不同页面共享“当前版本”造成报告串版。
|
// 版本编号显式随请求传递,避免不同页面共享“当前版本”造成报告串版。
|
||||||
export function revisionParams(revisionId: string): UTSJSONObject { return revisionId.length > 0 ? { revisionId } : new UTSJSONObject() }
|
export function revisionParams(revisionId: string): UTSJSONObject { return revisionId.length > 0 ? { revisionId } : new UTSJSONObject() }
|
||||||
export async function fetchAssessmentRevisions(id: string, cursor: string): Promise<AssessmentPage> {
|
export async function fetchAssessmentRevisions(id: string, cursor: string): Promise<AssessmentPage> {
|
||||||
const result = await requestJson('/assessments/' + encodeURIComponent(id) + '/revisions', 'GET', { cursor })
|
const result = await requestJson('/assessments/' + encodeURIComponent(id) + '/revisions', 'GET', { cursor })
|
||||||
return { items: (result['items'] as UTSJSONObject[]).map((item: UTSJSONObject): AssessmentRecord => recordFrom(item)), hasMore: result.getBoolean('hasMore') ?? false, nextCursor: draftText(result, 'nextCursor') }
|
return assessmentPageFrom(result, cursor, '', id)
|
||||||
}
|
}
|
||||||
export async function fetchAssessmentEdit(id: string): Promise<UTSJSONObject> {
|
export async function fetchAssessmentEdit(id: string): Promise<UTSJSONObject> {
|
||||||
const result = await requestJson('/assessments/' + encodeURIComponent(id) + '/edit', 'GET', {})
|
const result = await requestJson('/assessments/' + encodeURIComponent(id) + '/edit', 'GET', {})
|
||||||
if (draftText(result, 'assessmentId') != id || result.getString('status') == 'processing') throw new Error('评估正在处理中,请完成后再修改。')
|
const record = recordFrom(result, id)
|
||||||
const form = result['form'] as UTSJSONObject
|
if (record.status == 'processing') throw new Error('评估正在处理中,请完成后再修改。')
|
||||||
|
const form = result['form'] as UTSJSONObject | null
|
||||||
|
if (form == null || Array.isArray(form) || typeof form != 'object' || result.getString('moneyUnit') != 'yuan') throw new Error('修改资料不完整,请返回档案重新读取。')
|
||||||
const payload: UTSJSONObject = { type: result['type'], editAssessmentId: id, baseRevisionId: result['revisionId'], step: 0 }
|
const payload: UTSJSONObject = { type: result['type'], editAssessmentId: id, baseRevisionId: result['revisionId'], step: 0 }
|
||||||
const money = ['monthlyIncome', 'annualRevenue', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit']
|
const money = ['monthlyIncome', 'annualRevenue', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit']
|
||||||
const labels: UTSJSONObject = { married: '已婚', single: '未婚', divorced: '离异', widowed: '丧偶', yes: '有', no: '无', six_months_plus: '连续缴纳 6 个月以上', under_six_months: '缴纳不足 6 个月', none: '未缴纳' }
|
const labels: UTSJSONObject = { married: '已婚', single: '未婚', divorced: '离异', widowed: '丧偶', yes: '有', no: '无', six_months_plus: '连续缴纳 6 个月以上', under_six_months: '缴纳不足 6 个月', none: '未缴纳' }
|
||||||
|
|||||||
@@ -3,17 +3,122 @@ import { APP_ENVIRONMENT } from '@/services/config.uts'
|
|||||||
|
|
||||||
export type ReportDownload = { cancel: () => void, done: Promise<string> }
|
export type ReportDownload = { cancel: () => void, done: Promise<string> }
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
let previewFiles: string[] = []
|
||||||
|
const cleanPreviewFiles = () => {
|
||||||
|
previewFiles = previewFiles.filter((path: string): boolean => {
|
||||||
|
try { uni.getFileSystemManager().unlinkSync(path); return false } catch (_err) { return true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 系统文档预览期间保留临时文件,返回小程序后清理;不调用saveFile持久化报告。
|
||||||
|
uni.onAppShow(() => cleanPreviewFiles())
|
||||||
|
// #endif
|
||||||
|
|
||||||
export function downloadReportPdf(id: string, revisionId: string = ''): ReportDownload {
|
export function downloadReportPdf(id: string, revisionId: string = ''): ReportDownload {
|
||||||
// 首版仅开放普通浏览器H5;其他端明确提示,不生成假文件或成功回执。
|
// #ifndef H5 || MP-WEIXIN
|
||||||
// #ifndef H5
|
|
||||||
return { cancel: () => {}, done: new Promise<string>((_resolve, reject) => { reject(new Error('当前客户端暂未开放PDF下载,请在普通浏览器打开H5并登录后下载。')) }) }
|
return { cancel: () => {}, done: new Promise<string>((_resolve, reject) => { reject(new Error('当前客户端暂未开放PDF下载,请在普通浏览器打开H5并登录后下载。')) }) }
|
||||||
// #endif
|
// #endif
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
cleanPreviewFiles()
|
||||||
|
let cancelled = false; let opening = false; let settled = false
|
||||||
|
let task: DownloadTask | null = null; let tempPath = ''; let rejectDownload: ((error: Error) => void) | null = null
|
||||||
|
const removeTemp = () => {
|
||||||
|
if (tempPath.length == 0) return
|
||||||
|
try { uni.getFileSystemManager().unlinkSync(tempPath) } catch (_err) { previewFiles.push(tempPath) }
|
||||||
|
tempPath = ''
|
||||||
|
}
|
||||||
|
const fail = (message: string) => {
|
||||||
|
removeTemp()
|
||||||
|
if (settled) return
|
||||||
|
settled = true; rejectDownload?.(new Error(message))
|
||||||
|
}
|
||||||
|
const stop = (message: string) => { fail(message); task?.abort() }
|
||||||
|
const cancel = () => {
|
||||||
|
// openDocument已经接管文件后,原页面可能隐藏/销毁,不能中止或删除正在预览的文件。
|
||||||
|
if (opening || settled) return
|
||||||
|
cancelled = true; stop('下载已取消。')
|
||||||
|
}
|
||||||
|
const done = new Promise<string>((resolve, reject) => {
|
||||||
|
rejectDownload = reject
|
||||||
|
if (!/^[1-9][0-9]{0,9}$/.test(id) || (revisionId.length > 0 && !/^[1-9][0-9]{0,9}$/.test(revisionId))) { fail('报告或版本编号无效,请重新打开本人报告。'); return }
|
||||||
|
const token = getSession()?.getString('token') ?? ''
|
||||||
|
if (token.length == 0) { fail('登录已过期,请重新登录后下载。'); return }
|
||||||
|
if (!API_BASE_URL.startsWith('https://')) { fail('小程序PDF下载需要配置HTTPS服务地址。'); return }
|
||||||
|
const current = (): boolean => {
|
||||||
|
if (settled) return false
|
||||||
|
if (cancelled || getSession()?.getString('token') != token) { fail('页面或登录状态已变化,本次下载已取消。'); return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let mime = ''; let declaredSize = 0; let encoded = false
|
||||||
|
task = uni.downloadFile({
|
||||||
|
url: API_BASE_URL + '/assessments/' + id + '/report.pdf' + (revisionId.length > 0 ? '?revisionId=' + encodeURIComponent(revisionId) : ''),
|
||||||
|
header: { Authorization: 'Bearer ' + token, 'X-Requested-With': 'XMLHttpRequest' }, timeout: 45000,
|
||||||
|
success: (response) => {
|
||||||
|
tempPath = response.tempFilePath
|
||||||
|
if (!current()) { removeTemp(); return }
|
||||||
|
if (response.statusCode == 401) { clearSession(); fail('登录已过期,请重新登录后下载。'); return }
|
||||||
|
try {
|
||||||
|
const fs = uni.getFileSystemManager()
|
||||||
|
const size = (fs.statSync(tempPath, false) as UniNamespace.Stats).size
|
||||||
|
if (response.statusCode != 200 || mime != 'application/pdf') {
|
||||||
|
let message = 'PDF暂不可下载,请稍后重试;已保存报告不受影响。'
|
||||||
|
if (mime == 'application/json' && size > 0 && size <= 8192) {
|
||||||
|
const body = JSON.parse<UTSJSONObject>(fs.readFileSync(tempPath, 'utf8') as string)
|
||||||
|
const serverMessage = body?.getString('msg') ?? ''
|
||||||
|
if (serverMessage.length > 0 && serverMessage.length < 200) message = serverMessage
|
||||||
|
}
|
||||||
|
fail(message); return
|
||||||
|
}
|
||||||
|
if (size > 20971520) { fail('PDF文件过大,请联系管理员。'); return }
|
||||||
|
if (size < 100 || (!encoded && declaredSize > 0 && declaredSize != size)) { fail('下载内容不是完整PDF,请重试。'); return }
|
||||||
|
// 只读取头尾,避免将整份报告加载到小程序内存。
|
||||||
|
const start = fs.readFileSync(tempPath, 'ascii', 0, 5) as string
|
||||||
|
const tailLength = Math.min(size, 1024)
|
||||||
|
const end = fs.readFileSync(tempPath, 'ascii', size - tailLength, tailLength) as string
|
||||||
|
if (start != '%PDF-' || !end.includes('%%EOF')) { fail('下载内容不是完整PDF,请重试。'); return }
|
||||||
|
if (!current()) return
|
||||||
|
opening = true
|
||||||
|
uni.openDocument({ filePath: tempPath, fileType: 'pdf', showMenu: true,
|
||||||
|
success: () => {
|
||||||
|
if (!current()) return
|
||||||
|
previewFiles.push(tempPath); tempPath = ''; settled = true
|
||||||
|
resolve('PDF已打开,可通过右上角菜单保存或转发。')
|
||||||
|
}, fail: () => { opening = false; fail('PDF已下载但暂时无法打开,请重试或更新微信。') }
|
||||||
|
})
|
||||||
|
} catch (_err) { fail('PDF文件读取失败或内容异常,请重试。') }
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
if (!current()) return
|
||||||
|
const reason = error.errMsg ?? ''
|
||||||
|
if (reason.includes('domain list')) fail('PDF下载域名未配置,请联系管理员。')
|
||||||
|
else if (reason.includes('timeout')) fail('PDF生成超时,请稍后重试。')
|
||||||
|
else fail('PDF下载失败,请检查网络后重试。')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
task!.onHeadersReceived((response) => {
|
||||||
|
if (!current()) { task?.abort(); return }
|
||||||
|
const header = response.header as UTSJSONObject
|
||||||
|
Object.keys(header).forEach((key: string) => {
|
||||||
|
const value = header[key]?.toString() ?? ''
|
||||||
|
if (key.toLowerCase() == 'content-type') mime = value.split(';')[0].trim().toLowerCase()
|
||||||
|
if (key.toLowerCase() == 'content-length') declaredSize = Number(value)
|
||||||
|
if (key.toLowerCase() == 'content-encoding') encoded = value.length > 0 && value.toLowerCase() != 'identity'
|
||||||
|
})
|
||||||
|
if (declaredSize > 20971520) stop('PDF文件过大,请联系管理员。')
|
||||||
|
})
|
||||||
|
task!.onProgressUpdate((progress) => {
|
||||||
|
if (!current()) { task?.abort(); return }
|
||||||
|
if (progress.totalBytesWritten > 20971520 || progress.totalBytesExpectedToWrite > 20971520) stop('PDF文件过大,请联系管理员。')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return { cancel, done }
|
||||||
|
// #endif
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const cancel = () => { cancelled = true; controller.abort() }
|
const cancel = () => { cancelled = true; controller.abort() }
|
||||||
const done = async (): Promise<string> => {
|
const done = async (): Promise<string> => {
|
||||||
if (!/^[1-9][0-9]{0,9}$/.test(id)) throw new Error('报告编号无效,请重新打开本人报告。')
|
if (!/^[1-9][0-9]{0,9}$/.test(id) || (revisionId.length > 0 && !/^[1-9][0-9]{0,9}$/.test(revisionId))) throw new Error('报告或版本编号无效,请重新打开本人报告。')
|
||||||
if (/MicroMessenger/i.test(navigator.userAgent)) throw new Error('微信内浏览器暂不支持此下载。请点右上角菜单,选择“在浏览器打开”,登录后重新点击下载PDF。')
|
if (/MicroMessenger/i.test(navigator.userAgent)) throw new Error('微信内浏览器暂不支持此下载。请点右上角菜单,选择“在浏览器打开”,登录后重新点击下载PDF。')
|
||||||
const token = getSession()?.getString('token') ?? ''
|
const token = getSession()?.getString('token') ?? ''
|
||||||
if (token.length == 0) throw new Error('登录已过期,请重新登录后下载。')
|
if (token.length == 0) throw new Error('登录已过期,请重新登录后下载。')
|
||||||
@@ -28,6 +133,7 @@ export function downloadReportPdf(id: string, revisionId: string = ''): ReportDo
|
|||||||
cache: 'no-store', credentials: 'omit', redirect: 'error', signal: controller.signal
|
cache: 'no-store', credentials: 'omit', redirect: 'error', signal: controller.signal
|
||||||
})
|
})
|
||||||
current()
|
current()
|
||||||
|
if (response == null) throw new Error('下载服务未返回文件,请重试。')
|
||||||
if (response.status == 401) { clearSession(); throw new Error('登录已过期,请重新登录后下载。') }
|
if (response.status == 401) { clearSession(); throw new Error('登录已过期,请重新登录后下载。') }
|
||||||
const mime = (response.headers.get('Content-Type') ?? '').split(';')[0].trim().toLowerCase()
|
const mime = (response.headers.get('Content-Type') ?? '').split(';')[0].trim().toLowerCase()
|
||||||
if (!response.ok || mime != 'application/pdf') {
|
if (!response.ok || mime != 'application/pdf') {
|
||||||
@@ -44,7 +150,8 @@ export function downloadReportPdf(id: string, revisionId: string = ''): ReportDo
|
|||||||
current()
|
current()
|
||||||
const start = new TextDecoder('ascii').decode(bytes.slice(0, 5))
|
const start = new TextDecoder('ascii').decode(bytes.slice(0, 5))
|
||||||
const end = new TextDecoder('ascii').decode(bytes.slice(Math.max(0, bytes.byteLength - 1024)))
|
const end = new TextDecoder('ascii').decode(bytes.slice(Math.max(0, bytes.byteLength - 1024)))
|
||||||
if (bytes.byteLength < 100 || bytes.byteLength > 20971520 || start != '%PDF-' || !end.includes('%%EOF')) throw new Error('下载内容不是完整PDF,请重试。')
|
const encoded = (response.headers.get('Content-Encoding') ?? '').toLowerCase()
|
||||||
|
if (bytes.byteLength < 100 || bytes.byteLength > 20971520 || ((encoded.length == 0 || encoded == 'identity') && declaredSize > 0 && declaredSize != bytes.byteLength) || start != '%PDF-' || !end.includes('%%EOF')) throw new Error('下载内容不是完整PDF,请重试。')
|
||||||
// 验证响应后才创建本机Blob。令牌只放请求头,不写URL、文件名或本机存储。
|
// 验证响应后才创建本机Blob。令牌只放请求头,不写URL、文件名或本机存储。
|
||||||
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
|
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// Run after HBuilderX MP-WEIXIN compilation; execute the actual generated service with isolated API doubles.
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, '..');
|
||||||
|
const sourcePath = path.join(repo, 'packages/finance/services/report-export.uts');
|
||||||
|
const builtPath = path.resolve(process.argv[2] || path.join(repo, 'unpackage/dist/dev/mp-weixin/packages/finance/services/report-export.js'));
|
||||||
|
assert.ok(fs.statSync(builtPath).mtimeMs >= fs.statSync(sourcePath).mtimeMs, 'Compile MP-WEIXIN before running this test');
|
||||||
|
const source = fs.readFileSync(builtPath, 'utf8');
|
||||||
|
const maxSize = 20 * 1024 * 1024;
|
||||||
|
const pdf = Buffer.from('%PDF-1.7\n' + 'x'.repeat(2000) + '\n%%EOF\n');
|
||||||
|
let checks = 0;
|
||||||
|
|
||||||
|
function environment() {
|
||||||
|
const state = { token: 'synthetic-local-only', requests: [], opened: [], deleted: [], reads: [], files: new Map(), appShow: [], cleared: 0, aborted: 0, unlinkFails: false };
|
||||||
|
class JsonObject {
|
||||||
|
constructor(value) { Object.assign(this, value); }
|
||||||
|
getString(key) { return typeof this[key] === 'string' ? this[key] : null; }
|
||||||
|
}
|
||||||
|
class UtsType { static initProps(options) { return options; } }
|
||||||
|
const api = {
|
||||||
|
getSession: () => state.token ? new JsonObject({ token: state.token }) : null,
|
||||||
|
clearSession: () => { state.cleared++; state.token = ''; }
|
||||||
|
};
|
||||||
|
const config = { API_BASE_URL: 'https://isolated.invalid/api/v1' };
|
||||||
|
const manager = {
|
||||||
|
unlinkSync(file) {
|
||||||
|
if (state.unlinkFails) throw Error('simulated unlink failure');
|
||||||
|
assert.ok(state.files.has(file), 'Delete only a known temporary download');
|
||||||
|
state.files.delete(file); state.deleted.push(file);
|
||||||
|
},
|
||||||
|
statSync: file => {
|
||||||
|
if (state.statFails) throw Error('simulated stat failure');
|
||||||
|
return { size: state.size ?? state.files.get(file).length };
|
||||||
|
},
|
||||||
|
readFileSync(file, encoding, start = 0, length) {
|
||||||
|
if (state.readFails) throw Error('simulated read failure');
|
||||||
|
state.reads.push({ encoding, start, length });
|
||||||
|
return state.files.get(file).subarray(start, length == null ? undefined : start + length).toString(encoding === 'utf8' ? 'utf8' : 'ascii');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const uni = {
|
||||||
|
getFileSystemManager: () => manager,
|
||||||
|
onAppShow: callback => state.appShow.push(callback),
|
||||||
|
downloadFile(options) {
|
||||||
|
state.requests.push(options); state.request = options;
|
||||||
|
return {
|
||||||
|
abort() { state.aborted++; options.fail({ errMsg: 'downloadFile:fail abort' }); },
|
||||||
|
onHeadersReceived(callback) { state.headers = callback; },
|
||||||
|
onProgressUpdate(callback) { state.progress = callback; }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
openDocument(options) { state.opened.push(options); }
|
||||||
|
};
|
||||||
|
const exports = {};
|
||||||
|
vm.runInNewContext(source, {
|
||||||
|
exports,
|
||||||
|
require(module) {
|
||||||
|
if (module.endsWith('/common/vendor.js')) return { index: uni, UTSJSONObject: JsonObject, UTS: { UTSType: UtsType, JSON: { parse: text => new JsonObject(JSON.parse(text)) } } };
|
||||||
|
if (module.endsWith('/services/api.js')) return api;
|
||||||
|
if (module.endsWith('/services/config.js')) return config;
|
||||||
|
throw Error('Unexpected generated dependency: ' + module);
|
||||||
|
}
|
||||||
|
}, { filename: builtPath });
|
||||||
|
state.begin = (id = '45', revision = '72') => {
|
||||||
|
const operation = exports.downloadReportPdf(id, revision);
|
||||||
|
operation.result = operation.done.then(message => ({ ok: true, message }), error => ({ ok: false, message: error.message }));
|
||||||
|
return operation;
|
||||||
|
};
|
||||||
|
state.finish = (bytes = pdf, status = 200, mime = 'application/pdf', header = {}) => {
|
||||||
|
state.headers({ header: { 'Content-Type': mime, 'Content-Length': String(bytes.length), ...header } });
|
||||||
|
const file = 'wxfile://tmp/isolated-' + (state.requests.length) + '.pdf';
|
||||||
|
state.files.set(file, bytes);
|
||||||
|
state.request.success({ tempFilePath: file, statusCode: status });
|
||||||
|
return file;
|
||||||
|
};
|
||||||
|
state.config = config;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejected(state, operation, message) {
|
||||||
|
const result = await operation.result;
|
||||||
|
assert.equal(result.ok, false); assert.match(result.message, message);
|
||||||
|
assert.equal(state.opened.length, 0, 'Invalid or stale file must never reach the viewer');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test(label, callback) {
|
||||||
|
await callback(); checks++; console.log('PASS: ' + label);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await test('authenticated historical download, bounded validation and preview menu', async () => {
|
||||||
|
const s = environment(), op = s.begin();
|
||||||
|
assert.equal(s.request.url, 'https://isolated.invalid/api/v1/assessments/45/report.pdf?revisionId=72');
|
||||||
|
assert.equal(s.request.header.Authorization, 'Bearer synthetic-local-only');
|
||||||
|
assert.ok(!s.request.url.includes(s.token)); assert.equal(s.request.timeout, 45000);
|
||||||
|
const file = s.finish();
|
||||||
|
assert.equal(s.opened[0].filePath, file); assert.equal(s.opened[0].fileType, 'pdf'); assert.equal(s.opened[0].showMenu, true);
|
||||||
|
assert.ok(s.reads.every(read => read.length <= 1024));
|
||||||
|
op.cancel(); assert.equal(s.aborted, 0, 'Native preview must survive parent onHide');
|
||||||
|
s.opened[0].success(); assert.equal((await op.result).ok, true);
|
||||||
|
assert.ok(s.files.has(file), 'Keep temporary file while previewing');
|
||||||
|
s.appShow.forEach(callback => callback()); assert.equal(s.files.size, 0, 'Remove file on return to app');
|
||||||
|
});
|
||||||
|
await test('default revision and next export cleanup', async () => {
|
||||||
|
const s = environment(), first = s.begin('45', ''); assert.ok(!s.request.url.includes('?'));
|
||||||
|
const file = s.finish(); s.opened[0].success(); await first.result;
|
||||||
|
const second = s.begin(); assert.ok(s.deleted.includes(file)); second.cancel(); await second.result;
|
||||||
|
});
|
||||||
|
for (const [id, revision] of [['0', '72'], ['45', '0'], ['45', '72&token=bad'], ['bad', '']]) {
|
||||||
|
await test('invalid assessment/revision ' + id + '/' + revision, async () => {
|
||||||
|
const s = environment(), op = s.begin(id, revision); await rejected(s, op, /编号无效/); assert.equal(s.requests.length, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await test('no session does not download', async () => { const s = environment(); s.token = ''; const op = s.begin(); await rejected(s, op, /登录已过期/); assert.equal(s.requests.length, 0); });
|
||||||
|
await test('HTTPS required for mini program', async () => { const s = environment(); s.config.API_BASE_URL = 'http://isolated.invalid'; const op = s.begin(); await rejected(s, op, /HTTPS/); assert.equal(s.requests.length, 0); });
|
||||||
|
await test('401 clears only the current session and deletes error file', async () => {
|
||||||
|
const s = environment(), op = s.begin(); s.finish(Buffer.from('{"msg":"expired"}'), 401, 'application/json');
|
||||||
|
await rejected(s, op, /登录已过期/); assert.equal(s.cleared, 1); assert.equal(s.files.size, 0);
|
||||||
|
});
|
||||||
|
await test('late 401 cannot clear another account', async () => {
|
||||||
|
const s = environment(), op = s.begin(); s.token = 'another-synthetic-token';
|
||||||
|
s.finish(Buffer.from('{"msg":"expired"}'), 401, 'application/json'); await rejected(s, op, /状态已变化/);
|
||||||
|
assert.equal(s.cleared, 0); assert.equal(s.token, 'another-synthetic-token'); assert.equal(s.files.size, 0);
|
||||||
|
});
|
||||||
|
for (const status of [404, 409, 503]) {
|
||||||
|
await test('JSON ' + status + ' is shown as error, never opened as PDF', async () => {
|
||||||
|
const s = environment(), op = s.begin(); s.finish(Buffer.from('{"msg":"报告暂不可用"}'), status, 'application/json; charset=utf-8');
|
||||||
|
await rejected(s, op, /报告暂不可用/); assert.equal(s.files.size, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await test('JSON with HTTP200 is not a PDF', async () => { const s = environment(), op = s.begin(); s.finish(Buffer.from('{"msg":"错误回包"}'), 200, 'application/json'); await rejected(s, op, /错误回包/); });
|
||||||
|
await test('HTML or incorrect MIME cannot reach viewer', async () => { const s = environment(), op = s.begin(); s.finish(pdf, 200, 'text/html'); await rejected(s, op, /暂不可下载/); });
|
||||||
|
await test('malformed JSON safely fails and cleans file', async () => { const s = environment(), op = s.begin(); s.finish(Buffer.from('{broken'), 503, 'application/json'); await rejected(s, op, /内容异常/); assert.equal(s.files.size, 0); });
|
||||||
|
for (const [label, bytes] of [['wrong PDF signature', Buffer.from('HTML!' + 'x'.repeat(100) + '%%EOF')], ['missing EOF', Buffer.from('%PDF-' + 'x'.repeat(100))], ['empty body', Buffer.alloc(0)], ['tiny body', Buffer.from('%PDF-%%EOF')]]) {
|
||||||
|
await test(label, async () => { const s = environment(), op = s.begin(); s.finish(bytes); await rejected(s, op, /不是完整PDF/); assert.equal(s.files.size, 0); });
|
||||||
|
}
|
||||||
|
await test('declared length mismatch rejects truncated binary', async () => { const s = environment(), op = s.begin(); s.finish(pdf, 200, 'application/pdf', { 'Content-Length': pdf.length + 1 }); await rejected(s, op, /不是完整PDF/); });
|
||||||
|
await test('compressed content length need not equal decoded file', async () => {
|
||||||
|
const s = environment(), op = s.begin(); s.finish(pdf, 200, 'application/pdf', { 'Content-Encoding': 'gzip', 'Content-Length': '500' }); s.opened[0].success(); assert.equal((await op.result).ok, true);
|
||||||
|
});
|
||||||
|
await test('oversize header aborts before file read', async () => {
|
||||||
|
const s = environment(), op = s.begin(); s.headers({ header: { 'Content-Length': maxSize + 1 } }); await rejected(s, op, /文件过大/); assert.equal(s.aborted, 1); assert.equal(s.reads.length, 0);
|
||||||
|
});
|
||||||
|
for (const progress of [{ totalBytesWritten: maxSize + 1, totalBytesExpectedToWrite: 0 }, { totalBytesWritten: 0, totalBytesExpectedToWrite: maxSize + 1 }]) {
|
||||||
|
await test('oversize progress aborts download', async () => { const s = environment(), op = s.begin(); s.progress(progress); await rejected(s, op, /文件过大/); assert.equal(s.aborted, 1); });
|
||||||
|
}
|
||||||
|
await test('oversize actual file rejected before reading', async () => { const s = environment(), op = s.begin(); s.size = maxSize + 1; s.finish(); await rejected(s, op, /文件过大/); assert.equal(s.reads.length, 0); });
|
||||||
|
await test('exactly 20MiB accepted with bounded reads', async () => {
|
||||||
|
const s = environment(), op = s.begin(), bytes = Buffer.alloc(maxSize, 'x');
|
||||||
|
bytes.write('%PDF-1.7\n'); bytes.write('%%EOF\n', maxSize - 6);
|
||||||
|
s.progress({ totalBytesWritten: maxSize, totalBytesExpectedToWrite: maxSize }); s.finish(bytes);
|
||||||
|
s.opened[0].success(); assert.equal((await op.result).ok, true); assert.equal(s.aborted, 0);
|
||||||
|
assert.equal(s.reads.reduce((sum, read) => sum + read.length, 0), 1029);
|
||||||
|
});
|
||||||
|
await test('case insensitive MIME and missing content length accepted', async () => {
|
||||||
|
const s = environment(), op = s.begin(); s.finish(pdf, 200, 'APPLICATION/PDF; charset=binary', { 'Content-Length': '' });
|
||||||
|
s.opened[0].success(); assert.equal((await op.result).ok, true);
|
||||||
|
});
|
||||||
|
await test('cancellation rejects immediately and cleans late successful response', async () => {
|
||||||
|
const s = environment(), op = s.begin(); op.cancel(); await rejected(s, op, /下载已取消/);
|
||||||
|
s.finish(); assert.equal(s.opened.length, 0); assert.equal(s.files.size, 0);
|
||||||
|
});
|
||||||
|
await test('account switch during progress aborts', async () => { const s = environment(), op = s.begin(); s.token = 'other'; s.progress({ totalBytesWritten: 1, totalBytesExpectedToWrite: 10 }); await rejected(s, op, /状态已变化/); assert.equal(s.aborted, 1); });
|
||||||
|
await test('account switch before preview success rejects and cleans', async () => {
|
||||||
|
const s = environment(), op = s.begin(); s.finish(); s.token = 'other'; s.opened[0].success();
|
||||||
|
const result = await op.result; assert.equal(result.ok, false); assert.match(result.message, /状态已变化/); assert.equal(s.files.size, 0);
|
||||||
|
});
|
||||||
|
await test('preview failure cleans file and does not report success', async () => {
|
||||||
|
const s = environment(), op = s.begin(); s.finish(); s.opened[0].fail(); const result = await op.result; assert.equal(result.ok, false); assert.match(result.message, /无法打开/); assert.equal(s.files.size, 0);
|
||||||
|
});
|
||||||
|
for (const flag of ['statFails', 'readFails']) {
|
||||||
|
await test('filesystem ' + flag + ' fails safely', async () => { const s = environment(), op = s.begin(); s[flag] = true; s.finish(); await rejected(s, op, /文件读取失败/); assert.equal(s.files.size, 0); });
|
||||||
|
}
|
||||||
|
for (const [reason, expected] of [['downloadFile:fail url not in domain list', /域名未配置/], ['downloadFile:fail timeout', /生成超时/], ['downloadFile:fail connection reset', /检查网络/]]) {
|
||||||
|
await test(reason, async () => { const s = environment(), op = s.begin(); s.request.fail({ errMsg: reason }); await rejected(s, op, expected); });
|
||||||
|
}
|
||||||
|
await test('temporary unlink failure is retried on foreground return', async () => {
|
||||||
|
const s = environment(), op = s.begin(); s.unlinkFails = true; s.finish(Buffer.from('bad')); await rejected(s, op, /不是完整PDF/);
|
||||||
|
assert.equal(s.files.size, 1); s.unlinkFails = false; s.appShow.forEach(callback => callback()); assert.equal(s.files.size, 0);
|
||||||
|
});
|
||||||
|
console.log(`PASS: ${checks} generated MP-WEIXIN PDF export checks. No network or database access.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
+107
-21
@@ -5,7 +5,7 @@ export type NewsArticle = { id: string, title: string, summary: string, category
|
|||||||
export type FinanceLesson = { id: string, title: string, desc: string, duration: string, tag: string, image: string, intro: string, topics: string[], chapters: ContentSection[], chapterCount: number, lessonVersion: string }
|
export type FinanceLesson = { id: string, title: string, desc: string, duration: string, tag: string, image: string, intro: string, topics: string[], chapters: ContentSection[], chapterCount: number, lessonVersion: string }
|
||||||
export type ArticlePage = { items: NewsArticle[], total: number, hasMore: boolean }
|
export type ArticlePage = { items: NewsArticle[], total: number, hasMore: boolean }
|
||||||
export type LessonPage = { items: FinanceLesson[], total: number, hasMore: boolean }
|
export type LessonPage = { items: FinanceLesson[], total: number, hasMore: boolean }
|
||||||
export type LessonState = { lessonVersion: string, completedChapterIds: string[] }
|
export type LessonState = { lessonId: string, lessonVersion: string, completedChapterIds: string[] }
|
||||||
export type ContentHome = { lessons: FinanceLesson[], articles: NewsArticle[] }
|
export type ContentHome = { lessons: FinanceLesson[], articles: NewsArticle[] }
|
||||||
export type ContentSearchResult = { id: string, type: string, title: string, summary: string, category: string, meta: string, image: string }
|
export type ContentSearchResult = { id: string, type: string, title: string, summary: string, category: string, meta: string, image: string }
|
||||||
export type SearchPage = { items: ContentSearchResult[], total: number, hasMore: boolean }
|
export type SearchPage = { items: ContentSearchResult[], total: number, hasMore: boolean }
|
||||||
@@ -15,44 +15,130 @@ function imageUrl(value: string): string {
|
|||||||
if (value.startsWith('/uploads/')) return API_BASE_URL.replace('/api/v1', '') + value
|
if (value.startsWith('/uploads/')) return API_BASE_URL.replace('/api/v1', '') + value
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
function sections(value: UTSJSONObject[]): ContentSection[] {
|
function contentObject(value: any | null): UTSJSONObject {
|
||||||
return value.map((item: UTSJSONObject): ContentSection => ({ id: item.getString('id') ?? '', title: item.getString('title') ?? '', body: item.getString('body') ?? '' }))
|
if (value == null || typeof value != 'object' || Array.isArray(value)) throw new Error('内容响应格式不完整,请重试')
|
||||||
|
return value as UTSJSONObject
|
||||||
|
}
|
||||||
|
function contentText(data: UTSJSONObject, key: string, empty: boolean = false): string {
|
||||||
|
const value = data.getString(key)
|
||||||
|
if (value == null || (!empty && value.trim().length == 0)) throw new Error('内容响应格式不完整,请重试')
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
function validContentId(id: string): boolean { return /^[1-9][0-9]{0,9}$/.test(id) && parseFloat(id) <= 2147483647 }
|
||||||
|
function requireContentId(id: string): void { if (!validContentId(id)) throw new Error('内容编号无效,请重新选择') }
|
||||||
|
function uniqueStrings(value: any | null): string[] {
|
||||||
|
if (value == null || !Array.isArray(value)) throw new Error('内容响应格式不完整,请重试')
|
||||||
|
const result: string[] = []
|
||||||
|
;(value as any[]).forEach((item: any) => {
|
||||||
|
if (typeof item != 'string' || result.includes(item as string)) throw new Error('内容响应格式不完整,请重试')
|
||||||
|
result.push(item as string)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
function sections(value: any | null, lesson: boolean): ContentSection[] {
|
||||||
|
if (value == null || !Array.isArray(value) || (value as any[]).length > 30) throw new Error('内容章节格式不完整,请重试')
|
||||||
|
const ids: string[] = []
|
||||||
|
return (value as any[]).map((value: any): ContentSection => {
|
||||||
|
const item = contentObject(value); const id = lesson ? contentText(item, 'id') : ''
|
||||||
|
if (lesson && (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(id) || ids.includes(id))) throw new Error('课程章节编号无效,请重试')
|
||||||
|
ids.push(id)
|
||||||
|
return { id, title: contentText(item, 'title'), body: contentText(item, 'body') }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
export function contentArticleFrom(data: UTSJSONObject): NewsArticle {
|
export function contentArticleFrom(data: UTSJSONObject): NewsArticle {
|
||||||
return { id: data.getString('id') ?? '', title: data.getString('title') ?? '', summary: data.getString('summary') ?? '', category: data.getString('category') ?? '',
|
contentObject(data); const id = contentText(data, 'id'); requireContentId(id)
|
||||||
date: data.getString('date') ?? '', source: data.getString('source') ?? '', readingTime: data.getString('readingTime') ?? '', image: imageUrl(data.getString('image') ?? ''),
|
return { id, title: contentText(data, 'title'), summary: contentText(data, 'summary'), category: contentText(data, 'category'),
|
||||||
sections: sections(data['sections'] as UTSJSONObject[]) }
|
date: contentText(data, 'date'), source: contentText(data, 'source'), readingTime: contentText(data, 'readingTime'), image: imageUrl(contentText(data, 'image', true)),
|
||||||
|
sections: sections(data['sections'], false) }
|
||||||
}
|
}
|
||||||
export function contentLessonFrom(data: UTSJSONObject): FinanceLesson {
|
export function contentLessonFrom(data: UTSJSONObject): FinanceLesson {
|
||||||
return { id: data.getString('id') ?? '', title: data.getString('title') ?? '', desc: data.getString('desc') ?? '', duration: data.getString('duration') ?? '', tag: data.getString('tag') ?? '',
|
contentObject(data); const id = contentText(data, 'id'); requireContentId(id)
|
||||||
image: imageUrl(data.getString('image') ?? ''), intro: data.getString('intro') ?? '', topics: data['topics'] as string[], chapters: sections(data['chapters'] as UTSJSONObject[]),
|
const chapterCount = data.getNumber('chapterCount'); const lessonVersion = contentText(data, 'lessonVersion')
|
||||||
chapterCount: data.getNumber('chapterCount') ?? 0, lessonVersion: data.getString('lessonVersion') ?? '' }
|
const topics = uniqueStrings(data['topics']); const chapters = sections(data['chapters'], true)
|
||||||
|
if (chapterCount == null || !Number.isInteger(chapterCount) || chapterCount < 1 || chapterCount > 30
|
||||||
|
|| !/^[a-f0-9]{64}$/.test(lessonVersion) || topics.length == 0 || topics.some((topic: string): boolean => !['credit','business','documents'].includes(topic))
|
||||||
|
|| (chapters.length > 0 && chapters.length != chapterCount)) throw new Error('课程响应格式不完整,请重试')
|
||||||
|
return { id, title: contentText(data, 'title'), desc: contentText(data, 'desc'), duration: contentText(data, 'duration'), tag: contentText(data, 'tag'),
|
||||||
|
image: imageUrl(contentText(data, 'image', true)), intro: contentText(data, 'intro'), topics, chapters, chapterCount, lessonVersion }
|
||||||
|
}
|
||||||
|
function pageItems(data: UTSJSONObject, page: number): UTSJSONObject[] {
|
||||||
|
const total = data.getNumber('total'); const more = data.getBoolean('hasMore'); const items = data['items']
|
||||||
|
if (data.getNumber('page') != page || data.getNumber('pageSize') != 20 || total == null || !Number.isInteger(total) || total < 0
|
||||||
|
|| more == null || more != (page * 20 < total) || items == null || !Array.isArray(items)
|
||||||
|
|| (items as any[]).length > 20 || (items as any[]).length > total) throw new Error('内容分页响应不完整,请重试')
|
||||||
|
return (items as any[]).map((item: any): UTSJSONObject => contentObject(item))
|
||||||
|
}
|
||||||
|
export function validateContentAppend(previous: string[], incoming: string[]): void {
|
||||||
|
const seen = previous.slice()
|
||||||
|
incoming.forEach((key: string) => {
|
||||||
|
if (seen.includes(key)) throw new Error('内容列表已变化,请刷新后重试')
|
||||||
|
seen.push(key)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
export async function fetchArticles(category: string, page: number = 1): Promise<ArticlePage> {
|
export async function fetchArticles(category: string, page: number = 1): Promise<ArticlePage> {
|
||||||
const data = await requestJson('/content/articles', 'GET', { category, page, pageSize: 20 })
|
const data = await requestJson('/content/articles', 'GET', { category, page, pageSize: 20 })
|
||||||
return { items: (data['items'] as UTSJSONObject[]).map((item: UTSJSONObject): NewsArticle => contentArticleFrom(item)), total: data.getNumber('total') ?? 0, hasMore: data.getBoolean('hasMore') ?? false }
|
const items = pageItems(data, page).map((item: UTSJSONObject): NewsArticle => contentArticleFrom(item))
|
||||||
|
validateContentAppend([], items.map((item: NewsArticle): string => item.id))
|
||||||
|
if (category.length > 0 && items.some((item: NewsArticle): boolean => item.category != category)) throw new Error('资讯分类响应不符,请重试')
|
||||||
|
return { items, total: data.getNumber('total')!, hasMore: data.getBoolean('hasMore')! }
|
||||||
|
}
|
||||||
|
export async function fetchArticle(id: string): Promise<NewsArticle> {
|
||||||
|
requireContentId(id)
|
||||||
|
const result = contentArticleFrom(await requestJson('/content/articles/' + id, 'GET', {}))
|
||||||
|
if (result.id != id || result.sections.length == 0) throw new Error('资讯详情响应不符,请重试')
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
export async function fetchArticle(id: string): Promise<NewsArticle> { return contentArticleFrom(await requestJson('/content/articles/' + encodeURIComponent(id), 'GET', {})) }
|
|
||||||
export async function fetchLessons(topic: string, page: number = 1): Promise<LessonPage> {
|
export async function fetchLessons(topic: string, page: number = 1): Promise<LessonPage> {
|
||||||
const data = await requestJson('/content/lessons', 'GET', { topic, page, pageSize: 20 })
|
const data = await requestJson('/content/lessons', 'GET', { topic, page, pageSize: 20 })
|
||||||
return { items: (data['items'] as UTSJSONObject[]).map((item: UTSJSONObject): FinanceLesson => contentLessonFrom(item)), total: data.getNumber('total') ?? 0, hasMore: data.getBoolean('hasMore') ?? false }
|
const items = pageItems(data, page).map((item: UTSJSONObject): FinanceLesson => contentLessonFrom(item))
|
||||||
|
validateContentAppend([], items.map((item: FinanceLesson): string => item.id))
|
||||||
|
if (topic.length > 0 && items.some((item: FinanceLesson): boolean => !item.topics.includes(topic))) throw new Error('课程场景响应不符,请重试')
|
||||||
|
return { items, total: data.getNumber('total')!, hasMore: data.getBoolean('hasMore')! }
|
||||||
|
}
|
||||||
|
export async function fetchLesson(id: string): Promise<FinanceLesson> {
|
||||||
|
requireContentId(id)
|
||||||
|
const result = contentLessonFrom(await requestJson('/content/lessons/' + id, 'GET', {}))
|
||||||
|
if (result.id != id || result.chapters.length != result.chapterCount) throw new Error('课程详情响应不符,请重试')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
function stateFrom(data: UTSJSONObject, id: string): LessonState {
|
||||||
|
const lessonId = contentText(data, 'lessonId'); const lessonVersion = contentText(data, 'lessonVersion')
|
||||||
|
const completedChapterIds = uniqueStrings(data['completedChapterIds'])
|
||||||
|
if (lessonId != id || !/^[a-f0-9]{64}$/.test(lessonVersion) || completedChapterIds.length > 30
|
||||||
|
|| completedChapterIds.some((chapter: string): boolean => !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(chapter))) throw new Error('课程进度响应不符,请刷新后重试')
|
||||||
|
return { lessonId, lessonVersion, completedChapterIds }
|
||||||
|
}
|
||||||
|
export function validateLessonState(lesson: FinanceLesson, state: LessonState): void {
|
||||||
|
if (state.lessonId != lesson.id || state.lessonVersion != lesson.lessonVersion
|
||||||
|
|| state.completedChapterIds.some((id: string): boolean => !lesson.chapters.some((chapter: ContentSection): boolean => chapter.id == id)))
|
||||||
|
throw new Error('课程或进度已更新,请刷新后继续学习')
|
||||||
|
}
|
||||||
|
export async function fetchLessonProgress(id: string): Promise<LessonState> {
|
||||||
|
requireContentId(id)
|
||||||
|
return stateFrom(await requestJson('/content/lessons/' + id + '/progress', 'GET', {}), id)
|
||||||
}
|
}
|
||||||
export async function fetchLesson(id: string): Promise<FinanceLesson> { return contentLessonFrom(await requestJson('/content/lessons/' + encodeURIComponent(id), 'GET', {})) }
|
|
||||||
function stateFrom(data: UTSJSONObject): LessonState { return { lessonVersion: data.getString('lessonVersion') ?? '', completedChapterIds: data['completedChapterIds'] as string[] } }
|
|
||||||
export async function fetchLessonProgress(id: string): Promise<LessonState> { return stateFrom(await requestJson('/content/lessons/' + id + '/progress', 'GET', {})) }
|
|
||||||
export async function completeLessonChapter(id: string, chapterId: string, lessonVersion: string): Promise<LessonState> {
|
export async function completeLessonChapter(id: string, chapterId: string, lessonVersion: string): Promise<LessonState> {
|
||||||
return stateFrom(await requestJson('/content/lessons/' + id + '/progress', 'PUT', { chapterId, lessonVersion, completed: true }))
|
requireContentId(id)
|
||||||
|
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(chapterId) || !/^[a-f0-9]{64}$/.test(lessonVersion)) throw new Error('课程章节或版本无效,请刷新')
|
||||||
|
const state = stateFrom(await requestJson('/content/lessons/' + id + '/progress', 'PUT', { chapterId, lessonVersion, completed: true }), id)
|
||||||
|
if (state.lessonVersion != lessonVersion || !state.completedChapterIds.includes(chapterId)) throw new Error('课程进度保存响应不符,请刷新后重试')
|
||||||
|
return state
|
||||||
}
|
}
|
||||||
export async function fetchContentHome(): Promise<ContentHome> {
|
export async function fetchContentHome(): Promise<ContentHome> {
|
||||||
const data = await requestJson('/content/home', 'GET', {})
|
const data = await requestJson('/content/home', 'GET', {})
|
||||||
return { lessons: (data['lessons'] as UTSJSONObject[]).map((item: UTSJSONObject): FinanceLesson => contentLessonFrom(item)), articles: (data['articles'] as UTSJSONObject[]).map((item: UTSJSONObject): NewsArticle => contentArticleFrom(item)) }
|
const lessons = data['lessons']; const articles = data['articles']
|
||||||
|
if (lessons == null || !Array.isArray(lessons) || articles == null || !Array.isArray(articles)) throw new Error('推荐内容响应不完整,请重试')
|
||||||
|
return { lessons: (lessons as any[]).map((item: any): FinanceLesson => contentLessonFrom(contentObject(item))), articles: (articles as any[]).map((item: any): NewsArticle => contentArticleFrom(contentObject(item))) }
|
||||||
}
|
}
|
||||||
export async function searchContent(keyword: string, type: string, page: number = 1): Promise<SearchPage> {
|
export async function searchContent(keyword: string, type: string, page: number = 1): Promise<SearchPage> {
|
||||||
const data = await requestJson('/content/search', 'GET', { keyword, type, page, pageSize: 20 })
|
const data = await requestJson('/content/search', 'GET', { keyword, type, page, pageSize: 20 })
|
||||||
const items = (data['items'] as UTSJSONObject[]).map((item: UTSJSONObject): ContentSearchResult => ({
|
const items = pageItems(data, page).map((item: UTSJSONObject): ContentSearchResult => {
|
||||||
id: item.getString('id') ?? '', type: item.getString('type') ?? '', title: item.getString('title') ?? '', summary: item.getString('summary') ?? '',
|
const id = contentText(item, 'id'); const kind = contentText(item, 'type'); requireContentId(id)
|
||||||
category: item.getString('category') ?? '', meta: item.getString('meta') ?? '', image: imageUrl(item.getString('image') ?? '') }))
|
if (!['news','lesson'].includes(kind) || (type != 'all' && kind != type)) throw new Error('搜索分类响应不符,请重试')
|
||||||
return { items, total: data.getNumber('total') ?? 0, hasMore: data.getBoolean('hasMore') ?? false }
|
return { id, type: kind, title: contentText(item, 'title'), summary: contentText(item, 'summary'), category: contentText(item, 'category'), meta: contentText(item, 'meta'), image: imageUrl(contentText(item, 'image', true)) }
|
||||||
|
})
|
||||||
|
validateContentAppend([], items.map((item: ContentSearchResult): string => item.type + ':' + item.id))
|
||||||
|
return { items, total: data.getNumber('total')!, hasMore: data.getBoolean('hasMore')! }
|
||||||
}
|
}
|
||||||
export function emptyArticle(): NewsArticle { return { id: '', title: '', summary: '', category: '', date: '', source: '', readingTime: '', image: '', sections: [] } }
|
export function emptyArticle(): NewsArticle { return { id: '', title: '', summary: '', category: '', date: '', source: '', readingTime: '', image: '', sections: [] } }
|
||||||
export function emptyLesson(): FinanceLesson { return { id: '', title: '', desc: '', duration: '', tag: '', image: '', intro: '', topics: [], chapters: [], chapterCount: 0, lessonVersion: '' } }
|
export function emptyLesson(): FinanceLesson { return { id: '', title: '', desc: '', duration: '', tag: '', image: '', intro: '', topics: [], chapters: [], chapterCount: 0, lessonVersion: '' } }
|
||||||
|
|||||||
Reference in New Issue
Block a user