|
@@ -91,6 +91,7 @@ function Sidebar({ page, setPage }: { page: string; setPage: (p: string) => void
|
|
|
{ key: "tenants", label: "租户", icon: "M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z" },
|
|
{ key: "tenants", label: "租户", icon: "M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z" },
|
|
|
{ key: "users", label: "用户", icon: "M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" },
|
|
{ key: "users", label: "用户", icon: "M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" },
|
|
|
{ key: "license", label: "License 许可", icon: "M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" },
|
|
{ key: "license", label: "License 许可", icon: "M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" },
|
|
|
|
|
+ { key: "fetch_logs", label: "爬取日志", icon: "M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" },
|
|
|
];
|
|
];
|
|
|
return (
|
|
return (
|
|
|
<div style={{ width: 220, minHeight: "100vh", background: T.sidebar, padding: "24px 12px", position: "fixed", top: 0, left: 0, zIndex: 100 }}>
|
|
<div style={{ width: 220, minHeight: "100vh", background: T.sidebar, padding: "24px 12px", position: "fixed", top: 0, left: 0, zIndex: 100 }}>
|
|
@@ -211,6 +212,8 @@ function DomainsPage() {
|
|
|
const [newDomain, setNewDomain] = useState("");
|
|
const [newDomain, setNewDomain] = useState("");
|
|
|
const [newRemark, setNewRemark] = useState("");
|
|
const [newRemark, setNewRemark] = useState("");
|
|
|
const [fetchingId, setFetchingId] = useState<number | null>(null);
|
|
const [fetchingId, setFetchingId] = useState<number | null>(null);
|
|
|
|
|
+ const [fetchDate, setFetchDate] = useState("");
|
|
|
|
|
+ const [showDatePicker, setShowDatePicker] = useState<number | null>(null);
|
|
|
const [editingRemarkId, setEditingRemarkId] = useState<number | null>(null);
|
|
const [editingRemarkId, setEditingRemarkId] = useState<number | null>(null);
|
|
|
const [editingRemarkValue, setEditingRemarkValue] = useState("");
|
|
const [editingRemarkValue, setEditingRemarkValue] = useState("");
|
|
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
|
@@ -222,9 +225,9 @@ function DomainsPage() {
|
|
|
const { data: domains, isLoading } = useQuery({ queryKey: ["domains"], queryFn: () => domainApi.list().then((r) => r.data) });
|
|
const { data: domains, isLoading } = useQuery({ queryKey: ["domains"], queryFn: () => domainApi.list().then((r) => r.data) });
|
|
|
const addMutation = useMutation({ mutationFn: (data: MonitoredDomainCreate) => domainApi.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); setNewDomain(""); setNewRemark(""); showToast("域名添加成功", "success"); }, onError: () => { showToast("添加失败,请检查域名格式", "error"); } });
|
|
const addMutation = useMutation({ mutationFn: (data: MonitoredDomainCreate) => domainApi.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); setNewDomain(""); setNewRemark(""); showToast("域名添加成功", "success"); }, onError: () => { showToast("添加失败,请检查域名格式", "error"); } });
|
|
|
const deleteMutation = useMutation({ mutationFn: (id: number) => domainApi.remove(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); showToast("域名已删除", "success"); }, onError: () => { showToast("删除失败", "error"); } });
|
|
const deleteMutation = useMutation({ mutationFn: (id: number) => domainApi.remove(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); showToast("域名已删除", "success"); }, onError: () => { showToast("删除失败", "error"); } });
|
|
|
- const fetchMutation = useMutation({ mutationFn: (id: number) => domainApi.fetchTransactions(id), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["dashboard"] }); setFetchingId(null); showToast(`${res.data.domain} 爬取成功`, "success"); }, onError: () => { setFetchingId(null); showToast("爬取失败,请检查域名是否可达", "error"); } });
|
|
|
|
|
|
|
+ const fetchMutation = useMutation({ mutationFn: ({ id, date }: { id: number; date?: string }) => domainApi.fetchTransactions(id, date), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["dashboard"] }); setFetchingId(null); setShowDatePicker(null); showToast(`${res.data.domain} 爬取成功`, "success"); }, onError: () => { setFetchingId(null); showToast("爬取失败,请检查域名是否可达", "error"); } });
|
|
|
const updateRemarkMutation = useMutation({ mutationFn: ({ id, remark }: { id: number; remark: string }) => domainApi.updateRemark(id, { remark }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); setEditingRemarkId(null); showToast("备注更新成功", "success"); }, onError: () => { showToast("备注更新失败", "error"); } });
|
|
const updateRemarkMutation = useMutation({ mutationFn: ({ id, remark }: { id: number; remark: string }) => domainApi.updateRemark(id, { remark }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); setEditingRemarkId(null); showToast("备注更新成功", "success"); }, onError: () => { showToast("备注更新失败", "error"); } });
|
|
|
- const handleFetch = (id: number) => { setFetchingId(id); fetchMutation.mutate(id); };
|
|
|
|
|
|
|
+ const handleFetch = (id: number) => { setFetchingId(id); fetchMutation.mutate({ id, date: fetchDate || undefined }); };
|
|
|
const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!newDomain.trim()) return; addMutation.mutate({ domain: newDomain.trim(), remark: newRemark.trim() }); };
|
|
const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!newDomain.trim()) return; addMutation.mutate({ domain: newDomain.trim(), remark: newRemark.trim() }); };
|
|
|
const startEditRemark = (d: MonitoredDomain) => { setEditingRemarkId(d.id); setEditingRemarkValue(d.remark || ""); };
|
|
const startEditRemark = (d: MonitoredDomain) => { setEditingRemarkId(d.id); setEditingRemarkValue(d.remark || ""); };
|
|
|
const saveRemark = (id: number) => { updateRemarkMutation.mutate({ id, remark: editingRemarkValue }); };
|
|
const saveRemark = (id: number) => { updateRemarkMutation.mutate({ id, remark: editingRemarkValue }); };
|
|
@@ -272,10 +275,25 @@ function DomainsPage() {
|
|
|
</td>
|
|
</td>
|
|
|
<td style={{ padding: "12px 14px" }}><Badge active={d.is_active} text={d.is_active ? "启用" : "停用"} /></td>
|
|
<td style={{ padding: "12px 14px" }}><Badge active={d.is_active} text={d.is_active ? "启用" : "停用"} /></td>
|
|
|
<td style={{ padding: "12px 14px", fontSize: 13, color: T.textSec }}>{fmtDate(d.created_at)}</td>
|
|
<td style={{ padding: "12px 14px", fontSize: 13, color: T.textSec }}>{fmtDate(d.created_at)}</td>
|
|
|
- <td style={{ padding: "12px 14px", display: "flex", gap: 6 }}>
|
|
|
|
|
- <button onClick={() => handleFetch(d.id)} disabled={fetchingId === d.id} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary, fontWeight: 500 }}>
|
|
|
|
|
- {fetchingId === d.id ? "爬取中" : "爬取流水"}
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ <td style={{ padding: "12px 14px", display: "flex", gap: 6, alignItems: "center" }}>
|
|
|
|
|
+ {showDatePicker === d.id ? (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <input type="date" value={fetchDate} onChange={(e) => setFetchDate(e.target.value)} style={{ padding: "3px 6px", borderRadius: 4, border: `1px solid ${T.border}`, fontSize: 12, outline: "none", width: 140 }} />
|
|
|
|
|
+ <button onClick={() => handleFetch(d.id)} disabled={fetchingId === d.id} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: T.primary, color: "#fff", fontWeight: 500 }}>
|
|
|
|
|
+ {fetchingId === d.id ? "爬取中" : "爬取"}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button onClick={() => { setShowDatePicker(null); setFetchDate(""); }} style={{ padding: "3px 8px", borderRadius: 4, fontSize: 12, cursor: "pointer", border: "none", background: T.border, color: T.text }}>取消</button>
|
|
|
|
|
+ </>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <button onClick={() => setShowDatePicker(d.id)} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary, fontWeight: 500 }}>
|
|
|
|
|
+ 按日期爬取
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button onClick={() => handleFetch(d.id)} disabled={fetchingId === d.id} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.border}`, background: "transparent", color: T.text, fontWeight: 500 }}>
|
|
|
|
|
+ {fetchingId === d.id ? "爬取中" : "爬取当天"}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
<button onClick={() => deleteMutation.mutate(d.id)} disabled={deleteMutation.isPending} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: "transparent", color: T.danger, fontWeight: 500 }}>删除</button>
|
|
<button onClick={() => deleteMutation.mutate(d.id)} disabled={deleteMutation.isPending} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: "transparent", color: T.danger, fontWeight: 500 }}>删除</button>
|
|
|
</td>
|
|
</td>
|
|
|
</tr>
|
|
</tr>
|
|
@@ -674,48 +692,70 @@ function TenantsPage() {
|
|
|
/* ===== 用户页面 ===== */
|
|
/* ===== 用户页面 ===== */
|
|
|
function UsersPage() {
|
|
function UsersPage() {
|
|
|
const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
|
|
const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
|
|
|
|
|
+ const [page, setPage] = useState(1);
|
|
|
|
|
+ const [pageSize, setPageSize] = useState(20);
|
|
|
|
|
|
|
|
- const params: { start_date?: string; end_date?: string; super_admin_name?: string; tenant_name?: string } = {};
|
|
|
|
|
|
|
+ const params: { start_date?: string; end_date?: string; super_admin_name?: string; tenant_name?: string; page?: number; page_size?: number } = {};
|
|
|
if (query.startDate) params.start_date = query.startDate;
|
|
if (query.startDate) params.start_date = query.startDate;
|
|
|
if (query.endDate) params.end_date = query.endDate;
|
|
if (query.endDate) params.end_date = query.endDate;
|
|
|
if (query.saName) params.super_admin_name = query.saName;
|
|
if (query.saName) params.super_admin_name = query.saName;
|
|
|
if (query.tenantName) params.tenant_name = query.tenantName;
|
|
if (query.tenantName) params.tenant_name = query.tenantName;
|
|
|
|
|
+ params.page = page;
|
|
|
|
|
+ params.page_size = pageSize;
|
|
|
|
|
|
|
|
const { data: details, isLoading } = useQuery({
|
|
const { data: details, isLoading } = useQuery({
|
|
|
queryKey: ["consumption-details", params],
|
|
queryKey: ["consumption-details", params],
|
|
|
- queryFn: () => monitoringApi.getConsumptionDetails(Object.keys(params).length ? params : undefined).then((r) => r.data),
|
|
|
|
|
|
|
+ queryFn: () => monitoringApi.getConsumptionDetails(params).then((r) => r.data),
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
if (isLoading) return <PageLayout title="用户" subtitle="逐笔消费明细,用于对账"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
|
|
if (isLoading) return <PageLayout title="用户" subtitle="逐笔消费明细,用于对账"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
|
|
|
if (!details) return <PageLayout title="用户" subtitle="逐笔消费明细,用于对账"><Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card></PageLayout>;
|
|
if (!details) return <PageLayout title="用户" subtitle="逐笔消费明细,用于对账"><Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card></PageLayout>;
|
|
|
|
|
|
|
|
const records = details.records || [];
|
|
const records = details.records || [];
|
|
|
|
|
+ const total = details.total ?? 0;
|
|
|
|
|
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<PageLayout title="用户" subtitle="逐笔消费明细,用于对账">
|
|
<PageLayout title="用户" subtitle="逐笔消费明细,用于对账">
|
|
|
- <FilterBar currentQuery={query} onSearch={setQuery} showTenantFilter />
|
|
|
|
|
- <Card title={`消费明细(共 ${details.total} 笔)`}>
|
|
|
|
|
|
|
+ <FilterBar currentQuery={query} onSearch={(q) => { setQuery(q); setPage(1); }} showTenantFilter />
|
|
|
|
|
+ <Card title={`消费明细(共 ${total} 笔)`}>
|
|
|
{records.length === 0 ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>暂无消费记录</div> : (
|
|
{records.length === 0 ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>暂无消费记录</div> : (
|
|
|
- <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
|
|
|
|
- <thead><tr>{["用户", "所属租户", "订单号", "模型", "消费时间", "用户折扣", "用户金额(元)", "租户折扣", "租户金额(元)", "超管折扣", "超管金额(元)"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
|
|
|
|
|
- <tbody>
|
|
|
|
|
- {records.map((r: any, idx: number) => (
|
|
|
|
|
- <tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}>
|
|
|
|
|
- <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.user_name}</td>
|
|
|
|
|
- <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.tenant_name}</td>
|
|
|
|
|
- <td style={{ padding: "8px 10px", color: T.textSec }}>{r.order_no || "-"}</td>
|
|
|
|
|
- <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.model_code}</td>
|
|
|
|
|
- <td style={{ padding: "8px 10px", color: T.textSec }}>{r.consumption_date ? r.consumption_date.split(".")[0] : "-"}</td>
|
|
|
|
|
- <td style={{ padding: "8px 10px", color: T.textSec }}>{r.user_discount}</td>
|
|
|
|
|
- <td style={{ padding: "8px 10px" }}>{r.user_consumed}</td>
|
|
|
|
|
- <td style={{ padding: "8px 10px", color: T.textSec }}>{r.tenant_discount}</td>
|
|
|
|
|
- <td style={{ padding: "8px 10px" }}>{r.tenant_actual_price}</td>
|
|
|
|
|
- <td style={{ padding: "8px 10px", color: T.textSec }}>{r.super_admin_discount}</td>
|
|
|
|
|
- <td style={{ padding: "8px 10px" }}>{r.super_admin_actual_price}</td>
|
|
|
|
|
- </tr>
|
|
|
|
|
- ))}
|
|
|
|
|
- </tbody>
|
|
|
|
|
- </table>
|
|
|
|
|
|
|
+ <>
|
|
|
|
|
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
|
|
|
|
+ <thead><tr>{["用户", "所属租户", "订单号", "模型", "消费时间", "用户折扣", "用户金额(元)", "租户折扣", "租户金额(元)", "超管折扣", "超管金额(元)"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {records.map((r: any, idx: number) => (
|
|
|
|
|
+ <tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.user_name}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.tenant_name}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", color: T.textSec }}>{r.order_no || "-"}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.model_code}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", color: T.textSec }}>{r.consumption_date ? r.consumption_date.split(".")[0] : "-"}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", color: T.textSec }}>{r.user_discount}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px" }}>{r.user_consumed}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", color: T.textSec }}>{r.tenant_discount}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px" }}>{r.tenant_actual_price}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", color: T.textSec }}>{r.super_admin_discount}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px" }}>{r.super_admin_actual_price}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 16, fontSize: 13 }}>
|
|
|
|
|
+ <div style={{ display: "flex", alignItems: "center", gap: 8, color: T.textSec }}>
|
|
|
|
|
+ <span>每页</span>
|
|
|
|
|
+ <select value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none", background: "#fff" }}>
|
|
|
|
|
+ {[10, 20, 50, 100].map((n) => <option key={n} value={n}>{n} 条</option>)}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ <span>,共 {total} 条</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
|
|
|
+ <button disabled={page <= 1} onClick={() => setPage(page - 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 13, cursor: page <= 1 ? "default" : "pointer", border: `1px solid ${T.border}`, background: "#fff", color: page <= 1 ? T.textSec : T.text }}>上一页</button>
|
|
|
|
|
+ <span style={{ color: T.textSec }}>第 {page} / {totalPages} 页</span>
|
|
|
|
|
+ <button disabled={page >= totalPages} onClick={() => setPage(page + 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 13, cursor: page >= totalPages ? "default" : "pointer", border: `1px solid ${T.border}`, background: "#fff", color: page >= totalPages ? T.textSec : T.text }}>下一页</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
)}
|
|
)}
|
|
|
</Card>
|
|
</Card>
|
|
|
</PageLayout>
|
|
</PageLayout>
|
|
@@ -791,7 +831,7 @@ function LicensePage() {
|
|
|
const updateMutation = useMutation({
|
|
const updateMutation = useMutation({
|
|
|
mutationFn: ({ id, data }: { id: number; data: { license_key?: string; expires_at?: string } }) =>
|
|
mutationFn: ({ id, data }: { id: number; data: { license_key?: string; expires_at?: string } }) =>
|
|
|
licenseApi.update(id, data),
|
|
licenseApi.update(id, data),
|
|
|
- onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); setEditTarget(null); showToast("License 已更新", "success"); showSmsToast(res.data?.sms_status); },
|
|
|
|
|
|
|
+ onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); setEditTarget(null); showToast("License 已更新", "success"); showSmsToast(res.data?.sms_status, res.data?.sms_type ?? "restored"); },
|
|
|
onError: () => { showToast("更新失败", "error"); },
|
|
onError: () => { showToast("更新失败", "error"); },
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -950,6 +990,83 @@ function LicensePage() {
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/* ===== 爬取日志页面 ===== */
|
|
|
|
|
+function FetchLogsPage() {
|
|
|
|
|
+ const [filters, setFilters] = useState({ domain: "", status: "" });
|
|
|
|
|
+ const [page, setPage] = useState(1);
|
|
|
|
|
+ const size = 20;
|
|
|
|
|
+
|
|
|
|
|
+ const params: Record<string, string | number> = { page, size };
|
|
|
|
|
+ if (filters.domain) params.domain = filters.domain;
|
|
|
|
|
+ if (filters.status) params.status = filters.status;
|
|
|
|
|
+
|
|
|
|
|
+ const { data, isLoading } = useQuery({
|
|
|
|
|
+ queryKey: ["fetch-logs", params],
|
|
|
|
|
+ queryFn: () => domainApi.getFetchLogs(params).then((r) => r.data),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const statusBadge = (status: string) => {
|
|
|
|
|
+ if (status === "success") return <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 10px", borderRadius: 99, fontSize: 12, background: "#f0fdf4", color: "#16a34a" }}>成功</span>;
|
|
|
|
|
+ if (status === "failed") return <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 10px", borderRadius: 99, fontSize: 12, background: "#fef2f2", color: "#dc2626" }}>失败</span>;
|
|
|
|
|
+ if (status === "skipped") return <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 10px", borderRadius: 99, fontSize: 12, background: "#fefce8", color: "#ca8a04" }}>跳过</span>;
|
|
|
|
|
+ return <span style={{ color: T.textSec }}>{status}</span>;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const total = data?.total ?? 0;
|
|
|
|
|
+ const totalPages = Math.max(1, Math.ceil(total / size));
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <PageLayout title="爬取日志" subtitle="查看域名爬取历史记录">
|
|
|
|
|
+ <Card title="筛选">
|
|
|
|
|
+ <div style={{ display: "flex", gap: 16, alignItems: "flex-end", flexWrap: "wrap" }}>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>域名</label>
|
|
|
|
|
+ <input type="text" value={filters.domain} onChange={(e) => setFilters({ ...filters, domain: e.target.value })} placeholder="输入域名筛选" style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 200 }} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>状态</label>
|
|
|
|
|
+ <select value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", background: "#fff" }}>
|
|
|
|
|
+ <option value="">全部</option>
|
|
|
|
|
+ <option value="success">成功</option>
|
|
|
|
|
+ <option value="failed">失败</option>
|
|
|
|
|
+ <option value="skipped">跳过</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button onClick={() => { setPage(1); }} style={{ padding: "8px 20px", borderRadius: 6, fontSize: 14, cursor: "pointer", border: "none", background: T.primary, color: "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>查询</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+
|
|
|
|
|
+ <Card title={`日志记录(共 ${total} 条)`}>
|
|
|
|
|
+ {isLoading ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>加载中...</div> : !data?.items?.length ? <div style={{ textAlign: "center", padding: 48, color: T.textSec }}>暂无爬取日志</div> : (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
|
|
|
|
+ <thead><tr>{["ID", "域名", "状态", "信息", "时间"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {data.items.map((log: any) => (
|
|
|
|
|
+ <tr key={log.id} style={{ borderBottom: `1px solid ${T.border}` }}>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", color: T.textSec }}>{log.id}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", fontWeight: 500 }}>{log.domain}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px" }}>{statusBadge(log.status)}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", color: log.status === "failed" ? T.danger : T.text, maxWidth: 400, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={log.message}>{log.message}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", color: T.textSec, whiteSpace: "nowrap" }}>{log.created_at ? new Date(log.created_at).toLocaleString("zh-CN") : "-"}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ {totalPages > 1 && (
|
|
|
|
|
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 8, marginTop: 16 }}>
|
|
|
|
|
+ <button disabled={page <= 1} onClick={() => setPage(page - 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 13, cursor: page <= 1 ? "default" : "pointer", border: `1px solid ${T.border}`, background: "#fff", color: page <= 1 ? T.textSec : T.text }}>上一页</button>
|
|
|
|
|
+ <span style={{ fontSize: 13, color: T.textSec }}>第 {page} / {totalPages} 页</span>
|
|
|
|
|
+ <button disabled={page >= totalPages} onClick={() => setPage(page + 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 13, cursor: page >= totalPages ? "default" : "pointer", border: `1px solid ${T.border}`, background: "#fff", color: page >= totalPages ? T.textSec : T.text }}>下一页</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </PageLayout>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/* ===== App ===== */
|
|
/* ===== App ===== */
|
|
|
function App() {
|
|
function App() {
|
|
|
const [page, setPage] = useState("domains");
|
|
const [page, setPage] = useState("domains");
|
|
@@ -960,6 +1077,7 @@ function App() {
|
|
|
tenants: <TenantsPage />,
|
|
tenants: <TenantsPage />,
|
|
|
users: <UsersPage />,
|
|
users: <UsersPage />,
|
|
|
license: <LicensePage />,
|
|
license: <LicensePage />,
|
|
|
|
|
+ fetch_logs: <FetchLogsPage />,
|
|
|
};
|
|
};
|
|
|
return (
|
|
return (
|
|
|
<>
|
|
<>
|