|
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react";
|
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
import { domainApi } from "./api/domains";
|
|
import { domainApi } from "./api/domains";
|
|
|
import { monitoringApi } from "./api/monitoring";
|
|
import { monitoringApi } from "./api/monitoring";
|
|
|
|
|
+import { licenseApi } from "./api/license";
|
|
|
import type { MonitoredDomain, MonitoredDomainCreate } from "./types/domain";
|
|
import type { MonitoredDomain, MonitoredDomainCreate } from "./types/domain";
|
|
|
|
|
|
|
|
/* ===== 颜色主题 ===== */
|
|
/* ===== 颜色主题 ===== */
|
|
@@ -26,6 +27,7 @@ const globalStyle = `
|
|
|
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
|
|
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
|
|
|
html, body, #root { width:100%; height:100%; overflow:auto; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:${T.bg}; color:${T.text}; font-size:14px; }
|
|
html, body, #root { width:100%; height:100%; overflow:auto; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:${T.bg}; color:${T.text}; font-size:14px; }
|
|
|
@keyframes spin { to { transform:rotate(360deg); } }
|
|
@keyframes spin { to { transform:rotate(360deg); } }
|
|
|
|
|
+ @keyframes fadeIn { from { opacity:0; transform:translateY(-10px); } to { opacity:1; transform:translateY(0); } }
|
|
|
`;
|
|
`;
|
|
|
function GlobalStyle() { return <style>{globalStyle}</style>; }
|
|
function GlobalStyle() { return <style>{globalStyle}</style>; }
|
|
|
|
|
|
|
@@ -66,11 +68,29 @@ function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v:
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** Toast 提示 — 页面中上部固定位置 */
|
|
|
|
|
+function Toast({ message, type }: { message: string; type: "success" | "error" }) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "fixed", top: 20, left: "50%", transform: "translateX(-50%)",
|
|
|
|
|
+ zIndex: 9999, padding: "12px 24px", borderRadius: 8, fontSize: 14,
|
|
|
|
|
+ fontWeight: 500, color: "#fff",
|
|
|
|
|
+ background: type === "success" ? T.success : T.danger,
|
|
|
|
|
+ boxShadow: "0 4px 16px rgba(0,0,0,0.2)",
|
|
|
|
|
+ animation: "fadeIn 0.3s ease",
|
|
|
|
|
+ }}>{message}</div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/* ===== 侧边栏 ===== */
|
|
/* ===== 侧边栏 ===== */
|
|
|
function Sidebar({ page, setPage }: { page: string; setPage: (p: string) => void }) {
|
|
function Sidebar({ page, setPage }: { page: string; setPage: (p: string) => void }) {
|
|
|
const items = [
|
|
const items = [
|
|
|
{ key: "domains", label: "域名管理", icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" },
|
|
{ key: "domains", label: "域名管理", icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" },
|
|
|
{ key: "monitoring", label: "监控大屏", icon: "M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" },
|
|
{ key: "monitoring", label: "监控大屏", icon: "M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" },
|
|
|
|
|
+ { key: "super_admins", label: "超级管理员", icon: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" },
|
|
|
|
|
+ { 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: "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" },
|
|
|
];
|
|
];
|
|
|
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 }}>
|
|
@@ -91,60 +111,130 @@ function Sidebar({ page, setPage }: { page: string; setPage: (p: string) => void
|
|
|
function FetchControls() {
|
|
function FetchControls() {
|
|
|
const queryClient = useQueryClient();
|
|
const queryClient = useQueryClient();
|
|
|
const [autoFetch, setAutoFetch] = useState(false);
|
|
const [autoFetch, setAutoFetch] = useState(false);
|
|
|
- const [intervalSec, setIntervalSec] = useState(300);
|
|
|
|
|
- const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
|
|
|
+ const [scheduleTime, setScheduleTime] = useState("02:00");
|
|
|
|
|
+ const [fetchDate, setFetchDate] = useState("");
|
|
|
|
|
+ const [fetchingByDate, setFetchingByDate] = useState(false);
|
|
|
|
|
+ const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
|
|
|
|
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+ const showToast = (message: string, type: "success" | "error") => {
|
|
|
|
|
+ setToast({ message, type });
|
|
|
|
|
+ setTimeout(() => setToast(null), 4000);
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
const batchMutation = useMutation({
|
|
const batchMutation = useMutation({
|
|
|
mutationFn: () => domainApi.fetchAll(),
|
|
mutationFn: () => domainApi.fetchAll(),
|
|
|
- onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["dashboard"] }); },
|
|
|
|
|
|
|
+ onSuccess: (res) => {
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
|
|
|
|
+ const data = res.data;
|
|
|
|
|
+ if (data.errors && data.errors.length > 0) {
|
|
|
|
|
+ showToast(`部分失败: ${data.errors.length}/${data.total} 个域名出错`, "error");
|
|
|
|
|
+ } else if (data.total === 0) {
|
|
|
|
|
+ showToast("没有启用中的域名", "error");
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showToast(`全部爬取成功,共 ${data.total} 个域名`, "success");
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: () => { showToast("爬取请求失败,请稍后重试", "error"); },
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // 自动爬取定时器
|
|
|
|
|
|
|
+ const fetchByDateMutation = useMutation({
|
|
|
|
|
+ mutationFn: (date: string) => domainApi.fetchAll(date),
|
|
|
|
|
+ onSuccess: (res) => {
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
|
|
|
|
+ setFetchingByDate(false);
|
|
|
|
|
+ const data = res.data;
|
|
|
|
|
+ if (data.errors && data.errors.length > 0) {
|
|
|
|
|
+ showToast(`部分失败: ${data.errors.length}/${data.total} 个域名出错`, "error");
|
|
|
|
|
+ } else if (data.total === 0) {
|
|
|
|
|
+ showToast("没有启用中的域名", "error");
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showToast(`按日期爬取成功,共 ${data.total} 个域名`, "success");
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: () => { setFetchingByDate(false); showToast("爬取请求失败,请稍后重试", "error"); },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 定时爬取:计算到下一个目标时间点的延迟
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- if (autoFetch) {
|
|
|
|
|
- const fn = () => batchMutation.mutate();
|
|
|
|
|
- fn();
|
|
|
|
|
- timerRef.current = setInterval(fn, intervalSec * 1000);
|
|
|
|
|
|
|
+ if (!autoFetch) {
|
|
|
|
|
+ if (timerRef.current) clearTimeout(timerRef.current);
|
|
|
|
|
+ return;
|
|
|
}
|
|
}
|
|
|
- return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
|
|
|
|
- }, [autoFetch, intervalSec]);
|
|
|
|
|
|
|
+ const [h, m] = scheduleTime.split(":").map(Number);
|
|
|
|
|
+ const now = new Date();
|
|
|
|
|
+ let next = new Date(now);
|
|
|
|
|
+ next.setHours(h, m, 0, 0);
|
|
|
|
|
+ if (next <= now) next.setDate(next.getDate() + 1);
|
|
|
|
|
+ const delay = next.getTime() - now.getTime();
|
|
|
|
|
+ const fn = () => {
|
|
|
|
|
+ batchMutation.mutate();
|
|
|
|
|
+ timerRef.current = setTimeout(fn, 24 * 3600 * 1000);
|
|
|
|
|
+ };
|
|
|
|
|
+ timerRef.current = setTimeout(fn, delay);
|
|
|
|
|
+ return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
|
|
|
|
+ }, [autoFetch, scheduleTime]);
|
|
|
|
|
+
|
|
|
|
|
+ const handleFetchByDate = () => {
|
|
|
|
|
+ if (!fetchDate) return;
|
|
|
|
|
+ setFetchingByDate(true);
|
|
|
|
|
+ fetchByDateMutation.mutate(fetchDate);
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
- <div style={{ display: "flex", alignItems: "center", gap: 20, flexWrap: "wrap" }}>
|
|
|
|
|
- <Toggle checked={autoFetch} onChange={setAutoFetch} label="自动爬取" />
|
|
|
|
|
- {autoFetch && (
|
|
|
|
|
|
|
+ <>
|
|
|
|
|
+ {toast && <Toast message={toast.message} type={toast.type} />}
|
|
|
|
|
+ <div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
|
|
|
|
|
+ <Toggle checked={autoFetch} onChange={setAutoFetch} label="每日定时爬取" />
|
|
|
|
|
+ {autoFetch && (
|
|
|
|
|
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
|
|
|
+ <span style={{ fontSize: 13, color: T.textSec }}>时间</span>
|
|
|
|
|
+ <input type="time" value={scheduleTime} onChange={(e) => setScheduleTime(e.target.value)} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <button onClick={() => batchMutation.mutate()} disabled={batchMutation.isPending} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: batchMutation.isPending ? "#f1f5f9" : T.primary, color: batchMutation.isPending ? T.textSec : "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>
|
|
|
|
|
+ {batchMutation.isPending ? "爬取中..." : "全部爬取"}
|
|
|
|
|
+ </button>
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
|
- <span style={{ fontSize: 13, color: T.textSec }}>间隔</span>
|
|
|
|
|
- <select value={intervalSec} onChange={(e) => setIntervalSec(Number(e.target.value))} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }}>
|
|
|
|
|
- <option value={60}>1分钟</option>
|
|
|
|
|
- <option value={300}>5分钟</option>
|
|
|
|
|
- <option value={600}>10分钟</option>
|
|
|
|
|
- <option value={1800}>30分钟</option>
|
|
|
|
|
- </select>
|
|
|
|
|
|
|
+ <span style={{ fontSize: 13, color: T.textSec }}>按日期爬取</span>
|
|
|
|
|
+ <input type="date" value={fetchDate} onChange={(e) => setFetchDate(e.target.value)} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }} />
|
|
|
|
|
+ <button onClick={handleFetchByDate} disabled={fetchingByDate || !fetchDate} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: fetchingByDate || !fetchDate ? "default" : "pointer", border: `1px solid ${T.primary}`, background: fetchingByDate || !fetchDate ? "#f1f5f9" : "transparent", color: fetchingByDate || !fetchDate ? T.textSec : T.primary, fontWeight: 500, whiteSpace: "nowrap" }}>
|
|
|
|
|
+ {fetchingByDate ? "爬取中..." : "爬取"}
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
- )}
|
|
|
|
|
- <button onClick={() => batchMutation.mutate()} disabled={batchMutation.isPending} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: batchMutation.isPending ? "#f1f5f9" : T.primary, color: batchMutation.isPending ? T.textSec : "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>
|
|
|
|
|
- {batchMutation.isPending ? "爬取中..." : "全部爬取"}
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/* ===== 域名管理页面 ===== */
|
|
/* ===== 域名管理页面 ===== */
|
|
|
function DomainsPage() {
|
|
function DomainsPage() {
|
|
|
const [newDomain, setNewDomain] = useState("");
|
|
const [newDomain, setNewDomain] = useState("");
|
|
|
|
|
+ const [newRemark, setNewRemark] = useState("");
|
|
|
const [fetchingId, setFetchingId] = useState<number | null>(null);
|
|
const [fetchingId, setFetchingId] = useState<number | null>(null);
|
|
|
|
|
+ const [editingRemarkId, setEditingRemarkId] = useState<number | null>(null);
|
|
|
|
|
+ const [editingRemarkValue, setEditingRemarkValue] = useState("");
|
|
|
|
|
+ const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
|
|
const queryClient = useQueryClient();
|
|
const queryClient = useQueryClient();
|
|
|
|
|
+ const showToast = (message: string, type: "success" | "error") => {
|
|
|
|
|
+ setToast({ message, type });
|
|
|
|
|
+ setTimeout(() => setToast(null), 4000);
|
|
|
|
|
+ };
|
|
|
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(""); } });
|
|
|
|
|
- const deleteMutation = useMutation({ mutationFn: (id: number) => domainApi.remove(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); } });
|
|
|
|
|
- const fetchMutation = useMutation({ mutationFn: (id: number) => domainApi.fetchTransactions(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["dashboard"] }); setFetchingId(null); }, onError: () => { setFetchingId(null); } });
|
|
|
|
|
|
|
+ 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 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 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); };
|
|
|
- const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!newDomain.trim()) return; addMutation.mutate({ domain: newDomain.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 saveRemark = (id: number) => { updateRemarkMutation.mutate({ id, remark: editingRemarkValue }); };
|
|
|
const fmtDate = (s: string | null) => s ? new Date(s).toLocaleString("zh-CN") : "-";
|
|
const fmtDate = (s: string | null) => s ? new Date(s).toLocaleString("zh-CN") : "-";
|
|
|
const activeCount = domains?.filter((d: MonitoredDomain) => d.is_active).length ?? 0;
|
|
const activeCount = domains?.filter((d: MonitoredDomain) => d.is_active).length ?? 0;
|
|
|
return (
|
|
return (
|
|
|
- <div style={{ marginLeft: 220, padding: 32 }}>
|
|
|
|
|
|
|
+ <>
|
|
|
|
|
+ {toast && <Toast message={toast.message} type={toast.type} />}
|
|
|
|
|
+ <div style={{ marginLeft: 220, padding: 32 }}>
|
|
|
<div style={{ marginBottom: 28 }}><h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>域名管理</h1><p style={{ color: T.textSec, fontSize: 14 }}>管理需要爬取流水的域名列表</p></div>
|
|
<div style={{ marginBottom: 28 }}><h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>域名管理</h1><p style={{ color: T.textSec, fontSize: 14 }}>管理需要爬取流水的域名列表</p></div>
|
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 24 }}>
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 24 }}>
|
|
|
{[{ label: "域名总数", value: domains?.length ?? 0 }, { label: "启用中", value: activeCount }, { label: "已停用", value: (domains?.length ?? 0) - activeCount }, { label: "今日新增", value: 0 }].map((s) => (
|
|
{[{ label: "域名总数", value: domains?.length ?? 0 }, { label: "启用中", value: activeCount }, { label: "已停用", value: (domains?.length ?? 0) - activeCount }, { label: "今日新增", value: 0 }].map((s) => (
|
|
@@ -157,18 +247,30 @@ function DomainsPage() {
|
|
|
<Card title="添加域名">
|
|
<Card title="添加域名">
|
|
|
<form onSubmit={handleSubmit} style={{ display: "flex", gap: 12 }}>
|
|
<form onSubmit={handleSubmit} style={{ display: "flex", gap: 12 }}>
|
|
|
<input value={newDomain} onChange={(e) => setNewDomain(e.target.value)} placeholder="输入域名,如 tenant.example.com" style={{ flex: 1, padding: "9px 14px", border: `1px solid ${T.border}`, borderRadius: 8, fontSize: 14, color: T.text, background: "#fff", outline: "none" }} />
|
|
<input value={newDomain} onChange={(e) => setNewDomain(e.target.value)} placeholder="输入域名,如 tenant.example.com" style={{ flex: 1, padding: "9px 14px", border: `1px solid ${T.border}`, borderRadius: 8, fontSize: 14, color: T.text, background: "#fff", outline: "none" }} />
|
|
|
|
|
+ <input value={newRemark} onChange={(e) => setNewRemark(e.target.value)} placeholder="备注(用于标识超管)" style={{ width: 180, padding: "9px 14px", border: `1px solid ${T.border}`, borderRadius: 8, fontSize: 14, color: T.text, background: "#fff", outline: "none" }} />
|
|
|
<button type="submit" disabled={addMutation.isPending} style={{ padding: "9px 20px", borderRadius: 8, fontSize: 14, fontWeight: 500, cursor: "pointer", border: "none", background: addMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff" }}>{addMutation.isPending ? "添加中..." : "添加域名"}</button>
|
|
<button type="submit" disabled={addMutation.isPending} style={{ padding: "9px 20px", borderRadius: 8, fontSize: 14, fontWeight: 500, cursor: "pointer", border: "none", background: addMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff" }}>{addMutation.isPending ? "添加中..." : "添加域名"}</button>
|
|
|
</form>
|
|
</form>
|
|
|
</Card>
|
|
</Card>
|
|
|
<Card title="域名列表" extra={<FetchControls />}>
|
|
<Card title="域名列表" extra={<FetchControls />}>
|
|
|
{isLoading ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>加载中...</div> : !domains?.length ? <div style={{ textAlign: "center", padding: 48, color: T.textSec }}><div style={{ fontSize: 40, marginBottom: 12 }}>🌐</div><p>暂无域名</p></div> : (
|
|
{isLoading ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>加载中...</div> : !domains?.length ? <div style={{ textAlign: "center", padding: 48, color: T.textSec }}><div style={{ fontSize: 40, marginBottom: 12 }}>🌐</div><p>暂无域名</p></div> : (
|
|
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
|
- <thead><tr>{["ID", "域名", "状态", "创建时间", "操作"].map((h) => <th key={h} style={{ padding: "10px 14px", textAlign: "left", fontSize: 12, fontWeight: 600, color: T.textSec, textTransform: "uppercase", background: T.bg, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
|
|
|
|
|
|
|
+ <thead><tr>{["ID", "域名", "备注", "状态", "创建时间", "操作"].map((h) => <th key={h} style={{ padding: "10px 14px", textAlign: "left", fontSize: 12, fontWeight: 600, color: T.textSec, textTransform: "uppercase", background: T.bg, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
|
|
|
<tbody>
|
|
<tbody>
|
|
|
{domains.map((d: MonitoredDomain) => (
|
|
{domains.map((d: MonitoredDomain) => (
|
|
|
<tr key={d.id} style={{ borderBottom: `1px solid ${T.border}` }}>
|
|
<tr key={d.id} style={{ borderBottom: `1px solid ${T.border}` }}>
|
|
|
<td style={{ padding: "12px 14px", fontSize: 13, color: T.textSec }}>{d.id}</td>
|
|
<td style={{ padding: "12px 14px", fontSize: 13, color: T.textSec }}>{d.id}</td>
|
|
|
<td style={{ padding: "12px 14px", fontWeight: 500 }}>{d.domain}</td>
|
|
<td style={{ padding: "12px 14px", fontWeight: 500 }}>{d.domain}</td>
|
|
|
|
|
+ <td style={{ padding: "12px 14px" }}>
|
|
|
|
|
+ {editingRemarkId === d.id ? (
|
|
|
|
|
+ <div style={{ display: "flex", gap: 4, alignItems: "center" }}>
|
|
|
|
|
+ <input value={editingRemarkValue} onChange={(e) => setEditingRemarkValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") saveRemark(d.id); if (e.key === "Escape") setEditingRemarkId(null); }} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.primary}`, fontSize: 13, outline: "none", width: 160 }} autoFocus />
|
|
|
|
|
+ <button onClick={() => saveRemark(d.id)} style={{ padding: "3px 8px", borderRadius: 4, fontSize: 12, cursor: "pointer", border: "none", background: T.success, color: "#fff" }}>保存</button>
|
|
|
|
|
+ <button onClick={() => setEditingRemarkId(null)} style={{ padding: "3px 8px", borderRadius: 4, fontSize: 12, cursor: "pointer", border: "none", background: T.border, color: T.text }}>取消</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <span onDoubleClick={() => startEditRemark(d)} style={{ cursor: "pointer", fontSize: 13, color: d.remark ? T.text : T.textSec, padding: "2px 4px", borderRadius: 4 }} title="双击编辑">{d.remark || "双击添加备注"}</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </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 }}>
|
|
<td style={{ padding: "12px 14px", display: "flex", gap: 6 }}>
|
|
@@ -184,6 +286,7 @@ function DomainsPage() {
|
|
|
)}
|
|
)}
|
|
|
</Card>
|
|
</Card>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ </>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -206,14 +309,12 @@ function CollapsePanel({ title, children, defaultOpen = false, badge }: { title:
|
|
|
|
|
|
|
|
/* ===== 监控大屏页面 ===== */
|
|
/* ===== 监控大屏页面 ===== */
|
|
|
function MonitoringPage() {
|
|
function MonitoringPage() {
|
|
|
- const [startDate, setStartDate] = useState("");
|
|
|
|
|
- const [endDate, setEndDate] = useState("");
|
|
|
|
|
- const [saId, setSaId] = useState("");
|
|
|
|
|
|
|
+ const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
|
|
|
|
|
|
|
|
- const params: { start_date?: string; end_date?: string; super_admin_id?: number } = {};
|
|
|
|
|
- if (startDate) params.start_date = startDate;
|
|
|
|
|
- if (endDate) params.end_date = endDate;
|
|
|
|
|
- if (saId) params.super_admin_id = parseInt(saId);
|
|
|
|
|
|
|
+ const params: { start_date?: string; end_date?: string; super_admin_name?: string } = {};
|
|
|
|
|
+ if (query.startDate) params.start_date = query.startDate;
|
|
|
|
|
+ if (query.endDate) params.end_date = query.endDate;
|
|
|
|
|
+ if (query.saName) params.super_admin_name = query.saName;
|
|
|
|
|
|
|
|
const { data: dashboard, isLoading } = useQuery({
|
|
const { data: dashboard, isLoading } = useQuery({
|
|
|
queryKey: ["dashboard", params],
|
|
queryKey: ["dashboard", params],
|
|
@@ -224,13 +325,7 @@ function MonitoringPage() {
|
|
|
<div style={{ marginLeft: 220, padding: 32 }}>
|
|
<div style={{ marginLeft: 220, padding: 32 }}>
|
|
|
<div style={{ marginBottom: 28 }}><h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>监控大屏</h1><p style={{ color: T.textSec, fontSize: 14 }}>平台消费数据树状层级汇总</p></div>
|
|
<div style={{ marginBottom: 28 }}><h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>监控大屏</h1><p style={{ color: T.textSec, fontSize: 14 }}>平台消费数据树状层级汇总</p></div>
|
|
|
|
|
|
|
|
- <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="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
|
|
|
|
|
- <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>结束日期</label><input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
|
|
|
|
|
- <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>超级管理员ID</label><input type="number" value={saId} onChange={(e) => setSaId(e.target.value)} placeholder="留空查全部" style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 140 }} /></div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </Card>
|
|
|
|
|
|
|
+ <FilterBar currentQuery={query} onSearch={setQuery} />
|
|
|
|
|
|
|
|
{isLoading ? <div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div> : !dashboard ? (
|
|
{isLoading ? <div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div> : !dashboard ? (
|
|
|
<Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card>
|
|
<Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card>
|
|
@@ -255,30 +350,22 @@ function MonitoringPage() {
|
|
|
</Card>
|
|
</Card>
|
|
|
|
|
|
|
|
{dashboard.super_admins.map((sa) => (
|
|
{dashboard.super_admins.map((sa) => (
|
|
|
- <Card key={sa.super_admin_id} title={`${sa.nickname || sa.username}`} extra={<Badge active={true} text={`${sa.tenant_count} 个租户`} />}>
|
|
|
|
|
|
|
+ <Card key={sa.super_admin_id} title={`${sa.remark || sa.source_domain || sa.username}`} extra={<Badge active={true} text={`${sa.tenant_count} 个租户`} />}>
|
|
|
<div style={{ marginBottom: 12, fontSize: 13, color: T.textSec }}>消费 ¥{sa.total_consumption} · 收取 ¥{sa.total_tenant_charged}</div>
|
|
<div style={{ marginBottom: 12, fontSize: 13, color: T.textSec }}>消费 ¥{sa.total_consumption} · 收取 ¥{sa.total_tenant_charged}</div>
|
|
|
{sa.tenants.map((tenant) => (
|
|
{sa.tenants.map((tenant) => (
|
|
|
<CollapsePanel key={tenant.tenant_id} title={tenant.company_name || tenant.subdomain} badge={`${tenant.user_count} 用户 · ¥${tenant.total_consumption}`}>
|
|
<CollapsePanel key={tenant.tenant_id} title={tenant.company_name || tenant.subdomain} badge={`${tenant.user_count} 用户 · ¥${tenant.total_consumption}`}>
|
|
|
<div style={{ fontSize: 13, color: T.textSec, marginBottom: 12 }}>余额 ¥{tenant.balance} · 收取 ¥{tenant.total_tenant_charged}</div>
|
|
<div style={{ fontSize: 13, color: T.textSec, marginBottom: 12 }}>余额 ¥{tenant.balance} · 收取 ¥{tenant.total_tenant_charged}</div>
|
|
|
{tenant.users.map((u) => (
|
|
{tenant.users.map((u) => (
|
|
|
<CollapsePanel key={u.user_id} title={u.nickname || u.username} badge={`消费 ¥${u.total_consumption}`} defaultOpen={false}>
|
|
<CollapsePanel key={u.user_id} title={u.nickname || u.username} badge={`消费 ¥${u.total_consumption}`} defaultOpen={false}>
|
|
|
- <div style={{ fontSize: 12, color: T.textSec, marginBottom: 10 }}>平台收取该用户 ¥{u.tenant_actual_total}</div>
|
|
|
|
|
- {u.model_details.length > 0 && (
|
|
|
|
|
|
|
+ <div style={{ fontSize: 12, color: T.textSec, marginBottom: 10 }}>企业实际支付 ¥{u.tenant_actual_total}</div>
|
|
|
|
|
+ {u.consumption_records.length > 0 && (
|
|
|
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
|
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
|
|
- <thead><tr>{["模型", "原价", "企业折扣", "用户折扣", "金额(元)", "调用次数"].map((h) => <th key={h} style={{ padding: "6px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
|
|
|
|
|
- <tbody>{u.model_details.map((m) => (<tr key={m.model_code}><td style={{ padding: "6px 10px", fontWeight: 500 }}>{m.model_name}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{m.original_price}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{m.tenant_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{m.user_discount}</td><td style={{ padding: "6px 10px", fontWeight: 500 }}>{m.total_amount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{m.call_count.toLocaleString()}</td></tr>))}</tbody>
|
|
|
|
|
|
|
+ <thead><tr>{["订单号", "模型", "金额(元)", "用户折扣", "企业折扣", "超管折扣", "时间", "已开票"].map((h) => <th key={h} style={{ padding: "6px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
|
|
|
|
|
+ <tbody>{u.consumption_records.map((r: any, idx: number) => (<tr key={idx}><td style={{ padding: "6px 10px", color: T.textSec }}>{r.order_no || "-"}</td><td style={{ padding: "6px 10px", fontWeight: 500 }}>{r.model_name}</td><td style={{ padding: "6px 10px", fontWeight: 500 }}>{r.amount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.user_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.tenant_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.super_admin_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.created_at}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.invoiced ? "是" : "否"}</td></tr>))}</tbody>
|
|
|
</table>
|
|
</table>
|
|
|
)}
|
|
)}
|
|
|
</CollapsePanel>
|
|
</CollapsePanel>
|
|
|
))}
|
|
))}
|
|
|
- {tenant.model_summary.length > 0 && (
|
|
|
|
|
- <CollapsePanel title="模型汇总" defaultOpen={true}>
|
|
|
|
|
- <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
|
|
|
|
- <thead><tr>{["模型", "总金额(元)", "总调用次数"].map((h) => <th key={h} style={{ padding: "6px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
|
|
|
|
|
- <tbody>{tenant.model_summary.map((m) => (<tr key={m.model_code}><td style={{ padding: "6px 10px", fontWeight: 500 }}>{m.model_name}</td><td style={{ padding: "6px 10px", fontWeight: 500 }}>{m.total_amount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{m.call_count.toLocaleString()}</td></tr>))}</tbody>
|
|
|
|
|
- </table>
|
|
|
|
|
- </CollapsePanel>
|
|
|
|
|
- )}
|
|
|
|
|
</CollapsePanel>
|
|
</CollapsePanel>
|
|
|
))}
|
|
))}
|
|
|
</Card>
|
|
</Card>
|
|
@@ -289,14 +376,581 @@ function MonitoringPage() {
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/* ===== 通用页面布局 ===== */
|
|
|
|
|
+function PageLayout({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div style={{ marginLeft: 220, padding: 32 }}>
|
|
|
|
|
+ <div style={{ marginBottom: 28 }}>
|
|
|
|
|
+ <h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>{title}</h1>
|
|
|
|
|
+ <p style={{ color: T.textSec, fontSize: 14 }}>{subtitle}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {children}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 筛选条件组件(复用)— 输入与查询解耦,点击"查询"才触发请求,支持查询历史管理 */
|
|
|
|
|
+function FilterBar({ currentQuery, onSearch, showTenantFilter }: {
|
|
|
|
|
+ currentQuery: { startDate: string; endDate: string; saName: string; tenantName: string };
|
|
|
|
|
+ onSearch: (q: { startDate: string; endDate: string; saName: string; tenantName: string }) => void;
|
|
|
|
|
+ showTenantFilter?: boolean;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const [input, setInput] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ setInput({ startDate: currentQuery.startDate, endDate: currentQuery.endDate, saName: currentQuery.saName, tenantName: currentQuery.tenantName });
|
|
|
|
|
+ }, [currentQuery]);
|
|
|
|
|
+
|
|
|
|
|
+ const handleSearch = () => {
|
|
|
|
|
+ onSearch({ startDate: input.startDate, endDate: input.endDate, saName: input.saName, tenantName: input.tenantName });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const hasActiveFilter = currentQuery.startDate || currentQuery.endDate || currentQuery.saName || currentQuery.tenantName;
|
|
|
|
|
+
|
|
|
|
|
+ const removeCondition = (key: string) => {
|
|
|
|
|
+ const next = { ...currentQuery, [key]: "" };
|
|
|
|
|
+ onSearch(next);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const clearAll = () => {
|
|
|
|
|
+ onSearch({ startDate: "", endDate: "", saName: "", tenantName: "" });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Card title="筛选条件">
|
|
|
|
|
+ <div style={{ display: "flex", gap: 16, alignItems: "flex-end", flexWrap: "wrap", marginBottom: hasActiveFilter ? 12 : 0 }}>
|
|
|
|
|
+ <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>开始日期</label><input type="date" value={input.startDate} onChange={(e) => setInput({ ...input, startDate: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
|
|
|
|
|
+ <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>结束日期</label><input type="date" value={input.endDate} onChange={(e) => setInput({ ...input, endDate: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
|
|
|
|
|
+ <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>超级管理员名称</label><input type="text" value={input.saName} onChange={(e) => setInput({ ...input, saName: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 160 }} /></div>
|
|
|
|
|
+ {showTenantFilter && (
|
|
|
|
|
+ <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>租户名称</label><input type="text" value={input.tenantName} onChange={(e) => setInput({ ...input, tenantName: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 160 }} /></div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <button onClick={handleSearch} style={{ padding: "8px 20px", borderRadius: 6, fontSize: 14, cursor: "pointer", border: "none", background: T.primary, color: "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>查询</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {hasActiveFilter && (
|
|
|
|
|
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap", paddingTop: 12, borderTop: `1px solid ${T.border}` }}>
|
|
|
|
|
+ {currentQuery.startDate && <Tag label={`开始: ${currentQuery.startDate}`} onRemove={() => removeCondition("startDate")} />}
|
|
|
|
|
+ {currentQuery.endDate && <Tag label={`结束: ${currentQuery.endDate}`} onRemove={() => removeCondition("endDate")} />}
|
|
|
|
|
+ {currentQuery.saName && <Tag label={`超管: ${currentQuery.saName}`} onRemove={() => removeCondition("saName")} />}
|
|
|
|
|
+ {currentQuery.tenantName && <Tag label={`租户: ${currentQuery.tenantName}`} onRemove={() => removeCondition("tenantName")} />}
|
|
|
|
|
+ <button onClick={clearAll} style={{ padding: "2px 10px", borderRadius: 99, fontSize: 12, cursor: "pointer", border: "none", background: "#fef2f2", color: T.danger, fontWeight: 500 }}>清空全部</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function Tag({ label, onRemove }: { label: string; onRemove: () => void }) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 12px", borderRadius: 99, fontSize: 12, background: "#eff6ff", color: T.primary, fontWeight: 500 }}>
|
|
|
|
|
+ {label}
|
|
|
|
|
+ <span onClick={onRemove} style={{ cursor: "pointer", fontWeight: 700, opacity: 0.6 }} title="移除该条件">✕</span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 带提示的表头 */
|
|
|
|
|
+function TooltipTh({ text, tip }: { text: string; tip: string }) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }} title={tip}>
|
|
|
|
|
+ {text}
|
|
|
|
|
+ <span style={{ marginLeft: 3, fontSize: 10, opacity: 0.5, cursor: "help" }}>?</span>
|
|
|
|
|
+ </th>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== 超级管理员页面 ===== */
|
|
|
|
|
+function SuperAdminsPage() {
|
|
|
|
|
+ const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
|
|
|
|
|
+
|
|
|
|
|
+ const params: { start_date?: string; end_date?: string; super_admin_name?: string } = {};
|
|
|
|
|
+ if (query.startDate) params.start_date = query.startDate;
|
|
|
|
|
+ if (query.endDate) params.end_date = query.endDate;
|
|
|
|
|
+ if (query.saName) params.super_admin_name = query.saName;
|
|
|
|
|
+
|
|
|
|
|
+ const hasDate = Boolean(query.startDate && query.endDate);
|
|
|
|
|
+
|
|
|
|
|
+ const { data: dashboard, isLoading: dashLoading } = useQuery({
|
|
|
|
|
+ queryKey: ["dashboard", params],
|
|
|
|
|
+ queryFn: () => monitoringApi.getDashboard(Object.keys(params).length ? params : undefined).then((r) => r.data),
|
|
|
|
|
+ enabled: !hasDate,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const { data: dailyStats, isLoading: dailyLoading } = useQuery({
|
|
|
|
|
+ queryKey: ["daily-stats", params],
|
|
|
|
|
+ queryFn: () => monitoringApi.getDailyStats({ start_date: query.startDate!, end_date: query.endDate!, super_admin_name: query.saName || undefined }).then((r) => r.data),
|
|
|
|
|
+ enabled: hasDate,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (dashLoading || dailyLoading) return <PageLayout title="超级管理员" subtitle="查看各超级管理员消费数据"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
|
|
|
|
|
+
|
|
|
|
|
+ if (hasDate) {
|
|
|
|
|
+ const saStats = dailyStats?.sa_stats || [];
|
|
|
|
|
+ return (
|
|
|
|
|
+ <PageLayout title="超级管理员" subtitle={`${query.startDate} ~ ${query.endDate} 每日消费统计`}>
|
|
|
|
|
+ <FilterBar currentQuery={query} onSearch={setQuery} />
|
|
|
|
|
+ {saStats.length === 0 ? <Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}>该筛选条件下暂无每日消费数据</div></Card> : saStats.map((sa: any, saIdx: number) => (
|
|
|
|
|
+ <Card key={saIdx} title={`${sa.sa_name} 每日消费明细`}>
|
|
|
|
|
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
|
|
|
|
+ <thead><tr>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>日期</th>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th>
|
|
|
|
|
+ <TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" />
|
|
|
|
|
+ </tr></thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {(sa.tenants || []).map((t: any, idx: number) => (
|
|
|
|
|
+ <tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}>
|
|
|
|
|
+ <td style={{ padding: "8px 12px", fontWeight: 500 }}>{t.date}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 12px", color: T.textSec }}>{t.tenant_name || "-"}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 12px" }}>{t.consumption}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 12px" }}>{t.charged}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </PageLayout>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!dashboard) return <PageLayout title="超级管理员" subtitle="查看各超级管理员消费数据"><Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card></PageLayout>;
|
|
|
|
|
+
|
|
|
|
|
+ const totalCount = dashboard.super_admins.length;
|
|
|
|
|
+ const totalConsumption = dashboard.super_admins.reduce((s: number, sa: any) => s + parseFloat(sa.total_consumption || 0), 0).toFixed(4);
|
|
|
|
|
+ const totalCharged = dashboard.super_admins.reduce((s: number, sa: any) => s + parseFloat(sa.total_tenant_charged || 0), 0).toFixed(4);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <PageLayout title="超级管理员" subtitle="查看各超级管理员消费数据">
|
|
|
|
|
+ <FilterBar currentQuery={query} onSearch={setQuery} />
|
|
|
|
|
+ <Card title="统计">
|
|
|
|
|
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
|
|
|
|
|
+ <div style={{ background: T.bg, borderRadius: 8, padding: "14px 18px" }}><div style={{ fontSize: 12, color: T.textSec, marginBottom: 4 }}>管理员数</div><div style={{ fontSize: 20, fontWeight: 700, color: T.heading }}>{totalCount}</div></div>
|
|
|
|
|
+ <div style={{ background: T.bg, borderRadius: 8, padding: "14px 18px" }}><div style={{ fontSize: 12, color: T.textSec, marginBottom: 4 }}>总消费(元)</div><div style={{ fontSize: 20, fontWeight: 700, color: T.heading }}>{totalConsumption}</div></div>
|
|
|
|
|
+ <div style={{ background: T.bg, borderRadius: 8, padding: "14px 18px" }}><div style={{ fontSize: 12, color: T.textSec, marginBottom: 4 }}>总收取(元)</div><div style={{ fontSize: 20, fontWeight: 700, color: T.heading }}>{totalCharged}</div></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ {dashboard.super_admins.map((sa: any) => (
|
|
|
|
|
+ <Card key={sa.super_admin_id} title={`${sa.nickname || sa.username}`} extra={<Badge active={true} text={`${sa.tenant_count} 个租户`} />}>
|
|
|
|
|
+ <div style={{ marginBottom: 12, fontSize: 13, color: T.textSec }}>消费 ¥{sa.total_consumption} · 收取 ¥{sa.total_tenant_charged}</div>
|
|
|
|
|
+ {sa.tenants.length === 0 ? <div style={{ textAlign: "center", padding: 20, color: T.textSec, fontSize: 13 }}>暂无租户数据</div> : (
|
|
|
|
|
+ <table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
|
|
|
+ <thead><tr>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>用户数</th>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th>
|
|
|
|
|
+ <TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" />
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>余额(元)</th>
|
|
|
|
|
+ </tr></thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {sa.tenants.map((t: any) => (
|
|
|
|
|
+ <tr key={t.tenant_id} style={{ borderBottom: `1px solid ${T.border}` }}>
|
|
|
|
|
+ <td style={{ padding: "10px 12px", fontWeight: 500 }}>{t.company_name || t.subdomain}</td>
|
|
|
|
|
+ <td style={{ padding: "10px 12px" }}>{t.user_count}</td>
|
|
|
|
|
+ <td style={{ padding: "10px 12px" }}>{t.total_consumption}</td>
|
|
|
|
|
+ <td style={{ padding: "10px 12px" }}>{t.total_tenant_charged}</td>
|
|
|
|
|
+ <td style={{ padding: "10px 12px", color: T.textSec }}>{t.balance}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </PageLayout>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== 租户页面 ===== */
|
|
|
|
|
+function TenantsPage() {
|
|
|
|
|
+ const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
|
|
|
|
|
+
|
|
|
|
|
+ const params: { start_date?: string; end_date?: string; super_admin_name?: string } = {};
|
|
|
|
|
+ if (query.startDate) params.start_date = query.startDate;
|
|
|
|
|
+ if (query.endDate) params.end_date = query.endDate;
|
|
|
|
|
+ if (query.saName) params.super_admin_name = query.saName;
|
|
|
|
|
+
|
|
|
|
|
+ const hasDate = Boolean(query.startDate && query.endDate);
|
|
|
|
|
+
|
|
|
|
|
+ const { data: dashboard, isLoading: dashLoading } = useQuery({
|
|
|
|
|
+ queryKey: ["dashboard", params],
|
|
|
|
|
+ queryFn: () => monitoringApi.getDashboard(Object.keys(params).length ? params : undefined).then((r) => r.data),
|
|
|
|
|
+ enabled: !hasDate,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const { data: dailyStats, isLoading: dailyLoading } = useQuery({
|
|
|
|
|
+ queryKey: ["daily-stats", params],
|
|
|
|
|
+ queryFn: () => monitoringApi.getDailyStats({ start_date: query.startDate!, end_date: query.endDate!, super_admin_name: query.saName || undefined, tenant_name: query.tenantName || undefined }).then((r) => r.data),
|
|
|
|
|
+ enabled: hasDate,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (dashLoading || dailyLoading) return <PageLayout title="租户" subtitle="查看各租户消费数据"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
|
|
|
|
|
+
|
|
|
|
|
+ if (hasDate) {
|
|
|
|
|
+ const saStats = dailyStats?.sa_stats || [];
|
|
|
|
|
+ const rows: any[] = [];
|
|
|
|
|
+ saStats.forEach((sa: any) => {
|
|
|
|
|
+ (sa.tenants || []).forEach((t: any) => {
|
|
|
|
|
+ rows.push({ sa_name: sa.sa_name, tenant_name: t.tenant_name || "-", date: t.date, consumption: t.consumption, charged: t.charged });
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ return (
|
|
|
|
|
+ <PageLayout title="租户" subtitle={`${query.startDate} ~ ${query.endDate} 每日消费统计`}>
|
|
|
|
|
+ <FilterBar currentQuery={query} onSearch={setQuery} showTenantFilter />
|
|
|
|
|
+ <Card title={`每日明细(共 ${rows.length} 条)`}>
|
|
|
|
|
+ {rows.length === 0 ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>该筛选条件下暂无每日消费数据</div> : (
|
|
|
|
|
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
|
|
|
|
+ <thead><tr>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>管理员</th>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>日期</th>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th>
|
|
|
|
|
+ <TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" />
|
|
|
|
|
+ </tr></thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {rows.map((r: any, idx: number) => (
|
|
|
|
|
+ <tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}>
|
|
|
|
|
+ <td style={{ padding: "8px 12px", color: T.textSec }}>{r.sa_name}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 12px", fontWeight: 500 }}>{r.date}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 12px" }}>{r.tenant_name}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 12px" }}>{r.consumption}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 12px" }}>{r.charged}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </PageLayout>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!dashboard) return <PageLayout title="租户" subtitle="查看各租户消费数据"><Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card></PageLayout>;
|
|
|
|
|
+
|
|
|
|
|
+ const allTenants: any[] = [];
|
|
|
|
|
+ dashboard.super_admins.forEach((sa: any) => {
|
|
|
|
|
+ sa.tenants.forEach((t: any) => {
|
|
|
|
|
+ allTenants.push({ ...t, sa_name: sa.remark || sa.source_domain || sa.username });
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const filteredTenants = query.tenantName
|
|
|
|
|
+ ? allTenants.filter((t: any) => t.company_name?.toLowerCase().includes(query.tenantName.toLowerCase()) || t.subdomain?.toLowerCase().includes(query.tenantName.toLowerCase()))
|
|
|
|
|
+ : allTenants;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <PageLayout title="租户" subtitle="查看各租户消费数据">
|
|
|
|
|
+ <FilterBar currentQuery={query} onSearch={setQuery} showTenantFilter />
|
|
|
|
|
+ <Card title={`租户列表(共 ${filteredTenants.length} 个)`}>
|
|
|
|
|
+ {filteredTenants.length === 0 ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>暂无匹配的租户数据</div> : (
|
|
|
|
|
+ <table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
|
|
|
+ <thead><tr>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>管理员</th>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>用户数</th>
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th>
|
|
|
|
|
+ <TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" />
|
|
|
|
|
+ <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>余额(元)</th>
|
|
|
|
|
+ </tr></thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {filteredTenants.map((t: any) => (
|
|
|
|
|
+ <tr key={`${t.sa_name}-${t.tenant_id}`} style={{ borderBottom: `1px solid ${T.border}` }}>
|
|
|
|
|
+ <td style={{ padding: "10px 12px", color: T.textSec }}>{t.sa_name}</td>
|
|
|
|
|
+ <td style={{ padding: "10px 12px", fontWeight: 500 }}>{t.company_name || t.subdomain}</td>
|
|
|
|
|
+ <td style={{ padding: "10px 12px" }}>{t.user_count}</td>
|
|
|
|
|
+ <td style={{ padding: "10px 12px" }}>{t.total_consumption}</td>
|
|
|
|
|
+ <td style={{ padding: "10px 12px" }}>{t.total_tenant_charged}</td>
|
|
|
|
|
+ <td style={{ padding: "10px 12px", color: T.textSec }}>{t.balance}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </PageLayout>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== 用户页面 ===== */
|
|
|
|
|
+function UsersPage() {
|
|
|
|
|
+ const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
|
|
|
|
|
+
|
|
|
|
|
+ const params: { start_date?: string; end_date?: string; super_admin_name?: string; tenant_name?: string } = {};
|
|
|
|
|
+ if (query.startDate) params.start_date = query.startDate;
|
|
|
|
|
+ if (query.endDate) params.end_date = query.endDate;
|
|
|
|
|
+ if (query.saName) params.super_admin_name = query.saName;
|
|
|
|
|
+ if (query.tenantName) params.tenant_name = query.tenantName;
|
|
|
|
|
+
|
|
|
|
|
+ const { data: details, isLoading } = useQuery({
|
|
|
|
|
+ queryKey: ["consumption-details", params],
|
|
|
|
|
+ queryFn: () => monitoringApi.getConsumptionDetails(Object.keys(params).length ? params : undefined).then((r) => r.data),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ 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>;
|
|
|
|
|
+
|
|
|
|
|
+ const records = details.records || [];
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <PageLayout title="用户" subtitle="逐笔消费明细,用于对账">
|
|
|
|
|
+ <FilterBar currentQuery={query} onSearch={setQuery} showTenantFilter />
|
|
|
|
|
+ <Card title={`消费明细(共 ${details.total} 笔)`}>
|
|
|
|
|
+ {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>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </PageLayout>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== License 许可页面 ===== */
|
|
|
|
|
+function LicensePage() {
|
|
|
|
|
+ const queryClient = useQueryClient();
|
|
|
|
|
+ const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
|
|
|
|
+ const showToast = (message: string, type: "success" | "error") => {
|
|
|
|
|
+ setToast({ message, type });
|
|
|
|
|
+ setTimeout(() => setToast(null), 4000);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 域名列表(仅统计卡片用)
|
|
|
|
|
+ const { data: domains, isLoading: domainsLoading } = useQuery({
|
|
|
|
|
+ queryKey: ["license-domains"],
|
|
|
|
|
+ queryFn: () => domainApi.list().then((r) => r.data),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 超级管理员下拉选项
|
|
|
|
|
+ const { data: saOptions, isLoading: saLoading } = useQuery({
|
|
|
|
|
+ queryKey: ["sa-options"],
|
|
|
|
|
+ queryFn: () => licenseApi.getSuperAdmins().then((r) => r.data),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // License 列表
|
|
|
|
|
+ const { data: licenses, isLoading: licensesLoading } = useQuery({
|
|
|
|
|
+ queryKey: ["licenses"],
|
|
|
|
|
+ queryFn: () => licenseApi.list({ size: 100 }).then((r) => r.data),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 创建表单
|
|
|
|
|
+ const [createForm, setCreateForm] = useState({ super_admin_id: "", license_key: "", expires_at: "", max_tenants: "", max_users: "", remark: "" });
|
|
|
|
|
+
|
|
|
|
|
+ const createMutation = useMutation({
|
|
|
|
|
+ mutationFn: (data: any) => licenseApi.create(data),
|
|
|
|
|
+ onSuccess: () => {
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ["licenses"] });
|
|
|
|
|
+ setCreateForm({ super_admin_id: "", license_key: "", expires_at: "", max_tenants: "", max_users: "", remark: "" });
|
|
|
|
|
+ showToast("License 创建成功", "success");
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: (err: any) => { showToast(err.response?.data?.detail || "创建失败", "error"); },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const revokeMutation = useMutation({
|
|
|
|
|
+ mutationFn: (id: number) => licenseApi.revoke(id),
|
|
|
|
|
+ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已吊销", "success"); },
|
|
|
|
|
+ onError: () => { showToast("吊销失败", "error"); },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const deleteMutation = useMutation({
|
|
|
|
|
+ mutationFn: (id: number) => licenseApi.delete(id),
|
|
|
|
|
+ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已删除", "success"); },
|
|
|
|
|
+ onError: () => { showToast("删除失败", "error"); },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const restoreMutation = useMutation({
|
|
|
|
|
+ mutationFn: (id: number) => licenseApi.restore(id),
|
|
|
|
|
+ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已恢复", "success"); },
|
|
|
|
|
+ onError: () => { showToast("恢复失败", "error"); },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const updateMutation = useMutation({
|
|
|
|
|
+ mutationFn: ({ id, data }: { id: number; data: { license_key?: string; expires_at?: string } }) =>
|
|
|
|
|
+ licenseApi.update(id, data),
|
|
|
|
|
+ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); setEditTarget(null); showToast("License 已更新", "success"); },
|
|
|
|
|
+ onError: () => { showToast("更新失败", "error"); },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 编辑弹窗
|
|
|
|
|
+ const [editTarget, setEditTarget] = useState<any | null>(null);
|
|
|
|
|
+ const [editForm, setEditForm] = useState({ license_key: "", expires_at: "" });
|
|
|
|
|
+
|
|
|
|
|
+ const handleSaveEdit = () => {
|
|
|
|
|
+ if (!editTarget) return;
|
|
|
|
|
+ const data: { license_key?: string; expires_at?: string } = {};
|
|
|
|
|
+ if (editForm.license_key && editForm.license_key !== editTarget.license_key) data.license_key = editForm.license_key;
|
|
|
|
|
+ if (editForm.expires_at && editForm.expires_at !== (editTarget.expires_at || "").split(".")[0]?.substring(0, 16)) data.expires_at = editForm.expires_at;
|
|
|
|
|
+ if (Object.keys(data).length === 0) { showToast("未做任何修改", "error"); return; }
|
|
|
|
|
+ updateMutation.mutate({ id: editTarget.id, data });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (editTarget) {
|
|
|
|
|
+ setEditForm({
|
|
|
|
|
+ license_key: editTarget.license_key || "",
|
|
|
|
|
+ expires_at: editTarget.expires_at ? editTarget.expires_at.split(".")[0].substring(0, 16) : "",
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [editTarget?.id]);
|
|
|
|
|
+
|
|
|
|
|
+ const handleCreate = (e: React.FormEvent) => {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ if (!createForm.super_admin_id || !createForm.license_key || !createForm.expires_at) {
|
|
|
|
|
+ showToast("请填写必填项(超级管理员、License Key、过期时间)", "error");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const data: any = {
|
|
|
|
|
+ super_admin_id: parseInt(createForm.super_admin_id),
|
|
|
|
|
+ license_key: createForm.license_key,
|
|
|
|
|
+ expires_at: createForm.expires_at,
|
|
|
|
|
+ };
|
|
|
|
|
+ if (createForm.max_tenants) data.max_tenants = parseInt(createForm.max_tenants);
|
|
|
|
|
+ if (createForm.max_users) data.max_users_per_tenant = parseInt(createForm.max_users);
|
|
|
|
|
+ if (createForm.remark) data.remark = createForm.remark;
|
|
|
|
|
+ createMutation.mutate(data);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const statusColor = (status: string) => {
|
|
|
|
|
+ if (status === "active") return { bg: "#f0fdf4", color: "#16a34a", text: "有效" };
|
|
|
|
|
+ if (status === "expired") return { bg: "#fef2f2", color: "#dc2626", text: "已过期" };
|
|
|
|
|
+ if (status === "revoked") return { bg: "#f1f5f9", color: "#64748b", text: "已吊销" };
|
|
|
|
|
+ return { bg: "#f1f5f9", color: "#94a3b8", text: status };
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if (domainsLoading || saLoading || licensesLoading) return <PageLayout title="License 许可" subtitle="管理系统授权许可"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <>
|
|
|
|
|
+ {toast && <Toast message={toast.message} type={toast.type} />}
|
|
|
|
|
+ <PageLayout title="License 许可" subtitle="管理系统授权许可">
|
|
|
|
|
+ {/* 统计卡片 */}
|
|
|
|
|
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 24 }}>
|
|
|
|
|
+ {[
|
|
|
|
|
+ { label: "监控域名数", value: domains?.length ?? 0 },
|
|
|
|
|
+ { label: "有效 License", value: licenses?.items?.filter((l: any) => l.status === "active").length ?? 0 },
|
|
|
|
|
+ { label: "已过期", value: licenses?.items?.filter((l: any) => l.status === "expired").length ?? 0 },
|
|
|
|
|
+ { label: "已吊销", value: licenses?.items?.filter((l: any) => l.status === "revoked").length ?? 0 },
|
|
|
|
|
+ ].map((s) => (
|
|
|
|
|
+ <div key={s.label} style={{ background: T.card, borderRadius: 10, padding: "18px 22px", boxShadow: "0 1px 3px rgba(0,0,0,0.08)", border: `1px solid ${T.border}` }}>
|
|
|
|
|
+ <div style={{ fontSize: 13, color: T.textSec, marginBottom: 6 }}>{s.label}</div>
|
|
|
|
|
+ <div style={{ fontSize: 26, fontWeight: 700, color: T.heading }}>{s.value}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 创建 License */}
|
|
|
|
|
+ <Card title="创建 License">
|
|
|
|
|
+ <form onSubmit={handleCreate} style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-end" }}>
|
|
|
|
|
+ <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>超级管理员 *</label>
|
|
|
|
|
+ <select value={createForm.super_admin_id} onChange={(e) => setCreateForm({ ...createForm, super_admin_id: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, minWidth: 200, outline: "none", background: "#fff" }}>
|
|
|
|
|
+ <option value="">-- 请选择 --</option>
|
|
|
|
|
+ {saOptions?.map((sa: any) => <option key={sa.id} value={sa.id}>{sa.remark || sa.source_domain || sa.username} (ID: {sa.id})</option>)}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>License Key *</label><input value={createForm.license_key} onChange={(e) => setCreateForm({ ...createForm, license_key: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 220, outline: "none" }} /></div>
|
|
|
|
|
+ <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>过期时间 *</label><input type="datetime-local" value={createForm.expires_at} onChange={(e) => setCreateForm({ ...createForm, expires_at: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
|
|
|
|
|
+ <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>最大租户数</label><input type="number" value={createForm.max_tenants} onChange={(e) => setCreateForm({ ...createForm, max_tenants: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 100, outline: "none" }} /></div>
|
|
|
|
|
+ <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>每租户最大用户数</label><input type="number" value={createForm.max_users} onChange={(e) => setCreateForm({ ...createForm, max_users: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 120, outline: "none" }} /></div>
|
|
|
|
|
+ <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>备注</label><input value={createForm.remark} onChange={(e) => setCreateForm({ ...createForm, remark: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 150, outline: "none" }} /></div>
|
|
|
|
|
+ <button type="submit" disabled={createMutation.isPending} style={{ padding: "8px 20px", borderRadius: 6, fontSize: 14, cursor: "pointer", border: "none", background: createMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>{createMutation.isPending ? "创建中..." : "创建"}</button>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+
|
|
|
|
|
+ {/* License 列表 */}
|
|
|
|
|
+ <Card title={`License 列表(共 ${licenses?.total ?? 0} 条)`}>
|
|
|
|
|
+ {!licenses?.items?.length ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>暂无 License 记录</div> : (
|
|
|
|
|
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
|
|
|
|
+ <thead><tr>{["超管", "关联域名", "License Key", "过期时间", "状态", "剩余天数", "最大租户", "备注", "操作"].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>
|
|
|
|
|
+ {licenses.items.map((l: any) => {
|
|
|
|
|
+ const sc = statusColor(l.status);
|
|
|
|
|
+ const daysLeft = Math.ceil((new Date(l.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
|
|
|
|
+ return (
|
|
|
|
|
+ <tr key={l.id} style={{ borderBottom: `1px solid ${T.border}` }}>
|
|
|
|
|
+ <td style={{ padding: "8px 10px" }}>{l.super_admin_name || l.super_admin_id}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", fontFamily: "monospace", fontSize: 12, color: T.textSec }}>{l.domain || "-"}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", fontFamily: "monospace", fontWeight: 500, fontSize: 12 }}>{l.license_key}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", fontSize: 12 }}>{l.expires_at ? l.expires_at.split(".")[0]?.replace("T", " ") : "-"}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px" }}>
|
|
|
|
|
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 8px", borderRadius: 99, fontSize: 12, background: sc.bg, color: sc.color }}>{sc.text}</span>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", color: daysLeft <= 7 && l.status === "active" ? T.danger : T.text }}>{l.status === "active" ? `${daysLeft} 天` : "-"}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", color: T.textSec }}>{l.max_tenants ?? "-"}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", color: T.textSec, maxWidth: 150, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={l.remark || ""}>{l.remark || "-"}</td>
|
|
|
|
|
+ <td style={{ padding: "8px 10px", display: "flex", gap: 6 }}>
|
|
|
|
|
+ <button onClick={() => setEditTarget(l)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary }}>编辑</button>
|
|
|
|
|
+ {l.status === "active" && <button onClick={() => revokeMutation.mutate(l.id)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid #f59e0b`, background: "transparent", color: "#f59e0b" }}>吊销</button>}
|
|
|
|
|
+ {l.status === "revoked" && <button onClick={() => restoreMutation.mutate(l.id)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid #22c55e`, background: "transparent", color: "#22c55e" }}>恢复</button>}
|
|
|
|
|
+ <button onClick={() => deleteMutation.mutate(l.id)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: "transparent", color: T.danger }}>删除</button>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Card>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 编辑弹窗 */}
|
|
|
|
|
+ {editTarget && (
|
|
|
|
|
+ <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }} onClick={() => setEditTarget(null)}>
|
|
|
|
|
+ <div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", borderRadius: 12, padding: 28, width: 420, boxShadow: "0 20px 60px rgba(0,0,0,0.2)" }}>
|
|
|
|
|
+ <h3 style={{ margin: "0 0 16px", fontSize: 16, color: T.heading }}>编辑 License #{editTarget.id}</h3>
|
|
|
|
|
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>License Key</label>
|
|
|
|
|
+ <input value={editForm.license_key} onChange={(e) => setEditForm({ ...editForm, license_key: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: "100%", boxSizing: "border-box", outline: "none" }} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>过期时间</label>
|
|
|
|
|
+ <input type="datetime-local" value={editForm.expires_at} onChange={(e) => setEditForm({ ...editForm, expires_at: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: "100%", boxSizing: "border-box", outline: "none" }} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 20 }}>
|
|
|
|
|
+ <button onClick={() => setEditTarget(null)} style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text }}>取消</button>
|
|
|
|
|
+ <button onClick={handleSaveEdit} disabled={updateMutation.isPending} style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: updateMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff", fontWeight: 500 }}>{updateMutation.isPending ? "保存中..." : "保存"}</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </PageLayout>
|
|
|
|
|
+ </>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/* ===== App ===== */
|
|
/* ===== App ===== */
|
|
|
function App() {
|
|
function App() {
|
|
|
const [page, setPage] = useState("domains");
|
|
const [page, setPage] = useState("domains");
|
|
|
|
|
+ const pageMap: Record<string, React.ReactNode> = {
|
|
|
|
|
+ domains: <DomainsPage />,
|
|
|
|
|
+ monitoring: <MonitoringPage />,
|
|
|
|
|
+ super_admins: <SuperAdminsPage />,
|
|
|
|
|
+ tenants: <TenantsPage />,
|
|
|
|
|
+ users: <UsersPage />,
|
|
|
|
|
+ license: <LicensePage />,
|
|
|
|
|
+ };
|
|
|
return (
|
|
return (
|
|
|
<>
|
|
<>
|
|
|
<GlobalStyle />
|
|
<GlobalStyle />
|
|
|
<Sidebar page={page} setPage={setPage} />
|
|
<Sidebar page={page} setPage={setPage} />
|
|
|
- {page === "domains" ? <DomainsPage /> : <MonitoringPage />}
|
|
|
|
|
|
|
+ {pageMap[page] || <DomainsPage />}
|
|
|
</>
|
|
</>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|