feat:前端修改(我认为ok)

This commit is contained in:
2026-09-14 20:31:06 +08:00
parent 721cc96419
commit 7b6b2a7382
57 changed files with 9558 additions and 0 deletions
@@ -0,0 +1,229 @@
"use client";
import { MessageSquareQuote, PhoneCall } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { adminApi, errMessage, type Customer, type TalkTemplate, type Visit } from "@/lib/admin-api";
import {
Badge,
Button,
Card,
EmptyState,
ErrorState,
LoadingBlock,
Pagination,
Select,
TextInput,
formatTime,
useToast,
} from "@/components/admin/ui";
const VISIT_TYPES = ["电话回访", "线上沟通", "线下拜访"];
export function VisitsSection() {
const toast = useToast();
const [customers, setCustomers] = useState<Customer[]>([]);
const [items, setItems] = useState<Visit[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [page, setPage] = useState(1);
const [templates, setTemplates] = useState<TalkTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [customerId, setCustomerId] = useState("");
const [visitType, setVisitType] = useState(VISIT_TYPES[0]);
const [visitTime, setVisitTime] = useState("");
const [summary, setSummary] = useState("");
const [editingId, setEditingId] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const result = await adminApi.visits({ page, page_size: 10 });
setItems(result.items ?? []);
setTotal(result.total ?? 0);
setTotalPages(result.total_pages ?? 0);
} catch (e) {
setError(errMessage(e, "回访记录加载失败"));
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
void (async () => {
try {
const [customerPage, templateList] = await Promise.all([
adminApi.customers({ page: 1, page_size: 100 }),
adminApi.talkTemplates().catch(() => []),
]);
setCustomers(customerPage.items ?? []);
setTemplates(templateList ?? []);
if (!customerId && customerPage.items?.length) {
setCustomerId(String(customerPage.items[0].customer_id));
}
} catch {
/* 客户列表失败时用户仍可手动选择 */
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
void load();
}, [load]);
function resetForm() {
setEditingId(null);
setVisitType(VISIT_TYPES[0]);
setVisitTime("");
setSummary("");
}
async function submit() {
if (!customerId) {
toast("请先选择客户", "error");
return;
}
if (!visitTime) {
toast("请选择回访时间", "error");
return;
}
setBusy(true);
try {
const body = {
customer_id: Number(customerId),
visit_type: visitType,
visit_time: new Date(visitTime).toISOString(),
summary: summary || undefined,
};
if (editingId) await adminApi.updateVisit(editingId, body);
else await adminApi.createVisit(body);
toast(editingId ? "回访已更新" : "回访已新增");
resetForm();
await load();
} catch (e) {
toast(errMessage(e, "保存失败"), "error");
} finally {
setBusy(false);
}
}
return (
<div className="grid gap-4 lg:grid-cols-2">
<Card title={editingId ? "编辑回访" : "新增回访"} description="人工回访仅留痕,不触发客户记忆更新">
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs text-slate-500">客户</label>
<Select value={customerId} onChange={(event) => setCustomerId(event.target.value)} className="w-full">
<option value="">请选择客户</option>
{customers.map((customer) => (
<option key={customer.customer_id} value={customer.customer_id}>
{customer.real_name ?? "匿名客户"}(#{customer.customer_id})
</option>
))}
</Select>
</div>
<div>
<label className="mb-1 block text-xs text-slate-500">回访类型</label>
<Select value={visitType} onChange={(event) => setVisitType(event.target.value)} className="w-full">
{VISIT_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</Select>
</div>
<div>
<label className="mb-1 block text-xs text-slate-500">回访时间</label>
<TextInput type="datetime-local" value={visitTime} onChange={(event) => setVisitTime(event.target.value)} />
</div>
<div>
<label className="mb-1 block text-xs text-slate-500">回访摘要</label>
<textarea
value={summary}
onChange={(event) => setSummary(event.target.value)}
className="min-h-32 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>
<div className="flex gap-3">
<Button variant="primary" loading={busy} onClick={() => void submit()}>
{editingId ? "保存修改" : "保存回访"}
</Button>
{editingId && (
<Button onClick={resetForm} disabled={busy}>
取消编辑
</Button>
)}
</div>
</div>
</Card>
<div className="space-y-4">
<Card title="历史回访" description={`共 ${total} 条记录`} bodyClassName="p-0">
{loading ? (
<LoadingBlock />
) : error ? (
<ErrorState message={error} onRetry={() => void load()} />
) : items.length === 0 ? (
<EmptyState text="暂无回访记录" />
) : (
<>
<ul className="max-h-[420px] divide-y divide-slate-100 overflow-y-auto">
{items.map((visit) => (
<li key={visit.id} className="px-5 py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="flex items-center gap-2 text-sm font-medium text-slate-900">
<PhoneCall className="h-3.5 w-3.5 text-indigo-500" />
{visit.visit_type}
<Badge tone="neutral">客户 #{visit.customer_id}</Badge>
</p>
<p className="mt-1 text-xs text-slate-400">{formatTime(visit.visit_time)}</p>
<p className="mt-2 text-sm leading-6 text-slate-600">{visit.summary ?? "暂无摘要"}</p>
</div>
<Button
size="sm"
onClick={() => {
setEditingId(visit.id);
setCustomerId(String(visit.customer_id));
setVisitType(visit.visit_type ?? VISIT_TYPES[0]);
setVisitTime(visit.visit_time?.slice(0, 16) ?? "");
setSummary(visit.summary ?? "");
}}
>
编辑
</Button>
</div>
</li>
))}
</ul>
<Pagination page={page} totalPages={totalPages} total={total} onChange={setPage} />
</>
)}
</Card>
<Card title="合规话术库" description="仅作参考,正式发送走草稿流程">
{templates.length === 0 ? (
<EmptyState text="暂无话术模板" />
) : (
<ul className="space-y-3">
{templates.map((template) => (
<li key={template.scene} className="rounded-lg bg-slate-50 p-3">
<p className="flex items-center gap-2 text-sm font-medium text-slate-900">
<MessageSquareQuote className="h-3.5 w-3.5 text-indigo-500" />
{template.scene}
</p>
<p className="mt-1 text-xs leading-6 text-slate-600">{template.content}</p>
</li>
))}
</ul>
)}
</Card>
</div>
</div>
);
}