wendajia/frontend/src/RefundManagementPage.jsx

230 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState } from "react";
import { Plus, Search, X, Pencil, Trash2, History } from "lucide-react";
import { formatDateTime } from "./time.js";
const endpoint = "/api/admin/refund-payment-accounts";
const money = (fen) => (fen / 100).toFixed(2);
const emptyForm = () => ({ name: "", alipay_account: "", balance: "0.00", single_limit: "500.00", is_active: true });
export default function RefundManagementPage({ api }) {
const [data, setData] = useState({ items: [], total: 0, summary: {} });
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [reload, setReload] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [editing, setEditing] = useState(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [deleting, setDeleting] = useState(null);
const [deleteError, setDeleteError] = useState("");
const [ledger, setLedger] = useState(null);
const [ledgerLoading, setLedgerLoading] = useState(false);
const [ledgerPage, setLedgerPage] = useState(1);
const [ledgerPageSize, setLedgerPageSize] = useState(10);
const [funding, setFunding] = useState(null);
const [fundAmount, setFundAmount] = useState("");
const [fundNote, setFundNote] = useState("");
const [fundSaving, setFundSaving] = useState(false);
const [fundError, setFundError] = useState("");
useEffect(() => {
let cancelled = false;
setLoading(true);
setError("");
const timer = setTimeout(async () => {
try {
const result = await api(`${endpoint}?${new URLSearchParams({ q: query, page, page_size: pageSize })}`);
if (!cancelled) setData(result.data);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}, 250);
return () => { cancelled = true; clearTimeout(timer); };
}, [api, query, page, pageSize, reload]);
function open(account) {
setEditing(account || {});
setForm(account ? { name: account.name, alipay_account: account.alipay_account,
balance: money(account.balance_fen), single_limit: money(account.single_limit_fen), is_active: account.is_active } : emptyForm());
setSaveError("");
}
async function save(event) {
event.preventDefault();
if (saving) return;
setSaving(true);
setSaveError("");
try {
if (!form.name.trim() || !form.alipay_account.trim()) throw new Error("请填写账户名称和支付宝账户");
const moneyFields = editing.id ? [["single_limit", "单次付款限额"]] : [["balance", "初始资金"], ["single_limit", "单次付款限额"]];
for (const [key, label] of moneyFields) {
if (!/^\d+(\.\d{1,2})?$/.test(form[key]) || Number(form[key]) > 9999999.99 ||
(key === "single_limit" && Number(form[key]) <= 0)) {
throw new Error(`${label}需填写有效金额,最多两位小数,最高 9999999.99 元${key === "single_limit" ? ",且必须大于 0" : ""}`);
}
}
await api(editing.id ? `${endpoint}/${editing.id}` : endpoint, {
method: editing.id ? "PATCH" : "POST", body: JSON.stringify(form),
});
setEditing(null);
setPage(1);
setReload((n) => n + 1);
} catch (err) {
setSaveError(typeof err.message === "string" ? err.message : "保存失败,请检查填写内容");
} finally { setSaving(false); }
}
async function remove(account) {
if (deleting !== null || !window.confirm(`确定删除付款账户“${account.name}”(${account.alipay_account})吗?删除后无法恢复。`)) return;
setDeleting(account.id);
setDeleteError("");
try {
await api(`${endpoint}/${account.id}`, { method: "DELETE" });
setPage(1);
setReload((n) => n + 1);
} catch (err) {
setDeleteError(err.message || "删除失败,请重试");
} finally { setDeleting(null); }
}
async function loadLedger(account, targetPage = 1, targetPageSize = ledgerPageSize) {
setLedger({ account, items: [] });
setLedgerLoading(true);
try {
const result = await api(endpoint + "/" + account.id + "/ledger?" + new URLSearchParams({ page: targetPage, page_size: targetPageSize }));
setLedger(result.data);
setLedgerPage(result.data.page);
setLedgerPageSize(result.data.page_size);
} catch (err) {
setLedger({ account, items: [], error: err.message });
} finally {
setLedgerLoading(false);
}
}
async function openLedger(account) {
setLedgerPage(1);
await loadLedger(account, 1, ledgerPageSize);
}
function openFunding(account, type) {
setFunding({ account, type });
setFundAmount("");
setFundNote("");
setFundError("");
}
async function saveFunds(event) {
event.preventDefault();
if (fundSaving) return;
setFundSaving(true);
setFundError("");
try {
const amountPattern = funding.type === "追加资金" ? /^\d+(\.\d{1,2})?$/ : /^-?\d+(\.\d{1,2})?$/;
const amount = Number(fundAmount);
if (!amountPattern.test(fundAmount) || !Number.isFinite(amount) || amount === 0 || Math.abs(amount) > 9999999.99 ||
(funding.type === "追加资金" && amount <= 0)) {
throw new Error(funding.type === "追加资金" ? "追加金额必须大于 0,最多两位小数" : "请输入非 0 的调整金额,减少资金请填写负数");
}
if (funding.type === "资金调整" && !fundNote.trim()) throw new Error("资金调整必须填写原因");
await api(`${endpoint}/${funding.account.id}/funds`, {
method: "POST",
body: JSON.stringify({ adjustment_type: funding.type, amount: fundAmount, note: fundNote.trim() }),
});
setFunding(null);
setReload((n) => n + 1);
} catch (err) {
setFundError(err.message || "资金操作失败,请重试");
} finally {
setFundSaving(false);
}
}
const pages = Math.max(1, Math.ceil(data.total / pageSize));
return <>
<div className="page-heading"><div><h2>返款管理</h2><p>管理返款使用的支付宝付款账户。</p></div></div>
<section className="panel refund-management">
<div className="refund-fund-summary">
<article><span>累计入账</span><strong>¥{money(data.summary?.balance_fen || 0)}</strong><small>初始资金与后续追加合计</small></article>
<article><span>累计已使用</span><strong>¥{money(data.summary?.used_fen || 0)}</strong><small>已确认成功的返款</small></article>
<article><span>处理中</span><strong>¥{money(data.summary?.processing_fen || 0)}</strong><small>结果未明确,暂时冻结</small></article>
<article className="available"><span>可用余额</span><strong>¥{money(data.summary?.available_fen || 0)}</strong><small>累计入账减已使用和冻结</small></article>
</div>
<div className="refund-management-notice">页面显示的是系统账面资金,不代表支付宝实际余额。返款成功后自动计入已使用,处理中订单会冻结对应金额。</div>
<div className="refund-management-toolbar">
<label className="refund-account-search"><Search size={16} /><input aria-label="搜索付款账户" placeholder="搜索账户名称或支付宝账户" value={query} maxLength={128} onChange={(e) => { setQuery(e.target.value); setPage(1); }} /></label>
<button className="primary-button" onClick={() => open(null)}><Plus size={16} />新增付款账户</button>
</div>
{error ? <div className="form-error" role="alert">{error}<button onClick={() => setReload((n) => n + 1)}>重试</button></div> : null}
{deleteError && <div className="form-error" role="alert">{deleteError}</div>}
<div className="refund-account-table-wrap" aria-busy={loading}>
<table className="refund-account-table"><thead><tr>
<th>类型</th><th>账户名称</th><th>支付宝账户</th><th>累计入账</th><th>已使用</th><th>处理中</th><th>可用余额</th><th>单次限额</th><th>状态</th><th>操作</th>
</tr></thead><tbody>
{loading ? <tr><td colSpan={10} className="empty-state">加载中…</td></tr> : error ? null : data.items.length ? data.items.map((account) => <tr key={account.id}>
<td><span className="refund-account-tag">支付宝</span></td><td>{account.name}</td><td>{account.alipay_account}</td>
<td>¥{money(account.balance_fen)}</td><td>¥{money(account.used_fen)}</td><td>¥{money(account.processing_fen)}</td>
<td className="refund-account-balance">¥{money(account.available_fen)}</td><td>¥{money(account.single_limit_fen)}</td>
<td><span className={`refund-account-tag ${account.is_active ? "enabled" : "disabled"}`}>{account.is_active ? "启用" : "禁用"}</span></td>
<td><div className="refund-account-actions"><button className="refund-account-edit refund-account-add-funds" onClick={() => openFunding(account, "追加资金")}><Plus size={14} />追加资金</button><button className="refund-account-edit" onClick={() => openFunding(account, "资金调整")}><Pencil size={14} />资金调整</button><button className="refund-account-edit" onClick={() => openLedger(account)}><History size={14} />流水</button><button className="refund-account-edit" disabled={deleting !== null} onClick={() => open(account)}><Pencil size={14} />编辑</button><button className="refund-account-edit refund-account-delete" disabled={deleting !== null} onClick={() => remove(account)}><Trash2 size={14} />{deleting === account.id ? "删除中…" : "删除"}</button></div></td>
</tr>) : <tr><td colSpan={10} className="empty-state">{query ? "未找到匹配账户" : "暂无付款账户,请点击新增付款账户"}</td></tr>}
</tbody></table>
</div>
<div className="refund-account-pagination">
<span>共 {data.total} 条记录</span><button disabled={loading || page <= 1} onClick={() => setPage((n) => n - 1)}>上一页</button>
<span>{page} / {pages}</span><button disabled={loading || page >= pages} onClick={() => setPage((n) => n + 1)}>下一页</button>
<select aria-label="每页条数" value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}>{[10, 20, 50].map((n) => <option key={n} value={n}>{n} 条/页</option>)}</select>
</div>
</section>
{editing && <div className="refund-account-overlay"><section className="refund-account-modal" role="dialog" aria-modal="true" aria-labelledby="refund-account-title">
<header><h2 id="refund-account-title">{editing.id ? "编辑付款账户" : "新增付款账户"}</h2><button aria-label="关闭" disabled={saving} onClick={() => setEditing(null)}><X size={20} /></button></header>
<form onSubmit={save}>
<label>账户类型<input value="支付宝" disabled /></label>
<label>账户名称<input required maxLength={128} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="例如:公司名称或账户别名" /></label>
<label>支付宝账户<input required maxLength={128} value={form.alipay_account} onChange={(e) => setForm({ ...form, alipay_account: e.target.value })} placeholder="支付宝手机号或邮箱" /></label>
<div className="refund-account-money-fields">
{editing.id ? <label>累计入账(不可直接修改)<input disabled value={money(editing.balance_fen)} /></label> : <label>初始资金(元)<input required type="number" min="0" max="9999999.99" step="0.01" value={form.balance} onChange={(e) => setForm({ ...form, balance: e.target.value })} /></label>}
<label>单次付款限额(元)<input required type="number" min="0.01" max="9999999.99" step="0.01" value={form.single_limit} onChange={(e) => setForm({ ...form, single_limit: e.target.value })} /></label>
</div>
<label>状态<select value={String(form.is_active)} onChange={(e) => setForm({ ...form, is_active: e.target.value === "true" })}><option value="true">启用</option><option value="false">禁用</option></select></label>
<p className="refund-account-hint">{editing.id ? "账户资金请使用“追加资金”或“资金调整”,每次变化都会记录流水。" : "初始资金保存后不能在编辑账户中直接修改,后续通过追加资金或资金调整维护。"}</p>
{saveError && <div className="form-error" role="alert">{saveError}</div>}
<footer><button type="button" disabled={saving} onClick={() => setEditing(null)}>取消</button><button className="primary-button" disabled={saving}>{saving ? "保存中…" : "保存"}</button></footer>
</form>
</section></div>}
{ledger && <div className="refund-account-overlay"><section className="refund-ledger-modal" role="dialog" aria-modal="true">
<header><div><h2>{ledger.account?.name || "付款账户"}资金流水</h2><p>资金调整、成功返款和处理中冻结记录</p></div><button aria-label="关闭" onClick={() => setLedger(null)}><X size={20} /></button></header>
{ledgerLoading ? <div className="empty-state">加载中…</div> : ledger.error ? <div className="form-error">{ledger.error}</div> : <>
<div className="refund-ledger-summary"><span>累计入账<b>¥{money(ledger.account.balance_fen)}</b></span><span>已使用<b>¥{money(ledger.account.used_fen)}</b></span><span>处理中<b>¥{money(ledger.account.processing_fen)}</b></span><span>可用余额<b>¥{money(ledger.account.available_fen)}</b></span></div>
<div className="refund-ledger-list">{ledger.items.length ? ledger.items.map((item) => <article key={item.id}>
<div><strong>{item.type_label}</strong><small>{item.order_no || item.note || "--"}</small></div>
<div><b className={item.amount_fen >= 0 ? "income" : "expense"}>{item.amount_fen >= 0 ? "+" : "-"}¥{money(Math.abs(item.amount_fen))}</b><small>{formatDateTime(item.created_at)} · {item.operator_name || "系统"}</small></div>
</article>) : <div className="empty-state">暂无资金流水</div>}</div>
<div className="refund-ledger-pagination">
<span>共 {ledger.total || 0} 条</span>
<button disabled={ledgerLoading || ledgerPage <= 1} onClick={() => loadLedger(ledger.account, ledgerPage - 1, ledgerPageSize)}>上一页</button>
<strong>{ledgerPage} / {ledger.total_pages || 1}</strong>
<button disabled={ledgerLoading || ledgerPage >= (ledger.total_pages || 1)} onClick={() => loadLedger(ledger.account, ledgerPage + 1, ledgerPageSize)}>下一页</button>
<select value={ledgerPageSize} onChange={(event) => loadLedger(ledger.account, 1, Number(event.target.value))}>{[5, 10, 20, 50].map((size) => <option key={size} value={size}>{size} 条/页</option>)}</select>
</div>
</>}
</section></div>}
{funding && <div className="refund-account-overlay"><section className="refund-account-modal refund-fund-modal" role="dialog" aria-modal="true">
<header><div><h2>{funding.type}</h2><p>{funding.account.name}</p></div><button aria-label="关闭" disabled={fundSaving} onClick={() => setFunding(null)}><X size={20} /></button></header>
<div className="refund-fund-current"><span>当前累计入账<b>¥{money(funding.account.balance_fen)}</b></span><span>当前可用余额<b>¥{money(funding.account.available_fen)}</b></span></div>
<form onSubmit={saveFunds}>
<label>{funding.type === "追加资金" ? "追加金额(元)" : "调整金额(元)"}<input autoFocus required type="number" min={funding.type === "追加资金" ? "0.01" : undefined} max="9999999.99" step="0.01" value={fundAmount} onChange={(e) => setFundAmount(e.target.value)} placeholder={funding.type === "追加资金" ? "例如:5000.00" : "增加填正数,减少填负数"} /></label>
<label>{funding.type === "资金调整" ? "调整原因(必填)" : "备注(选填)"}<input required={funding.type === "资金调整"} maxLength={255} value={fundNote} onChange={(e) => setFundNote(e.target.value)} placeholder={funding.type === "资金调整" ? "例如:修正录入错误" : "例如:本月追加预算"} /></label>
<p className="refund-account-hint">{funding.type === "追加资金" ? "仅允许增加资金,提交后会立即计入累计入账并生成流水。" : "允许增加或减少,但调整后的累计入账不能低于已使用与处理中金额之和。"}</p>
{fundError && <div className="form-error" role="alert">{fundError}</div>}
<footer><button type="button" disabled={fundSaving} onClick={() => setFunding(null)}>取消</button><button className="primary-button" disabled={fundSaving}>{fundSaving ? "提交中…" : "确认提交"}</button></footer>
</form>
</section></div>}
</>;
}