XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEServerManager

【Java】我的世界XFE服务器管理器

公开
关注 0 Fork 0 Star 0
UTF-8
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>;
}