|
|
@@ -1,11 +1,14 @@
|
|
|
import { useState } from "react";
|
|
|
-import { useQuery } from "@tanstack/react-query";
|
|
|
-import { monitoringApi } from "../api/monitoring";
|
|
|
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
+import { monitoringApi, saBalanceApi } from "../api/monitoring";
|
|
|
import { T } from "../theme";
|
|
|
import { Card, Badge, LoadingDots } from "../components/Shared";
|
|
|
import { PageLayout, FilterBar, TooltipTh } from "../components/PageLayout";
|
|
|
|
|
|
+const WARNING_THRESHOLD = 100;
|
|
|
+
|
|
|
export function SuperAdminsPage() {
|
|
|
+ const qc = useQueryClient();
|
|
|
const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
|
|
|
const hasDate = Boolean(query.startDate && query.endDate);
|
|
|
const statsParams: { start_date?: string; end_date?: string; super_admin_name?: string } = {};
|
|
|
@@ -13,9 +16,32 @@ export function SuperAdminsPage() {
|
|
|
if (query.endDate) statsParams.end_date = query.endDate;
|
|
|
if (query.saName) statsParams.super_admin_name = query.saName;
|
|
|
|
|
|
+ // 充值弹窗状态
|
|
|
+ const [rechargeTarget, setRechargeTarget] = useState<{ id: number; name: string; balance: string } | null>(null);
|
|
|
+ const [rechargeForm, setRechargeForm] = useState({ amount: "", remark: "" });
|
|
|
+ // 余额记录弹窗状态
|
|
|
+ const [logsTarget, setLogsTarget] = useState<{ id: number; name: string } | null>(null);
|
|
|
+ const [logsPage, setLogsPage] = useState(1);
|
|
|
+
|
|
|
const { data: dashboard, isLoading: dashLoading } = useQuery({ queryKey: ["dashboard", statsParams], queryFn: () => monitoringApi.getDashboard(Object.keys(statsParams).length ? statsParams : undefined).then((r) => r.data) });
|
|
|
+ const { data: balanceList } = useQuery({ queryKey: ["sa-balance-list"], queryFn: () => saBalanceApi.list().then((r) => r.data.data) });
|
|
|
const dailyParams = hasDate ? statsParams as { start_date: string; end_date: string; super_admin_name?: string } : undefined;
|
|
|
const { data: dailyStats, isLoading: dailyLoading } = useQuery({ queryKey: ["daily-stats", dailyParams], queryFn: () => monitoringApi.getDailyStats(dailyParams!).then((r) => r.data), enabled: hasDate });
|
|
|
+ const { data: logsData } = useQuery({
|
|
|
+ queryKey: ["sa-balance-logs", logsTarget?.id, logsPage],
|
|
|
+ queryFn: () => saBalanceApi.getLogs(logsTarget!.id, logsPage).then((r) => r.data.data),
|
|
|
+ enabled: !!logsTarget,
|
|
|
+ });
|
|
|
+
|
|
|
+ const rechargeMutation = useMutation({
|
|
|
+ mutationFn: () => saBalanceApi.recharge(rechargeTarget!.id, parseFloat(rechargeForm.amount), rechargeForm.remark),
|
|
|
+ onSuccess: () => {
|
|
|
+ qc.invalidateQueries({ queryKey: ["sa-balance-list"] });
|
|
|
+ qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
|
+ setRechargeTarget(null);
|
|
|
+ setRechargeForm({ amount: "", remark: "" });
|
|
|
+ },
|
|
|
+ });
|
|
|
|
|
|
if (dashLoading || (hasDate && dailyLoading)) return <PageLayout title="超级管理员" subtitle="查看各超级管理员消费数据"><LoadingDots /></PageLayout>;
|
|
|
|
|
|
@@ -23,8 +49,12 @@ export function SuperAdminsPage() {
|
|
|
const totalConsumption = dashboard?.super_admins?.reduce((s: number, sa: any) => s + parseFloat(sa.total_consumption || 0), 0).toFixed(4) ?? "0";
|
|
|
const totalCharged = dashboard?.super_admins?.reduce((s: number, sa: any) => s + parseFloat(sa.total_tenant_charged || 0), 0).toFixed(4) ?? "0";
|
|
|
|
|
|
+ // 余额查找辅助函数
|
|
|
+ const findBalance = (saId: number) => balanceList?.find((b: any) => b.id === saId);
|
|
|
+ const balanceColor = (bal: number) => bal <= 0 ? T.danger : bal <= WARNING_THRESHOLD ? "#f59e0b" : T.success;
|
|
|
+
|
|
|
return (
|
|
|
- <PageLayout title="超级管理员" subtitle={hasDate ? `${query.startDate} ~ ${query.endDate} 每日消费统计` : "查看各超级管理员消费数据"}>
|
|
|
+ <PageLayout title="超级管理员" subtitle={hasDate ? `${query.startDate} ~ ${query.endDate} 每日消费统计` : "查看各超级管理员消费数据与余额"}>
|
|
|
<FilterBar currentQuery={query} onSearch={setQuery} />
|
|
|
<Card title="统计">
|
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
|
|
|
@@ -40,6 +70,38 @@ export function SuperAdminsPage() {
|
|
|
))}
|
|
|
</div>
|
|
|
</Card>
|
|
|
+
|
|
|
+ {/* 超管余额总览卡片 */}
|
|
|
+ {balanceList && balanceList.length > 0 && (
|
|
|
+ <Card title="余额总览">
|
|
|
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 12 }}>
|
|
|
+ {balanceList.map((sa: any) => {
|
|
|
+ const bal = parseFloat(sa.balance || 0);
|
|
|
+ return (
|
|
|
+ <div key={sa.id} style={{ background: T.bg, borderRadius: 10, padding: "14px 18px", border: `1px solid ${bal <= 0 ? T.danger + "40" : T.border}` }}>
|
|
|
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
|
|
+ <span style={{ fontSize: 14, fontWeight: 600, color: T.heading }}>{sa.remark || sa.username}</span>
|
|
|
+ <span style={{ fontSize: 18, fontWeight: 700, color: balanceColor(bal) }}>¥{bal.toFixed(2)}</span>
|
|
|
+ </div>
|
|
|
+ <div style={{ display: "flex", gap: 6 }}>
|
|
|
+ <button
|
|
|
+ onClick={() => { setRechargeTarget({ id: sa.id, name: sa.remark || sa.username, balance: sa.balance }); setRechargeForm({ amount: "", remark: "" }); }}
|
|
|
+ style={{ flex: 1, padding: "5px 0", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary }}
|
|
|
+ >充值</button>
|
|
|
+ <button
|
|
|
+ onClick={() => { setLogsTarget({ id: sa.id, name: sa.remark || sa.username }); setLogsPage(1); }}
|
|
|
+ style={{ flex: 1, padding: "5px 0", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.border}`, background: "transparent", color: T.textSec }}
|
|
|
+ >余额记录</button>
|
|
|
+ </div>
|
|
|
+ {bal <= 0 && <div style={{ marginTop: 6, fontSize: 11, color: T.danger, fontWeight: 500 }}>余额已耗尽,旗下所有服务已暂停</div>}
|
|
|
+ {bal > 0 && bal <= WARNING_THRESHOLD && <div style={{ marginTop: 6, fontSize: 11, color: "#f59e0b", fontWeight: 500 }}>余额不足,请及时充值</div>}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+
|
|
|
{hasDate && (
|
|
|
(dailyStats?.sa_stats || []).length === 0
|
|
|
? <Card title="每日消费明细"><div style={{ textAlign: "center", padding: "40px 24px", color: T.textSec }}><div style={{ fontSize: 36, marginBottom: 12, opacity: 0.5 }}>📅</div><p style={{ fontSize: 14, fontWeight: 500, color: T.heading, marginBottom: 4 }}>暂无每日数据</p><p style={{ fontSize: 13 }}>尝试调整筛选日期范围</p></div></Card>
|
|
|
@@ -53,17 +115,136 @@ export function SuperAdminsPage() {
|
|
|
))
|
|
|
)}
|
|
|
{!hasDate && !dashboard && <Card title="数据"><div style={{ textAlign: "center", padding: "48px 24px", color: T.textSec }}><div style={{ fontSize: 48, marginBottom: 16, opacity: 0.6 }}>📊</div><p style={{ fontSize: 15, fontWeight: 500, color: T.heading, marginBottom: 4 }}>暂无数据</p><p style={{ fontSize: 13 }}>请先在域名管理页面添加并爬取域名流水</p></div></Card>}
|
|
|
- {!hasDate && dashboard && dashboard.super_admins.map((sa: any) => (
|
|
|
- <Card key={sa.super_admin_id} title={`${sa.nickname || sa.username}`} extra={<Badge active 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: 24, 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>
|
|
|
- ))}
|
|
|
+ {!hasDate && dashboard && dashboard.super_admins.map((sa: any) => {
|
|
|
+ const balInfo = findBalance(sa.super_admin_id);
|
|
|
+ const bal = balInfo ? parseFloat(balInfo.balance || 0) : null;
|
|
|
+ return (
|
|
|
+ <Card
|
|
|
+ key={sa.super_admin_id}
|
|
|
+ title={`${sa.nickname || sa.username}`}
|
|
|
+ extra={
|
|
|
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
|
+ {bal !== null && (
|
|
|
+ <span style={{ fontSize: 14, fontWeight: 700, color: balanceColor(bal) }}>
|
|
|
+ ¥{bal.toFixed(2)}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ <Badge active text={`${sa.tenant_count} 个租户`} />
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <div style={{ marginBottom: 12, fontSize: 13, color: T.textSec, display: "flex", gap: 16, alignItems: "center" }}>
|
|
|
+ <span>消费 ¥{sa.total_consumption}</span>
|
|
|
+ <span>收取 ¥{sa.total_tenant_charged}</span>
|
|
|
+ {bal !== null && (
|
|
|
+ <button
|
|
|
+ onClick={() => { setRechargeTarget({ id: sa.super_admin_id, name: sa.nickname || sa.username, balance: balInfo.balance }); setRechargeForm({ amount: "", remark: "" }); }}
|
|
|
+ style={{ padding: "3px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary }}
|
|
|
+ >充值</button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ {sa.tenants.length === 0 ? <div style={{ textAlign: "center", padding: 24, 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>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+
|
|
|
+ {/* 充值弹窗 */}
|
|
|
+ {rechargeTarget && (
|
|
|
+ <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }} onClick={() => setRechargeTarget(null)}>
|
|
|
+ <div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", borderRadius: 12, padding: 28, width: 400, boxShadow: "0 20px 60px rgba(0,0,0,0.2)" }}>
|
|
|
+ <h3 style={{ margin: "0 0 16px", fontSize: 16, color: T.heading }}>充值 — {rechargeTarget.name}</h3>
|
|
|
+ <div style={{ marginBottom: 12, fontSize: 13, color: T.textSec }}>
|
|
|
+ 当前余额:<span style={{ fontWeight: 700, color: balanceColor(parseFloat(rechargeTarget.balance)) }}>¥{parseFloat(rechargeTarget.balance).toFixed(2)}</span>
|
|
|
+ </div>
|
|
|
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
|
+ <div>
|
|
|
+ <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>充值金额(元)</label>
|
|
|
+ <input
|
|
|
+ type="number"
|
|
|
+ min="0.01"
|
|
|
+ step="0.01"
|
|
|
+ value={rechargeForm.amount}
|
|
|
+ onChange={(e) => setRechargeForm({ ...rechargeForm, amount: e.target.value })}
|
|
|
+ placeholder="请输入充值金额"
|
|
|
+ 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
|
|
|
+ value={rechargeForm.remark}
|
|
|
+ onChange={(e) => setRechargeForm({ ...rechargeForm, remark: e.target.value })}
|
|
|
+ placeholder="充值备注"
|
|
|
+ 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={() => setRechargeTarget(null)} style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text }}>取消</button>
|
|
|
+ <button
|
|
|
+ onClick={() => rechargeMutation.mutate()}
|
|
|
+ disabled={!rechargeForm.amount || parseFloat(rechargeForm.amount) <= 0 || rechargeMutation.isPending}
|
|
|
+ style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: rechargeMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff", fontWeight: 500 }}
|
|
|
+ >{rechargeMutation.isPending ? "充值中..." : "确认充值"}</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 余额记录弹窗 */}
|
|
|
+ {logsTarget && (
|
|
|
+ <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }} onClick={() => setLogsTarget(null)}>
|
|
|
+ <div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", borderRadius: 12, padding: 28, width: 640, maxHeight: "80vh", overflow: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.2)" }}>
|
|
|
+ <h3 style={{ margin: "0 0 16px", fontSize: 16, color: T.heading }}>余额记录 — {logsTarget.name}</h3>
|
|
|
+ {logsData?.items?.length === 0 ? (
|
|
|
+ <div style={{ textAlign: "center", padding: 32, color: T.textSec, fontSize: 13 }}>暂无记录</div>
|
|
|
+ ) : (
|
|
|
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th style={{ padding: "8px 10px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>时间</th>
|
|
|
+ <th style={{ padding: "8px 10px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>类型</th>
|
|
|
+ <th style={{ padding: "8px 10px", textAlign: "right", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>变动</th>
|
|
|
+ <th style={{ padding: "8px 10px", textAlign: "right", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>余额</th>
|
|
|
+ <th style={{ padding: "8px 10px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>备注</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {(logsData?.items || []).map((log: any) => {
|
|
|
+ const change = parseFloat(log.change_amount);
|
|
|
+ const typeLabel = log.biz_type === "recharge" ? "充值" : log.biz_type === "consume" ? "消费" : "调整";
|
|
|
+ const typeColor = log.biz_type === "recharge" ? T.success : log.biz_type === "consume" ? T.danger : T.textSec;
|
|
|
+ return (
|
|
|
+ <tr key={log.id} style={{ borderBottom: `1px solid ${T.border}` }}>
|
|
|
+ <td style={{ padding: "8px 10px", fontSize: 12, color: T.textSec }}>{log.created_at?.replace("T", " ").split("+")[0]}</td>
|
|
|
+ <td style={{ padding: "8px 10px" }}><span style={{ color: typeColor, fontWeight: 500 }}>{typeLabel}</span></td>
|
|
|
+ <td style={{ padding: "8px 10px", textAlign: "right", fontWeight: 600, color: change >= 0 ? T.success : T.danger }}>{change >= 0 ? "+" : ""}{change.toFixed(2)}</td>
|
|
|
+ <td style={{ padding: "8px 10px", textAlign: "right" }}>¥{parseFloat(log.balance_after).toFixed(2)}</td>
|
|
|
+ <td style={{ padding: "8px 10px", color: T.textSec, maxWidth: 120, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={log.remark || ""}>{log.remark || log.biz_order_no || "-"}</td>
|
|
|
+ </tr>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ )}
|
|
|
+ {logsData && logsData.total > 20 && (
|
|
|
+ <div style={{ display: "flex", justifyContent: "center", gap: 8, marginTop: 16 }}>
|
|
|
+ <button disabled={logsPage <= 1} onClick={() => setLogsPage(logsPage - 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text }}>上一页</button>
|
|
|
+ <span style={{ fontSize: 12, color: T.textSec, lineHeight: "28px" }}>第 {logsPage} 页 / 共 {Math.ceil(logsData.total / 20)} 页</span>
|
|
|
+ <button disabled={logsPage >= Math.ceil(logsData.total / 20)} onClick={() => setLogsPage(logsPage + 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text }}>下一页</button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <div style={{ display: "flex", justifyContent: "flex-end", marginTop: 16 }}>
|
|
|
+ <button onClick={() => setLogsTarget(null)} style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text }}>关闭</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</PageLayout>
|
|
|
);
|
|
|
}
|