feat(admin): upgrade admin console UI widgets, add plan & status management, and redesign system status dashboard
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user