feat: improve account deletion and Agent authorization review
This commit is contained in:
1 parent
5ad64460f7
commit
91365ee315
21 files changed
+2276
-713
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, type Position } from './api';
|
||||
import { showToast } from './Toast';
|
||||
type Impact = {
|
||||
name: string;
|
||||
historyCount: number;
|
||||
movementCount: number;
|
||||
scheduleCount: number;
|
||||
linkCount: number;
|
||||
canDelete: boolean;
|
||||
};
|
||||
export function AccountDeletion({
|
||||
position,
|
||||
busy,
|
||||
cancel,
|
||||
remove,
|
||||
}: {
|
||||
position: Position;
|
||||
busy: boolean;
|
||||
cancel: () => void;
|
||||
remove: (name: string) => void;
|
||||
}) {
|
||||
const [impact, setImpact] = useState<Impact | null>(null),
|
||||
[name, setName] = useState('');
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void api<Impact>('/positions/' + position.id + '/deletion')
|
||||
.then((v) => {
|
||||
if (active) setImpact(v);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) showToast(e.message, 'error');
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [position.id]);
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (impact?.canDelete && name === impact.name && !busy) remove(name);
|
||||
}}
|
||||
>
|
||||
<h3>删除账户「{position.name}」</h3>
|
||||
{!impact ? (
|
||||
<p>正在检查关联记录…</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
删除后将移除该账户、{impact.historyCount} 条余额历史及 {impact.linkCount}{' '}
|
||||
个债务关联。资产总览、趋势和收支日历将同步更新,无法撤销。
|
||||
</p>
|
||||
{!impact.canDelete ? (
|
||||
<div className="agent-warning" role="status">
|
||||
<strong>请先处理账户引用</strong>
|
||||
<p>
|
||||
还有 {impact.movementCount} 条资金往来、{impact.scheduleCount}{' '}
|
||||
个定时计划。请先撤销相关资金往来并删除相关计划,再删除账户。若需要保留历史,可改用归档。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<label>
|
||||
输入完整账户名称确认删除
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="secondary" disabled={busy} onClick={cancel}>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
className="primary danger"
|
||||
disabled={busy || !impact?.canDelete || name !== impact.name}
|
||||
>
|
||||
删除账户
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
+434
-654
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,416 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, money } from './api';
|
||||
import { showToast } from './Toast';
|
||||
import {
|
||||
displayValue,
|
||||
fieldLabels,
|
||||
permissionLabel,
|
||||
statusLabel,
|
||||
toolLabel,
|
||||
type Reference,
|
||||
} from './agent-display';
|
||||
type Consent = {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
redirectUri: string;
|
||||
resource: string;
|
||||
};
|
||||
type Preview = {
|
||||
operationId: string;
|
||||
tool: string;
|
||||
status: string;
|
||||
description: string;
|
||||
expiresAt: string;
|
||||
connectionName: string;
|
||||
destructive: boolean;
|
||||
impact: {
|
||||
parameters: Record<string, unknown>;
|
||||
references: Record<string, Reference>;
|
||||
current?: Record<string, unknown>;
|
||||
};
|
||||
result?: unknown;
|
||||
};
|
||||
function ReviewError({ message, retry }: { message: string; retry: () => void }) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<h2>暂时无法加载</h2>
|
||||
<p>{message}</p>
|
||||
<button className="secondary" onClick={retry}>
|
||||
重新加载
|
||||
</button>{' '}
|
||||
<a href="/">返回资产空间</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function AgentAuthorization({ id }: { id: string | null }) {
|
||||
const [consent, setConsent] = useState<Consent | null>(null),
|
||||
[level, setLevel] = useState('read'),
|
||||
[days, setDays] = useState('30'),
|
||||
[hiddenRead, setHiddenRead] = useState(false),
|
||||
[hiddenWrite, setHiddenWrite] = useState(false),
|
||||
[busy, setBusy] = useState(false),
|
||||
[error, setError] = useState(''),
|
||||
[reload, setReload] = useState(0);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setConsent(null);
|
||||
setError('');
|
||||
setLevel('read');
|
||||
setDays('30');
|
||||
setHiddenRead(false);
|
||||
setHiddenWrite(false);
|
||||
if (!id) {
|
||||
setError('授权链接缺少请求编号,请从客户端重新发起连接。');
|
||||
return;
|
||||
}
|
||||
void api<Consent>('/agent/authorizations/' + id)
|
||||
.then((v) => {
|
||||
if (active) setConsent(v);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [id, reload]);
|
||||
const selected =
|
||||
consent?.scopes.filter(
|
||||
(s) =>
|
||||
s === 'read' ||
|
||||
s === level ||
|
||||
(s === 'hidden_read' && hiddenRead) ||
|
||||
(s === 'hidden_write' && hiddenWrite && hiddenRead && level !== 'read'),
|
||||
) || [];
|
||||
async function decide(approve: boolean) {
|
||||
if (!consent || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const v = await api<{ redirect: string }>('/agent/authorizations/' + consent.id, 'POST', {
|
||||
approve,
|
||||
scopes: selected,
|
||||
days: days === 'permanent' ? null : Number(days),
|
||||
});
|
||||
location.assign(v.redirect);
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : '授权失败', 'error');
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
if (error) return <ReviewError message={error} retry={() => setReload((v) => v + 1)} />;
|
||||
if (!consent)
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>正在读取连接请求…</p>
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<section className="panel agent-consent-card">
|
||||
<span className="badge">连接授权</span>
|
||||
<h1>允许「{consent.name}」连接 WorthPath?</h1>
|
||||
<p className="muted">选择它可以访问的范围。你可以随时在连接管理中撤销授权。</p>
|
||||
<div className="agent-permission-choices" role="group" aria-label="连接权限">
|
||||
{[
|
||||
['read', '只读查询', '查看账户和统计,无法修改账目。'],
|
||||
['draft', '草稿修改', '提出修改,由你逐项确认后执行。'],
|
||||
['write', '直接写入', '普通账目修改立即执行,无需逐次确认。'],
|
||||
]
|
||||
.filter(([key]) => consent.scopes.includes(key))
|
||||
.map(([key, title, detail]) => (
|
||||
<button
|
||||
key={key}
|
||||
className={level === key ? 'permission-choice selected' : 'permission-choice'}
|
||||
disabled={busy}
|
||||
aria-pressed={level === key}
|
||||
onClick={() => {
|
||||
setLevel(key);
|
||||
if (key === 'read') setHiddenWrite(false);
|
||||
}}
|
||||
>
|
||||
<strong>{title}</strong>
|
||||
<span>{detail}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="agent-duration">
|
||||
授权有效期
|
||||
<select value={days} disabled={busy} onChange={(e) => setDays(e.target.value)}>
|
||||
{[
|
||||
['1', '1 天'],
|
||||
['3', '3 天'],
|
||||
['7', '7 天'],
|
||||
['30', '30 天'],
|
||||
['365', '1 年(365 天)'],
|
||||
['permanent', '永久(可撤销)'],
|
||||
].map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>有限期限到期后重新授权;永久授权仍可随时撤销。</small>
|
||||
</label>
|
||||
{consent.scopes.includes('hidden_read') && (
|
||||
<fieldset className="agent-scope-options">
|
||||
<legend>隐藏账户(默认不授权)</legend>
|
||||
<label className="check-line">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={busy}
|
||||
checked={hiddenRead}
|
||||
onChange={(e) => {
|
||||
setHiddenRead(e.target.checked);
|
||||
if (!e.target.checked) setHiddenWrite(false);
|
||||
}}
|
||||
/>
|
||||
允许读取隐藏账户
|
||||
</label>
|
||||
{consent.scopes.includes('hidden_write') && (
|
||||
<label className="check-line">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={busy || !hiddenRead || level === 'read'}
|
||||
checked={hiddenWrite}
|
||||
onChange={(e) => setHiddenWrite(e.target.checked)}
|
||||
/>
|
||||
允许修改隐藏账户
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
<div className="agent-final-permission" role="status">
|
||||
<strong>最终授权:{permissionLabel(selected)}</strong>
|
||||
<span>
|
||||
授权期限:
|
||||
{days === 'permanent'
|
||||
? '永久(可撤销)'
|
||||
: days === '365'
|
||||
? '1 年(365 天)'
|
||||
: days + ' 天'}
|
||||
</span>
|
||||
<span>
|
||||
隐藏账户:{hiddenRead ? (hiddenWrite ? '可读取和修改' : '仅可读取') : '不可访问'}
|
||||
</span>
|
||||
</div>
|
||||
<details className="agent-details">
|
||||
<summary>核对客户端与授权地址</summary>
|
||||
<dl className="agent-fields">
|
||||
<dt>服务地址</dt>
|
||||
<dd>{consent.resource}</dd>
|
||||
<dt>回调地址</dt>
|
||||
<dd>{consent.redirectUri}</dd>
|
||||
<dt>候选权限</dt>
|
||||
<dd>{consent.scopes.join(', ')}</dd>
|
||||
<dt>最终权限代码</dt>
|
||||
<dd>{selected.join(', ')}</dd>
|
||||
</dl>
|
||||
<p>客户端请求的是候选范围;最终仅授予上方选中的权限。只批准你正在连接的客户端。</p>
|
||||
</details>
|
||||
<div className="modal-actions">
|
||||
<button className="secondary" disabled={busy} onClick={() => void decide(false)}>
|
||||
拒绝授权
|
||||
</button>
|
||||
<button
|
||||
className="primary"
|
||||
disabled={busy || !selected.includes('read')}
|
||||
onClick={() => void decide(true)}
|
||||
>
|
||||
{busy ? '正在处理…' : '授权此连接'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function Fields({
|
||||
data,
|
||||
references,
|
||||
currency,
|
||||
receivedCurrency,
|
||||
liability = false,
|
||||
}: {
|
||||
data: unknown;
|
||||
references: Record<string, Reference>;
|
||||
currency?: string;
|
||||
receivedCurrency?: string;
|
||||
liability?: boolean;
|
||||
}) {
|
||||
if (data === null || typeof data !== 'object' || Array.isArray(data))
|
||||
return <p>{displayValue(data, references)}</p>;
|
||||
const entries = Object.entries(data as Record<string, unknown>);
|
||||
const object = data as Record<string, unknown>;
|
||||
const localCurrency =
|
||||
typeof object.currency === 'string'
|
||||
? object.currency
|
||||
: references[String(object.sourceId || object.id || '')]?.currency || currency;
|
||||
const targetCurrency =
|
||||
references[String(object.targetId || '')]?.currency || receivedCurrency || localCurrency;
|
||||
return (
|
||||
<dl className="agent-fields">
|
||||
{entries.map(([key, value]) => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value))
|
||||
return (
|
||||
<div className="agent-field-section" key={key}>
|
||||
<h3>{key === 'data' ? '修改内容' : fieldLabels[key] || key}</h3>
|
||||
<Fields
|
||||
data={value}
|
||||
references={references}
|
||||
currency={localCurrency}
|
||||
receivedCurrency={targetCurrency}
|
||||
liability={liability}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const amount =
|
||||
['amount', 'received', 'fee', 'metalCostPerGram', 'costPerGram'].includes(key) &&
|
||||
typeof value === 'string' &&
|
||||
/^-?\d+(\.\d+)?$/.test(value);
|
||||
let shown = displayValue(value, references);
|
||||
const fieldCurrency = key === 'received' ? targetCurrency : localCurrency;
|
||||
if (amount && fieldCurrency) {
|
||||
let v = String(value);
|
||||
if (key === 'amount' && liability && !/^-?0(\.0+)?$/.test(v))
|
||||
v = v.startsWith('-') ? v.slice(1) : '-' + v;
|
||||
shown = money(v, fieldCurrency);
|
||||
}
|
||||
return (
|
||||
<div className="agent-field" key={key}>
|
||||
<dt>{fieldLabels[key] || key}</dt>
|
||||
<dd>{shown}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
export function AgentOperation({ id }: { id: string | null }) {
|
||||
const [preview, setPreview] = useState<Preview | null>(null),
|
||||
[error, setError] = useState(''),
|
||||
[busy, setBusy] = useState(false),
|
||||
[reload, setReload] = useState(0),
|
||||
[now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setPreview(null);
|
||||
setError('');
|
||||
if (!id) {
|
||||
setError('草稿链接缺少操作编号,请从操作记录重新打开。');
|
||||
return;
|
||||
}
|
||||
void api<Preview>('/agent/operations/' + id)
|
||||
.then((v) => {
|
||||
if (active) setPreview(v);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [id, reload]);
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
async function decide(approve: boolean) {
|
||||
if (!preview || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api('/agent/operations/' + preview.operationId, 'POST', { approve });
|
||||
setPreview(await api<Preview>('/agent/operations/' + preview.operationId));
|
||||
showToast(approve ? '修改已完成,Agent 可以查询结果' : '草稿已取消', 'success');
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : '操作失败', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
if (error) return <ReviewError message={error} retry={() => setReload((v) => v + 1)} />;
|
||||
if (!preview)
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>正在读取草稿…</p>
|
||||
</section>
|
||||
);
|
||||
const expired = preview.status === 'pending' && new Date(preview.expiresAt).getTime() <= now;
|
||||
const pending = preview.status === 'pending' && !expired;
|
||||
const params = preview.impact.parameters,
|
||||
refs = preview.impact.references || {};
|
||||
const ref = refs[String(params.id || params.sourceId || '')];
|
||||
const absolute = ['balance_record', 'history_update', 'position_create'].includes(preview.tool);
|
||||
const currency =
|
||||
ref?.currency ||
|
||||
refs[String(preview.impact.current?.sourceId || '')]?.currency ||
|
||||
(typeof params.currency === 'string' ? params.currency : undefined);
|
||||
const receivedCurrency =
|
||||
refs[String(params.targetId || preview.impact.current?.targetId || '')]?.currency;
|
||||
return (
|
||||
<section className="panel agent-consent-card">
|
||||
<span className="badge">{statusLabel(expired ? 'expired' : preview.status)}</span>
|
||||
<h1>{toolLabel(preview.tool)}</h1>
|
||||
<p className="muted">来自连接:{preview.connectionName}</p>
|
||||
{pending && <p>请核对下方内容。确认后修改才会写入账目。</p>}
|
||||
{absolute && (
|
||||
<p className="agent-final-permission">金额为变更后的余额或估值;负债按欠款显示为负数。</p>
|
||||
)}
|
||||
{preview.destructive && (
|
||||
<div className="agent-warning">这项操作会删除记录并重算相关余额,请核对后再确认。</div>
|
||||
)}
|
||||
{preview.impact.current && (
|
||||
<section className="agent-existing">
|
||||
<h3>当前记录</h3>
|
||||
<Fields
|
||||
data={preview.impact.current}
|
||||
references={refs}
|
||||
currency={currency}
|
||||
liability={
|
||||
['balance_record', 'history_update', 'history_delete'].includes(preview.tool) &&
|
||||
ref?.side === 'liability'
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<h3>{preview.destructive ? '操作对象' : '提交的修改'}</h3>
|
||||
<Fields
|
||||
data={params}
|
||||
references={refs}
|
||||
currency={currency}
|
||||
receivedCurrency={receivedCurrency}
|
||||
liability={absolute && (ref?.side === 'liability' || params.side === 'liability')}
|
||||
/>
|
||||
{pending && (
|
||||
<p className="muted">
|
||||
有效至{' '}
|
||||
{new Date(preview.expiresAt).toLocaleString('zh-CN', { timeZone: 'Asia/Hong_Kong' })}
|
||||
(UTC+8)。账目发生变化时,需要 Agent 重新创建草稿。
|
||||
</p>
|
||||
)}
|
||||
{expired && <p className="agent-warning">草稿已过期,请让 Agent 重新创建。</p>}
|
||||
{preview.status === 'completed' && <p role="status">这项修改已经执行完成。</p>}
|
||||
{preview.result !== undefined && preview.result !== null && (
|
||||
<details className="agent-details">
|
||||
<summary>查看执行结果</summary>
|
||||
<Fields data={preview.result} references={refs} />
|
||||
</details>
|
||||
)}
|
||||
<details className="agent-details">
|
||||
<summary>查看工具说明</summary>
|
||||
<code>{preview.tool}</code>
|
||||
<p>{preview.description}</p>
|
||||
</details>
|
||||
<div className="modal-actions">
|
||||
<a href="/">返回资产空间</a>
|
||||
{pending && (
|
||||
<>
|
||||
<button className="secondary" disabled={busy} onClick={() => void decide(false)}>
|
||||
取消草稿
|
||||
</button>
|
||||
<button className="primary" disabled={busy} onClick={() => void decide(true)}>
|
||||
{busy ? '正在处理…' : '确认执行'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+65
-16
@@ -54,6 +54,8 @@ import { orderedGroups, mergeGroupOrder } from './group-order';
|
||||
import { Calendar } from './Calendar';
|
||||
import { SchedulePanel } from './SchedulePanel';
|
||||
import { AgentConnections } from './AgentConnections';
|
||||
import { AgentAuthorization, AgentOperation } from './AgentReview';
|
||||
import { AccountDeletion } from './AccountDeletion';
|
||||
import { IconPicker } from './IconPicker';
|
||||
import { MetalPanel } from './MetalPanel';
|
||||
import { QuickTransfer } from './QuickTransfer';
|
||||
@@ -233,7 +235,7 @@ export default function App() {
|
||||
[page, setPage] = useState(
|
||||
new URLSearchParams(location.search).has('agent_authorization') ||
|
||||
new URLSearchParams(location.search).has('agent_operation')
|
||||
? 'settings'
|
||||
? 'agent-review'
|
||||
: 'overview',
|
||||
),
|
||||
[positions, setPositions] = useState<Position[]>([]),
|
||||
@@ -693,7 +695,7 @@ export default function App() {
|
||||
const agentReturn =
|
||||
new URLSearchParams(location.search).has('agent_authorization') ||
|
||||
new URLSearchParams(location.search).has('agent_operation');
|
||||
setPage(agentReturn ? 'settings' : 'overview');
|
||||
setPage(agentReturn ? 'agent-review' : 'overview');
|
||||
if (agentReturn) setSettingsSection('agent');
|
||||
setSelected(null);
|
||||
setModal(null);
|
||||
@@ -878,6 +880,30 @@ export default function App() {
|
||||
if (session === sessionGeneration.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
const agentParams = new URLSearchParams(location.search);
|
||||
if (
|
||||
agentParams.has('agent_authorization') ||
|
||||
agentParams.has('agent_operation') ||
|
||||
location.pathname === '/agent/authorize' ||
|
||||
location.pathname === '/agent/operation'
|
||||
) {
|
||||
return (
|
||||
<main className="agent-review-page">
|
||||
<header className="agent-review-header">
|
||||
<a href="/">WorthPath</a>
|
||||
<span>{user.username}</span>
|
||||
<button className="text" disabled={busy} onClick={() => void logout()}>
|
||||
退出登录
|
||||
</button>
|
||||
</header>
|
||||
{agentParams.has('agent_authorization') || location.pathname === '/agent/authorize' ? (
|
||||
<AgentAuthorization id={agentParams.get('agent_authorization')} />
|
||||
) : (
|
||||
<AgentOperation id={agentParams.get('agent_operation')} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
const nav = [
|
||||
['overview', LayoutDashboard],
|
||||
['account', Wallet],
|
||||
@@ -1341,6 +1367,15 @@ export default function App() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{p.kind === 'account' && (
|
||||
<button
|
||||
className="secondary danger-text"
|
||||
disabled={busy}
|
||||
onClick={() => setModal({ kind: 'delete-account', p })}
|
||||
>
|
||||
删除账户
|
||||
</button>
|
||||
)}
|
||||
<button className="secondary" onClick={() => setModal({ kind: 'edit', p })}>
|
||||
{tr('编辑资料')}
|
||||
</button>
|
||||
@@ -2572,25 +2607,39 @@ export default function App() {
|
||||
? modal.p?.kind === 'debt'
|
||||
? operationLabel(modal.p.side === 'asset' ? 'collect' : 'repay')
|
||||
: tr('收款 / 还款')
|
||||
: modal.kind === 'transfer'
|
||||
? tr('转账')
|
||||
: modal.kind === 'reveal'
|
||||
? tr('验证密码以显示隐藏资产')
|
||||
: modal.kind === 'edit'
|
||||
? tr('编辑项目')
|
||||
: modal.kind === 'revision'
|
||||
? tr('更新余额 / 估值')
|
||||
: modal.kind === 'correction'
|
||||
? tr('更正历史记录')
|
||||
: modal.kind === 'links'
|
||||
? tr('管理债务关联')
|
||||
: tr('新增') + tr(labels[modal.kind])
|
||||
: modal.kind === 'delete-account'
|
||||
? '删除账户'
|
||||
: modal.kind === 'transfer'
|
||||
? tr('转账')
|
||||
: modal.kind === 'reveal'
|
||||
? tr('验证密码以显示隐藏资产')
|
||||
: modal.kind === 'edit'
|
||||
? tr('编辑项目')
|
||||
: modal.kind === 'revision'
|
||||
? tr('更新余额 / 估值')
|
||||
: modal.kind === 'correction'
|
||||
? tr('更正历史记录')
|
||||
: modal.kind === 'links'
|
||||
? tr('管理债务关联')
|
||||
: tr('新增') + tr(labels[modal.kind])
|
||||
}
|
||||
close={() => {
|
||||
if (!busy) setModal(null);
|
||||
}}
|
||||
>
|
||||
{['transfer', 'debt-payment'].includes(modal.kind) ? (
|
||||
{modal.kind === 'delete-account' && modal.p ? (
|
||||
<AccountDeletion
|
||||
position={modal.p}
|
||||
busy={busy}
|
||||
cancel={() => setModal(null)}
|
||||
remove={(confirmation) =>
|
||||
void act(async () => {
|
||||
await api('/positions/' + modal.p!.id, 'DELETE', { confirmation });
|
||||
setSelected(null);
|
||||
}, '账户已删除')
|
||||
}
|
||||
/>
|
||||
) : ['transfer', 'debt-payment'].includes(modal.kind) ? (
|
||||
movementLoading ? (
|
||||
<p>{tr('正在刷新数据…')}</p>
|
||||
) : modal.kind === 'transfer' ? (
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
export const toolLabels: Record<string, string> = {
|
||||
schedule_toggle: '启用或暂停计划',
|
||||
metal_holding_create: '新增贵金属持仓',
|
||||
metal_value: '按参考价更新贵金属估值',
|
||||
metals_prices: '查询贵金属参考价',
|
||||
connection_revoke: '撤销当前连接',
|
||||
position_create: '新增账户、资产或债务',
|
||||
position_update: '修改项目资料',
|
||||
balance_record: '更新余额或估值',
|
||||
history_update: '更正余额历史',
|
||||
history_delete: '删除余额历史',
|
||||
debt_links_set: '更新债务关联',
|
||||
movement_create: '记录资金往来',
|
||||
movement_update: '修改资金往来',
|
||||
movement_delete: '撤销资金往来',
|
||||
schedule_create: '新增定时计划',
|
||||
schedule_update: '修改定时计划',
|
||||
schedule_delete: '删除定时计划',
|
||||
schedules_run: '执行到期计划',
|
||||
schedule_run: '执行到期计划',
|
||||
settings_update: '更新偏好设置',
|
||||
account_groups_order: '调整账户分组顺序',
|
||||
icon_publish: '保存私有图标',
|
||||
metal_configure: '设置贵金属持仓',
|
||||
metal_holding_update: '更新贵金属克数',
|
||||
positions_list: '查询账户、资产和债务',
|
||||
position_get: '查看项目详情',
|
||||
history_list: '查询余额历史',
|
||||
overview_get: '查看资产总览',
|
||||
trend_get: '查看净资产趋势',
|
||||
movements_list: '查询资金往来',
|
||||
movement_by_revision: '查看关联资金往来',
|
||||
calendar_month: '查看月度收支',
|
||||
calendar_day: '查看每日收支',
|
||||
schedules_list: '查看定时计划',
|
||||
schedule_runs: '查看计划执行记录',
|
||||
settings_get: '读取偏好设置',
|
||||
rates_list: '查询汇率',
|
||||
metals_list: '查询贵金属参考价',
|
||||
icons_list: '查询图标库',
|
||||
connection_info: '查看连接权限',
|
||||
state_get: '核对账目状态',
|
||||
operation_get: '查询草稿结果',
|
||||
file_upload_request: '上传私有图标',
|
||||
file_status: '查询上传状态',
|
||||
icon_image: '读取图标',
|
||||
};
|
||||
export const statusLabels: Record<string, string> = {
|
||||
pending: '待确认',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
expired: '已过期',
|
||||
failed: '失败',
|
||||
success: '成功',
|
||||
error: '失败',
|
||||
denied: '已拒绝',
|
||||
};
|
||||
export function toolLabel(name: string) {
|
||||
return toolLabels[name] || '账目操作';
|
||||
}
|
||||
export function statusLabel(status: string) {
|
||||
return statusLabels[status] || status;
|
||||
}
|
||||
export function permissionLabel(scopes: string[]) {
|
||||
return scopes.includes('write') ? '直接写入' : scopes.includes('draft') ? '草稿修改' : '只读查询';
|
||||
}
|
||||
export const fieldLabels: Record<string, string> = {
|
||||
status: '状态',
|
||||
message: '处理说明',
|
||||
createdAt: '创建时间',
|
||||
updatedAt: '更新时间',
|
||||
effectiveDate: '业务时间',
|
||||
data: '修改内容',
|
||||
nextAt: '下次执行时间',
|
||||
intervalDays: '间隔天数(0 为一次)',
|
||||
scheduled_expense: '计划支出',
|
||||
maxRuns: '最多执行次数',
|
||||
repeat: '重复规则',
|
||||
nextRunAt: '下次执行时间',
|
||||
id: '项目',
|
||||
positionId: '项目',
|
||||
sourceId: '转出账户',
|
||||
targetId: '转入项目',
|
||||
revisionId: '历史记录',
|
||||
name: '名称',
|
||||
kind: '类型',
|
||||
side: '资产属性',
|
||||
category: '分类',
|
||||
groupName: '账户分组',
|
||||
currency: '币种',
|
||||
amount: '金额',
|
||||
received: '到账金额',
|
||||
fee: '手续费',
|
||||
date: '业务时间',
|
||||
notes: '备注',
|
||||
reason: '变更原因',
|
||||
operation: '操作类型',
|
||||
archived: '归档',
|
||||
hidden: '隐藏',
|
||||
included: '计入总览',
|
||||
iconId: '图标',
|
||||
targetIds: '关联项目',
|
||||
groups: '分组顺序',
|
||||
metalType: '贵金属品种',
|
||||
grams: '重量(克)',
|
||||
metalGrams: '重量(克)',
|
||||
costPerGram: '每克买入成本',
|
||||
metalCostPerGram: '每克买入成本',
|
||||
enabled: '启用计划',
|
||||
frequency: '执行频率',
|
||||
interval: '间隔',
|
||||
nextDate: '下次执行时间',
|
||||
startDate: '开始时间',
|
||||
endDate: '结束时间',
|
||||
dayOfMonth: '每月执行日',
|
||||
baseCurrency: '本位币',
|
||||
showNotes: '显示备注',
|
||||
hiddenMenus: '隐藏菜单',
|
||||
overviewCards: '总览卡片',
|
||||
includeIndependentAssets: '独立资产计入总览',
|
||||
accountGroupOrder: '分组顺序',
|
||||
ok: '执行成功',
|
||||
executed: '已执行次数',
|
||||
errors: '执行异常',
|
||||
hasMore: '仍有待执行计划',
|
||||
};
|
||||
export const valueLabels: Record<string, string> = {
|
||||
account: '账户',
|
||||
asset: '资产',
|
||||
liability: '负债',
|
||||
debt: '债务',
|
||||
bank: '银行账户',
|
||||
cash: '现金',
|
||||
investment: '投资账户',
|
||||
credit_card: '信用卡',
|
||||
loan: '贷款账户',
|
||||
other: '其他',
|
||||
gold: '黄金',
|
||||
silver: '白银',
|
||||
property: '房产',
|
||||
vehicle: '车辆',
|
||||
personal: '个人借款',
|
||||
transfer: '转账',
|
||||
borrow: '借入',
|
||||
lend: '借出',
|
||||
repay: '还款',
|
||||
collect: '收款',
|
||||
expense: '支出',
|
||||
initial: '初始余额',
|
||||
balance: '余额更新',
|
||||
valuation: '估值更新',
|
||||
correction: '历史更正',
|
||||
repayment: '还款',
|
||||
daily: '每天',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
};
|
||||
export type Reference = { name: string; currency: string; side: string; kind: string };
|
||||
export function displayValue(value: unknown, references: Record<string, Reference> = {}): string {
|
||||
if (value === null || value === undefined || value === '') return '未设置';
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
if (Array.isArray(value))
|
||||
return value.length ? value.map((v) => displayValue(v, references)).join('、') : '无';
|
||||
const s = String(value);
|
||||
return references[s]?.name || valueLabels[s] || s;
|
||||
}
|
||||
@@ -2042,3 +2042,229 @@ textarea,
|
||||
.agent-tabs button {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* Agent setup and standalone review pages */
|
||||
.agent-review-page {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 64px;
|
||||
}
|
||||
.agent-review-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.agent-review-header a {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
margin-right: auto;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.agent-consent-card h1 {
|
||||
font-size: clamp(24px, 4vw, 32px);
|
||||
line-height: 1.35;
|
||||
margin: 18px 0 12px;
|
||||
}
|
||||
.agent-consent-card > p {
|
||||
line-height: 1.7;
|
||||
}
|
||||
.agent-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.agent-connect-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(240px, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.agent-step-number {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #e8f3ef;
|
||||
color: #246952;
|
||||
font-weight: 700;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.agent-setup h3 {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.agent-help {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
}
|
||||
.agent-permission-choices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.permission-choice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
height: 100%;
|
||||
border: 1px solid #dde4df;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
color: #24392f;
|
||||
white-space: normal;
|
||||
}
|
||||
.permission-choice span {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #617068;
|
||||
}
|
||||
.permission-choice.selected {
|
||||
border-color: #327d60;
|
||||
background: #f0f8f3;
|
||||
box-shadow: inset 0 0 0 1px #327d60;
|
||||
}
|
||||
.agent-final-permission {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
margin: 24px 0;
|
||||
border-radius: 12px;
|
||||
background: #f0f8f3;
|
||||
color: #2c5d48;
|
||||
}
|
||||
.agent-warning {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: #fff4e7;
|
||||
color: #8b5013;
|
||||
line-height: 1.6;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.agent-fields {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.agent-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(110px, 1fr) minmax(0, 3fr);
|
||||
gap: 20px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid #e8ece9;
|
||||
}
|
||||
.agent-fields dt {
|
||||
color: #617068;
|
||||
font-size: 14px;
|
||||
}
|
||||
.agent-fields dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.agent-field-section {
|
||||
padding: 12px 0;
|
||||
}
|
||||
.agent-field-section h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.agent-tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.agent-tool-card {
|
||||
border: 1px solid #e3e9e5;
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
.agent-tool-card h4 {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.agent-tool-card code {
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.agent-review-link {
|
||||
background: #246952;
|
||||
color: #fff;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agent-empty {
|
||||
text-align: center;
|
||||
padding: 28px 16px;
|
||||
color: #617068;
|
||||
}
|
||||
.agent-card + .agent-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.agent-form .check-line {
|
||||
display: flex;
|
||||
}
|
||||
.agent-consent-card .modal-actions {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.agent-secret {
|
||||
user-select: text;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.agent-connect-layout,
|
||||
.agent-permission-choices,
|
||||
.agent-tool-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.agent-help {
|
||||
position: static;
|
||||
}
|
||||
.agent-review-page {
|
||||
padding: 20px 14px 40px;
|
||||
}
|
||||
.agent-row {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.agent-field {
|
||||
grid-template-columns: minmax(90px, 1fr) minmax(0, 2fr);
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-consent-card .agent-scope-options .check-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.agent-consent-card .check-line input[type='checkbox'],
|
||||
.agent-form .check-line input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-height: 18px;
|
||||
padding: 0;
|
||||
flex: 0 0 18px;
|
||||
}
|
||||
.agent-existing {
|
||||
padding: 16px;
|
||||
background: #f8faf8;
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.agent-setup .agent-scope-options .check-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.agent-setup .check-line input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
}
|
||||
Reference in new issue
Block a user