feat: 完成原型04-10全链路功能开发,包括AI助手、PDF导出等

This commit is contained in:
hujun
2026-09-28 17:50:46 +08:00
parent 7c252fc730
commit 79feb03b19
27 changed files with 1083 additions and 180 deletions
+15 -1
View File
@@ -1,11 +1,15 @@
# AI 融资移动端 # AI 融资移动端
基于 uni-app x、Vue 3、UTS 的移动端原型,包含 26 个页面。使用 HBuilderX(VDOM 模式)开发,已验证 5.24 和 5.26 的本地编译;本项目没有独立的 npm 构建入口。 基于 uni-app x、Vue 3、UTS 的移动端原型,包含 27 个页面。使用 HBuilderX(VDOM 模式)开发,已验证 5.24 和 5.26 的本地编译;本项目没有独立的 npm 构建入口。
2026-09-28:原型07产品对比已接通,匹配列表/本次产品详情可选择2—3项历史产品,支持补选、移除、刷新恢复、失败列重试及手机横向滚动。复用本人冻结匹配详情,不新增表或模型调用;当前26页,真实产品与跨平台验收仍待完成。见[开发进度6.16](docs/开发进度.md)。 2026-09-28:原型07产品对比已接通,匹配列表/本次产品详情可选择2—3项历史产品,支持补选、移除、刷新恢复、失败列重试及手机横向滚动。复用本人冻结匹配详情,不新增表或模型调用;当前26页,真实产品与跨平台验收仍待完成。见[开发进度6.16](docs/开发进度.md)。
2026-09-28:原型09报告已改为“评估概况/匹配结果/改善建议”,支持栏目URL刷新、最多3项历史候选、分组产品依据与局部重试;重新评估进入空白个人/企业表单,旧报告不补算。PDF、上下文助手与行动页继续独立开发。验证见[开发进度6.17](docs/开发进度.md)。 2026-09-28:原型09报告已改为“评估概况/匹配结果/改善建议”,支持栏目URL刷新、最多3项历史候选、分组产品依据与局部重试;重新评估进入空白个人/企业表单,旧报告不补算。PDF、上下文助手与行动页继续独立开发。验证见[开发进度6.17](docs/开发进度.md)。
2026-09-28:原型10下一步行动已接通报告/产品入口,支持材料准备、查看已存报告、优化资料后同类型空白重评和上下文AI咨询;登录返回与当前可用性守卫已验证。H5 38项检查、Android/微信编译通过;09 PDF待开发,真实产品/真机待验收。见[开发进度6.20](docs/开发进度.md)。
2026-09-28:09报告与10行动页已接入普通H5 PDF下载:鉴权、冻结数据、中文分页、脱敏及失败重试;29项文件检查、20项HTTP及24项浏览器检查通过,Android/微信编译通过。微信内浏览器/原生下载与生产容量待验收。见[开发进度6.21](docs/开发进度.md)。
## 本地运行 ## 本地运行
2026-09-24:本地产品库已按用户要求上架6条演示产品(ID22—27)。新评估会冻结目录、计算三态及证据并保存匹配结果;报告已展示实际统计和演示标记,旧报告不补算。匹配列表/详情API和03/04/05/06基础已接通,可从新报告进入本次匹配;07—10扩展待开发。数据、脚本与参数见[产品规则契约第7节](docs/产品匹配规则开发契约.md),验收进度见[开发进度6.12](docs/开发进度.md)。 2026-09-24:本地产品库已按用户要求上架6条演示产品(ID22—27)。新评估会冻结目录、计算三态及证据并保存匹配结果;报告已展示实际统计和演示标记,旧报告不补算。匹配列表/详情API和03/04/05/06基础已接通,可从新报告进入本次匹配;07—10扩展待开发。数据、脚本与参数见[产品规则契约第7节](docs/产品匹配规则开发契约.md),验收进度见[开发进度6.12](docs/开发进度.md)。
@@ -145,3 +149,13 @@ $projectPath = (Get-Location).Path
“我的融资档案”恢复个人/企业切换、完整度与四组资料卡;默认读取该类型最新提交,支持历史分页选择与只读展开。完整度使用所选成功报告中的冻结结果,旧版/未完成/无记录显示未评定说明;底部可查看同次报告或发起空白新评估。不恢复草稿,不编辑历史资料。后端沿用GET /assessments的type过滤及详情/报告接口,验证范围见docs/开发进度.md第6.14节。 “我的融资档案”恢复个人/企业切换、完整度与四组资料卡;默认读取该类型最新提交,支持历史分页选择与只读展开。完整度使用所选成功报告中的冻结结果,旧版/未完成/无记录显示未评定说明;底部可查看同次报告或发起空白新评估。不恢复草稿,不编辑历史资料。后端沿用GET /assessments的type过滤及详情/报告接口,验证范围见docs/开发进度.md第6.14节。
2026-09-28:个人中心入口去重,顶部为“开始评估”(选择个人/企业)和“最近报告”(本人最近提交且已完成的报告直达),下方“我的融资档案”管理历史资料。无报告但有任务时可查看进度,无记录时明确提示;不重新提交或调用模型。验证范围见开发进度6.15。 2026-09-28:个人中心入口去重,顶部为“开始评估”(选择个人/企业)和“最近报告”(本人最近提交且已完成的报告直达),下方“我的融资档案”管理历史资料。无报告但有任务时可查看进度,无记录时明确提示;不重新提交或调用模型。验证范围见开发进度6.15。
## 2026-09-28 原型04/05与单产品解释
已按原型还原产品匹配详情,接通逐条DeepSeek解释、状态查询、失败重试和历史保存。新增POST /api/v1/assessments/:id/matches/:productId/explanation,body仅requestId,返回匹配详情中的异步状态;GET不调用模型。复用fa_ai_chat匹配JSON和fa_ai_chat_log,无新表/迁移。analysis:work新增消费解释任务,更新代码后重启进程;成功解释重复打开不收费,失败须明确新键重试,每产品最多三次,与报告共享每日用户额度。
原型08上下文咨询、09 PDF、10行动页和真实产品验收仍待完成。51项解释回归、70项匹配HTTP/快照检查及真实Flash/浏览器验证详见前端docs/开发进度.md第6.18节;模型凭据只从后端配置读取。
## 2026-09-28 原型08上下文AI助手
已接普通/报告/产品/对比的持久会话、消息分页、异步回复、原键重试和登录返回,界面参考原型08。需保持后端analysis:work运行;进入页面、点击快捷问题不调用模型,明确发送才预留调用。后端本机增量迁移已获授权并完成;原报告未改写。助手70项及相关回归通过,Android/微信编译与H5手机/桌面联调通过;真实多轮DeepSeek语义、真机和生产容量待验收。详见[助手契约](docs/上下文AI助手开发契约.md)及[开发进度6.19](docs/开发进度.md)。
+53
View File
@@ -0,0 +1,53 @@
# 原型08上下文AI助手
版本1.0,2026-09-28。CHAT-001—003完成本地接口和页面联调,待验收;CHAT-004异常与额度本地检查通过,真实DeepSeek多轮语义、移动端真机与商业额度仍待验收。
## 1. 会话与事实边界
- 复用`fa_ai_chat`/`fa_ai_chat_log`,不新增业务表。助手始终`scene=assistant`、`assessment_id=NULL`、`analysis_context=NULL`。
- `context_assessment_id`可空,仅引用本人成功评估;复合外键`(context_assessment_id,user_id)`防跨账户绑定。`context_data`保存版本、创建幂等键、最多3个产品ID和冻结事实摘要,不接收客户端报告正文。
- 普通、报告、单产品、2—3项对比均可进入。服务端读取冻结报告、匹配理由和证据,白名单排除姓名、手机、证件、附件、职业及自由用途说明;产品名称/数字/判定采用评估时版本,不读取当前产品替换历史事实。
- 会话固定上下文。URL指定的评估及产品须与chatId一致;上下文删除、失效或摘要变化则拒绝。后续单产品AI解释和当前上下架状态不参与摘要,不影响会话恢复。
- 模型仅解释,不改变规则结论,不生成匹配率、获批概率或授信承诺。系统约束和输出格式校验不等同于语义完全正确,正式上线仍需真实多轮人工验收。
## 2. 已实现接口
均为会员Bearer、`Cache-Control: no-store`、FastAdmin响应格式。业务成功HTTP200不表示异步模型完成。
| 方法与路径(前缀/api/v1) | 参数 | 返回data |
| --- | --- | --- |
| GET /assistant/context | contextAssessmentId可空字符串、contextProductIds逗号列表 | 上下文标题、产品名/实际状态、快捷问题和说明;不创建会话、不调用模型 |
| GET /assistant/chats | limit字符串1—30,默认20;cursor可空 | 本人普通会话items、hasMore、nextCursor;按创建ID倒序 |
| POST /assistant/chats | requestId;contextAssessmentId字符串或null;contextProductIds字符串数组 | chatId、title、context;只创建会话,不调用模型 |
| GET /assistant/chats/:id | 可选contextAssessmentId/contextProductIds,同preview格式 | chatId、title、context;传入上下文时严格核对一致 |
| GET /assistant/chats/:id/messages | limit字符串1—30,默认20;cursor可空 | chatId、items、hasMore、nextCursor;首屏最新20轮,页内按时间正序 |
| POST /assistant/chats/:id/messages | requestId、message、retryAttempt整数0—2 | chatId、item;queued/running/completed/failed |
requestId为8—64位字母、数字、下划线、短横线。message为1—800字纯文本。拒绝额外参数、无评估产品列表、重复产品或超过3项产品。创建同键不同绑定、发送同键不同正文均409;越权或不存在404,未完成报告409,未配置模型503,超额度429,非法参数422。
每轮DTO为`{id,requestId,message,reply,status,attempt,canRetry,createdAt}`,reply仅成功时非空。消息内部调用行、错误码、凭据、用量不透传页面。加密游标绑定用户及会话,不能跨会话使用。
## 3. 异步、重试与额度
- 用户明确发送后,在用户行锁及会话行锁中保存用户消息并预留assistant调用行。`ast:`前缀调用日志是持久待办,既有`analysis:work`在报告和产品解释队列空闲时消费。
- 每会话最多100轮、同一时间只有一条回复在排队/执行。每用户每天最多新建30个会话。模型调用与报告和产品解释共享`deepseek.daily_calls_per_user`(本地默认20),失败也占预留次数,防止故障重试无限计费。
- 第一次retryAttempt=0。HTTP回执丢失必须原requestId/正文/重试序号重放,不增加调用。已确认失败后,用户手动重试保持requestId、retryAttempt递增,最多3次;只能重试最近一轮,成功和运行中均不能重跑。
- 每次尝试独立assistant日志,reply_id指向同一用户消息。保留所有尝试用量;tokens未知保持null,不把失败当零消耗。MySQL可重复读下关键幂等、序号和额度查询使用锁定当前读,避免旧快照绕过串行用户锁。
- 运行租约`max(180秒,供应商timeout+60秒)`。超时由worker恢复扫描标失败,不自动收费重试;迟到响应只能补原调用用量,不能改成成功或覆盖新尝试。
- 默认Flash,复用既有DeepSeekClient。上下文保留最近6轮成功问答(失败不发送),回复最长1200字,总请求序列化最多96KB;用户主动输入的手机号、身份证及长银行卡号脱敏后才发送模型。不声称自由文本已完全去除所有个人信息。
## 4. 页面与返回
机器人、问候气泡、浅蓝快捷问题及底部输入栏参考原型08,复用现有素材;上下文卡为必要增补。普通模式不假定已有报告。快捷问题按实际判定生成,点击仅填入问题,点击发送才请求模型。
首次发送创建会话并在H5地址中保存chatId,刷新重新鉴权恢复;历史对话入口可恢复普通或上下文会话。消息3秒轮询,最多60次,超限/查询失败提供继续查询。失败明确提示并支持原键重试。消息、报告不落本机持久缓存;离页清理渲染和轮询,Token变化拒绝迟到回包并清除内容。
04/05咨询入口保留产品ID,07保留产品选择,09带本次评估;navigateBack优先保持原页面状态。冷启动的返回产品/对比/报告仅由白名单来源与编号重建;普通模式回首页。登录页只支持白名单助手返回,拒绝任意returnUrl;登录后再次校验归属。
## 5. 迁移与验证
本轮用户明确授权本机迁移后,执行后端`database/migrate_assistant_context.py --apply --worker-stopped`。新增2列、索引、归属外键与场景检查约束共5项;复跑预检0项。备份位于`D:/AAdesk/202609/airz-backups/assistant-context-20260928-154651-271907/`。恢复优先回退助手代码并保留可空列,不整表覆盖后续业务数据,详见后端`database/assistant-context-migration.md`。
后端70项助手检查、51项产品解释、31项任务、36项评估HTTP、70项匹配HTTP/快照检查通过。HBuilderX5.26 Android与微信编译通过。H5 Chromium390×844、1280×900验证普通/上下文、三入口、发送/刷新/失败重试、回执丢失、23轮历史分页、上下文冲突、跨账号及真实密码登录返回;模型输出使用假模型,未新发收费DeepSeek调用。最终正常流程无业务console.error/warn。
临时截图/脚本:`C:/Users/JUN/AppData/Local/Temp/airz-assistant-20260928/`。合成账号/评估/会话/日志/令牌/队列已清理,隔离服务和浏览器已关闭。原有2份评估会话及2条调用记录逐条对照迁移前备份未改写。未验收:真实多轮模型语义、真实产品人工金标准、微信内H5/Safari、真机键盘/安全区以及生产守护/容量。
+55
View File
@@ -241,3 +241,58 @@ productSnapshot包含产品ID/版本/名称/机构/类型,amountMinYuan、amou
报告入口携带assessmentId;列表三组统计与分页来自同次结果;卡片只预览最多三条主要理由,详情保留全部不符/待核实理由,已满足项可展开。无评估编号时选择历史或重新评估;非法编号明确错误,绝不读取最近结果。详情无assessmentId只展示公共资料,有assessmentId始终读取本人快照。页面离开清空结果,响应返回时校验当前Token和请求序号,防止旧账户迟到响应恢复界面。 报告入口携带assessmentId;列表三组统计与分页来自同次结果;卡片只预览最多三条主要理由,详情保留全部不符/待核实理由,已满足项可展开。无评估编号时选择历史或重新评估;非法编号明确错误,绝不读取最近结果。详情无assessmentId只展示公共资料,有assessmentId始终读取本人快照。页面离开清空结果,响应返回时校验当前Token和请求序号,防止旧账户迟到响应恢复界面。
本轮`tests/match_records.php --worker-stopped`62项验证接口身份/方法、三组、游标分页和篡改、快照归属、未知键投影、损坏数据、下架/过期/删除/改价后历史、旧版报告、重复读取无副作用。产品变化只在测试连接事务内发生并回滚,随机账户及队列清理;停止正式worker以避免恢复扫描拾取合成评估。浏览器结果及截图见开发进度6.13。 本轮`tests/match_records.php --worker-stopped`62项验证接口身份/方法、三组、游标分页和篡改、快照归属、未知键投影、损坏数据、下架/过期/删除/改价后历史、旧版报告、重复读取无副作用。产品变化只在测试连接事务内发生并回滚,随机账户及队列清理;停止正式worker以避免恢复扫描拾取合成评估。浏览器结果及截图见开发进度6.13。
## 9. 单产品解释任务与原型04/05(2026-09-28已实现,覆盖第7/8节的未接入状态)
### 9.1 接口与状态
`POST /api/v1/assessments/:id/matches/:productId/explanation`要求会员Bearer Token、本人成功且未删除评估及该产品冻结结果,Content-Type为application/json。请求只允许`{"requestId":"explanation_20260928_001"}`;requestId为8—64位ASCII字母/数字/下划线/短横线,无金额或模型参数。重复读取GET不生成解释。
HTTP200沿用`{code:1,msg,time,data}`,data与匹配详情相同,不表示模型已经完成;客户端检查item.aiExplanation.status。queued/running期间轮询原GET匹配详情。例:
```json
{
"status": "completed",
"text": "该项信息来自自填资料,仍需核对证明材料。",
"evidenceRefs": ["product:22:rule:base_amount"],
"reasonExplanations": [{
"reasonId": "product:22:rule:base_amount:reason",
"text": "该项信息来自自填资料,仍需核对证明材料。",
"evidenceRefs": ["product:22:rule:base_amount"]
}],
"generatedAt": "2026-09-28T07:07:00+00:00",
"errorCode": null,
"canRetry": false
}
```
上例只说明结构,不是完整产品样例。text/evidenceRefs兼容已有消费者;逐条渲染使用reasonExplanations,数值/来源仍取确定性证据,不能从模型文本反解析金额。首次not_requested;queued/running保留空文本/数组;失败为failed,errorCode仅固定错误码,canRetry表示未超过三次上限;成功为completed并带生成时间。有效的旧not_requested对象缺新增字段时,前端按空值兼容。
| HTTP/错误 | 含义与行为 |
| --- | --- |
| 401 AUTH_REQUIRED | 会话失效;客户端清理并回登录 |
| 404 ASSESSMENT_NOT_FOUND / MATCH_NOT_FOUND | 非本人、已删记录或产品不在本次快照;不回退公共产品 |
| 409 REPORT_NOT_READY / MATCH_RESULT_UNAVAILABLE | 报告未成功或存储结构损坏,不调用模型 |
| 422 VALIDATION_ERROR | 非法requestId或多余请求字段 |
| 503 ANALYSIS_NOT_CONFIGURED | 模型服务未配置,不预留新调用 |
| 429 AI_DAILY_LIMIT | 与报告共用当日用户预留额度,开发默认二十次 |
| 409 AI_ATTEMPT_LIMIT | 每评估每产品最多三次尝试,不再创建任务 |
模型/网络失败写入状态,不使用供应商异常正文:AI_TIMEOUT、AI_NETWORK、AI_AUTH、AI_CONFIGURATION、AI_BALANCE、AI_RATE_LIMIT、AI_PROVIDER_ERROR、AI_INVALID_RESPONSE、AI_INVALID_REPORT、AI_TASK_INTERRUPTED等。用户可读状态与规则理由仍保留;不自动重新调用收费模型。
### 9.2 持久化、幂等和恢复
- fa_ai_chat.match_result中的单项ai_explanation保存状态、reason_explanations及生成时间;内部还保存task_id/request_keys/attempts/lease_until。只更新这一个对象,不修改冻结参数、证据、判定、排序、报告或评估生命周期。不同产品更新同一JSON时使用会话行锁,防止相互覆盖。
- fa_ai_chat_log使用role=assistant、request_id前缀`pex:`、pending/streaming/completed/failed以及已存在的模型/Token/耗时/错误字段。用户行锁串行预算预留;会话锁串行序号和快照写入。数据库事务原子保存任务和快照状态,不新增业务表/索引/迁移。
- 相同requestId重放返回当前状态;queued/running期间不同键也合并为同一任务;成功后不同键返回历史解释。明确failed后用户使用新键重试,最多三次。次数包含失败和不确定超时,防止无限收费。
- analysis:work继续优先处理既有报告队列,空闲时领取一条持久解释日志;pending任务无需Redis投递,进程重启不会丢失。租约为max(180秒,供应商超时+60秒),streaming过期改失败;`--recover-only`也回收解释租约,不调用模型。服务停机期间queued保持等待,恢复进程后继续消费。
- 多worker用日志状态和会话锁只允许一次领取。迟到回复不得回写已失效任务,已发生用量仍补记原日志。删除评估/禁用账户后不保存成功解释。运营需关注pending积压、失败错误码和调用用量;当前未实现独立解释后台管理页。
- 分页摘要排除ai_explanation,解释生成不使产品分页失效;其他冻结字段仍参与摘要并校验。部署时已有旧摘要游标可收到一次MATCH_CURSOR_INVALID,重新从首屏读取即可,不改历史报告。
### 9.3 模型边界与页面
使用已配置deepseek-v4-flash-0731,只发该产品规则理由、字段白名单、actual/expected/source/outcome,不发送姓名、电话、证件、附件、机构说明自由文本或完整报告。模型输出仅reasons数组,每项reasonId/text;按ID集合检验恰好覆盖所有理由,无重复、遗漏和外来ID,恢复规则顺序后由服务端附原证据引用。每条最多九十字,数字/金额/期限/HTML/明显授信承诺等拦截。确定性校验不能替代真实产品人工语义金标准,正式机构资料仍待验收。
页面生成期间可离开,重新打开读取保存状态;每三秒查询、最多六十次,断网/轮询结束提供手动查询,不重提任务。页面离开清理个人数据和计时器;迟到回包校验Token/请求序号/可见性。初始依据、AI解释失败、已保存解释可区分;已有数值仍以万元精确展示。布局与有意差异见原型方案第8节。
验证入口:`php tests/product_explanations.php --worker-stopped`(51项,假模型);可选`--live`额外调用一次真实Flash。`php tests/match_records.php --worker-stopped`更新为70项,含解释POST方法、归属、参数、删除记录和幂等回执。必须先停止worker,测试仅清理其随机账号/日志/评估/队列。真实模型和浏览器验证记录见开发进度6.18。
+26 -12
View File
@@ -1,11 +1,11 @@
# 原型04—10页面改造与跳转方案 # 原型04—10页面改造与跳转方案
版本:1.3。日期:2026-09-28。依据:[原型图](原型.jpg)、[开发文档](开发文档.md)、[数据契约](需求与评估结果数据契约.md)。本轮用户明确要求补齐04—10页面并随后进入产品匹配开发;此前“对比/PDF另行排期”的范围限制,自本方案起由下列正式任务覆盖。 版本:1.7。日期:2026-09-28。依据:[原型图](原型.jpg)、[开发文档](开发文档.md)、[数据契约](需求与评估结果数据契约.md)。本轮用户明确要求补齐04—10页面并随后进入产品匹配开发;此前“对比/PDF另行排期”的范围限制,自本方案起由下列正式任务覆盖。
## 1. 范围与落地原则 ## 1. 范围与落地原则
- 04、05是同一产品匹配详情的不同判定状态,06是同一详情页的产品信息栏;复用现有product/product,不机械新增三个页面。 - 04、05是同一产品匹配详情的不同判定状态,06是同一详情页的产品信息栏;复用现有product/product,不机械新增三个页面。
- 07产品对比、10下一步行动新增两个融资分包页面。compare已注册,当前pages.json共26页;next-step仍待开发注册,最终目标27页。 - 07产品对比、10下一步行动新增两个融资分包页面。compare与next-step均已注册,当前pages.json共27页。
- 08复用assistant/assistant;09复用report/report并改为“评估概况/匹配结果/改善建议”。03匹配列表作为04—10的必要入口一并改造。 - 08复用assistant/assistant;09复用report/report并改为“评估概况/匹配结果/改善建议”。03匹配列表作为04—10的必要入口一并改造。
- 保留蓝白主色、卡片、银行标识、正反理由分组和清晰底部行动区。数据必须来自本人本次评估;原型的92%/B+不作为默认值。 - 保留蓝白主色、卡片、银行标识、正反理由分组和清晰底部行动区。数据必须来自本人本次评估;原型的92%/B+不作为默认值。
- 首版使用既有三态准入:符合已知条件、条件不符、待核实。score=null,不展示匹配百分比;稳定顺序采用productId升序并注明不代表优劣。真实评分、运营优先级另发版本化策略。 - 首版使用既有三态准入:符合已知条件、条件不符、待核实。score=null,不展示匹配百分比;稳定顺序采用productId升序并注明不代表优劣。真实评分、运营优先级另发版本化策略。
@@ -17,13 +17,13 @@
| 原型 | 页面实现 | 页面内容与行为 | 任务 | 当前状态 | | 原型 | 页面实现 | 页面内容与行为 | 任务 | 当前状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 04 单家银行AI解释 | product/product,tab=explanation,eligible状态 | 银行/产品名、条件判定、已满足条件、注意事项、证据来源、AI解释、对比/准备申请 | PRODUCT-004 | 基础已接规则理由/证据/历史快照及对比;单产品AI解释和行动页待开发 | | 04 单家银行AI解释 | product/product,tab=explanation,eligible状态 | 银行/产品名、条件判定、已满足条件、注意事项、证据来源、AI解释、对比/准备申请 | PRODUCT-004 | 布局按原型还原;逐条DeepSeek解释/持久任务/失败重试本地验证通过;准备入口已接10,真实产品/平台待验收 |
| 05 其他银行不推荐解释 | 同一页面,ineligible或unknown状态 | 不符条件与待核实项分组;已满足部分称“已满足条件”;查看资料/询问AI,不建议直接申请 | PRODUCT-004 | 已接不符/待核实及证据;AI咨询待接;服务端判定决定状态 | | 05 其他银行不推荐解释 | 同一页面,ineligible或unknown状态 | 不符条件与待核实项分组;已满足部分称“已满足条件”;查看资料/询问AI,不建议直接申请 | PRODUCT-004 | 已接不符/待核实、逐条解释和证据;AI咨询已接08上下文;服务端判定决定状态 |
| 06 产品详情 | product/product,tab=details或conditions | 额度/期限/年化利率范围、还款方式、材料、渠道、适用对象、来源及版本;独立显示当前可用性 | PRODUCT-003、PRODUCT-004 | 已接三栏、公共/历史双模式及当前可用性;真实机构资料待录入 | | 06 产品详情 | product/product,tab=details或conditions | 额度/期限/年化利率范围、还款方式、材料、渠道、适用对象、来源及版本;独立显示当前可用性 | PRODUCT-003、PRODUCT-004 | 已接三栏、公共/历史双模式及当前可用性;真实机构资料待录入 |
| 07 同类产品对比 | 新增compare/compare | 同一评估2—3个产品列式对比,首列指标固定,移动端横向滚动;条件判定、金额/利率/期限、材料、差异依据 | PRODUCT-005 | 基础完成,本地联调通过;真实产品及平台待验收 | | 07 同类产品对比 | 新增compare/compare | 同一评估2—3个产品列式对比,首列指标固定,移动端横向滚动;条件判定、金额/利率/期限、材料、差异依据 | PRODUCT-005 | 基础完成,本地联调通过;真实产品及平台待验收 |
| 08 AI助手延伸问答 | assistant/assistant | 本次评估及选定产品上下文卡、按实际判定生成快捷问题、持久会话、分页、重试、返回报告/产品 | CHAT-001—004 | 页面已有;真实上下文问答待接 | | 08 AI助手延伸问答 | assistant/assistant | 本次评估及选定产品上下文卡、按实际判定生成快捷问题、持久会话、分页、重试、返回报告/产品 | CHAT-001—004 | 页面已有;持久上下文/异步问答/分页重试已本地联调,真实语义与真机待验收 |
| 09 我的融资报告 | report/report | 三栏内容、资料准备状态、完整度、最多三个候选及查看更多、改善项、PDF下载、重新评估、下一步行动 | REPORT-004、REPORT-005 | 三栏/历史候选/改善证据基础已联调;PDF及行动入口待独立任务 | | 09 我的融资报告 | report/report | 三栏内容、资料准备状态、完整度、最多三个候选及查看更多、改善项、PDF下载、重新评估、下一步行动 | REPORT-004、REPORT-005 | 三栏/历史候选/改善证据基础已联调;行动入口已接10,PDF普通H5基础完成,跨浏览器/真机待验收 |
| 10 下一步行动 | 新增next-step/next-step | 准备申请、查看已保存报告、下载PDF、优化资料、咨询AI、回首页;依结果决定可用行动 | REPORT-006 | 新增任务,未开始 | | 10 下一步行动 | 新增next-step/next-step | 准备申请、查看已保存报告、下载PDF、优化资料、咨询AI、回首页;依结果决定可用行动 | REPORT-006 | 本地联调完成,38项H5检查与Android/微信编译通过;真实产品与平台待验收 |
### 03 匹配列表入口(必要配套) ### 03 匹配列表入口(必要配套)
@@ -41,7 +41,7 @@
产品快照中的额度/利率/期限保持评估时版本。读取时发现当前产品下架/过期,另加状态条并禁用准备申请;查询当前状态失败也不能假定可用。允许查看历史解释、参与带状态标识的历史对比或咨询AI。 产品快照中的额度/利率/期限保持评估时版本。读取时发现当前产品下架/过期,另加状态条并禁用准备申请;查询当前状态失败也不能假定可用。允许查看历史解释、参与带状态标识的历史对比或咨询AI。
金额以万元范围展示,期限以月、年化利率以百分比范围展示。缺还款方式/审批时长/渠道等资料时显示“未提供”,不得采用原型示例值。银行Logo只使用已提供或可合法使用的真实素材;缺失时用文字简称,不生造银行标志。 金额以万元范围展示,期限以月、年化利率以百分比范围展示。缺还款方式/审批时长/渠道等资料时显示“未提供”,不得采用原型示例值。银行Logo只使用已提供或可合法使用的真实素材;缺失时用文字简称或既有通用机构图标,不生造银行标志。
“准备申请”仅当eligible且currentAvailability=available时进入10的准备区域;“查看同类产品对比”进入07并保留当前产品选择;“我有疑问,咨询AI”进入08,展示上下文而不自动发送消息或调用模型。 “准备申请”仅当eligible且currentAvailability=available时进入10的准备区域;“查看同类产品对比”进入07并保留当前产品选择;“我有疑问,咨询AI”进入08,展示上下文而不自动发送消息或调用模型。
@@ -58,7 +58,7 @@
- 有assessmentId时,只使用本人已成功报告和指定产品的冻结理由;可不指定产品、指定单项或指定对比的2—3项。 - 有assessmentId时,只使用本人已成功报告和指定产品的冻结理由;可不指定产品、指定单项或指定对比的2—3项。
- 快捷问题按实际状态生成,例如“这些条件为什么符合”“哪些条件不满足”“还需要核实哪些资料”“两项产品有哪些差异”;无匹配时不出现“为什么推荐这家银行”。 - 快捷问题按实际状态生成,例如“这些条件为什么符合”“哪些条件不满足”“还需要核实哪些资料”“两项产品有哪些差异”;无匹配时不出现“为什么推荐这家银行”。
- 输入问题后由用户发送;仅打开页面不创建模型请求。失败重试保持消息requestId,切换账号清理页面内容。 - 输入问题后由用户发送;仅打开页面不创建模型请求。失败重试保持消息requestId,切换账号清理页面内容。
- 普通会话继续使用scene=assistant,assessment_id=NULL;计划随CHAT-001增量迁移新增context_assessment_id及context_data,前者保存本人评估引用,后者保存产品ID及报告摘要引用。当前库尚无这两列,本轮不迁移;已有普通会话两列为NULL,新增归属索引/约束需先检查历史数据,不复用或覆盖正式评估的analysis_context。 - 普通会话继续使用scene=assistant,assessment_id=NULL;CHAT-001已增量迁移新增context_assessment_id及context_data,前者保存本人评估引用,后者保存产品ID及报告摘要引用。2026-09-28已获用户授权完成两列、归属索引/外键及场景约束迁移;原有记录新增列为NULL,不复用或覆盖正式评估的analysis_context。
- chatId恢复时以服务端已保存上下文为准;URL与会话上下文冲突拒绝,不静默改绑定。失效上下文提示重新选择,不能回答成另一份报告。 - chatId恢复时以服务端已保存上下文为准;URL与会话上下文冲突拒绝,不静默改绑定。失效上下文提示重新选择,不能回答成另一份报告。
### 09 报告三栏与PDF ### 09 报告三栏与PDF
@@ -136,9 +136,9 @@ flowchart TD
| GET /api/v1/assessments/:id/matches | 已实现 | 03/09,三态分页、默认limit=3;既有契约字段不变 | | GET /api/v1/assessments/:id/matches | 已实现 | 03/09,三态分页、默认limit=3;既有契约字段不变 |
| GET /api/v1/assessments/:id/matches/:productId | 已实现 | 04/05/06,以及07每次最多读取三项;本人成功记录、本次冻结产品,404不得回退公共数据 | | GET /api/v1/assessments/:id/matches/:productId | 已实现 | 04/05/06,以及07每次最多读取三项;本人成功记录、本次冻结产品,404不得回退公共数据 |
| GET /api/v1/products/:id | 已实现,会员Bearer | 06公共模式;不含个人判定及理由,当前资料与历史快照分开 | | GET /api/v1/products/:id | 已实现,会员Bearer | 06公共模式;不含个人判定及理由,当前资料与历史快照分开 |
| POST /api/v1/assistant/chats | 计划新增 | contextAssessmentId:string或null、contextProductIds:string[]、requestId;服务端校验同一评估和产品快照,不接客户端报告正文 | | POST /api/v1/assistant/chats | 已实现,见助手契约 | contextAssessmentId:string或null、contextProductIds:string[]、requestId;服务端校验同一评估和产品快照,不接客户端报告正文 |
| GET/POST /api/v1/assistant/chats/:id/messages | 计划新增 | 08消息分页、提交与幂等;上下文由会话固定 | | GET/POST /api/v1/assistant/chats/:id/messages | 已实现,见助手契约 | 08消息分页、提交与幂等;上下文由会话固定 |
| GET /api/v1/assessments/:id/report.pdf | 计划新增,REPORT-005 | Bearer鉴权;成功application/pdf,Content-Disposition安全文件名、no-store;401/404/409/503为JSON错误;失败不生成报告或调用模型 | | GET /api/v1/assessments/:id/report.pdf | 已实现,REPORT-005待验收 | Bearer鉴权;成功application/pdf,Content-Disposition安全文件名、no-store;401/404/409/503为JSON错误;失败不生成报告或调用模型 |
PDF接口owner校验先于渲染,未完成返回409 REPORT_NOT_READY,格式不支持409 REPORT_FORMAT_UNAVAILABLE,能力不可用503 REPORT_EXPORT_UNAVAILABLE。仅由受控模板渲染,用户文本转义,禁止任意HTML/外部图片URL触发文件或网络读取。客户端按状态码和Content-Type区分PDF与JSON,不把错误响应另存为PDF。 PDF接口owner校验先于渲染,未完成返回409 REPORT_NOT_READY,格式不支持409 REPORT_FORMAT_UNAVAILABLE,能力不可用503 REPORT_EXPORT_UNAVAILABLE。仅由受控模板渲染,用户文本转义,禁止任意HTML/外部图片URL触发文件或网络读取。客户端按状态码和Content-Type区分PDF与JSON,不把错误响应另存为PDF。
@@ -205,3 +205,17 @@ report/report的overview/matches/improvements已接现有报告与匹配只读
匹配栏默认eligible前3项,改善栏分别读取ineligible/unknown前3项,各有完整列表和产品详情入口,不把摘要当全部结果。关联产品证据直接展示规则理由、字段值和来源;AI改善文字保留报告中的字段/准备依据标签。读取时核对报告与列表的版本、目录标识及三态统计,异常保留其他可用区块并单独重试。 匹配栏默认eligible前3项,改善栏分别读取ineligible/unknown前3项,各有完整列表和产品详情入口,不把摘要当全部结果。关联产品证据直接展示规则理由、字段值和来源;AI改善文字保留报告中的字段/准备依据标签。读取时核对报告与列表的版本、目录标识及三态统计,异常保留其他可用区块并单独重试。
“重新评估”选择主体后清空流程内存进入空白表单,历史报告不编辑。PDF、下一步行动及上下文问答按钮待对应能力完成后接入。26页、63任务编号和历史估时不变;REPORT-004整体保持开发中,具体验证/剩余边界见[开发进度6.17](开发进度.md)。 “重新评估”选择主体后清空流程内存进入空白表单,历史报告不编辑。PDF、下一步行动及上下文问答按钮待对应能力完成后接入。26页、63任务编号和历史估时不变;REPORT-004整体保持开发中,具体验证/剩余边界见[开发进度6.17](开发进度.md)。
## 8. 2026-09-28 原型04/05还原记录
- 已实现:白底、紧凑机构头部、状态标签、三栏蓝色选中线、绿色/橙色编号、理由标题和灰色解释、双按钮底栏。将重复“资料尚需核验”从长标题移至说明和证据,避免窄屏标题折断;全部原始证据仍可展开。
- 数据差异:无匹配百分比;只展示有依据的真实数量,不凑三正三反;不符/未知产品的绿色标题使用“已满足的条件”;机构资料尚为明确标注的演示数据,使用通用图标。
- 操作差异:立即申请暂用“查看资料准备”,只切申请条件栏;05的咨询按钮待08上下文问答完成后接入,当前为可用的产品对比入口。生成AI解释为独立明确点击,成功后逐条补充且重新打开不重复生成。
- 栏目切换替换当前路由,保留assessmentId/productId/productIds/from;刷新停留原栏,退出页面停止轮询并清理个人数据。默认每三秒读状态,最多六十次,失败或停止后可手动重新查询;不发起新模型任务。
- 视觉/功能验证:Chromium 390×844、390×1000、1280×900;参考图原始单页约220×568像素,已查看原图和放大局部,并用接近其纵横比的390×1000屏幕检查。记录见开发进度6.18,不代表微信内浏览器或真机验收。
2026-09-28原型08实施补充:创建/详情/历史/上下文预览和消息API、重试序号及迁移恢复以[上下文AI助手开发契约](上下文AI助手开发契约.md)为准。CHAT-001—003本地联调后待验收,CHAT-004真实语义/生产容量仍开发中。
### 2026-09-28 PDF落地补充
09顶部与10页共用鉴权下载组件,服务端内存生成并输出二进制,无公开文件。Dompdf3.1.6及Noto Sans SC中文字体独立锁定;逐字段脱敏、转义和远程读取禁用。普通H5真实下载,微信内/原生/小程序提示使用普通浏览器。29项模板、20项HTTP、24项受控浏览器检查通过,平台/容量待验收;详见进度6.21。
+43 -4
View File
@@ -1,9 +1,37 @@
# AI 融资平台开发文档 # AI 融资平台开发文档
> 版本:v1.14 · 基线日期:2026-09-22 · 更新日期:2026-09-28 · 状态:匹配分页/详情及公共产品详情已接通;03/04/05/06基础接入,07对比与09报告三栏本地联调通过;08助手、09PDF、10行动待开发 > 版本:v1.18 · 基线日期:2026-09-22 · 更新日期:2026-09-28 · 状态:匹配分页/详情及公共产品详情已接通;04/05按原型还原并接通单产品DeepSeek解释,07对比与09报告三栏本地联调通过;08上下文助手本地联调通过,真实多轮语义/真机待验收;10行动本地联调通过;09PDF普通H5本地验证通过,平台/容量待验收
> 配套台账:[开发进度](开发进度.md)。本文定义范围、接口和验收口径,台账记录实际执行与证据。 > 配套台账:[开发进度](开发进度.md)。本文定义范围、接口和验收口径,台账记录实际执行与证据。
> 文档初始规划后已开始开发;以下更新记录优先于原始基线。除明确列为已实现的部分外,计划接口和增量数据仍待开发。 > 文档初始规划后已开始开发;以下更新记录优先于原始基线。除明确列为已实现的部分外,计划接口和增量数据仍待开发。
### 0.20 2026-09-28 原型09报告PDF导出
REPORT-005完成本地实施,状态待验收。09报告顶部与10行动页接入同一下载组件;用户点击后才以Bearer请求`GET /api/v1/assessments/:id/report.pdf`,不提供永久公开链接、不重跑模型、不修改已存报告或产品。27页、63项任务不变。
后端复用本人成功报告与全部冻结匹配快照,模板包含需求、概况、结构化事实、完整度/准备状态、匹配三态/产品理由、建议、版本、时间和免责声明;旧v1不补造v2指标。排除身份、联系方式、企业名和自由填写内容,纯文本转义;引擎禁止远程资源、脚本和任意文件读取。精确元字符串转万元,PDF中文字体嵌入,页脚编号、长理由分页。
导出引擎Dompdf3.1.6及传递依赖单独锁定在后端extend/report-pdf,中文Noto Sans SC Sans2.004固定400字重、字体来源/哈希/许可入库;根依赖不变,无迁移。成功响应application/pdf、no-store/private、安全文件名;401/404/409/503为JSON。引擎缺失/关闭明确503,不生成空文件。
H5检查状态、MIME及PDF首尾标识后创建Blob下载;失败可重试且保留报告,离页取消、换账号拒绝迟到文件并清空旧报告,401白名单回登录。首版普通H5开放;微信内浏览器提示“在浏览器打开并重新登录下载”,App/小程序明确未开放,不假报成功。真实Safari/微信/原生下载未验收。
29项PDF检查、20项真实HTTP检查、24项浏览器检查及Android/微信编译通过,样本渲染和文字提取通过;证据与局限见开发进度6.21。下一步为08真实多轮语义、真实产品样例及跨浏览器/真机验收,生产导出容量仍需评估。
### 0.19 2026-09-28 原型10下一步行动
REPORT-006页面与入口已完成本地联调,状态待验收。新增`packages/finance/next-step/next-step`,当前27个页面、63项稳定任务。界面按原型采用白底、蓝色图标、四个行动行、重要提示和首页按钮;关键守卫与业务边界有中文注释。
本人成功报告与冻结产品只读展示;仅eligible且当前available展示申请准备材料,进入/返回/刷新重新读取状态,快照不一致时关闭准备。未选产品回本次匹配,不符/待核实/不可用可查看报告、优化资料与咨询。材料与来源使用冻结数据,渠道未提供时明确说明,不提交银行申请、不增加PDF假操作。
优化资料先展示待补/待核实项与已存建议,再清空流程内存进入同类型空白表单;不覆盖历史。AI咨询携带评估和可选产品,进入不发送。支持白名单登录返回、归属复验、离页清空及迟到响应隔离;冷启动返回本次报告,首页reLaunch。
H5 Chromium手机/桌面38项检查与最终Android/微信编译通过;详见进度6.20。复用已有API,未改后端业务、未迁移、未调用收费模型。下一步为09 PDF;08真实多轮语义、真实产品、微信内H5/Safari和真机仍未验收。
### 0.18 2026-09-28 原型08上下文AI助手
CHAT-001—003已实现持久会话、消息分页、固定评估/产品上下文、异步回复、原键重试和页面接入,状态待验收;CHAT-004本地异常/额度检查通过,整体仍开发中。普通问答及04/05产品、07对比、09报告入口已接通,进入页面不调用模型,快捷问题只填入输入框。关键实现增加中文注释,保留26个页面和63项任务。
经本轮用户明确授权,后端fa_ai_chat新增context_assessment_id/context_data及归属约束,正式assessment_id/analysis_context和已有报告不改写。模型调用默认Flash,复用analysis:work,助手/报告/产品解释共用每日预留额度。完整接口、重试、迁移恢复和验证见[上下文AI助手开发契约](上下文AI助手开发契约.md)。下一步为原型10及09 PDF;08真实多轮语义/真机与生产容量仍需验收。
## 0. 2026-09-22 实施更新 ## 0. 2026-09-22 实施更新
- 用户最新决定:删除fa_user_token,会员Token仅存Redis;已备份并删除本地表,当前25张表。fa_user和各业务关系保持不变。运行配置不再依赖MySQL Token驱动。 - 用户最新决定:删除fa_user_token,会员Token仅存Redis;已备份并删除本地表,当前25张表。fa_user和各业务关系保持不变。运行配置不再依赖MySQL Token驱动。
@@ -176,6 +204,16 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
继续无草稿。“重新评估”选择个人/企业并清空当前流程内存,进入空白填写页;只有再次正式提交才创建新assessmentId。页面仍26个,未改后端业务、未迁移、未调用收费模型。PDF(REPORT-005)、下一步行动(REPORT-006)与上下文助手按独立任务推进,未展示虚假可用按钮。REPORT-004整体仍开发中,真实产品/外部渠道与微信内H5、真机尚未验收;证据见开发进度6.17。 继续无草稿。“重新评估”选择个人/企业并清空当前流程内存,进入空白填写页;只有再次正式提交才创建新assessmentId。页面仍26个,未改后端业务、未迁移、未调用收费模型。PDF(REPORT-005)、下一步行动(REPORT-006)与上下文助手按独立任务推进,未展示虚假可用按钮。REPORT-004整体仍开发中,真实产品/外部渠道与微信内H5、真机尚未验收;证据见开发进度6.17。
### 0.17 2026-09-28 原型04/05与单产品AI解释
本轮优先完成04、05,复用product/product的eligible/ineligible/unknown三种状态。按原型恢复紧凑机构信息、AI解释/产品详情/申请条件三栏、绿色序号理由、橙色注意事项和底部双按钮;额度/年化/期限移入产品详情。每组默认最多三条,可展开全部和逐条依据。非候选的绿色区域称“已满足的条件”,不把部分满足理解为推荐。无真实Logo时沿用通用机构图标,演示产品保留标记。
新增已实现POST `/api/v1/assessments/:id/matches/:productId/explanation`,Bearer鉴权,JSON体仅`requestId:string`(8—64位字母/数字/下划线/短横线);HTTP200回传与GET匹配详情相同的data,异步状态为queued/running/completed/failed,首次未请求为not_requested。GET只读;仅点击生成/重试调用POST,不自动重跑评估。状态和逐条解释保存在fa_ai_chat.match_result,fa_ai_chat_log预留并记录调用,无新表和迁移。完整接口与故障边界见[产品匹配契约第9节](产品匹配规则开发契约.md)。
复用analysis:work消费持久调用日志;同产品并发/成功缓存/相同请求键重放不重复调用,每评估每产品最多三次尝试,与报告共用每日用户调用额度。租约失效明确失败,手动新键重试;旧回复只补用量,不覆盖新任务。DeepSeek仅解释该产品冻结理由,逐条引用由服务端绑定;模型输出允许调整顺序,按理由ID校验无缺失/重复/外来项后恢复规则顺序。禁止模型输出数值与授信承诺。
已完成本地验证,PRODUCT-004整体仍开发中;原工时/依赖与63个任务编号不变。原型08上下文咨询和10行动页未完成,当前底部为产品对比、产品详情及资料准备说明,不提供假咨询/假申请。真实产品/人工业务金标准、微信内H5/Safari及真机仍待验收。
## 1. 项目目标与现状 ## 1. 项目目标与现状
### 1.1 已确认范围 ### 1.1 已确认范围
@@ -190,7 +228,7 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
| 项目 | 当前事实 | 规划用途 | | 项目 | 当前事实 | 规划用途 |
| --- | --- | --- | | --- | --- | --- |
| 前端 | uni-app x / Vue 3 / UTS,26 页,无独立 npm 构建入口 | 沿用 HBuilderX 和现有分包,不重新搭建前端 | | 前端 | uni-app x / Vue 3 / UTS,27 页,无独立 npm 构建入口 | 沿用 HBuilderX 和现有分包,不重新搭建前端 |
| 后端 | FastAdmin / ThinkPHP 5.0.28 / PHP 7.4 | 复用认证、后台、模型与队列依赖,新增业务 API/服务 | | 后端 | FastAdmin / ThinkPHP 5.0.28 / PHP 7.4 | 复用认证、后台、模型与队列依赖,新增业务 API/服务 |
| 数据库 | MySQL 8.0.44,删除空 Token 表后实际 25 张 InnoDB 表 | 已核对本地库并更新结构快照;恢复点见进度第0节 | | 数据库 | MySQL 8.0.44,删除空 Token 表后实际 25 张 InnoDB 表 | 已核对本地库并更新结构快照;恢复点见进度第0节 |
| 当前前端模式 | 开发环境已连接本地 v1 API,模拟请求开关关闭 | 密码账户流程已接入,内容与产品匹配等页面仍有原型数据,不按页面数计算完成度 | | 当前前端模式 | 开发环境已连接本地 v1 API,模拟请求开关关闭 | 密码账户流程已接入,内容与产品匹配等页面仍有原型数据,不按页面数计算完成度 |
@@ -217,9 +255,9 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
## 2. 页面与功能覆盖 ## 2. 页面与功能覆盖
路由以 [pages.json](../pages.json) 为准。以下覆盖现有全部 26 页;反馈表单、历史列表等优先补入所属现有页面,若交互确需新增页面,在所属分包注册并同步台账。 路由以 [pages.json](../pages.json) 为准。以下覆盖现有全部 27 页;反馈表单、历史列表等优先补入所属现有页面,若交互确需新增页面,在所属分包注册并同步台账。
原型07路由`packages/finance/compare/compare`(PRODUCT-005)已注册,现共26页;`packages/finance/next-step/next-step`(REPORT-006)待开发注册,目标共27页。04/05/06均复用product/product,报告PDF不另加页面。参数与返回矩阵见专项页面方案第3节。 原型07路由`packages/finance/compare/compare`(PRODUCT-005)与10路由`packages/finance/next-step/next-step`(REPORT-006)均已注册,现共27页。04/05/06均复用product/product,报告PDF不另加页面。参数与返回矩阵见专项页面方案第3节。
| 序号 | 页面路由 | 功能职责 | 主要任务 | | 序号 | 页面路由 | 功能职责 | 主要任务 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -249,6 +287,7 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
| 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 |
## 3. 数据与架构约定 ## 3. 数据与架构约定
+125 -18
View File
@@ -1,6 +1,8 @@
# AI 融资平台开发进度 # AI 融资平台开发进度
基线日期:2026-09-22。文档版本:1.14。最新规划及实施见0.16节与6.17节;此前章节保留阶段记录。技术方案和接口说明见[开发文档](./开发文档.md)。本台账现有63项任务,编号与开发文档保持一致。 基线日期:2026-09-22。文档版本:1.18。最新规划及实施见0.20节与6.21节;此前章节保留阶段记录。技术方案和接口说明见[开发文档](./开发文档.md)。本台账现有63项任务,编号与开发文档保持一致。
会话交接(2026-09-28):已整理[新会话交接](新会话交接.md),包含当前成果、验证边界、业务约定、代码入口和环境状态。原型08、10及09 PDF已完成本地实施,当前边界见0.18—0.20与6.19—6.21节;下一步为真实模型语义、产品与跨平台验收。
## 0. 2026-09-22 首轮实施更新(优先于初始基线) ## 0. 2026-09-22 首轮实施更新(优先于初始基线)
@@ -175,6 +177,46 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
继续无草稿。“重新评估”选择个人/企业并清空当前流程内存,进入空白填写页;只有再次正式提交才创建新assessmentId。页面仍26个,未改后端业务、未迁移、未调用收费模型。PDF(REPORT-005)、下一步行动(REPORT-006)与上下文助手按独立任务推进,未展示虚假可用按钮。REPORT-004整体仍开发中,真实产品/外部渠道与微信内H5、真机尚未验收;证据见开发进度6.17。 继续无草稿。“重新评估”选择个人/企业并清空当前流程内存,进入空白填写页;只有再次正式提交才创建新assessmentId。页面仍26个,未改后端业务、未迁移、未调用收费模型。PDF(REPORT-005)、下一步行动(REPORT-006)与上下文助手按独立任务推进,未展示虚假可用按钮。REPORT-004整体仍开发中,真实产品/外部渠道与微信内H5、真机尚未验收;证据见开发进度6.17。
### 0.17 2026-09-28 原型04/05与单产品AI解释
本轮优先完成04、05,复用product/product的eligible/ineligible/unknown三种状态。按原型恢复紧凑机构信息、AI解释/产品详情/申请条件三栏、绿色序号理由、橙色注意事项和底部双按钮;额度/年化/期限移入产品详情。每组默认最多三条,可展开全部和逐条依据。非候选的绿色区域称“已满足的条件”,不把部分满足理解为推荐。无真实Logo时沿用通用机构图标,演示产品保留标记。
新增已实现POST `/api/v1/assessments/:id/matches/:productId/explanation`,Bearer鉴权,JSON体仅`requestId:string`(8—64位字母/数字/下划线/短横线);HTTP200回传与GET匹配详情相同的data,异步状态为queued/running/completed/failed,首次未请求为not_requested。GET只读;仅点击生成/重试调用POST,不自动重跑评估。状态和逐条解释保存在fa_ai_chat.match_result,fa_ai_chat_log预留并记录调用,无新表和迁移。完整接口与故障边界见[产品匹配契约第9节](产品匹配规则开发契约.md)。
复用analysis:work消费持久调用日志;同产品并发/成功缓存/相同请求键重放不重复调用,每评估每产品最多三次尝试,与报告共用每日用户调用额度。租约失效明确失败,手动新键重试;旧回复只补用量,不覆盖新任务。DeepSeek仅解释该产品冻结理由,逐条引用由服务端绑定;模型输出允许调整顺序,按理由ID校验无缺失/重复/外来项后恢复规则顺序。禁止模型输出数值与授信承诺。
已完成本地验证,PRODUCT-004整体仍开发中;原工时/依赖与63个任务编号不变。原型08上下文咨询和10行动页未完成,当前底部为产品对比、产品详情及资料准备说明,不提供假咨询/假申请。真实产品/人工业务金标准、微信内H5/Safari及真机仍待验收。
### 0.18 2026-09-28 原型08上下文AI助手
CHAT-001—003已实现持久会话、消息分页、固定评估/产品上下文、异步回复、原键重试和页面接入,状态待验收;CHAT-004本地异常/额度检查通过,整体仍开发中。普通问答及04/05产品、07对比、09报告入口已接通,进入页面不调用模型,快捷问题只填入输入框。关键实现增加中文注释,保留26个页面和63项任务。
经本轮用户明确授权,后端fa_ai_chat新增context_assessment_id/context_data及归属约束,正式assessment_id/analysis_context和已有报告不改写。模型调用默认Flash,复用analysis:work,助手/报告/产品解释共用每日预留额度。完整接口、重试、迁移恢复和验证见[上下文AI助手开发契约](上下文AI助手开发契约.md)。下一步为原型10及09 PDF;08真实多轮语义/真机与生产容量仍需验收。
### 0.19 2026-09-28 原型10下一步行动
REPORT-006页面与入口已完成本地联调,状态待验收。新增`packages/finance/next-step/next-step`,当前27个页面、63项稳定任务。界面按原型采用白底、蓝色图标、四个行动行、重要提示和首页按钮;关键守卫与业务边界有中文注释。
本人成功报告与冻结产品只读展示;仅eligible且当前available展示申请准备材料,进入/返回/刷新重新读取状态,快照不一致时关闭准备。未选产品回本次匹配,不符/待核实/不可用可查看报告、优化资料与咨询。材料与来源使用冻结数据,渠道未提供时明确说明,不提交银行申请、不增加PDF假操作。
优化资料先展示待补/待核实项与已存建议,再清空流程内存进入同类型空白表单;不覆盖历史。AI咨询携带评估和可选产品,进入不发送。支持白名单登录返回、归属复验、离页清空及迟到响应隔离;冷启动返回本次报告,首页reLaunch。
H5 Chromium手机/桌面38项检查与最终Android/微信编译通过;详见进度6.20。复用已有API,未改后端业务、未迁移、未调用收费模型。下一步为09 PDF;08真实多轮语义、真实产品、微信内H5/Safari和真机仍未验收。
### 0.20 2026-09-28 原型09报告PDF导出
REPORT-005完成本地实施,状态待验收。09报告顶部与10行动页接入同一下载组件;用户点击后才以Bearer请求`GET /api/v1/assessments/:id/report.pdf`,不提供永久公开链接、不重跑模型、不修改已存报告或产品。27页、63项任务不变。
后端复用本人成功报告与全部冻结匹配快照,模板包含需求、概况、结构化事实、完整度/准备状态、匹配三态/产品理由、建议、版本、时间和免责声明;旧v1不补造v2指标。排除身份、联系方式、企业名和自由填写内容,纯文本转义;引擎禁止远程资源、脚本和任意文件读取。精确元字符串转万元,PDF中文字体嵌入,页脚编号、长理由分页。
导出引擎Dompdf3.1.6及传递依赖单独锁定在后端extend/report-pdf,中文Noto Sans SC Sans2.004固定400字重、字体来源/哈希/许可入库;根依赖不变,无迁移。成功响应application/pdf、no-store/private、安全文件名;401/404/409/503为JSON。引擎缺失/关闭明确503,不生成空文件。
H5检查状态、MIME及PDF首尾标识后创建Blob下载;失败可重试且保留报告,离页取消、换账号拒绝迟到文件并清空旧报告,401白名单回登录。首版普通H5开放;微信内浏览器提示“在浏览器打开并重新登录下载”,App/小程序明确未开放,不假报成功。真实Safari/微信/原生下载未验收。
29项PDF检查、20项真实HTTP检查、24项浏览器检查及Android/微信编译通过,样本渲染和文字提取通过;证据与局限见开发进度6.21。下一步为08真实多轮语义、真实产品样例及跨浏览器/真机验收,生产导出容量仍需评估。
## 1. 项目状态与维护规则 ## 1. 项目状态与维护规则
当前已具备25个页面原型、部分本机交互、优化后的数据库结构和评估内部生命周期服务;密码账户基础已完成本地前后端联调,短信、评估、报告、匹配等完整业务仍待完成。AI供应商确定为DeepSeek,模型已确定为deepseek-v4-pro-0813和deepseek-v4-flash-0731,两个模型权限及自填报告已真实验证,商业预算、余额及生产容量仍待核验;其他服务商待定。 当前已具备25个页面原型、部分本机交互、优化后的数据库结构和评估内部生命周期服务;密码账户基础已完成本地前后端联调,短信、评估、报告、匹配等完整业务仍待完成。AI供应商确定为DeepSeek,模型已确定为deepseek-v4-pro-0813和deepseek-v4-flash-0731,两个模型权限及自填报告已真实验证,商业预算、余额及生产容量仍待核验;其他服务商待定。
@@ -244,9 +286,9 @@ P0 表示核心流程、安全、首发外部能力或发布门槛;P1 表示
| 编译与页面金额检查 | 上一轮 Web 与 Android 编译通过;手机 390×844、桌面 1280×900 浏览器检查金额输入、超限、确认、模拟提交和产品显示 | [前端 README](../README.md)、[页面注册](../pages.json);前序任务会话验证记录 | 不是短信/AI/上传真实联调;无 iOS/Xcode 或 Android 真机上线结论;独立历史日志路径未留档,后续回归须新增证据 | | 编译与页面金额检查 | 上一轮 Web 与 Android 编译通过;手机 390×844、桌面 1280×900 浏览器检查金额输入、超限、确认、模拟提交和产品显示 | [前端 README](../README.md)、[页面注册](../pages.json);前序任务会话验证记录 | 不是短信/AI/上传真实联调;无 iOS/Xcode 或 Android 真机上线结论;独立历史日志路径未留档,后续回归须新增证据 |
| 迁移与恢复基础 | 本机迁移及仓库外备份已完成 | [数据库迁移说明](D:/AAdesk/202609/airz/database/README.md) | 旧迁移限定业务空表,不得直接用于已有业务数据环境;未完成通用部署和恢复演练 | | 迁移与恢复基础 | 本机迁移及仓库外备份已完成 | [数据库迁移说明](D:/AAdesk/202609/airz/database/README.md) | 旧迁移限定业务空表,不得直接用于已有业务数据环境;未完成通用部署和恢复演练 |
### 2.2 全部 26 个页面的验收归属 ### 2.2 全部 27 个页面的验收归属
页面路径以[pages.json](../pages.json)为准。下表记录页面真实业务的主要收口任务;其内部依赖仍需全部完成。当前 26 页均未通过一期真实业务验收。 页面路径以[pages.json](../pages.json)为准。下表记录页面真实业务的主要收口任务;其内部依赖仍需全部完成。当前 27 页均未通过一期真实业务验收。
| 页面 | 注册路径 | 主要任务 | 当前状态 | | 页面 | 注册路径 | 主要任务 | 当前状态 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -257,9 +299,10 @@ P0 表示核心流程、安全、首发外部能力或发布门槛;P1 表示
| 分析中 | packages/finance/analyzing/analyzing | REPORT-004 | 按assessmentId轮询状态、失败重试、成功后打开真实报告 | | 分析中 | packages/finance/analyzing/analyzing | REPORT-004 | 按assessmentId轮询状态、失败重试、成功后打开真实报告 |
| 评估报告 | packages/finance/report/report | REPORT-004 | 三栏已接本人冻结报告、匹配候选与改善证据;真实业务及平台验收未完成 | | 评估报告 | packages/finance/report/report | REPORT-004 | 三栏已接本人冻结报告、匹配候选与改善证据;真实业务及平台验收未完成 |
| 匹配结果 | packages/finance/matches/matches | PRODUCT-004、REPORT-004 | 已接本人冻结结果、三态/分页及对比选择;演示产品联调 | | 匹配结果 | packages/finance/matches/matches | PRODUCT-004、REPORT-004 | 已接本人冻结结果、三态/分页及对比选择;演示产品联调 |
| 产品详情 | packages/finance/product/product | PRODUCT-004 | 已接历史理由/证据和参数、对比入口;单产品AI解释待开发 | | 产品详情 | 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项冻结对比本地联调通过,真实产品与平台待验收 |
| AI 助手 | packages/finance/assistant/assistant | CHAT-004 | 模拟对话 | | 下一步行动 | packages/finance/next-step/next-step | REPORT-006 | 四项行动与上下文/返回本地联调通过,真实产品及平台待验收 |
| AI 助手 | packages/finance/assistant/assistant | CHAT-004 | 持久上下文对话已本地联调,真实语义/真机待验收 |
| 融资档案 | 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 | 未完成真实认证 |
@@ -432,7 +475,7 @@ 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 | 全栈开发(待指定) | 产品用途/地区/额度/期限有真实依据;规则可配置并启停,版本变化留依据;公共详情无个人分数。 | 发布预检及模型保存已实现;本地6条演示产品已授权上架,重复录入不覆盖;公共详情已接;真实资料/发布验收、结构化编辑和公共列表待完成 | 2026-09-24 | | PRODUCT-003 | 全栈开发(待指定) | 产品用途/地区/额度/期限有真实依据;规则可配置并启停,版本变化留依据;公共详情无个人分数。 | 发布预检及模型保存已实现;本地6条演示产品已授权上架,重复录入不覆盖;公共详情已接;真实资料/发布验收、结构化编辑和公共列表待完成 | 2026-09-24 |
| PRODUCT-004 | 全栈开发(待指定) | 每条解释可追溯且不凑三正三反;当前产品变化不改历史;跨账户拒绝;unavailable/零候选/failed不混淆。 | 匹配分页/详情和03/04/05/06基础已接;62项HTTP/快照检查及H5验证见6.13;单产品AI解释、真实业务验收待完成 | 2026-09-24 | | PRODUCT-004 | 全栈开发(待指定) | 每条解释可追溯且不凑三正三反;当前产品变化不改历史;跨账户拒绝;unavailable/零候选/failed不混淆。 | 原型04/05和逐条DeepSeek解释本地完成;51项任务/输出检查、70项HTTP/快照检查与浏览器验证见6.18;真实机构资料及平台验收待完成 | 2026-09-28 |
| PRODUCT-005 | 全栈开发(待指定) | 不跨评估/混用现价;缺值不同于0;手机横向表格可读,单项失败明确;不重跑模型。 | compare页面及6.16本地编译/真实读取与异常注入证据;真实机构产品和微信内H5/真机待验收 | 2026-09-28 | | PRODUCT-005 | 全栈开发(待指定) | 不跨评估/混用现价;缺值不同于0;手机横向表格可读,单项失败明确;不重跑模型。 | compare页面及6.16本地编译/真实读取与异常注入证据;真实机构产品和微信内H5/真机待验收 | 2026-09-28 |
剩余门槛:DEC-MATCH开发口径已采用既有契约,真实产品规则/金标准业务签收待完成;PRODUCT-003真实发布仍依赖DEC-PRODUCT。纯规则、后台模型预检及字段编译已实施,未解除整模块验收依赖。 剩余门槛:DEC-MATCH开发口径已采用既有契约,真实产品规则/金标准业务签收待完成;PRODUCT-003真实发布仍依赖DEC-PRODUCT。纯规则、后台模型预检及字段编译已实施,未解除整模块验收依赖。
@@ -447,8 +490,8 @@ 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 | 待验收 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 | | 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
@@ -456,8 +499,8 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
| 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按状态验证;格式错误不成功,产品/规则更新不改旧报告。 | v2自填支线已实现;本轮58项回归及Flash3例/Pro1例真实样例通过;真实产品/外部查询、生产与业务人工签收待完成 | 2026-09-24 |
| REPORT-004 | 全栈开发(待指定) | 个人/企业真实链路按版本展示;未发布无分或等级,100%不等于授信;旧报告不重算;新复评新assessmentId。 | 6.17三栏/局部重试/旧版/权限及生命周期验证;真实产品与外部渠道仍待验收 | 2026-09-28 | | REPORT-004 | 全栈开发(待指定) | 个人/企业真实链路按版本展示;未发布无分或等级,100%不等于授信;旧报告不重算;新复评新assessmentId。 | 6.17三栏/局部重试/旧版/权限及生命周期验证;真实产品与外部渠道仍待验收 | 2026-09-28 |
| REPORT-005 | 全栈开发(待指定) | 本人成功报告可导出;金额/理由与历史一致;不重跑模型/泄露身份;中文分页可读,JSON错误不保存成PDF。 | 04—10方案定义导出接口与失败/隐私边界;PDF引擎及中文字体待选型锁定,未生成样本 | 2026-09-24 | | REPORT-005 | 全栈开发(待指定) | 本人成功报告可导出;金额/理由与历史一致;不重跑模型/泄露身份;中文分页可读,JSON错误不保存成PDF。 | 鉴权接口/H5导出已实施,29项PDF+20项HTTP+24项浏览器检查通过;跨浏览器/真机和生产容量待验收,见6.21 | 2026-09-28 |
| REPORT-006 | 全栈开发(待指定) | 根据判定及当前可用性开放行动;无银行申请提交;新评估不改历史、无草稿;上下文和返回正确。 | 04—10方案已定义全部按钮去向和条件,尚未注册路由/实施页面 | 2026-09-24 | | REPORT-006 | 全栈开发(待指定) | 根据判定及当前可用性开放行动;无银行申请提交;新评估不改历史、无草稿;上下文和返回正确。 | 27页已注册;38项H5流程/边界检查、Android/微信编译通过,真实产品与平台待验收,见6.20 | 2026-09-28 |
剩余依赖:REPORT-001商业预算/生产额度待DEC-AI;REPORT-003完整报告仍依赖DATA、PRODUCT、DEC-MATCH;DEC-READINESS首版开发策略已发布,人工业务验收待完成。模型真实权限已验证。 剩余依赖:REPORT-001商业预算/生产额度待DEC-AI;REPORT-003完整报告仍依赖DATA、PRODUCT、DEC-MATCH;DEC-READINESS首版开发策略已发布,人工业务验收待完成。模型真实权限已验证。
@@ -467,17 +510,17 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 | | 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| CHAT-001 | 会话与消息API | 普通会话创建/列表/消息分页/归属;明确可引用评估上下文且不占用正式assessment_id。 | P1 | AUTH-002、BASE-001 | 1–1.5 | 未开始 | | CHAT-001 | 会话与消息API | 普通会话创建/列表/消息分页/归属;明确可引用评估上下文且不占用正式assessment_id。 | P1 | AUTH-002、BASE-001 | 1–1.5 | 待验收 |
| CHAT-002 | DeepSeek对话调用 | 上下文裁剪、敏感信息最小化、消息幂等、异步回复与本地配额记录。 | P1 | CHAT-001、REPORT-001、REPORT-002 | 1–2 | 未开始 | | CHAT-002 | DeepSeek对话调用 | 上下文裁剪、敏感信息最小化、消息幂等、异步回复与本地配额记录。 | P1 | CHAT-001、REPORT-001、REPORT-002 | 1–2 | 待验收 |
| CHAT-003 | 助手页面接入 | chatId/requestId、历史加载、发送状态、失败重试、由报告进入解释上下文。 | P1 | CHAT-002、REPORT-004 | 1–2 | 未开始 | | CHAT-003 | 助手页面接入 | chatId/requestId、历史加载、发送状态、失败重试、由报告进入解释上下文。 | P1 | CHAT-002、REPORT-004 | 1–2 | 待验收 |
| CHAT-004 | 对话异常与额度验收 | 并发消息、重复点击、额度耗尽、供应商超时及失效会话。 | P1 | CHAT-003 | 1–1.5 | 未开始 | | CHAT-004 | 对话异常与额度验收 | 并发消息、重复点击、额度耗尽、供应商超时及失效会话。 | P1 | CHAT-003 | 1–1.5 | 开发中 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 | | 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| CHAT-001 | 全栈开发(待指定) | 用户只能读自身会话;评估会话与普通会话分离;重进恢复历史。 | 未验收;无真实业务验收证据 | 2026-09-22 | | CHAT-001 | 全栈开发(待指定) | 用户只能读自身会话;评估会话与普通会话分离;重进恢复历史。 | 本地70项助手及H5联调通过;真实语义/真机待验收,见6.19 | 2026-09-28 |
| CHAT-002 | 全栈开发(待指定) | 同请求不重复建消息或记本地消耗;错误回复不标成功;不会发送无关证件资料。 | 未验收;无真实业务验收证据 | 2026-09-22 | | CHAT-002 | 全栈开发(待指定) | 同请求不重复建消息或记本地消耗;错误回复不标成功;不会发送无关证件资料。 | 本地70项助手及H5联调通过;真实语义/真机待验收,见6.19 | 2026-09-28 |
| CHAT-003 | 全栈开发(待指定) | 切换会话不串上下文;超时保留可重试消息;重进页面能继续。 | 未验收;无真实业务验收证据 | 2026-09-22 | | CHAT-003 | 全栈开发(待指定) | 切换会话不串上下文;超时保留可重试消息;重进页面能继续。 | 本地70项助手及H5联调通过;真实语义/真机待验收,见6.19 | 2026-09-28 |
| CHAT-004 | 全栈开发(待指定) | 配额并发不超扣;供应商计费不明可对账;仅真实成功回复显示完成。 | 未验收;无真实业务验收证据 | 2026-09-22 | | CHAT-004 | 全栈开发(待指定) | 配额并发不超扣;供应商计费不明可对账;仅真实成功回复显示完成。 | 并发额度/超时/迟到/重试本地通过;供应商真实语义与生产容量待验收,见6.19 | 2026-09-28 |
直接外部/业务阻塞:无;仍须满足前置任务。 直接外部/业务阻塞:无;仍须满足前置任务。
@@ -1017,3 +1060,67 @@ REPORT-004三栏页面子项完成本地联调,整体仍开发中。改动repo
后续建议按页面方案推进08上下文AI助手(CHAT模块),再衔接10行动页与09 PDF独立任务。真实产品/人工金标准、外部渠道、微信内H5及真机均仍待验收,不标整体验收完成。 后续建议按页面方案推进08上下文AI助手(CHAT模块),再衔接10行动页与09 PDF独立任务。真实产品/人工金标准、外部渠道、微信内H5及真机均仍待验收,不标整体验收完成。
收尾:随机合成账户、3条评估/会话、Token和隔离队列已清理,测试HTTP服务和浏览器会话已关闭;原1会员、2条成功评估/会话、25张表保持,syntheticUsers=0。本轮开始时未发现运行中的analysis:work,清理后已启动正式worker(PID33796),错误日志为空。文本编码、diff检查、63任务一致性与26条路由检查通过;未提交、推送或发布。 收尾:随机合成账户、3条评估/会话、Token和隔离队列已清理,测试HTTP服务和浏览器会话已关闭;原1会员、2条成功评估/会话、25张表保持,syntheticUsers=0。本轮开始时未发现运行中的analysis:work,清理后已启动正式worker(PID33796),错误日志为空。文本编码、diff检查、63任务一致性与26条路由检查通过;未提交、推送或发布。
### 6.18 2026-09-28 原型04/05还原与单产品AI解释
本轮优先实现PRODUCT-004的04、05子范围。页面按原型还原机构头部、状态标签、三栏栏目、绿色推荐依据、橙色不推荐/注意事项、底部双按钮;真实匹配仍使用符合已知条件/条件不符/待核实三态,不展示原型中的匹配百分比、审批概率或虚构银行优势。额度、年化、期限移入产品详情;证据和来源保持可展开查看。
| 检查 | 结果与证据 | 验证边界 |
| --- | --- | --- |
| 后端回归 | `php tests/product_explanations.php --worker-stopped`通过51项;`php tests/match_records.php --worker-stopped`通过70项。覆盖鉴权、归属、POST参数、并发幂等、成功缓存、失败三次边界、用量记录、超时回收、迟到结果、快照/游标和删除记录 | 使用随机合成账户与假模型,测试前停止worker,结束自动清理;无迁移/新表 |
| 真实模型 | DeepSeek `deepseek-v4-flash-0731`调用成功,输入911—1087 tokens、输出235—315 tokens,耗时约2.9—4.5秒;保存逐条reasonId/text/evidenceRefs | 共6次真实Flash调用,4次成功、2次因理由顺序被旧校验拒绝;按ID集合校验修复后不符与待核实样例通过。未声称真实机构业务准确性,不记录密钥 |
| 前端主流程 | Chromium,H5 `http://localhost:5173/`;390×844、390×1000、1280×900。已保存解释读取、证据展开、产品详情/申请条件切换、失败重试、刷新保留queued、三态页面和底部操作通过 | Browser plugin不可用,使用Playwright CLI;未做微信内置浏览器、Safari、Android/iOS真机 |
| 故障与安全 | 轮询断网后规则理由仍保留并可手动查询;迟到响应不切回旧栏;下架保留历史参数且禁用资料准备;公共模式不显示AI栏;非法评估ID不发请求;越权不回退公共产品;401回登录;读取已保存结果不发POST | 故障为本地路由注入;未做多设备压力测试 |
| 视觉对照 | 已查看`docs/原型.jpg`原图及04/05放大局部;对照机构头部、绿色/橙色编号、三栏、底部按钮、白底密度五类要素。保存截图于`C:/Users/JUN/AppData/Local/Temp/airz-product-explanation-20260928/` | 原型中的真实银行Logo、92%匹配度、立即申请和咨询AI无已确认数据/接口,因此使用通用图标、状态标签、资料准备/对比入口并在页面说明 |
本轮新增接口和状态契约见`docs/产品匹配规则开发契约.md`第9节;不新增业务表或数据库迁移。analysis:work已恢复运行。下一步为原型08上下文助手,随后10行动页和09 PDF;真实产品资料与人工金标准提供后再做正式匹配验收。
6.18收尾补记:HBuilderX5.26 Android与微信小程序最终编译通过;H5运行页面身份正确、非空、无框架错误遮罩,最终三态读取流程无console.error/warn,不发POST。首次Android暴露UTS局部函数前向引用限制,调整声明顺序和轮询回调后修复。临时HTTP服务首次未指定public目录导致首页HTML响应,修正启动参数后重跑;早期脚本误用不存在的按钮文案和CLI环境未定义setTimeout,修正定位/等待后通过,不将脚本失败算作业务通过。
最终对照检查了机构头部、栏目文字/选中线、理由顺序和编号、字体与行距、底部按钮及桌面容器;原型单页小图不适合按原始像素直接作为可读手机界面,使用390×1000接近纵横比并同时检查390×844。长规则标题缩短重复核验模板,原始依据仍完整可展开。首屏有意差异为演示标记/通用图标、三态替代百分比、真实理由数量、资料准备和对比替代尚未接通的申请/咨询;说明见原型方案第8节。
截图:[04手机](<C:/Users/JUN/AppData/Local/Temp/airz-product-explanation-20260928/product04-mobile.png>)、[05手机](<C:/Users/JUN/AppData/Local/Temp/airz-product-explanation-20260928/product05-mobile.png>)、[04/05并排](<C:/Users/JUN/AppData/Local/Temp/airz-product-explanation-20260928/prototype04-05-result.png>)、[待核实](<C:/Users/JUN/AppData/Local/Temp/airz-product-explanation-20260928/product-unknown-mobile.png>)。测试脚本flow.js/retry.js/states.js/capture05.js/edges.js/final-capture.js均在同一临时目录;界面已保存输出来自真实Flash,基础评估报告由假模型生成。
合成账号及其评估/会话/日志、Token和隔离队列已清理,测试HTTP服务关闭,正式analysis:work恢复(PID50752);数据库仍为1会员、2成功评估/会话、25张表。63项编号/依赖/工时一致且无环、26路由、UTF-8无BOM、链接及diff检查通过;本轮没有执行git提交、推送或发布。
### 6.19 2026-09-28 原型08上下文助手实施与验证
- 界面按原型恢复机器人问候、快捷问题、聊天气泡和输入栏,新增紧凑上下文卡与历史对话。报告/单产品/对比入口、登录返回及H5 chatId刷新已接通。
- 后端AssistantChats/Assistant控制器、四组助手API、持久日志任务已实现。普通会话不占正式assessment_id;服务端冻结事实、同键幂等、三次重试、租约恢复、迟到只补用量、六轮历史裁剪及共享每日额度均已接入。API与边界见[助手契约](上下文AI助手开发契约.md)。
- 经本轮用户授权,停止空闲worker并备份后完成5项增量迁移,复跑0项;备份为后端相邻airz-backups/assistant-context-20260928-154651-271907。逐条对照确认原2份会话/2条日志未改写,原评估保留。数据库仍25表、1会员、2份评估/会话。
| 验证 | 结果 | 边界 |
| --- | --- | --- |
| 后端 | 助手70项、产品解释51项、分析任务31项、评估HTTP36项、匹配HTTP/快照70项通过 | 合成账户/假模型;无本轮收费模型调用 |
| 并发及故障 | 双进程发送串行;跨会话仅剩一次额度不会超扣;同键重放、三次重试、租约失效、迟到用量、归属及上下文冲突通过 | 修复MySQL旧快照导致序号竞争,关键查询使用锁定当前读 |
| H5 | Chromium390×844及1280×900;普通/评估/产品/对比、23轮分页、刷新、回执丢失、额度错误、失败重试、跨账号清理、真实密码登录返回通过 | Browser plugin not available,使用现有Playwright CLI;无安装依赖 |
| 编译及视觉 | HBuilderX5.26 Android、微信编译通过;最终页面非空、无框架遮罩,正常流程console.error/warn为0 | 原生端百分比max-width已修正;旧开发服务器未载入新登录代码,重启后复测通过;H5编译器仍提示共享layout既有类型声明警告,不是浏览器运行错误 |
| 清理 | 合成账号、评估、会话、日志、Token及队列已清理,隔离HTTP服务与浏览器关闭 | 截图及无凭据QA脚本保留临时目录,不提交仓库 |
截图:[普通助手](<C:/Users/JUN/AppData/Local/Temp/airz-assistant-20260928/assistant-normal-mobile-final.png>)、[上下文助手](<C:/Users/JUN/AppData/Local/Temp/airz-assistant-20260928/assistant-context-mobile-final.png>)、[桌面](<C:/Users/JUN/AppData/Local/Temp/airz-assistant-20260928/assistant-context-desktop-final.png>)。真实DeepSeek多轮语义、真实产品人工样例、微信内H5/Safari、真机键盘/安全区及生产容量未验收,不能将假模型联调描述成真实回复验收。CHAT-001—003待验收、CHAT-004开发中;原型10及09 PDF仍未开始。保留用户已有改动,未提交、推送或发布。
### 6.20 2026-09-28 原型10实施与验证
- 新增next-step页、主包白名单路由帮助函数和四个SVG图标;报告/产品/助手/登录入口增量接通。63项编号及依赖保持不变,REPORT-006待验收;无提交、推送或发布。
- H5 Chromium 390×844、1280×900:主流程14项、边界21项、导航及本机缓存3项,共38项通过。覆盖报告/产品入口、无产品选择、准备刷新、AI上下文往返、个人/企业空白重评、首页清栈、冷启动返回、越权、错误产品、参数、账户切换迟到响应及真实密码登录恢复。正常主流程无业务console.error/warn,无POST或模型请求。
- 主流程使用临时合成个人/企业成功报告和真实只读API;不符/待核实及跨账户拒绝也来自真实API。不可用、快照不一致、503、无匹配、旧版、未完成、迟到响应采用可控响应注入,未修改正式产品。登录验证单独发送登录POST,不能把只读页面测试说成无任何写入。
- 最终HBuilderX5.26 Android(17:04:58)及微信小程序(17:05:58)27页编译成功。共享layout既有H5类型警告保留,未做无关重构。真机、微信内H5/Safari、真实产品渠道未验收;08真实多轮语义仍待验收。
- 测试前确认worker空闲并停止,结束清理全部合成账号/评估/会话/日志/令牌/队列,关闭隔离HTTP和浏览器。恢复1会员、2评估/会话、2日志;原评估161/229会话摘要前后相同。正式worker恢复PID45420,H5最新服务5173(PID51832),新会话须重新检查PID。
- 证据目录:`C:/Users/JUN/AppData/Local/Temp/airz-next-step-20260928/`,包含无凭据QA脚本与verification.md;脚本中的__QA_FIXTURE__需由新合成数据注入。截图:[行动页手机](<C:/Users/JUN/AppData/Local/Temp/airz-next-step-20260928/actions-mobile.png>)、[材料准备手机](<C:/Users/JUN/AppData/Local/Temp/airz-next-step-20260928/prepare-mobile.png>)、[行动页桌面](<C:/Users/JUN/AppData/Local/Temp/airz-next-step-20260928/actions-desktop.png>)。
下一步实施REPORT-005 PDF导出;当前无下载按钮或虚假成功提示,不包含银行受理/授信/放款。
### 6.21 2026-09-28 PDF导出实施与验证
- 后端ReportPdf、Assessments::pdf、MatchRecords::exportSnapshot及report.pdf路由;前端ReportExport组件、report-export.uts下载器与report-route.uts登录白名单。只读本人成功报告,无迁移/新表/收费模型调用。依赖和部署见后端extend/report-pdf/README.md。
- `php tests/report_pdf.php`29项:精度、零值、最大金额、隐私字段与文本遮蔽、HTML转义、v1、未匹配/零候选、企业、关闭引擎503、真实PDF。样本通过Poppler逐页渲染、pdfplumber中文提取/隐私/页面边界检查,末尾版本说明整体分页,避免只剩免责声明的尾页。
- `php tests/report_pdf_http.php --worker-stopped`20项真实HTTP:401/404/409 JSON、no-store、合法二进制/MIME/安全文件名、重复下载不改报告与调用日志、不查询当前产品目录、损坏/撤销会话拒绝。合成账号/评估/日志/Token/队列及隔离服务已清理;没有真实模型调用。
- 浏览器使用Playwright CLI(Browser plugin not available),Chromium390×844/1280×900,24项检查通过:普通流程6项、边界18项。页面身份/非空/无框架浮层、正常流程console.error/warn为空;实际download事件文件可解析。API响应采用合成报告及后端生成的真实PDF注入;错误页、损坏、断网、503、401、离页、账号切换、微信UA提示均验证。不能把受控UI响应检查描述为浏览器完整真实后端联调;真实HTTP另由上项覆盖。真实密码登录回跳本轮未重跑。
- 最终HBuilderX5.26 Android17:34:03、微信小程序17:36:09编译通过。原生/小程序仅提示普通H5下载,不宣称原生保存可用;微信内H5/Safari/真机、生产并发容量仍未验收。共享layout既有H5类型警告保留。
- 自动审批曾拒绝一条“创建合成UI账号+启动隔离服务+浏览器”的组合命令,仅返回blocked by policy,未给具体理由;没有重试该组合或规避限制。已恢复worker,并采用无服务器凭据的受控浏览器响应完成UI检查。其他工具路径/定位问题修正后验证通过。
- 当前数据库1用户、2成功评估/会话、2日志、0待处理;原161/229报告会话摘要与执行前一致。worker PID34324、H5 5173/PID51832已核对运行,后续勿硬编码PID。无提交/推送/发布。
- 证据目录`C:/Users/JUN/AppData/Local/Temp/airz-report-pdf-20260928/`:personal/enterprise/legacy/unmatched/zero-candidates/long.pdf、http-personal.pdf、browser-report.pdf及对应渲染图;ui-flow.js/ui-edges.js只含不能访问服务器的合成会话标识。截图:[手机报告](<C:/Users/JUN/AppData/Local/Temp/airz-report-pdf-20260928/report-pdf-mobile.png>)、[桌面报告](<C:/Users/JUN/AppData/Local/Temp/airz-report-pdf-20260928/report-pdf-desktop.png>);[合成PDF样本](<C:/Users/JUN/AppData/Local/Temp/airz-report-pdf-20260928/personal.pdf>)。
REPORT-005待验收。下一步08真实DeepSeek多轮语义、真实产品人工样例与微信内H5/Safari/真机验收;真实模型调用会产生用量,应在具体样例及预算边界明确后执行。
+102
View File
@@ -0,0 +1,102 @@
# AI融资项目新会话交接
记录日期:2026-09-28。本文件为接续入口;详细任务状态以[开发进度](开发进度.md)0.20、6.21节为准,设计以[开发文档](开发文档.md)v1.18、[原型页面方案](原型04-10页面改造与跳转方案.md)v1.7为准。历史章节中的“待开发”应结合最新更新阅读。
## 1. 当前停在哪
用户要求界面内容尽可能还原`docs/原型.jpg`。原型08已继续实施:界面、持久会话、固定评估/产品上下文、异步回复、分页、原键重试、共享额度、报告/产品/对比入口及登录返回已完成本地联调;关键代码有中文注释。原型10也已完成本地联调;09 PDF普通H5也已实施;下一步为08真实多轮语义与跨平台/真实产品验收。
| 内容 | 当前状态 |
| --- | --- |
| 04/05 产品匹配详情 | 原型布局与单产品解释保留;咨询AI已接08;产品对比仍可进入 |
| 06 产品详情 | 公共资料和本次评估冻结快照分开读取 |
| 07 产品对比 | 同次2—3产品、补选/移除/刷新保留;可带选择进入08,PRODUCT-005待验收 |
| 08 上下文AI助手 | CHAT-001—003待验收,CHAT-004开发中;70项助手测试与H5、Android/微信编译通过;真实多轮模型语义/真机未验收 |
| 09 报告 | 三栏及本次报告咨询AI入口完成;REPORT-004整体仍开发中;REPORT-005普通H5导出已本地验证,平台/容量待验收 |
| 10 下一步行动 | REPORT-006待验收;四项行动/登录恢复/状态守卫已接通,38项H5检查及Android/微信编译通过 |
当前27个注册页面、63个稳定任务编号。未提交/推送/发布;前后端已有改动均保留。
## 2. 原型08实现与下一步
完整契约见[上下文AI助手开发契约](上下文AI助手开发契约.md)。前端`packages/finance/assistant/assistant.uvue`、`packages/finance/services/assistant.uts`;后端`AssistantChats.php`、v1 `Assistant.php`和`AnalysisWork.php`。旧sendAssistantMessage模拟入口已移除。
- 普通会话scene=assistant、assessment_id/analysis_context=NULL。本轮用户明确授权后已执行后端`database/migrate_assistant_context.py --apply --worker-stopped`,新增两列及归属约束共5项,复跑0项。备份`D:/AAdesk/202609/airz-backups/assistant-context-20260928-154651-271907/`,原2份评估会话/2条日志逐条未改写。恢复优先保留可空列、回退助手代码,不能整表覆盖后续记录。
- GET `/assistant/context`只读预览,GET/POST `/assistant/chats`读取列表/创建,GET `/assistant/chats/:id`恢复并校验URL绑定,GET/POST `/assistant/chats/:id/messages`分页/发送。创建与进入页面均不调用模型,快捷问题只填入文本框。
- 明确发送创建用户行与ast:调用日志,analysis:work持久消费。每条逻辑消息requestId保持不变;回执丢失同retryAttempt重放,明确失败才手动递增0→1→2。每会话100轮、同时一条待处理;每日创建30会话,调用与评估/单产品解释共享每日额度。
- 并发序号/幂等/额度必须保持锁定当前读,不能仅依赖用户锁后普通SELECT;测试发现MySQL可重复读旧快照会造成重复序号。
- 页面每3秒查询、最多60次,可续查;离页清空、Token变化拒绝迟到回包。消息/报告不落本机缓存,H5只保存chatId等ID到URL。登录后只接受白名单助手返回并重新鉴权。
- 后端默认Flash真实适配已接通,本轮测试使用假模型,未新发收费模型调用;不要把假模型回复当作真实DeepSeek多轮语义验收。
原型10及09 PDF已实施;下一步补真实模型/产品与平台验收;并补08真实多轮人工语义、微信内H5/Safari/真机键盘安全区及生产容量验收。供应商与真实产品准备仍可推进,不虚构整体完成率。
## 3. 必须延续的业务约定
- 不做草稿;重新评估进入空白表单,不覆盖历史评估。
- fa_user_info一条对应一次评估;fa_ai_chat保存关联报告及匹配结果;fa_ai_chat_log保存消息和调用记录。不新建独立评估/报告/匹配表。
- Token保存在Redis,fa_user_token已删除。
- 金额数据库DECIMAL(11,2),单位元;API用元十进制字符串;客户端万元,保留精度,例如123456.78元显示12.345678万元。
- 产品稳定条件、额度/期限/利率等使用独立字段,地区/补充规则JSON;沿用冻结产品快照,不用当前产品参数覆盖历史。
- 规则决定eligible/ineligible/unknown,AI只解释;无评分策略则score=null,不使用原型92%、B+或固定三正三反。非候选的正向部分称“已满足条件”。
- 申请仅限资料准备与引导;不提交银行申请、不声称受理成功。真实产品尚未提供,本地6条上架资料是明确标注的演示产品。
- DeepSeek走腾讯TokenHub,已配置Flash/Pro;默认Flash。短信、微信/一键登录、外部查询、客服服务商仍待定。
- 中文交流与关键中文注释;UTF-8无BOM。保留未提交工作,不自行提交/推送/建PR、迁移或覆盖用户改动。凭据只从后端私密配置读取,不在文档、命令输出、日志或客户端复述。
## 4. 04/05实现入口和注意点
前端:`packages/finance/product/product.uvue`、`packages/finance/services/matches.uts`、`packages/finance/services/compare.uts`。
后端:`application/common/library/ProductExplanations.php`、`MatchRecords.php`、`DeepSeekClient.php`、`AnalysisTasks.php`、`application/admin/command/AnalysisWork.php`、`application/api/controller/v1/Assessments.php`、`application/route.php`。
- POST `/api/v1/assessments/:id/matches/:productId/explanation`仅接受requestId;返回HTTP200的匹配详情及异步状态,不能把HTTP200理解为模型完成。
- fa_ai_chat.match_result中单项ai_explanation保存queued/running/completed/failed和逐条解释;首次not_requested。fa_ai_chat_log的`pex:`调用日志作为持久待办,analysis:work在报告队列空闲时消费。
- 成功缓存、并发点击和同键重放不会重复调用。每评估每产品最多三次尝试,与报告共享每日用户调用预留额度。失败新键可重试;租约失效明确失败,迟到仅补用量。
- 模型可能重排理由,按reasonId完整集合校验无重复/遗漏/外来项,再恢复规则顺序,不能再用数组位置强制匹配。
- 更新解释不能使分页失效:游标摘要排除ai_explanation,仍校验其他冻结字段。
- 前端每三秒读状态、最多六十次,可手动续查;离页停止轮询、清理数据并拒绝迟到回包。UTS Android局部函数不支持任意前向引用,声明顺序须注意。
详见[产品匹配规则开发契约](产品匹配规则开发契约.md)第9节及[需求与评估结果数据契约](需求与评估结果数据契约.md)。
## 5. 验证与现场状态
- 51项单产品解释测试、70项匹配HTTP/快照检查通过。真实Flash共6次调用,4次成功、2次因旧顺序校验失败,修复后不符及待核实样例通过;不是6次均成功。
- HBuilderX5.26 Android/微信小程序编译通过;H5 Chromium在390×844、390×1000、1280×900验证,正常最终流程无业务console.error/warn。微信内H5、Safari及真机、真实产品人工金标准未验收。
- 合成测试账号/评估/会话/日志、Token与队列已清理,测试HTTP和浏览器会话已关闭。上次数据库核验为1会员、2成功评估/会话、25张表;原评估161/229未改写。
- 2026-09-28本轮收尾正式analysis:work已重启加载助手代码,PID45420已恢复运行(原型10测试后);新会话应重新检查进程/任务状态,不能将旧PID视为永久有效。已有模型worker需在代码变更后安全重启。
- 前后端均有未提交改动;本次已实施原型08及10,不清理既有工作树。开始新一轮修改前先git status并保留用户现有改动。
本轮08验证:助手70项、产品解释51项、任务31项、评估HTTP36项、匹配HTTP/快照70项通过;H5 Chromium390×844/1280×900完成发送/刷新/分页/重试/回执丢失/三入口/越权/登录返回验证,Android和微信编译通过。合成数据、令牌、隔离服务、浏览器已清理。截图和无凭据QA脚本在`C:/Users/JUN/AppData/Local/Temp/airz-assistant-20260928/`。共享layout既有H5类型警告保留,正常浏览器运行无业务error/warn。
此前04/05测试与截图目录:`C:/Users/JUN/AppData/Local/Temp/airz-product-explanation-20260928/`。其中flow/retry/states/capture05/edges/final-capture脚本和截图可参考,但临时目录可能被系统清理;fixture.json已删除,不假定旧账号仍可登录。产品对比和报告三栏证据分别在airz-compare-20260928、airz-report-tabs-20260928临时目录。
原型10最新验证见开发进度6.20:38项H5检查(真实API主流程+边界响应注入)、Android/微信27页编译通过。测试已清理,原161/229报告会话摘要未改变,数据库恢复1用户、2评估/会话、2日志。H5最新5173/PID51832,worker PID45420;新会话先检查当前状态。截图与无凭据脚本在`C:/Users/JUN/AppData/Local/Temp/airz-next-step-20260928/`。
原型10代码:`packages/finance/next-step/next-step.uvue`、`services/next-step-route.uts`。只读status/report/matches接口,核对成功归属与快照元数据;eligible且available才开放准备。重新评估按status.type进入空白表单;登录白名单恢复;onHide清空个人数据、generation/token拒绝迟到请求。未修改后端业务或迁移。不要把模拟不可用/旧版响应测试说成真实产品/旧数据验收。
原型09 PDF最新见进度0.20/6.21:29项PDF、20项真实HTTP、24项受控浏览器检查通过,最终Android/微信编译通过。引擎Dompdf3.1.6在后端extend/report-pdf独立composer.lock,字体Noto Sans SC含许可与哈希;部署须在该目录composer install,详见其README。后端ReportPdf与report.pdf路由、MatchRecords::exportSnapshot;前端ReportExport组件/report-export.uts,登录report-route白名单。无迁移,无收费模型调用。仅普通H5开放下载,其余端给操作提示;不能把编译通过说成原生下载验收。
本轮曾被自动审批拒绝组合UI夹具启动,改以受控响应验证浏览器,不声称完整真实浏览器联调。真实HTTP检查独立通过。全部真实合成账号/Token/队列已清理;原两份报告摘要相同。worker现PID34324,H5仍5173/PID51832;每次重查。PDF/截图证据在`C:/Users/JUN/AppData/Local/Temp/airz-report-pdf-20260928/`。
## 6. 环境与验证命令
前端目录:`D:/WorkSpace/cyun/ai融资`,uni-app x/Vue3/UTS、HBuilderX5.26 VDOM,无npm构建入口。后端目录:`D:/AAdesk/202609/airz`,FastAdmin/ThinkPHP、PHP7.4、MySQL/Redis。
```powershell
# 在对应项目目录执行;后端合成任务测试前先核对并停止全部分析worker,结束清理后恢复。
& 'D:/Develop/phpstudy_pro/Extensions/php/php7.4.3nts/php.exe' tests/product_explanations.php --worker-stopped
& 'D:/Develop/phpstudy_pro/Extensions/php/php7.4.3nts/php.exe' tests/match_records.php --worker-stopped
& 'D:/Develop/phpstudy_pro/Extensions/php/php7.4.3nts/php.exe' tests/assistant_chats.php --worker-stopped
# --live会真实调用并产生用量,日常回归不加。
& 'D:/Develop/phpstudy_pro/Extensions/php/php7.4.3nts/php.exe' think analysis:work
& 'D:/Develop/HBuilderX/cli.exe' launch app-android --project 'D:/WorkSpace/cyun/ai融资' --compile true --continue-on-error false
& 'D:/Develop/HBuilderX/cli.exe' launch mp-weixin --project 'D:/WorkSpace/cyun/ai融资' --compile true --continue-on-error false
```
H5已清理旧开发服务器并以最新代码恢复`http://localhost:5173/`,新会话先核对监听。注意HBuilder多次launch可能保留旧端口产物,新增代码未加载时先重启正确服务,不把旧包误判为业务错误。可用Python为`D:/Develop/Python311/python.exe`。已有Playwright CLI使用`npx --no-install @playwright/cli`,从临时QA目录运行避免向项目写入日志;工具可用性按新会话实际环境选择,无需安装全局依赖。
本地PHP测试服务需指定`-t D:/AAdesk/202609/airz/public`,否则可能返回首页HTML。UI测试使用随机合成账号,不输出密码/Token;原有真实用户账号无需改动。恢复扫描可能拾取测试任务,仅换Redis队列不足以隔离,故测试前要检查worker。
## 7. 新会话可以直接发送
> 请先阅读docs/新会话交接.md、开发进度0.20/6.21和原型04-10页面方案,继续原型08真实多轮语义及跨平台验收准备,先核对可执行样例和用量边界。界面尽量还原docs/原型.jpg,关键代码加中文注释;保留已有04/05解释、07对比、08上下文助手、09报告及10行动成果。只做材料准备与引导,不提交银行申请;PDF未完成时不做假下载。先检查工作树与数据库/worker现状,新增迁移仍按具体方案申请授权。
+1 -1
View File
@@ -171,7 +171,7 @@ unknown不是建议申请;ineligible不是永久拒绝。展示应为“本次
- 推荐依据 `positiveReasons`、不符条件 `blockingReasons`、待核实/注意事项 `cautions` 分开保存。首页各组最多展示三条;没有证据就不生成,详情可读完整列表。通过部分条件的不适配产品,其positiveReasons不能标成“推荐该产品”。 - 推荐依据 `positiveReasons`、不符条件 `blockingReasons`、待核实/注意事项 `cautions` 分开保存。首页各组最多展示三条;没有证据就不生成,详情可读完整列表。通过部分条件的不适配产品,其positiveReasons不能标成“推荐该产品”。
- 理由 `{reasonId,code,text,evidenceRefs}` 由确定性规则/模板生成,evidenceRefs至少一个且只引用本项证据。失败条件、未知依据全部保存,不能因为只展示三条而丢掉阻断项。产品公共优势不是用户个性化理由。 - 理由 `{reasonId,code,text,evidenceRefs}` 由确定性规则/模板生成,evidenceRefs至少一个且只引用本项证据。失败条件、未知依据全部保存,不能因为只展示三条而丢掉阻断项。产品公共优势不是用户个性化理由。
- 证据 `{evidenceId,ruleId,ruleVersion,kind,fieldRefs,operator,expected,actual,outcome,source,observedAt}` 固定类型、单位和来源。evidenceId在整份报告内唯一且稳定,例如由productId、ruleId和条目序号组成,不能让不同产品的同名规则共用ID。kind=hard/soft,source=self_reported/verified/product_snapshot;expected/actual金额保持元字符串,未知actual=null。规则快照保存requiredSource,复核时能判明证据是否够用。 - 证据 `{evidenceId,ruleId,ruleVersion,kind,fieldRefs,operator,expected,actual,outcome,source,observedAt}` 固定类型、单位和来源。evidenceId在整份报告内唯一且稳定,例如由productId、ruleId和条目序号组成,不能让不同产品的同名规则共用ID。kind=hard/soft,source=self_reported/verified/product_snapshot;expected/actual金额保持元字符串,未知actual=null。规则快照保存requiredSource,复核时能判明证据是否够用。
- 单项 `aiExplanation={status,text,evidenceRefs}`,status=not_requested/completed/failed;只解释服务端已生成的理由,不能新增规则、伪造优势/风险或改判定。模型内容缺证据、含审批承诺或改写数值时拒绝保存该段,保留规则理由并显示解释暂不可用。不存在“必须每家补足三正三反”的生成要求。 - 单项 `aiExplanation={status,text,evidenceRefs,reasonExplanations,generatedAt,errorCode,canRetry}`,status=not_requested/queued/running/completed/failed(2026-09-28已实现);reasonExplanations为`{reasonId,text,evidenceRefs}[]`,generatedAt为ISO时间或null,errorCode为稳定错误码或null,canRetry为boolean。taskId/leaseUntil/requestKeys/attempts仅内部保存,不通过DTO外发;只解释服务端已生成的理由,不能新增规则、伪造优势/风险或改判定。模型内容缺证据、含审批承诺或改写数值时拒绝保存该段,保留规则理由并显示解释暂不可用。不存在“必须每家补足三正三反”的生成要求。
证据类型增补:source在事实完全缺失/无法确定来源时允许null,不捏造self_reported/verified;observedAt未知也为null。额外提供requiredSource、reasonCode、valueType及unit,现已映射入正式match_result。not_applicable以product_snapshot作为适用范围依据来源。 证据类型增补:source在事实完全缺失/无法确定来源时允许null,不捏造self_reported/verified;observedAt未知也为null。额外提供requiredSource、reasonCode、valueType及unit,现已映射入正式match_result。not_applicable以product_snapshot作为适用范围依据来源。
+18 -2
View File
@@ -27,7 +27,23 @@
import { API_BASE_URL, requestSmsCode, loginWithSms, loginWithPassword } from '@/packages/account/services/auth.uts' import { API_BASE_URL, requestSmsCode, loginWithSms, loginWithPassword } from '@/packages/account/services/auth.uts'
import { showAgreementNotice } from '@/services/assessment.uts' import { showAgreementNotice } from '@/services/assessment.uts'
import { consumeInitialLaunch } from '@/services/launch.uts' import { consumeInitialLaunch } from '@/services/launch.uts'
onLoad(() => { consumeInitialLaunch() }) import { assistantReturnPath } from '@/services/assistant-return.uts'
import { nextStepRoute } from '@/services/next-step-route.uts'
import { reportRoute } from '@/services/report-route.uts'
let afterLoginUrl = '/pages/home/home'
onLoad((options) => {
consumeInitialLaunch()
if (options?.['resumeAssistant'] == '1') {
const target = assistantReturnPath(options?.['assessmentId'] ?? '', options?.['productIds'] ?? '', options?.['chatId'] ?? '', options?.['from'] ?? '')
if (target.length > 0) afterLoginUrl = target
} else if (options?.['resumeNextStep'] == '1') {
const target = nextStepRoute(options?.['assessmentId'] ?? '', options?.['productId'] ?? '', options?.['section'] ?? 'actions')
if (target.length > 0) afterLoginUrl = target
} else if (options?.['resumeReport'] == '1') {
const target = reportRoute(options?.['assessmentId'] ?? '', options?.['tab'] ?? 'overview')
if (target.length > 0) afterLoginUrl = target
}
})
const { statusStyle, contentStyle, pageStyle } = usePageLayout() const { statusStyle, contentStyle, pageStyle } = usePageLayout()
const mode = ref('password') const mode = ref('password')
const phone = ref('') const phone = ref('')
@@ -58,7 +74,7 @@
if (countdown.value <= 1) { countdown.value = 0; clearInterval(countdownTimer) } else countdown.value -= 1 if (countdown.value <= 1) { countdown.value = 0; clearInterval(countdownTimer) } else countdown.value -= 1
}, 1000) }, 1000)
} }
const finishLogin = (_token: string) => { uni.redirectTo({ url: '/pages/home/home' }) } const finishLogin = (_token: string) => { uni.redirectTo({ url: afterLoginUrl }) }
const resendSms = async () => { const resendSms = async () => {
if (loading.value || countdown.value > 0 || !validPhone()) return if (loading.value || countdown.value > 0 || !validPhone()) return
if (!agreed.value) { errorMessage.value = '请先阅读并勾选用户协议与隐私政策'; return } if (!agreed.value) { errorMessage.value = '请先阅读并勾选用户协议与隐私政策'; return }
+222 -86
View File
@@ -1,92 +1,228 @@
<template> <template>
<view class="assistant-page page" :style="pageStyle()"> <view class="page assistant-page" :style="pageStyle()">
<AppHeader title="AI融资助手" /> <AppHeader title="AI融资助手" :customBack="true" @back="back" />
<scroll-view scroll-y class="chat-scroll" :scroll-into-view="lastMessageId" :style="contentStyle(210, false)"> <view class="chat-shell">
<view class="chat-content"> <view class="chat-toolbar"><text class="toolbar-note">{{ chatId.length > 0 ? '对话已保存' : '您的融资问题,继续聊聊' }}</text><view class="toolbar-actions"><text class="toolbar-link" @click="showHistory">历史对话</text><text class="toolbar-link" @click="newChat">新对话</text></view></view>
<view class="assistant-intro"><image src="/packages/finance/static/robot.png" mode="aspectFit" class="assistant-intro-image"></image><view class="assistant-bubble"><text class="bubble-text">您好!我是AI融资助手,可以帮您了解融资条件、准备材料和比较方案。</text></view></view> <scroll-view scroll-y class="chat-scroll" :scroll-into-view="scrollTarget" :style="contentStyle(300, false)">
<view class="quick-questions"> <view v-if="active" class="chat-content">
<view class="question" v-for="item in questions" :key="item" @click="ask(item)"><text class="question-text">{{ item }}</text><AppIcon name="chevron-right" :size="28" muted /></view> <view v-if="historyOpen" class="history-panel">
</view> <view class="history-title"><text class="context-title">历史对话</text><text class="toolbar-link" @click="historyOpen = false">收起</text></view>
<view v-for="(item,index) in messages" :key="index" :id="'message-' + index" :class="item.role == 'user' ? 'message-row user-row' : 'message-row'"> <text v-if="histories.length == 0 && !historyLoading" class="status-text">暂无历史对话</text>
<image v-if="item.role == 'assistant'" class="message-avatar" src="/packages/finance/static/robot.png" mode="aspectFit"></image> <view v-for="item in histories" :key="item.id" class="history-item" @click="openHistory(item.id)"><text class="history-label">{{ item.title }}</text><text class="history-date">{{ item.createdAt }} ›</text></view>
<view :class="item.role == 'user' ? 'message-bubble user-bubble' : 'message-bubble'"><text :class="item.role == 'user' ? 'bubble-text user-text' : 'bubble-text'">{{ item.text }}</text></view> <text v-if="historyError.length > 0" class="error-text">{{ historyError }}</text>
</view> <text v-if="historyMore || historyError.length > 0" class="toolbar-link history-more" @click="loadHistory">{{ historyLoading ? '正在加载…' : '加载更多对话' }}</text>
<view v-if="sending" class="message-row"><image class="message-avatar" src="/packages/finance/static/robot.png" mode="aspectFit"></image><view class="message-bubble"><text class="sending-text">正在整理建议…</text></view></view> </view>
<view v-if="errorMessage.length > 0" class="assistant-error"><text class="error-text">{{ errorMessage }}</text><text @click="retry" class="retry-text">重试</text></view> <text v-if="loading" class="status-text">正在读取对话上下文…</text>
<view id="chat-end" class="chat-end"></view> <view v-if="pageError.length > 0" class="error-panel"><text class="error-title">暂时无法继续对话</text><text class="error-text">{{ pageError }}</text><text v-if="routeError.length == 0" class="toolbar-link" @click="load">重新加载</text><text class="toolbar-link" @click="back">返回来源页面</text></view>
</view> <template v-if="context != null">
</scroll-view> <view v-if="context!.assessmentId.length > 0" class="context-card">
<view class="chat-input" :style="bottomStyle(17)"><input v-model="draft" confirm-type="send" @confirm="send" placeholder="请输入您的问题..." class="chat-input-input" /><button @click="send" :disabled="sending || draft.trim().length == 0" :class="sending || draft.trim().length == 0 ? 'chat-input-button send-disabled' : 'chat-input-button'"><AppIcon name="send" :size="40" /></button></view> <view class="context-heading"><text class="context-title">{{ context!.title }}</text><text class="context-link" @click="source">{{ sourceLabel }}</text></view>
</view> <text v-if="context!.products.length == 0" class="context-note">围绕这份已保存报告解答,不重新评估</text>
<view v-for="item in context!.products" :key="item.id" class="context-product"><text class="context-product-name">{{ item.name }}</text><text :class="item.state == 'eligible' ? 'product-state good-state' : 'product-state'">{{ item.stateLabel }}</text></view>
<text v-if="hasDemo" class="context-note">演示产品 · 仅供功能验证</text>
</view>
<view class="assistant-intro"><image src="/packages/finance/static/robot.png" mode="aspectFit" class="intro-image" /><view class="intro-bubble"><text class="bubble-text">您好!我是您的 AI 融资助手。
{{ context!.assessmentId.length > 0 ? '我可以结合本次报告,帮您理解产品条件、匹配原因和需要准备的资料。' : '我可以帮您解答关于银行产品、申请条件、资料准备等问题。' }}</text></view></view>
<view v-if="messages.length == 0" class="quick-questions"><view v-for="question in context!.questions" :key="question" class="question" @click="chooseQuestion(question)"><text class="question-text">{{ question }}</text></view><text class="question-hint">点击问题后,可编辑并发送</text></view>
<text v-if="more" class="load-more" @click="older">{{ paging ? '正在加载…' : '加载更早的消息' }}</text>
<view v-for="item in messages" :key="item.requestId" class="turn">
<view class="message-row user-row"><view class="user-bubble"><text class="user-text" :selectable="true">{{ item.message }}</text></view></view>
<view class="message-row"><image class="message-avatar" src="/packages/finance/static/robot.png" mode="aspectFit" /><view class="message-bubble"><text v-if="item.status == 'completed'" class="bubble-text" :selectable="true">{{ item.reply }}</text><text v-else class="status-text">{{ statusLabel(item) }}</text><text v-if="item.canRetry && item.requestId == lastRequestId && !pending" class="retry-link" @click="retryTurn(item)">重新生成回复</text></view></view>
</view>
<view v-if="sendError.length > 0" class="send-error"><text class="error-text">{{ sendError }}</text><text v-if="uncertain" class="retry-link" @click="retryTransport">重试本次发送</text></view>
<text v-if="pollStopped || queryError.length > 0" class="load-more" @click="resumeQuery">{{ queryError.length > 0 ? queryError + ' · 重新查询' : '回复仍在处理中,点击继续查询' }}</text>
<view id="chat-end" class="chat-end" />
</template>
</view>
</scroll-view>
<view class="composer" :style="bottomStyle(16)"><view class="input-row"><input v-model="draft" :disabled="context == null || sending || pending || uncertain" :maxlength="800" confirm-type="send" @confirm="send" placeholder="请输入您的问题…" class="chat-input" /><button class="send-button" :disabled="!canSend" @click="send"><image src="/packages/finance/static/send.svg" class="send-icon" mode="aspectFit" /></button></view><text class="composer-note">AI仅供参考,不代表审批结果 · 请勿输入证件或银行卡号</text></view>
</view>
</view>
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { usePageLayout } from '@/services/layout.uts' import AppHeader from '@/components/AppHeader.uvue'
const { bottomStyle, contentStyle, pageStyle } = usePageLayout() import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue' import { getSession } from '@/services/api.uts'
import AppIcon from '@/components/AppIcon.uvue' import { validRecordId } from '@/packages/finance/services/matches.uts'
import { sendAssistantMessage } from '@/packages/finance/services/api.uts' import { parseCompareIds } from '@/packages/finance/services/compare.uts'
const questions = ['我的融资额度受哪些因素影响?', '如何提高贷款通过率?', '企业融资需要哪些材料?', '有哪些适合我的方案?'] import { AssistantProduct, AssistantContext, AssistantTurn, AssistantHistory, assistantKey, assistantRoute, previewAssistant, loadAssistant, createAssistant, assistantMessages, submitAssistant, assistantHistory } from '@/packages/finance/services/assistant.uts'
type ChatMessage = { role: string, text: string } const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
const messages = ref<ChatMessage[]>([]) const assessmentId = ref(''); const productIds = ref<string[]>([]); const chatId = ref(''); const from = ref('')
const draft = ref('') const context = ref<AssistantContext | null>(null); const messages = ref<AssistantTurn[]>([])
const lastMessageId = ref('') const active = ref(false); const loading = ref(false); const sending = ref(false); const paging = ref(false)
const lastQuestion = ref('') const draft = ref(''); const pageError = ref(''); const routeError = ref(''); const sendError = ref(''); const queryError = ref('')
const sending = ref(false) const scrollTarget = ref(''); const more = ref(false); const cursor = ref(''); const pollStopped = ref(false)
const errorMessage = ref('') const historyOpen = ref(false); const histories = ref<AssistantHistory[]>([]); const historyMore = ref(false); const historyLoading = ref(false); const historyError = ref('')
const scrollToEnd = async () => { const uncertain = ref(false)
lastMessageId.value = '' let historyCursor = ''; let createKey = assistantKey(); let bindingSpecified = false; let generation = 0; let scope = ''; let timer = 0; let polling = false; let pollCount = 0
await nextTick() let sendKey = ''; let sendText = ''; let sendAttempt = 0
lastMessageId.value = 'chat-end' const pending = computed((): boolean => messages.value.some((t: AssistantTurn): boolean => t.status == 'queued' || t.status == 'running'))
} const hasDemo = computed((): boolean => context.value?.products.some((p: AssistantProduct): boolean => p.demo) ?? false)
const requestAnswer = async (text: string) => { const lastRequestId = computed((): string => messages.value.length > 0 ? messages.value[messages.value.length - 1].requestId : '')
sending.value = true const canSend = computed((): boolean => context.value != null && !sending.value && !pending.value && !uncertain.value && draft.value.trim().length > 0)
errorMessage.value = '' const sourceLabel = computed((): string => from.value == 'next-step' ? '返回下一步行动 ›' : (from.value == 'product' && productIds.value.length == 1 ? '返回产品 ›' : (from.value == 'compare' ? '返回对比 ›' : '查看报告 ›')))
scrollToEnd() const statusLabel = (item: AssistantTurn): string => item.status == 'queued' ? '问题已收到,等待AI回复…' : (item.status == 'running' ? '正在整理建议…' : (item.status == 'local' ? (sending.value ? '正在确认发送结果…' : '尚未确认发送结果,请查看下方提示。') : '回复暂未完成。' + (item.canRetry ? '您可以重新尝试。' : '已达到本条消息重试上限。')))
try { const answer = await sendAssistantMessage(text); const response: ChatMessage = { role: 'assistant', text: answer }; messages.value.push(response) } catch (error) { errorMessage.value = '暂未收到回复,请重试' } const source = () => {
sending.value = false if (assessmentId.value.length == 0) { uni.reLaunch({ url: '/pages/home/home' }); return }
scrollToEnd() let url = '/packages/finance/report/report?assessmentId=' + assessmentId.value
} if (from.value == 'product' && productIds.value.length == 1) url = '/packages/finance/product/product?assessmentId=' + assessmentId.value + '&productId=' + productIds.value[0] + '&tab=explanation'
const send = () => { if (from.value == 'compare') url = '/packages/finance/compare/compare?assessmentId=' + assessmentId.value + '&productIds=' + productIds.value.join(',')
const text = draft.value.trim() if (from.value == 'next-step') url = '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (productIds.value.length == 1 ? '&productId=' + productIds.value[0] + '&section=prepare' : '&section=actions')
if (text.length == 0 || sending.value) return uni.redirectTo({ url })
lastQuestion.value = text }
const message: ChatMessage = { role: 'user', text: text } const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else source() }
messages.value.push(message) const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeAssistant=1&assessmentId=' + assessmentId.value + '&productIds=' + productIds.value.join(',') + '&chatId=' + chatId.value + '&from=' + from.value }) }
draft.value = '' const stopTimer = () => { if (timer != 0) { clearInterval(timer); timer = 0 } }
requestAnswer(text) const clearContent = () => { context.value = null; messages.value = []; draft.value = ''; sendText = ''; sendKey = ''; uncertain.value = false; histories.value = []; historyCursor = ''; historyOpen.value = false; sendError.value = ''; queryError.value = '' }
} // 请求代次与登录Token双重校验;离页、退出、换账号后旧响应不得回填个人对话。
const ask = (text: string) => { if (sending.value) return; draft.value = text; send() } const current = (ticket: number, token: string): boolean => {
const retry = () => { if (!sending.value && lastQuestion.value.length > 0) requestAnswer(lastQuestion.value) } if (!active.value || ticket != generation) return false
if (getSession()?.getString('token') == token) return true
generation++; clearContent(); stopTimer(); loading.value = false; sending.value = false
pageError.value = '登录状态已变化,请重新登录后读取本人对话。'
if (getSession() == null) login()
return false
}
const scrollEnd = async () => { scrollTarget.value = ''; await nextTick(); if (active.value) scrollTarget.value = 'chat-end' }
const merge = (items: AssistantTurn[]) => { items.forEach((item: AssistantTurn) => { const index = messages.value.findIndex((old: AssistantTurn): boolean => old.requestId == item.requestId); if (index >= 0) messages.value[index] = item; else messages.value.push(item) }) }
const refreshMessages = async () => {
if (chatId.value.length == 0 || polling || loading.value) return
const ticket = generation; const token = scope; polling = true; queryError.value = ''
try { const page = await assistantMessages(chatId.value, ''); if (!current(ticket, token)) return; merge(page.items); pollStopped.value = false; if (pending.value) pollCount++; else { pollCount = 0; scrollEnd() } }
catch (error) { if (current(ticket, token)) queryError.value = error instanceof Error ? error.message : '状态读取失败' }
finally { if (ticket == generation) polling = false }
}
const resumeQuery = () => { pollCount = 0; refreshMessages() }
const beginTimer = () => {
stopTimer(); timer = setInterval(() => {
if (!current(generation, scope)) return
if (pending.value && !polling && !sending.value && queryError.value.length == 0) { if (pollCount >= 60) { pollStopped.value = true; return }; refreshMessages() }
}, 3000)
}
const load = async () => {
const ticket = ++generation; clearContent(); stopTimer(); polling = false; more.value = false; cursor.value = ''; pageError.value = ''; loading.value = false
if (routeError.value.length > 0) { pageError.value = routeError.value; return }
const session = getSession()
if (session == null) { pageError.value = '请先登录后使用AI助手。'; login(); return }
scope = session.getString('token') ?? ''; const token = scope; loading.value = true
try {
if (chatId.value.length > 0) {
const chat = await loadAssistant(chatId.value, assessmentId.value, productIds.value, bindingSpecified)
if (!current(ticket, token)) return
const page = await assistantMessages(chatId.value, '')
if (!current(ticket, token)) return
context.value = chat.context; assessmentId.value = chat.context.assessmentId; productIds.value = chat.context.productIds
messages.value = page.items; more.value = page.more; cursor.value = page.cursor; scrollEnd()
} else { const data = await previewAssistant(assessmentId.value, productIds.value); if (!current(ticket, token)) return; context.value = data }
pollCount = 0; pollStopped.value = false; beginTimer()
} catch (error) { if (current(ticket, token)) { context.value = null; messages.value = []; pageError.value = error instanceof Error ? error.message : '对话读取失败' } }
finally { if (ticket == generation) loading.value = false }
}
const older = async () => {
if (paging.value || !more.value) return
const ticket = generation; const token = scope; paging.value = true; queryError.value = ''
try { const page = await assistantMessages(chatId.value, cursor.value); if (!current(ticket, token)) return; messages.value = page.items.filter((t: AssistantTurn): boolean => !messages.value.some((old: AssistantTurn): boolean => old.requestId == t.requestId)).concat(messages.value); more.value = page.more; cursor.value = page.cursor }
catch (error) { if (current(ticket, token)) queryError.value = error instanceof Error ? error.message : '历史消息读取失败' }
finally { if (ticket == generation) paging.value = false }
}
const updateUrl = () => {
// H5仅更新地址中的ID,刷新后重新鉴权;不把消息、Token或报告写入URL/本地缓存。
// #ifdef WEB
history.replaceState(history.state, '', location.pathname + location.search + '#' + assistantRoute(assessmentId.value, productIds.value, from.value, chatId.value))
// #endif
}
const transmit = async () => {
if (sending.value || context.value == null) return
const ticket = generation; const token = scope; sending.value = true; sendError.value = ''; uncertain.value = true
try {
if (chatId.value.length == 0) { const chat = await createAssistant(assessmentId.value, productIds.value, createKey); if (!current(ticket, token)) return; chatId.value = chat.id; updateUrl() }
const item = await submitAssistant(chatId.value, sendKey, sendText, sendAttempt)
if (!current(ticket, token)) return
merge([item]); uncertain.value = false; draft.value = ''; pollCount = 0; pollStopped.value = false; scrollEnd()
} catch (error) { if (current(ticket, token)) sendError.value = error instanceof Error ? error.message : '发送结果未确认,请重试本次发送' }
finally { if (ticket == generation) sending.value = false }
}
const send = () => { if (!canSend.value) return; sendKey = assistantKey(); sendText = draft.value.trim(); sendAttempt = 0; merge([{ id: '', requestId: sendKey, message: sendText, reply: '', status: 'local', attempt: 0, canRetry: false }]); scrollEnd(); transmit() }
const retryTransport = () => { if (!sending.value && uncertain.value) transmit() }
const retryTurn = (item: AssistantTurn) => { if (sending.value || pending.value || uncertain.value || !item.canRetry || item.requestId != lastRequestId.value) return; sendKey = item.requestId; sendText = item.message; sendAttempt = item.attempt + 1; transmit() }
const chooseQuestion = (text: string) => { if (!sending.value && !pending.value && !uncertain.value) draft.value = text }
const loadHistory = async () => {
if (historyLoading.value) return
const ticket = generation; const token = scope; historyLoading.value = true; historyError.value = ''
try { const page = await assistantHistory(historyCursor); if (!current(ticket, token)) return; histories.value = histories.value.concat(page.items); historyMore.value = page.more; historyCursor = page.cursor }
catch (error) { if (current(ticket, token)) historyError.value = error instanceof Error ? error.message : '历史对话读取失败' }
finally { if (ticket == generation) historyLoading.value = false }
}
const showHistory = () => { historyOpen.value = !historyOpen.value; if (historyOpen.value && histories.value.length == 0) loadHistory() }
const openHistory = (id: string) => { uni.redirectTo({ url: assistantRoute('', [], 'archive', id) }) }
const newChat = () => { uni.redirectTo({ url: assistantRoute(assessmentId.value, productIds.value, from.value) }) }
onLoad((options) => {
assessmentId.value = options?.['assessmentId'] ?? ''; chatId.value = options?.['chatId'] ?? ''; from.value = options?.['from'] ?? ''; bindingSpecified = options?.['assessmentId'] != null || options?.['productIds'] != null
try {
productIds.value = parseCompareIds(options?.['productIds'] ?? '')
if ((assessmentId.value.length > 0 && !validRecordId(assessmentId.value)) || (chatId.value.length > 0 && !validRecordId(chatId.value)) || (productIds.value.length > 0 && assessmentId.value.length == 0)) throw new Error('会话或评估参数无效,请返回重新选择。')
if (!['','matches','product','compare','report','next-step','archive'].includes(from.value)) throw new Error('来源页面无效。')
} catch (error) { routeError.value = error instanceof Error ? error.message : '参数无效' }
})
onShow(() => { active.value = true; load() })
onHide(() => { active.value = false; generation++; stopTimer(); clearContent(); loading.value = false; sending.value = false; paging.value = false; historyLoading.value = false })
onUnload(() => { active.value = false; generation++; stopTimer(); clearContent() })
</script> </script>
<style> <style>
.assistant-page { background-color: #f4f7fb; } .assistant-page { background-color: #fcfdff; }
.chat-scroll { } .chat-shell { flex: 1; }
.chat-content { padding: 28rpx 26rpx 50rpx; } .chat-toolbar { padding: 8rpx 30rpx 18rpx; min-height: 60rpx; flex-direction: row; align-items: center; justify-content: space-between; }
.assistant-intro { flex-direction: row; align-items: flex-start; } .toolbar-note { color: #95a1b3; font-size: 21rpx; }
.assistant-intro-image, .message-avatar { width: 78rpx; height: 78rpx; border-radius: 24rpx; background-color: #e7f2ff; margin-right: 16rpx; } .toolbar-actions { flex-direction: row; }
.assistant-bubble, .message-bubble { max-width: 530rpx; padding: 24rpx; border-radius: 8rpx 22rpx 22rpx 22rpx; background-color: #fff; color: #344259; font-size: 25rpx; line-height: 42rpx; border: 1rpx solid #e4ebf5; } .toolbar-link { color: #2672d9; font-size: 23rpx; line-height: 40rpx; margin-left: 22rpx; }
.quick-questions { margin: 25rpx 0 36rpx 94rpx; } .chat-content { padding: 14rpx 30rpx 36rpx; }
.question { min-height: 72rpx; margin-top: 13rpx; padding: 14rpx 18rpx; border-radius: 16rpx; border: 2rpx solid #bcd9ff; background-color: #eef6ff; color: #0868f7; font-size: 23rpx; flex-direction: row; align-items: center; justify-content: space-between; } .context-card { padding: 20rpx 22rpx; margin-bottom: 30rpx; border-radius: 14rpx; background-color: #f0f6ff; border: 1rpx solid #e0ebfb; }
.question-arrow { font-size: 34rpx; } .context-heading, .context-product, .history-title { flex-direction: row; justify-content: space-between; align-items: center; }
.message-row { margin-top: 22rpx; flex-direction: row; align-items: flex-start; } .context-title { color: #315578; font-size: 24rpx; font-weight: 600; }
.user-row { justify-content: flex-end; } .context-link { color: #2672d9; font-size: 21rpx; }
.user-bubble { color: #fff; background-color: #0868f7; border-color: #0868f7; border-radius: 22rpx 8rpx 22rpx 22rpx; } .context-note { color: #7b8da7; font-size: 21rpx; line-height: 34rpx; margin-top: 6rpx; }
.chat-input { position: fixed; left: 0; right: 0; bottom: 0; min-height: 114rpx; padding: 17rpx 24rpx; background-color: #fff; border-top: 1rpx solid #e3eaf4; flex-direction: row; align-items: center; } .context-product { margin-top: 12rpx; align-items: flex-start; }
.chat-input-input { flex: 1; height: 78rpx; border-radius: 39rpx; background-color: #f2f5f9; padding: 0 28rpx; font-size: 25rpx; } .context-product-name { flex: 1; color: #506580; font-size: 23rpx; line-height: 34rpx; margin-right: 14rpx; }
.chat-input-button { width: 76rpx; height: 76rpx; line-height: 76rpx; border-radius: 50%; margin-left: 15rpx; padding: 0; background-color: #0868f7; align-items: center; justify-content: center; flex-shrink: 0; } .product-state { color: #bc8151; font-size: 21rpx; line-height: 34rpx; flex-shrink: 0; }
.send-disabled { opacity: .55; } .good-state { color: #289875; }
/* #ifndef APP */ .assistant-intro { flex-direction: row; align-items: flex-start; margin-top: 10rpx; }
.chat-input-button { display: flex; } .intro-image { width: 120rpx; height: 138rpx; margin: 6rpx 12rpx 0 -6rpx; flex-shrink: 0; }
/* #endif */ .intro-bubble { flex: 1; padding: 22rpx 24rpx; border-radius: 8rpx 22rpx 22rpx 22rpx; background-color: #f3f8ff; }
.bubble-text { font-size: 27rpx; line-height: 43rpx; color: #344259; } .bubble-text { font-size: 26rpx; line-height: 44rpx; color: #4e5e75; }
.user-text { color: #ffffff; } .quick-questions { margin: 34rpx 0 30rpx 26rpx; align-items: flex-start; }
.question-text { flex: 1; margin-right: 12rpx; color: #0868f7; font-size: 24rpx; line-height: 38rpx; } .question { padding: 12rpx 23rpx; margin-bottom: 18rpx; max-width: 630rpx; border-radius: 32rpx; background-color: #edf7ff; }
.sending-text { font-size: 25rpx; line-height: 40rpx; color: #75869d; } .question-text { color: #2674bd; font-size: 25rpx; line-height: 38rpx; }
.error-text { flex: 1; color: #bb4857; font-size: 23rpx; line-height: 36rpx; } .question-hint { color: #a1abba; font-size: 20rpx; margin: 0 0 0 20rpx; }
.retry-text { color: #0868f7; font-size: 25rpx; padding: 0 6rpx; } .message-row { flex-direction: row; align-items: flex-start; margin-top: 24rpx; }
.chat-end { height: 12rpx; } .user-row { justify-content: flex-end; }
.assistant-error { margin: 18rpx 0 0 94rpx; color: #e05a5a; font-size: 21rpx; flex-direction: row; justify-content: space-between; } .message-avatar { width: 62rpx; height: 70rpx; margin-right: 12rpx; flex-shrink: 0; }
.assistant-error-text-last-child { color: #0868f7; margin-left: 18rpx; } .message-bubble { max-width: 560rpx; flex-shrink: 1; padding: 20rpx 24rpx; background-color: #f1f6fd; border-radius: 6rpx 22rpx 22rpx 22rpx; }
.user-bubble { max-width: 570rpx; padding: 20rpx 24rpx; border-radius: 22rpx 6rpx 22rpx 22rpx; background-color: #1673ef; }
.user-text { color: #ffffff; font-size: 26rpx; line-height: 42rpx; }
.status-text { font-size: 24rpx; color: #8796aa; line-height: 40rpx; }
.composer { position: fixed; bottom: 0; left: 0; right: 0; background-color: #fcfdff; padding: 16rpx 30rpx; border-top: 1rpx solid #eef1f6; }
.input-row { flex-direction: row; align-items: center; }
.chat-input { flex: 1; height: 78rpx; color: #344259; font-size: 26rpx; padding: 0 12rpx; }
.send-button { width: 72rpx; height: 72rpx; border-radius: 36rpx; background-color: #1673ef; padding: 0; margin-left: 16rpx; align-items: center; justify-content: center; }
.send-icon { width: 38rpx; height: 38rpx; }
.composer-note { color: #a0aaba; font-size: 19rpx; text-align: center; margin-top: 10rpx; line-height: 28rpx; }
.error-panel, .history-panel { background-color: #f4f7fc; border-radius: 16rpx; padding: 24rpx; margin-bottom: 28rpx; }
.error-title { color: #5c6680; font-size: 28rpx; margin-bottom: 14rpx; }
.error-text { color: #bb6571; font-size: 23rpx; line-height: 38rpx; }
.retry-link { color: #2672d9; font-size: 24rpx; line-height: 42rpx; margin-top: 10rpx; }
.send-error { margin: 24rpx 0 0 74rpx; }
.load-more { text-align: center; color: #2672d9; font-size: 23rpx; line-height: 40rpx; padding: 18rpx; }
.history-item { flex-direction: row; align-items: center; padding: 20rpx 0; border-bottom: 1rpx solid #e4ecf6; }
.history-label { flex: 1; color: #4e5e75; font-size: 24rpx; }
.history-date { font-size: 20rpx; color: #93a0b2; }
.history-more { text-align: center; margin: 14rpx 0 0; }
.chat-end { height: 30rpx; }
/* #ifndef APP */
.send-button { display: flex; }
.send-button[disabled] { background-color: #6dacf8; color: #ffffff; }
.bubble-text, .user-text { white-space: pre-wrap; overflow-wrap: anywhere; }
/* #endif */
/* #ifdef WEB */
@media (min-width: 800px) { .chat-shell { max-width: 760px; width: 100%; margin: 0 auto; } .composer { max-width: 760px; margin: 0 auto; } }
/* #endif */
</style> </style>
+3
View File
@@ -35,6 +35,7 @@
</scroll-view> </scroll-view>
</view> </view>
<text class="footer-note">历史参数保留评估时版本;当前可用状态单独展示。缺失信息不会被推断为零,演示产品仅供功能验证。</text> <text class="footer-note">历史参数保留评估时版本;当前可用状态单独展示。缺失信息不会被推断为零,演示产品仅供功能验证。</text>
<button class="secondary" @click="consult">产品差异,咨询AI</button>
<button class="secondary" @click="toMatches">返回本次匹配</button> <button class="secondary" @click="toMatches">返回本次匹配</button>
</template> </template>
</view> </view>
@@ -45,6 +46,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 { getSession } from '@/services/api.uts' import { getSession } from '@/services/api.uts'
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
import { MatchDetail, MatchReason, validRecordId, fetchMatchDetailRecord } from '@/packages/finance/services/matches.uts' import { MatchDetail, MatchReason, validRecordId, fetchMatchDetailRecord } from '@/packages/finance/services/matches.uts'
import { parseCompareIds, saveCompareSelection, compareQuery, sameMatchSnapshot, compareReasonText } from '@/packages/finance/services/compare.uts' import { parseCompareIds, saveCompareSelection, compareQuery, sameMatchSnapshot, compareReasonText } from '@/packages/finance/services/compare.uts'
type CompareColumn = { id: string, loading: boolean, error: string, data: MatchDetail | null } type CompareColumn = { id: string, loading: boolean, error: string, data: MatchDetail | null }
@@ -120,6 +122,7 @@
uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, ids.value) }) uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, ids.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') }) }
const remove = (id: string) => { const remove = (id: string) => {
const next = ids.value.filter((value: string): boolean => value != id) const next = ids.value.filter((value: string): boolean => value != id)
saveCompareSelection(assessmentId.value, next) saveCompareSelection(assessmentId.value, next)
@@ -0,0 +1,42 @@
<template>
<view class="export-area">
<view class="export-row"><text class="export-label">报告已保存至融资档案</text><button class="export-button" :disabled="busy" @click="download">{{ busy ? '正在生成…' : '下载PDF' }}</button></view>
<text v-if="message.length > 0" :class="failed ? 'export-message export-error' : 'export-message'">{{ message }}</text>
<text v-if="message.length == 0" class="export-message">导出不含身份信息,请妥善保管下载文件。</text>
</view>
</template>
<script setup lang="uts">
import { getSession } from '@/services/api.uts'
import { ReportDownload, downloadReportPdf } from '@/packages/finance/services/report-export.uts'
const props = defineProps({ assessmentId: { type: String, required: true } })
const emit = defineEmits(['authrequired', 'sessionchanged'])
const busy = ref(false); const message = ref(''); const failed = ref(false)
let operation: ReportDownload | null = null; let generation = 0
const ownerToken = getSession()?.getString('token') ?? ''
const dispose = () => { generation++; operation?.cancel(); operation = null; busy.value = false }
const download = async () => {
if (busy.value) return
if (getSession() == null) { emit('authrequired'); return }
if (getSession()?.getString('token') != ownerToken) { emit('sessionchanged'); return }
const request = ++generation; busy.value = true; failed.value = false; message.value = ''
const token = getSession()?.getString('token') ?? ''
operation = downloadReportPdf(props.assessmentId)
try { const result = await operation!.done; if (request == generation) message.value = result }
catch (err) {
if (request != generation) return
failed.value = true; message.value = err instanceof Error ? err.message : '下载失败,请检查网络后重试。'
if (getSession() == null) emit('authrequired')
else if (getSession()?.getString('token') != token) emit('sessionchanged')
} finally { if (request == generation) { busy.value = false; operation = null } }
}
// 父页面离开即移除报告,组件销毁时中止下载,避免隐藏页面导出旧账户资料。
onUnmounted(() => dispose())
</script>
<style>
.export-area { margin-top: 18rpx; margin-bottom: 22rpx; }
.export-row { flex-direction: row; align-items: center; justify-content: space-between; }
.export-label { font-size: 23rpx; color: #73869d; flex: 1; }
.export-button { font-size: 24rpx; color: #0868f7; background-color: #eaf3ff; border-radius: 10rpx; margin-left: 18rpx; padding-left: 24rpx; padding-right: 24rpx; }
.export-message { color: #8393a6; font-size: 22rpx; line-height: 36rpx; margin-top: 12rpx; }
.export-error { color: #a05d37; }
</style>
+178
View File
@@ -0,0 +1,178 @@
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="下一步行动" :customBack="true" @back="back" />
<scroll-view scroll-y :scroll-into-view="scrollTarget" :style="contentStyle(96, false)">
<view class="content">
<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>
<template v-if="report != null && !loading">
<text class="eyebrow">评估记录 #{{ assessmentId }} · {{ assessmentType == 'enterprise' ? '企业' : '个人' }}评估</text>
<text class="intro">根据本次报告,安排您的下一步</text>
<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="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 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 v-if="section == 'prepare'" id="prepare-section" class="panel">
<text class="heading">材料准备</text>
<text v-if="productId.length == 0" class="body">请先选择本次匹配中的产品,再查看对应材料。本页不会自动替您选择产品。</text>
<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">
<text class="product-title">{{ match!.product.name }}</text><text class="note">{{ match!.product.bank }} · 评估时版本 v{{ match!.product.version }}</text>
<text v-if="match!.product.demo" class="demo">演示产品,仅用于功能验证,不代表真实在售产品。</text>
<text class="status">{{ match!.stateLabel }} · {{ match!.availabilityLabel }}</text>
<template v-if="canPrepare">
<text class="label">所需材料与办理说明</text><text class="body">{{ match!.product.materials }}</text>
<text class="label">申请条件(评估时)</text><text v-for="item in match!.product.conditions" :key="item" class="body">{{ item }}</text><text v-if="match!.product.conditions.length == 0" class="body">未提供</text>
<text class="label">资料来源</text><text class="body">{{ match!.product.source.trim() == '·' ? '未提供' : match!.product.source }}</text><text class="note">{{ match!.product.sourceRef.length > 0 ? match!.product.sourceRef : '来源引用未提供' }}</text><text class="note">核对日期:{{ match!.product.checkedOn.length > 0 ? match!.product.checkedOn : '未提供' }}</text>
<text class="label">机构办理渠道</text><text class="body">未提供。请自行通过机构官方渠道核实产品、材料和办理方式。</text>
</template>
<text v-else class="body">{{ match!.state != 'eligible' ? '本产品存在不符条件或待核实项,暂不能进入申请准备。您仍可查看报告、优化资料或咨询AI。' : '产品当前不可用或状态未知,暂不能进入申请准备。历史报告与评估时产品信息仍保留。' }}</text>
</template>
<button v-if="report!.matching.status == 'completed'" class="secondary" @click="chooseProduct">{{ productId.length == 0 ? '选择本次匹配产品' : '查看本次其他产品' }}</button>
<text v-else class="note">{{ report!.version == 1 ? '旧版报告未执行产品匹配,可重新评估。' : report!.matchingMessage }}</text>
</view>
<view v-if="improveOpen" id="improve-section" class="panel">
<text class="heading">优化资料后,重新评估</text><text class="note">以下内容来自本次已保存报告。请按实际情况核对资料。</text>
<view v-for="item in missingItems" :key="item.label" class="field-row"><text class="field-label">{{ item.label }}</text><text class="field-state">{{ item.value }}</text></view>
<text v-if="missingItems.length == 0" class="body">{{ report!.version == 1 ? '旧版报告未记录逐项完整度,请参考以下报告提示。' : '本次报告没有列出待补充或格式需核实字段,自填资料仍需核验。' }}</text>
<text v-for="item in report!.dataGaps" :key="item" class="body">{{ item }}</text><text v-for="item in report!.suggestions" :key="item" class="body">{{ item }}</text>
<text class="note">将进入同类型空白表单,不带入旧资料。正式提交后生成新记录,原报告保持不变。</text><button class="primary" @click="restart">重新进行{{ assessmentType == 'enterprise' ? '企业' : '个人' }}评估</button>
</view>
<view class="notice"><text class="notice-title">重要提示</text><text class="notice-text">本页仅提供材料准备与办理引导,不向银行或其他机构提交申请。匹配结果基于自填资料,不代表授信或获批承诺;实际产品条件、材料及办理方式以机构核实为准。</text></view>
<ReportExport :assessmentId="assessmentId" @authrequired="login" @sessionchanged="load" />
</template>
<button class="home-button" @click="home">返回首页</button>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import AppHeader from '@/components/AppHeader.uvue'
import ReportExport from '@/packages/finance/components/ReportExport.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { clearAssessmentFlow } from '@/services/assessment-flow.uts'
import { nextStepRoute } from '@/services/next-step-route.uts'
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
import { AnalysisReport, AssessmentField, fetchAnalysisReport, fetchAssessmentStatus } from '@/packages/finance/services/records.uts'
import { MatchView, fetchMatchDetailRecord, validRecordId } from '@/packages/finance/services/matches.uts'
const { pageStyle, contentStyle } = usePageLayout()
const assessmentId = ref(''); const productId = ref(''); const section = ref('actions'); const assessmentType = ref('')
const report = ref<AnalysisReport | null>(null); const match = ref<MatchView | null>(null)
const loading = ref(false); const error = ref(''); const matchError = ref(''); const routeError = ref(''); const improveOpen = ref(false)
const scrollTarget = ref('')
let visible = false; let generation = 0; let loadedToken = ''
const canPrepare = computed((): boolean => match.value?.state == 'eligible' && match.value?.availability == 'available')
const missingItems = computed((): AssessmentField[] => report.value == null ? [] : report.value!.completenessItems.filter((item: AssessmentField): boolean => ['待补充', '需核实'].includes(item.value)))
const reset = () => { report.value = null; match.value = null; assessmentType.value = ''; matchError.value = ''; loadedToken = ''; improveOpen.value = false; scrollTarget.value = '' }
const home = () => { uni.reLaunch({ url: '/pages/home/home' }) }
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeNextStep=1&assessmentId=' + assessmentId.value + '&productId=' + productId.value + '&section=' + section.value }) }
// 离页或账户变更后,丢弃旧请求;重新进入必须重查归属和当前可用性。
const stale = (request: number, token: string): boolean => {
if (!visible || request != generation) return true
if (getSession()?.getString('token') == token) return false
generation++; reset(); loading.value = false
if (getSession() == null) login(); else error.value = '账户已切换,请重新加载本人报告。'
return true
}
const load = async () => {
const request = ++generation; reset(); error.value = ''; loading.value = false
if (routeError.value.length > 0) { error.value = routeError.value; return }
const token = getSession()?.getString('token') ?? ''
if (token.length == 0) { login(); return }
loading.value = true
try {
const status = await fetchAssessmentStatus(assessmentId.value)
if (stale(request, token)) return
if (status.id != assessmentId.value || status.status != 'succeeded' || !['personal', 'enterprise'].includes(status.type)) throw new Error('请从本人已成功生成的报告进入下一步行动。')
const result = await fetchAnalysisReport(assessmentId.value)
if (stale(request, token)) return
report.value = result; assessmentType.value = status.type; loadedToken = token
if (productId.value.length > 0) {
try {
if (result.matching.status != 'completed') throw new Error('本报告没有可读取的产品匹配结果。')
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value)
if (stale(request, token)) return
// 材料必须来自同次评估冻结产品,不允许用公共或其他报告产品补齐。
if (detail.assessmentId != assessmentId.value || detail.resultVersion != result.version || detail.snapshotId != result.matching.snapshotId || detail.ruleVersion != result.matching.ruleVersion || detail.orderingVersion != result.matching.orderingVersion) throw new Error('产品与本报告的冻结版本不一致,请重新选择产品。')
match.value = detail.item
} catch (err) { if (!stale(request, token)) matchError.value = err instanceof Error ? err.message : '产品读取失败,请重试' }
}
} catch (err) { if (!stale(request, token)) { reset(); error.value = err instanceof Error ? err.message : '报告读取失败,请重试' } }
finally {
if (request == generation) {
loading.value = false
if (report.value != null && section.value == 'prepare') nextTick(() => { if (request == generation) scrollTarget.value = 'prepare-section' })
}
}
}
const ready = (): boolean => !stale(generation, loadedToken) && report.value != null && !loading.value
const openReport = () => { if (ready()) uni.navigateTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value }) }
const chooseProduct = () => { if (ready() && report.value!.matching.status == 'completed') uni.navigateTo({ url: '/packages/finance/matches/matches?assessmentId=' + assessmentId.value + '&group=eligible' }) }
const prepare = () => {
if (!ready()) return
if (section.value == 'prepare') { load(); return }
uni.redirectTo({ url: nextStepRoute(assessmentId.value, productId.value, 'prepare') })
}
const optimize = () => {
if (!ready()) return
improveOpen.value = !improveOpen.value; scrollTarget.value = ''
const request = generation
if (improveOpen.value) nextTick(() => { if (request == generation) scrollTarget.value = 'improve-section' })
}
const consult = () => {
if (!ready()) return
if (productId.value.length > 0 && match.value == null) { uni.showToast({ title: '请先重新核对产品或从报告咨询AI', icon: 'none' }); return }
uni.navigateTo({ url: assistantRoute(assessmentId.value, productId.value.length > 0 ? [productId.value] : [], 'next-step') })
}
const restart = () => {
if (!ready() || !improveOpen.value) return
clearAssessmentFlow()
uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + assessmentType.value })
}
const back = () => {
if (getCurrentPages().length > 1) uni.navigateBack()
else if (validRecordId(assessmentId.value)) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value })
else history()
}
onLoad((options) => {
assessmentId.value = options?.['assessmentId'] ?? ''; productId.value = options?.['productId'] ?? ''; section.value = options?.['section'] ?? 'actions'
if (nextStepRoute(assessmentId.value, productId.value, section.value).length == 0) routeError.value = '页面参数无效,请从本次报告或匹配产品重新进入。'
})
onShow(() => { visible = true; load() })
onHide(() => { visible = false; generation++; reset(); loading.value = false; error.value = '' })
onUnload(() => { visible = false; generation++; reset() })
</script>
<style>
.page { background-color: #fff; }
.content { padding: 30rpx 36rpx 64rpx; }
.eyebrow { font-size: 22rpx; color: #91a0b2; }
.intro { font-size: 27rpx; color: #536882; margin-top: 16rpx; line-height: 42rpx; }
.actions { margin-top: 26rpx; }
.action-row { flex-direction: row; align-items: center; padding: 30rpx 0; border-bottom: 1rpx solid #eef2f7; }
.last { border-bottom-width: 0; }
.action-icon { width: 88rpx; height: 88rpx; flex-shrink: 0; }
.action-copy { flex: 1; margin-left: 24rpx; }
.action-title { font-size: 31rpx; font-weight: 700; color: #263d58; }
.action-note { font-size: 23rpx; color: #8795a8; line-height: 36rpx; margin-top: 10rpx; }
.chevron { color: #b2c0d2; font-size: 40rpx; margin-left: 16rpx; }
.panel { padding: 28rpx; margin-top: 24rpx; border: 1rpx solid #e3ebf5; border-radius: 18rpx; background-color: #f9fbff; }
.heading { font-size: 30rpx; color: #243e5c; font-weight: 700; line-height: 44rpx; }
.body { font-size: 25rpx; color: #536981; line-height: 42rpx; margin-top: 16rpx; }
.note { font-size: 22rpx; color: #8292a6; line-height: 36rpx; margin-top: 14rpx; }
.product-title { font-size: 29rpx; color: #214c83; font-weight: 700; margin-top: 24rpx; }
.demo, .status { color: #9a753d; font-size: 23rpx; line-height: 38rpx; margin-top: 16rpx; }
.label { color: #304b69; font-size: 26rpx; font-weight: 700; margin-top: 28rpx; }
.field-row { flex-direction: row; justify-content: space-between; padding: 22rpx 0; border-bottom: 1rpx solid #e8eef6; }
.field-label { flex: 1; font-size: 24rpx; color: #526880; }
.field-state { font-size: 24rpx; color: #aa7b37; margin-left: 16rpx; }
.notice { background-color: #eef6ff; border-radius: 16rpx; padding: 28rpx; margin-top: 32rpx; }
.notice-title { color: #2869b6; font-size: 27rpx; font-weight: 700; }
.notice-text { font-size: 23rpx; line-height: 39rpx; color: #6e87a4; margin-top: 14rpx; }
.primary, .home-button { margin-top: 32rpx; background-color: #0868f7; color: #fff; font-size: 27rpx; border-radius: 12rpx; }
.secondary { margin-top: 24rpx; background-color: #e7f0ff; color: #0868f7; font-size: 25rpx; border-radius: 12rpx; }
.link { color: #0868f7; font-size: 25rpx; margin-top: 24rpx; }
</style>
+56 -42
View File
@@ -22,11 +22,11 @@
</view> </view>
<template v-if="tab == 'explanation' && match != null"> <template v-if="tab == 'explanation' && match != null">
<view class="reason-section"> <view class="reason-section">
<text class="heading good-text">推荐依据({{ match!.positive.length }}条已满足条件)</text> <text class="heading good-text">{{ match!.state == 'eligible' ? '推荐依据(' + match!.positive.length + '条已满足条件)' : '已满足的条件(' + match!.positive.length + '条)' }}</text>
<text v-if="match!.positive.length == 0" class="empty-note">暂无已确认满足的条件,不补充未经验证的推荐理由。</text> <text v-if="match!.positive.length == 0" class="empty-note">暂无已确认满足的条件,不补充未经验证的推荐理由。</text>
<view v-for="(reason, index) in positiveVisible" :key="reason.id" class="reason-row"> <view v-for="(reason, index) in positiveVisible" :key="reason.id" class="reason-row">
<text class="number green-number">{{ index + 1 }}</text> <text class="number green-number">{{ index + 1 }}</text>
<view class="reason-content"><text class="reason-title">{{ reason.text }}</text><text class="reason-detail">{{ reason.explanation.length > 0 ? reason.explanation : reason.evidence.join(';') }}</text><text class="evidence-toggle" @click="openReason = openReason == reason.id ? '' : reason.id">{{ openReason == reason.id ? '收起依据' : '查看依据' }}</text><view v-if="openReason == reason.id" class="evidence-box"><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view></view> <view class="reason-content"><text class="reason-title">{{ reasonTitle(reason) }}</text><text class="reason-detail">{{ reason.explanation.length > 0 ? reason.explanation : reason.evidence.join(';') }}</text><text class="evidence-toggle" @click="openReason = openReason == reason.id ? '' : reason.id">{{ openReason == reason.id ? '收起依据' : '查看依据' }}</text><view v-if="openReason == reason.id" class="evidence-box"><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view></view>
</view> </view>
<text v-if="match!.positive.length > 3" class="expand" @click="expanded = !expanded">{{ expanded ? '收起' : '展开其余' + (match!.positive.length - 3) + '条依据' }}</text> <text v-if="match!.positive.length > 3" class="expand" @click="expanded = !expanded">{{ expanded ? '收起' : '展开其余' + (match!.positive.length - 3) + '条依据' }}</text>
</view> </view>
@@ -35,11 +35,12 @@
<text v-if="attention.length == 0" class="empty-note">已知规则中暂无不符或待核实项,仍需核验资料,不代表审批结果。</text> <text v-if="attention.length == 0" class="empty-note">已知规则中暂无不符或待核实项,仍需核验资料,不代表审批结果。</text>
<view v-for="(reason, index) in attentionVisible" :key="reason.id" class="reason-row"> <view v-for="(reason, index) in attentionVisible" :key="reason.id" class="reason-row">
<text class="number orange-number">{{ index + 1 }}</text> <text class="number orange-number">{{ index + 1 }}</text>
<view class="reason-content"><text class="reason-title">{{ reason.text }}</text><text class="reason-detail">{{ reason.explanation.length > 0 ? reason.explanation : reason.evidence.join(';') }}</text><text class="evidence-toggle" @click="openReason = openReason == reason.id ? '' : reason.id">{{ openReason == reason.id ? '收起依据' : '查看依据' }}</text><view v-if="openReason == reason.id" class="evidence-box"><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view></view> <view class="reason-content"><text class="reason-title">{{ reasonTitle(reason) }}</text><text class="reason-detail">{{ reason.explanation.length > 0 ? reason.explanation : reason.evidence.join(';') }}</text><text class="evidence-toggle" @click="openReason = openReason == reason.id ? '' : reason.id">{{ openReason == reason.id ? '收起依据' : '查看依据' }}</text><view v-if="openReason == reason.id" class="evidence-box"><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view></view>
</view> </view>
<text v-if="attention.length > 3" class="expand" @click="attentionExpanded = !attentionExpanded">{{ attentionExpanded ? '收起' : '展开其余' + (attention.length - 3) + '条注意事项' }}</text> <text v-if="attention.length > 3" class="expand" @click="attentionExpanded = !attentionExpanded">{{ attentionExpanded ? '收起' : '展开其余' + (attention.length - 3) + '条注意事项' }}</text>
</view> </view>
<view class="ai-panel"> <view class="ai-panel">
<text class="link" @click="consult">我有疑问,咨询AI ›</text>
<text class="ai-title">AI详细解释</text> <text class="ai-title">AI详细解释</text>
<text v-if="match!.aiStatus == 'completed'" class="ai-note">已将逐条解释补充在上方,生成结果已保存,重新打开无需再次生成。</text> <text v-if="match!.aiStatus == 'completed'" class="ai-note">已将逐条解释补充在上方,生成结果已保存,重新打开无需再次生成。</text>
<text v-else-if="aiPending" class="ai-note">{{ pollStopped ? '生成仍在进行,可稍后查看状态。' : '正在解释本次匹配依据,请稍候。离开页面后任务仍会继续。' }}</text> <text v-else-if="aiPending" class="ai-note">{{ pollStopped ? '生成仍在进行,可稍后查看状态。' : '正在解释本次匹配依据,请稍候。离开页面后任务仍会继续。' }}</text>
@@ -68,13 +69,13 @@
<view class="details-section"><text class="heading">产品准入条件</text><text class="muted">公开条件不代表个人已符合,缺少核验依据时仍需核实。</text><text v-for="(condition, index) in product!.conditions" :key="index" class="condition">{{ condition }}</text></view> <view class="details-section"><text class="heading">产品准入条件</text><text class="muted">公开条件不代表个人已符合,缺少核验依据时仍需核实。</text><text v-for="(condition, index) in product!.conditions" :key="index" class="condition">{{ condition }}</text></view>
<view class="details-section"><text class="heading">资料准备说明</text><text class="body">{{ product!.materials }}</text><text class="muted">仅提供材料引导,不向机构提交申请。实际条件和材料须向机构核实。</text></view> <view class="details-section"><text class="heading">资料准备说明</text><text class="body">{{ product!.materials }}</text><text class="muted">仅提供材料引导,不向机构提交申请。实际条件和材料须向机构核实。</text></view>
</template> </template>
<view class="footnotes"><text class="record-note">{{ assessmentId.length > 0 ? '本次评估 #' + assessmentId : '公共产品资料' }} · 产品版本 v{{ product!.version }}</text><text v-if="match != null" class="record-note">{{ match!.availabilityLabel }},历史参数与结论保留。</text><text v-if="match != null && !canPrepare" class="record-note">{{ match!.state != 'eligible' ? '请先核对不符条件或待核实项。' : '当前产品不可用或状态未知,暂不提供准备入口。' }}</text><text class="record-note">自填信息未经核验;本页面不承诺额度、放款或获批。</text><text v-if="assessmentId.length > 0" class="link" @click="toMatches">返回本次匹配</text><text v-else class="link" @click="history">选择评估查看匹配结果</text></view> <view class="footnotes"><text class="record-note">{{ assessmentId.length > 0 ? '本次评估 #' + assessmentId : '公共产品资料' }} · 产品版本 v{{ product!.version }}</text><text v-if="match != null" class="record-note">{{ match!.availabilityLabel }},历史参数与结论保留。</text><text v-if="match != null && !canPrepare" class="record-note">{{ match!.state != 'eligible' ? '请先核对不符条件或待核实项。' : '当前产品不可用或状态未知,暂不提供准备入口。' }}</text><text class="record-note">自填信息未经核验;本页面不承诺额度、放款或获批。</text><text v-if="assessmentId.length > 0" class="link" @click="openCompare">查看同类产品对比</text><text v-if="assessmentId.length > 0" class="link" @click="toMatches">返回本次匹配</text><text v-else class="link" @click="history">选择评估查看匹配结果</text></view>
</template> </template>
</view> </view>
</scroll-view> </scroll-view>
<view v-if="pageActive && product != null" class="action-bar" :style="bottomStyle(22)"> <view v-if="pageActive && product != null" class="action-bar" :style="bottomStyle(22)">
<template v-if="match != null && canPrepare"><button class="footer-button secondary" @click="openCompare">查看同类产品对比</button><button class="footer-button primary" @click="prepare">{{ tab == 'conditions' ? '返回本次匹配' : '查看资料准备' }}</button></template> <template v-if="match != null && canPrepare"><button class="footer-button secondary" @click="openCompare">查看同类产品对比</button><button class="footer-button primary" @click="prepare">准备申请</button></template>
<template v-else-if="match != null"><button class="footer-button secondary" @click="switchTab(tab == 'details' ? 'explanation' : 'details')">{{ tab == 'details' ? '查看匹配解释' : '查看产品详情' }}</button><button class="footer-button primary" @click="openCompare">{{ selectedIds.includes(productId) ? '查看产品对比' : '加入产品对比' }}</button></template> <template v-else-if="match != null"><button class="footer-button secondary" @click="switchTab(tab == 'details' ? 'explanation' : 'details')">{{ tab == 'details' ? '查看匹配解释' : '查看产品详情' }}</button><button class="footer-button primary" @click="consult">我有疑问,咨询AI</button></template>
<button v-else class="footer-button primary" @click="switchTab(tab == 'conditions' ? 'details' : 'conditions')">{{ tab == 'conditions' ? '查看产品详情' : '查看申请条件' }}</button> <button v-else class="footer-button primary" @click="switchTab(tab == 'conditions' ? 'details' : 'conditions')">{{ tab == 'conditions' ? '查看产品详情' : '查看申请条件' }}</button>
</view> </view>
</view> </view>
@@ -85,6 +86,7 @@
import { getSession } from '@/services/api.uts' import { getSession } from '@/services/api.uts'
import { MatchView, MatchReason, ProductView, fetchMatchDetail, fetchPublicProduct, validRecordId, requestProductExplanation } from '@/packages/finance/services/matches.uts' import { MatchView, MatchReason, ProductView, fetchMatchDetail, 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 } from '@/packages/finance/services/compare.uts'
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout() const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
const assessmentId = ref(''); const productId = ref(''); const tab = ref('details') const assessmentId = ref(''); const productId = ref(''); const tab = ref('details')
const selectedIds = ref<string[]>([]); let from = '' const selectedIds = ref<string[]>([]); let from = ''
@@ -99,6 +101,46 @@
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))
const aiPending = computed((): boolean => match.value != null && ['queued','running'].includes(match.value!.aiStatus)) const aiPending = computed((): boolean => match.value != null && ['queued','running'].includes(match.value!.aiStatus))
const canPrepare = computed((): boolean => match.value != null && match.value!.state == 'eligible' && match.value!.availability == 'available') const canPrepare = computed((): boolean => match.value != null && match.value!.state == 'eligible' && match.value!.availability == 'available')
// 缩短重复的规则模板标题;核验限制仍保留在逐条依据和页尾,不改判定含义。
const reasonTitle = (reason: MatchReason): string => reason.text.replace('(资料尚需核验)。', '').replace('本产品已配置条件', '产品条件')
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 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
}
let pollAction: () => void = () => {}
function schedulePoll(): void {
stopPoll()
if (!visible || !aiPending.value) return
if (++pollCount > 60) { pollStopped.value = true; return }
timer = setTimeout(() => { timer = 0; pollAction() }, 3000)
}
async function refreshExplanation(): Promise<void> {
if (aiBusy.value || match.value == null) return
const request = generation; const token = getSession()?.getString('token') ?? ''
aiError.value = ''; aiBusy.value = true
try {
const result = await fetchMatchDetail(assessmentId.value, productId.value)
if (current(request, token)) { match.value = result; schedulePoll() }
} catch (err) { explanationError(request, token, err instanceof Error ? err.message : '解释状态读取失败,请重试')
} finally { if (current(request, token)) aiBusy.value = false }
}
pollAction = () => { refreshExplanation() }
const checkExplanation = () => { pollCount = 0; pollStopped.value = false; refreshExplanation() }
const generateExplanation = async () => {
if (aiBusy.value || match.value == null || aiPending.value || match.value!.aiStatus == 'completed') return
const request = generation; const token = getSession()?.getString('token') ?? ''
// 网络结果不确定时保留同一个幂等键,只有收到明确失败状态后才开始新的尝试。
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
try {
const result = await requestProductExplanation(assessmentId.value, productId.value, requestKey)
if (current(request, token)) { match.value = result; requestKey = ''; schedulePoll() }
} catch (err) { explanationError(request, token, err instanceof Error ? err.message : 'AI解释请求失败,请重试')
} finally { if (current(request, token)) aiBusy.value = false }
}
const load = async () => { const load = async () => {
stopPoll(); pollCount = 0; pollStopped.value = false; aiError.value = '' stopPoll(); pollCount = 0; pollStopped.value = false; aiError.value = ''
const request = ++generation; product.value = null; match.value = null; error.value = ''; expanded.value = false const request = ++generation; product.value = null; match.value = null; error.value = ''; expanded.value = false
@@ -118,46 +160,18 @@
} catch (err) { if (visible && generation == request) error.value = err instanceof Error ? err.message : '产品读取失败' } } catch (err) { if (visible && generation == request) 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' }) }
// 仅携带选择ID;助手会重新校验归属并读取冻结事实,进入页面不会调用模型。
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, [productId.value], 'product') }) }
function toMatches(): void { if (assessmentId.value.length > 0) uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, selectedIds.value) }); else history() }
// 替换当前路由保留栏目、来源与对比选择,刷新后仍停留在同一栏。 // 替换当前路由保留栏目、来源与对比选择,刷新后仍停留在同一栏。
const switchTab = (value: string) => { const switchTab = (value: string) => {
if (value == tab.value) return if (value == tab.value) return
const query = assessmentId.value.length > 0 ? compareQuery(assessmentId.value, selectedIds.value) + '&' : '?' const query = assessmentId.value.length > 0 ? compareQuery(assessmentId.value, selectedIds.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) { if (tab.value == 'conditions') toMatches(); else switchTab('conditions') } } // 下一步页重新核对当前可用性,避免沿用产品详情进入时的旧状态。
const stopPoll = () => { if (timer != 0) clearTimeout(timer); timer = 0 } const prepare = () => { if (canPrepare.value) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + '&productId=' + productId.value + '&section=prepare' }) }
const current = (request: number, token: string): boolean => visible && generation == request && getSession()?.getString('token') == token
const refreshExplanation = async () => {
if (aiBusy.value || match.value == null) return
const request = generation; const token = getSession()?.getString('token') ?? ''
aiError.value = ''; aiBusy.value = true
try {
const result = await fetchMatchDetail(assessmentId.value, productId.value)
if (current(request, token)) { match.value = result; schedulePoll() }
} catch (err) { if (current(request, token)) aiError.value = err instanceof Error ? err.message : '解释状态读取失败,请重试'
} finally { if (current(request, token)) aiBusy.value = false }
}
const schedulePoll = () => {
stopPoll()
if (!visible || !aiPending.value) return
if (++pollCount > 60) { pollStopped.value = true; return }
timer = setTimeout(() => { timer = 0; refreshExplanation() }, 3000)
}
const checkExplanation = () => { pollCount = 0; pollStopped.value = false; refreshExplanation() }
const generateExplanation = async () => {
if (aiBusy.value || match.value == null || aiPending.value || match.value!.aiStatus == 'completed') return
const request = generation; const token = getSession()?.getString('token') ?? ''
// 网络结果不确定时保留同一个幂等键,只有收到明确失败状态后才开始新的尝试。
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
try {
const result = await requestProductExplanation(assessmentId.value, productId.value, requestKey)
if (current(request, token)) { match.value = result; requestKey = ''; schedulePoll() }
} catch (err) { if (current(request, token)) aiError.value = err instanceof Error ? err.message : 'AI解释请求失败,请重试'
} finally { if (current(request, token)) aiBusy.value = false }
}
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
const toMatches = () => { if (assessmentId.value.length > 0) uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, selectedIds.value) }); else history() }
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else if (from == 'compare') uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value) }); else toMatches() } const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else if (from == 'compare') uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value) }); else toMatches() }
const openCompare = () => { const openCompare = () => {
if (match.value == null) return if (match.value == null) return
@@ -218,8 +232,8 @@
.green-number { background-color: #35b38c; } .green-number { background-color: #35b38c; }
.orange-number { background-color: #f49162; } .orange-number { background-color: #f49162; }
.reason-content { flex: 1; } .reason-content { flex: 1; }
.reason-title { color: #27364c; font-size: 26rpx; line-height: 40rpx; font-weight: 600; } .reason-title { color: #27364c; font-size: 28rpx; line-height: 42rpx; font-weight: 600; }
.reason-detail { color: #7b8594; font-size: 23rpx; line-height: 36rpx; margin-top: 4rpx; } .reason-detail { color: #7b8594; font-size: 25rpx; line-height: 39rpx; margin-top: 4rpx; }
.evidence-toggle { color: #8596ad; font-size: 21rpx; padding: 8rpx 0; } .evidence-toggle { color: #8596ad; font-size: 21rpx; padding: 8rpx 0; }
.evidence-box { background-color: #f5f8fc; border-radius: 8rpx; padding: 8rpx 14rpx 14rpx; margin-top: 4rpx; } .evidence-box { background-color: #f5f8fc; border-radius: 8rpx; padding: 8rpx 14rpx 14rpx; margin-top: 4rpx; }
.evidence { color: #7f8b9b; font-size: 22rpx; line-height: 36rpx; margin-top: 8rpx; } .evidence { color: #7f8b9b; font-size: 22rpx; line-height: 36rpx; margin-top: 8rpx; }
+10 -2
View File
@@ -8,6 +8,7 @@
<text v-if="loading" class="body-text">正在读取已保存报告…</text> <text v-if="loading" class="body-text">正在读取已保存报告…</text>
<view v-if="error.length > 0" class="panel"><text class="section-title">报告暂不可读取</text><text class="body-text">{{ error }}</text><button v-if="routeError.length == 0" class="secondary" @click="loadReport">重新加载报告</button><text class="action" @click="history">前往融资档案</text></view> <view v-if="error.length > 0" class="panel"><text class="section-title">报告暂不可读取</text><text class="body-text">{{ error }}</text><button v-if="routeError.length == 0" class="secondary" @click="loadReport">重新加载报告</button><text class="action" @click="history">前往融资档案</text></view>
<template v-if="report != null"> <template v-if="report != null">
<ReportExport :assessmentId="assessmentId" @authrequired="login" @sessionchanged="loadReport" />
<text class="report-note">生成于 {{ report!.generatedAt }} · 用户自填资料分析</text> <text class="report-note">生成于 {{ report!.generatedAt }} · 用户自填资料分析</text>
<view v-if="report!.version == 1" class="legacy"><text class="report-note">这是一份旧版自填报告,当时未采集融资需求、未计算完整度与准备状态,也未执行产品匹配。历史内容保持原样。</text></view> <view v-if="report!.version == 1" class="legacy"><text class="report-note">这是一份旧版自填报告,当时未采集融资需求、未计算完整度与准备状态,也未执行产品匹配。历史内容保持原样。</text></view>
<template v-if="tab == 'overview'"> <template v-if="tab == 'overview'">
@@ -49,6 +50,8 @@
<template v-else><view v-for="group in adviceGroups" :key="group.id" class="panel"><text class="section-title">{{ group.label }}</text><text class="report-note">仅展示本组前3项产品的已有依据,可进入完整列表查看更多。</text><text v-if="group.loading" class="body-text">正在读取产品依据…</text><template v-if="group.error.length > 0"><text class="body-text">{{ group.error }}</text><button class="secondary" @click="loadAdvice(group.id)">重试{{ group.label }}</button></template><text v-if="!group.loading && group.error.length == 0 && group.items.length == 0" class="report-note">本组暂无产品依据。</text><view v-for="item in group.items" :key="item.id" class="advice-product"><text class="product-name" @click="openProduct(item.id)">{{ item.product.name }} ›</text><text class="source-note">{{ item.stateLabel }} · 评估时版本 v{{ item.product.version }}</text><view v-for="reason in adviceReasons(item)" :key="reason.id"><text class="body-text">{{ reason.text }}</text><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view><text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }}</text></view><text class="action" @click="allMatches(group.id)">查看全部{{ group.label }} ›</text></view></template> <template v-else><view v-for="group in adviceGroups" :key="group.id" class="panel"><text class="section-title">{{ group.label }}</text><text class="report-note">仅展示本组前3项产品的已有依据,可进入完整列表查看更多。</text><text v-if="group.loading" class="body-text">正在读取产品依据…</text><template v-if="group.error.length > 0"><text class="body-text">{{ group.error }}</text><button class="secondary" @click="loadAdvice(group.id)">重试{{ group.label }}</button></template><text v-if="!group.loading && group.error.length == 0 && group.items.length == 0" class="report-note">本组暂无产品依据。</text><view v-for="item in group.items" :key="item.id" class="advice-product"><text class="product-name" @click="openProduct(item.id)">{{ item.product.name }} ›</text><text class="source-note">{{ item.stateLabel }} · 评估时版本 v{{ item.product.version }}</text><view v-for="reason in adviceReasons(item)" :key="reason.id"><text class="body-text">{{ reason.text }}</text><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view><text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }}</text></view><text class="action" @click="allMatches(group.id)">查看全部{{ group.label }} ›</text></view></template>
</template> </template>
<text class="disclaimer">{{ report!.disclaimer }}</text> <text class="disclaimer">{{ report!.disclaimer }}</text>
<button class="primary" @click="nextStep">下一步行动</button>
<button class="secondary" @click="consult">对报告有疑问,咨询AI</button>
<view class="bottom-actions"><button class="secondary" @click="start">重新评估</button><button class="secondary" @click="history">查看历史评估</button></view> <view class="bottom-actions"><button class="secondary" @click="start">重新评估</button><button class="secondary" @click="history">查看历史评估</button></view>
</template> </template>
</view> </view>
@@ -57,8 +60,10 @@
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import AppHeader from '@/components/AppHeader.uvue' import AppHeader from '@/components/AppHeader.uvue'
import ReportExport from '@/packages/finance/components/ReportExport.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 { assistantRoute } from '@/packages/finance/services/assistant.uts'
import { clearAssessmentFlow } from '@/services/assessment-flow.uts' import { clearAssessmentFlow } from '@/services/assessment-flow.uts'
import { AnalysisReport, AssessmentField, fetchAnalysisReport } from '@/packages/finance/services/records.uts' import { AnalysisReport, AssessmentField, fetchAnalysisReport } from '@/packages/finance/services/records.uts'
import { MatchPage, MatchView, MatchReason, fetchMatchPage, validRecordId } from '@/packages/finance/services/matches.uts' import { MatchPage, MatchView, MatchReason, fetchMatchPage, validRecordId } from '@/packages/finance/services/matches.uts'
@@ -71,6 +76,7 @@
const candidates = ref<MatchView[]>([]); const candidateLoading = ref(false); const candidateError = ref('') const candidates = ref<MatchView[]>([]); const candidateLoading = ref(false); const candidateError = ref('')
const adviceGroups = ref<AdviceGroup[]>([]) const adviceGroups = ref<AdviceGroup[]>([])
let generation = 0; let visible = false let generation = 0; let visible = false
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeReport=1&assessmentId=' + assessmentId.value + '&tab=' + tab.value }) }
const missingItems = computed((): AssessmentField[] => report.value == null ? [] : report.value!.completenessItems.filter((item: AssessmentField): boolean => ['待补充','需核实'].includes(item.value))) const missingItems = computed((): AssessmentField[] => report.value == null ? [] : report.value!.completenessItems.filter((item: AssessmentField): boolean => ['待补充','需核实'].includes(item.value)))
const adviceReasons = (item: MatchView): MatchReason[] => item.state == 'ineligible' ? item.blocking.concat(item.cautions) : item.cautions const adviceReasons = (item: MatchView): MatchReason[] => item.state == 'ineligible' ? item.blocking.concat(item.cautions) : item.cautions
const reset = () => { report.value = null; candidates.value = []; adviceGroups.value = []; candidateLoading.value = false; candidateError.value = ''; factsOpen.value = false; completenessOpen.value = false } const reset = () => { report.value = null; candidates.value = []; adviceGroups.value = []; candidateLoading.value = false; candidateError.value = ''; factsOpen.value = false; completenessOpen.value = false }
@@ -79,7 +85,7 @@
if (!visible || request != generation) return true if (!visible || request != generation) return true
if (getSession()?.getString('token') == token) return false if (getSession()?.getString('token') == token) return false
generation++; reset(); loading.value = false generation++; reset(); loading.value = false
if (getSession() == null) uni.redirectTo({ url: '/packages/account/login/login' }) if (getSession() == null) login()
else error.value = '账户已切换,请重新加载本人报告。' else error.value = '账户已切换,请重新加载本人报告。'
return true return true
} }
@@ -114,7 +120,7 @@
const request = ++generation; reset(); error.value = ''; loading.value = false const request = ++generation; reset(); error.value = ''; loading.value = false
if (routeError.value.length > 0) { error.value = routeError.value; return } if (routeError.value.length > 0) { error.value = routeError.value; 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') ?? ''; loading.value = true
try { try {
const result = await fetchAnalysisReport(assessmentId.value) const result = await fetchAnalysisReport(assessmentId.value)
@@ -131,6 +137,8 @@
// 切栏替换当前路由,刷新恢复所选栏目;只读取已保存快照,不提交评估或触发模型。 // 切栏替换当前路由,刷新恢复所选栏目;只读取已保存快照,不提交评估或触发模型。
const switchTab = (value: string) => { if (value != tab.value && routeError.value.length == 0) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value + '&tab=' + value }) } const switchTab = (value: string) => { if (value != tab.value && routeError.value.length == 0) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value + '&tab=' + value }) }
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) } const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, [], 'report') }) }
const nextStep = () => { if (report.value != null) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + '&section=actions' }) }
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else uni.redirectTo({ url: '/packages/finance/archive/archive' }) } const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else uni.redirectTo({ url: '/packages/finance/archive/archive' }) }
const allMatches = (group: string) => { uni.navigateTo({ url: '/packages/finance/matches/matches?assessmentId=' + assessmentId.value + '&group=' + group }) } const allMatches = (group: string) => { uni.navigateTo({ url: '/packages/finance/matches/matches?assessmentId=' + assessmentId.value + '&group=' + group }) }
const openProduct = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product?assessmentId=' + assessmentId.value + '&productId=' + id + '&tab=explanation&from=report' }) } const openProduct = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product?assessmentId=' + assessmentId.value + '&productId=' + id + '&tab=explanation&from=report' }) }
-12
View File
@@ -1,18 +1,6 @@
import { API_DEMO_ENABLED, API_BASE_URL, wait, requestJson } from '@/services/api.uts'
import { createAssessment } from './records.uts' import { createAssessment } from './records.uts'
export { API_BASE_URL } from '@/services/api.uts' export { API_BASE_URL } from '@/services/api.uts'
export async function submitAssessment(payload: UTSJSONObject): Promise<string> { export async function submitAssessment(payload: UTSJSONObject): Promise<string> {
return await createAssessment(payload) return await createAssessment(payload)
} }
export async function sendAssistantMessage(message: string): Promise<string> {
if (API_DEMO_ENABLED) {
await wait(600)
return '结合您的评估结果,建议优先选择年化利率较低、可随借随还的产品,并重点确认提前还款规则。'
}
const result = await requestJson('/assistant/messages', 'POST', { message })
const reply = result['reply'] as string | null
if (reply == null || reply.length == 0) throw new Error('助手暂时没有返回内容')
return reply
}
+42
View File
@@ -0,0 +1,42 @@
import { requestJson } from '@/services/api.uts'
export type AssistantProduct = { id: string, name: string, state: string, stateLabel: string, demo: boolean }
export type AssistantContext = { assessmentId: string, productIds: string[], title: string, products: AssistantProduct[], questions: string[], notice: string }
export type AssistantChat = { id: string, title: string, context: AssistantContext }
export type AssistantTurn = { id: string, requestId: string, message: string, reply: string, status: string, attempt: number, canRetry: boolean }
export type AssistantPage = { items: AssistantTurn[], more: boolean, cursor: string }
export type AssistantHistory = { id: string, title: string, createdAt: string }
export type AssistantHistoryPage = { items: AssistantHistory[], more: boolean, cursor: string }
function text(data: UTSJSONObject, key: string): string { return data.getString(key) ?? '' }
function contextView(data: UTSJSONObject): AssistantContext {
return { assessmentId: text(data, 'assessmentId'), productIds: data['productIds'] as string[], title: text(data, 'title'),
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') }
}
function chatView(data: UTSJSONObject): AssistantChat { return { id: text(data, 'chatId'), title: text(data, 'title'), context: contextView(data['context'] as UTSJSONObject) } }
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[]): UTSJSONObject { return { contextAssessmentId: assessmentId, contextProductIds: ids.join(',') } }
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 = ''): string {
return '/packages/finance/assistant/assistant?from=' + from + (assessmentId.length > 0 ? '&assessmentId=' + assessmentId + '&productIds=' + ids.join(',') : '') + (chatId.length > 0 ? '&chatId=' + chatId : '')
}
export async function previewAssistant(assessmentId: string, ids: string[]): Promise<AssistantContext> { return contextView(await requestJson('/assistant/context', 'GET', contextQuery(assessmentId, ids))) }
export async function loadAssistant(chatId: string, assessmentId: string, ids: string[], checkBinding: boolean): Promise<AssistantChat> {
const result = chatView(await requestJson('/assistant/chats/' + chatId, 'GET', checkBinding ? contextQuery(assessmentId, ids) : new UTSJSONObject()))
if (result.id != chatId) throw new Error('返回的会话不一致,请重新加载。')
return result
}
export async function createAssistant(assessmentId: string, ids: string[], requestId: string): Promise<AssistantChat> { return chatView(await requestJson('/assistant/chats', 'POST', { contextAssessmentId: assessmentId.length > 0 ? assessmentId : null, contextProductIds: ids, requestId })) }
export async function assistantMessages(chatId: string, cursor: string): Promise<AssistantPage> {
const data = await requestJson('/assistant/chats/' + chatId + '/messages', 'GET', { limit: '20', cursor })
if (text(data, 'chatId') != chatId) throw new Error('消息不属于本次会话。')
return { items: (data['items'] as UTSJSONObject[]).map((v: UTSJSONObject): AssistantTurn => turnView(v)), more: data.getBoolean('hasMore') ?? false, cursor: text(data, 'nextCursor') }
}
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 })
if (text(data, 'chatId') != chatId) throw new Error('回复不属于本次会话。')
return turnView(data['item'] as UTSJSONObject)
}
export async function assistantHistory(cursor: string): Promise<AssistantHistoryPage> {
const data = await requestJson('/assistant/chats', 'GET', { limit: '20', cursor })
return { items: (data['items'] as UTSJSONObject[]).map((v: UTSJSONObject): AssistantHistory => ({ id: text(v, 'chatId'), title: text(v, 'title'), createdAt: text(v, 'createdAt').slice(0, 10) })), more: data.getBoolean('hasMore') ?? false, cursor: text(data, 'nextCursor') }
}
@@ -0,0 +1,64 @@
import { API_BASE_URL, getSession, clearSession } from '@/services/api.uts'
import { APP_ENVIRONMENT } from '@/services/config.uts'
export type ReportDownload = { cancel: () => void, done: Promise<string> }
export function downloadReportPdf(id: string): ReportDownload {
// 首版仅开放普通浏览器H5;其他端明确提示,不生成假文件或成功回执。
// #ifndef H5
return { cancel: () => {}, done: new Promise<string>((_resolve, reject) => { reject(new Error('当前客户端暂未开放PDF下载,请在普通浏览器打开H5并登录后下载。')) }) }
// #endif
// #ifdef H5
const controller = new AbortController()
let cancelled = false
const cancel = () => { cancelled = true; controller.abort() }
const done = async (): Promise<string> => {
if (!/^[1-9][0-9]{0,9}$/.test(id)) throw new Error('报告编号无效,请重新打开本人报告。')
if (/MicroMessenger/i.test(navigator.userAgent)) throw new Error('微信内浏览器暂不支持此下载。请点右上角菜单,选择“在浏览器打开”,登录后重新点击下载PDF。')
const token = getSession()?.getString('token') ?? ''
if (token.length == 0) throw new Error('登录已过期,请重新登录后下载。')
if (API_BASE_URL.length == 0 || (APP_ENVIRONMENT == 'production' && !API_BASE_URL.startsWith('https://'))) throw new Error('下载服务地址尚未配置。')
const current = () => {
if (cancelled || getSession()?.getString('token') != token) throw new Error('页面或登录状态已变化,本次下载已取消。')
}
const timeout = setTimeout(() => controller.abort(), 45000)
try {
const response = await fetch(API_BASE_URL + '/assessments/' + id + '/report.pdf', {
method: 'GET', headers: { Authorization: 'Bearer ' + token, 'X-Requested-With': 'XMLHttpRequest' },
cache: 'no-store', credentials: 'omit', redirect: 'error', signal: controller.signal
})
current()
if (response.status == 401) { clearSession(); throw new Error('登录已过期,请重新登录后下载。') }
const mime = (response.headers.get('Content-Type') ?? '').split(';')[0].trim().toLowerCase()
if (!response.ok || mime != 'application/pdf') {
let message = 'PDF暂不可下载,请稍后重试;已保存报告不受影响。'
if (mime == 'application/json') {
const body = await response.json()
if (typeof body.msg == 'string' && body.msg.length > 0 && body.msg.length < 200) message = body.msg
}
current(); throw new Error(message)
}
const declaredSize = Number(response.headers.get('Content-Length') ?? '0')
if (declaredSize > 20971520) throw new Error('PDF文件过大,请联系管理员。')
const bytes = await response.arrayBuffer()
current()
const start = new TextDecoder('ascii').decode(bytes.slice(0, 5))
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,请重试。')
// 验证响应后才创建本机Blob。令牌只放请求头,不写URL、文件名或本机存储。
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
const link = document.createElement('a')
link.href = url; link.download = 'financing-report-' + id + '.pdf'; link.style.display = 'none'
document.body.appendChild(link); link.click(); link.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
return 'PDF已交给浏览器处理,请查看下载列表;若打开预览,可使用浏览器的保存功能。'
} catch (err) {
if (cancelled) throw new Error('下载已取消。')
if (controller.signal.aborted) throw new Error('PDF生成超时,请稍后重试。')
if (err instanceof TypeError) throw new Error('网络连接失败,请检查网络后重试。')
throw err
} finally { clearTimeout(timeout) }
}
return { cancel, done: done() }
// #endif
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><rect width="52" height="52" rx="12" fill="#0870f7"/><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12h26v23H24l-8 6v-6h-4zM19 21h12m-12 7h8"/></g></svg>

After

Width:  |  Height:  |  Size: 296 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><rect width="52" height="52" rx="12" fill="#0870f7"/><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 34v6h6l20-20-6-6zM28 19l6 6M13 15h10M13 23h5"/></g></svg>

After

Width:  |  Height:  |  Size: 300 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><rect width="52" height="52" rx="12" fill="#0870f7"/><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 15h12m-12 7h12m-12 7h7"/><rect x="12" y="9" width="24" height="31" rx="3"/><path d="m28 33 3 3 6-7"/></g></svg>

After

Width:  |  Height:  |  Size: 354 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><rect width="52" height="52" rx="12" fill="#0870f7"/><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 10h15l7 7v23H15zM29 10v9h8M21 25h10m-10 7h10"/></g></svg>

After

Width:  |  Height:  |  Size: 300 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="m5 5 15 7-15 7 3-7-3-7Z" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 12h7" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 292 B

+1
View File
@@ -11,6 +11,7 @@
{ "path": "confirm/confirm", "style": { "navigationStyle": "custom" } }, { "path": "confirm/confirm", "style": { "navigationStyle": "custom" } },
{ "path": "analyzing/analyzing", "style": { "navigationStyle": "custom" } }, { "path": "analyzing/analyzing", "style": { "navigationStyle": "custom" } },
{ "path": "report/report", "style": { "navigationStyle": "custom" } }, { "path": "report/report", "style": { "navigationStyle": "custom" } },
{ "path": "next-step/next-step", "style": { "navigationStyle": "custom" } },
{ "path": "matches/matches", "style": { "navigationStyle": "custom" } }, { "path": "matches/matches", "style": { "navigationStyle": "custom" } },
{ "path": "product/product", "style": { "navigationStyle": "custom" } }, { "path": "product/product", "style": { "navigationStyle": "custom" } },
{ "path": "compare/compare", "style": { "navigationStyle": "custom" } }, { "path": "compare/compare", "style": { "navigationStyle": "custom" } },
+11
View File
@@ -0,0 +1,11 @@
// 登录返回只允许助手路由与编号;拒绝任意returnUrl,重新登录后仍由服务端校验归属。
export function assistantReturnPath(assessmentId: string, productIds: string, chatId: string, from: string): string {
const valid = (id: string): boolean => /^[1-9][0-9]{0,9}$/.test(id)
if ((assessmentId.length > 0 && !valid(assessmentId)) || (chatId.length > 0 && !valid(chatId))) return ''
const ids = productIds.length == 0 ? [] as string[] : productIds.split(',')
if (ids.length > 3 || (ids.length > 0 && assessmentId.length == 0)) return ''
const seen: string[] = []
for (let i = 0; i < ids.length; i++) { if (!valid(ids[i]) || seen.includes(ids[i])) return ''; seen.push(ids[i]) }
if (!['','matches','product','compare','report','next-step','archive'].includes(from)) return ''
return '/packages/finance/assistant/assistant?from=' + from + (assessmentId.length > 0 ? '&assessmentId=' + assessmentId + '&productIds=' + productIds : '') + (chatId.length > 0 ? '&chatId=' + chatId : '')
}
+6
View File
@@ -0,0 +1,6 @@
// 登录回跳只接受固定页面和白名单参数,不接受外部 URL;页面仍需重新鉴权。
export function nextStepRoute(assessmentId: string, productId: string = '', section: string = 'actions'): string {
const valid = (id: string): boolean => /^[1-9][0-9]{0,9}$/.test(id)
if (!valid(assessmentId) || (productId.length > 0 && !valid(productId)) || !['actions', 'prepare'].includes(section)) return ''
return '/packages/finance/next-step/next-step?assessmentId=' + assessmentId + (productId.length > 0 ? '&productId=' + productId : '') + '&section=' + section
}
+5
View File
@@ -0,0 +1,5 @@
// 登录后只恢复本人报告的编号和栏目,不接受任意回跳URL。
export function reportRoute(assessmentId: string, tab: string = 'overview'): string {
if (!/^[1-9][0-9]{0,9}$/.test(assessmentId) || !['overview', 'matches', 'improvements'].includes(tab)) return ''
return '/packages/finance/report/report?assessmentId=' + assessmentId + '&tab=' + tab
}