652 lines
25 KiB
TypeScript
652 lines
25 KiB
TypeScript
"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");
|
||
|
||
/* 会话在「打开抽屉」时才建立(见 openAssistant),关闭时丢弃,不在挂载时就占用上下文 */
|
||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||
const [customerId, setCustomerId] = useState<number | null>(null);
|
||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||
const [input, setInput] = useState("");
|
||
const [streaming, setStreaming] = useState(false);
|
||
const [historyLoading, setHistoryLoading] = useState(false);
|
||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||
|
||
const [drafts, setDrafts] = useState<AgentDraft[]>([]);
|
||
const [draftsLoading, setDraftsLoading] = useState(false);
|
||
const [activeDraft, setActiveDraft] = useState<AgentDraft | null>(null);
|
||
const [draftDraft, setDraftDraft] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
const abortRef = useRef<AbortController | null>(null);
|
||
const scrollRef = useRef<HTMLDivElement | null>(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 ? sessionId.slice(-6) : "--------"), [sessionId]);
|
||
|
||
/* 客户列表只用于会话上下文选择,失败不阻断助手 */
|
||
useEffect(() => {
|
||
void (async () => {
|
||
try {
|
||
const page = await adminApi.customers({ page: 1, page_size: 100 });
|
||
setCustomers(page.items ?? []);
|
||
} catch {
|
||
/* 静默 */
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
/*
|
||
* 会话握手:打开抽屉(sessionId 就绪)后拉一次该会话的记录。
|
||
* 投顾 Agent 没有独立的 session/create,后端按 (advisor_id, session_id) 惰性建立上下文,
|
||
* 所以这个请求就是「打开助手时发出的会话请求」;关闭抽屉会把 sessionId 置空、丢弃上下文。
|
||
*/
|
||
useEffect(() => {
|
||
if (!sessionId) {
|
||
setMessages([]);
|
||
setHistoryError(null);
|
||
setHistoryLoading(false);
|
||
return;
|
||
}
|
||
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 ?? undefined,
|
||
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 ?? undefined });
|
||
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]
|
||
);
|
||
|
||
/**
|
||
* 打开抽屉 = 建立会话。
|
||
* 投顾 Agent 后端只有 chat/stream 与 session/{id}/history,没有独立的 session/create,
|
||
* 所以这里生成会话键,由上面的 useEffect 调 history 完成握手(后端按需建立上下文)。
|
||
*/
|
||
const openAssistant = useCallback(() => {
|
||
setOpen(true);
|
||
setSessionId((current) => current ?? newSessionId());
|
||
}, []);
|
||
|
||
/**
|
||
* 关闭抽屉 = 结束会话:中止生成、清空消息并丢弃上下文。
|
||
* 投顾侧后端未提供 session/end,因此结束只能在前端完成(下次打开是新的 session_id)。
|
||
*/
|
||
const closeAssistant = useCallback(() => {
|
||
abortRef.current?.abort();
|
||
setOpen(false);
|
||
setMessages([]);
|
||
setInput("");
|
||
setStreaming(false);
|
||
setSessionId(null);
|
||
}, []);
|
||
|
||
/** 抽屉内「结束会话」:不等关闭,立即换一个全新会话并重新握手。 */
|
||
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 && (
|
||
<button
|
||
onClick={openAssistant}
|
||
aria-label="打开投顾助手"
|
||
className="fixed bottom-6 right-6 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-indigo-600 text-white shadow-lg shadow-indigo-600/30 transition-transform hover:scale-105 hover:bg-indigo-700"
|
||
>
|
||
<Bot className="h-6 w-6" />
|
||
{drafts.length > 0 && (
|
||
<span className="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium text-white">
|
||
{drafts.length > 99 ? "99+" : drafts.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)}
|
||
|
||
{open && (
|
||
<>
|
||
<div
|
||
className="fixed inset-0 z-40 bg-slate-900/10"
|
||
onClick={closeAssistant}
|
||
aria-hidden
|
||
/>
|
||
<aside className="fixed inset-y-0 right-0 z-50 flex w-full max-w-[560px] flex-col bg-white shadow-2xl">
|
||
<header className="flex items-center gap-3 border-b border-slate-200 px-5 py-4">
|
||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600">
|
||
<Bot className="h-5 w-5" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-sm font-semibold text-slate-900">投顾助手</p>
|
||
<p className="flex items-center gap-1.5 text-xs text-slate-400">
|
||
会话 #{sessionTag}
|
||
{streaming && (
|
||
<span className="flex items-center gap-1 text-indigo-500">
|
||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-indigo-500" />
|
||
处理中
|
||
</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Button size="sm" variant="ghost" onClick={endSession} title="结束当前会话并开启新会话">
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
结束会话
|
||
</Button>
|
||
<button
|
||
onClick={closeAssistant}
|
||
aria-label="收起助手"
|
||
className="rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||
>
|
||
<Minus className="h-4 w-4" />
|
||
</button>
|
||
</header>
|
||
|
||
<div className="flex items-center gap-3 border-b border-slate-100 px-5 py-3">
|
||
<Select
|
||
value={customerId ? String(customerId) : ""}
|
||
onChange={(event) =>
|
||
setCustomerId(event.target.value ? Number(event.target.value) : null)
|
||
}
|
||
className="flex-1"
|
||
>
|
||
<option value="">未指定客户(通用问答)</option>
|
||
{customers.map((customer) => (
|
||
<option key={customer.customer_id} value={customer.customer_id}>
|
||
#{customer.customer_id} {customer.real_name ?? "未填写姓名"}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
|
||
<div className="flex rounded-lg bg-slate-100 p-0.5">
|
||
{(["chat", "drafts"] as const).map((key) => (
|
||
<button
|
||
key={key}
|
||
onClick={() => setTab(key)}
|
||
className={cn(
|
||
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
||
tab === key ? "bg-white text-slate-900 shadow-sm" : "text-slate-500"
|
||
)}
|
||
>
|
||
{key === "chat" ? "对话" : `草稿${drafts.length ? ` (${drafts.length})` : ""}`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{tab === "chat" ? (
|
||
<>
|
||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||
{historyLoading && messages.length === 0 && (
|
||
<p className="py-8 text-center text-sm text-slate-400">正在建立会话...</p>
|
||
)}
|
||
{historyError && (
|
||
<p className="rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600">{historyError}</p>
|
||
)}
|
||
|
||
{!historyLoading && messages.length === 0 && (
|
||
<div className="flex h-full flex-col items-center justify-center px-6 text-center">
|
||
<Bot className="mb-3 h-9 w-9 text-slate-300" />
|
||
<p className="text-sm text-slate-500">
|
||
我可以帮你查询客户数据、分析基金、生成沟通话术和调仓建议。
|
||
</p>
|
||
<p className="mt-2 text-xs text-slate-400">
|
||
指定客户后,助手只能读取你名下已授权的客户数据。
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{messages.map((message) => (
|
||
<div
|
||
key={message.id}
|
||
className={cn("flex gap-2.5", message.role === "user" && "flex-row-reverse")}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
|
||
message.role === "user"
|
||
? "bg-indigo-100 text-indigo-600"
|
||
: "bg-slate-100 text-slate-500"
|
||
)}
|
||
>
|
||
{message.role === "user" ? (
|
||
<User className="h-3.5 w-3.5" />
|
||
) : (
|
||
<Bot className="h-3.5 w-3.5" />
|
||
)}
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"max-w-[80%] rounded-xl px-3.5 py-2.5 text-sm leading-6",
|
||
message.role === "user"
|
||
? "bg-indigo-600 text-white"
|
||
: message.error
|
||
? "bg-red-50 text-red-600"
|
||
: "bg-slate-100 text-slate-700"
|
||
)}
|
||
>
|
||
{message.content ? (
|
||
<p className="whitespace-pre-wrap break-words">{message.content}</p>
|
||
) : (
|
||
<p className="flex items-center gap-1.5 text-slate-400">
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
助手正在思考{elapsed > 0 ? ` ${elapsed}s` : ""}
|
||
</p>
|
||
)}
|
||
{message.intent && (
|
||
<p className="mt-1.5 text-xs opacity-70">意图:{message.intent}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="space-y-2.5 border-t border-slate-100 px-5 py-3.5">
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{QUICK_TOOLS.map((tool) => (
|
||
<button
|
||
key={tool.key}
|
||
onClick={() => void runTool(tool.key)}
|
||
disabled={streaming}
|
||
title={tool.hint}
|
||
className="flex items-center gap-1 rounded-full border border-slate-200 px-2.5 py-1 text-xs text-slate-500 transition-colors hover:border-indigo-300 hover:text-indigo-600 disabled:opacity-50"
|
||
>
|
||
<tool.icon className="h-3 w-3" />
|
||
{tool.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex items-end gap-2">
|
||
<textarea
|
||
value={input}
|
||
onChange={(event) => setInput(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" && !event.shiftKey) {
|
||
event.preventDefault();
|
||
void send(input);
|
||
}
|
||
}}
|
||
rows={2}
|
||
placeholder="描述你的问题,回车发送(Shift+Enter 换行)"
|
||
className="max-h-32 flex-1 resize-none rounded-lg border border-slate-200 px-3 py-2 text-sm leading-6 text-slate-700 focus:border-indigo-400 focus:outline-none"
|
||
/>
|
||
{streaming ? (
|
||
<Button
|
||
variant="danger"
|
||
onClick={() => abortRef.current?.abort()}
|
||
aria-label="取消本次提问"
|
||
title="取消本次提问"
|
||
>
|
||
<Square className="h-4 w-4" />
|
||
</Button>
|
||
) : (
|
||
<Button variant="primary" disabled={!input.trim()} onClick={() => void send(input)}>
|
||
<Send className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="flex-1 overflow-y-auto">
|
||
<div className="flex items-center justify-between px-5 pt-4">
|
||
<p className="text-xs text-slate-400">助手生成的建议草稿</p>
|
||
<Button size="sm" variant="ghost" onClick={() => void loadDrafts()} disabled={draftsLoading}>
|
||
<RefreshCw className={cn("h-3.5 w-3.5", draftsLoading && "animate-spin")} />
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
|
||
{drafts.length === 0 ? (
|
||
<EmptyState text="暂无 Agent 草稿" />
|
||
) : (
|
||
<ul className="mt-2 divide-y divide-slate-100">
|
||
{drafts.map((draft) => {
|
||
const id = draft.draft_id ?? draft.id ?? "";
|
||
return (
|
||
<li key={id} className="px-5 py-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<p className="truncate text-sm font-medium text-slate-800">
|
||
{draft.title || "未命名草稿"}
|
||
</p>
|
||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">
|
||
{draft.content || "(无正文)"}
|
||
</p>
|
||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||
{draft.intent && <Badge tone="info">{draft.intent}</Badge>}
|
||
{draft.status && (
|
||
<Badge tone={statusTone(draft.status)}>{draft.status}</Badge>
|
||
)}
|
||
<span className="text-xs text-slate-400">
|
||
{formatTime(draft.update_time ?? draft.create_time)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 gap-1">
|
||
<Button size="sm" variant="ghost" onClick={() => void openDraft(id)}>
|
||
编辑
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => void discardDraft(id)}
|
||
aria-label="废弃草稿"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5 text-red-500" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
</aside>
|
||
</>
|
||
)}
|
||
|
||
<Modal
|
||
open={!!activeDraft}
|
||
title="编辑草稿"
|
||
onClose={() => setActiveDraft(null)}
|
||
footer={
|
||
<>
|
||
<Button onClick={() => setActiveDraft(null)}>取消</Button>
|
||
<Button variant="primary" loading={saving} onClick={() => void saveDraft()}>
|
||
保存
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<p className="mb-3 text-xs text-slate-500">
|
||
草稿 ID:{activeDraft?.draft_id ?? activeDraft?.id ?? "--"}
|
||
</p>
|
||
<textarea
|
||
value={draftDraft}
|
||
onChange={(event) => setDraftDraft(event.target.value)}
|
||
rows={14}
|
||
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm leading-6 text-slate-700 focus:border-indigo-400 focus:outline-none"
|
||
/>
|
||
<p className="mt-3 flex items-start gap-2 text-xs text-amber-600">
|
||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||
保存后会按客户风险等级做合规校验,未通过的修改会被后端拒绝。
|
||
</p>
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|