feat:前端修改(我认为ok)
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, PieChart, Search, Users } from "lucide-react";
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
adminApi,
|
||||
errMessage,
|
||||
type Customer,
|
||||
type CustomerDetail,
|
||||
type Diagnosis,
|
||||
type Holding,
|
||||
type ReportItem,
|
||||
} from "@/lib/admin-api";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingBlock,
|
||||
Modal,
|
||||
Pagination,
|
||||
SearchInput,
|
||||
Select,
|
||||
TableWrap,
|
||||
Td,
|
||||
Th,
|
||||
formatMoney,
|
||||
formatPercent,
|
||||
formatTime,
|
||||
statusTone,
|
||||
useToast,
|
||||
} from "@/components/admin/ui";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "", label: "全部状态" },
|
||||
{ value: "已分配", label: "已分配" },
|
||||
{ value: "已签约", label: "已签约" },
|
||||
{ value: "已结束", label: "已结束" },
|
||||
];
|
||||
|
||||
const REPORT_STATUS: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
sent: "已发送",
|
||||
discarded: "已废弃",
|
||||
};
|
||||
|
||||
export function CustomersSection() {
|
||||
const toast = useToast();
|
||||
const [items, setItems] = useState<Customer[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [activeId, setActiveId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await adminApi.customers({
|
||||
status: status || undefined,
|
||||
keyword: keyword || undefined,
|
||||
page,
|
||||
page_size: 10,
|
||||
});
|
||||
setItems(result.items ?? []);
|
||||
setTotal(result.total ?? 0);
|
||||
setTotalPages(result.total_pages ?? 0);
|
||||
} catch (e) {
|
||||
setError(errMessage(e, "客户列表加载失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status, keyword, page]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card title="客户管理" description="仅展示名下客户,敏感信息由后端脱敏" bodyClassName="p-0">
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-slate-100 p-4">
|
||||
<SearchInput
|
||||
className="w-64"
|
||||
placeholder="搜索姓名或手机号"
|
||||
value={keyword}
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select value={status} onChange={(event) => { setStatus(event.target.value); setPage(1); }}>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button size="sm" className="ml-auto" onClick={() => void load()} loading={loading}>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoadingBlock />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={() => void load()} />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState text="没有匹配的客户" />
|
||||
) : (
|
||||
<>
|
||||
<TableWrap>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>客户</Th>
|
||||
<Th>客户等级</Th>
|
||||
<Th>风险等级</Th>
|
||||
<Th>总资产</Th>
|
||||
<Th>关系状态</Th>
|
||||
<Th className="text-right">操作</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{items.map((customer) => (
|
||||
<tr key={customer.customer_id} className="transition-colors hover:bg-slate-50">
|
||||
<Td>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-100 text-sm font-medium text-slate-600">
|
||||
<Users className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{customer.real_name ?? "匿名客户"}</p>
|
||||
<p className="text-xs text-slate-400">{customer.phone ?? "--"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>{customer.customer_level ?? "--"}</Td>
|
||||
<Td>{customer.risk_level ?? "--"}</Td>
|
||||
<Td>{customer.total_assets !== null ? formatMoney(customer.total_assets) : "--"}</Td>
|
||||
<Td>
|
||||
<Badge tone={statusTone(customer.relation_status)}>{customer.relation_status}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" onClick={() => setActiveId(customer.customer_id)}>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
详情
|
||||
</Button>
|
||||
</div>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</TableWrap>
|
||||
<Pagination page={page} totalPages={totalPages} total={total} onChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{activeId !== null && (
|
||||
<CustomerDetailModal
|
||||
customerId={activeId}
|
||||
onClose={() => setActiveId(null)}
|
||||
onChanged={() => void load()}
|
||||
onToast={(text, tone) => toast(text, tone)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerDetailModal({
|
||||
customerId,
|
||||
onClose,
|
||||
onChanged,
|
||||
onToast,
|
||||
}: {
|
||||
customerId: number;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
onToast: (text: string, tone?: "success" | "error" | "info") => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"profile" | "holdings" | "diagnosis" | "reports">("profile");
|
||||
const [detail, setDetail] = useState<CustomerDetail | null>(null);
|
||||
const [holdings, setHoldings] = useState<Holding[]>([]);
|
||||
const [diagnosis, setDiagnosis] = useState<Diagnosis | null>(null);
|
||||
const [reports, setReports] = useState<ReportItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [unmasked, setUnmasked] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [base, holdingList, diag, reportPage] = await Promise.all([
|
||||
adminApi.customer(customerId),
|
||||
adminApi.holdings(customerId).catch(() => []),
|
||||
adminApi.diagnosis(customerId).catch(() => null),
|
||||
adminApi.reports(customerId).catch(() => null),
|
||||
]);
|
||||
setDetail(base);
|
||||
setHoldings(holdingList ?? []);
|
||||
setDiagnosis(diag);
|
||||
setReports(reportPage?.items ?? []);
|
||||
} catch (e) {
|
||||
setError(errMessage(e, "客户详情加载失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [customerId]);
|
||||
|
||||
async function unmaskPhone() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await adminApi.customer(customerId, true);
|
||||
setDetail(result);
|
||||
setUnmasked(true);
|
||||
onToast("已查看完整手机号,操作已记入审计台账", "info");
|
||||
} catch (e) {
|
||||
onToast(errMessage(e, "查看失败"), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeRelation(action: "sign" | "close") {
|
||||
const text = action === "sign" ? "确认签约该客户?" : "确认结束对该客户的服务?结束後不可恢复。";
|
||||
if (!window.confirm(text)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await adminApi.setRelation(customerId, action);
|
||||
onToast(action === "sign" ? "已签约" : "已结束服务");
|
||||
const refreshed = await adminApi.customer(customerId, unmasked);
|
||||
setDetail(refreshed);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
onToast(errMessage(e, "操作失败"), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ key: "profile", label: "基本资料" },
|
||||
{ key: "holdings", label: `持仓 (${holdings.length})` },
|
||||
{ key: "diagnosis", label: "组合诊断" },
|
||||
{ key: "reports", label: `历史报告 (${reports.length})` },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={`客户 #${customerId} 详情`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
{detail?.relation_status === "已签约" ? (
|
||||
<Button variant="danger" loading={busy} onClick={() => void changeRelation("close")}>
|
||||
结束服务
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" loading={busy} onClick={() => void changeRelation("sign")}>
|
||||
签约
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingBlock />
|
||||
) : error ? (
|
||||
<ErrorState message={error} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2 border-b border-slate-100 pb-3">
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setTab(item.key)}
|
||||
className={
|
||||
tab === item.key
|
||||
? "rounded-lg bg-indigo-50 px-3 py-1.5 text-xs font-medium text-indigo-600"
|
||||
: "rounded-lg px-3 py-1.5 text-xs text-slate-500 hover:bg-slate-100"
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "profile" && detail && (
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||
<Field label="姓名" value={detail.real_name ?? "--"} />
|
||||
<Field
|
||||
label="手机号"
|
||||
value={
|
||||
<span className="flex items-center gap-2">
|
||||
{detail.phone ?? "--"}
|
||||
{!unmasked && (
|
||||
<button
|
||||
onClick={() => void unmaskPhone()}
|
||||
disabled={busy}
|
||||
className="text-xs text-indigo-600 hover:underline disabled:opacity-50"
|
||||
>
|
||||
查看完整号码
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Field label="客户等级" value={detail.customer_level ?? "--"} />
|
||||
<Field label="风险等级" value={detail.risk_level ?? "--"} />
|
||||
<Field label="风险评分" value={detail.risk_score?.toString() ?? "--"} />
|
||||
<Field label="总资产" value={formatMoney(detail.total_assets)} />
|
||||
<Field
|
||||
label="关系状态"
|
||||
value={<Badge tone={statusTone(detail.relation_status)}>{detail.relation_status}</Badge>}
|
||||
/>
|
||||
<Field label="签约时间" value={formatTime(detail.signed_time)} />
|
||||
<Field label="分配时间" value={formatTime(detail.assigned_time)} />
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{tab === "holdings" &&
|
||||
(holdings.length === 0 ? (
|
||||
<EmptyState text="该客户暂无持仓" />
|
||||
) : (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-slate-500">
|
||||
<th className="py-2 text-left">产品</th>
|
||||
<th className="py-2 text-right">市值</th>
|
||||
<th className="py-2 text-right">浮动盈亏</th>
|
||||
<th className="py-2 text-right">收益率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{holdings.map((holding) => (
|
||||
<tr key={holding.product_id}>
|
||||
<td className="py-2">
|
||||
<p className="text-slate-900">{holding.product_name ?? "--"}</p>
|
||||
<p className="text-xs text-slate-400">{holding.product_code ?? ""}</p>
|
||||
</td>
|
||||
<td className="py-2 text-right">{formatMoney(holding.current_value)}</td>
|
||||
<td
|
||||
className={`py-2 text-right ${
|
||||
Number(holding.profit_loss) >= 0 ? "text-red-600" : "text-emerald-600"
|
||||
}`}
|
||||
>
|
||||
{formatMoney(holding.profit_loss)}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 text-right ${
|
||||
Number(holding.profit_ratio) >= 0 ? "text-red-600" : "text-emerald-600"
|
||||
}`}
|
||||
>
|
||||
{formatPercent(Number(holding.profit_ratio), 2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{tab === "diagnosis" &&
|
||||
(diagnosis ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg bg-slate-50 p-4">
|
||||
<p className="text-xs text-slate-500">持仓总市值</p>
|
||||
<p className="mt-1 text-lg font-semibold">{formatMoney(diagnosis.total_value)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-slate-50 p-4">
|
||||
<p className="text-xs text-slate-500">最大单一产品占比</p>
|
||||
<p className="mt-1 text-lg font-semibold">
|
||||
{formatPercent(diagnosis.concentration?.top_product_ratio ?? 0)}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-slate-400">
|
||||
{diagnosis.concentration?.top_product?.product_name ?? "--"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-2 text-xs font-medium text-slate-500">
|
||||
<PieChart className="h-3.5 w-3.5" />
|
||||
资产配置分布
|
||||
</p>
|
||||
{Object.keys(diagnosis.allocation ?? {}).length === 0 ? (
|
||||
<EmptyState text="暂无配置数据" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(diagnosis.allocation).map(([type, value]) => {
|
||||
const totalValue = Number(diagnosis.total_value) || 1;
|
||||
const ratio = Number(value) / totalValue;
|
||||
return (
|
||||
<div key={type}>
|
||||
<div className="mb-1 flex justify-between text-xs">
|
||||
<span className="text-slate-600">{type}</span>
|
||||
<span className="text-slate-900">
|
||||
{formatMoney(value)} · {formatPercent(ratio)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-500"
|
||||
style={{ width: `${Math.min(ratio * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState text="暂无诊断数据" />
|
||||
))}
|
||||
|
||||
{tab === "reports" &&
|
||||
(reports.length === 0 ? (
|
||||
<EmptyState text="暂无历史建议报告" />
|
||||
) : (
|
||||
<ul className="max-h-80 divide-y divide-slate-100 overflow-y-auto">
|
||||
{reports.map((report) => (
|
||||
<li key={report.report_id} className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-slate-900">{report.title ?? "未命名报告"}</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{report.intent ?? "--"} · {formatTime(report.send_time)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={statusTone(report.send_status)}>
|
||||
{REPORT_STATUS[report.send_status] ?? report.send_status}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs text-slate-500">{label}</dt>
|
||||
<dd className="mt-0.5 text-slate-900">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user