|
|
@@ -1,1089 +1,38 @@
|
|
|
-import { useState, useEffect } from "react";
|
|
|
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
-import { domainApi } from "./api/domains";
|
|
|
-import { monitoringApi } from "./api/monitoring";
|
|
|
-import { licenseApi } from "./api/license";
|
|
|
-import type { MonitoredDomain, MonitoredDomainCreate } from "./types/domain";
|
|
|
+import { Routes, Route } from "react-router-dom";
|
|
|
+import { globalStyle } from "./theme";
|
|
|
+import { MainLayout } from "./layouts/MainLayout";
|
|
|
+import { ProtectedRoute } from "./pages/LoginPage";
|
|
|
+import { DashboardPage } from "./pages/DashboardPage";
|
|
|
+import { DomainsPage } from "./pages/DomainsPage";
|
|
|
+import { MonitoringPage } from "./pages/MonitoringPage";
|
|
|
+import { SuperAdminsPage } from "./pages/SuperAdminsPage";
|
|
|
+import { TenantsPage } from "./pages/TenantsPage";
|
|
|
+import { UsersPage } from "./pages/UsersPage";
|
|
|
+import { LicensePage } from "./pages/LicensePage";
|
|
|
+import { FetchLogsPage } from "./pages/FetchLogsPage";
|
|
|
+import { LoginPage } from "./pages/LoginPage";
|
|
|
|
|
|
-/* ===== 颜色主题 ===== */
|
|
|
-const T = {
|
|
|
- primary: "#6366f1",
|
|
|
- primaryHover: "#4f46e5",
|
|
|
- danger: "#ef4444",
|
|
|
- success: "#22c55e",
|
|
|
- sidebar: "#1e293b",
|
|
|
- sidebarText: "#94a3b8",
|
|
|
- sidebarActive: "#ffffff",
|
|
|
- bg: "#f8fafc",
|
|
|
- card: "#ffffff",
|
|
|
- text: "#334155",
|
|
|
- textSec: "#94a3b8",
|
|
|
- border: "#e2e8f0",
|
|
|
- heading: "#0f172a",
|
|
|
-};
|
|
|
-
|
|
|
-/* ===== 全局样式 ===== */
|
|
|
-const globalStyle = `
|
|
|
- *, *::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; }
|
|
|
- @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 Icon({ d }: { d: string }) {
|
|
|
- return <svg style={{ width: 18, height: 18, flexShrink: 0 }} viewBox="0 0 24 24" fill="currentColor" opacity={0.7}><path d={d} /></svg>;
|
|
|
-}
|
|
|
-
|
|
|
-function Badge({ active, text }: { active: boolean; text: string }) {
|
|
|
- return (
|
|
|
- <span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "3px 10px", borderRadius: 99, fontSize: 12, fontWeight: 500, background: active ? "#f0fdf4" : "#fef2f2", color: active ? "#16a34a" : "#dc2626" }}>
|
|
|
- <span style={{ width: 6, height: 6, borderRadius: "50%", background: "currentColor" }} />{text}
|
|
|
- </span>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-function Card({ title, children, extra }: { title: string; children: React.ReactNode; extra?: React.ReactNode }) {
|
|
|
- return (
|
|
|
- <div style={{ background: T.card, borderRadius: 10, boxShadow: "0 1px 3px rgba(0,0,0,0.08)", border: `1px solid ${T.border}`, marginBottom: 20 }}>
|
|
|
- <div style={{ padding: "14px 22px", borderBottom: `1px solid ${T.border}`, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
|
|
- <h2 style={{ fontSize: 15, fontWeight: 600, color: T.heading }}>{title}</h2>
|
|
|
- {extra}
|
|
|
- </div>
|
|
|
- <div style={{ padding: 22 }}>{children}</div>
|
|
|
- </div>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-/** 开关组件 */
|
|
|
-function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
|
|
|
- return (
|
|
|
- <label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", fontSize: 14 }}>
|
|
|
- <div onClick={() => onChange(!checked)} style={{ width: 40, height: 22, borderRadius: 11, position: "relative", transition: "background 0.2s", background: checked ? T.primary : T.border }}>
|
|
|
- <div style={{ width: 18, height: 18, borderRadius: "50%", background: "#fff", position: "absolute", top: 2, left: checked ? 20 : 2, transition: "left 0.2s", boxShadow: "0 1px 2px rgba(0,0,0,0.15)" }} />
|
|
|
- </div>
|
|
|
- {label && <span style={{ color: T.text }}>{label}</span>}
|
|
|
- </label>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-/** 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 }) {
|
|
|
- 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: "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" },
|
|
|
- { 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 (
|
|
|
- <div style={{ width: 220, minHeight: "100vh", background: T.sidebar, padding: "24px 12px", position: "fixed", top: 0, left: 0, zIndex: 100 }}>
|
|
|
- <div style={{ color: "#fff", fontSize: 16, fontWeight: 700, padding: "0 12px 20px", borderBottom: "1px solid rgba(255,255,255,0.08)", marginBottom: 12, display: "flex", alignItems: "center", gap: 8 }}>
|
|
|
- <span style={{ width: 28, height: 28, background: T.primary, borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 13, fontWeight: 700 }}>D</span>
|
|
|
- 域名流水监控
|
|
|
- </div>
|
|
|
- {items.map((item) => (
|
|
|
- <div key={item.key} onClick={() => setPage(item.key)} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", borderRadius: 8, fontSize: 14, cursor: "pointer", marginBottom: 2, color: page === item.key ? "#fff" : T.sidebarText, background: page === item.key ? T.primary : "transparent", transition: "all 0.2s" }}>
|
|
|
- <Icon d={item.icon} />{item.label}
|
|
|
- </div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-/** 爬取控制面板 — 自动 + 手动 */
|
|
|
-function FetchControls() {
|
|
|
- const queryClient = useQueryClient();
|
|
|
- const [autoFetch, setAutoFetch] = useState(false);
|
|
|
- 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 [configLoaded, setConfigLoaded] = useState(false);
|
|
|
-
|
|
|
- // 加载配置
|
|
|
- useEffect(() => {
|
|
|
- domainApi.getSchedule().then(res => {
|
|
|
- setAutoFetch(res.data.enabled);
|
|
|
- setScheduleTime(res.data.schedule_time);
|
|
|
- setConfigLoaded(true);
|
|
|
- }).catch(() => setConfigLoaded(true));
|
|
|
- }, []);
|
|
|
-
|
|
|
- const showToast = (message: string, type: "success" | "error") => {
|
|
|
- setToast({ message, type });
|
|
|
- setTimeout(() => setToast(null), 4000);
|
|
|
- };
|
|
|
-
|
|
|
- // 保存配置
|
|
|
- const handleSaveSchedule = () => {
|
|
|
- domainApi.saveSchedule({ enabled: autoFetch, schedule_time: scheduleTime }).then(res => {
|
|
|
- showToast("配置已保存", "success");
|
|
|
- }).catch(() => showToast("保存失败", "error"));
|
|
|
- };
|
|
|
-
|
|
|
- const batchMutation = useMutation({
|
|
|
- mutationFn: () => domainApi.fetchAll(),
|
|
|
- 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"); },
|
|
|
- });
|
|
|
-
|
|
|
- const handleFetchByDate = () => {
|
|
|
- if (!fetchDate) return;
|
|
|
- setFetchingByDate(true);
|
|
|
- fetchByDateMutation.mutate(fetchDate);
|
|
|
- };
|
|
|
-
|
|
|
- return (
|
|
|
- <>
|
|
|
- {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={handleSaveSchedule} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary, fontWeight: 500, whiteSpace: "nowrap" }}>
|
|
|
- 保存配置
|
|
|
- </button>
|
|
|
- <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 }}>
|
|
|
- <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>
|
|
|
- </>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-/* ===== 域名管理页面 ===== */
|
|
|
-function DomainsPage() {
|
|
|
- const [newDomain, setNewDomain] = useState("");
|
|
|
- const [newRemark, setNewRemark] = useState("");
|
|
|
- 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 [editingRemarkValue, setEditingRemarkValue] = useState("");
|
|
|
- const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
|
|
- 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 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, 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 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 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 activeCount = domains?.filter((d: MonitoredDomain) => d.is_active).length ?? 0;
|
|
|
- return (
|
|
|
- <>
|
|
|
- {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={{ 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) => (
|
|
|
- <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>
|
|
|
- <Card title="添加域名">
|
|
|
- <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={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>
|
|
|
- </form>
|
|
|
- </Card>
|
|
|
- <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> : (
|
|
|
- <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>
|
|
|
- <tbody>
|
|
|
- {domains.map((d: MonitoredDomain) => (
|
|
|
- <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", 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", fontSize: 13, color: T.textSec }}>{fmtDate(d.created_at)}</td>
|
|
|
- <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>
|
|
|
- </td>
|
|
|
- </tr>
|
|
|
- ))}
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
- )}
|
|
|
- </Card>
|
|
|
- </div>
|
|
|
- </>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-/* ===== 可折叠面板 ===== */
|
|
|
-function CollapsePanel({ title, children, defaultOpen = false, badge }: { title: React.ReactNode; children: React.ReactNode; defaultOpen?: boolean; badge?: string }) {
|
|
|
- const [open, setOpen] = useState(defaultOpen);
|
|
|
- return (
|
|
|
- <div style={{ border: `1px solid ${T.border}`, borderRadius: 8, marginBottom: 8, overflow: "hidden" }}>
|
|
|
- <div onClick={() => setOpen(!open)} style={{ padding: "10px 16px", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "space-between", background: open ? T.bg : "#fff", userSelect: "none" }}>
|
|
|
- <span style={{ fontWeight: 500, color: T.heading }}>{title}</span>
|
|
|
- <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
|
- {badge && <span style={{ fontSize: 12, color: T.textSec }}>{badge}</span>}
|
|
|
- <span style={{ fontSize: 12, color: T.textSec, transition: "transform 0.2s", transform: open ? "rotate(90deg)" : "rotate(0deg)" }}>{">"}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- {open && <div style={{ padding: 16 }}>{children}</div>}
|
|
|
- </div>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-/* ===== 监控大屏页面 ===== */
|
|
|
-function MonitoringPage() {
|
|
|
- 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 { data: dashboard, isLoading } = useQuery({
|
|
|
- queryKey: ["dashboard", params],
|
|
|
- queryFn: () => monitoringApi.getDashboard(Object.keys(params).length ? params : undefined).then((r) => r.data),
|
|
|
- });
|
|
|
-
|
|
|
- return (
|
|
|
- <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>
|
|
|
-
|
|
|
- <FilterBar currentQuery={query} onSearch={setQuery} />
|
|
|
-
|
|
|
- {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={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
|
|
|
- {[
|
|
|
- { label: "超级管理员数", value: dashboard.overview.total_super_admins },
|
|
|
- { label: "租户总数", value: dashboard.overview.total_tenants },
|
|
|
- { label: "用户总数", value: dashboard.overview.total_users },
|
|
|
- { label: "总消费(元)", value: dashboard.overview.total_consumption },
|
|
|
- { label: "总收取(元)", value: dashboard.overview.total_tenant_charged },
|
|
|
- { label: "总余额(元)", value: dashboard.overview.total_balance },
|
|
|
- ].map((s) => (
|
|
|
- <div key={s.label} style={{ background: T.bg, borderRadius: 8, padding: "14px 18px" }}>
|
|
|
- <div style={{ fontSize: 12, color: T.textSec, marginBottom: 4 }}>{s.label}</div>
|
|
|
- <div style={{ fontSize: 20, fontWeight: 700, color: T.heading }}>{s.value}</div>
|
|
|
- </div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </Card>
|
|
|
-
|
|
|
- {dashboard.super_admins.map((sa) => (
|
|
|
- <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>
|
|
|
- {sa.tenants.map((tenant) => (
|
|
|
- <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>
|
|
|
- {tenant.users.map((u) => (
|
|
|
- <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.consumption_records.length > 0 && (
|
|
|
- <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.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>
|
|
|
- )}
|
|
|
- </CollapsePanel>
|
|
|
- ))}
|
|
|
- </CollapsePanel>
|
|
|
- ))}
|
|
|
- </Card>
|
|
|
- ))}
|
|
|
- </>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-/* ===== 通用页面布局 ===== */
|
|
|
-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 [page, setPage] = useState(1);
|
|
|
- const [pageSize, setPageSize] = useState(20);
|
|
|
-
|
|
|
- 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.endDate) params.end_date = query.endDate;
|
|
|
- if (query.saName) params.super_admin_name = query.saName;
|
|
|
- if (query.tenantName) params.tenant_name = query.tenantName;
|
|
|
- params.page = page;
|
|
|
- params.page_size = pageSize;
|
|
|
-
|
|
|
- const { data: details, isLoading } = useQuery({
|
|
|
- queryKey: ["consumption-details", params],
|
|
|
- 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 (!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 total = details.total ?? 0;
|
|
|
- const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
|
-
|
|
|
- return (
|
|
|
- <PageLayout title="用户" subtitle="逐笔消费明细,用于对账">
|
|
|
- <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> : (
|
|
|
- <>
|
|
|
- <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>
|
|
|
- </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 showSmsToast = (status: string, action: "expired" | "restored" = "restored") => {
|
|
|
- const label = action === "expired" ? "预警" : "恢复";
|
|
|
- if (status === "sent") showToast(`${label}短信已发送成功`, "success");
|
|
|
- else if (status === "skipped") showToast(`未找到联系人手机号,${label}短信未发送`, "error");
|
|
|
- else if (status === "failed") showToast(`业务操作成功,但${label}短信发送失败,请查看日志`, "error");
|
|
|
- };
|
|
|
-
|
|
|
- const revokeMutation = useMutation({
|
|
|
- mutationFn: (id: number) => licenseApi.revoke(id),
|
|
|
- onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已吊销", "success"); showSmsToast(res.data?.sms_status, "expired"); },
|
|
|
- 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: (res) => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已恢复", "success"); showSmsToast(res.data?.sms_status, "restored"); },
|
|
|
- onError: () => { showToast("恢复失败", "error"); },
|
|
|
- });
|
|
|
-
|
|
|
- const updateMutation = useMutation({
|
|
|
- mutationFn: ({ id, data }: { id: number; data: { license_key?: string; expires_at?: string } }) =>
|
|
|
- licenseApi.update(id, data),
|
|
|
- onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); setEditTarget(null); showToast("License 已更新", "success"); showSmsToast(res.data?.sms_status, res.data?.sms_type ?? "restored"); },
|
|
|
- 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", fontSize: 12 }}>
|
|
|
- {l.contact?.name || l.contact?.phone || l.contact?.email ? (
|
|
|
- <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
|
- {l.contact.name && <span style={{ fontWeight: 500 }}>{l.contact.name}</span>}
|
|
|
- {l.contact.phone && <span style={{ color: T.textSec }}>{l.contact.phone}</span>}
|
|
|
- {l.contact.email && <span style={{ color: T.textSec }}>{l.contact.email}</span>}
|
|
|
- </div>
|
|
|
- ) : "-"}
|
|
|
- </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.replace("T", " ").split("+")[0] || "-") : "-"}</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, 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>
|
|
|
- </>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-/* ===== 爬取日志页面 ===== */
|
|
|
-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 ===== */
|
|
|
function App() {
|
|
|
- const [page, setPage] = useState("domains");
|
|
|
- const pageMap: Record<string, React.ReactNode> = {
|
|
|
- domains: <DomainsPage />,
|
|
|
- monitoring: <MonitoringPage />,
|
|
|
- super_admins: <SuperAdminsPage />,
|
|
|
- tenants: <TenantsPage />,
|
|
|
- users: <UsersPage />,
|
|
|
- license: <LicensePage />,
|
|
|
- fetch_logs: <FetchLogsPage />,
|
|
|
- };
|
|
|
return (
|
|
|
<>
|
|
|
<GlobalStyle />
|
|
|
- <Sidebar page={page} setPage={setPage} />
|
|
|
- {pageMap[page] || <DomainsPage />}
|
|
|
+ <Routes>
|
|
|
+ <Route path="/login" element={<LoginPage />} />
|
|
|
+ <Route element={<ProtectedRoute />}>
|
|
|
+ <Route element={<MainLayout />}>
|
|
|
+ <Route path="/" element={<DashboardPage />} />
|
|
|
+ <Route path="/domains" element={<DomainsPage />} />
|
|
|
+ <Route path="/monitoring" element={<MonitoringPage />} />
|
|
|
+ <Route path="/super-admins" element={<SuperAdminsPage />} />
|
|
|
+ <Route path="/tenants" element={<TenantsPage />} />
|
|
|
+ <Route path="/users" element={<UsersPage />} />
|
|
|
+ <Route path="/license" element={<LicensePage />} />
|
|
|
+ <Route path="/fetch-logs" element={<FetchLogsPage />} />
|
|
|
+ </Route>
|
|
|
+ </Route>
|
|
|
+ </Routes>
|
|
|
</>
|
|
|
);
|
|
|
}
|