200 lines
6.9 KiB
TypeScript
200 lines
6.9 KiB
TypeScript
"use client";
|
||||
|
|
|
|||
|
|
import { FileSignature, Save, Send, Trash2 } from "lucide-react";
|
|||
|
|
import { useCallback, useEffect, useState } from "react";
|
|||
|
|
import { adminApi, errMessage, type Draft } from "@/lib/admin-api";
|
|||
|
|
import {
|
|||
|
|
Badge,
|
|||
|
|
Button,
|
|||
|
|
Card,
|
|||
|
|
EmptyState,
|
|||
|
|
ErrorState,
|
|||
|
|
LoadingBlock,
|
|||
|
|
Pagination,
|
|||
|
|
TextInput,
|
|||
|
|
cn,
|
|||
|
|
formatTime,
|
|||
|
|
statusTone,
|
|||
|
|
useToast,
|
|||
|
|
} from "@/components/admin/ui";
|
|||
|
|
|
|||
|
|
function draftKey(draft: Draft) {
|
|||
|
|
return String(draft.draft_id ?? draft.id ?? "");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function DraftsSection() {
|
|||
|
|
const toast = useToast();
|
|||
|
|
const [items, setItems] = useState<Draft[]>([]);
|
|||
|
|
const [total, setTotal] = useState(0);
|
|||
|
|
const [totalPages, setTotalPages] = useState(0);
|
|||
|
|
const [page, setPage] = useState(1);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [error, setError] = useState("");
|
|||
|
|
|
|||
|
|
const [activeKey, setActiveKey] = useState("");
|
|||
|
|
const [title, setTitle] = useState("");
|
|||
|
|
const [content, setContent] = useState("");
|
|||
|
|
const [dirty, setDirty] = useState(false);
|
|||
|
|
const [busy, setBusy] = useState(false);
|
|||
|
|
|
|||
|
|
const load = useCallback(async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
setError("");
|
|||
|
|
try {
|
|||
|
|
const result = await adminApi.drafts({ page, page_size: 20 });
|
|||
|
|
setItems(result.items ?? []);
|
|||
|
|
setTotal(result.total ?? 0);
|
|||
|
|
setTotalPages(result.total_pages ?? 0);
|
|||
|
|
} catch (e) {
|
|||
|
|
setError(errMessage(e, "草稿列表加载失败"));
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [page]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
void load();
|
|||
|
|
}, [load]);
|
|||
|
|
|
|||
|
|
async function choose(draft: Draft) {
|
|||
|
|
if (dirty && !window.confirm("当前草稿有未保存的修改,确认切换?")) return;
|
|||
|
|
setBusy(true);
|
|||
|
|
try {
|
|||
|
|
const detail = await adminApi.draft(draftKey(draft));
|
|||
|
|
setActiveKey(draftKey(draft));
|
|||
|
|
setTitle(detail.title ?? "");
|
|||
|
|
setContent(detail.content ?? "");
|
|||
|
|
setDirty(false);
|
|||
|
|
} catch (e) {
|
|||
|
|
toast(errMessage(e, "草稿详情加载失败"), "error");
|
|||
|
|
} finally {
|
|||
|
|
setBusy(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function run(action: "save" | "send" | "discard") {
|
|||
|
|
if (!activeKey) return;
|
|||
|
|
if (action === "send" && !window.confirm("确认发送该草稿?发送后将进入终审并触达客户。")) return;
|
|||
|
|
if (action === "discard" && !window.confirm("确认废弃该草稿?废弃后不可恢复。")) return;
|
|||
|
|
setBusy(true);
|
|||
|
|
try {
|
|||
|
|
if (action === "save") await adminApi.saveDraft(activeKey, { title, content: content });
|
|||
|
|
if (action === "send") await adminApi.sendDraft(activeKey);
|
|||
|
|
if (action === "discard") await adminApi.discardDraft(activeKey);
|
|||
|
|
toast(action === "save" ? "草稿已保存" : action === "send" ? "草稿已发送" : "草稿已废弃");
|
|||
|
|
setDirty(false);
|
|||
|
|
if (action !== "save") {
|
|||
|
|
setActiveKey("");
|
|||
|
|
setTitle("");
|
|||
|
|
setContent("");
|
|||
|
|
}
|
|||
|
|
await load();
|
|||
|
|
} catch (e) {
|
|||
|
|
toast(errMessage(e, "操作失败"), "error");
|
|||
|
|
} finally {
|
|||
|
|
setBusy(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
|
|||
|
|
<Card title="草稿列表" description="由投顾 Agent 生成,可编辑后发送" bodyClassName="p-0">
|
|||
|
|
{loading ? (
|
|||
|
|
<LoadingBlock />
|
|||
|
|
) : error ? (
|
|||
|
|
<ErrorState message={error} onRetry={() => void load()} />
|
|||
|
|
) : items.length === 0 ? (
|
|||
|
|
<EmptyState text="暂无草稿" />
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<ul className="max-h-[520px] divide-y divide-slate-100 overflow-y-auto">
|
|||
|
|
{items.map((draft) => (
|
|||
|
|
<li key={draftKey(draft) || draft.title}>
|
|||
|
|
<button
|
|||
|
|
onClick={() => void choose(draft)}
|
|||
|
|
className={cn(
|
|||
|
|
"w-full px-4 py-3 text-left text-sm transition-colors",
|
|||
|
|
draftKey(draft) === activeKey
|
|||
|
|
? "bg-indigo-50 text-indigo-700"
|
|||
|
|
: "text-slate-600 hover:bg-slate-50"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center justify-between gap-2">
|
|||
|
|
<span className="truncate font-medium">{draft.title ?? "未命名草稿"}</span>
|
|||
|
|
<Badge tone={statusTone(draft.status)}>{draft.status ?? "草稿"}</Badge>
|
|||
|
|
</div>
|
|||
|
|
<p className="mt-1 truncate text-xs text-slate-400">
|
|||
|
|
{draft.customer_id ? `客户 #${draft.customer_id} · ` : ""}
|
|||
|
|
{formatTime(draft.update_time ?? draft.create_time)}
|
|||
|
|
</p>
|
|||
|
|
</button>
|
|||
|
|
</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
<Pagination page={page} totalPages={totalPages} total={total} onChange={setPage} />
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
<Card
|
|||
|
|
title={activeKey ? "编辑草稿" : "选择草稿"}
|
|||
|
|
description={activeKey ? `草稿 ID:${activeKey}` : "从左侧列表选择一份草稿进行编辑"}
|
|||
|
|
actions={
|
|||
|
|
activeKey ? (
|
|||
|
|
<>
|
|||
|
|
<Button size="sm" variant="primary" loading={busy} onClick={() => void run("save")}>
|
|||
|
|
<Save className="h-3.5 w-3.5" />
|
|||
|
|
保存
|
|||
|
|
</Button>
|
|||
|
|
<Button size="sm" loading={busy} onClick={() => void run("send")}>
|
|||
|
|
<Send className="h-3.5 w-3.5" />
|
|||
|
|
发送
|
|||
|
|
</Button>
|
|||
|
|
<Button size="sm" variant="danger" loading={busy} onClick={() => void run("discard")}>
|
|||
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|||
|
|
废弃
|
|||
|
|
</Button>
|
|||
|
|
</>
|
|||
|
|
) : undefined
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
{!activeKey ? (
|
|||
|
|
<EmptyState text="暂无选中的草稿" />
|
|||
|
|
) : (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="mb-1 block text-xs text-slate-500">草稿标题</label>
|
|||
|
|
<TextInput
|
|||
|
|
value={title}
|
|||
|
|
onChange={(event) => {
|
|||
|
|
setTitle(event.target.value);
|
|||
|
|
setDirty(true);
|
|||
|
|
}}
|
|||
|
|
placeholder="请输入草稿标题"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="mb-1 block text-xs text-slate-500">草稿内容</label>
|
|||
|
|
<textarea
|
|||
|
|
value={content}
|
|||
|
|
onChange={(event) => {
|
|||
|
|
setContent(event.target.value);
|
|||
|
|
setDirty(true);
|
|||
|
|
}}
|
|||
|
|
className="min-h-[360px] w-full rounded-lg border border-slate-200 px-3 py-2 text-sm leading-6 text-slate-700 transition-colors focus:border-indigo-400 focus:outline-none"
|
|||
|
|
placeholder="请输入建议内容"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
{dirty && (
|
|||
|
|
<p className="flex items-center gap-2 text-xs text-amber-600">
|
|||
|
|
<FileSignature className="h-3.5 w-3.5" />
|
|||
|
|
有未保存的修改
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</Card>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|