230 lines
17 KiB
JavaScript
230 lines
17 KiB
JavaScript
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>}
|
||
</>;
|
||
}
|