From 7b6b2a738297e5e03d7fa62fca70c506c94a6280 Mon Sep 17 00:00:00 2001 From: lpm Date: Mon, 14 Sep 2026 20:31:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=89=8D=E7=AB=AF=E4=BF=AE=E6=94=B9(?= =?UTF-8?q?=E6=88=91=E8=AE=A4=E4=B8=BAok)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design-preview/index.html | 1055 ++++++++++ frontend/.env.example | 2 + frontend/AGENTS.md | 9 + frontend/CLAUDE.md | 1 + frontend/README.md | 34 + frontend/app/account/adjust/page.tsx | 12 + frontend/app/account/page.tsx | 23 + frontend/app/admin/administrator/page.tsx | 6 + frontend/app/admin/advisor/page.tsx | 6 + frontend/app/admin/common/page.tsx | 6 + frontend/app/admin/page.tsx | 9 + frontend/app/admin/risk/page.tsx | 6 + frontend/app/globals.css | 48 + frontend/app/layout.tsx | 18 + frontend/app/login/page.tsx | 27 + frontend/app/page.tsx | 49 + frontend/app/products/[code]/page.tsx | 40 + frontend/app/products/page.tsx | 92 + frontend/app/register/page.tsx | 14 + frontend/app/risk-assessment/page.tsx | 16 + frontend/app/tools/knowledge/page.tsx | 371 ++++ frontend/app/tools/nl2sql/page.tsx | 6 + .../components/admin/assistant-launcher.tsx | 617 ++++++ frontend/components/admin/sections/admin.tsx | 70 + .../components/admin/sections/customers.tsx | 466 +++++ frontend/components/admin/sections/drafts.tsx | 199 ++ frontend/components/admin/sections/funds.tsx | 152 ++ .../admin/sections/nl2sql-console.tsx | 530 +++++ frontend/components/admin/sections/nl2sql.tsx | 208 ++ .../components/admin/sections/overview.tsx | 318 +++ frontend/components/admin/sections/risk.tsx | 278 +++ frontend/components/admin/sections/todos.tsx | 179 ++ frontend/components/admin/sections/visits.tsx | 229 +++ .../components/admin/sections/workorders.tsx | 282 +++ frontend/components/admin/shell.tsx | 301 +++ frontend/components/admin/ui.tsx | 479 +++++ .../components/admin/unified-workspace.tsx | 217 ++ frontend/components/agent-chat.tsx | 85 + frontend/components/agent-mount.tsx | 17 + frontend/components/public-header.tsx | 29 + frontend/lib/admin-api.ts | 340 ++++ frontend/lib/api.ts | 198 ++ frontend/lib/assistant-api.ts | 154 ++ frontend/lib/auth.ts | 21 + frontend/lib/knowledge-api.ts | 71 + frontend/lib/nl2sql-api.ts | 199 ++ frontend/lib/risk.ts | 18 + frontend/lib/roles.ts | 117 ++ frontend/next-env.d.ts | 7 + frontend/next.config.ts | 14 + frontend/package-lock.json | 1798 +++++++++++++++++ frontend/package.json | 25 + frontend/postcss.config.mjs | 7 + frontend/public/hero-finance.jpg | Bin 0 -> 208159 bytes frontend/tsconfig.json | 41 + frontend/tsconfig.lint.json | 8 + frontend/types/api.ts | 34 + 57 files changed, 9558 insertions(+) create mode 100644 design-preview/index.html create mode 100644 frontend/.env.example create mode 100644 frontend/AGENTS.md create mode 100644 frontend/CLAUDE.md create mode 100644 frontend/README.md create mode 100644 frontend/app/account/adjust/page.tsx create mode 100644 frontend/app/account/page.tsx create mode 100644 frontend/app/admin/administrator/page.tsx create mode 100644 frontend/app/admin/advisor/page.tsx create mode 100644 frontend/app/admin/common/page.tsx create mode 100644 frontend/app/admin/page.tsx create mode 100644 frontend/app/admin/risk/page.tsx create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/login/page.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/app/products/[code]/page.tsx create mode 100644 frontend/app/products/page.tsx create mode 100644 frontend/app/register/page.tsx create mode 100644 frontend/app/risk-assessment/page.tsx create mode 100644 frontend/app/tools/knowledge/page.tsx create mode 100644 frontend/app/tools/nl2sql/page.tsx create mode 100644 frontend/components/admin/assistant-launcher.tsx create mode 100644 frontend/components/admin/sections/admin.tsx create mode 100644 frontend/components/admin/sections/customers.tsx create mode 100644 frontend/components/admin/sections/drafts.tsx create mode 100644 frontend/components/admin/sections/funds.tsx create mode 100644 frontend/components/admin/sections/nl2sql-console.tsx create mode 100644 frontend/components/admin/sections/nl2sql.tsx create mode 100644 frontend/components/admin/sections/overview.tsx create mode 100644 frontend/components/admin/sections/risk.tsx create mode 100644 frontend/components/admin/sections/todos.tsx create mode 100644 frontend/components/admin/sections/visits.tsx create mode 100644 frontend/components/admin/sections/workorders.tsx create mode 100644 frontend/components/admin/shell.tsx create mode 100644 frontend/components/admin/ui.tsx create mode 100644 frontend/components/admin/unified-workspace.tsx create mode 100644 frontend/components/agent-chat.tsx create mode 100644 frontend/components/agent-mount.tsx create mode 100644 frontend/components/public-header.tsx create mode 100644 frontend/lib/admin-api.ts create mode 100644 frontend/lib/api.ts create mode 100644 frontend/lib/assistant-api.ts create mode 100644 frontend/lib/auth.ts create mode 100644 frontend/lib/knowledge-api.ts create mode 100644 frontend/lib/nl2sql-api.ts create mode 100644 frontend/lib/risk.ts create mode 100644 frontend/lib/roles.ts create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.ts create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/public/hero-finance.jpg create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.lint.json create mode 100644 frontend/types/api.ts diff --git a/design-preview/index.html b/design-preview/index.html new file mode 100644 index 0000000..ace6bd4 --- /dev/null +++ b/design-preview/index.html @@ -0,0 +1,1055 @@ + + + + + +华夏基金 · 界面设计总览 + + + + + +
+
+ + +
+
+
+ +
工作台概览
+
按当前角色展示核心经营指标
+
+
+ +
李
+
李静
投顾
+
+
+
+
+
+ + + + + + +
+ + + + + +
+
+
+ +

知识库管理

+

上传投顾 / 客服 Agent 可检索的文档,入库前可先预览切分效果

+ +
上传文档
+
先预览切分效果,确认无误后再入库
+
+
+ 选择文件 + 投顾话术手册_v3.pdf +
+
+
标题
+
+
切分策略
+
+
+
分片长度
+
+
重叠长度
+
+
+
+
+ + + +
+ +
+
+ default + 分片 800重叠 120 + 共切出 42 段 +
+
+
+ 第 1 段:本手册适用于一线投顾在客户沟通场景中使用,所有表述须通过合规校验后方可对外发送。
+
+ 第 2 段:风险揭示话术:任何基金产品均不承诺保本,历史业绩不代表未来表现,请在客户理解后确认。
+
仅展示前 20 段,共 42 段
+
+
+
+
+ +
已入库文档
+
共 3 篇
+
+
+ + + + + + + + + +
文档集合分段入库时间
投顾话术手册_v3.pdfadvisor_kb42 段2026-09-10 14:22删除
基金产品说明书汇总.docxproduct_kb128 段2026-09-08 09:41删除
合规红线与禁语清单.mdcompliance_kb17 段2026-09-02 16:05删除
+
+
+
+
+
+ + +
+ +
+

让每一份长期信任,
变成清晰的选择。

+

基于你的风险承受能力与投资目标,提供更合适的方案。

+
+
+
+
+
+
公募基金
+
覆盖股票、债券、混合、指数等多类型产品。
+
风险管理
+
全流程风险识别与预警,守住投资底线。
+
智能投顾
+
数据驱动的持仓诊断与配置建议。
+
+
+
+

找到适合自己的基金。

精选白名单产品,数据每日更新。
+
+ + + +
+
+
基金投资有风险,过往业绩不代表未来表现。
+
+ +
+ +
+

基金产品

+
+ + + +
+
+
+
+ + + + + + + + + + + + + + + +
基金代码基金名称类型风险等级单位净值日涨跌净值日期基金经理
000001华夏成长混合混合型R32.1840+1.24%2026-09-13张伟
000311华夏复兴股票股票型R43.0420-0.62%2026-09-13李娜
001056华夏稳健债券债券型R21.3260+0.18%2026-09-13王强
+
+
+ +
+ +
+
000001 · 混合型 · R3
+

华夏成长混合

+
+
单位净值
+
2.1840
+
日涨跌
+1.24%
+
近一年
+18.62%
+
基金经理
张伟
+
+
+
+
+

历史净值走势

+
+ + + + +
2025-092026-032026-09
+
+
基金过往业绩不预示其未来表现,投资须谨慎。
+
+
+ +
+
+
+
华夏基金
+

专业研究,
陪伴长期价值。

+

以长期视角管理每一笔托付, + 用纪律与数据应对市场波动。

+
+
+

欢迎回来

+
请使用您的账号登录员工工作台或用户中心
+
+
+ +
还没有账号? + 立即注册
+
+
+
+ +
+ +
+
投资前必做
+

风险承受能力测评

+ +
第 3 / 10 题 · 完成约需 2 分钟
+
+
+
+
您的投资期限通常是?
+
单选,请选择最符合您实际情况的一项
+
+ + + + +
+
+
+
+
测评结果仅用于评估您的风险承受能力,不构成投资建议。
+
+
+ +
+ +
+
欢迎回来
+

用户中心

+
+
+
+
总资产
+
¥ 1,284,600
较昨日 +0.42%
+
持仓收益
+
+¥ 86,240
收益率 +7.19%
+
风险等级
+
C3
稳健型
+
待办
+
2
风险测评待更新
+
+ +
+
+
我的持仓
按市值排序
+
+
+ + + + + +
基金持有份额市值收益
华夏成长混合12,400.00¥ 27,081+12.4%
华夏复兴股票5,200.00¥ 15,818-3.1%
华夏稳健债券30,000.00¥ 39,780+2.6%
+ +
+
快捷操作
+
+
+ +
+
消息通知
+
· 您的风险测评将于 30 天后过期
+
· 华夏成长混合 09-13 净值已更新
+
交易记录
+
· 09-12 申购 华夏稳健债券 ¥ 10,000
+
· 09-05 赎回 华夏复兴股票 1,000 份
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + 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/administrator/page.tsx b/frontend/app/admin/administrator/page.tsx new file mode 100644 index 0000000..6c4d7b0 --- /dev/null +++ b/frontend/app/admin/administrator/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +/** 旧入口重定向到统一工作台的系统管理分区。 */ +export default function AdministratorPage() { + redirect("/admin?section=admin"); +} diff --git a/frontend/app/admin/advisor/page.tsx b/frontend/app/admin/advisor/page.tsx new file mode 100644 index 0000000..d51d04e --- /dev/null +++ b/frontend/app/admin/advisor/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +/** 旧入口重定向到统一工作台的投顾分区。 */ +export default function AdvisorPage() { + redirect("/admin?section=customers"); +} diff --git a/frontend/app/admin/common/page.tsx b/frontend/app/admin/common/page.tsx new file mode 100644 index 0000000..a84b4b8 --- /dev/null +++ b/frontend/app/admin/common/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +/** 旧入口重定向到统一工作台概览。 */ +export default function CommonPage() { + redirect("/admin?section=overview"); +} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx new file mode 100644 index 0000000..b5b2798 --- /dev/null +++ b/frontend/app/admin/page.tsx @@ -0,0 +1,9 @@ +import { UnifiedWorkspace } from "@/components/admin/unified-workspace"; + +/** + * 统一员工工作台唯一入口。 + * 分区通过 ?section= 切换,/admin/advisor 等旧路径会重定向到此处。 + */ +export default function AdminPage() { + return ; +} diff --git a/frontend/app/admin/risk/page.tsx b/frontend/app/admin/risk/page.tsx new file mode 100644 index 0000000..2158ed3 --- /dev/null +++ b/frontend/app/admin/risk/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +/** 旧入口重定向到统一工作台的风控分区。 */ +export default function RiskPage() { + redirect("/admin?section=risk"); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..fa72010 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,48 @@ +@import "tailwindcss"; + +:root { + --brand-primary: #4f46e5; + --brand-primary-dark: #4338ca; + --brand-navy: #071a49; + --brand-gold: #d9a441; + --page-background: #f8fafc; + --text-primary: #111827; + --text-muted: #64748b; + --border-subtle: #e2e8f0; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: var(--page-background); + color: var(--text-primary); +} + +::selection { + background: var(--brand-primary); + color: white; +} + +.hero-grid { + background-image: linear-gradient(rgba(7, 26, 73, 0.16) 1px, transparent 1px), + linear-gradient(90deg, rgba(7, 26, 73, 0.16) 1px, transparent 1px); + background-size: 46px 46px; +} + +.section-rule { + border-top: 1px solid rgba(148, 163, 184, 0.24); +} + +button, +input, +select, +textarea { + font: inherit; +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..1e86fd0 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { AgentMount } from "@/components/agent-mount"; + +export const metadata: Metadata = { + title: "华夏基金", + description: "华夏基金 PC 网页平台", +}; + +export default function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + + {children} + + ); +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..1c82c95 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, ArrowRight, LockKeyhole } from "lucide-react"; +import { FormEvent, useState } from "react"; +import { apiFetch } from "@/lib/api"; +import { getPostLoginPath, setToken } from "@/lib/auth"; + +interface LoginResult { token: string; user: { user_type: string; employee_role?: string | null } } + +export default function LoginPage() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); setMessage(""); setLoading(true); + try { + const result = await apiFetch("/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); + setToken(result.token); + window.location.href = getPostLoginPath(result.user); + } catch (error) { setMessage(error instanceof Error ? error.message : "登录失败,请稍后重试"); } finally { setLoading(false); } + } + + return
返回首页

Huaxia Fund

专业研究,
陪伴长期价值。

登录后查看您的账户、持仓和专属服务。

欢迎回来

登录华夏基金账户

{message &&

{message}

}

还没有账号? 立即注册

; +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..58982b2 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight, BarChart3, Globe2, ShieldCheck, Sparkles, TrendingUp } from "lucide-react"; +import { useEffect, useState } from "react"; +import { PublicHeader } from "@/components/public-header"; +import { apiFetch } from "@/lib/api"; +import { normalizeRiskLevel } from "@/lib/risk"; +import type { FundProduct, FundProductList } from "@/types/api"; + +export default function HomePage() { + const [products, setProducts] = useState([]); + const [fundState, setFundState] = useState<"loading" | "ready" | "error">("loading"); + + useEffect(() => { + apiFetch("/products?page=1&page_size=3") + .then((data) => { setProducts(data.items); setFundState("ready"); }) + .catch(() => setFundState("error")); + }, []); + + return ( +
+ +
+
+
+
+
+
+

长期主义的财富伙伴

+

让每一份长期信任,
都拥有专业的答案。

+

华夏基金以专业研究、稳健风控和智能科技,为每一位投资者提供更清晰的基金选择与更长期的财富陪伴。

+
查看基金产品 注册账户
+
+
合规投资服务面向长期价值
+
+
+ +

About Huaxia Fund

把复杂的市场,
变成清晰的选择。

让财富管理更智能、更普惠。我们坚持客户至上、诚信为本、专业精进与合规底线,用科技提升每一次投资决策的确定感。

了解我们的理念

长期

用持续研究穿越周期,陪伴客户建立长期投资习惯。

稳健

把风险管理放在每一次产品选择与服务流程之前。

科技赋能

通过数据、智能投顾与数字化服务,让专业能力更容易被每一位投资者使用。

+ +

Core Services

为每一种目标,
提供更合适的方案。

公募基金

覆盖多种基金类型,帮助投资者按风险偏好和目标筛选产品。

风险管理

通过风险测评和适当性服务,让投资选择建立在理解之上。

智能投顾

用智能工具辅助信息理解和资产配置,服务每一段长期旅程。

+ +

Fund Marketplace

从真实数据开始,
找到适合自己的基金。

查看全部产品
{fundState === "loading" && [1, 2, 3].map((item) =>
)}{fundState === "error" &&
基金数据暂时无法加载,请确认后端服务已启动后重试。
}{fundState === "ready" && products.map((product) =>

{product.product_name}

{product.product_code} · {product.product_type}

{normalizeRiskLevel(product.risk_level)}

最新净值

{product.nav ?? "--"}

{product.nav_date ?? "暂无日期"})}{fundState === "ready" && products.length === 0 &&
当前暂无可展示的基金产品。
}
+ +

Start With Clarity

先了解自己,再做投资选择。

基金投资有风险,投资者应根据自身风险承受能力选择适合的产品。过往业绩不代表未来表现。

开启账户
+

华夏基金

让财富管理更智能、更普惠

登录注册风险揭示
+
+ ); +} diff --git a/frontend/app/products/[code]/page.tsx b/frontend/app/products/[code]/page.tsx new file mode 100644 index 0000000..b7edccb --- /dev/null +++ b/frontend/app/products/[code]/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, CalendarDays, LineChart as LineChartIcon, ShieldCheck } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { PublicHeader } from "@/components/public-header"; +import { apiFetch } from "@/lib/api"; +import type { FundProduct, FundProductList } from "@/types/api"; + +interface HistoryPoint { nav_date: string; unit_nav: number; daily_growth?: number | null; cum_return_pct?: number; } +interface HistoryResponse { items: HistoryPoint[]; total_growth_rate: number; max_return_rate: number; max_drawdown_rate: number; } + +export default function FundDetailPage({ params }: { params: Promise<{ code: string }> }) { + const [product, setProduct] = useState(null); + const [history, setHistory] = useState(null); + const [state, setState] = useState<"loading" | "ready" | "error">("loading"); + + useEffect(() => { + void params.then(({ code }) => Promise.all([ + apiFetch(`/products?keyword=${encodeURIComponent(code)}&page_size=10`), + apiFetch(`/products/${encodeURIComponent(code)}/history`), + ]).then(([list, chart]) => { + setProduct(list.items.find((item) => item.product_code === code) ?? null); + setHistory(chart); setState("ready"); + }).catch(() => setState("error"))); + }, [params]); + + return
返回基金列表{state === "loading" &&
}{state === "error" && }{state === "ready" && product && <>
{product.risk_level}

{product.product_name}

{product.product_code} · {product.product_type}

最新单位净值

{product.nav ?? "--"}

更新于 {product.nav_date ?? "暂无日期"}

历史净值走势

数据来自基金历史净值接口 · {history?.items.length ?? 0} 个数据点

基金投资有风险,过往业绩不代表未来表现。

}{state === "ready" && !product && }
; +} + +function Metric({ label, value, tone }: { label: string; value?: number; tone: "green" | "gold" | "red" }) { const color = tone === "green" ? "text-emerald-600" : tone === "gold" ? "text-[var(--brand-gold)]" : "text-rose-600"; return

{label}

{value ?? "--"}{value !== undefined && "%"}

; } + +function NavLineChart({ points }: { points: HistoryPoint[] }) { + const chartPoints = useMemo(() => points.slice(-60), [points]); + if (!chartPoints.length) return
暂无历史净值数据
; + const values = chartPoints.map((point) => point.unit_nav); const min = Math.min(...values); const max = Math.max(...values); const range = Math.max(max - min, 0.000001); const coords = chartPoints.map((point, index) => { const x = (index / Math.max(chartPoints.length - 1, 1)) * 100; const y = 92 - ((point.unit_nav - min) / range) * 76; return `${x},${y}`; }).join(" "); + return
{chartPoints[0].nav_date}{chartPoints[chartPoints.length - 1].nav_date}
最低净值 {min.toFixed(4)}最高净值 {max.toFixed(4)}
; +} + +function Notice({ text }: { text: string }) { return
{text}
; } diff --git a/frontend/app/products/page.tsx b/frontend/app/products/page.tsx new file mode 100644 index 0000000..5aeacc6 --- /dev/null +++ b/frontend/app/products/page.tsx @@ -0,0 +1,92 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, ArrowRight, Search } from "lucide-react"; +import { useEffect, useState } from "react"; +import { PublicHeader } from "@/components/public-header"; +import { apiFetch } from "@/lib/api"; +import { normalizeRiskLevel, RISK_LEVELS, riskLevelDescription } from "@/lib/risk"; +import type { FundProduct, FundProductList } from "@/types/api"; + +export default function ProductsPage() { + const [items, setItems] = useState([]); + const [keyword, setKeyword] = useState(""); + const [riskLevel, setRiskLevel] = useState(""); + const [state, setState] = useState<"loading" | "ready" | "error">("loading"); + + useEffect(() => { + let cancelled = false; + + async function load() { + setState("loading"); + try { + const params = new URLSearchParams({ page: "1", page_size: "24" }); + if (keyword.trim()) params.set("keyword", keyword.trim()); + if (riskLevel) params.set("risk_level", riskLevel); + + let data = await apiFetch(`/products?${params.toString()}`); + if (riskLevel && data.items.length === 0) { + const fallbackParams = new URLSearchParams({ page: "1", page_size: "100" }); + if (keyword.trim()) fallbackParams.set("keyword", keyword.trim()); + const fallback = await apiFetch(`/products?${fallbackParams.toString()}`); + data = { + ...fallback, + items: fallback.items.filter((item) => normalizeRiskLevel(item.risk_level) === riskLevel).slice(0, 24), + }; + } + + if (!cancelled) { + setItems(data.items); + setState("ready"); + } + } catch { + if (!cancelled) setState("error"); + } + } + + void load(); + return () => { cancelled = true; }; + }, [keyword, riskLevel]); + + return ( +
+ +
+
+ 返回首页 +

Fund Marketplace

+

基金产品

+

基于真实产品数据,按基金类型、风险等级和投资目标了解更多选择。

+
+
+ +
+
+
+ + setKeyword(event.target.value)} placeholder="搜索基金名称或代码" className="w-full rounded-xl border border-slate-200 bg-white py-3 pl-10 pr-4 text-sm outline-none focus:border-[var(--brand-primary)]" /> +
+ +
+ +
+ {state === "loading" &&
{[1, 2, 3, 4, 5, 6].map((item) =>
)}
} + {state === "error" &&
基金数据暂时无法加载,请确认后端服务已启动后重试。
} + {state === "ready" && ( + <> +
基金产品风险等级基金类型最新净值净值日期
+ {items.map((item) => { + const level = normalizeRiskLevel(item.risk_level); + return

{item.product_name}

{item.product_code}

{level}{item.product_type}{item.nav ?? "--"}{item.nav_date ?? "暂无日期"}; + })} + {items.length === 0 &&
没有找到匹配的基金产品。
} + + )} +
+
+
+ ); +} diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx new file mode 100644 index 0000000..c6d5137 --- /dev/null +++ b/frontend/app/register/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, ArrowRight, UserPlus } from "lucide-react"; +import { FormEvent, useState } from "react"; +import { apiFetch } from "@/lib/api"; + +export default function RegisterPage() { + const [form, setForm] = useState({ username: "", password: "", phone: "", real_name: "" }); + const [message, setMessage] = useState(""); const [loading, setLoading] = useState(false); const [done, setDone] = useState(false); + async function handleSubmit(event: FormEvent) { event.preventDefault(); setMessage(""); setLoading(true); try { await apiFetch("/auth/register", { method: "POST", body: JSON.stringify(form) }); setDone(true); } catch (error) { setMessage(error instanceof Error ? error.message : "注册失败,请稍后重试"); } finally { setLoading(false); } } + const update = (key: keyof typeof form, value: string) => setForm((current) => ({ ...current, [key]: value })); + return
返回首页

创建华夏基金账户

注册后即可查看个人账户和基金持仓。

{done ?
注册成功,请前往登录。登录账户
:
{message &&

{message}

}
}

已有账号? 立即登录

; +} diff --git a/frontend/app/risk-assessment/page.tsx b/frontend/app/risk-assessment/page.tsx new file mode 100644 index 0000000..62fa1fc --- /dev/null +++ b/frontend/app/risk-assessment/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, CheckCircle2 } from "lucide-react"; +import { FormEvent, useState } from "react"; +import { apiFetch } from "@/lib/api"; + +const questions = ["您的投资目标是什么?", "您能接受多大程度的本金波动?", "您的投资期限通常是?", "您对基金投资的了解程度?", "如果短期亏损,您会如何处理?"]; +const options = ["稳健保值", "平衡增长", "积极增值", "长期财富积累"]; + +export default function RiskAssessmentPage() { + const [answers, setAnswers] = useState(Array(questions.length).fill("")); + const [message, setMessage] = useState(""); const [done, setDone] = useState(false); const [loading, setLoading] = useState(false); + async function submit(event: FormEvent) { event.preventDefault(); if (answers.some((item) => !item)) { setMessage("请完成全部测评问题"); return; } setLoading(true); setMessage(""); try { await apiFetch("/risk-assessment/submit", { method: "POST", body: JSON.stringify({ questionnaire_id: 1, answers: answers.map((option, index) => ({ question_no: index + 1, option })) }) }); setDone(true); } catch (error) { setMessage(error instanceof Error ? error.message : "测评提交失败"); } finally { setLoading(false); } } + return
返回用户中心

Risk Profile

风险承受能力测评

{done ?
测评已提交,系统将根据结果提供适当性提示。
:
{questions.map((question, index) =>
{index + 1}. {question}
{options.map((option) => )}
)}{message &&

{message}

}
}
; +} diff --git a/frontend/app/tools/knowledge/page.tsx b/frontend/app/tools/knowledge/page.tsx new file mode 100644 index 0000000..41d9b7e --- /dev/null +++ b/frontend/app/tools/knowledge/page.tsx @@ -0,0 +1,371 @@ +"use client"; + +import Link from "next/link"; +import { + ArrowLeft, + CheckCircle2, + FileText, + Loader2, + ScanEye, + Trash2, + Upload, +} from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { + Badge, + Button, + Card, + EmptyState, + ErrorState, + TextInput, + LoadingBlock, + Select, +} from "@/components/admin/ui"; +import { adminApi, errMessage } from "@/lib/admin-api"; +import { buildKnowledgeForm, knowledgeApi, type KnowledgeDocument, type UploadPreview } from "@/lib/knowledge-api"; +import { isAdmin } from "@/lib/roles"; + +/** 切分策略,与后端 KnowledgeUploadService 支持的策略保持一致。 */ +const STRATEGIES = [ + { value: "default", label: "默认(按段落递归切分)" }, + { value: "fixed", label: "固定长度切分" }, + { value: "sentence", label: "按句子切分" }, +]; + +export default function KnowledgePage() { + const [state, setState] = useState<"loading" | "ready" | "denied" | "error">("loading"); + const [items, setItems] = useState([]); + + const [file, setFile] = useState(null); + const [title, setTitle] = useState(""); + const [strategy, setStrategy] = useState("default"); + const [chunkSize, setChunkSize] = useState(""); + const [chunkOverlap, setChunkOverlap] = useState(""); + + const [preview, setPreview] = useState(null); + const [uploading, setUploading] = useState(false); + const [removing, setRemoving] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const load = useCallback(async () => { + try { + setItems(await knowledgeApi.list()); + } catch (err) { + setError(errMessage(err, "知识库加载失败")); + } + }, []); + + useEffect(() => { + let alive = true; + (async () => { + try { + const user = await adminApi.me(); + if (!alive) return; + if (!isAdmin(user)) { + setState("denied"); + return; + } + setState("ready"); + await load(); + } catch (err) { + if (!alive) return; + // 401 时 apiFetch 已跳转登录页,这里只兜底其余失败 + setError(errMessage(err, "请先登录管理员账号")); + setState("error"); + } + })(); + return () => { + alive = false; + }; + }, [load]); + + const resetUpload = () => { + setFile(null); + setTitle(""); + setChunkSize(""); + setChunkOverlap(""); + setPreview(null); + }; + + const runPreview = async () => { + if (!file || uploading) return; + setUploading(true); + setError(null); + setNotice(null); + try { + const result = await knowledgeApi.preview( + buildKnowledgeForm(file, { + strategy, + chunk_size: chunkSize ? Number(chunkSize) : null, + chunk_overlap: chunkOverlap ? Number(chunkOverlap) : null, + }) + ); + setPreview(result); + if (!title) setTitle(file.name); + } catch (err) { + setError(errMessage(err, "预览失败,请检查文件格式")); + } finally { + setUploading(false); + } + }; + + const runConfirm = async () => { + if (!file || uploading) return; + setUploading(true); + setError(null); + setNotice(null); + try { + await knowledgeApi.confirm( + buildKnowledgeForm(file, { + title: title || file.name, + doc_id: preview?.doc_id ? String(preview.doc_id) : undefined, + strategy, + chunk_size: chunkSize ? Number(chunkSize) : null, + chunk_overlap: chunkOverlap ? Number(chunkOverlap) : null, + }) + ); + setNotice(`《${title || file.name}》已入库`); + resetUpload(); + await load(); + } catch (err) { + setError(errMessage(err, "入库失败,请稍后重试")); + } finally { + setUploading(false); + } + }; + + const remove = async (doc: KnowledgeDocument) => { + const docId = doc.doc_id ?? doc.id ?? ""; + if (!docId) return; + if (!window.confirm(`确认删除《${doc.title ?? doc.filename ?? docId}》?该操作不可撤销。`)) { + return; + } + setRemoving(docId); + setError(null); + try { + await knowledgeApi.remove(docId); + setNotice("文档已删除"); + await load(); + } catch (err) { + setError(errMessage(err, "删除失败,请稍后重试")); + } finally { + setRemoving(null); + } + }; + + return ( +
+
+ + + 返回统一工作台 + + +
+

知识库管理

+

+ 上传投顾 / 客服 Agent 可检索的文档,入库前可先预览切分效果 +

+
+ + {state === "loading" && } + + {state === "denied" && ( +
+ 知识库管理仅对管理员开放。当前账号无操作权限,如有需要请联系系统管理员。 +
+ )} + + {state === "error" && location.reload()} />} + + {state === "ready" && ( + <> + +
+
+ + + {file ? file.name : "未选择文件"} + +
+ +
+ + +
+ + +
+
+ +
+ + + {file && ( + + )} +
+ + {preview && ( +
+
+ {preview.strategy || strategy} + {preview.chunk_size != null && 分片 {preview.chunk_size}} + {preview.chunk_overlap != null && 重叠 {preview.chunk_overlap}} + + 共切出 {preview.chunks?.length ?? 0} 段 + +
+
+ {(preview.chunks ?? []).slice(0, 20).map((chunk, index) => ( +

+ {typeof chunk === "string" ? chunk : JSON.stringify(chunk)} +

+ ))} + {(preview.chunks?.length ?? 0) > 20 && ( +

+ 仅展示前 20 段,共 {preview.chunks?.length} 段 +

+ )} +
+
+ )} +
+
+ + {error && } + {notice && ( +
+ {notice} +
+ )} + + + {items.length === 0 ? ( + + ) : ( +
+ {items.map((item) => { + const docId = item.doc_id ?? item.id ?? ""; + const busy = removing === docId; + return ( +
+
+ +
+

+ {item.title ?? item.filename ?? docId} +

+

+ {item.collection_name && {item.collection_name}} + {item.chunk_count != null && {item.chunk_count} 段} + {item.create_time && {item.create_time}} + {docId} +

+
+
+ +
+ ); + })} +
+ )} +
+ + )} +
+
+ ); +} diff --git a/frontend/app/tools/nl2sql/page.tsx b/frontend/app/tools/nl2sql/page.tsx new file mode 100644 index 0000000..fe3b77b --- /dev/null +++ b/frontend/app/tools/nl2sql/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +/** 问数入口已并入统一工作台的「智能问数」分区,旧路径重定向过去。 */ +export default function Nl2SqlPage() { + redirect("/admin?section=nl2sql"); +} diff --git a/frontend/components/admin/assistant-launcher.tsx b/frontend/components/admin/assistant-launcher.tsx new file mode 100644 index 0000000..da94bad --- /dev/null +++ b/frontend/components/admin/assistant-launcher.tsx @@ -0,0 +1,617 @@ +"use client"; + +import { + AlertCircle, + BarChart3, + Bot, + Loader2, + MessageSquareQuote, + Minus, + RefreshCw, + Search, + Send, + Square, + Trash2, + User, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Badge, + Button, + EmptyState, + Modal, + Select, + cn, + formatTime, + statusTone, + useToast, +} from "@/components/admin/ui"; +import { adminApi, errMessage } from "@/lib/admin-api"; +import { assistantApi, newSessionId, type AgentDraft } from "@/lib/assistant-api"; +import type { Customer } from "@/lib/admin-api"; + +interface ChatMessage { + id: string; + role: "user" | "assistant"; + content: string; + intent?: string; + draftId?: string; + error?: boolean; +} + +const QUICK_TOOLS = [ + { key: "data-query", label: "查客户数据", icon: Search, hint: "自然语言查询授权范围内的客户数据" }, + { key: "fund-analysis", label: "基金分析", icon: BarChart3, hint: "问题里需带上基金代码,如 000001" }, + { key: "talk-script", label: "生成话术", icon: MessageSquareQuote, hint: "按场景生成合规话术草稿" }, + { key: "rebalance", label: "调仓再平衡", icon: RefreshCw, hint: "异步生成调仓建议草稿" }, +] as const; + +type ToolKey = (typeof QUICK_TOOLS)[number]["key"]; + +/** + * 投顾助手:右下角常驻入口。 + * 悬浮球打开抽屉后可直接对话、调用 Agent 工具、管理草稿; + * 「结束会话」会中止生成并重置 session,历史不再延续到下一次对话。 + */ +export function AssistantLauncher() { + const toast = useToast(); + const [open, setOpen] = useState(false); + const [tab, setTab] = useState<"chat" | "drafts">("chat"); + + const [sessionId, setSessionId] = useState(() => newSessionId()); + const [customers, setCustomers] = useState([]); + const [customerId, setCustomerId] = useState(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [streaming, setStreaming] = useState(false); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(null); + + const [drafts, setDrafts] = useState([]); + const [draftsLoading, setDraftsLoading] = useState(false); + const [activeDraft, setActiveDraft] = useState(null); + const [draftDraft, setDraftDraft] = useState(""); + const [saving, setSaving] = useState(false); + + const abortRef = useRef(null); + const scrollRef = useRef(null); + const [elapsed, setElapsed] = useState(0); + + /* 后端是整包输出,等待期间给个计时,避免看起来像卡死 */ + useEffect(() => { + if (!streaming) { + setElapsed(0); + return; + } + const timer = setInterval(() => setElapsed((value) => value + 1), 1000); + return () => clearInterval(timer); + }, [streaming]); + + const sessionTag = useMemo(() => sessionId.slice(-6), [sessionId]); + + /* 客户列表只用于会话上下文选择,失败不阻断助手 */ + useEffect(() => { + void (async () => { + try { + const page = await adminApi.customers({ page: 1, page_size: 100 }); + setCustomers(page.items ?? []); + } catch { + /* 静默 */ + } + })(); + }, []); + + /* 会话 ID 变化时恢复该会话的历史;新会话为空属正常,不报错。 */ + useEffect(() => { + let alive = true; + void (async () => { + setHistoryLoading(true); + setHistoryError(null); + try { + const history = await assistantApi.sessionHistory(sessionId); + if (!alive) return; + setMessages( + (history ?? []) + .filter((item) => item?.content) + .map((item, index) => ({ + id: `h-${index}`, + role: item.role === "user" ? "user" : "assistant", + content: String(item.content ?? ""), + })) + ); + } catch (error) { + if (!alive) return; + setHistoryError(errMessage(error, "会话历史加载失败")); + } finally { + if (alive) setHistoryLoading(false); + } + })(); + return () => { + alive = false; + }; + }, [sessionId]); + + const loadDrafts = useCallback(async () => { + setDraftsLoading(true); + try { + const page = await assistantApi.drafts({ page: 1, page_size: 20 }); + setDrafts(page.items ?? []); + } catch { + /* 草稿列表加载失败不打断对话 */ + } finally { + setDraftsLoading(false); + } + }, []); + + useEffect(() => { + if (open) void loadDrafts(); + }, [open, loadDrafts]); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); + }, [messages, tab]); + + useEffect(() => () => abortRef.current?.abort(), []); + + const pushPair = useCallback((query: string, answer: string, intent?: string) => { + setMessages((prev) => [ + ...prev, + { id: `u-${Date.now()}`, role: "user", content: query }, + { id: `a-${Date.now()}`, role: "assistant", content: answer, intent }, + ]); + }, []); + + const send = useCallback( + async (raw: string) => { + const query = raw.trim(); + if (!query || streaming) return; + + const replyId = `a-${Date.now()}`; + setMessages((prev) => [ + ...prev, + { id: `u-${Date.now()}`, role: "user", content: query }, + { id: replyId, role: "assistant", content: "" }, + ]); + setInput(""); + setStreaming(true); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const result = await assistantApi.chatOnce( + { + query, + session_id: sessionId, + scope: customerId ? "customer" : "advisor", + customer_id: customerId, + }, + controller.signal + ); + + const content = result.error || result.text || "助手本次未返回内容。"; + setMessages((prev) => + prev.map((item) => + item.id === replyId + ? { + ...item, + content, + intent: result.intent, + draftId: result.draftId, + error: !!result.error, + } + : item + ) + ); + if (result.draftId) void loadDrafts(); + } catch (error) { + const aborted = (error as Error)?.name === "AbortError"; + if (aborted) { + /* 取消提问:撤掉占位的助手回复,用户消息保留 */ + setMessages((prev) => prev.filter((item) => item.id !== replyId)); + } else { + setMessages((prev) => + prev.map((item) => + item.id === replyId + ? { ...item, content: errMessage(error, "助手请求失败,请稍后重试"), error: true } + : item + ) + ); + } + } finally { + setStreaming(false); + abortRef.current = null; + } + }, + [customerId, loadDrafts, sessionId, streaming] + ); + + const runTool = useCallback( + async (tool: ToolKey) => { + const query = input.trim(); + if (!query) { + toast("请先在输入框描述你的问题", "error"); + return; + } + if (tool === "rebalance" && !customerId) { + toast("调仓需要指定客户,请先在上方选择客户", "error"); + return; + } + + setStreaming(true); + try { + if (tool === "data-query") { + const result = await assistantApi.dataQuery({ query, session_id: sessionId }); + pushPair(query, result.answer ?? result.summary ?? "查询完成,无文本摘要。", "data_query"); + } else if (tool === "fund-analysis") { + const result = await assistantApi.fundAnalysis(query); + pushPair( + query, + result.analysis_text ?? + (Array.isArray(result.items) ? JSON.stringify(result.items, null, 2) : "未返回结果"), + "fund_analysis" + ); + } else if (tool === "talk-script") { + const result = await assistantApi.generateTalkScript(query); + pushPair(query, result.script ?? result.content ?? "未生成话术。", "dialogue-script"); + } else { + const result = await assistantApi.runRebalance(query); + toast( + result.accepted ? "调仓任务已受理,草稿稍后生成" : "调仓任务未受理", + result.accepted ? "success" : "error" + ); + void loadDrafts(); + } + setInput(""); + } catch (error) { + toast(errMessage(error, "工具调用失败"), "error"); + } finally { + setStreaming(false); + } + }, + [customerId, input, loadDrafts, pushPair, sessionId, toast] + ); + + /** 结束会话:中止生成、清空上下文并启用新的 session_id。 */ + const endSession = useCallback(() => { + abortRef.current?.abort(); + setMessages([]); + setInput(""); + setStreaming(false); + setSessionId(newSessionId()); + toast("会话已关闭,已开启新的对话上下文", "success"); + }, [toast]); + + const openDraft = useCallback( + async (draftId: string) => { + try { + const draft = await assistantApi.draft(draftId); + setActiveDraft(draft); + setDraftDraft(draft.content ?? ""); + } catch (error) { + toast(errMessage(error, "草稿详情加载失败"), "error"); + } + }, + [toast] + ); + + const saveDraft = useCallback(async () => { + const draftId = activeDraft?.draft_id ?? activeDraft?.id; + if (!draftId) return; + setSaving(true); + try { + await assistantApi.saveDraft(draftId, { content: draftDraft }); + toast("草稿已保存", "success"); + setActiveDraft(null); + await loadDrafts(); + } catch (error) { + toast(errMessage(error, "保存失败"), "error"); + } finally { + setSaving(false); + } + }, [activeDraft, draftDraft, loadDrafts, toast]); + + const discardDraft = useCallback( + async (draftId: string) => { + try { + await assistantApi.operateDraft(draftId, "discard"); + toast("草稿已废弃", "success"); + await loadDrafts(); + } catch (error) { + toast(errMessage(error, "废弃失败"), "error"); + } + }, + [loadDrafts, toast] + ); + + return ( + <> + {!open && ( + + )} + + {open && ( + <> +
setOpen(false)} + aria-hidden + /> +