From 9d4d4aaa6de23fe7b7c785e3516e2d8d847505fb Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 10 Sep 2026 22:30:07 +0800 Subject: [PATCH] feat(chat): Enhance session management with new API endpoints and filtering options - Added `status` query parameter to `list_sessions_api` for filtering sessions by their status (active/closed). - Introduced `close_all_sessions_api` endpoint to allow users to close all active sessions for the current actor. - Updated `SessionRepository` to support status filtering in session listing and implemented logic for closing active sessions. - Improved Redis connection settings for better performance and reliability. This update enhances the chat functionality by providing more control over session management, improving user experience and system efficiency. --- app/api/chat.py | 23 +- app/config/database.py | 4 +- app/repository/session_repository.py | 35 +- app/service/customer_prompts.py | 16 + app/service/customer_service.py | 5 +- app/service/visitor_prompts.py | 4 + app/service/visitor_service.py | 41 +- docs/course/index.html | 24 +- docs/course/jinrong-module-analyst/_base.html | 1 + docs/course/jinrong-module-analyst/index.html | 123 +++++- .../modules/02-pipeline.html | 5 +- .../modules/03-agent-api.html | 6 +- .../modules/05-d06-cache.html | 111 ++++++ .../course/jinrong-module-frontend/index.html | 16 +- .../modules/04-unwired.html | 16 +- docs/course/jinrong-module-risk/index.html | 4 +- .../modules/01-capabilities.html | 2 +- .../modules/03-tools-rest.html | 2 +- docs/course/jinrong-overview/_base.html | 1 + docs/course/jinrong-overview/index.html | 153 ++++++- .../jinrong-overview/modules/05-status.html | 20 +- .../modules/07-not-implemented.html | 6 +- .../jinrong-overview/modules/08-defense.html | 126 ++++++ docs/frontend/FRONTEND-HANDOFF.md | 24 ++ docs/memory/ITERATION.md | 6 +- docs/memory/MEMORY.md | 17 +- docs/memory/REQUIREMENTS.md | 2 +- docs/memory/TODO.md | 45 ++- docs/整体测试/前端整体测试交接.md | 373 ++++++++++++++++++ docs/答辩/答辩知识点清单.md | 199 ++++++++++ tests/test_chat.py | 24 ++ tests/test_main.py | 1 + tests/test_visitor_faq.py | 93 +++++ tests/test_wave3_customer_service.py | 22 +- web/index.html | 4 +- web/src/App.tsx | 43 +- web/src/api/chat.ts | 66 +++- web/src/components/chat/ChatPanel.tsx | 250 ++++++++---- web/src/components/chat/VisitorChatWidget.tsx | 3 +- web/src/context/AuthProvider.tsx | 53 +++ web/src/hooks/useAdvisorRosterDashboard.ts | 9 +- web/src/hooks/useAlertsDashboard.ts | 21 +- web/src/hooks/useAsyncSequence.ts | 15 + web/src/hooks/useChatPanel.ts | 190 +++++++-- web/src/hooks/useHoldingsDashboard.ts | 9 +- web/src/hooks/useMarketSnapshot.ts | 9 +- web/src/layouts/AppLayout.tsx | 14 +- web/src/main.tsx | 13 +- .../pages/advisor/AdvisorCustomersPage.tsx | 9 +- web/src/pages/analytics/AnalystQueryPage.tsx | 3 +- web/src/pages/customer/CustomerTradesPage.tsx | 20 +- .../pages/dashboard/RiskAlertsDashboard.tsx | 12 +- web/src/pages/login/LoginPage.tsx | 15 +- web/src/pages/risk/RiskAlertsPage.tsx | 31 +- .../stores/__tests__/chatSessionStore.test.ts | 17 + web/src/stores/chatSessionStore.ts | 25 ++ web/src/utils/__tests__/displayLabels.test.ts | 23 ++ web/src/utils/displayLabels.ts | 95 +++++ web/src/utils/renderChatMarkdown.tsx | 27 ++ 59 files changed, 2261 insertions(+), 265 deletions(-) create mode 100644 docs/course/jinrong-module-analyst/modules/05-d06-cache.html create mode 100644 docs/course/jinrong-overview/modules/08-defense.html create mode 100644 docs/整体测试/前端整体测试交接.md create mode 100644 docs/答辩/答辩知识点清单.md create mode 100644 tests/test_visitor_faq.py create mode 100644 web/src/context/AuthProvider.tsx create mode 100644 web/src/hooks/useAsyncSequence.ts create mode 100644 web/src/stores/__tests__/chatSessionStore.test.ts create mode 100644 web/src/stores/chatSessionStore.ts create mode 100644 web/src/utils/__tests__/displayLabels.test.ts create mode 100644 web/src/utils/displayLabels.ts create mode 100644 web/src/utils/renderChatMarkdown.tsx diff --git a/app/api/chat.py b/app/api/chat.py index 597cf17..2952da1 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -327,17 +327,38 @@ def list_sessions_api( request: Request, limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), + status: str | None = Query( + None, + description="按状态筛选:active / closed;缺省返回全部", + pattern="^(active|closed)$", + ), auth: AuthContext = Depends(get_auth_context), ) -> dict: """当前登录人的会话列表(created_at 倒序分页;agent_type 经 X-Agent-Type 头指定)。""" agent_type = _resolve_agent_type(request) _assert_chat_entry(auth, agent_type) items, total = _session_repo().list_sessions( - actor_id=auth.actor_id, agent_type=agent_type, limit=limit, offset=offset + actor_id=auth.actor_id, + agent_type=agent_type, + limit=limit, + offset=offset, + status=status, ) return {"items": items, "total": total, "limit": limit, "offset": offset} +@router.post("/sessions/close-all") +def close_all_sessions_api( + request: Request, + auth: AuthContext = Depends(get_auth_context), +) -> dict: + """关闭当前 actor 在本 Agent 线上的全部 active 会话(不清消息,仅侧栏不可续聊)。""" + agent_type = _resolve_agent_type(request) + _assert_chat_entry(auth, agent_type) + closed = _session_repo().close_all_active(actor_id=auth.actor_id, agent_type=agent_type) + return {"closed_count": closed, "agent_type": agent_type} + + @router.get("/sessions/{session_id}/messages") def list_messages_api( session_id: str, diff --git a/app/config/database.py b/app/config/database.py index 24f6cbf..2ea3dde 100644 --- a/app/config/database.py +++ b/app/config/database.py @@ -33,8 +33,8 @@ def _redis_kwargs() -> dict: return { "decode_responses": True, "protocol": 2, - "socket_timeout": 5, - "socket_connect_timeout": 3, + "socket_timeout": 1, + "socket_connect_timeout": 0.5, } diff --git a/app/repository/session_repository.py b/app/repository/session_repository.py index 8db1738..01c5974 100644 --- a/app/repository/session_repository.py +++ b/app/repository/session_repository.py @@ -34,20 +34,37 @@ class SessionRepository: return dict(row) if row else None def list_sessions( - self, *, actor_id: str, agent_type: str, limit: int = 20, offset: int = 0 + self, + *, + actor_id: str, + agent_type: str, + limit: int = 20, + offset: int = 0, + status: str | None = None, ) -> tuple[list[dict[str, Any]], int]: """前端会话列表(方案 B):仅本人 + 本 Agent 线,created_at 倒序分页。 返回 (items, total);total 供前端分页器。id 倒序兜底同秒并发建的 会话排序稳定(created_at 精度秒级时并列)。datetime 统一转 str—— sqlite 返 str、MySQL 返 datetime,响应体跨库同构。 + + status 可选:active / closed;缺省返回全部状态(兼容旧客户端)。 """ where = "WHERE actor_id = :actor AND agent_type = :atype" + params: dict[str, Any] = { + "actor": actor_id, + "atype": agent_type, + "lim": limit, + "off": offset, + } + if status is not None: + where += " AND status = :status" + params["status"] = status with self._engine.connect() as conn: total = int( conn.execute( text(f"SELECT COUNT(*) FROM agent_session {where}"), - {"actor": actor_id, "atype": agent_type}, + params, ).scalar_one() ) rows = conn.execute( @@ -60,7 +77,7 @@ class SessionRepository: LIMIT :lim OFFSET :off """ ), - {"actor": actor_id, "atype": agent_type, "lim": limit, "off": offset}, + params, ).mappings().all() items = [ { @@ -93,6 +110,18 @@ class SessionRepository: ) return result.rowcount > 0 + def close_all_active(self, *, actor_id: str, agent_type: str) -> int: + """关闭该 actor 在某 Agent 线上的全部 active 会话(前端「清空历史」)。""" + with self._engine.begin() as conn: + result = conn.execute( + text( + "UPDATE agent_session SET status = 'closed', closed_at = CURRENT_TIMESTAMP" + " WHERE actor_id = :actor AND agent_type = :atype AND status = 'active'" + ), + {"actor": actor_id, "atype": agent_type}, + ) + return int(result.rowcount) + def create_session( self, *, diff --git a/app/service/customer_prompts.py b/app/service/customer_prompts.py index 8726ea8..9902e39 100644 --- a/app/service/customer_prompts.py +++ b/app/service/customer_prompts.py @@ -133,6 +133,12 @@ _SAVE_NOTE_KW = ( "帮我记住", "你要记", ) +# 问候/闲聊快路由(不调 LLM;避免 DeepSeek 不可用时「你好」落入 fallback) +_GREETING_KW = ( + "你好", "您好", "你好呀", "您好呀", "嗨", "hello", "hi", + "在吗", "在不在", "早上好", "下午好", "晚上好", "哈喽", +) + def keyword_route(message: str) -> tuple[str, str] | None: """关键词快速路由。返回 (intent, preset_reply);无命中返回 None。 @@ -144,6 +150,10 @@ def keyword_route(message: str) -> tuple[str, str] | None: if any(k in msg for k in _TRANSFER_KEYWORDS): return ("transfer_human", "") + stripped = msg.strip().lower() + if stripped in {"hi", "hello"} or any(k in msg for k in _GREETING_KW): + return ("chit_chat", "") + # 拒绝类(边界外请求) if any(k in msg for k in _REJECT_ADVICE_KW): return ("reject", "advice") @@ -377,6 +387,12 @@ FALLBACK_TEXT = ( "3. 拨打客服热线 95XXX 或回复「转人工」" ) +# LLM 不可用时的闲聊降级(仍走 chit_chat 意图,避免误用 FALLBACK 话术) +CHITCHAT_DEGRADED_TEXT = ( + "您好,我是您的财富助手。您可以问我「我的持仓」「最近流水」或「我能买什么产品」," + "也可以直接描述您的问题。" +) + # 数据查询工具调用失败时的兜底 DATA_ERROR_TEXT = "暂时无法查询您的数据,请稍后再试,或回复「转人工」联系客服人员。" diff --git a/app/service/customer_service.py b/app/service/customer_service.py index 2f9f1f0..4bacf73 100644 --- a/app/service/customer_service.py +++ b/app/service/customer_service.py @@ -24,6 +24,7 @@ from langgraph.graph import END, StateGraph from app.config.settings import settings from app.model.schemas import AuthContext from app.service.customer_prompts import ( + CHITCHAT_DEGRADED_TEXT, CHITCHAT_SYSTEM, CHITCHAT_USER_TEMPLATE, DATA_ERROR_TEXT, @@ -351,9 +352,9 @@ def chitchat(state: CustomerState) -> CustomerState: ) reply = content.strip() if not reply: - return {"reply": FALLBACK_TEXT, "intent": "fallback"} + return {"reply": CHITCHAT_DEGRADED_TEXT, "intent": "chit_chat"} except Exception: - return {"reply": FALLBACK_TEXT, "intent": "fallback"} + return {"reply": CHITCHAT_DEGRADED_TEXT, "intent": "chit_chat"} reply, need_transfer = finalize_sanitized_reply(reply, intent=state.get("intent")) if need_transfer: diff --git a/app/service/visitor_prompts.py b/app/service/visitor_prompts.py index dec8552..5436c1d 100644 --- a/app/service/visitor_prompts.py +++ b/app/service/visitor_prompts.py @@ -89,3 +89,7 @@ FALLBACK_TEXT = ( "3. 拨打客服热线 95XXX\n" '4. 回复"转人工"联系客服人员' ) + +CHITCHAT_DEGRADED_TEXT = ( + "您好,欢迎咨询。您可以问我产品规则、开户流程等,或回复「转人工」联系客服。" +) diff --git a/app/service/visitor_service.py b/app/service/visitor_service.py index 3909cd4..950bac7 100644 --- a/app/service/visitor_service.py +++ b/app/service/visitor_service.py @@ -101,6 +101,35 @@ _REJECT_REALTIME_KW = ( "当前价格", "最新行情", ) +# FAQ 关键词:LLM 分类失败时仍可走 RAG(开户/账户操作类) +_FAQ_KEYWORDS = ( + "开户", "开账户", "办理开户", "开户资料", "开户材料", + "需要什么资料", "需要哪些资料", "需要什么材料", "需要哪些材料", + "身份认证", "实名认证", "风险测评", "风评问卷", "忘记密码", "重置密码", +) + + +def _visitor_memory_prompt(session_id: str, kind: str) -> str: + """读取游客短期记忆;Redis 不可用时降级为空(与 recall_memory 口径一致)。""" + try: + return VisitorMemoryService().as_prompt_text(session_id, kind) + except Exception: + return "" + + +def _degraded_reply_from_rag(rag_context: str) -> str | None: + """LLM 不可用时,从 RAG 首片段提取可读答案。""" + text = (rag_context or "").strip() + if not text: + return None + parts = text.split("\n", 1) + if len(parts) < 2: + return None + body = parts[1].strip() + if len(body) < 8: + return None + return body[:400] + def recall_memory(state: VisitorState) -> VisitorState: """节点 1:从 Redis 读取两类短期记忆(Redis 不可用则降级为空)。""" @@ -138,6 +167,9 @@ def intent_classify(state: VisitorState) -> VisitorState: if any(k in msg for k in _REJECT_REALTIME_KW): return {"intent": "reject", "reply": REJECT_REALTIME} + if any(k in msg for k in _FAQ_KEYWORDS): + return {"intent": "faq"} + # DeepSeek 分类 try: llm = _build_llm() @@ -191,7 +223,7 @@ def generate(state: VisitorState) -> VisitorState: try: llm = _build_llm() - mem_text = VisitorMemoryService().as_prompt_text(state["session_id"], "consult") + mem_text = _visitor_memory_prompt(state["session_id"], "consult") user_prompt = GENERATE_USER_TEMPLATE.format( rag_context=state["rag_context"], memory=mem_text, @@ -203,7 +235,10 @@ def generate(state: VisitorState) -> VisitorState: ]) reply = resp.content.strip() except Exception: - return {"reply": FALLBACK_TEXT, "intent": "fallback"} + degraded = _degraded_reply_from_rag(state.get("rag_context", "")) + if not degraded: + return {"reply": FALLBACK_TEXT, "intent": "fallback"} + reply = degraded # 合规护栏(1B:命中不自动 transfer) reply, need_transfer = finalize_sanitized_reply(reply, intent=state.get("intent")) @@ -222,7 +257,7 @@ def chitchat(state: VisitorState) -> VisitorState: """节点 5:闲聊生成。""" try: llm = _build_llm() - mem_text = VisitorMemoryService().as_prompt_text(state["session_id"], "chitchat") + mem_text = _visitor_memory_prompt(state["session_id"], "chitchat") user_prompt = CHITCHAT_USER_TEMPLATE.format( memory=mem_text, message=state["message"], diff --git a/docs/course/index.html b/docs/course/index.html index b634b2d..5867bb3 100644 --- a/docs/course/index.html +++ b/docs/course/index.html @@ -123,13 +123,19 @@

面向 vibe coder 的仓库导览:滚动式模块、代码白话对照、动效与测验。直接用浏览器打开各课程 index.html,无需安装。

-

总览

+

总览 · 答辩

JinRong 项目现状

-

四角色、JWT 双通道、数据从哪来、merger 分支做到哪——5 分钟建立仓库地图。

- 7 模块 · 入门首选 +

四角色、JWT 双通道、数据从哪来、merger 做到哪——含模块 8 答辩动线(804 pytest 基线)。

+ 8 模块 · 入门首选 +
+ + +

答辩知识点清单(Markdown)

+

总架构、数据流、分 Agent 亮点、Demo 动线、诚实边界、Q&A 锚点——与交互课同步。

+ docs/答辩 · 可打印提纲
@@ -139,7 +145,7 @@

客户财富 Agent 深潜

客户 SSE 对话、14 节点 LangGraph、口吻护栏、两套 RAG、合规 vs 一期拒答。

- customer_service · 5 模块 + customer_service · 7 模块 @@ -150,14 +156,14 @@

数据分析 Agent 深潜

-

问数 NL2SQL、dashboard 指标、问数红线、口径种子排障。

- analyst · /api/analyst · 4 模块 +

问数 NL2SQL、D-06 模板/结果缓存、dashboard、问数红线、口径种子排障。

+ analyst · /api/analyst · 5 模块

风控监测 Agent 深潜

-

预警台账、AML 只读 Tool、模拟交易、cron/空台账演示边界。

- risk · X-Agent-Type: risk · 4 模块 +

预警台账、AML 只读 Tool、模拟交易、写侧并发(模块 7)、cron/空台账演示边界。

+ risk · X-Agent-Type: risk · 7 模块
@@ -168,7 +174,7 @@

前端 web 深潜

-

四角色 HashRouter、Chat 鉴权差异、未接线清单、会话 B 三端点。

+

四角色 HashRouter、Chat 鉴权差异、useAsyncSequence 竞态、问数标签、未接线清单。

web/ · React · 4 模块
diff --git a/docs/course/jinrong-module-analyst/_base.html b/docs/course/jinrong-module-analyst/_base.html index 92db22f..8794330 100644 --- a/docs/course/jinrong-module-analyst/_base.html +++ b/docs/course/jinrong-module-analyst/_base.html @@ -33,6 +33,7 @@ + diff --git a/docs/course/jinrong-module-analyst/index.html b/docs/course/jinrong-module-analyst/index.html index 99a71ec..9b6b1ca 100644 --- a/docs/course/jinrong-module-analyst/index.html +++ b/docs/course/jinrong-module-analyst/index.html @@ -33,6 +33,7 @@ + @@ -194,9 +195,10 @@
@@ -303,11 +305,11 @@

消歧与生成

-

_detect_ambiguity 命中则 _clarify 返回 suggestions;否则 _generate_sql。

+

_detect_ambiguity → clarify;否则 模板填参 或 _generate_sql。

执行与缓存

-

validate → execute_readonly;Redis 按权限指纹 + SQL hash 缓存结果。

+

validate → execute_readonly;Redis 权限指纹 + SQL + 表世代;写交易/预警/L3 后 bump。

解读与留痕

@@ -315,7 +317,7 @@
- 返回体 AnalystResponse: answer + table + sql + meta(exec_ms, cache_hit, cost_est)+ disclaimer + status(success / degrade / clarify)。 + 返回体 AnalystResponse: answer + table + sql + meta(exec_ms, template_hit, cache_hit, cost_est)+ disclaimer + status(success / degrade / clarify)。
@@ -460,6 +462,117 @@ + +
+
+

模块 5 · D-06

+

模板填参 + 结果缓存
写侧 bump 失效

+

+ 问数两层「快路径」:① 同形态问题命中 published 模板,跳过 LLM 写 SQL(template_service.py) + ② 执行结果进 Redis,键里带表世代——交易/预警/L3 写后 bump,不等 TTL 过期。 +

+ +
+

两层 D-06 各管什么

+ + + + + + + + + + + + + + + + + + + + + +
层入口答辩句
模板缓存match_template → 填 :days 等「客户+总数」→ template_hit: true,省 LLM 写 SQL
结果缓存cache_service.py 权限指纹 + SQL hash + 世代「同 SQL 同权限」秒回;写侧 bump 后旧键自然 miss
写侧失效analyst_cache_invalidate 交易/预警/L3 后「演示里刚模拟交易,问数不会还显示旧汇总」
+
+ 前端问数页会展示 模板命中 / 结果缓存 标签(读 meta.template_hit · cache_hit)。 +
+
+ +
+
+
+ analyst_agent 决策顺序(简化) +
clarify? → return suggestions
+template match? → fill SQL → validate → execute
+else LLM generate_sql → validate → execute (cache?)
+guardrail → persist analytics_query_log
+
+
+ 白话 +
+

像预制菜 + 冰箱贴保质期:常问题型直接拿模板填天数;跑完的结果贴冰箱,但 Core 有新交易就撕掉旧标签(bump)。

+

种子:scripts/agent/seed-analyst-query-templates.sql · 一键演示:prepare_all.ps1。

+
+
+
+
+ +
+

群聊:模板 vs LLM vs 缓存

+
+
+ + + +
+ +
+ +
+
+

AI 说「问数缓存就是 Redis TTL 10 分钟」,你怎么纠正?

+
+ + + +
+
+
+ + +
+
+
diff --git a/docs/course/jinrong-module-analyst/modules/02-pipeline.html b/docs/course/jinrong-module-analyst/modules/02-pipeline.html index dee8548..88f66ab 100644 --- a/docs/course/jinrong-module-analyst/modules/02-pipeline.html +++ b/docs/course/jinrong-module-analyst/modules/02-pipeline.html @@ -13,9 +13,10 @@
diff --git a/docs/course/jinrong-module-analyst/modules/03-agent-api.html b/docs/course/jinrong-module-analyst/modules/03-agent-api.html index d7a9ca5..93cab1f 100644 --- a/docs/course/jinrong-module-analyst/modules/03-agent-api.html +++ b/docs/course/jinrong-module-analyst/modules/03-agent-api.html @@ -17,11 +17,11 @@

消歧与生成

-

_detect_ambiguity 命中则 _clarify 返回 suggestions;否则 _generate_sql。

+

_detect_ambiguity → clarify;否则 模板填参 或 _generate_sql。

执行与缓存

-

validate → execute_readonly;Redis 按权限指纹 + SQL hash 缓存结果。

+

validate → execute_readonly;Redis 权限指纹 + SQL + 表世代;写交易/预警/L3 后 bump。

解读与留痕

@@ -29,7 +29,7 @@
- 返回体 AnalystResponse: answer + table + sql + meta(exec_ms, cache_hit, cost_est)+ disclaimer + status(success / degrade / clarify)。 + 返回体 AnalystResponse: answer + table + sql + meta(exec_ms, template_hit, cache_hit, cost_est)+ disclaimer + status(success / degrade / clarify)。
diff --git a/docs/course/jinrong-module-analyst/modules/05-d06-cache.html b/docs/course/jinrong-module-analyst/modules/05-d06-cache.html new file mode 100644 index 0000000..ef7f080 --- /dev/null +++ b/docs/course/jinrong-module-analyst/modules/05-d06-cache.html @@ -0,0 +1,111 @@ +
+
+

模块 5 · D-06

+

模板填参 + 结果缓存
写侧 bump 失效

+

+ 问数两层「快路径」:① 同形态问题命中 published 模板,跳过 LLM 写 SQL(template_service.py) + ② 执行结果进 Redis,键里带表世代——交易/预警/L3 写后 bump,不等 TTL 过期。 +

+ +
+

两层 D-06 各管什么

+ + + + + + + + + + + + + + + + + + + + + +
层入口答辩句
模板缓存match_template → 填 :days 等「客户+总数」→ template_hit: true,省 LLM 写 SQL
结果缓存cache_service.py 权限指纹 + SQL hash + 世代「同 SQL 同权限」秒回;写侧 bump 后旧键自然 miss
写侧失效analyst_cache_invalidate 交易/预警/L3 后「演示里刚模拟交易,问数不会还显示旧汇总」
+
+ 前端问数页会展示 模板命中 / 结果缓存 标签(读 meta.template_hit · cache_hit)。 +
+
+ +
+
+
+ analyst_agent 决策顺序(简化) +
clarify? → return suggestions
+template match? → fill SQL → validate → execute
+else LLM generate_sql → validate → execute (cache?)
+guardrail → persist analytics_query_log
+
+
+ 白话 +
+

像预制菜 + 冰箱贴保质期:常问题型直接拿模板填天数;跑完的结果贴冰箱,但 Core 有新交易就撕掉旧标签(bump)。

+

种子:scripts/agent/seed-analyst-query-templates.sql · 一键演示:prepare_all.ps1。

+
+
+
+
+ +
+

群聊:模板 vs LLM vs 缓存

+
+
+ + + +
+ +
+ +
+
+

AI 说「问数缓存就是 Redis TTL 10 分钟」,你怎么纠正?

+
+ + + +
+
+
+ + +
+
+
+
diff --git a/docs/course/jinrong-module-frontend/index.html b/docs/course/jinrong-module-frontend/index.html index 5a3ac2c..5fbf8bf 100644 --- a/docs/course/jinrong-module-frontend/index.html +++ b/docs/course/jinrong-module-frontend/index.html @@ -354,20 +354,24 @@

模块 4 · 未接线

菜单有 ≠ API 已接
Chat 还有 B 方案三端点

- 前端 P0 主链路已真接,但和后端课对照,仍有页面占位、风控/平台按钮缺失、SSE 行为等缝。 - 改 URL 进别人工作台,前端藏菜单拦不住,后端才是闸。 + 前端 P0 主链路已真接(含风控三页 · 问数标签 · Dashboard hooks),但仍有分析对话占位、看板无钻取等缝。 + 开发态若 Dashboard「先闪错再正常」,多半是 StrictMode 双请求——已用 useAsyncSequence 丢弃过期响应。

-

前端未全接清单(2026-09)

+

仍开放 / 已修清单(2026-09-10)

-

风控

-

AML scan · 适当性校验按钮 · 台账高级筛选 — 部分 API 有,UI 简版或未接。

+

风控 UI

+

台账筛选 · 适当性校验 · AML 扫描页已接 REST;高级运营能力仍简版。

问数

-

AnalystChatShell 占位 · 真问数在 AnalystQueryPage + dashboard/assets。

+

AnalystChatShell 占位 · 真问数在 AnalystQueryPage(模板/缓存标签 + dashboard/assets)。

+
+
+

竞态修复

+

useAsyncSequence 用于 Dashboard hooks、ChatPanel、风控/客户列表页等。

游客

diff --git a/docs/course/jinrong-module-frontend/modules/04-unwired.html b/docs/course/jinrong-module-frontend/modules/04-unwired.html index 4f8f2d8..11df890 100644 --- a/docs/course/jinrong-module-frontend/modules/04-unwired.html +++ b/docs/course/jinrong-module-frontend/modules/04-unwired.html @@ -3,20 +3,24 @@

模块 4 · 未接线

菜单有 ≠ API 已接
Chat 还有 B 方案三端点

- 前端 P0 主链路已真接,但和后端课对照,仍有页面占位、风控/平台按钮缺失、SSE 行为等缝。 - 改 URL 进别人工作台,前端藏菜单拦不住,后端才是闸。 + 前端 P0 主链路已真接(含风控三页 · 问数标签 · Dashboard hooks),但仍有分析对话占位、看板无钻取等缝。 + 开发态若 Dashboard「先闪错再正常」,多半是 StrictMode 双请求——已用 useAsyncSequence 丢弃过期响应。

-

前端未全接清单(2026-09)

+

仍开放 / 已修清单(2026-09-10)

-

风控

-

AML scan · 适当性校验按钮 · 台账高级筛选 — 部分 API 有,UI 简版或未接。

+

风控 UI

+

台账筛选 · 适当性校验 · AML 扫描页已接 REST;高级运营能力仍简版。

问数

-

AnalystChatShell 占位 · 真问数在 AnalystQueryPage + dashboard/assets。

+

AnalystChatShell 占位 · 真问数在 AnalystQueryPage(模板/缓存标签 + dashboard/assets)。

+
+
+

竞态修复

+

useAsyncSequence 用于 Dashboard hooks、ChatPanel、风控/客户列表页等。

游客

diff --git a/docs/course/jinrong-module-risk/index.html b/docs/course/jinrong-module-risk/index.html index 8cf9f7e..b68180d 100644 --- a/docs/course/jinrong-module-risk/index.html +++ b/docs/course/jinrong-module-risk/index.html @@ -49,7 +49,7 @@ 风控专员 Demo STAFF-30001 带 risk_officer + risk_demo 角色: 能看预警台账、处置、跑 AML 扫描,还能在模拟交易页触发规则引擎。 本地登录页一键切换,改 JWT 角色后需重新登录。 - 仓库基线 merger · python -m pytest → 795 passed。 + 仓库基线 merger · python -m pytest → 804 passed。

@@ -409,7 +409,7 @@

- 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 795 passed。 + 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 804 passed。

diff --git a/docs/course/jinrong-module-risk/modules/01-capabilities.html b/docs/course/jinrong-module-risk/modules/01-capabilities.html index f4ed541..291610a 100644 --- a/docs/course/jinrong-module-risk/modules/01-capabilities.html +++ b/docs/course/jinrong-module-risk/modules/01-capabilities.html @@ -6,7 +6,7 @@ 风控专员 Demo STAFF-30001 带 risk_officer + risk_demo 角色: 能看预警台账、处置、跑 AML 扫描,还能在模拟交易页触发规则引擎。 本地登录页一键切换,改 JWT 角色后需重新登录。 - 仓库基线 merger · python -m pytest → 795 passed。 + 仓库基线 merger · python -m pytest → 804 passed。

diff --git a/docs/course/jinrong-module-risk/modules/03-tools-rest.html b/docs/course/jinrong-module-risk/modules/03-tools-rest.html index 117211a..65cd4f1 100644 --- a/docs/course/jinrong-module-risk/modules/03-tools-rest.html +++ b/docs/course/jinrong-module-risk/modules/03-tools-rest.html @@ -128,7 +128,7 @@

- 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 795 passed。 + 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 804 passed。

diff --git a/docs/course/jinrong-overview/_base.html b/docs/course/jinrong-overview/_base.html index 5bed6c0..ef84c0e 100644 --- a/docs/course/jinrong-overview/_base.html +++ b/docs/course/jinrong-overview/_base.html @@ -36,6 +36,7 @@ +
diff --git a/docs/course/jinrong-overview/index.html b/docs/course/jinrong-overview/index.html index 4dc4faf..7b5b230 100644 --- a/docs/course/jinrong-overview/index.html +++ b/docs/course/jinrong-overview/index.html @@ -36,6 +36,7 @@ + @@ -411,17 +412,17 @@

模块 5 · 现状与下一步

现在做到哪?
怎么本地跑起来

- 分支 merger · 测试基线 774 pytest · 前端 19 Vitest · Redis Docker 6380。 - 文档入口:docs/memory/MEMORY.md §0 交接清单。 + 分支 merger · 测试基线 804 pytest · 前端 19 Vitest · Redis Docker 6380。 + 文档入口:docs/memory/MEMORY.md §0 · 答辩提纲 docs/答辩/答辩知识点清单.md · 滚动版见模块 8。

-

已完成(2026-09-09 快照)

+

已完成(2026-09-10 快照)

✓ 平台 API v0.1

客户/产品/顾问/合规只读 REST

-

✓ 四 Agent 对话 + SSE

含 customer 客服线分流

-

✓ 问数 S3 + P2

问数页 · 看板 metrics · 资产沉淀 UI

-

✓ 风控全链路

台账 · 模拟交易 · 规则 FR-1~10

+

✓ 四 Agent 对话 + SSE

客服 Wave3 · 游客 1B · customer 14 节点

+

✓ 问数 S3 + D-06

模板填参 · 结果缓存 + 写侧 bump · 问数 UI 标签

+

✓ 风控 + 前端 P0

台账/模拟/AML 页 · prepare_all.ps1 灌库

@@ -439,11 +440,11 @@

本地启动(最小路径)

1

pip install -r requirements.txt

-
2

.\scripts\core\reset.ps1 灌 Core 模拟库

+
2

.\scripts\demo\prepare_all.ps1 或 .\scripts\core\reset.ps1

3

.\scripts\dev\start-redis.ps1 → 6380

4

uvicorn app.main:app --reload :8000

5

cd web && npm run dev → :5173 代理 API

-
6

python -m pytest 验收 774 绿

+
6

python -m pytest 验收 804 绿

改后端后: 若浏览器问数 404,多半是 :8000 的 uvicorn 没重启,OpenAPI 里还缺新路由。 @@ -476,10 +477,10 @@
- 想继续深入? 打开 课程中心,按角色进入 7 门模块深潜课(客户 / 顾问 / 问数 / 风控 / 平台 API / 前端 / 共用底座)。 + 想继续深入? 模块 8 有答辩 5~8 分钟动线;或打开 课程中心 按角色深潜。
- + @@ -583,7 +584,7 @@

已实现(别重复造轮子)

✓平台 API v0.1 · 四 Agent 对话 + customer SSE
-
✓问数 S3 + dashboard/assets · 风控台账 + 模拟交易
+
✓问数 S3 + D-06 模板/结果缓存 · 风控台账 + 模拟交易 + 前端四页
✓客服 14 节点 + 游客 9 节点 · R-02 网关阻断
@@ -600,8 +601,8 @@

A-03 话术草稿复核 · A-05 合规巡检台 · A-06 跟进草稿入库 — prompt 有约束,无前端/表流程。

-

前端未接线

-

AML scan UI · 平台适当性按钮 · 风控台账高级筛选 — 后端部分已有,web/ 未全接。

+

前端仍简版

+

风控 P1 已接(筛选/适当性/AML)· 看板无钻取 · 分析 Chat 仍占位 · 平台适当性按钮部分未接。

运维 cron 无 UI

@@ -666,6 +667,132 @@
+ +
+
+

模块 8 · 答辩动线

+

5~8 分钟 Demo
+ 必背铁律

+

+ 对照 docs/答辩/答辩知识点清单.md 的滚动版:先讲清四角色不互调 LLM,再按动线演示,最后主动说边界。 + 基线 804 pytest · 19 Vitest · 分支 merger。 +

+ +
+

30 秒总架构(开口即说)

+
+
+

分层

+

浏览器 → FastAPI → 编排 service → tool/repository → 双库 MySQL + Redis + Milvus

+
+
+

三条入口

+

Chat + X-Agent-Type · 客服 customer 14 节点 · 问数 /api/analyst/chat(无 Agent 头)

+
+
+

铁律

+

L0 画像不可覆盖 · 仅 R-02 可阻断交易 · 风控不自动冻户 · 审计只 INSERT

+
+
+
+ +
+

推荐演示顺序(动画)

+
+
+
🖥登录/Dashboard
+
💬客户线
+
🛡风控线
+
📊问数线
+
+

灌库:scripts/demo/prepare_all.ps1(含 Core reset + 问数模板种子)

+
+ + +
+
+
+ +
+

群聊:评委问「四个 Agent 为什么不互相打电话?」

+
+
+ + + + +
+ +
+
+ +
+

诚实边界(主动说加分)

+
    +
  • L0 是模拟 Core,不是真银行托管库
  • +
  • 分析对话页占位,真 NL2SQL 在问数工作台(D-09 多轮未做)
  • +
  • 看板无钻取 · 行情 Phase B 草案未接
  • +
  • 知识库 T-21 脚本入库,上传 API 一期不做
  • +
+ +
+
+

评委:「刷新 Dashboard 会先红一下再正常,是不是没做错误处理?」

+
+ + + +
+
+
+ + +
+ +
+ 全文提纲: 仓库内 docs/答辩/答辩知识点清单.md · 深潜按角色打开 课程中心。 +
+ +
+
diff --git a/docs/course/jinrong-overview/modules/05-status.html b/docs/course/jinrong-overview/modules/05-status.html index 9574d8b..2199b85 100644 --- a/docs/course/jinrong-overview/modules/05-status.html +++ b/docs/course/jinrong-overview/modules/05-status.html @@ -3,17 +3,17 @@

模块 5 · 现状与下一步

现在做到哪?
怎么本地跑起来

- 分支 merger · 测试基线 774 pytest · 前端 19 Vitest · Redis Docker 6380。 - 文档入口:docs/memory/MEMORY.md §0 交接清单。 + 分支 merger · 测试基线 804 pytest · 前端 19 Vitest · Redis Docker 6380。 + 文档入口:docs/memory/MEMORY.md §0 · 答辩提纲 docs/答辩/答辩知识点清单.md · 滚动版见模块 8。

-

已完成(2026-09-09 快照)

+

已完成(2026-09-10 快照)

✓ 平台 API v0.1

客户/产品/顾问/合规只读 REST

-

✓ 四 Agent 对话 + SSE

含 customer 客服线分流

-

✓ 问数 S3 + P2

问数页 · 看板 metrics · 资产沉淀 UI

-

✓ 风控全链路

台账 · 模拟交易 · 规则 FR-1~10

+

✓ 四 Agent 对话 + SSE

客服 Wave3 · 游客 1B · customer 14 节点

+

✓ 问数 S3 + D-06

模板填参 · 结果缓存 + 写侧 bump · 问数 UI 标签

+

✓ 风控 + 前端 P0

台账/模拟/AML 页 · prepare_all.ps1 灌库

@@ -31,11 +31,11 @@

本地启动(最小路径)

1

pip install -r requirements.txt

-
2

.\scripts\core\reset.ps1 灌 Core 模拟库

+
2

.\scripts\demo\prepare_all.ps1 或 .\scripts\core\reset.ps1

3

.\scripts\dev\start-redis.ps1 → 6380

4

uvicorn app.main:app --reload :8000

5

cd web && npm run dev → :5173 代理 API

-
6

python -m pytest 验收 774 绿

+
6

python -m pytest 验收 804 绿

改后端后: 若浏览器问数 404,多半是 :8000 的 uvicorn 没重启,OpenAPI 里还缺新路由。 @@ -68,10 +68,10 @@
- 想继续深入? 打开 课程中心,按角色进入 7 门模块深潜课(客户 / 顾问 / 问数 / 风控 / 平台 API / 前端 / 共用底座)。 + 想继续深入? 模块 8 有答辩 5~8 分钟动线;或打开 课程中心 按角色深潜。
- + diff --git a/docs/course/jinrong-overview/modules/07-not-implemented.html b/docs/course/jinrong-overview/modules/07-not-implemented.html index 587e6cd..47a219c 100644 --- a/docs/course/jinrong-overview/modules/07-not-implemented.html +++ b/docs/course/jinrong-overview/modules/07-not-implemented.html @@ -11,7 +11,7 @@

已实现(别重复造轮子)

✓平台 API v0.1 · 四 Agent 对话 + customer SSE
-
✓问数 S3 + dashboard/assets · 风控台账 + 模拟交易
+
✓问数 S3 + D-06 模板/结果缓存 · 风控台账 + 模拟交易 + 前端四页
✓客服 14 节点 + 游客 9 节点 · R-02 网关阻断
@@ -28,8 +28,8 @@

A-03 话术草稿复核 · A-05 合规巡检台 · A-06 跟进草稿入库 — prompt 有约束,无前端/表流程。

-

前端未接线

-

AML scan UI · 平台适当性按钮 · 风控台账高级筛选 — 后端部分已有,web/ 未全接。

+

前端仍简版

+

风控 P1 已接(筛选/适当性/AML)· 看板无钻取 · 分析 Chat 仍占位 · 平台适当性按钮部分未接。

运维 cron 无 UI

diff --git a/docs/course/jinrong-overview/modules/08-defense.html b/docs/course/jinrong-overview/modules/08-defense.html new file mode 100644 index 0000000..a21dad8 --- /dev/null +++ b/docs/course/jinrong-overview/modules/08-defense.html @@ -0,0 +1,126 @@ +
+
+

模块 8 · 答辩动线

+

5~8 分钟 Demo
+ 必背铁律

+

+ 对照 docs/答辩/答辩知识点清单.md 的滚动版:先讲清四角色不互调 LLM,再按动线演示,最后主动说边界。 + 基线 804 pytest · 19 Vitest · 分支 merger。 +

+ +
+

30 秒总架构(开口即说)

+
+
+

分层

+

浏览器 → FastAPI → 编排 service → tool/repository → 双库 MySQL + Redis + Milvus

+
+
+

三条入口

+

Chat + X-Agent-Type · 客服 customer 14 节点 · 问数 /api/analyst/chat(无 Agent 头)

+
+
+

铁律

+

L0 画像不可覆盖 · 仅 R-02 可阻断交易 · 风控不自动冻户 · 审计只 INSERT

+
+
+
+ +
+

推荐演示顺序(动画)

+
+
+
🖥登录/Dashboard
+
💬客户线
+
🛡风控线
+
📊问数线
+
+

灌库:scripts/demo/prepare_all.ps1(含 Core reset + 问数模板种子)

+
+ + +
+
+
+ +
+

群聊:评委问「四个 Agent 为什么不互相打电话?」

+
+
+ + + + +
+ +
+
+ +
+

诚实边界(主动说加分)

+
    +
  • L0 是模拟 Core,不是真银行托管库
  • +
  • 分析对话页占位,真 NL2SQL 在问数工作台(D-09 多轮未做)
  • +
  • 看板无钻取 · 行情 Phase B 草案未接
  • +
  • 知识库 T-21 脚本入库,上传 API 一期不做
  • +
+ +
+
+

评委:「刷新 Dashboard 会先红一下再正常,是不是没做错误处理?」

+
+ + + +
+
+
+ + +
+ +
+ 全文提纲: 仓库内 docs/答辩/答辩知识点清单.md · 深潜按角色打开 课程中心。 +
+ +
+
+
diff --git a/docs/frontend/FRONTEND-HANDOFF.md b/docs/frontend/FRONTEND-HANDOFF.md index 8fad44f..83d9c6a 100644 --- a/docs/frontend/FRONTEND-HANDOFF.md +++ b/docs/frontend/FRONTEND-HANDOFF.md @@ -163,6 +163,7 @@ Tailwind 负责: - 理财师显示客户数、总 AUM 和最大客户 fallback;客户市值降序、客户与 Chat 链接可用。 - 分析员显示产品数、上涨/下跌/平盘;按日变化绝对值排序,行情/问数/Chat 链接可用。 - 风控显示待审数量和 API disclaimer;无预警时显示 Empty,不出现假图表或假表格;有预警时显示图表、处置和助手链接。 +- **四角色 Chat(含客户 SSE)**:…侧栏每条可删除(调用 close API);侧栏**仅展示 active**;**清空历史会话** 调用 `POST /api/chat/sessions/close-all`(消息仍留库,仅关闭续聊)。 - API 错误态显示 code、message、trace ID 和重试;loading 态保留 shell 与 skeleton。 - 检查正收益红色、负收益绿色、零值灰色。 @@ -182,6 +183,7 @@ Tailwind 负责: - 不要在页面里新增硬编码颜色或随意 spacing。 - 不要删除共享组件而不检查 downstream agent 的引用。 - 不要修改 `AuthState`、`jinrong.auth` localStorage key 或登录默认落点。 +- 对话续聊用 `sessionStorage` 键 `jinrong.chat.activeSession.{agentType}`(与登录 key 无关);勿在流式 POST 省略 `session_id`。 - 不要把表格的 sorting、pagination、row action 逻辑重写成页面外的副本。 - 修改后至少运行: @@ -193,3 +195,25 @@ npm run lint ``` 当前已知 lint warning 主要来自既有 hooks 的 `set-state-in-effect` 和 React Fast Refresh 对“组件文件同时导出常量”的提示;它们不是编译错误。Vite 可能提示主 chunk 超过 500 kB,属于当前依赖包体积提示。 + +## 10. 看板地图与加载(2026-09-10) + +**有可视化,入口是角色 home,不是单独 BI 产品:** + +| 角色 | 路由 | 图表 | +| --- | --- | --- | +| 客户 | `#/app/customer/home` | 资产结构饼图 · 持仓盈亏柱图 · 表 | +| 理财师 | `#/app/advisor/home` | 名下客户 AUM 分布 | +| 分析 | `#/app/analyst/home` | 市场/产品概览 | +| 风控 | `#/app/risk/home` | 预警类型分布 | +| 问数 | `#/app/analytics/query` | MetricCard(`/api/analyst/dashboard`)· 无钻取(D-12 未做) | + +**为何每次进页都转圈:** + +- **Redis 不缓存**平台 REST(持仓/净值/预警);只服务 Chat 窗口、问数 D-06、限流、L3。 +- 每个页面 **mount 时 `loading=true` 全量重拉**;离开路由 state 丢弃(无 React Query)。 +- `fetchNavMap` 对**每个 product_id 单独请求** `/nav`,持仓/行情页请求次数多。 + +**明日优化方向(见 `TODO.md` §2026-09-11):** 批量净值 API · 客户端 stale-while-revalidate · 可选 Dashboard 短 TTL 缓存(后端)。 + +**客户「产品趋势」:** 客户助手 Chat 仅 **C-05 最新净值**;走势/预测 reject。统计类趋势走 **问数工作台** + customer **`self` 域**(`数据分析Agent-合并说明.md` §9.6)。分析对话页仍为占位。 diff --git a/docs/memory/ITERATION.md b/docs/memory/ITERATION.md index 5708118..5672d41 100644 --- a/docs/memory/ITERATION.md +++ b/docs/memory/ITERATION.md @@ -25,4 +25,8 @@ | 2026-09-10 | **数据分析 Agent 护栏/口径迭代**(cherry-pick `445cfbb` 功能层)· guardrail Decimal/编号/时间窗 · 流水 N-01 消歧 · battery 脚本改 `issue_dev_token` | 远程 `data-analysis-agent` 未合 merger | guardrail / dict_service / wave6 测试 / `数据分析Agent-代码迭代.md` | | 2026-09-10 | **数据分析 D-06 缓存**:结果缓存表世代 + 写侧 bump(trade/alert/L3)· **模板填参**(`template_service`)· 问数页 template/cache 标签 · `804 pytest` | 用户拍板 D-06 必须 · PII 脱敏暂缓 | cache_service / template_service / analyst_agent / web | | 2026-09-10 | **风控前端 P1 + 演示**:台账筛选 · 适当性/AML 页 · `prepare_all.ps1` · 深潜课模块 7 | 2026-09-10 拍板收口 | web/risk · scripts/demo · docs/course | -| 2026-09-10 | **`battery_report.json` 不入库** | 本地跑分产物 | `.gitignore` | +| 2026-09-10 | **客户/共用 ChatPanel**:流式请求补传 `session_id`(续聊不再每轮新建会话)· `sessionStorage` 刷新恢复 · 会话侧栏 8 条分页 · 固定高度布局 | E2E 反馈 CHAT-1/2 | web · 整体测试交接 · MEMORY | +| 2026-09-10 | **前端 E2E 问题清单收口**:`AuthProvider` 修登录崩溃/退出死循环 · Redis `socket_connect_timeout` 0.5s · 预警筛选防抖 · I18N formatter · 对话 Markdown 粗体 · A11Y `index.html` · AntD `` | `docs/整体测试/前端整体测试交接.md` 登记项 | web · `database.py` · MEMORY | +| 2026-09-10 | **答辩/课程**:`docs/答辩/答辩知识点清单.md` · 总览模块 8 · 问数课 D-06 模块 5 · 导览中心更新 | 用户答辩准备 | docs/course · MEMORY | +| 2026-09-10 | **客服问候修复**:「你好」关键词 → `chit_chat` · LLM 失败 `CHITCHAT_DEGRADED_TEXT`(非 FALLBACK)· 游客问候快路由 | 用户反馈 Chat 无法回答问候 | customer_prompts · customer_service · visitor · tests | +| 2026-09-10 | **产品口径落账**:Redis≠Dashboard 缓存 · 四角色 home 有图无 BI · 客户趋势=问数 self 域非 Chat 曲线(§9.6) | 用户问答澄清 | MEMORY §0/§3 · TODO §2026-09-11 · FRONTEND-HANDOFF §10 | diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index 44099de..ce55b7c 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -9,7 +9,7 @@ **项目是什么:** 金融四 Agent(客户财富 / 代理人 / 数据分析 / 风控)共用数据层与合规底座;**不**互调 LLM,跨 Agent 走 L1/L2/L3 画像与预警表。 -**当前进度:** 需求与表设计已定 · **风控 + 平台 API + 客服 S2 Wave3 + 数据分析 S3/P2/D-06 + 前端四角色 P0 Demo** · **804 pytest** · **19 Vitest** · **Redis @ 6380** · **`merger` 工作区未 commit** +**当前进度:** 需求与表设计已定 · **风控 + 平台 API + 客服 S2 Wave3 + 数据分析 S3/P2/D-06 + 前端四角色 P0 Demo** · **804 pytest** · **22 Vitest** · **Redis @ 6380** · **`merger` 工作区未 commit** **工作分支:** 团队开发在 **`merger`**;历史 `risk-control-agent` 交付冻结。 @@ -41,14 +41,14 @@ | `app/utils/` | **基本就绪** | trace(trace_id+request_id 双 contextvar)/ desensitize / db(引擎工厂)/ response(统一错误体+4xx/500 handler)/ exceptions(含 ApiError)已实现;logger 占位 | | `app/config/database.py` | **已实现** | MySQL 双引擎 + **Redis 单例**(`REDIS_URL` · **RESP2 `protocol=2`** 兼容 Docker Redis 7 / 旧 Windows Redis 3) | | `app/config/settings.py` | **已实现** | 双库 + risk_* 阈值 + JWT + **customer/visitor/profile** 字段 + `kb_root_dir` | -| `app/service/customer_service.py` `visitor_service.py` | **已实现(S2 + Wave3 部分)** | 客服 14 节点 · **C-04 阈值** · **C-05 nav_query** · **C-11 匹配说明** · 游客 9 节点;共用 `sanitize_postprocess.finalize_sanitized_reply`(1B);RAG **fin_* 三库** | +| `app/service/customer_service.py` `visitor_service.py` | **已实现(S2 + Wave3 + 问候快路由)** | 客服 14 节点 · **C-04 阈值** · **C-05 nav_query** · **C-11 匹配说明** · **「你好」等问候关键词 → chit_chat;LLM 失败闲聊降级 `CHITCHAT_DEGRADED_TEXT`** · 游客 9 节点;共用 `sanitize_postprocess`(1B);RAG **fin_* 三库** | | `app/service/threshold_service.py` | **已实现(C-04)** | L1 `threshold_pref_summary` → `customer_threshold_config`;持仓查询加权盈亏 vs 阈值 → `customer_notify_log` | | `app/utils/sanitize_postprocess.py` | **已实现(1B 共用)** | 数据查询 intent(含 nav_query)违禁 → 回退 fact_text;RAG/闲聊 → COMPLIANCE_REJECT;均不自动 transfer | | `scripts/core/*.sql` + `reset.ps1` | **已实现** | Core 模拟库 DDL + 种子 | | `scripts/agent/` `scripts/demo/` `scripts/dev/` | **已实现** | AML 种子 · **`prepare_all.ps1` 一键灌库** · `seed-analyst-query-templates.sql`(模板缓存)· `run_query_battery.py`(**不入库**)· `start-redis.ps1` | | `scripts/sync/*.py` | **已实现** | 归属同步 + Neo4j 全图 | | `tests/` | **已实现** | **804 用例** 1 skipped(Wave6 template/cache + Wave3 customer + 1B/R1) -| `docs/course/` | **交互课程集** | 导览中心 + 风控深潜 **7 模块**(含模块 7 写侧并发) +| `docs/course/` | **交互课程集** | 导览中心 + 总览 **8 模块**(含模块 8 答辩动线)+ 问数 **5 模块**(D-06)+ 风控深潜 **7 模块**(含写侧并发)· 提纲 `docs/答辩/答辩知识点清单.md` | `docs/PRD/PRD-风控监测Agent.md` | **已冻结(v1.1)** | 风控 PRD v1.0 + v1.1 追加 FR-8/9/10(§4A)+ 规则表附录 | | `docs/项目框架设计/实现方案-风控追加需求v1.1-C4C6.md` | **已定稿** | C4~C6 编码依据(经独立 AI 评审修订闭环);分支/进度速览另见项目根 `交接文档.md` | | `docs/项目框架设计/合并注意事项-风控模块并入main.md` | **AL-09 已执行(2026-09-08)** | 合并接线完成;接缝见《风控Agent模块边界与合并接缝标注.md》 | @@ -58,7 +58,7 @@ | `docs/项目框架设计/接口契约-代销平台API-v0.2-行情扩展草案.md` | **草案(2026-09-09)** | `nav-snapshot` · `sync_market_nav` · 前端产品行情 Phase B | | `docs/项目框架设计/表设计/` | 已定 | Agent 共用 11 表 + agent 专用 SQL | | `docs/项目框架设计/Core模拟底座/` | 已定 | 无真实 Core 时的 L0 方案 | -| `web/` | **P0 Demo 齐备(2026-09-10)** | 四角色 Dashboard/Chat · 问数工作台(**模板/缓存标签**)· 资产沉淀 · 风控台账筛选+适当性+AML+模拟交易 · 平台行情 · **分析对话仍为轻量占位**(NL2SQL 走问数页) | +| `web/` | **P0 Demo 齐备 + E2E/Chat 缺陷已修(2026-09-10)** | 四角色 Dashboard/Chat · **`ChatPanel` 流式续聊 `session_id` + 刷新恢复** · 会话侧栏分页 · 固定面板高度 · 清单见 `docs/整体测试/前端整体测试交接.md` | **本地 bootstrap(首次):** 完整步骤与前置说明见 `FLOW.md` §0(权威),速览: @@ -77,10 +77,14 @@ **AL-09 合并后架构(一句话):** 宿主 `gateway/` + 模块 `deps.py` **双栈并存**;对外登录/token **统一**;chat/risk 均走模块鉴权;接缝 S2 用 `auth_adapter`。 -**下一步(见 TODO):** git commit `merger` · 20 题 battery 实跑 · 客服手工验收/TEST-LOG · 接口契约发群 · analyst Q17/Q7 挂账 · D-09/N-03/N-07 未做 +**下一步(见 TODO · §2026-09-11 接续):** git commit `merger`(含问候修复)· 前端加载体验(batch nav / 客户端缓存)· 产品口径文档已落 MEMORY §3 · battery · 客服验收 · D-09/D-12 往后排 + +**⚠️ 前端 E2E 未跑测试(2026-09-10):** 该轮走查**只做到「25 路由能渲染 + 无 console/HTTP 错误」,不是逐功能详测** —— 数字/图表/交互正确性、**接口错误态**、对话多轮与会话管理、适当性通过路径、交易拦截路径等**均未验证**(CHAT-1~4 就是这样漏掉的)。**未跑清单见 `TODO.md` §「前端 E2E · 未跑测试」**,报告见 `docs/整体测试/前端整体测试交接.md`。 **Redis(2026-09-09):** 推荐 **Docker** `jinrong-redis`(`redis:7-alpine`)· 宿主机 **6380** → 容器 6379 · `.env` `REDIS_URL=redis://127.0.0.1:6380/0` · 客户端 **RESP2**(`database.py` / `redis_gateway.py`)。 +**Redis 不加速页面看板(2026-09-10 澄清):** Redis 管 **Chat 会话窗口 / 问数 D-06 结果 / 限流 / L3 热读**;**平台 GET(持仓/净值/预警列表)与 Dashboard hooks 每次进页仍打 MySQL**,前端无跨路由缓存 · 净值 **`fetchNavMap` 按产品 N 次 HTTP** → 体感「每页都转圈」见 `TODO.md` §2026-09-11 · `FRONTEND-HANDOFF.md` §10。 + **前端启动(`web/`):** `npm install` → `npm run dev`(`5173`,`/api` 代理 `8000`)· 验收四 demo 账号见 **`docs/frontend/FRONTEND-HANDOFF.md` §8** · 改 UI 后跑 `npm run build && npm run test && npm run lint`。 **平台 API 硬规则(2026-09-08 拍板):** 路由 **A · 按业务域**(`/api/customers` 等);与 Agent 功能重复时 **以本平台 API 为准**;命名见契约 §2;合并时统一改 canonical 路径。 @@ -166,6 +170,7 @@ audit_log 等审计表(只 INSERT) | **C-11** | 「我能买什么/匹配产品」→ `suitability_check` + Core 可购列表;**「推荐稳赚/买什么好」仍 reject** | `customer_prompts._ELIGIBLE_PRODUCTS_KW` | | **C-07** | 风评查询走 Core;**「重新测评/重做风评」** → 引导 App/网点(Agent 内不做问卷) | `customer_prompts._RISK_KW` | | **C-08** | 仅 L1 槽位 `investment.allocation_target`(13 槽);**无自动偏离检测/调仓** | `profile_slots.py` | +| **客户「趋势/走势」** | **客户 Chat**:仅 **C-05 最新净值快照** + 持仓/流水;**走势预测/实时盘口 reject**;**无 Chat 内净值历史曲线 Tool** · **统计类趋势**(近 N 日笔数、结构描述)→ **问数** `POST /api/analyst/chat` · **`self` 域**(拍板 `数据分析Agent-合并说明.md` §9.6 · 尾注「AI 分析有风险」)· **D-12 看板钻取未做** | 问数 ≠ 客户助手 · Phase B 行情 sync 未做 | | **L0 优先** | 抽槽与 L0 撞车**永远听 L0**;L1 只 enrich 措辞 | `profile_slots` D7 | ------ @@ -181,7 +186,7 @@ Core 模拟:scripts/core/reset.ps1 · 文档 docs/项目框架设计/Core模 启动:uvicorn app.main:app --reload → GET /health Redis:`docker compose up -d redis` · `REDIS_URL=redis://127.0.0.1:6380/0` · `scripts/dev/start-redis.ps1` 测试:python -m pytest(**804 绿**;集成需本机 MySQL + AML + 风控演示数据) -前端:cd web && npm run dev · npm run build/test/lint(**19** Vitest)· 四角色 Demo 见 `docs/frontend/FRONTEND-HANDOFF.md` §8 +前端:cd web && npm run dev · npm run build/test/lint(**22** Vitest)· 四角色 Demo 见 `docs/frontend/FRONTEND-HANDOFF.md` §8 问数模板:mysql … < scripts/agent/seed-analyst-query-templates.sql 风控一键灌库:.\scripts\demo\prepare_all.ps1 运维/演示脚本:scripts/demo/subscribe_alerts.py(订阅推送演示)· rebuild_alerts.py TRD-xxx(引擎异常补偿重放) diff --git a/docs/memory/REQUIREMENTS.md b/docs/memory/REQUIREMENTS.md index 215890a..c997bca 100644 --- a/docs/memory/REQUIREMENTS.md +++ b/docs/memory/REQUIREMENTS.md @@ -60,7 +60,7 @@ | C-02 | 产品规则咨询 | fin_product RAG + 免责 | **已实现** · Wave1~2 | — | | C-03 | 政策/FAQ | fin_policy / fin_faq RAG | **已实现** · Wave1~2 | — | | C-04 | 亏损阈值提醒 | `customer_threshold_config` + 持仓查询追加提醒 | **已实现(2026-09-10)** · `threshold_service` | ~~T-40~~ | -| C-05 | 产品净值 | Core 最新净值快照;禁止实时盘口 | **已实现(2026-09-10)** · `nav_query`;「实时净值」reject | ~~T-40~~ | +| C-05 | 产品净值 | Core 最新净值快照;禁止实时盘口 | **已实现(2026-09-10)** · `nav_query`;「实时净值」reject · **无 Chat 历史走势曲线**;统计趋势见问数 self 域(合并说明 §9.6) | ~~T-40~~ | | C-07 | 风评查询 / 重测引导 | Core L0;重测引导 App/网点 | **部分已实现** · 无 Agent 内问卷 | T-41 | | C-08 | 配置目标 | L1 槽 `allocation_target` | **部分已实现** · 无偏离检测/调仓 | T-41 | | C-11 | 适当性匹配说明 | 非营销推荐;联动 R-02 | **部分已实现** · 「我能买什么」→ suitability;「推荐稳赚」仍 reject | T-42 | diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index 6e472a1..dff7153 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -9,7 +9,7 @@ ### 本批次 · 优先收尾(推荐顺序) -- [ ] **git commit `merger`**:Wave3 + analyst D-06/模板缓存 + 风控前端/灌库/深潜课7 + 问数 UI 标签 + 文档(你确认后执行) +- [ ] **git commit `merger`**:Wave3 + analyst D-06/模板缓存 + 风控前端/灌库/深潜课7 + 问数 UI 标签 + **客服问候快路由/闲聊降级** + 答辩清单/课程 + 文档(你确认后执行) - [x] **风控深潜课改版(方案 1 · 7 模块 · 读者 dev)**:模块 1–7 已写(含写侧并发)· `jinrong-module-risk/index.html` 已拼接 - [ ] **数据分析 · 20 题 live battery**:`uvicorn` + DeepSeek → `python scripts/dev/run_query_battery.py` → 本地写 `scripts/dev/battery_report.json`(**已拍板:不入库** · 见 `.gitignore`) - [ ] **客服 · 手工验收**:`docs/memory/tests/2026-09-10-customer-1b-r1/MANUAL-CHECKLIST.md`(CUST-9527 持仓/sanitize/R1 种子) @@ -17,6 +17,22 @@ - [ ] **测试包签核**:`TEST-LOG-2026-09-10-CS-001` → 模块负责人 zhangyong 确认 - [ ] **接口契约发群稿**:`docs/项目管理/接口契约发群-2026-09-09.md`(复制到群即完成) - [ ] **口径字典/问数模板种子**:本机执行 `seed-analyst-metric-dict.sql` · **`seed-analyst-query-templates.sql`**(若未灌) +- [ ] **前端 E2E 补齐未跑测试**(见下方「前端 E2E · 未跑测试」;2026-09-10 走查只做到「路由能渲染」,**不是逐功能详测**) + +### 2026-09-11 接续(2026-09-10 晚对话落账 · 明天干) + +> 工作区已有改动 · **未 commit**。先读 `MEMORY.md` §0(Redis≠页面缓存)· §3 客户「趋势」行。 + +**Tonight 已做** + +- [x] `docs/答辩/答辩知识点清单.md` + 课程对齐(总览模块 8 · 问数 D-06 模块 5) +- [x] 客服「你好」→ 问候 `keyword_route` + `CHITCHAT_DEGRADED_TEXT`(`customer_service` / `visitor_service` · 单测 `test_greeting_chitchat_without_llm`) + +**明天优先** + +- [ ] **前端进页转圈**:batch 净值 API 或合并请求 · 客户端 stale-while-revalidate(见 `FRONTEND-HANDOFF.md` §10) +- [ ] **看板演示口径**:四角色 `/app/{role}/home` 有图表;无 BI 大屏 · D-12 钻取仍 open +- [ ] **客户持仓产品趋势(若要做)**:拍板 Chat 历史 nav Tool vs 问数引导 · 现状 §9.6 仅问数 `self` 域 ### 需拍板(定了再开任务) @@ -54,6 +70,33 @@ - [x] **问数结果标签**:`AnalystQueryPage` 展示 `template_hit` / `cache_hit`(2026-09-10) - [ ] **分析对话与问数合一** / **看板钻取** — 规格 D-09/D-12,往后排 - [ ] **Vitest 补测**(可选):`useChatPanel` · `api/analyst.ts` mock + +### 前端 E2E · 未跑测试(2026-09-10 走查缺口) + +> ⚠️ **口径说明:** 2026-09-10 那轮 E2E **不是逐功能详测**。25 条路由的断言只是「页面渲染出来 + 无 console error + 无 4xx/5xx」, +> **页面上的数字、图表、交互是否正确一律未验证**。下面这些是**明确没跑**的,**复测前不要当作已验证**。 +> 报告与脚本:`docs/整体测试/前端整体测试交接.md` · 脚手架在仓库外 `C:/Users/Windows/e2e-jinrong/`。 +> **反例佐证:** CHAT-1~4 这四条真实缺陷是**后来别人发现的**,走查时完全漏掉 —— 因为对话线每条只发了 1 句就收工。 + +**P1** + +- [ ] **对话模块详测**(最优先 · CHAT-1~4 已修需回归):多轮连发 · F5 刷新保持同一 session · 会话侧栏分页 · 会话切换/删除/关闭 · 并发发送 · 超长消息 · 断流重连 +- [ ] **接口错误态**:主动打断后端(停 uvicorn / 改 proxy 返 500 / 制造超时),逐页验证 `ApiErrorResult` 是否给出 code / message / trace ID + 可重试 —— 这是 `FRONTEND-HANDOFF.md` §8 白纸黑字的验收项,**本轮完全未验证** +- [ ] **数据正确性**:拿后端原始响应逐项核对前端渲染值(持仓市值/盈亏、AUM、预警条数、仪表指标卡)—— 本轮**一次都没对过** +- [ ] **业务边界路径**: + - 适当性:只跑了 A-1 阻断,**通过路径未测** + - 模拟交易:只跑了放行,**风控拦截路径未测** + - 预警处置:只提交一次,**其余状态流转 / 校验失败未测** + - AML 扫描:只全量跑一次,**未核对结果正确性** + +**P2** + +- [ ] **图表正确性**:`@ant-design/charts` 数据映射、空数据、单点、极值 —— 本轮只确认「渲染出来没报错」 +- [ ] **排序/分页正确性**:只确认「点了不崩」,**未验证排序真的有序、翻页真的换内容** +- [ ] **资产沉淀**:只确认表单渲染,**从未提交过一条** +- [ ] **客户档案**:只看到 5 个字段(customer_id / display_name / risk_code / risk_expires_at / phone_masked),其余字段渲染未验证(另见 OBS-2 后端 `occupation:"???"` 乱码) +- [ ] **掩码持久化**:眼睛按钮状态跨路由 / 刷新 / 重登录的行为未全量验证 +- [ ] **PERF-1 复测**:Redis 起到 6380 后重测接口耗时 —— 当前记录的 **2.05s 是 Redis 未起环境下**的测量值,环境修好后数字会变 - [x] **simulate 交易 UI**:风控菜单「模拟交易」+ `RiskSimulatePage` + `api/simulate.ts`(演示说明 · A-3/A-1 预设) - [x] **接口契约发群稿**(见 `docs/项目管理/接口契约发群-2026-09-09.md` · 复制到群即完成) diff --git a/docs/整体测试/前端整体测试交接.md b/docs/整体测试/前端整体测试交接.md new file mode 100644 index 0000000..8cfa536 --- /dev/null +++ b/docs/整体测试/前端整体测试交接.md @@ -0,0 +1,373 @@ +# JinRong 前端整体测试交接 + +> **修复状态(2026-09-10 晚):** 仓库已合入 P0~P2 修复 —— `AuthProvider`(BUG-1/2)· Redis 连接超时 0.5s(PERF-1 降级)· 预警客户 ID 防抖 · `displayLabels`/`formatDateTime` · 对话 `**` 粗体 · 登录重入 · `index.html` lang/title · Ant Design `` · 分析对话 Banner。**CHAT-1/2(2026-09-10 夜):** 流式续聊传 `session_id` + `sessionStorage` 恢复当前会话 · 侧栏分页 · 固定 640px 面板高度。建议复测:客户助手连发 2 轮 → F5 → 仍 1 条会话。E2E 脚本仍在 `C:/Users/Windows/e2e-jinrong/`,BUG-1 修好后可删 `login()` 内 `reload`。 + +> 交接对象:接手修复的工程师 / 下一轮测试的人 +> 测试日期:2026-09-10 | 分支:`merger`(含当时未提交改动) +> 测试范围:`http://localhost:5173` 全部 25 条路由 × 4 个角色,真实浏览器端到端 + +--- + +## 0. 一句话结论 + +主路径是**断的**:全新浏览器第一次点演示账号登录必定白屏崩溃,点「退出」会把标签页转死。 +两个都是 `web/src/App.tsx` 里同一处 auth 下发方式导致的,**一处修改可同时修掉**。 +其余为性能(全站 +2s)、展示中文化与交互细节问题,不影响可访问,但影响观感。 + +--- + +## 1. 测试范围与覆盖 + +| 维度 | 覆盖内容 | +| --- | --- | +| 路由 | 全部 25 条 × 4 角色(客户 / 理财师 / 分析员 / 风控) | +| 主流程 | 登录(4 账号)、退出、侧栏折叠展开、菜单高亮、actor ID 复制、面包屑 | +| 业务 | 持仓、交易明细、产品行情、名下客户、问数工作台、资产沉淀、预警台账、适当性校验、AML 扫描、模拟交易、预警处置弹窗 | +| 对话 | 4 条 SSE 对话线(客户 / 顾问 / 风控 / 分析)+ 登录页游客试聊 | +| 边界 | 未登录深链、未知路由、连点提交、刷新保持、空输入 | +| 响应式 | 1280 / 1024 / 768 / 320px 四档 | +| 可访问性 | 标签标题、`lang`、`h1` 层级、图片 `alt`、按钮名称、键盘焦点环 | + +**结论:25 条路由全部可渲染,无未捕获异常、无 4xx/5xx、无请求失败。** 阻断问题集中在登录/退出这一对状态切换上。 + +--- + +## 2. 环境与已知环境因素 + +| 组件 | 地址 | 备注 | +| --- | --- | --- | +| 前端 Vite | `http://localhost:5173` | `HashRouter`,URL 形如 `/#/app/customer/home` | +| 后端 FastAPI | `http://127.0.0.1:8000` | Vite 代理 `/api` | +| **Redis** | `.env` 配的是 `127.0.0.1:6380` | ⚠️ **该端口是关的**,见 PERF-1 | + +> ⚠️ **复测前先确认 Redis 已启动。** 本次测试期间 6380 无 Redis(本机 6379 上是另一个实例), +> 这直接造成了 PERF-1。**PERF-1 的测量结果在 Redis 正常后会显著变化**,届时需要复测一次。 + +复现失败时先排除环境因素: + +```bash +cd web && npm install && npm run dev # 前端 +uvicorn app.main:app --reload # 后端 +redis-cli -p 6380 ping # 应回 PONG,不通就是 PERF-1 的成因 +``` + +--- + +## 3. 问题清单(按优先级) + +| 编号 | 级别 | 问题 | 位置 | 状态 | +| --- | --- | --- | --- | --- | +| BUG-1 | 🔴 阻断 | 首次登录整站崩溃,白屏「应用加载失败」 | `web/src/App.tsx` → **`AuthProvider`** | ✅ 已修 | +| BUG-2 | 🔴 阻断 | 点「退出」标签页卡死(无限重定向) | 同上 | ✅ 已修 | +| PERF-1 | 🟠 高 | 每个已鉴权接口固定慢 ~2s,全站 2~6s 才出数据 | `.env` / 鉴权热路径 | ⚠️ 环境仍须起 Redis;代码已降连接超时 | +| BUG-3 | 🟠 高 | 筛选框无防抖,敲 9 个字发 9 个请求 | `RiskAlertsPage.tsx:177` | ✅ 已修 | +| I18N-1 | 🟡 中 | 交易明细「日期」显示原始 ISO | `CustomerTradesPage.tsx:42` | ✅ 已修 | +| I18N-2 | 🟡 中 | 预警台账「创建时间」显示带微秒时间戳 | `RiskAlertsPage.tsx` 列定义 | ✅ 已修 | +| I18N-3 | 🟡 中 | 后端英文枚举直接露出(6 处) | 各表格列 | ✅ 主要列已映射 | +| I18N-4 | 🟡 中 | 风控首页大指标硬编码英文枚举 | `RiskAlertsDashboard.tsx:70` | ✅ 已修 | +| I18N-5 | 🟡 中 | 问数工作台指标卡标题是英文 key | `AnalystQueryPage.tsx:100` | ✅ 已修 | +| UX-1 | 🟡 中 | AI 回复的 Markdown 未渲染,`**` 直接显示 | 各对话气泡 | ✅ 粗体已渲染 | +| UX-2 | 🟡 中 | 连点登录按钮发 2 次请求 | `LoginPage` | ✅ 已修 | +| UX-3 | 🟡 中 | 「分析对话」查数答不了,与问数工作台定位冲突 | 分析对话页 | ℹ️ 仍占位;Banner 已加强 | +| A11Y-1 | 🟡 中 | 标签页标题是 Vite 默认 `web` | `web/index.html` | ✅ 已修 | +| A11Y-2 | 🟡 中 | `` 与全中文界面不符 | `web/index.html` | ✅ 已修 | +| A11Y-3 | 🟡 中 | 登录页有两个 `

` | `LoginPage` | ✅ 侧栏改为 h2 | +| OBS-1 | 🟡 中 | 常驻 AntD 弃用告警;`message` 静态调用不继承主题 | 全局 | ⚠️ 已包 ``;弃用 API 未全量替换 | +| OBS-2 | ⚪ 低 | 后端种子数据乱码 `occupation:"???"` | 尚未暴露到界面 | 未修 | +| CHAT-1 | 🔴 高 | **流式对话每轮新建 session**(刷新后同一轮聊天拆成多条会话) | `useChatPanel` 未传 `session_id` 给 `postChatStream` | ✅ 已修 | +| CHAT-2 | 🟡 中 | 会话列表撑高整页;应固定对话框高度 + 列表分页 | `ChatPanel.tsx` | ✅ 已修 | +| CHAT-3 | 🟡 中 | 对话区过矮 · 用户气泡字色被 AntD 盖掉 · 无删除入口 | `ChatPanel.tsx` | ✅ 已修(加高 + 白字 + 侧栏/当前删除) | +| CHAT-4 | 🟠 高 | **已关闭会话**仍出现在侧栏,再点删除 → 409 删不掉 | 列表未筛 active | ✅ 侧栏仅 active(服务端+客户端双滤)+ close-all **或** 逐条 close 兜底 + **须重启 uvicorn** | + +--- + +## 4. 复现步骤与定位 + +### BUG-1 🔴 首次登录整站崩溃 + +**复现:** + +1. 打开浏览器开发者工具 → Application → Local Storage → 清空(或直接用无痕窗口) +2. 访问 `http://localhost:5173/#/login` +3. 点任意「Demo」演示账号按钮 +4. 页面顶部闪现角色欢迎语,随即整页变为 + `应用加载失败 / Cannot read properties of null (reading 'roleLabel') / 刷新页面` + +**复现率 100%。对没登录过的用户,这就是默认路径。** + +| 场景 | 结果 | +| --- | --- | +| 全新会话 → 点演示账号登录 | ❌ 崩溃 | +| 崩溃后手动刷新一次 | ✅ 正常 | +| 已有会话时冷启动 | ✅ 正常 | + +**定位 —— `web/src/App.tsx`:** + +```tsx +function AppRoutes() { + const auth = loadAuth() // ← 只在 AppRoutes 渲染时读一次 + return ( + + {/* ← 导航时重新 loadAuth(),拿到新值 */} + {/* ← 这个 prop 在 element 构建期就求值并冻死了 null */} + + } /> +``` + +`AppRoutes` 不订阅 router context,`navigate()` 不会让它重渲染。所以首次登录时 +`` 这个 element 仍带着**登录前那次渲染**捕获的 `auth = null`; +而 `RequireAuth` 在导航时重新 `loadAuth()` 拿到非 null 于是放行 → +`AppLayout` 收到 `null` → `auth.roleLabel` 抛错 → 根级 ErrorBoundary 接管。 + +**建议改法:** 不要在 element 构建期求值。把 auth 提到 React state(`AuthProvider` + `useContext`), +或让 `RequireAuth` 读到 auth 后通过 render-prop / context 下发给子组件。 + +> ⚠️ 注意 `FRONTEND-HANDOFF.md` §9 明确写着「不要修改 `AuthState`、`jinrong.auth` localStorage key」—— +> 本次修复**不需要动 key、也不需要动 `AuthState` 结构**,只改 auth 的**传递方式**,与那条约束不冲突。 + +--- + +### BUG-2 🔴 点「退出」把标签页卡死 + +**复现:** + +1. 任一角色登录成功 +2. 点右上角「退出」 +3. 页面变全白,**浏览器标签页 CPU 跑满、完全无响应**,只能强制关闭标签页 + +**实测证据(决定性):** 点击「退出」后对 `history.replaceState` 计数: + +``` +点击前: 1 +点击后 +0.5s: 1,297 +点击后 +2.0s: 28,257 ← 仍在持续增长 +点击后 +3.0s: 页面 evaluate 无响应(主线程被占满) +``` + +**根因(与 BUG-1 同源):** `logout()` 执行 `clearAuth()` + `navigate('/login')`,但 + +- `/login` 的 element 是「auth 非 null」时构建的 `` +- `/app` 的 element 里 `RequireAuth` 现场 `loadAuth()` 得到 `null` → 返回 `` + +两者互相 ``,形成**无限重定向循环**。每轮 `` 走独立 effect + `replaceState`, +React 不会抛 "Maximum update depth exceeded",所以**没有任何错误提示,只是静默地把标签页转死**。 + +> `docs/frontend/FRONTEND-HANDOFF.md` §8 把「退出后回到登录页」列为验收项, +> 说明这是**回归缺陷**,不是设计如此。 + +--- + +### PERF-1 🟠 每个已鉴权接口固定慢 ~2 秒 + +**实测(连打 3 次结果一致):** + +| 接口 | 耗时 | +| --- | --- | +| `POST /api/auth/login` | 0.014s | +| `GET /api/products`(**不带 token**) | 0.066s | +| `GET /api/products`(带 token) | **2.05s** | +| `GET /api/customers/CUST-9527/holdings` | **2.06s** | +| `GET /api/risk/alerts` | **2.06s** | +| `GET /api/analyst/dashboard` | **2.10s** | + +**根因:** `.env` 配 `REDIS_URL=redis://127.0.0.1:6380/0`,6380 上没有 Redis。 +`auth_service.is_revoked()` 在**每个请求的鉴权路径**上同步查 jti 黑名单,连不上时 fail-open 放行 —— +**但连接失败本身要耗时 ~2.4 秒**: + +``` +redis err after 2.45s: ConnectionError Error 10061 connecting to 127.0.0.1:6380 +``` + +于是「每个接口 +2s」被累加到每次页面加载:客户「我的持仓」要 ~5s 才脱离骨架屏, +「交易明细」要 ~5s 才出 2 行数据。**整个 Demo 用起来就是「卡」**,很容易被误认为页面坏了。 + +**建议:** + +1. 环境上按 README 起 Redis(**先做这步,再复测性能**); +2. 代码上给鉴权热路径的 Redis 查询加**熔断**:首次连接失败后 N 秒内直接 fail-open 不再重试; + 或把 `socket_connect_timeout` 从 3s 降到 0.3s 量级。 + +否则任何一次 Redis 抖动都会放大成全站慢 —— 这是 fail-open 设计没有配套超时预算的典型问题。 + +--- + +### BUG-3 🟠 筛选输入框没有防抖 + +`web/src/pages/risk/RiskAlertsPage.tsx:177` 附近,「客户 ID」的 `onChange` 直接 `setCustomerId`, +该 state 是 `load` 的 `useCallback` 依赖 → `useEffect` 每次重跑。 + +**实测:** 逐字输入 `CUST-1002`(9 个字符)→ **发出 9 次 `/api/risk/alerts`**。 + +叠加 PERF-1 后,用户每敲一个字都要等 2 秒、页面反复重渲染。 +(`useAsyncSequence` 只挡住过期写入,挡不住请求风暴。) + +**建议:** 加 300ms 防抖,或改成回车 / 失焦才提交。 + +--- + +### I18N-1 ~ I18N-5 展示层 + +| 编号 | 现象 | 位置 | +| --- | --- | --- | +| I18N-1 | 交易明细「日期」渲染成 `2026-08-15T14:20:00`(列宽 120px 还截断) | `CustomerTradesPage.tsx:42`,`dataIndex: 'traded_at'` 无 render | +| I18N-2 | 预警台账「创建时间」渲染成 `2026-09-10T16:54:50.398000`(180px 放不下) | `RiskAlertsPage.tsx` 列定义 | +| I18N-3 | 英文枚举漏出:`aml` / `large_amount` / `pending_review` / `confirmed_normal` / `subscribe` / `confirmed` / `money` / `bond` / `stock` / `mixed` | 各表格列 | +| I18N-4 | 风控首页大指标 `mainLabel="pending_review 总数"` | `RiskAlertsDashboard.tsx:70` | +| I18N-5 | 问数工作台指标卡标题是英文 key:`CUSTOMERS 33` / `HOLDINGS 7290960` / `PENDING 1` / `DICT_COUNT 0` | `AnalystQueryPage.tsx:100` `label={key}` | + +> **I18N-3 / I18N-5 的修法很直接:页面里已经有中文映射了。** +> 例如 `RiskAlertsPage` 的筛选下拉就是 `{value:'aml', label:'AML'}`,表格列只是没复用。 +> I18N-5 更明显 —— 同页面下面那行「可用维度:客户总数 · 总持仓规模 · 今日交易笔数与金额 · +> 待处理预警数 · 口径字典资产数」**已经是中文**,说明后端 `cards` 字段本就是给这里用的,只是没接上。 +> +> 建议抽一个公共 formatter(时间 / 枚举 → 中文)放 `src/utils`,按 `FRONTEND-HANDOFF.md` §6.3 补纯函数测试。 + +--- + +### UX-1 ~ UX-3 + +- **UX-1 Markdown 未渲染**:所有对话线都用 `` 原样输出,用户看到成排 `**`。 + 实测片段:`当前共有 **1 条**待处理预警`、`1. **有效身份证件** 2. **本人银行卡**`。 +- **UX-2 连点登录发 2 次请求**:`LoginPage.handleLogin` 无重入保护,`

- {chat.loadingSessions ? ( -
- -
- ) : chat.sessions.length === 0 ? ( - - 暂无历史会话 - - ) : ( - 0 ? ( + { + void chat.clearAllSessions().then(() => { + message.success('已清空进行中的会话') + }) + }} + > + + + ) : null} +
+ {chat.loadingSessions ? ( +
+ +
+ ) : chat.sessions.length === 0 ? ( + + 暂无历史会话 + + ) : ( + ( + { + void chat.deleteSession(item.session_id).then(() => { + message.success('已删除') + }) + }} + > + + , + ]} + > +
void chat.loadSession(item.session_id)} + onKeyDown={(e) => { + if (e.key === 'Enter') void chat.loadSession(item.session_id) + }} + role="button" + tabIndex={0} + > + + {item.title || item.session_id} + + } + description={ + + 进行中 · {item.created_at?.slice(0, 16) ?? '—'} + + } + /> +
+
+ )} + /> + )} +
+ {chat.sessionTotal > chat.sessionPageSize ? ( + ( - void chat.loadSession(item.session_id)} - > - - {item.title || item.session_id} - - } - description={ - - {item.status} · {item.created_at?.slice(0, 16) ?? '—'} - - } - /> - - )} + className="shrink-0 !text-center" + current={chat.sessionPage} + pageSize={chat.sessionPageSize} + total={chat.sessionTotal} + onChange={(page) => chat.setSessionPage(page)} /> - )} - {chat.sessionId && ( - - )} + ) : null} + {chat.sessionId ? ( + void chat.closeSession()} + > + + + ) : null} - -
+ +
{title} {sessionLabel} · {mode === 'sync' ? '同步回复' : '流式回复'} · Agent: {agentType}
- {banner &&
{banner}
} + {banner ? ( +
{banner}
+ ) : null} -
- {chat.error && } +
+ {chat.error ? : null} {chat.messages.length === 0 && !chat.sending ? ( ) : ( - chat.messages.map((m) => ( -
- - {m.content || (chat.sending ? '…' : '')} - - {m.meta?.intent && ( -
- {m.meta.intent} - {m.meta.transfer_to_human && 建议转人工} -
- )} -
- )) + chat.messages.map((m) => { + const isUser = m.role === 'user' + return ( +
+ {isUser ? ( +
+ {m.content ? renderChatMarkdown(m.content) : chat.sending ? '…' : ''} +
+ ) : ( + + {m.content ? renderChatMarkdown(m.content) : chat.sending ? '…' : ''} + + )} + {m.meta?.intent ? ( +
+ + {m.meta.intent} + + {m.meta.transfer_to_human ? ( + 建议转人工 + ) : null} +
+ ) : null} +
+ ) + }) )} - {chat.sending && chat.messages.at(-1)?.role === 'user' && ( + {chat.sending && chat.messages.at(-1)?.role === 'user' ? (
正在生成回复…
- )} + ) : null}
- {chat.disclaimer && ( + {chat.disclaimer ? ( - )} + ) : null} -
+
setDraft(e.target.value)} diff --git a/web/src/components/chat/VisitorChatWidget.tsx b/web/src/components/chat/VisitorChatWidget.tsx index c360da6..f8512b5 100644 --- a/web/src/components/chat/VisitorChatWidget.tsx +++ b/web/src/components/chat/VisitorChatWidget.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { ApiErrorResult } from '../ApiErrorResult' import { Button, Surface } from '../ui' import { postVisitorChat } from '../../api/visitor' +import { renderChatMarkdown } from '../../utils/renderChatMarkdown' type VisitorMessage = { id: string @@ -74,7 +75,7 @@ export function VisitorChatWidget() { m.role === 'user' ? 'ml-6 bg-jr-ink/90 text-white' : 'mr-6 bg-white text-jr-text' }`} > -
{m.content}
+
{renderChatMarkdown(m.content)}
{m.intent && (
diff --git a/web/src/context/AuthProvider.tsx b/web/src/context/AuthProvider.tsx new file mode 100644 index 0000000..c9dcecd --- /dev/null +++ b/web/src/context/AuthProvider.tsx @@ -0,0 +1,53 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from 'react' +import { + clearAuth, + loadAuth, + saveAuth, + type AuthState, +} from '../stores/authStore' + +type AuthContextValue = { + auth: AuthState | null + setAuth: (state: AuthState) => void + logout: () => void +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [auth, setAuthState] = useState(() => loadAuth()) + + const setAuth = useCallback((state: AuthState) => { + saveAuth(state) + setAuthState(state) + }, []) + + const logout = useCallback(() => { + clearAuth() + setAuthState(null) + }, []) + + const value = useMemo( + () => ({ + auth, + setAuth, + logout, + }), + [auth, setAuth, logout], + ) + + return {children} +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx +} diff --git a/web/src/hooks/useAdvisorRosterDashboard.ts b/web/src/hooks/useAdvisorRosterDashboard.ts index bb7144a..e191dc3 100644 --- a/web/src/hooks/useAdvisorRosterDashboard.ts +++ b/web/src/hooks/useAdvisorRosterDashboard.ts @@ -2,11 +2,13 @@ import { useCallback, useEffect, useState } from 'react' import { listAdvisorCustomers } from '../api/advisors' import { getCustomer, listHoldings } from '../api/customers' import { ApiError } from '../api/client' +import { useAsyncSequence } from './useAsyncSequence' import { aggregateAdvisorRoster } from '../utils/aggregateAdvisorRoster' import type { AdvisorRosterRow, ChartDatum } from '../utils/dashboardTypes' import { topNChartData } from '../utils/mergeProductNavRows' export function useAdvisorRosterDashboard(token: string, advisorId: string) { + const { start, isCurrent } = useAsyncSequence() const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [rows, setRows] = useState([]) @@ -15,6 +17,7 @@ export function useAdvisorRosterDashboard(token: string, advisorId: string) { const [barData, setBarData] = useState([]) const load = useCallback(async () => { + const seq = start() setLoading(true) setError(null) try { @@ -34,6 +37,7 @@ export function useAdvisorRosterDashboard(token: string, advisorId: string) { } }), ) + if (!isCurrent(seq)) return const agg = aggregateAdvisorRoster(enriched) setRows(agg.rows) setSummary(agg) @@ -52,11 +56,12 @@ export function useAdvisorRosterDashboard(token: string, advisorId: string) { })), ) } catch (e) { + if (!isCurrent(seq)) return setError(e instanceof Error ? e : new Error('load failed')) } finally { - setLoading(false) + if (isCurrent(seq)) setLoading(false) } - }, [token, advisorId]) + }, [token, advisorId, start, isCurrent]) useEffect(() => { void load() diff --git a/web/src/hooks/useAlertsDashboard.ts b/web/src/hooks/useAlertsDashboard.ts index f2ce1a3..5a067ec 100644 --- a/web/src/hooks/useAlertsDashboard.ts +++ b/web/src/hooks/useAlertsDashboard.ts @@ -1,7 +1,9 @@ import { useCallback, useEffect, useState } from 'react' import { listPendingAlerts, type RiskAlertItem } from '../api/risk' import { ApiError } from '../api/client' +import { useAsyncSequence } from './useAsyncSequence' import type { ChartDatum } from '../utils/dashboardTypes' +import { labelAlertType } from '../utils/displayLabels' function countToday(items: RiskAlertItem[]) { const today = new Date().toISOString().slice(0, 10) @@ -9,6 +11,7 @@ function countToday(items: RiskAlertItem[]) { } export function useAlertsDashboard(token: string) { + const { start, isCurrent } = useAsyncSequence() const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [items, setItems] = useState([]) @@ -20,10 +23,12 @@ export function useAlertsDashboard(token: string) { const [customerCount, setCustomerCount] = useState(0) const load = useCallback(async () => { + const seq = start() setLoading(true) setError(null) try { const data = await listPendingAlerts(token) + if (!isCurrent(seq)) return setItems(data.items) setTotal(data.total) setDisclaimer(data.disclaimer) @@ -41,19 +46,25 @@ export function useAlertsDashboard(token: string) { cur.count += 1 typeScores.set(item.alert_type, cur) } - setPieData([...typeCounts.entries()].map(([label, value]) => ({ label, value }))) + setPieData( + [...typeCounts.entries()].map(([type, value]) => ({ + label: labelAlertType(type), + value, + })), + ) setBarData( - [...typeScores.entries()].map(([label, { sum, count }]) => ({ - label, + [...typeScores.entries()].map(([type, { sum, count }]) => ({ + label: labelAlertType(type), value: count ? sum / count : 0, })), ) } catch (e) { + if (!isCurrent(seq)) return setError(e instanceof Error ? e : new Error('load failed')) } finally { - setLoading(false) + if (isCurrent(seq)) setLoading(false) } - }, [token]) + }, [token, start, isCurrent]) useEffect(() => { void load() diff --git a/web/src/hooks/useAsyncSequence.ts b/web/src/hooks/useAsyncSequence.ts new file mode 100644 index 0000000..6392b19 --- /dev/null +++ b/web/src/hooks/useAsyncSequence.ts @@ -0,0 +1,15 @@ +import { useCallback, useRef } from 'react' + +/** + * 忽略过期的 async setState(StrictMode 双 mount、筛选条件连点等)。 + * 每次 load 开头 `const seq = start()`,写状态前 `if (!isCurrent(seq)) return`。 + */ +export function useAsyncSequence() { + const seqRef = useRef(0) + const start = useCallback(() => { + seqRef.current += 1 + return seqRef.current + }, []) + const isCurrent = useCallback((seq: number) => seqRef.current === seq, []) + return { start, isCurrent } +} diff --git a/web/src/hooks/useChatPanel.ts b/web/src/hooks/useChatPanel.ts index f516482..2bd813a 100644 --- a/web/src/hooks/useChatPanel.ts +++ b/web/src/hooks/useChatPanel.ts @@ -1,8 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { + closeAllActiveSessionsFallback, + closeAllChatSessions, closeChatSession, + fetchAllActiveChatSessions, listChatMessages, - listChatSessions, postChatStream, postChatSync, type AgentType, @@ -10,6 +12,14 @@ import { type ChatSessionItem, } from '../api/chat' import { ApiError } from '../api/client' +import { + clearActiveChatSession, + readActiveChatSession, + writeActiveChatSession, +} from '../stores/chatSessionStore' +import { useAsyncSequence } from './useAsyncSequence' + +export const CHAT_SESSION_PAGE_SIZE = 8 export type UiChatMessage = { id: string @@ -24,19 +34,32 @@ export type UiChatMessage = { type UseChatPanelOptions = { token: string agentType: AgentType - /** customer 走专用 LangGraph,后端仅同步接口可用 */ mode: 'sync' | 'stream' } export function useChatPanel({ token, agentType, mode }: UseChatPanelOptions) { + const { start, isCurrent } = useAsyncSequence() + const [allActiveSessions, setAllActiveSessions] = useState([]) const [sessions, setSessions] = useState([]) + const [sessionTotal, setSessionTotal] = useState(0) + const [sessionPage, setSessionPage] = useState(1) const [sessionId, setSessionId] = useState(null) const [messages, setMessages] = useState([]) const [disclaimer, setDisclaimer] = useState(null) const [loadingSessions, setLoadingSessions] = useState(true) const [sending, setSending] = useState(false) + const [deletingId, setDeletingId] = useState(null) + const [clearing, setClearing] = useState(false) const [error, setError] = useState(null) const streamBuffer = useRef('') + const restoreAttempted = useRef(false) + + const applySessionPage = useCallback((all: ChatSessionItem[], page: number) => { + const start = (page - 1) * CHAT_SESSION_PAGE_SIZE + setAllActiveSessions(all) + setSessionTotal(all.length) + setSessions(all.slice(start, start + CHAT_SESSION_PAGE_SIZE)) + }, []) const mapHistory = useCallback((items: ChatMessageItem[]): UiChatMessage[] => { return items @@ -49,26 +72,34 @@ export function useChatPanel({ token, agentType, mode }: UseChatPanelOptions) { }, []) const refreshSessions = useCallback(async () => { + const seq = start() setLoadingSessions(true) setError(null) try { - const data = await listChatSessions(token, agentType) - setSessions(data.items) + const all = await fetchAllActiveChatSessions(token, agentType) + if (!isCurrent(seq)) return + applySessionPage(all, sessionPage) } catch (e) { + if (!isCurrent(seq)) return setError(e instanceof Error ? e : new Error('load sessions failed')) } finally { - setLoadingSessions(false) + if (isCurrent(seq)) setLoadingSessions(false) } - }, [token, agentType]) + }, [token, agentType, sessionPage, start, isCurrent, applySessionPage]) useEffect(() => { void refreshSessions() }, [refreshSessions]) + useEffect(() => { + applySessionPage(allActiveSessions, sessionPage) + }, [sessionPage, allActiveSessions, applySessionPage]) + const loadSession = useCallback( async (sid: string) => { setError(null) setSessionId(sid) + writeActiveChatSession(agentType, sid) try { const data = await listChatMessages(token, agentType, sid) setMessages(mapHistory(data.items)) @@ -79,12 +110,25 @@ export function useChatPanel({ token, agentType, mode }: UseChatPanelOptions) { [token, agentType, mapHistory], ) + useEffect(() => { + if (loadingSessions || restoreAttempted.current) return + restoreAttempted.current = true + const saved = readActiveChatSession(agentType) + if (!saved) return + if (allActiveSessions.some((s) => s.session_id === saved)) { + void loadSession(saved) + } else { + clearActiveChatSession(agentType) + } + }, [loadingSessions, allActiveSessions, agentType, loadSession]) + const startNewSession = useCallback(() => { setSessionId(null) setMessages([]) setDisclaimer(null) setError(null) - }, []) + clearActiveChatSession(agentType) + }, [agentType]) const sendMessage = useCallback( async (text: string) => { @@ -105,6 +149,7 @@ export function useChatPanel({ token, agentType, mode }: UseChatPanelOptions) { if (mode === 'sync') { const resp = await postChatSync(token, agentType, trimmed, sessionId) setSessionId(resp.session_id) + writeActiveChatSession(agentType, resp.session_id) setMessages((prev) => [ ...prev, { @@ -118,9 +163,7 @@ export function useChatPanel({ token, agentType, mode }: UseChatPanelOptions) { }, ]) if (resp.has_disclaimer) { - setDisclaimer( - '本回复仅供参考,不构成投资建议。市场有风险,投资需谨慎。', - ) + setDisclaimer('本回复仅供参考,不构成投资建议。市场有风险,投资需谨慎。') } void refreshSessions() return @@ -129,25 +172,34 @@ export function useChatPanel({ token, agentType, mode }: UseChatPanelOptions) { const assistantId = `a-${Date.now()}` setMessages((prev) => [...prev, { id: assistantId, role: 'assistant', content: '' }]) - await postChatStream(token, agentType, trimmed, { - onMeta: (meta) => { - if (meta.session_id) setSessionId(meta.session_id) - if (meta.disclaimer) setDisclaimer(meta.disclaimer) - else if (meta.has_disclaimer) { - setDisclaimer('本回复仅供参考,不构成投资建议。市场有风险,投资需谨慎。') - } + await postChatStream( + token, + agentType, + trimmed, + { + onMeta: (meta) => { + if (meta.session_id) { + setSessionId(meta.session_id) + writeActiveChatSession(agentType, meta.session_id) + } + if (meta.disclaimer) setDisclaimer(meta.disclaimer) + else if (meta.has_disclaimer) { + setDisclaimer('本回复仅供参考,不构成投资建议。市场有风险,投资需谨慎。') + } + }, + onDelta: (chunk) => { + streamBuffer.current += chunk + const content = streamBuffer.current + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content } : m)), + ) + }, + onError: (code, message) => { + throw new ApiError(message, code) + }, }, - onDelta: (chunk) => { - streamBuffer.current += chunk - const content = streamBuffer.current - setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content } : m)), - ) - }, - onError: (code, message) => { - throw new ApiError(message, code) - }, - }) + sessionId, + ) void refreshSessions() } catch (e) { setError(e instanceof Error ? e : new Error('send failed')) @@ -164,30 +216,94 @@ export function useChatPanel({ token, agentType, mode }: UseChatPanelOptions) { [agentType, mode, refreshSessions, sending, sessionId, token], ) + const deleteSession = useCallback( + async (sid: string) => { + setError(null) + setDeletingId(sid) + try { + const row = allActiveSessions.find((s) => s.session_id === sid) + if (row?.status !== 'active') { + applySessionPage( + allActiveSessions.filter((s) => s.session_id !== sid), + sessionPage, + ) + return + } + try { + await closeChatSession(token, agentType, sid) + } catch (e) { + if (!(e instanceof ApiError && e.errorCode === 'STATE_CONFLICT')) { + throw e + } + } + if (sessionId === sid) { + clearActiveChatSession(agentType) + startNewSession() + } + const all = await fetchAllActiveChatSessions(token, agentType) + applySessionPage(all, sessionPage) + } catch (e) { + setError(e instanceof Error ? e : new Error('delete failed')) + } finally { + setDeletingId(null) + } + }, + [ + agentType, + allActiveSessions, + applySessionPage, + sessionId, + sessionPage, + startNewSession, + token, + ], + ) + + const clearAllSessions = useCallback(async () => { + setError(null) + setClearing(true) + try { + try { + await closeAllChatSessions(token, agentType) + } catch (e) { + await closeAllActiveSessionsFallback(token, agentType) + } + clearActiveChatSession(agentType) + startNewSession() + setSessionPage(1) + applySessionPage([], 1) + } catch (e) { + setError(e instanceof Error ? e : new Error('clear failed')) + } finally { + setClearing(false) + } + }, [agentType, applySessionPage, startNewSession, token]) + const closeSession = useCallback(async () => { if (!sessionId) return - setError(null) - try { - await closeChatSession(token, agentType, sessionId) - await refreshSessions() - startNewSession() - } catch (e) { - setError(e instanceof Error ? e : new Error('close failed')) - } - }, [agentType, refreshSessions, sessionId, startNewSession, token]) + await deleteSession(sessionId) + }, [deleteSession, sessionId]) return { sessions, + sessionTotal, + sessionPage, + setSessionPage, + sessionPageSize: CHAT_SESSION_PAGE_SIZE, sessionId, messages, disclaimer, loadingSessions, sending, + deletingId, + clearing, error, refreshSessions, loadSession, startNewSession, sendMessage, closeSession, + deleteSession, + clearAllSessions, } } diff --git a/web/src/hooks/useHoldingsDashboard.ts b/web/src/hooks/useHoldingsDashboard.ts index 60dbc98..cce7320 100644 --- a/web/src/hooks/useHoldingsDashboard.ts +++ b/web/src/hooks/useHoldingsDashboard.ts @@ -2,12 +2,14 @@ import { useCallback, useEffect, useState } from 'react' import { listHoldings } from '../api/customers' import { fetchNavMap } from '../api/products' import { ApiError } from '../api/client' +import { useAsyncSequence } from './useAsyncSequence' import { aggregateHoldingsHero } from '../utils/aggregateHoldingsHero' import type { ChartDatum, HoldingRow } from '../utils/dashboardTypes' import { mergeHoldingsWithNav } from '../utils/mergeHoldingsWithNav' import { topNChartData } from '../utils/mergeProductNavRows' export function useHoldingsDashboard(token: string, customerId: string) { + const { start, isCurrent } = useAsyncSequence() const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [rows, setRows] = useState([]) @@ -17,6 +19,7 @@ export function useHoldingsDashboard(token: string, customerId: string) { const [asOf, setAsOf] = useState('') const load = useCallback(async () => { + const seq = start() setLoading(true) setError(null) try { @@ -25,6 +28,7 @@ export function useHoldingsDashboard(token: string, customerId: string) { token, holdings.items.map((h) => h.product_id), ) + if (!isCurrent(seq)) return const merged = mergeHoldingsWithNav(holdings.items, navMap) const metrics = aggregateHoldingsHero(merged) setRows(merged) @@ -37,11 +41,12 @@ export function useHoldingsDashboard(token: string, customerId: string) { ) setBarData(merged.map((h) => ({ label: h.product_name, value: h.pnl_pct }))) } catch (e) { + if (!isCurrent(seq)) return setError(e instanceof Error ? e : new Error('load failed')) } finally { - setLoading(false) + if (isCurrent(seq)) setLoading(false) } - }, [token, customerId]) + }, [token, customerId, start, isCurrent]) useEffect(() => { void load() diff --git a/web/src/hooks/useMarketSnapshot.ts b/web/src/hooks/useMarketSnapshot.ts index 536e8c1..35d2716 100644 --- a/web/src/hooks/useMarketSnapshot.ts +++ b/web/src/hooks/useMarketSnapshot.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { listProducts, fetchNavMap } from '../api/products' import { ApiError } from '../api/client' +import { useAsyncSequence } from './useAsyncSequence' import { avgChgByType, countMarketMoves, @@ -10,6 +11,7 @@ import { import type { ChartDatum, ProductRow } from '../utils/dashboardTypes' export function useMarketSnapshot(token: string) { + const { start, isCurrent } = useAsyncSequence() const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [rows, setRows] = useState([]) @@ -19,6 +21,7 @@ export function useMarketSnapshot(token: string) { const [asOf, setAsOf] = useState('') const load = useCallback(async () => { + const seq = start() setLoading(true) setError(null) try { @@ -27,6 +30,7 @@ export function useMarketSnapshot(token: string) { token, products.items.map((p) => p.product_id), ) + if (!isCurrent(seq)) return const merged = mergeProductNavRows(products.items, navMap) setRows(merged) setMoves(countMarketMoves(merged)) @@ -35,11 +39,12 @@ export function useMarketSnapshot(token: string) { const dates = merged.map((r) => r.nav_date).filter(Boolean) as string[] setAsOf(dates.length ? dates.sort().at(-1)! : '') } catch (e) { + if (!isCurrent(seq)) return setError(e instanceof Error ? e : new Error('load failed')) } finally { - setLoading(false) + if (isCurrent(seq)) setLoading(false) } - }, [token]) + }, [token, start, isCurrent]) useEffect(() => { void load() diff --git a/web/src/layouts/AppLayout.tsx b/web/src/layouts/AppLayout.tsx index fdc88c4..4a2ccbe 100644 --- a/web/src/layouts/AppLayout.tsx +++ b/web/src/layouts/AppLayout.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react' import { Outlet, useLocation, useNavigate } from 'react-router-dom' -import { clearAuth, loadAuth, type AuthState } from '../stores/authStore' +import { useAuth } from '../context/AuthProvider' +import type { AuthState } from '../stores/authStore' import { buildMenuGroups, flattenMenuPaths, toAntdMenuItems } from '../routes/menus' import { AppShell, BrandSidebar, Topbar } from '../components/layout' @@ -24,9 +25,10 @@ export function AppLayout({ auth }: AppLayoutProps) { navigate(key) } - const logout = () => { - clearAuth() - navigate('/login') + const { logout } = useAuth() + const onLogout = () => { + logout() + navigate('/login', { replace: true }) } return ( @@ -46,7 +48,7 @@ export function AppLayout({ auth }: AppLayoutProps) { actorId={auth.actorId} collapsed={collapsed} onToggle={() => setCollapsed((value) => !value)} - onLogout={logout} + onLogout={onLogout} /> } > @@ -56,7 +58,7 @@ export function AppLayout({ auth }: AppLayoutProps) { } export function useAppAuth(): AuthState { - const auth = loadAuth() + const { auth } = useAuth() if (!auth) throw new Error('not authenticated') return auth } diff --git a/web/src/main.tsx b/web/src/main.tsx index 7c5442a..d80b69c 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { ConfigProvider } from 'antd' +import { App as AntApp, ConfigProvider } from 'antd' +import { AuthProvider } from './context/AuthProvider' import zhCN from 'antd/locale/zh_CN' import { HashRouter } from 'react-router-dom' import App from './App' @@ -12,9 +13,13 @@ createRoot(document.getElementById('root')!).render( - - - + + + + + + + , diff --git a/web/src/pages/advisor/AdvisorCustomersPage.tsx b/web/src/pages/advisor/AdvisorCustomersPage.tsx index 40c4d19..ced357a 100644 --- a/web/src/pages/advisor/AdvisorCustomersPage.tsx +++ b/web/src/pages/advisor/AdvisorCustomersPage.tsx @@ -9,27 +9,32 @@ import { Button } from '../../components/ui' import { listAdvisorCustomers } from '../../api/advisors' import { ApiError } from '../../api/client' import { useAppAuth } from '../../layouts/AppLayout' +import { useAsyncSequence } from '../../hooks/useAsyncSequence' type Row = { customer_id: string; display_name: string | null } export function AdvisorCustomersPage() { const auth = useAppAuth() + const { start, isCurrent } = useAsyncSequence() const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [rows, setRows] = useState([]) const load = useCallback(async () => { + const seq = start() setLoading(true) setError(null) try { const data = await listAdvisorCustomers(auth.accessToken, auth.actorId) + if (!isCurrent(seq)) return setRows(data.items) } catch (e) { + if (!isCurrent(seq)) return setError(e instanceof Error ? e : new Error('load failed')) } finally { - setLoading(false) + if (isCurrent(seq)) setLoading(false) } - }, [auth.accessToken, auth.actorId]) + }, [auth.accessToken, auth.actorId, start, isCurrent]) useEffect(() => { void load() diff --git a/web/src/pages/analytics/AnalystQueryPage.tsx b/web/src/pages/analytics/AnalystQueryPage.tsx index 2ac1091..f1aeacd 100644 --- a/web/src/pages/analytics/AnalystQueryPage.tsx +++ b/web/src/pages/analytics/AnalystQueryPage.tsx @@ -7,6 +7,7 @@ import { ApiErrorResult } from '../../components/ApiErrorResult' import { MetricCard } from '../../components/ui' import { PageShell } from '../../components/PageShell' import { useAppAuth } from '../../layouts/AppLayout' +import { labelAnalystMetric } from '../../utils/displayLabels' const { Text, Paragraph } = Typography @@ -97,7 +98,7 @@ export function AnalystQueryPage() { {Object.entries(dashMetrics).map(([key, val]) => ( - + ))} diff --git a/web/src/pages/customer/CustomerTradesPage.tsx b/web/src/pages/customer/CustomerTradesPage.tsx index c4e6419..f1d9441 100644 --- a/web/src/pages/customer/CustomerTradesPage.tsx +++ b/web/src/pages/customer/CustomerTradesPage.tsx @@ -8,34 +8,44 @@ import { Button } from '../../components/ui' import { listTrades, type TradeRow } from '../../api/customers' import { ApiError } from '../../api/client' import { useAppAuth } from '../../layouts/AppLayout' +import { useAsyncSequence } from '../../hooks/useAsyncSequence' import { formatMoney } from '../../utils/formatMoney' +import { + formatDateTime, + labelTradeStatus, + labelTradeType, +} from '../../utils/displayLabels' export function CustomerTradesPage() { const auth = useAppAuth() + const { start, isCurrent } = useAsyncSequence() const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [rows, setRows] = useState([]) const load = useCallback(async () => { + const seq = start() setLoading(true) setError(null) try { const data = await listTrades(auth.accessToken, auth.actorId) + if (!isCurrent(seq)) return setRows(data.items) } catch (e) { + if (!isCurrent(seq)) return setError(e instanceof Error ? e : new Error('load failed')) } finally { - setLoading(false) + if (isCurrent(seq)) setLoading(false) } - }, [auth.accessToken, auth.actorId]) + }, [auth.accessToken, auth.actorId, start, isCurrent]) useEffect(() => { void load() }, [load]) const columns: ColumnsType = [ - { title: '日期', dataIndex: 'traded_at', width: 120 }, - { title: '类型', dataIndex: 'trade_type', width: 80 }, + { title: '日期', dataIndex: 'traded_at', width: 120, render: (v: string) => formatDateTime(v) }, + { title: '类型', dataIndex: 'trade_type', width: 80, render: (v: string) => labelTradeType(v) }, { title: '产品', dataIndex: 'product_name' }, { title: '风险', dataIndex: 'min_risk_code', width: 72 }, { @@ -43,7 +53,7 @@ export function CustomerTradesPage() { dataIndex: 'amount', render: (v: number) => formatMoney(v), }, - { title: '状态', dataIndex: 'trade_status', width: 96 }, + { title: '状态', dataIndex: 'trade_status', width: 96, render: (v: string) => labelTradeStatus(v) }, ] return ( diff --git a/web/src/pages/dashboard/RiskAlertsDashboard.tsx b/web/src/pages/dashboard/RiskAlertsDashboard.tsx index f33d930..0a999fb 100644 --- a/web/src/pages/dashboard/RiskAlertsDashboard.tsx +++ b/web/src/pages/dashboard/RiskAlertsDashboard.tsx @@ -5,6 +5,11 @@ import { DashboardHero, DashboardLayout } from '../../components/dashboard' import { useAlertsDashboard } from '../../hooks/useAlertsDashboard' import { useAppAuth } from '../../layouts/AppLayout' import type { RiskAlertItem } from '../../api/risk' +import { + formatDateTime, + labelAlertStatus, + labelAlertType, +} from '../../utils/displayLabels' export function RiskAlertsDashboard() { const auth = useAppAuth() @@ -23,17 +28,18 @@ export function RiskAlertsDashboard() { const columns: ColumnsType = [ { title: '预警 ID', dataIndex: 'alert_id', width: 120 }, - { title: '类型', dataIndex: 'alert_type' }, + { title: '类型', dataIndex: 'alert_type', render: (v: string) => labelAlertType(v) }, { title: '客户', dataIndex: 'customer_id' }, { title: '分数', dataIndex: 'risk_score', sorter: (a, b) => a.risk_score - b.risk_score, }, - { title: '状态', dataIndex: 'status' }, + { title: '状态', dataIndex: 'status', render: (v: string) => labelAlertStatus(v) }, { title: '创建时间', dataIndex: 'created_at', + render: (v: string) => formatDateTime(v), defaultSortOrder: 'descend', sorter: (a, b) => a.created_at.localeCompare(b.created_at), }, @@ -67,7 +73,7 @@ export function RiskAlertsDashboard() { title="待审预警" mainValue={total} mainDisplay={{total}} - mainLabel="pending_review 总数" + mainLabel="待审核预警总数" metrics={[ { label: '今日新增', value: {todayCount} }, { label: '涉及客户', value: {customerCount} }, diff --git a/web/src/pages/login/LoginPage.tsx b/web/src/pages/login/LoginPage.tsx index 9cba967..72a3efb 100644 --- a/web/src/pages/login/LoginPage.tsx +++ b/web/src/pages/login/LoginPage.tsx @@ -1,4 +1,4 @@ -import { Typography, message } from 'antd' +import { App, Typography } from 'antd' import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { Badge, Button, Surface } from '../../components/ui' @@ -6,20 +6,24 @@ import { VisitorChatWidget } from '../../components/chat/VisitorChatWidget' import { login } from '../../api/auth' import { ApiError } from '../../api/client' import { DEMO_ACCOUNTS } from '../../config/demoAccounts' -import { matchDemoAccount, resolveRoleLabel, saveAuth } from '../../stores/authStore' +import { useAuth } from '../../context/AuthProvider' +import { matchDemoAccount, resolveRoleLabel } from '../../stores/authStore' export function LoginPage() { const navigate = useNavigate() + const { setAuth } = useAuth() + const { message } = App.useApp() const [loadingKey, setLoadingKey] = useState(null) const handleLogin = async (accountKey: string) => { + if (loadingKey) return const account = DEMO_ACCOUNTS.find((a) => a.key === accountKey) if (!account) return setLoadingKey(accountKey) try { const { data } = await login(account.actorId, account.tokenType) const demo = matchDemoAccount(data.sub) - saveAuth({ + setAuth({ accessToken: data.access_token, actorId: data.sub, roles: data.roles, @@ -59,7 +63,7 @@ export function LoginPage() { alt="金融数据与资产守护示意" className="mb-10 w-full max-w-[460px] opacity-95" /> - + 让每一项决策,
都有数据作答。 @@ -102,7 +106,8 @@ export function LoginPage() { size="lg" className="!min-h-[84px] !w-full !justify-between !rounded-jr-md !px-5 !py-4 !text-left hover:!border-jr-gold hover:!shadow-jr-surface" loading={loadingKey === acc.key} - onClick={() => handleLogin(acc.key)} + disabled={loadingKey !== null} + onClick={() => void handleLogin(acc.key)} > diff --git a/web/src/pages/risk/RiskAlertsPage.tsx b/web/src/pages/risk/RiskAlertsPage.tsx index 457ea4e..325ebca 100644 --- a/web/src/pages/risk/RiskAlertsPage.tsx +++ b/web/src/pages/risk/RiskAlertsPage.tsx @@ -11,16 +11,24 @@ import { type RiskAlertItem, } from '../../api/risk' import { ApiError } from '../../api/client' +import { useAsyncSequence } from '../../hooks/useAsyncSequence' import { useAppAuth } from '../../layouts/AppLayout' +import { + formatDateTime, + labelAlertStatus, + labelAlertType, +} from '../../utils/displayLabels' export function RiskAlertsPage() { const auth = useAppAuth() + const { start, isCurrent } = useAsyncSequence() const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [rows, setRows] = useState([]) const [total, setTotal] = useState(0) const [status, setStatus] = useState('pending_review') const [alertType, setAlertType] = useState() + const [customerIdInput, setCustomerIdInput] = useState('') const [customerId, setCustomerId] = useState('') const [startDate, setStartDate] = useState('') const [endDate, setEndDate] = useState('') @@ -34,6 +42,7 @@ export function RiskAlertsPage() { const [submitting, setSubmitting] = useState(false) const load = useCallback(async () => { + const seq = start() setLoading(true) setError(null) try { @@ -46,15 +55,22 @@ export function RiskAlertsPage() { page, page_size: 20, }) + if (!isCurrent(seq)) return setRows(data.items) setTotal(data.total) setDisclaimer(data.disclaimer) } catch (e) { + if (!isCurrent(seq)) return setError(e instanceof Error ? e : new Error('load failed')) } finally { - setLoading(false) + if (isCurrent(seq)) setLoading(false) } - }, [auth.accessToken, alertType, customerId, endDate, page, startDate, status]) + }, [auth.accessToken, alertType, customerId, endDate, page, startDate, status, start, isCurrent]) + + useEffect(() => { + const t = window.setTimeout(() => setCustomerId(customerIdInput), 300) + return () => window.clearTimeout(t) + }, [customerIdInput]) useEffect(() => { void load() @@ -80,17 +96,17 @@ export function RiskAlertsPage() { const columns: ColumnsType = [ { title: '预警编号', dataIndex: 'alert_id', width: 140 }, - { title: '类型', dataIndex: 'alert_type', width: 120 }, + { title: '类型', dataIndex: 'alert_type', width: 120, render: (v: string) => labelAlertType(v) }, { title: '客户', dataIndex: 'customer_id', width: 120 }, { title: '风险分', dataIndex: 'risk_score', width: 80 }, { title: '状态', dataIndex: 'status', render: (v: string) => ( - {v} + {labelAlertStatus(v)} ), }, - { title: '创建时间', dataIndex: 'created_at', width: 180 }, + { title: '创建时间', dataIndex: 'created_at', width: 180, render: (v: string) => formatDateTime(v) }, { title: '操作', width: 100, @@ -167,11 +183,11 @@ export function RiskAlertsPage() { { setPage(1) - setCustomerId(e.target.value) + setCustomerIdInput(e.target.value) }} />
@@ -205,6 +221,7 @@ export function RiskAlertsPage() { setPage(1) setStatus(undefined) setAlertType(undefined) + setCustomerIdInput('') setCustomerId('') setStartDate('') setEndDate('') diff --git a/web/src/stores/__tests__/chatSessionStore.test.ts b/web/src/stores/__tests__/chatSessionStore.test.ts new file mode 100644 index 0000000..d0481c8 --- /dev/null +++ b/web/src/stores/__tests__/chatSessionStore.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { + clearActiveChatSession, + readActiveChatSession, + writeActiveChatSession, +} from '../../stores/chatSessionStore' + +describe('chatSessionStore', () => { + it('persists active session per agent line', () => { + clearActiveChatSession('customer') + expect(readActiveChatSession('customer')).toBeNull() + writeActiveChatSession('customer', 'sess-abc') + expect(readActiveChatSession('customer')).toBe('sess-abc') + clearActiveChatSession('customer') + expect(readActiveChatSession('customer')).toBeNull() + }) +}) diff --git a/web/src/stores/chatSessionStore.ts b/web/src/stores/chatSessionStore.ts new file mode 100644 index 0000000..fe61651 --- /dev/null +++ b/web/src/stores/chatSessionStore.ts @@ -0,0 +1,25 @@ +const KEY_PREFIX = 'jinrong.chat.activeSession.' + +export function readActiveChatSession(agentType: string): string | null { + try { + return sessionStorage.getItem(`${KEY_PREFIX}${agentType}`) + } catch { + return null + } +} + +export function writeActiveChatSession(agentType: string, sessionId: string) { + try { + sessionStorage.setItem(`${KEY_PREFIX}${agentType}`, sessionId) + } catch { + /* ignore quota / private mode */ + } +} + +export function clearActiveChatSession(agentType: string) { + try { + sessionStorage.removeItem(`${KEY_PREFIX}${agentType}`) + } catch { + /* ignore */ + } +} diff --git a/web/src/utils/__tests__/displayLabels.test.ts b/web/src/utils/__tests__/displayLabels.test.ts new file mode 100644 index 0000000..070116d --- /dev/null +++ b/web/src/utils/__tests__/displayLabels.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { + formatDateTime, + labelAlertStatus, + labelAlertType, + labelAnalystMetric, + labelTradeType, +} from '../displayLabels' + +describe('displayLabels', () => { + it('formatDateTime strips ISO to locale minute', () => { + const out = formatDateTime('2026-08-15T14:20:00') + expect(out).toMatch(/2026/) + expect(out).not.toContain('T') + }) + + it('maps enums to Chinese', () => { + expect(labelAlertType('aml')).toBe('AML') + expect(labelAlertStatus('pending_review')).toBe('待审核') + expect(labelTradeType('subscribe')).toBe('申购') + expect(labelAnalystMetric('dict_count')).toBe('口径字典资产数') + }) +}) diff --git a/web/src/utils/displayLabels.ts b/web/src/utils/displayLabels.ts new file mode 100644 index 0000000..c84abfe --- /dev/null +++ b/web/src/utils/displayLabels.ts @@ -0,0 +1,95 @@ +/** 表格/看板:ISO 时间 → 本地可读(不含秒以下)。 */ +export function formatDateTime(iso: string | undefined | null): string { + if (!iso) return '—' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + return d.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) +} + +const ALERT_TYPE_LABELS: Record = { + large_amount: '大额', + freq_trade: '频繁交易', + suitability: '适当性', + aml: 'AML', + pattern: '行为/模式', +} + +const ALERT_STATUS_LABELS: Record = { + pending_review: '待审核', + handled: '已处置', +} + +const HANDLER_RESULT_LABELS: Record = { + confirmed_normal: '确认正常', + confirmed_risk: '确认风险', + false_positive: '误报', +} + +const TRADE_TYPE_LABELS: Record = { + subscribe: '申购', + redeem: '赎回', +} + +const TRADE_STATUS_LABELS: Record = { + confirmed: '已确认', + pending: '待确认', + cancelled: '已取消', +} + +const PRODUCT_TYPE_LABELS: Record = { + money: '货币', + bond: '债券', + stock: '权益', + mixed: '混合', +} + +const ANALYST_METRIC_LABELS: Record = { + customers: '客户总数', + holdings: '总持仓规模', + pending: '待处理预警数', + dict_count: '口径字典资产数', + clients: '名下客户数', + aum: '名下资产规模', + trades_30d: '近30日交易笔数', +} + +export function labelAlertType(value: string | undefined | null): string { + if (!value) return '—' + return ALERT_TYPE_LABELS[value] ?? value +} + +export function labelAlertStatus(value: string | undefined | null): string { + if (!value) return '—' + return ALERT_STATUS_LABELS[value] ?? value +} + +export function labelHandlerResult(value: string | undefined | null): string { + if (!value) return '—' + return HANDLER_RESULT_LABELS[value] ?? value +} + +export function labelTradeType(value: string | undefined | null): string { + if (!value) return '—' + return TRADE_TYPE_LABELS[value] ?? value +} + +export function labelTradeStatus(value: string | undefined | null): string { + if (!value) return '—' + return TRADE_STATUS_LABELS[value] ?? value +} + +export function labelProductType(value: string | undefined | null): string { + if (!value) return '—' + return PRODUCT_TYPE_LABELS[value] ?? value +} + +export function labelAnalystMetric(key: string): string { + return ANALYST_METRIC_LABELS[key] ?? key +} diff --git a/web/src/utils/renderChatMarkdown.tsx b/web/src/utils/renderChatMarkdown.tsx new file mode 100644 index 0000000..6dca4a2 --- /dev/null +++ b/web/src/utils/renderChatMarkdown.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react' + +/** 对话气泡:粗体 **text** 与换行,不引入 markdown 依赖。 */ +export function renderChatMarkdown(text: string): ReactNode[] { + const lines = text.split('\n') + return lines.map((line, lineIdx) => { + const parts: ReactNode[] = [] + const re = /\*\*(.+?)\*\*/g + let last = 0 + let match: RegExpExecArray | null + let key = 0 + while ((match = re.exec(line)) !== null) { + if (match.index > last) { + parts.push(line.slice(last, match.index)) + } + parts.push( + {match[1]}, + ) + last = match.index + match[0].length + } + if (last < line.length) parts.push(line.slice(last)) + if (lineIdx < lines.length - 1) { + parts.push(
) + } + return {parts.length ? parts : line || '\u00a0'} + }) +}