feat(admin): upgrade admin console UI widgets, add plan & status management, and redesign system status dashboard

This commit is contained in:
Blizzard
2026-07-18 22:18:12 +08:00
parent 197531ea33
commit 0d2929931f
16 changed files with 1979 additions and 503 deletions
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState } from "react";
import { grantCredits, type TenantRow } from "../api";
// GrantCreditsModal:替换 window.prompt 的完整充值弹窗。
// 支持正数充值 / 负数人工校正;带操作原因 + 备注 + 风险提示 + 汇率说明。
const MICRO = 1_000_000;
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const REASONS = [
"线下回款核销",
"补偿发放(服务故障)",
"测试赠送",
"人工校正(超扣补偿)",
"合同预付款",
"其它(见备注)",
];
interface Props {
tenant: TenantRow | null;
onClose: () => void;
onDone: () => void;
}
export function GrantCreditsModal({ tenant, onClose, onDone }: Props) {
const [amount, setAmount] = useState("100");
const [reason, setReason] = useState(REASONS[0]);
const [memo, setMemo] = useState("");
const [saving, setSaving] = useState(false);
const [err, setErr] = useState("");
const amountRef = useRef<HTMLInputElement>(null);
// 每次打开聚焦数量输入框
useEffect(() => {
if (tenant) {
setAmount("100");
setReason(REASONS[0]);
setMemo("");
setErr("");
setTimeout(() => amountRef.current?.select(), 50);
}
}, [tenant]);
if (!tenant) return null;
const n = Number(amount);
const isValid = !isNaN(n) && n !== 0;
const handleConfirm = async () => {
if (!isValid) {
setErr("积分数量不能为 0");
return;
}
setSaving(true);
setErr("");
try {
const finalMemo = reason === "其它(见备注)" ? memo : reason + (memo ? `${memo}` : "");
await grantCredits(tenant.id, n, finalMemo);
onDone();
onClose();
} catch (e) {
setErr((e as Error).message);
} finally {
setSaving(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
{/* 头部 */}
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
<div>
<h3 className="text-base font-semibold text-gray-900"></h3>
<p className="mt-0.5 text-xs text-gray-400"></p>
</div>
<button onClick={onClose} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
</div>
{/* 内容 */}
<div className="space-y-4 px-6 py-5">
{/* 租户信息 */}
<div className="flex items-center gap-3 rounded-xl border border-violet-100 bg-violet-50 p-3">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-violet-100 text-sm font-bold text-violet-700">
{tenant.name.charAt(0)}
</span>
<div>
<div className="text-sm font-semibold text-gray-800">{tenant.name}</div>
<div className="font-mono text-xs text-gray-400">
<span className="font-medium text-violet-600">{credits(tenant.credit_balance_micro)}</span>
</div>
</div>
</div>
{/* 积分数量 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-600"></label>
<input
ref={amountRef}
type="number"
value={amount}
onChange={(e) => { setAmount(e.target.value); setErr(""); }}
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm font-mono focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
placeholder="正数=充值,负数=扣减/校正"
/>
<p className="mt-1 text-xs text-gray-400">=/=/1 = 1000 token</p>
</div>
{/* 操作原因 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-600"></label>
<select
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-700 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
>
{REASONS.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
{/* 备注 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-600">
{reason === "其它(见备注)" && <span className="text-rose-500"> *</span>}
</label>
<input
type="text"
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder="如:2026-07-18 线下转账 ¥500 核销"
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
/>
</div>
{/* 风险提示 */}
<div className="rounded-lg border border-amber-100 bg-amber-50 px-3 py-2 text-xs text-amber-700">
</div>
{err && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{err}</div>}
</div>
{/* 底部 */}
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<span className="text-xs text-gray-400"></span>
<div className="flex gap-2">
<button onClick={onClose} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-500 hover:bg-gray-50">
</button>
<button
onClick={() => void handleConfirm()}
disabled={saving || !isValid}
className="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-40"
>
{saving ? "发放中…" : "确认发放"}
</button>
</div>
</div>
</div>
</div>
);
}
+86 -14
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { adminOrders, adminReconcile, adminRefundOrder, type PayOrder, type OrderStats, type ReconcileDiff } from "../api";
// 充值订单流 + 日终对账(P5.3 观测)。全平台充值单的落地视角:
// - 状态计数卡片(pending/paid/expired + 累计到账额)
// - 订单流(可按状态筛)
@@ -28,6 +29,12 @@ export function OrderStream() {
const [diffs, setDiffs] = useState<ReconcileDiff[] | null>(null);
const [checking, setChecking] = useState(false);
const [refunding, setRefunding] = useState(""); // 正在退款的订单 id
// 退款 Modal
const [refundTarget, setRefundTarget] = useState<PayOrder | null>(null);
const [refundMemo, setRefundMemo] = useState("");
const [refundErr, setRefundErr] = useState("");
const refundMemoRef = useRef<HTMLTextAreaElement>(null);
const load = useCallback(() => {
adminOrders(filter)
@@ -52,29 +59,94 @@ export function OrderStream() {
}
};
const refund = async (o: PayOrder) => {
// 退款不可逆(冲销积分、订单置 refunded),且真渠道钱需另在商户后台原路退——二次确认 + 留原因。
const memo = window.prompt(
`确认退款?将冲销 ${credits(o.credits_micro)} 积分并把订单置为已退款(余额可能因积分已消费而变负)。\n` +
`注意:微信真单的钱款原路退回需另在商户后台操作,此处仅冲销本地积分与订单态。\n\n请填写退款原因:`,
"",
);
if (memo === null) return; // 取消
setRefunding(o.id);
setErr("");
const openRefund = (o: PayOrder) => {
setRefundTarget(o);
setRefundMemo("");
setRefundErr("");
setTimeout(() => refundMemoRef.current?.focus(), 50);
};
const doRefund = async () => {
if (!refundTarget) return;
if (!refundMemo.trim()) { setRefundErr("退款原因不能为空"); return; }
setRefunding(refundTarget.id);
setRefundErr("");
try {
const r = await adminRefundOrder(o.id, memo);
const r = await adminRefundOrder(refundTarget.id, refundMemo.trim());
if (r.status === "noop") setErr(r.detail ?? "该订单无需退款");
setRefundTarget(null);
load();
} catch (e) {
setErr((e as Error).message);
setRefundErr((e as Error).message);
} finally {
setRefunding("");
}
};
return (
<div className="space-y-4">
{/* 退款 Modal */}
{refundTarget && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && setRefundTarget(null)}
>
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
<div>
<h3 className="text-base font-semibold text-gray-900">退</h3>
<p className="mt-0.5 text-xs text-gray-400"></p>
</div>
<button onClick={() => setRefundTarget(null)} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
</div>
<div className="space-y-4 px-6 py-5">
{/* 订单摘要 */}
<div className="space-y-2 rounded-xl border border-gray-100 bg-gray-50 p-3 text-sm">
<div className="flex justify-between text-xs uppercase tracking-wide text-gray-400">
<span></span>
<span className="font-mono">{refundTarget.id.slice(-12)}</span>
</div>
<div className="flex justify-between"><span className="text-gray-600"></span><span className="font-medium">{refundTarget.tenant_name || refundTarget.tenant_id}</span></div>
<div className="flex justify-between"><span className="text-gray-600"></span><span className="font-medium text-gray-800">{credits(refundTarget.credits_micro)} </span></div>
{refundTarget.amount_fen > 0 && <div className="flex justify-between"><span className="text-gray-600"></span><span className="font-medium text-emerald-600">{yuan(refundTarget.amount_fen)}</span></div>}
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-600">退</label>
<textarea
ref={refundMemoRef}
rows={3}
value={refundMemo}
onChange={(e) => { setRefundMemo(e.target.value); setRefundErr(""); }}
placeholder="请说明退款原因,此内容将写入审计日志…"
className="w-full resize-none rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
/>
</div>
<div className="rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs text-rose-700 space-y-1">
<div className="font-semibold"> </div>
<div>· <strong>{credits(refundTarget.credits_micro)} </strong></div>
<div>· 退</div>
<div>· </div>
</div>
{refundErr && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{refundErr}</div>}
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<span className="text-xs text-gray-400"></span>
<div className="flex gap-2">
<button onClick={() => setRefundTarget(null)} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-500 hover:bg-gray-50"></button>
<button
onClick={() => void doRefund()}
disabled={!!refunding || !refundMemo.trim()}
className="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-40"
>
{refunding ? "退款中…" : "确认退款"}
</button>
</div>
</div>
</div>
</div>
)}
<div className="flex items-center gap-2 pt-1">
<h3 className="text-sm font-semibold text-gray-700"></h3>
<span className="text-[11px] text-gray-400"> + </span>
@@ -158,7 +230,7 @@ export function OrderStream() {
</td>
<td className="py-1.5 text-right">
{o.status === "paid" ? (
<button onClick={() => void refund(o)} disabled={refunding === o.id}
<button onClick={() => openRefund(o)} disabled={refunding === o.id}
className="rounded border border-rose-200 px-2 py-0.5 text-[11px] text-rose-600 hover:bg-rose-50 disabled:opacity-40">
{refunding === o.id ? "退款中…" : "退款"}
</button>