feat:前端修改(我认为ok)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user