diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0a7faf0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.py text eol=lf diff --git a/agent/advisor_agent/data_query.py b/agent/advisor_agent/data_query.py index d5d389c..f359831 100644 --- a/agent/advisor_agent/data_query.py +++ b/agent/advisor_agent/data_query.py @@ -24,12 +24,43 @@ from repositories.fin_holdings import FinHoldingsRepo from repositories.fin_product import FinProductRepo +def enrich_customer_rows( + rows: list[dict[str, Any]], + columns: list[str], + name_by_id: dict[int, str], +) -> dict[str, Any]: + """为查询结果补充客户姓名,并生成可直接展示的姓名摘要。""" + id_keys = ("customer_id", "客户ID", "客户编号", "客户_id") + enriched = [dict(row) for row in rows] + names: list[str] = [] + for row in enriched: + customer_id = next((row.get(key) for key in id_keys if row.get(key) is not None), None) + try: + customer_id = int(customer_id) + except (TypeError, ValueError): + continue + name = name_by_id.get(customer_id) + if name: + row["客户姓名"] = name + names.append(f"客户{customer_id}({name})") + output_columns = list(columns) + if any("客户姓名" in row for row in enriched) and "客户姓名" not in output_columns: + insert_at = next( + (index + 1 for index, column in enumerate(output_columns) if column in id_keys), + len(output_columns), + ) + output_columns.insert(insert_at, "客户姓名") + return {"rows": enriched, "columns": output_columns, "name_summary": "、".join(dict.fromkeys(names))} + + def _is_current_holdings_query(question: str) -> bool: text = "".join((question or "").split()) return any(term in text for term in ("当前持仓", "目前持仓", "现有持仓", "持仓明细", "持仓情况")) -async def _query_current_holdings(db, *, customer_id: int, trace_id: str) -> dict[str, Any]: +async def _query_current_holdings( + db, *, customer_id: int, trace_id: str, customer_name: str | None = None +) -> dict[str, Any]: holdings = await FinHoldingsRepo(db).list_by_customer(customer_id, status="持有中") product_repo = FinProductRepo(db) rows: list[dict[str, Any]] = [] @@ -60,7 +91,8 @@ async def _query_current_holdings(db, *, customer_id: int, trace_id: str) -> dic "truncated": False, "summary": f"当前持仓共 {len(rows)} 条记录。", "answer": ( - f"当前持有 {len(rows)} 只基金,总市值约 {total_value:.2f} 元。" + (f"客户{customer_id}({customer_name})" if customer_name else f"客户{customer_id}") + + f"当前持有 {len(rows)} 只基金,总市值约 {total_value:.2f} 元。" + (f"包括:{'、'.join(names[:6])}。" if names else "") ), "sql": None, @@ -76,6 +108,7 @@ async def execute_advisor_data_query( question: str, trace_id: str, session_id: str | None = None, + conversation_context: str = "", data_scope: dict[str, Any] | None = None, max_rows: int | None = None, page: int = 1, @@ -107,11 +140,25 @@ async def execute_advisor_data_query( await ensure_customer_access(db, advisor_id=advisor_id, customer_id=customer_id) customer_ids = [customer_id] + name_by_id: dict[int, str] = {} + try: + relation_rows = await CustomerRelationRepo(db).list_customer_rows( + advisor_id=advisor_id, limit=max(len(customer_ids), 100) + ) + name_by_id = { + int(account.id): account.real_name + for _relation, account, _profile in relation_rows + if account.real_name + } + except Exception: # noqa: BLE001 姓名增强失败不阻断数据查询 + pass + if scope == "customer" and _is_current_holdings_query(question): return await _query_current_holdings( db, customer_id=customer_id, trace_id=trace_id, + customer_name=name_by_id.get(customer_id), ) permission = await load_query_permission(db, advisor_id) if not permission.get("can_query", False): @@ -162,10 +209,17 @@ async def execute_advisor_data_query( llm_client=llm_client, summary_llm=llm_client, masks=permission.get("masks"), + conversation_context=conversation_context, ) except (EmbeddingError, LLMFailError) as exc: raise QueryServiceError("投顾 Agent 依赖服务不可用,请检查 LLM/Embedding 服务连接") from exc payload = asdict(result) + enriched = enrich_customer_rows(payload.get("rows", []), payload.get("columns", []), name_by_id) + payload["rows"] = enriched["rows"] + payload["columns"] = enriched["columns"] + if enriched["name_summary"]: + existing_answer = payload.get("answer") or payload.get("summary") or "" + payload["answer"] = f"{existing_answer.rstrip('。')}。客户姓名:{enriched['name_summary']}。" payload["sql"] = None payload["customer_id"] = customer_id return payload diff --git a/agent/advisor_agent/intent/classifier.py b/agent/advisor_agent/intent/classifier.py index c8b0993..b4a6057 100644 --- a/agent/advisor_agent/intent/classifier.py +++ b/agent/advisor_agent/intent/classifier.py @@ -49,6 +49,7 @@ _CLASSIFIER_PROMPT = """你是基金投顾工作台的意图分类器,只负 """ _RECOMMENDATION_TERMS = ("推荐", "组合建议", "配置建议", "买什么", "适合配置", "筛选基金", "投资方案") +_SCOPE_ERROR_MESSAGE = "投顾范围查询仅支持客户数据查询" def _rule_fallback(query: str | None) -> IntentClassification: @@ -94,6 +95,13 @@ async def classify_advisor_intent( return IntentClassification(explicit_intent, 1.0, "explicit", "客户端显式指定") if not query or not query.strip(): return IntentClassification("", 0.0, "fallback", "空输入") + if re.sub(r"\s+", "", query) == _SCOPE_ERROR_MESSAGE: + return IntentClassification( + AGENT_INTENT_CASUAL_CHAT, + 1.0, + "rule", + "识别为系统提示文本而非业务查询", + ) if llm_client is not None: try: raw = await asyncio.wait_for( @@ -114,7 +122,7 @@ async def classify_advisor_intent( rule_intent = recognize_advisor_intent(query) if ( rule_intent == AGENT_INTENT_DATA_QUERY - and parsed.intent == AGENT_INTENT_RECOMMEND + and parsed.intent != AGENT_INTENT_DATA_QUERY and not any(term in (query or "") for term in _RECOMMENDATION_TERMS) ): return IntentClassification( diff --git a/agent/advisor_agent/intent/generation_flow.py b/agent/advisor_agent/intent/generation_flow.py index d8e82d6..7d2eb74 100644 --- a/agent/advisor_agent/intent/generation_flow.py +++ b/agent/advisor_agent/intent/generation_flow.py @@ -71,6 +71,7 @@ async def generate_recommendation_draft( memories: list[dict] | None = None, llm_client=None, llm_timeout: float = 5.0, + customer_context: dict | None = None, ): draft_data = build_recommendation_draft( customer_id=customer_id, @@ -87,7 +88,10 @@ async def generate_recommendation_draft( user_prompt=( "请根据以下候选基金和客户记忆生成简洁推荐说明:" + json.dumps( - {"candidates": candidates, "memories": memories or []}, + { + "candidates": candidates, + "customer_context": customer_context or {}, + }, ensure_ascii=False, ) ), diff --git a/agent/advisor_agent/intent/recognizer.py b/agent/advisor_agent/intent/recognizer.py index 9813958..99e0317 100644 --- a/agent/advisor_agent/intent/recognizer.py +++ b/agent/advisor_agent/intent/recognizer.py @@ -40,6 +40,8 @@ _REBALANCE_TERMS = ("调仓", "再平衡", "组合调整", "配置偏离", "偏 _FUND_ANALYSIS_TERMS = ("基金分析", "分析基金", "基金表现", "净值走势", "最大回撤", "夏普比率", "年化波动") _DIALOGUE_TERMS = ("话术", "沟通", "怎么跟客户说", "如何向客户解释", "安抚客户", "投诉处理") _RECOMMEND_TERMS = ("推荐", "组合建议", "配置建议", "买什么", "适合配置", "筛选基金", "投资方案") +_CUSTOMER_IDENTITY_TERMS = ("是谁", "姓名", "实名", "基本信息", "联系方式", "手机号") +_SCOPE_ERROR_MESSAGE = "投顾范围查询仅支持客户数据查询" def _contains_any(text: str, terms: tuple[str, ...]) -> bool: @@ -57,6 +59,8 @@ def recognize_advisor_intent(query: str | None, explicit_intent: str | None = No text = re.sub(r"\s+", "", query or "") if not text: return None + if text == _SCOPE_ERROR_MESSAGE: + return None # 先处理最明确的任务词,避免“查询基金收益”被误判成普通数据查询。 if _contains_any(text, _REBALANCE_TERMS): @@ -68,6 +72,13 @@ def recognize_advisor_intent(query: str | None, explicit_intent: str | None = No has_action = any(term in text for term in _QUERY_ACTIONS) has_data = any(term in text for term in _DATA_TERMS) + has_customer_identity = ( + "客户" in text + and _contains_any(text, _CUSTOMER_IDENTITY_TERMS) + and not _contains_any(text, _RECOMMEND_TERMS) + ) + if has_customer_identity: + return AGENT_INTENT_DATA_QUERY if has_data and (has_action or "客户" in text or "近一年" in text or "本月" in text): return AGENT_INTENT_DATA_QUERY if _contains_any(text, _RECOMMEND_TERMS): diff --git a/api/routers/advisor_agent.py b/api/routers/advisor_agent.py index 04b105c..bd84d06 100644 --- a/api/routers/advisor_agent.py +++ b/api/routers/advisor_agent.py @@ -56,7 +56,9 @@ from service.advisor_agent.context import ( load_rebalance_context, load_recommendation_context, ) +from service.advisor_agent.customer_context import load_customer_context from service.event_publisher import publish_event +from nl2sql.session_context import SessionContextStore, build_conversation_context from schemas.advisor_agent import ( AdvisorDraftOperateReq, AdvisorDraftSaveReq, @@ -110,6 +112,16 @@ def _advisor_runtime(request: Request): return getattr(getattr(app, "state", None), "advisor_agent_runtime", None) +@router.get("/session/{session_id}/history") +async def advisor_session_history( + session_id: str, + user: SysUser = Depends(audited_advisor), +): + """读取当前投顾自己的短期 Agent 会话记录。""" + messages = await SessionContextStore(redis_db.client()).load(user.id, session_id) + return agent_success(messages, trace_id=new_request_id()) + + def _infer_chat_intent(query: str) -> str | None: """从自然语言问题推断投顾意图;无法确定时保留通用问答。""" if any(word in query for word in ("调仓", "再平衡", "组合偏离")): @@ -242,7 +254,8 @@ async def chat_stream( explicit_intent=None, ) inferred_intent = classification.intent - customer_id = None + scope = chat_request.scope + customer_id = chat_request.customer_id if not chat_request.query: code = ERR_CODE_FORBIDDEN_CUSTOMER message = "对话请求缺少有效参数" @@ -259,60 +272,30 @@ async def chat_stream( payload = None - # 单客户范围且未传编号时,兼容从问题中解析客户;投顾范围查询不解析客户。 - if inferred_intent in { - AGENT_INTENT_RECOMMEND, - AGENT_INTENT_REBALANCE, - AGENT_INTENT_FUND_ANALYSIS, - AGENT_INTENT_DIALOGUE_SCRIPT, - AGENT_INTENT_DATA_QUERY, - }: - customer_id, resolve_error = await _resolve_customer_from_query( + # 客户 ID 是可选上下文;未选择客户时尝试从自然语言识别姓名,失败不阻断 Agent 判断。 + if customer_id is not None: + await ensure_customer_access(db, advisor_id=user.id, customer_id=int(customer_id)) + else: + resolved_customer_id, _resolve_error = await _resolve_customer_from_query( db, advisor_id=user.id, query=chat_request.query ) - if resolve_error: - payload = agent_failure(ERR_CODE_FORBIDDEN_CUSTOMER, resolve_error, trace_id=trace_id) - async def resolve_error_events(): - yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_ERROR, **payload}, ensure_ascii=False)}\n\n" - return StreamingResponse(resolve_error_events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}) + if resolved_customer_id is not None: + customer_id = resolved_customer_id + scope = "customer" - # 不带客户编号时只提供通用基金问答,不读取客户画像,也不生成个性化草稿。 - if False: - payload = agent_failure( - ERR_CODE_FORBIDDEN_CUSTOMER, - "投顾范围查询仅支持客户数据查询", - trace_id=trace_id, - ) - elif False: - payload = agent_failure( - ERR_CODE_FORBIDDEN_CUSTOMER, - "投顾范围查询不能指定单个客户", - trace_id=trace_id, - ) - elif customer_id is None: - if inferred_intent in { - AGENT_INTENT_RECOMMEND, - AGENT_INTENT_REBALANCE, - AGENT_INTENT_FUND_ANALYSIS, - AGENT_INTENT_DIALOGUE_SCRIPT, - AGENT_INTENT_DATA_QUERY, - }: - if payload is None: - payload = agent_failure( - ERR_CODE_FORBIDDEN_CUSTOMER, - "个性化投顾分析需要在问题中明确客户编号或姓名", - trace_id=trace_id, - ) + if customer_id is None: + if inferred_intent == AGENT_INTENT_DATA_QUERY: + scope = "advisor" else: llm_client = getattr(runtime, "llm_client", None) if inferred_intent == AGENT_INTENT_CASUAL_CHAT: - answer = "您好,我是投顾助手,请选择客户后使用个性化分析。" + answer = "您好,我是投顾助手,可以协助您查询名下客户数据、分析基金和生成投顾辅助方案。" elif llm_client is None: - answer = "已收到问题。当前未配置通用投顾模型,请选择客户后使用个性化分析,或联系管理员配置 Agent 服务。" + answer = "已收到问题。当前未配置通用投顾模型,但您可以直接查询名下客户数据。" else: answer = await generate_text( llm_client, - system_prompt="你是基金投顾助手,只回答通用基金知识和产品分析问题,不读取或推断任何客户信息,不承诺收益,不代客交易。", + system_prompt="你是基金投顾助手。投顾未指定单个客户时,你可以回答通用问题或说明需要的客户范围;不得越权读取客户数据,不承诺收益,不代客交易。", user_prompt=chat_request.query, fallback=lambda: "当前模型暂时不可用,请稍后重试。", timeout=5.0, @@ -331,7 +314,42 @@ async def chat_stream( media_type="text/event-stream", headers={"X-Trace-Id": trace_id}, ) - else: + if inferred_intent == AGENT_INTENT_DATA_QUERY: + effective_scope = "customer" if customer_id is not None else "advisor" + try: + context_store = SessionContextStore(redis_db.client()) + conversation_context = build_conversation_context( + await context_store.load(user.id, chat_request.session_id) + ) + result = await execute_advisor_data_query( + db, + advisor_id=user.id, + customer_id=int(customer_id) if customer_id is not None else None, + scope=effective_scope, + question=chat_request.query, + trace_id=trace_id, + session_id=chat_request.session_id, + conversation_context=conversation_context, + llm_client=getattr(_advisor_runtime(request), "llm_client", None), + ) + except QueryServiceError as exc: + payload = agent_failure(ERR_CODE_LLM_ERROR, _data_query_error_message(exc), trace_id=trace_id) + else: + await context_store.append( + user.id, chat_request.session_id, chat_request.query, + result.get("answer") or result.get("summary") or "查询完成", + ) + + async def events(): + yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_META, 'intent': AGENT_INTENT_DATA_QUERY, 'query_id': result.get('query_id'), 'trace_id': trace_id}, ensure_ascii=False)}\n\n" + answer = result.get("answer") or result.get("summary") + if answer: + yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_TEXT, 'content': answer}, ensure_ascii=False)}\n\n" + yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_DONE, 'query_id': result.get('query_id')}, ensure_ascii=False)}\n\n" + + return StreamingResponse(events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}) + + if customer_id is not None: relation = await ensure_customer_access( db, advisor_id=user.id, customer_id=int(customer_id) ) @@ -345,6 +363,9 @@ async def chat_stream( context = await load_recommendation_context( db, customer_id=int(customer_id) ) + customer_context = await load_customer_context( + db, customer_id=int(customer_id), memories=memories + ) if context is not None: try: draft = await generate_recommendation_draft( @@ -354,6 +375,7 @@ async def chat_stream( trace_id=trace_id, memories=memories, llm_client=getattr(runtime, "llm_client", None), + customer_context=customer_context, **context, ) except Exception: @@ -377,43 +399,6 @@ async def chat_stream( ): yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" - return StreamingResponse( - events(), - media_type="text/event-stream", - headers={"X-Trace-Id": trace_id}, - ) - if inferred_intent == AGENT_INTENT_DATA_QUERY: - if not chat_request.query or not chat_request.query.strip(): - payload = agent_failure( - ERR_CODE_FORBIDDEN_CUSTOMER, - "查询问题不能为空", - trace_id=trace_id, - ) - else: - try: - result = await execute_advisor_data_query( - db, - advisor_id=user.id, - customer_id=int(customer_id) if customer_id is not None else None, - scope="customer", - question=chat_request.query, - trace_id=trace_id, - llm_client=getattr(_advisor_runtime(request), "llm_client", None), - ) - except QueryServiceError as exc: - payload = agent_failure( - ERR_CODE_LLM_ERROR, - _data_query_error_message(exc), - trace_id=trace_id, - ) - else: - async def events(): - yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_META, 'intent': AGENT_INTENT_DATA_QUERY, 'query_id': result.get('query_id'), 'trace_id': trace_id}, ensure_ascii=False)}\n\n" - answer = result.get("answer") or result.get("summary") - if answer: - yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_TEXT, 'content': answer}, ensure_ascii=False)}\n\n" - yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_DONE, 'query_id': result.get('query_id')}, ensure_ascii=False)}\n\n" - return StreamingResponse( events(), media_type="text/event-stream", @@ -432,6 +417,35 @@ async def chat_stream( result = build_fund_analysis( contexts[0]["fund"], contexts[0]["performance"] ) + runtime = _advisor_runtime(request) + if getattr(runtime, "llm_client", None) is not None: + customer_context = await load_customer_context( + db, + customer_id=int(customer_id), + memories=await _recall_advisor_memories( + request, + customer_id=int(customer_id), + query=chat_request.query, + ), + ) + result["analysis_text"] = await generate_text( + runtime.llm_client, + system_prompt=( + "你是合规的基金投顾分析助手。只能根据基金数据和客户上下文回答," + "不得承诺收益,不得代客交易;如果信息不足要明确说明。" + ), + user_prompt=json.dumps( + { + "question": chat_request.query, + "fund": contexts[0]["fund"], + "performance": contexts[0]["performance"], + "customer_context": customer_context, + }, + ensure_ascii=False, + ), + fallback=lambda: result["analysis_text"], + timeout=5.0, + ) async def events(): for event in ( @@ -456,6 +470,33 @@ async def chat_stream( else: scene_type = TALK_SCENE_PORTFOLIO_DIVERGENCE result = build_talk_script(scene_type) + runtime = _advisor_runtime(request) + if getattr(runtime, "llm_client", None) is not None: + memories = await _recall_advisor_memories( + request, + customer_id=int(customer_id), + query=chat_request.query, + ) + customer_context = await load_customer_context( + db, customer_id=int(customer_id), memories=memories + ) + result["content"] = await generate_text( + runtime.llm_client, + system_prompt=( + "你是华夏基金合规投顾助手。请生成简洁、克制、尊重客户的沟通参考话术," + "结合客户上下文但不要暴露内部字段,不承诺收益,不代客交易。" + ), + user_prompt=json.dumps( + { + "scene": scene_type, + "request": chat_request.query, + "customer_context": customer_context, + }, + ensure_ascii=False, + ), + fallback=lambda: result["content"], + timeout=5.0, + ) async def events(): for event in ( diff --git a/frontend/.env.example b/frontend/.env.example deleted file mode 100644 index 44711ac..0000000 --- a/frontend/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -NEXT_PUBLIC_API_BASE_URL=/backend -BACKEND_API_ORIGIN=http://127.0.0.1:8000 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md deleted file mode 100644 index 643577d..0000000 --- a/frontend/AGENTS.md +++ /dev/null @@ -1,9 +0,0 @@ - - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. - -This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. - - diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md deleted file mode 100644 index 43c994c..0000000 --- a/frontend/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 7d8d1ee..0000000 --- a/frontend/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# 华夏基金 PC 前端 - -独立的用户端官网与员工后台前端工程。 - -## 当前状态 - -已建立 Next.js + React + TypeScript + Tailwind CSS + Lucide 的基础工程和页面路由占位。 - -## 启动 - -```bash -npm install -npm run dev -``` - -默认访问地址:`http://localhost:3000` - -## 配置 - -复制 `.env.example` 为 `.env.local`,根据实际后端地址调整: - -```env -NEXT_PUBLIC_API_BASE_URL=/backend -BACKEND_API_ORIGIN=http://127.0.0.1:8000 - -前端默认通过 Next.js 同源代理访问后端,避免浏览器 CORS 预检失败。 -``` - -## 边界 - -- 只开发 PC 前端。 -- 不修改项目根目录下现有 FastAPI 后端。 -- 基金数据通过后端 API 获取,不直接连接 MySQL。 -- 角色显示由前端映射为“投顾专员”“风控专员”“员工通用”。 diff --git a/frontend/app/account/adjust/page.tsx b/frontend/app/account/adjust/page.tsx deleted file mode 100644 index 969946e..0000000 --- a/frontend/app/account/adjust/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; -import { FormEvent, useState } from "react"; -import { apiFetch } from "@/lib/api"; - -export default function AccountAdjustPage() { - const [direction, setDirection] = useState<"add" | "sub">("add"); const [amount, setAmount] = useState(""); const [message, setMessage] = useState(""); - async function submit(event: FormEvent) { event.preventDefault(); try { await apiFetch("/account/adjust", { method: "POST", body: JSON.stringify({ direction, amount }) }); setMessage("余额调整已提交"); setAmount(""); } catch (error) { setMessage(error instanceof Error ? error.message : "余额调整失败"); } } - return
返回用户中心

账户余额调整

充值或提现将由后端进行权限与金额校验。

{message &&

{message}

}
; -} diff --git a/frontend/app/account/page.tsx b/frontend/app/account/page.tsx deleted file mode 100644 index 1b7e6c5..0000000 --- a/frontend/app/account/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { ArrowLeft, ArrowUpRight, CircleUserRound, Landmark, RefreshCw, ShieldCheck, WalletCards, X } from "lucide-react"; -import { FormEvent, useEffect, useState } from "react"; -import { apiFetch } from "@/lib/api"; -import { clearToken } from "@/lib/auth"; -import type { UserPayload } from "@/types/api"; - -interface Balance { balance?: number | string; available_balance?: number | string; } -interface Holding { product_id?: number; product_name?: string; product_code?: string; shares?: number | string; current_value?: number | string; market_value?: number | string; profit_loss?: number | string; } - -export default function AccountPage() { - const [user, setUser] = useState(null); const [balance, setBalance] = useState(null); const [holdings, setHoldings] = useState([]); const [state, setState] = useState<"loading" | "ready" | "error">("loading"); const [action, setAction] = useState<"purchase" | "redeem" | null>(null); - async function loadAccount() { setState("loading"); try { const [me, account, holdingList] = await Promise.all([apiFetch<{ user: UserPayload }>("/auth/me"), apiFetch("/account/balance"), apiFetch("/holdings")]); setUser(me.user); setBalance(account); setHoldings(holdingList); setState("ready"); } catch { setState("error"); } } - useEffect(() => { void loadAccount(); }, []); - function signOut() { clearToken(); window.location.href = "/login"; } - return
华华夏基金
基金产品
返回首页

Personal Account

用户中心

{user?.real_name ?? user?.username ?? "您的华夏基金账户"}

{state === "loading" && }{state === "error" && }{state === "ready" && <>

账户余额

¥{balance?.balance ?? "--"}

可用余额

¥{balance?.available_balance ?? "--"}

风险测评

待完成测评

当前后端仅提供提交接口

我的持仓

{holdings.length ?
{holdings.map((item) =>

{item.product_name ?? "基金产品"}

{item.product_code}

¥{item.current_value ?? item.market_value ?? "--"}

份额 {item.shares ?? "--"} · 盈亏 {item.profit_loss ?? "--"}

)}
:

暂无持仓数据

}

快捷操作

交易记录

当前后端暂未提供交易记录查询接口,页面不会展示虚构数据。

消息通知

当前后端暂未提供用户消息查询接口。

}
{action && setAction(null)} onDone={() => { setAction(null); void loadAccount(); }} />}
; -} - -function TradeModal({ type, holdings, onClose, onDone }: { type: "purchase" | "redeem"; holdings: Holding[]; onClose: () => void; onDone: () => void }) { const [productId, setProductId] = useState(String(holdings[0]?.product_id ?? "")); const [value, setValue] = useState(""); const [message, setMessage] = useState(""); const [loading, setLoading] = useState(false); async function submit(event: FormEvent) { event.preventDefault(); setLoading(true); setMessage(""); try { await apiFetch(type === "purchase" ? "/purchase" : "/redeem", { method: "POST", body: JSON.stringify(type === "purchase" ? { product_id: Number(productId), amount: value } : { product_id: Number(productId), shares: value }) }); onDone(); } catch (error) { setMessage(error instanceof Error ? error.message : "操作失败,请稍后重试"); } finally { setLoading(false); } } return

{type === "purchase" ? "申购基金" : "赎回基金"}

{message &&

{message}

}
; } - -function Notice({ text }: { text: string }) { return
{text}
; } diff --git a/frontend/app/admin/advisor/page.tsx b/frontend/app/admin/advisor/page.tsx deleted file mode 100644 index 200eb60..0000000 --- a/frontend/app/admin/advisor/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { AdvisorWorkspace } from "@/components/role-workspaces"; -export default function AdvisorPage() { return ; } diff --git a/frontend/app/admin/advisor/tools/page.tsx b/frontend/app/admin/advisor/tools/page.tsx deleted file mode 100644 index 5a58f8f..0000000 --- a/frontend/app/admin/advisor/tools/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { ArrowLeft, Download, RefreshCw, Save, Send, Trash2 } from "lucide-react"; -import { FormEvent, useEffect, useState } from "react"; -import { apiFetch } from "@/lib/api"; - -interface Customer { customer_id?: number; id?: number; real_name?: string; name?: string; } -interface Draft { draft_id?: string; id?: string; title?: string; content?: string; status?: string; customer_id?: number; } -interface Visit { id: number; customer_id: number; visit_type?: string; visit_time?: string; summary?: string; content?: string; } - -export default function AdvisorToolsPage() { - const [customers, setCustomers] = useState([]); const [customerId, setCustomerId] = useState(""); const [section, setSection] = useState("detail"); const [data, setData] = useState(null); const [message, setMessage] = useState(""); - const [drafts, setDrafts] = useState([]); const [draftId, setDraftId] = useState(""); const [draftTitle, setDraftTitle] = useState(""); const [draftContent, setDraftContent] = useState(""); - const [visits, setVisits] = useState([]); const [visitId, setVisitId] = useState(null); const [visitType, setVisitType] = useState("电话回访"); const [visitTime, setVisitTime] = useState(""); const [visitSummary, setVisitSummary] = useState(""); - useEffect(() => { void apiFetch<{ items?: Customer[] }>("/advisor/customers?page=1&page_size=100").then((result) => { setCustomers(result.items ?? []); const first = result.items?.[0]; if (first) setCustomerId(String(first.customer_id ?? first.id)); }).catch(() => setMessage("客户列表加载失败")); }, []); - async function load() { setMessage(""); try { const id = Number(customerId); const paths: Record = { detail: `/advisor/customers/${id}`, holdings: `/advisor/customers/${id}/holdings`, reports: `/advisor/customers/${id}/reports`, diagnosis: `/advisor/diagnosis/${id}`, strategies: "/advisor/strategies", funds: "/advisor/funds", audit: "/advisor/audit/ledger" }; if (section === "drafts") { const result = await apiFetch<{ items?: Draft[] }>(`/advisor/drafts?page=1&page_size=50${customerId ? `&customer_id=${id}` : ""}`); setDrafts(result.items ?? []); setData(result); return; } if (section === "visits") { const result = await apiFetch<{ items?: Visit[] }>(`/advisor/visits?page=1&page_size=50${customerId ? `&customer_id=${id}` : ""}`); setVisits(result.items ?? []); setData(result); return; } setData(await apiFetch(paths[section])); } catch (error) { setMessage(error instanceof Error ? error.message : "加载失败"); } } - function chooseDraft(draft: Draft) { const id = String(draft.draft_id ?? draft.id ?? ""); setDraftId(id); setDraftTitle(draft.title ?? ""); setDraftContent(draft.content ?? ""); } - async function draftAction(action: "save" | "discard" | "send") { if (!draftId) return; try { if (action === "save") await apiFetch(`/advisor/drafts/${draftId}/save`, { method: "PUT", body: JSON.stringify({ title: draftTitle, content: draftContent }) }); else await apiFetch(`/advisor/drafts/${draftId}/${action}`, { method: "POST" }); setMessage(action === "save" ? "草稿已保存" : action === "discard" ? "草稿已废弃" : "草稿已发送"); await load(); } catch (error) { setMessage(error instanceof Error ? error.message : "草稿操作失败"); } } - async function saveVisit(event: FormEvent) { event.preventDefault(); try { const body = { customer_id: Number(customerId), visit_type: visitType, visit_time: new Date(visitTime).toISOString(), summary: visitSummary }; if (visitId) await apiFetch(`/advisor/visits/${visitId}`, { method: "PUT", body: JSON.stringify(body) }); else await apiFetch("/advisor/visits", { method: "POST", body: JSON.stringify(body) }); setMessage(visitId ? "回访已更新" : "回访已新增"); setVisitId(null); setVisitSummary(""); await load(); } catch (error) { setMessage(error instanceof Error ? error.message : "回访保存失败"); } } - return
返回投顾工作区

投顾业务工具

{section === "audit" && }
{message &&

{message}

} - {section === "drafts" &&

草稿列表

{drafts.map((draft) => )}{!drafts.length &&

暂无草稿

}
setDraftTitle(event.target.value)} placeholder="草稿标题" className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm" />