feat:cash-assets-batch-draft-review-and-login-memory

This commit is contained in:
陈煜 committed 2026-10-05 12:29:07 +08:00
1 parent ce8609116b
commit cfbba73791
16 files changed
+872 -46

No files matched your search

+318
View File
@@ -0,0 +1,318 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { api } from './api';
import { AgentOperation, Fields, type Preview } from './AgentReview';
import { toolLabel, statusLabel } from './agent-display';
import { showToast } from './Toast';
export function ReviewDialog({
children,
close,
busy = false,
}: {
children: ReactNode;
close: () => void;
busy?: boolean;
}) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
ref.current?.focus();
return () => {
document.body.style.overflow = overflow;
previous?.focus();
};
}, []);
return createPortal(
<div
className="veil"
onClick={(e) => {
if (e.target === e.currentTarget && !busy) close();
}}
>
<section
ref={ref}
tabIndex={-1}
className="modal draft-dialog"
role="dialog"
aria-modal="true"
aria-label="审阅草稿"
onKeyDown={(e) => {
if (e.key === 'Escape' && !busy) {
e.preventDefault();
close();
}
if (e.key === 'Tab') {
const nodes = Array.from(
ref.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled),input:not(:disabled),a[href],select:not(:disabled),summary',
) || [],
);
const first = nodes[0],
last = nodes[nodes.length - 1];
if (!first) {
e.preventDefault();
return;
}
if (
e.shiftKey &&
(document.activeElement === first || document.activeElement === ref.current)
) {
e.preventDefault();
last.focus();
} else if (
!e.shiftKey &&
(document.activeElement === last || document.activeElement === ref.current)
) {
e.preventDefault();
first.focus();
}
}
}}
>
<header>
<h2>审阅草稿</h2>
<button className="icon" aria-label="关闭" disabled={busy} onClick={close}>
×
</button>
</header>
{children}
</section>
</div>,
document.body,
);
}
type Draft = Preview & { error?: string };
export function AgentDrafts({
initialId,
close,
changed,
}: {
initialId?: string;
close: () => void;
changed: () => void;
}) {
const [items, setItems] = useState<Draft[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [detail, setDetail] = useState(false);
const [now, setNow] = useState(Date.now());
async function load(initial = false) {
setLoading(true);
setError('');
try {
const all: Draft[] = [];
let cursor: string | null = null;
do {
const page: { items: Draft[]; nextCursor: string | null } = await api(
'/agent/drafts' + (cursor ? '?cursor=' + encodeURIComponent(cursor) : ''),
);
all.push(...page.items);
cursor = page.nextCursor;
} while (cursor);
setItems(all);
setSelected(initial && initialId ? [initialId] : []);
setDetail(!!initialId && !all.some((o) => o.operationId === initialId));
} catch (e) {
setError(e instanceof Error ? e.message : '草稿加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void load(true);
}, [initialId]);
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
const valid = items.filter(
(o) => o.status === 'pending' && !o.error && new Date(o.expiresAt).getTime() > now,
);
const eligible = selected.filter((id) => valid.some((o) => o.operationId === id));
async function decide(approve: boolean) {
if (busy || !eligible.length || eligible.length > 100) return;
setBusy(true);
setError('');
try {
await api('/agent/operations/confirm-batch', 'POST', {
approve,
operationIds: eligible,
});
showToast(approve ? '所选草稿已全部执行' : '所选草稿已取消', 'success');
changed();
await load();
} catch (e) {
const message = e instanceof Error ? e.message : '提交失败';
setError(message + '。本批修改未执行,请刷新后重新审阅。');
showToast(message, 'error');
} finally {
setBusy(false);
}
}
async function cancelUnavailable(id: string) {
if (busy) return;
setBusy(true);
try {
await api('/agent/operations/' + id, 'POST', { approve: false });
changed();
await load();
} catch (e) {
setError(e instanceof Error ? e.message : '取消失败');
} finally {
setBusy(false);
}
}
return (
<ReviewDialog close={close} busy={busy}>
{loading ? (
<p>正在读取全部草稿…</p>
) : detail ? (
<AgentOperation id={initialId || null} changed={changed} />
) : (
<>
<p className="muted">核对每项修改后选择。一次最多同意 100 项,任一失败整批回滚。</p>
<div className="draft-toolbar">
<button
className="secondary"
disabled={busy || !valid.length}
onClick={() => setSelected(valid.slice(0, 100).map((o) => o.operationId))}
>
{valid.length > 100 ? '选择前 100 项' : '全选'}
</button>
<button className="secondary" disabled={busy} onClick={() => setSelected([])}>
取消全选
</button>
<button className="secondary" disabled={busy} onClick={() => void load()}>
刷新草稿
</button>
<span role="status">
已选择 {eligible.length} / {items.length} 项
</span>
</div>
{!items.length && <p>没有等待确认的修改。</p>}
<div className="draft-list">
{items.map((o) => {
const available = valid.some((v) => v.operationId === o.operationId);
const p = o.impact?.parameters || {},
refs = o.impact?.references || {};
const ref = refs[String(p.id || p.sourceId || '')];
const absolute = ['balance_record', 'history_update', 'position_create'].includes(
o.tool,
);
return (
<article className="draft-card" key={o.operationId}>
<label className="check-line">
<input
type="checkbox"
disabled={busy || !available}
checked={eligible.includes(o.operationId)}
onChange={(e) =>
setSelected((ids) =>
e.target.checked
? [...ids, o.operationId]
: ids.filter((id) => id !== o.operationId),
)
}
/>
<strong>{toolLabel(o.tool)}</strong>
<span>
{o.connectionName} · {statusLabel(available ? o.status : 'expired')}
</span>
</label>
{o.error ? (
<div>
<p className="danger-text">{o.error}</p>
<button
className="secondary"
disabled={busy}
onClick={() => void cancelUnavailable(o.operationId)}
>
取消草稿
</button>
</div>
) : (
<>
{o.destructive && (
<p className="agent-warning">删除记录并重算余额,请仔细核对。</p>
)}
{o.impact.current && (
<details open>
<summary>当前记录</summary>
<Fields
data={o.impact.current}
references={refs}
currency={ref?.currency}
liability={absolute && ref?.side === 'liability'}
/>
</details>
)}
<Fields
data={
o.tool === 'position_create'
? Object.fromEntries(
Object.entries(p).filter(
([key, value]) =>
!['hidden', 'archived', 'notes'].includes(key) ||
(value !== false && value !== '' && value !== null),
),
)
: p
}
references={refs}
currency={ref?.currency}
liability={
absolute && (ref?.side === 'liability' || p.side === 'liability')
}
/>
{o.tool === 'position_create' && (
<details className="agent-details">
<summary>查看全部字段</summary>
<Fields data={p} references={refs} liability={p.side === 'liability'} />
</details>
)}
<small className="muted">
有效至 {new Date(o.expiresAt).toLocaleString()}
</small>
</>
)}
</article>
);
})}
</div>
<div className="draft-footer">
<button className="secondary" disabled={busy} onClick={close}>
稍后处理
</button>
<button
className="secondary"
disabled={busy || !eligible.length || eligible.length > 100}
onClick={() => void decide(false)}
>
取消所选草稿
</button>
<button
className="primary"
disabled={busy || !eligible.length || eligible.length > 100}
onClick={() => void decide(true)}
>
{busy ? '正在执行…' : '同意所选 ' + eligible.length + ' 项'}
</button>
</div>
</>
)}
{error && (
<p role="alert" className="danger-text">
{error}
<button className="secondary" disabled={busy} onClick={() => void load()}>
重新加载
</button>
</p>
)}
</ReviewDialog>
);
}