diff --git a/.gitignore b/.gitignore index 20a29a3..5f822aa 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ @@ -74,8 +74,14 @@ data/output/ docs/ .workbuddy/ +# Next.js 前端依赖、构建缓存与本地配置 +frontend/node_modules/ +frontend/.next/ +frontend/*.tsbuildinfo +frontend/.env.local + # 本地临时需求文档 DK2_客服Agent模块完整开发计划(v1.1).md DK4_客服Agent需求文档(修复完善版v1.4).md 客服Agent模块 · TODO List(最终修复版v1.2).md -客服Agent长期记忆与置信度最终开发文档.md \ No newline at end of file +客服Agent长期记忆与置信度最终开发文档.md diff --git a/api/routers/advisor_agent.py b/api/routers/advisor_agent.py index 2c2e792..04b105c 100644 --- a/api/routers/advisor_agent.py +++ b/api/routers/advisor_agent.py @@ -239,13 +239,13 @@ async def chat_stream( classification = await classify_advisor_intent( chat_request.query, getattr(runtime, "llm_client", None), - explicit_intent=chat_request.intent, + explicit_intent=None, ) inferred_intent = classification.intent - customer_id = chat_request.customer_id - if not chat_request.query and not chat_request.intent: - code = ERR_CODE_LLM_ERROR if customer_id is not None else ERR_CODE_FORBIDDEN_CUSTOMER - message = _NOT_READY_MESSAGE if customer_id is not None else "对话请求缺少有效参数" + customer_id = None + if not chat_request.query: + code = ERR_CODE_FORBIDDEN_CUSTOMER + message = "对话请求缺少有效参数" payload = agent_failure(code, message, trace_id=trace_id) async def empty_query_events(): @@ -260,43 +260,36 @@ async def chat_stream( payload = None # 单客户范围且未传编号时,兼容从问题中解析客户;投顾范围查询不解析客户。 - if chat_request.scope == "customer" and 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, + }: customer_id, resolve_error = await _resolve_customer_from_query( - db, - advisor_id=user.id, - query=chat_request.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, - ) + 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}, - ) - else: - payload = None + return StreamingResponse(resolve_error_events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}) # 不带客户编号时只提供通用基金问答,不读取客户画像,也不生成个性化草稿。 - if chat_request.scope == "advisor" and inferred_intent != AGENT_INTENT_DATA_QUERY: + if False: payload = agent_failure( ERR_CODE_FORBIDDEN_CUSTOMER, "投顾范围查询仅支持客户数据查询", trace_id=trace_id, ) - elif chat_request.scope == "advisor" and customer_id is not None: + elif False: payload = agent_failure( ERR_CODE_FORBIDDEN_CUSTOMER, "投顾范围查询不能指定单个客户", trace_id=trace_id, ) - elif chat_request.scope == "customer" and customer_id is None: + elif customer_id is None: if inferred_intent in { AGENT_INTENT_RECOMMEND, AGENT_INTENT_REBALANCE, @@ -339,12 +332,9 @@ async def chat_stream( headers={"X-Trace-Id": trace_id}, ) else: - relation = None - if chat_request.scope == "customer": - customer_id = chat_request.customer_id - relation = await ensure_customer_access( - db, advisor_id=user.id, customer_id=int(customer_id) - ) + relation = await ensure_customer_access( + db, advisor_id=user.id, customer_id=int(customer_id) + ) if inferred_intent == AGENT_INTENT_RECOMMEND: memories = await _recall_advisor_memories( request, @@ -405,7 +395,7 @@ async def chat_stream( db, advisor_id=user.id, customer_id=int(customer_id) if customer_id is not None else None, - scope=chat_request.scope, + scope="customer", question=chat_request.query, trace_id=trace_id, llm_client=getattr(_advisor_runtime(request), "llm_client", None), @@ -516,12 +506,17 @@ async def advisor_data_query( ): """查询当前投顾选中客户的数据,不返回 SQL,也不生成草稿。""" trace_id = _trace_id(request) + customer_id, resolve_error = await _resolve_customer_from_query( + db, advisor_id=user.id, query=body.query + ) + if resolve_error or customer_id is None: + return agent_failure(ERR_CODE_FORBIDDEN_CUSTOMER, resolve_error or "请在问题中补充客户编号或客户姓名", trace_id=trace_id) try: result = await execute_advisor_data_query( db, advisor_id=user.id, - customer_id=body.customer_id, - question=body.question, + customer_id=customer_id, + question=body.query, trace_id=trace_id, session_id=body.session_id, max_rows=body.max_rows, @@ -626,7 +621,11 @@ async def run_rebalance( db: AsyncSession = Depends(get_db), background_tasks: BackgroundTasks = None, ): - customer_id = body.customer_id + customer_id, resolve_error = await _resolve_customer_from_query( + db, advisor_id=user.id, query=body.query + ) + if resolve_error or customer_id is None: + return agent_failure(ERR_CODE_FORBIDDEN_CUSTOMER, resolve_error or "请在问题中补充客户编号或客户姓名", trace_id=_trace_id(request)) relation = await ensure_customer_access( db, advisor_id=user.id, customer_id=int(customer_id) ) @@ -657,34 +656,22 @@ async def fund_analysis( user: SysUser = Depends(audited_advisor), db: AsyncSession = Depends(get_db), ): - memories: list[dict] = [] - if body.customer_id is not None: - await ensure_customer_access( - db, advisor_id=user.id, customer_id=body.customer_id + customer_id, resolve_error = await _resolve_customer_from_query(db, advisor_id=user.id, query=body.query) + if resolve_error or customer_id is None: + return agent_failure(ERR_CODE_FORBIDDEN_CUSTOMER, resolve_error or "请在问题中补充客户编号或客户姓名", trace_id=_trace_id(request)) + fund_codes = re.findall(r"[A-Za-z]{1,6}\d{3,8}", body.query.upper()) + if not fund_codes: + return agent_failure(ERR_CODE_LLM_ERROR, "请在问题中补充基金代码", trace_id=_trace_id(request)) + memories = await _recall_advisor_memories(request, customer_id=customer_id, query=body.query) + contexts = await load_fund_analysis_context(db, fund_codes=fund_codes) + if not contexts: + return _not_ready(request) + if len(contexts) > 1: + return agent_success( + {"items": [build_fund_analysis(item["fund"], item["performance"]) for item in contexts]}, + trace_id=_trace_id(request), ) - memories = await _recall_advisor_memories( - request, - customer_id=body.customer_id, - query=f"基金分析 {','.join(body.fund_codes)}", - ) - fund = body.fund - performance = body.performance - if fund is None or performance is None: - contexts = await load_fund_analysis_context( - db, - fund_codes=[str(code) for code in body.fund_codes], - ) - if not contexts: - return _not_ready(request) - if len(contexts) == 1: - fund = contexts[0]["fund"] - performance = contexts[0]["performance"] - else: - return agent_success( - {"items": [build_fund_analysis(item["fund"], item["performance"]) for item in contexts]}, - trace_id=_trace_id(request), - ) - result = build_fund_analysis(fund, performance) + result = build_fund_analysis(contexts[0]["fund"], contexts[0]["performance"]) runtime = _advisor_runtime(request) if getattr(runtime, "llm_client", None) is not None: result["analysis_text"] = await generate_text( @@ -707,16 +694,25 @@ async def generate_talk_script( user: SysUser = Depends(audited_advisor), db: AsyncSession = Depends(get_db), ): - await ensure_customer_access(db, advisor_id=user.id, customer_id=body.customer_id) + customer_id, resolve_error = await _resolve_customer_from_query(db, advisor_id=user.id, query=body.query) + if resolve_error or customer_id is None: + return agent_failure(ERR_CODE_FORBIDDEN_CUSTOMER, resolve_error or "请在问题中补充客户编号或客户姓名", trace_id=_trace_id(request)) + scene_type = TALK_SCENE_MARKET_FLUCTUATION + if "投诉" in body.query: + scene_type = TALK_SCENE_CUSTOMER_COMPLAINT + elif "拦截" in body.query or "风控" in body.query: + scene_type = TALK_SCENE_RISK_BLOCK_ORDER + elif "调仓" in body.query or "偏离" in body.query: + scene_type = TALK_SCENE_PORTFOLIO_DIVERGENCE memories = await _recall_advisor_memories( request, - customer_id=body.customer_id, - query=f"沟通话术 {body.scene_type}", + customer_id=customer_id, + query=body.query, ) try: result = build_talk_script( - body.scene_type, - customer_name=body.customer_name, + scene_type, + customer_name="客户", ) except ValueError as exc: raise ApiError(ERR_CODE_LLM_ERROR, str(exc)) from exc diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..44711ac --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..643577d --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,9 @@ + + +# 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 new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7d8d1ee --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,34 @@ +# 华夏基金 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 new file mode 100644 index 0000000..969946e --- /dev/null +++ b/frontend/app/account/adjust/page.tsx @@ -0,0 +1,12 @@ +"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 new file mode 100644 index 0000000..1b7e6c5 --- /dev/null +++ b/frontend/app/account/page.tsx @@ -0,0 +1,23 @@ +"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 new file mode 100644 index 0000000..200eb60 --- /dev/null +++ b/frontend/app/admin/advisor/page.tsx @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..5a58f8f --- /dev/null +++ b/frontend/app/admin/advisor/tools/page.tsx @@ -0,0 +1,26 @@ +"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" />