import {
ArrowRightLeft, CircleDollarSign, Coins, History, Image as ImageIcon, Landmark,
Pencil, Plus, Save, Search, Trash2, UserRound, WalletCards, X,
} from 'lucide-react';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { ModalPortal } from '../components/modal-portal';
import {
Badge, Button, EmptyState, Field, Input, PageHeader, Panel, ResourceState, Select,
TableShell, Textarea, Toast, formatDate,
} from '../components/ui';
import { useApiResource, useServer } from '../context/server-context';
import { hasCapability } from '../lib/permissions';
import type {
CurrencyDefinition, EconomyAccount, EconomyBalance, EconomyTransaction,
} from '../types';
import { ImagePicker } from './menus';
type View = 'currencies' | 'accounts' | 'ledger';
type Adjustment = 'deposit' | 'withdraw' | 'set';
const amountPattern = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
function compareAmounts(left: string, right: string): number | undefined {
if (left.length > 64 || right.length > 64 || !amountPattern.test(left) || !amountPattern.test(right)) return undefined;
const parse = (value: string) => {
const negative = value.startsWith('-');
const unsigned = negative ? value.slice(1) : value;
const [whole, fraction = ''] = unsigned.split('.');
return { negative, whole, fraction };
};
const a = parse(left); const b = parse(right);
const scale = Math.max(a.fraction.length, b.fraction.length);
const integer = (value: ReturnType<typeof parse>) => {
const magnitude = BigInt(`${value.whole}${value.fraction.padEnd(scale, '0')}`);
return value.negative ? -magnitude : magnitude;
};
const aValue = integer(a); const bValue = integer(b);
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
}
const positiveAmount = (value: string) => compareAmounts(value, '0') === 1;
function newCurrency(): CurrencyDefinition {
const now = new Date().toISOString();
return {
id: '', code: '', name: '', symbol: '', icon: 'item:minecraft:emerald', fractionDigits: 2,
initialBalance: '0', minimumBalance: '0', maximumBalance: '1000000000000',
playerTransferAllowed: true, enabled: true, primary: false, revision: 0,
createdBy: '', createdAt: now, updatedAt: now,
};
}
function formatMoney(amount: string, currency?: Pick<CurrencyDefinition, 'symbol'>): string {
return currency ? `${currency.symbol}${amount}` : amount;
}
function accountBalance(account: EconomyAccount | undefined, currencyId: string): EconomyBalance | undefined {
return account?.balances.find((balance) => balance.currencyId === currencyId);
}
function CurrencyIcon({ currency, size = 34 }: {
currency: Pick<CurrencyDefinition, 'icon' | 'symbol' | 'name'>; size?: number;
}) {
const { api } = useServer();
const [image, setImage] = useState('');
useEffect(() => {
setImage('');
if (!currency.icon.startsWith('uploaded:')) return undefined;
let active = true;
api.menuImageContent(currency.icon.slice('uploaded:'.length))
.then((result) => { if (active) setImage(result.dataUrl); })
.catch(() => undefined);
return () => { active = false; };
}, [api, currency.icon]);
return <span className="economy-icon" style={{ width: size, height: size }} title={`${currency.name} · ${currency.icon}`}>
{image ? <img src={image} alt="" /> : currency.symbol ? <strong>{currency.symbol}</strong> : <Coins size={size * .55} />}
</span>;
}
function CurrencyEditor({ initial, currencies, onClose, onSaved }: {
initial?: CurrencyDefinition; currencies: CurrencyDefinition[]; onClose: () => void; onSaved: () => void;
}) {
const { api } = useServer();
const [draft, setDraft] = useState<CurrencyDefinition>(() => initial ? { ...initial } : newCurrency());
const [reason, setReason] = useState(initial ? '更新货币配置' : '创建货币');
const [picker, setPicker] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const creating = !initial;
const validAmounts = [draft.initialBalance, draft.minimumBalance, draft.maximumBalance]
.every((value) => value.length <= 64 && amountPattern.test(value));
const valid = /^[a-z][a-z0-9_]{0,31}$/.test(draft.code)
&& draft.name.trim().length > 0 && draft.name.trim().length <= 64
&& draft.symbol.trim().length > 0 && draft.symbol.trim().length <= 16
&& Number.isInteger(draft.fractionDigits) && draft.fractionDigits >= 0 && draft.fractionDigits <= 6
&& validAmounts && compareAmounts(draft.minimumBalance, draft.initialBalance) !== 1
&& compareAmounts(draft.initialBalance, draft.maximumBalance) !== 1 && reason.trim().length > 0;
const save = async () => {
if (!valid || busy) return;
setBusy(true); setError('');
try {
const payload = { ...draft, code: draft.code.trim().toLowerCase(), name: draft.name.trim(),
symbol: draft.symbol.trim() };
if (creating) {
const { id: _id, revision: _revision, createdBy: _createdBy, createdAt: _createdAt,
updatedAt: _updatedAt, ...create } = payload;
await api.createCurrency(create, reason.trim());
} else await api.updateCurrency(payload, reason.trim());
onSaved(); onClose();
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally { setBusy(false); }
};
useEffect(() => {
const keydown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault(); void save();
}
};
window.addEventListener('keydown', keydown);
return () => window.removeEventListener('keydown', keydown);
});
const update = <K extends keyof CurrencyDefinition>(key: K, value: CurrencyDefinition[K]) =>
setDraft((current) => ({ ...current, [key]: value }));
return <ModalPortal><div className="modal-backdrop" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}><section className="modal economy-currency-modal" role="dialog" aria-modal="true" aria-label="货币编辑器">
<header className="modal__header"><div><strong>{creating ? '创建货币' : `编辑 ${initial.name}`}</strong>
<small>余额采用精确十进制定点数存储;创建账户后不可更改小数位数。</small></div>
<button type="button" onClick={onClose} aria-label="关闭"><X /></button></header>
<div className="modal__body economy-currency-form">
<div className="form-grid form-grid--three">
<Field label="货币代码" hint="1–32 位小写字母、数字或下划线,可在触发器中使用。"><Input autoFocus value={draft.code}
disabled={!creating} placeholder="coins" onChange={(event) => update('code', event.target.value.toLowerCase())} /></Field>
<Field label="显示名称"><Input value={draft.name} placeholder="金币"
onChange={(event) => update('name', event.target.value)} /></Field>
<Field label="符号"><Input value={draft.symbol} placeholder="⛃"
onChange={(event) => update('symbol', event.target.value)} /></Field>
</div>
<Field label="货币图标" hint="可搜索已上传图片、原版物品/方块和已安装模组贴图。">
<div className="economy-icon-field"><CurrencyIcon currency={draft} />
<Input readOnly value={draft.icon} /><Button type="button" variant="secondary" onClick={() => setPicker(true)}>
<ImageIcon size={15} />选择图片</Button></div>
</Field>
<div className="form-grid form-grid--4">
<Field label="小数位数" hint={creating ? '0–6 位' : '已有账户后由服务器锁定'}><Input type="number" min={0} max={6}
value={draft.fractionDigits} onChange={(event) => update('fractionDigits', Number(event.target.value))} /></Field>
<Field label="初始余额"><Input inputMode="decimal" value={draft.initialBalance}
onChange={(event) => update('initialBalance', event.target.value)} /></Field>
<Field label="最低余额"><Input inputMode="decimal" value={draft.minimumBalance}
onChange={(event) => update('minimumBalance', event.target.value)} /></Field>
<Field label="最高余额"><Input inputMode="decimal" value={draft.maximumBalance}
onChange={(event) => update('maximumBalance', event.target.value)} /></Field>
</div>
<div className="economy-toggle-grid">
<label><input type="checkbox" checked={draft.enabled} disabled={draft.primary}
onChange={(event) => update('enabled', event.target.checked)} /><span><strong>启用货币</strong><small>停用后保留余额和流水,但拒绝新交易。</small></span></label>
<label><input type="checkbox" checked={draft.playerTransferAllowed}
onChange={(event) => update('playerTransferAllowed', event.target.checked)} /><span><strong>允许玩家转账</strong><small>仅控制玩家自助付款;管理操作和触发器不受影响。</small></span></label>
<label><input type="checkbox" checked={draft.primary}
onChange={(event) => update('primary', event.target.checked)} /><span><strong>设为主货币</strong><small>主货币用于省略货币参数的变量和玩家指令。</small></span></label>
</div>
{!creating && draft.primary && currencies.length > 1 && <p className="form-hint">要取消主货币,请直接将另一种货币设为主货币。</p>}
<Field label="审计原因"><Textarea rows={2} maxLength={512} value={reason} onChange={(event) => setReason(event.target.value)} /></Field>
{error && <div className="form-error" role="alert">{error}</div>}
</div>
<footer className="modal__footer"><span className="muted">Ctrl / ⌘ + S 保存</span><div className="button-row">
<Button type="button" variant="secondary" onClick={onClose}>取消</Button>
<Button type="button" disabled={!valid || busy} onClick={() => void save()}><Save size={15} />{busy ? '保存中…' : '保存并生效'}</Button>
</div></footer>
</section>{picker && <ImagePicker api={api} value={draft.icon} onSelect={(icon) => update('icon', icon)} onClose={() => setPicker(false)} />}</div></ModalPortal>;
}
function AdjustmentPanel({ accounts, currencies, onSaved }: {
accounts: EconomyAccount[]; currencies: CurrencyDefinition[]; onSaved: () => void;
}) {
const { api } = useServer();
const enabled = currencies.filter((currency) => currency.enabled);
const [operation, setOperation] = useState<Adjustment>('deposit');
const [playerId, setPlayerId] = useState(accounts[0]?.playerId ?? '');
const [currencyId, setCurrencyId] = useState(enabled[0]?.id ?? '');
const [amount, setAmount] = useState('');
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' }>();
useEffect(() => { if (!accounts.some((item) => item.playerId === playerId)) setPlayerId(accounts[0]?.playerId ?? ''); }, [accounts, playerId]);
useEffect(() => { if (!enabled.some((item) => item.id === currencyId)) setCurrencyId(enabled[0]?.id ?? ''); }, [enabled, currencyId]);
const player = accounts.find((item) => item.playerId === playerId);
const currency = enabled.find((item) => item.id === currencyId);
const balance = accountBalance(player, currencyId);
const validAmount = operation === 'set' ? compareAmounts(amount, '0') !== undefined : positiveAmount(amount);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!player || !currency || !validAmount || !reason.trim()) return;
setBusy(true);
try {
await api.adjustEconomy({ playerId: player.playerId, playerName: player.playerName, currency: currency.id,
operation, amount, reason: reason.trim(), expectedRevision: balance?.revision });
setAmount(''); setReason(''); setToast({ message: '余额调整已完成并写入流水', tone: 'good' }); onSaved();
} catch (cause) { setToast({ message: cause instanceof Error ? cause.message : String(cause), tone: 'danger' }); }
finally { setBusy(false); }
};
return <Panel title={<><CircleDollarSign size={18} />余额调整</>}>
<form className="economy-operation-form" onSubmit={(event) => void submit(event)}>
<Field label="操作"><Select value={operation} onChange={(event) => setOperation(event.target.value as Adjustment)}>
<option value="deposit">增加余额</option><option value="withdraw">扣除余额</option><option value="set">设置余额</option>
</Select></Field>
<Field label="玩家"><Select value={playerId} onChange={(event) => setPlayerId(event.target.value)}>
{accounts.map((item) => <option key={item.playerId} value={item.playerId}>{item.playerName}</option>)}
</Select></Field>
<Field label="货币"><Select value={currencyId} onChange={(event) => setCurrencyId(event.target.value)}>
{enabled.map((item) => <option key={item.id} value={item.id}>{item.symbol} {item.name} ({item.code})</option>)}
</Select></Field>
<Field label={operation === 'set' ? '新余额' : '金额'} hint={balance ? `当前:${formatMoney(balance.amount, currency)}` : undefined}>
<Input inputMode="decimal" value={amount} onChange={(event) => setAmount(event.target.value)} /></Field>
<Field label="审计原因"><Input maxLength={512} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="必填" /></Field>
<Button disabled={busy || !player || !currency || !validAmount || !reason.trim()}>{busy ? '处理中…' : '执行调整'}</Button>
</form>{toast && <Toast message={toast.message} tone={toast.tone} onClose={() => setToast(undefined)} />}
</Panel>;
}
function TransferPanel({ accounts, currencies, onSaved }: {
accounts: EconomyAccount[]; currencies: CurrencyDefinition[]; onSaved: () => void;
}) {
const { api } = useServer();
const enabled = currencies.filter((currency) => currency.enabled);
const [sourceId, setSourceId] = useState(accounts[0]?.playerId ?? '');
const [targetId, setTargetId] = useState(accounts[1]?.playerId ?? '');
const [currencyId, setCurrencyId] = useState(enabled[0]?.id ?? '');
const [amount, setAmount] = useState(''); const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' }>();
const source = accounts.find((item) => item.playerId === sourceId);
const target = accounts.find((item) => item.playerId === targetId);
const currency = enabled.find((item) => item.id === currencyId);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!source || !target || source === target || !currency || !positiveAmount(amount) || !reason.trim()) return;
setBusy(true);
try {
await api.transferEconomy({ sourcePlayerId: source.playerId, sourcePlayerName: source.playerName,
targetPlayerId: target.playerId, targetPlayerName: target.playerName, currency: currency.id, amount,
reason: reason.trim(), expectedSourceRevision: accountBalance(source, currency.id)?.revision });
setAmount(''); setReason(''); setToast({ message: '转账已原子完成', tone: 'good' }); onSaved();
} catch (cause) { setToast({ message: cause instanceof Error ? cause.message : String(cause), tone: 'danger' }); }
finally { setBusy(false); }
};
return <Panel title={<><ArrowRightLeft size={18} />账户转账</>}>
<form className="economy-operation-form" onSubmit={(event) => void submit(event)}>
<Field label="付款方"><Select value={sourceId} onChange={(event) => setSourceId(event.target.value)}>
{accounts.map((item) => <option key={item.playerId} value={item.playerId}>{item.playerName}</option>)}</Select></Field>
<Field label="收款方"><Select value={targetId} onChange={(event) => setTargetId(event.target.value)}>
{accounts.map((item) => <option key={item.playerId} value={item.playerId}>{item.playerName}</option>)}</Select></Field>
<Field label="货币"><Select value={currencyId} onChange={(event) => setCurrencyId(event.target.value)}>
{enabled.map((item) => <option key={item.id} value={item.id}>{item.symbol} {item.name}</option>)}</Select></Field>
<Field label="金额" hint={source && currency ? `可用:${formatMoney(accountBalance(source, currency.id)?.amount ?? '0', currency)}` : undefined}>
<Input inputMode="decimal" value={amount} onChange={(event) => setAmount(event.target.value)} /></Field>
<Field label="审计原因"><Input maxLength={512} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="必填" /></Field>
<Button disabled={busy || !source || !target || source === target || !currency || !reason.trim() || !positiveAmount(amount)}>
{busy ? '转账中…' : '确认转账'}</Button>
</form>{toast && <Toast message={toast.message} tone={toast.tone} onClose={() => setToast(undefined)} />}
</Panel>;
}
function AccountTable({ accounts, currencies }: { accounts: EconomyAccount[]; currencies: CurrencyDefinition[] }) {
return <TableShell><thead><tr><th>玩家</th>{currencies.map((currency) => <th key={currency.id}>{currency.symbol} {currency.name}</th>)}<th>更新于</th></tr></thead>
<tbody>{accounts.map((account) => <tr key={account.playerId}><td><div className="player-cell"><span className="player-avatar">
{account.playerName.slice(0, 2).toUpperCase()}</span><span><strong>{account.playerName}</strong><small>{account.playerId}</small></span></div></td>
{currencies.map((currency) => { const balance = accountBalance(account, currency.id); return <td key={currency.id}>
<strong>{formatMoney(balance?.amount ?? '—', currency)}</strong>{!currency.enabled && <Badge>已停用</Badge>}</td>; })}
<td>{formatDate(account.updatedAt)}</td></tr>)}</tbody></TableShell>;
}
function LedgerTable({ transactions, currencies }: { transactions: EconomyTransaction[]; currencies: CurrencyDefinition[] }) {
const label: Record<EconomyTransaction['kind'], string> = { initial: '开户', deposit: '增加', withdraw: '扣除', set: '设置', transfer: '转账' };
return <TableShell><thead><tr><th>时间 / 类型</th><th>货币 / 金额</th><th>来源 → 目标</th><th>原因</th><th>执行者</th></tr></thead>
<tbody>{transactions.map((item) => { const currency = currencies.find((entry) => entry.id === item.currencyId); return <tr key={item.id}>
<td><strong>{formatDate(item.createdAt)}</strong><small className="table-subline"><Badge tone={item.kind === 'withdraw' ? 'warn' : item.kind === 'transfer' ? 'info' : 'good'}>{label[item.kind]}</Badge> {item.origin}</small></td>
<td><strong>{currency?.symbol}{item.amount}</strong><small className="table-subline">{item.currencyCode}</small></td>
<td>{item.sourcePlayerName || '系统'} <ArrowRightLeft size={13} /> {item.targetPlayerName || '系统'}</td>
<td><span className="economy-ledger-reason">{item.reason}</span><small className="table-subline">{item.correlationId}</small></td>
<td>{item.actor}</td></tr>; })}</tbody></TableShell>;
}
export function EconomyPage() {
const { api, session } = useServer();
const [view, setView] = useState<View>('currencies');
const [queryInput, setQueryInput] = useState(''); const [query, setQuery] = useState('');
const [ledgerKind, setLedgerKind] = useState(''); const [ledgerCurrency, setLedgerCurrency] = useState('');
const [editing, setEditing] = useState<CurrencyDefinition | 'new'>();
const [deleteTarget, setDeleteTarget] = useState<CurrencyDefinition>(); const [deleteReason, setDeleteReason] = useState('');
const [busy, setBusy] = useState(false); const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' }>();
const workspace = useApiResource(() => api.economy(), [], ['economy-changed']);
const accounts = useApiResource(() => api.economyAccounts(query, 200), [query], ['economy-changed', 'player-changed']);
const ledger = useApiResource(() => api.economyTransactions({ query, kind: ledgerKind, currency: ledgerCurrency, limit: 200 }),
[query, ledgerKind, ledgerCurrency], ['economy-changed']);
const currencies = workspace.data?.currencies ?? [];
const summaries = workspace.data?.overview.currencies ?? [];
const canManage = hasCapability(session?.actor, 'economy_manage');
const accountItems = accounts.data?.items ?? [];
const ledgerItems = ledger.data?.items ?? [];
const primary = currencies.find((currency) => currency.primary);
const supply = summaries.find((item) => item.primary)?.totalSupply ?? '0';
const transferVolume = summaries.find((item) => item.primary)?.transferVolume24h ?? '0';
const reloadAll = () => { workspace.reload(); accounts.reload(); ledger.reload(); };
const tabs = useMemo(() => [
{ id: 'currencies' as const, label: '货币与概览', icon: Coins },
{ id: 'accounts' as const, label: '账户与调整', icon: UserRound },
{ id: 'ledger' as const, label: '交易流水', icon: History },
], []);
const remove = async () => {
if (!deleteTarget || !deleteReason.trim()) return;
setBusy(true);
try { await api.deleteCurrency(deleteTarget, deleteReason.trim()); setDeleteTarget(undefined); setDeleteReason('');
setToast({ message: '货币已删除', tone: 'good' }); reloadAll(); }
catch (cause) { setToast({ message: cause instanceof Error ? cause.message : String(cause), tone: 'danger' }); }
finally { setBusy(false); }
};
return <div className="page economy-page">
<PageHeader eyebrow="MULTI-CURRENCY LEDGER" title="服务器经济" description="管理多种货币、玩家账户、原子转账与完整审计流水;变量、触发器、菜单和玩家指令共享同一套余额。"
actions={canManage && <Button onClick={() => setEditing('new')}><Plus size={16} />添加货币</Button>} />
<div className="economy-metrics">
<Panel><span>主货币</span><strong>{primary ? <><CurrencyIcon currency={primary} size={28} />{primary.name}</> : '—'}</strong><small>{primary?.code ?? '尚未配置'}</small></Panel>
<Panel><span>货币数量</span><strong>{currencies.length}</strong><small>{currencies.filter((item) => item.enabled).length} 种启用</small></Panel>
<Panel><span>主货币总量</span><strong>{primary?.symbol}{supply}</strong><small>{summaries.find((item) => item.primary)?.accountCount ?? 0} 个账户</small></Panel>
<Panel><span>24 小时转账</span><strong>{primary?.symbol}{transferVolume}</strong><small>仅统计玩家间转账</small></Panel>
</div>
<div className="economy-tabs" role="tablist">{tabs.map((tab) => { const Icon = tab.icon; return <button key={tab.id} type="button"
className={view === tab.id ? 'is-active' : ''} onClick={() => { setView(tab.id); setQuery(''); setQueryInput(''); }}>
<Icon size={16} />{tab.label}</button>; })}</div>
{view === 'currencies' && <ResourceState loading={workspace.loading} error={workspace.error} empty={currencies.length === 0} onRetry={workspace.reload}>
<div className="economy-currency-grid">{currencies.map((currency) => { const summary = summaries.find((item) => item.currencyId === currency.id); return <Panel key={currency.id} className={`economy-currency-card ${!currency.enabled ? 'is-disabled' : ''}`}>
<div className="economy-currency-card__heading"><CurrencyIcon currency={currency} size={46} /><div><h2>{currency.name}</h2>
<code>{currency.code}</code></div><div className="badge-row">{currency.primary && <Badge tone="good">主货币</Badge>}
<Badge tone={currency.enabled ? 'info' : 'neutral'}>{currency.enabled ? '启用' : '停用'}</Badge></div></div>
<div className="economy-currency-card__amount"><span>{currency.symbol}</span><strong>{summary?.totalSupply ?? '0'}</strong><small>流通总量</small></div>
<dl><div><dt>账户</dt><dd>{summary?.accountCount ?? 0}</dd></div><div><dt>24h 转账</dt><dd>{currency.symbol}{summary?.transferVolume24h ?? '0'}</dd></div>
<div><dt>余额范围</dt><dd>{currency.minimumBalance} – {currency.maximumBalance}</dd></div><div><dt>精度</dt><dd>{currency.fractionDigits} 位小数</dd></div></dl>
<footer><span title={currency.icon}>{currency.icon}</span>{canManage && <div className="button-row"><Button variant="secondary" onClick={() => setEditing(currency)}><Pencil size={14} />编辑</Button>
{!currency.primary && <Button variant="ghost" onClick={() => setDeleteTarget(currency)}><Trash2 size={14} />删除</Button>}</div>}</footer>
</Panel>; })}</div>
</ResourceState>}
{view === 'accounts' && <><Panel className="economy-filter-panel"><form className="search-box" onSubmit={(event) => { event.preventDefault(); setQuery(queryInput.trim()); }}>
<Search size={16} /><Input value={queryInput} onChange={(event) => setQueryInput(event.target.value)} placeholder="搜索玩家名称或 UUID" /></form></Panel>
<ResourceState loading={accounts.loading} error={accounts.error} empty={accountItems.length === 0} onRetry={accounts.reload}>
<Panel title={<><WalletCards size={18} />经济账户</>}><AccountTable accounts={accountItems} currencies={currencies} /></Panel>
{canManage && accountItems.length > 0 && <div className="economy-operation-grid"><AdjustmentPanel accounts={accountItems} currencies={currencies} onSaved={reloadAll} />
<TransferPanel accounts={accountItems} currencies={currencies} onSaved={reloadAll} /></div>}
</ResourceState></>}
{view === 'ledger' && <><Panel className="economy-filter-panel"><form onSubmit={(event) => { event.preventDefault(); setQuery(queryInput.trim()); }}>
<label className="search-box"><Search size={16} /><Input value={queryInput} onChange={(event) => setQueryInput(event.target.value)} placeholder="搜索玩家、原因或执行者" /></label>
<Select value={ledgerCurrency} onChange={(event) => setLedgerCurrency(event.target.value)}><option value="">全部货币</option>
{currencies.map((item) => <option key={item.id} value={item.id}>{item.symbol} {item.name}</option>)}</Select>
<Select value={ledgerKind} onChange={(event) => setLedgerKind(event.target.value)}><option value="">全部类型</option><option value="initial">开户</option>
<option value="deposit">增加</option><option value="withdraw">扣除</option><option value="set">设置</option><option value="transfer">转账</option></Select>
<Button variant="secondary">筛选</Button></form></Panel>
<Panel title={<><Landmark size={18} />不可变交易流水</>}><ResourceState loading={ledger.loading} error={ledger.error} empty={ledgerItems.length === 0} onRetry={ledger.reload}>
<LedgerTable transactions={ledgerItems} currencies={currencies} /></ResourceState></Panel></>}
{editing && <CurrencyEditor initial={editing === 'new' ? undefined : editing} currencies={currencies} onClose={() => setEditing(undefined)}
onSaved={() => { setToast({ message: '货币配置已保存并立即生效', tone: 'good' }); reloadAll(); }} />}
{deleteTarget && <ModalPortal><div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setDeleteTarget(undefined); }}>
<section className="modal modal--small" role="dialog" aria-modal="true"><header className="modal__header"><div><strong>删除 {deleteTarget.name}</strong>
<small>已有账户或流水的货币不能删除,请改为停用。</small></div><button type="button" onClick={() => setDeleteTarget(undefined)}><X /></button></header>
<div className="modal__body"><Field label="审计原因"><Textarea autoFocus rows={3} maxLength={512} value={deleteReason} onChange={(event) => setDeleteReason(event.target.value)} /></Field></div>
<footer className="modal__footer"><Button variant="secondary" onClick={() => setDeleteTarget(undefined)}>取消</Button>
<Button variant="danger" disabled={busy || !deleteReason.trim()} onClick={() => void remove()}><Trash2 size={15} />确认删除</Button></footer>
</section></div></ModalPortal>}
{toast && <Toast message={toast.message} tone={toast.tone} onClose={() => setToast(undefined)} />}
</div>;
}
import {
ArrowRightLeft, CircleDollarSign, Coins, History, Image as ImageIcon, Landmark,
Pencil, Plus, Save, Search, Trash2, UserRound, WalletCards, X,
} from 'lucide-react';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { ModalPortal } from '../components/modal-portal';
import {
Badge, Button, EmptyState, Field, Input, PageHeader, Panel, ResourceState, Select,
TableShell, Textarea, Toast, formatDate,
} from '../components/ui';
import { useApiResource, useServer } from '../context/server-context';
import { hasCapability } from '../lib/permissions';
import type {
CurrencyDefinition, EconomyAccount, EconomyBalance, EconomyTransaction,
} from '../types';
import { ImagePicker } from './menus';
type View = 'currencies' | 'accounts' | 'ledger';
type Adjustment = 'deposit' | 'withdraw' | 'set';
const amountPattern = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
function compareAmounts(left: string, right: string): number | undefined {
if (left.length > 64 || right.length > 64 || !amountPattern.test(left) || !amountPattern.test(right)) return undefined;
const parse = (value: string) => {
const negative = value.startsWith('-');
const unsigned = negative ? value.slice(1) : value;
const [whole, fraction = ''] = unsigned.split('.');
return { negative, whole, fraction };
};
const a = parse(left); const b = parse(right);
const scale = Math.max(a.fraction.length, b.fraction.length);
const integer = (value: ReturnType<typeof parse>) => {
const magnitude = BigInt(`${value.whole}${value.fraction.padEnd(scale, '0')}`);
return value.negative ? -magnitude : magnitude;
};
const aValue = integer(a); const bValue = integer(b);
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
}
const positiveAmount = (value: string) => compareAmounts(value, '0') === 1;
function newCurrency(): CurrencyDefinition {
const now = new Date().toISOString();
return {
id: '', code: '', name: '', symbol: '', icon: 'item:minecraft:emerald', fractionDigits: 2,
initialBalance: '0', minimumBalance: '0', maximumBalance: '1000000000000',
playerTransferAllowed: true, enabled: true, primary: false, revision: 0,
createdBy: '', createdAt: now, updatedAt: now,
};
}
function formatMoney(amount: string, currency?: Pick<CurrencyDefinition, 'symbol'>): string {
return currency ? `${currency.symbol}${amount}` : amount;
}
function accountBalance(account: EconomyAccount | undefined, currencyId: string): EconomyBalance | undefined {
return account?.balances.find((balance) => balance.currencyId === currencyId);
}
function CurrencyIcon({ currency, size = 34 }: {
currency: Pick<CurrencyDefinition, 'icon' | 'symbol' | 'name'>; size?: number;
}) {
const { api } = useServer();
const [image, setImage] = useState('');
useEffect(() => {
setImage('');
if (!currency.icon.startsWith('uploaded:')) return undefined;
let active = true;
api.menuImageContent(currency.icon.slice('uploaded:'.length))
.then((result) => { if (active) setImage(result.dataUrl); })
.catch(() => undefined);
return () => { active = false; };
}, [api, currency.icon]);
return <span className="economy-icon" style={{ width: size, height: size }} title={`${currency.name} · ${currency.icon}`}>
{image ? <img src={image} alt="" /> : currency.symbol ? <strong>{currency.symbol}</strong> : <Coins size={size * .55} />}
</span>;
}
function CurrencyEditor({ initial, currencies, onClose, onSaved }: {
initial?: CurrencyDefinition; currencies: CurrencyDefinition[]; onClose: () => void; onSaved: () => void;
}) {
const { api } = useServer();
const [draft, setDraft] = useState<CurrencyDefinition>(() => initial ? { ...initial } : newCurrency());
const [reason, setReason] = useState(initial ? '更新货币配置' : '创建货币');
const [picker, setPicker] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const creating = !initial;
const validAmounts = [draft.initialBalance, draft.minimumBalance, draft.maximumBalance]
.every((value) => value.length <= 64 && amountPattern.test(value));
const valid = /^[a-z][a-z0-9_]{0,31}$/.test(draft.code)
&& draft.name.trim().length > 0 && draft.name.trim().length <= 64
&& draft.symbol.trim().length > 0 && draft.symbol.trim().length <= 16
&& Number.isInteger(draft.fractionDigits) && draft.fractionDigits >= 0 && draft.fractionDigits <= 6
&& validAmounts && compareAmounts(draft.minimumBalance, draft.initialBalance) !== 1
&& compareAmounts(draft.initialBalance, draft.maximumBalance) !== 1 && reason.trim().length > 0;
const save = async () => {
if (!valid || busy) return;
setBusy(true); setError('');
try {
const payload = { ...draft, code: draft.code.trim().toLowerCase(), name: draft.name.trim(),
symbol: draft.symbol.trim() };
if (creating) {
const { id: _id, revision: _revision, createdBy: _createdBy, createdAt: _createdAt,
updatedAt: _updatedAt, ...create } = payload;
await api.createCurrency(create, reason.trim());
} else await api.updateCurrency(payload, reason.trim());
onSaved(); onClose();
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally { setBusy(false); }
};
useEffect(() => {
const keydown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault(); void save();
}
};
window.addEventListener('keydown', keydown);
return () => window.removeEventListener('keydown', keydown);
});
const update = <K extends keyof CurrencyDefinition>(key: K, value: CurrencyDefinition[K]) =>
setDraft((current) => ({ ...current, [key]: value }));
return <ModalPortal><div className="modal-backdrop" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}><section className="modal economy-currency-modal" role="dialog" aria-modal="true" aria-label="货币编辑器">
<header className="modal__header"><div><strong>{creating ? '创建货币' : `编辑 ${initial.name}`}</strong>
<small>余额采用精确十进制定点数存储;创建账户后不可更改小数位数。</small></div>
<button type="button" onClick={onClose} aria-label="关闭"><X /></button></header>
<div className="modal__body economy-currency-form">
<div className="form-grid form-grid--three">
<Field label="货币代码" hint="1–32 位小写字母、数字或下划线,可在触发器中使用。"><Input autoFocus value={draft.code}
disabled={!creating} placeholder="coins" onChange={(event) => update('code', event.target.value.toLowerCase())} /></Field>
<Field label="显示名称"><Input value={draft.name} placeholder="金币"
onChange={(event) => update('name', event.target.value)} /></Field>
<Field label="符号"><Input value={draft.symbol} placeholder="⛃"
onChange={(event) => update('symbol', event.target.value)} /></Field>
</div>
<Field label="货币图标" hint="可搜索已上传图片、原版物品/方块和已安装模组贴图。">
<div className="economy-icon-field"><CurrencyIcon currency={draft} />
<Input readOnly value={draft.icon} /><Button type="button" variant="secondary" onClick={() => setPicker(true)}>
<ImageIcon size={15} />选择图片</Button></div>
</Field>
<div className="form-grid form-grid--4">
<Field label="小数位数" hint={creating ? '0–6 位' : '已有账户后由服务器锁定'}><Input type="number" min={0} max={6}
value={draft.fractionDigits} onChange={(event) => update('fractionDigits', Number(event.target.value))} /></Field>
<Field label="初始余额"><Input inputMode="decimal" value={draft.initialBalance}
onChange={(event) => update('initialBalance', event.target.value)} /></Field>
<Field label="最低余额"><Input inputMode="decimal" value={draft.minimumBalance}
onChange={(event) => update('minimumBalance', event.target.value)} /></Field>
<Field label="最高余额"><Input inputMode="decimal" value={draft.maximumBalance}
onChange={(event) => update('maximumBalance', event.target.value)} /></Field>
</div>
<div className="economy-toggle-grid">
<label><input type="checkbox" checked={draft.enabled} disabled={draft.primary}
onChange={(event) => update('enabled', event.target.checked)} /><span><strong>启用货币</strong><small>停用后保留余额和流水,但拒绝新交易。</small></span></label>
<label><input type="checkbox" checked={draft.playerTransferAllowed}
onChange={(event) => update('playerTransferAllowed', event.target.checked)} /><span><strong>允许玩家转账</strong><small>仅控制玩家自助付款;管理操作和触发器不受影响。</small></span></label>
<label><input type="checkbox" checked={draft.primary}
onChange={(event) => update('primary', event.target.checked)} /><span><strong>设为主货币</strong><small>主货币用于省略货币参数的变量和玩家指令。</small></span></label>
</div>
{!creating && draft.primary && currencies.length > 1 && <p className="form-hint">要取消主货币,请直接将另一种货币设为主货币。</p>}
<Field label="审计原因"><Textarea rows={2} maxLength={512} value={reason} onChange={(event) => setReason(event.target.value)} /></Field>
{error && <div className="form-error" role="alert">{error}</div>}
</div>
<footer className="modal__footer"><span className="muted">Ctrl / ⌘ + S 保存</span><div className="button-row">
<Button type="button" variant="secondary" onClick={onClose}>取消</Button>
<Button type="button" disabled={!valid || busy} onClick={() => void save()}><Save size={15} />{busy ? '保存中…' : '保存并生效'}</Button>
</div></footer>
</section>{picker && <ImagePicker api={api} value={draft.icon} onSelect={(icon) => update('icon', icon)} onClose={() => setPicker(false)} />}</div></ModalPortal>;
}
function AdjustmentPanel({ accounts, currencies, onSaved }: {
accounts: EconomyAccount[]; currencies: CurrencyDefinition[]; onSaved: () => void;
}) {
const { api } = useServer();
const enabled = currencies.filter((currency) => currency.enabled);
const [operation, setOperation] = useState<Adjustment>('deposit');
const [playerId, setPlayerId] = useState(accounts[0]?.playerId ?? '');
const [currencyId, setCurrencyId] = useState(enabled[0]?.id ?? '');
const [amount, setAmount] = useState('');
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' }>();
useEffect(() => { if (!accounts.some((item) => item.playerId === playerId)) setPlayerId(accounts[0]?.playerId ?? ''); }, [accounts, playerId]);
useEffect(() => { if (!enabled.some((item) => item.id === currencyId)) setCurrencyId(enabled[0]?.id ?? ''); }, [enabled, currencyId]);
const player = accounts.find((item) => item.playerId === playerId);
const currency = enabled.find((item) => item.id === currencyId);
const balance = accountBalance(player, currencyId);
const validAmount = operation === 'set' ? compareAmounts(amount, '0') !== undefined : positiveAmount(amount);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!player || !currency || !validAmount || !reason.trim()) return;
setBusy(true);
try {
await api.adjustEconomy({ playerId: player.playerId, playerName: player.playerName, currency: currency.id,
operation, amount, reason: reason.trim(), expectedRevision: balance?.revision });
setAmount(''); setReason(''); setToast({ message: '余额调整已完成并写入流水', tone: 'good' }); onSaved();
} catch (cause) { setToast({ message: cause instanceof Error ? cause.message : String(cause), tone: 'danger' }); }
finally { setBusy(false); }
};
return <Panel title={<><CircleDollarSign size={18} />余额调整</>}>
<form className="economy-operation-form" onSubmit={(event) => void submit(event)}>
<Field label="操作"><Select value={operation} onChange={(event) => setOperation(event.target.value as Adjustment)}>
<option value="deposit">增加余额</option><option value="withdraw">扣除余额</option><option value="set">设置余额</option>
</Select></Field>
<Field label="玩家"><Select value={playerId} onChange={(event) => setPlayerId(event.target.value)}>
{accounts.map((item) => <option key={item.playerId} value={item.playerId}>{item.playerName}</option>)}
</Select></Field>
<Field label="货币"><Select value={currencyId} onChange={(event) => setCurrencyId(event.target.value)}>
{enabled.map((item) => <option key={item.id} value={item.id}>{item.symbol} {item.name} ({item.code})</option>)}
</Select></Field>
<Field label={operation === 'set' ? '新余额' : '金额'} hint={balance ? `当前:${formatMoney(balance.amount, currency)}` : undefined}>
<Input inputMode="decimal" value={amount} onChange={(event) => setAmount(event.target.value)} /></Field>
<Field label="审计原因"><Input maxLength={512} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="必填" /></Field>
<Button disabled={busy || !player || !currency || !validAmount || !reason.trim()}>{busy ? '处理中…' : '执行调整'}</Button>
</form>{toast && <Toast message={toast.message} tone={toast.tone} onClose={() => setToast(undefined)} />}
</Panel>;
}
function TransferPanel({ accounts, currencies, onSaved }: {
accounts: EconomyAccount[]; currencies: CurrencyDefinition[]; onSaved: () => void;
}) {
const { api } = useServer();
const enabled = currencies.filter((currency) => currency.enabled);
const [sourceId, setSourceId] = useState(accounts[0]?.playerId ?? '');
const [targetId, setTargetId] = useState(accounts[1]?.playerId ?? '');
const [currencyId, setCurrencyId] = useState(enabled[0]?.id ?? '');
const [amount, setAmount] = useState(''); const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' }>();
const source = accounts.find((item) => item.playerId === sourceId);
const target = accounts.find((item) => item.playerId === targetId);
const currency = enabled.find((item) => item.id === currencyId);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!source || !target || source === target || !currency || !positiveAmount(amount) || !reason.trim()) return;
setBusy(true);
try {
await api.transferEconomy({ sourcePlayerId: source.playerId, sourcePlayerName: source.playerName,
targetPlayerId: target.playerId, targetPlayerName: target.playerName, currency: currency.id, amount,
reason: reason.trim(), expectedSourceRevision: accountBalance(source, currency.id)?.revision });
setAmount(''); setReason(''); setToast({ message: '转账已原子完成', tone: 'good' }); onSaved();
} catch (cause) { setToast({ message: cause instanceof Error ? cause.message : String(cause), tone: 'danger' }); }
finally { setBusy(false); }
};
return <Panel title={<><ArrowRightLeft size={18} />账户转账</>}>
<form className="economy-operation-form" onSubmit={(event) => void submit(event)}>
<Field label="付款方"><Select value={sourceId} onChange={(event) => setSourceId(event.target.value)}>
{accounts.map((item) => <option key={item.playerId} value={item.playerId}>{item.playerName}</option>)}</Select></Field>
<Field label="收款方"><Select value={targetId} onChange={(event) => setTargetId(event.target.value)}>
{accounts.map((item) => <option key={item.playerId} value={item.playerId}>{item.playerName}</option>)}</Select></Field>
<Field label="货币"><Select value={currencyId} onChange={(event) => setCurrencyId(event.target.value)}>
{enabled.map((item) => <option key={item.id} value={item.id}>{item.symbol} {item.name}</option>)}</Select></Field>
<Field label="金额" hint={source && currency ? `可用:${formatMoney(accountBalance(source, currency.id)?.amount ?? '0', currency)}` : undefined}>
<Input inputMode="decimal" value={amount} onChange={(event) => setAmount(event.target.value)} /></Field>
<Field label="审计原因"><Input maxLength={512} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="必填" /></Field>
<Button disabled={busy || !source || !target || source === target || !currency || !reason.trim() || !positiveAmount(amount)}>
{busy ? '转账中…' : '确认转账'}</Button>
</form>{toast && <Toast message={toast.message} tone={toast.tone} onClose={() => setToast(undefined)} />}
</Panel>;
}
function AccountTable({ accounts, currencies }: { accounts: EconomyAccount[]; currencies: CurrencyDefinition[] }) {
return <TableShell><thead><tr><th>玩家</th>{currencies.map((currency) => <th key={currency.id}>{currency.symbol} {currency.name}</th>)}<th>更新于</th></tr></thead>
<tbody>{accounts.map((account) => <tr key={account.playerId}><td><div className="player-cell"><span className="player-avatar">
{account.playerName.slice(0, 2).toUpperCase()}</span><span><strong>{account.playerName}</strong><small>{account.playerId}</small></span></div></td>
{currencies.map((currency) => { const balance = accountBalance(account, currency.id); return <td key={currency.id}>
<strong>{formatMoney(balance?.amount ?? '—', currency)}</strong>{!currency.enabled && <Badge>已停用</Badge>}</td>; })}
<td>{formatDate(account.updatedAt)}</td></tr>)}</tbody></TableShell>;
}
function LedgerTable({ transactions, currencies }: { transactions: EconomyTransaction[]; currencies: CurrencyDefinition[] }) {
const label: Record<EconomyTransaction['kind'], string> = { initial: '开户', deposit: '增加', withdraw: '扣除', set: '设置', transfer: '转账' };
return <TableShell><thead><tr><th>时间 / 类型</th><th>货币 / 金额</th><th>来源 → 目标</th><th>原因</th><th>执行者</th></tr></thead>
<tbody>{transactions.map((item) => { const currency = currencies.find((entry) => entry.id === item.currencyId); return <tr key={item.id}>
<td><strong>{formatDate(item.createdAt)}</strong><small className="table-subline"><Badge tone={item.kind === 'withdraw' ? 'warn' : item.kind === 'transfer' ? 'info' : 'good'}>{label[item.kind]}</Badge> {item.origin}</small></td>
<td><strong>{currency?.symbol}{item.amount}</strong><small className="table-subline">{item.currencyCode}</small></td>
<td>{item.sourcePlayerName || '系统'} <ArrowRightLeft size={13} /> {item.targetPlayerName || '系统'}</td>
<td><span className="economy-ledger-reason">{item.reason}</span><small className="table-subline">{item.correlationId}</small></td>
<td>{item.actor}</td></tr>; })}</tbody></TableShell>;
}
export function EconomyPage() {
const { api, session } = useServer();
const [view, setView] = useState<View>('currencies');
const [queryInput, setQueryInput] = useState(''); const [query, setQuery] = useState('');
const [ledgerKind, setLedgerKind] = useState(''); const [ledgerCurrency, setLedgerCurrency] = useState('');
const [editing, setEditing] = useState<CurrencyDefinition | 'new'>();
const [deleteTarget, setDeleteTarget] = useState<CurrencyDefinition>(); const [deleteReason, setDeleteReason] = useState('');
const [busy, setBusy] = useState(false); const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' }>();
const workspace = useApiResource(() => api.economy(), [], ['economy-changed']);
const accounts = useApiResource(() => api.economyAccounts(query, 200), [query], ['economy-changed', 'player-changed']);
const ledger = useApiResource(() => api.economyTransactions({ query, kind: ledgerKind, currency: ledgerCurrency, limit: 200 }),
[query, ledgerKind, ledgerCurrency], ['economy-changed']);
const currencies = workspace.data?.currencies ?? [];
const summaries = workspace.data?.overview.currencies ?? [];
const canManage = hasCapability(session?.actor, 'economy_manage');
const accountItems = accounts.data?.items ?? [];
const ledgerItems = ledger.data?.items ?? [];
const primary = currencies.find((currency) => currency.primary);
const supply = summaries.find((item) => item.primary)?.totalSupply ?? '0';
const transferVolume = summaries.find((item) => item.primary)?.transferVolume24h ?? '0';
const reloadAll = () => { workspace.reload(); accounts.reload(); ledger.reload(); };
const tabs = useMemo(() => [
{ id: 'currencies' as const, label: '货币与概览', icon: Coins },
{ id: 'accounts' as const, label: '账户与调整', icon: UserRound },
{ id: 'ledger' as const, label: '交易流水', icon: History },
], []);
const remove = async () => {
if (!deleteTarget || !deleteReason.trim()) return;
setBusy(true);
try { await api.deleteCurrency(deleteTarget, deleteReason.trim()); setDeleteTarget(undefined); setDeleteReason('');
setToast({ message: '货币已删除', tone: 'good' }); reloadAll(); }
catch (cause) { setToast({ message: cause instanceof Error ? cause.message : String(cause), tone: 'danger' }); }
finally { setBusy(false); }
};
return <div className="page economy-page">
<PageHeader eyebrow="MULTI-CURRENCY LEDGER" title="服务器经济" description="管理多种货币、玩家账户、原子转账与完整审计流水;变量、触发器、菜单和玩家指令共享同一套余额。"
actions={canManage && <Button onClick={() => setEditing('new')}><Plus size={16} />添加货币</Button>} />
<div className="economy-metrics">
<Panel><span>主货币</span><strong>{primary ? <><CurrencyIcon currency={primary} size={28} />{primary.name}</> : '—'}</strong><small>{primary?.code ?? '尚未配置'}</small></Panel>
<Panel><span>货币数量</span><strong>{currencies.length}</strong><small>{currencies.filter((item) => item.enabled).length} 种启用</small></Panel>
<Panel><span>主货币总量</span><strong>{primary?.symbol}{supply}</strong><small>{summaries.find((item) => item.primary)?.accountCount ?? 0} 个账户</small></Panel>
<Panel><span>24 小时转账</span><strong>{primary?.symbol}{transferVolume}</strong><small>仅统计玩家间转账</small></Panel>
</div>
<div className="economy-tabs" role="tablist">{tabs.map((tab) => { const Icon = tab.icon; return <button key={tab.id} type="button"
className={view === tab.id ? 'is-active' : ''} onClick={() => { setView(tab.id); setQuery(''); setQueryInput(''); }}>
<Icon size={16} />{tab.label}</button>; })}</div>
{view === 'currencies' && <ResourceState loading={workspace.loading} error={workspace.error} empty={currencies.length === 0} onRetry={workspace.reload}>
<div className="economy-currency-grid">{currencies.map((currency) => { const summary = summaries.find((item) => item.currencyId === currency.id); return <Panel key={currency.id} className={`economy-currency-card ${!currency.enabled ? 'is-disabled' : ''}`}>
<div className="economy-currency-card__heading"><CurrencyIcon currency={currency} size={46} /><div><h2>{currency.name}</h2>
<code>{currency.code}</code></div><div className="badge-row">{currency.primary && <Badge tone="good">主货币</Badge>}
<Badge tone={currency.enabled ? 'info' : 'neutral'}>{currency.enabled ? '启用' : '停用'}</Badge></div></div>
<div className="economy-currency-card__amount"><span>{currency.symbol}</span><strong>{summary?.totalSupply ?? '0'}</strong><small>流通总量</small></div>
<dl><div><dt>账户</dt><dd>{summary?.accountCount ?? 0}</dd></div><div><dt>24h 转账</dt><dd>{currency.symbol}{summary?.transferVolume24h ?? '0'}</dd></div>
<div><dt>余额范围</dt><dd>{currency.minimumBalance} – {currency.maximumBalance}</dd></div><div><dt>精度</dt><dd>{currency.fractionDigits} 位小数</dd></div></dl>
<footer><span title={currency.icon}>{currency.icon}</span>{canManage && <div className="button-row"><Button variant="secondary" onClick={() => setEditing(currency)}><Pencil size={14} />编辑</Button>
{!currency.primary && <Button variant="ghost" onClick={() => setDeleteTarget(currency)}><Trash2 size={14} />删除</Button>}</div>}</footer>
</Panel>; })}</div>
</ResourceState>}
{view === 'accounts' && <><Panel className="economy-filter-panel"><form className="search-box" onSubmit={(event) => { event.preventDefault(); setQuery(queryInput.trim()); }}>
<Search size={16} /><Input value={queryInput} onChange={(event) => setQueryInput(event.target.value)} placeholder="搜索玩家名称或 UUID" /></form></Panel>
<ResourceState loading={accounts.loading} error={accounts.error} empty={accountItems.length === 0} onRetry={accounts.reload}>
<Panel title={<><WalletCards size={18} />经济账户</>}><AccountTable accounts={accountItems} currencies={currencies} /></Panel>
{canManage && accountItems.length > 0 && <div className="economy-operation-grid"><AdjustmentPanel accounts={accountItems} currencies={currencies} onSaved={reloadAll} />
<TransferPanel accounts={accountItems} currencies={currencies} onSaved={reloadAll} /></div>}
</ResourceState></>}
{view === 'ledger' && <><Panel className="economy-filter-panel"><form onSubmit={(event) => { event.preventDefault(); setQuery(queryInput.trim()); }}>
<label className="search-box"><Search size={16} /><Input value={queryInput} onChange={(event) => setQueryInput(event.target.value)} placeholder="搜索玩家、原因或执行者" /></label>
<Select value={ledgerCurrency} onChange={(event) => setLedgerCurrency(event.target.value)}><option value="">全部货币</option>
{currencies.map((item) => <option key={item.id} value={item.id}>{item.symbol} {item.name}</option>)}</Select>
<Select value={ledgerKind} onChange={(event) => setLedgerKind(event.target.value)}><option value="">全部类型</option><option value="initial">开户</option>
<option value="deposit">增加</option><option value="withdraw">扣除</option><option value="set">设置</option><option value="transfer">转账</option></Select>
<Button variant="secondary">筛选</Button></form></Panel>
<Panel title={<><Landmark size={18} />不可变交易流水</>}><ResourceState loading={ledger.loading} error={ledger.error} empty={ledgerItems.length === 0} onRetry={ledger.reload}>
<LedgerTable transactions={ledgerItems} currencies={currencies} /></ResourceState></Panel></>}
{editing && <CurrencyEditor initial={editing === 'new' ? undefined : editing} currencies={currencies} onClose={() => setEditing(undefined)}
onSaved={() => { setToast({ message: '货币配置已保存并立即生效', tone: 'good' }); reloadAll(); }} />}
{deleteTarget && <ModalPortal><div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setDeleteTarget(undefined); }}>
<section className="modal modal--small" role="dialog" aria-modal="true"><header className="modal__header"><div><strong>删除 {deleteTarget.name}</strong>
<small>已有账户或流水的货币不能删除,请改为停用。</small></div><button type="button" onClick={() => setDeleteTarget(undefined)}><X /></button></header>
<div className="modal__body"><Field label="审计原因"><Textarea autoFocus rows={3} maxLength={512} value={deleteReason} onChange={(event) => setDeleteReason(event.target.value)} /></Field></div>
<footer className="modal__footer"><Button variant="secondary" onClick={() => setDeleteTarget(undefined)}>取消</Button>
<Button variant="danger" disabled={busy || !deleteReason.trim()} onClick={() => void remove()}><Trash2 size={15} />确认删除</Button></footer>
</section></div></ModalPortal>}
{toast && <Toast message={toast.message} tone={toast.tone} onClose={() => setToast(undefined)} />}
</div>;
}