diff --git a/README.md b/README.md index 4f83d9d..965e7cc 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json ## 连接 Agent / MCP -设置中的“连接助手”提供可复制的接入提示词、能力说明及权限选择。OAuth 授权和草稿确认使用独立页面;可选择 1、3、7、30 天、1 年或永久授权,并分别控制读取、修改隐藏账户。普通权限分为只读、草稿修改、直接写入,草稿需本人在网页审阅确认后才生效。 +设置中的“连接助手”提供可复制的接入提示词、能力说明及权限选择。OAuth 授权使用独立页面,草稿在弹窗中审阅并可全选批量同意;可选择 1、3、7、30 天、1 年或永久授权,并分别控制读取、修改隐藏账户。普通权限分为只读、草稿修改、直接写入,草稿需本人在网页审阅确认后才生效。 密码修改、清空数据、备份恢复、共享图标发布、汇率及贵金属报价修改在网站完成,MCP 不提供这些敏感操作。远程接入必须配置客户端可访问的 MCP_PUBLIC_URL 与 MCP_WEB_URL;localhost 仅代表客户端本机。详见 [连接与授权更新](docs/update-agent-accounts-2026-10-04.md)。 @@ -133,3 +133,5 @@ git config remote.pushDefault github 上述接口由后端访问,浏览器业务请求使用本站 `/api`,第三方不会收到本应用登录 Cookie。Gold API 返回的参考价只用于持仓估值,不包含饰品工费、买卖价差或回收折价。外币价格使用最新可用公共日汇率,休市时两类报价时间可能不同;报价日期会显示在界面并随备份保存。自动估价为可选项,开启后新增估值历史,不改写历史记录。 MCP 同一连接、同一账目快照下的多个新增账号草稿可依次审阅确认;其他修改仍保留状态冲突检查。修复前已失效的草稿需只为尚未新增的账号重新生成,见 [多账号草稿确认修复](docs/fix-mcp-create-drafts-2026-10-04.md)。 + +独立资产支持现金分类和快速估值。登录页可记住账号并交由浏览器密码管理器保存密码。草稿弹窗支持全量分页、批量同意与取消,提交失败整批回滚。详见 [现金、草稿审阅与登录更新](docs/update-cash-drafts-login-2026-10-05.md)。 diff --git a/apps/api/package.json b/apps/api/package.json index 98f29fb..343883f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,7 @@ "dev": "node scripts/dev.cjs", "build": "tsc", "typecheck": "tsc --noEmit", - "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts ../web/test/quick-entry.test.ts ../web/test/net-worth-chart.test.ts", + "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts ../web/test/quick-entry.test.ts ../web/test/net-worth-chart.test.ts ../web/test/login-preferences.test.ts", "db:generate": "prisma generate", "db:migrate": "node scripts/database.cjs deploy", "db:status": "node scripts/database.cjs status", diff --git a/apps/api/src/mcp/management.ts b/apps/api/src/mcp/management.ts index 71458d7..26b1579 100644 --- a/apps/api/src/mcp/management.ts +++ b/apps/api/src/mcp/management.ts @@ -5,6 +5,7 @@ import { Delete, Req, Param, + Query, Body, Res, ForbiddenException, @@ -117,6 +118,18 @@ export class AgentManagementController { .parse(raw); return this.oauth.consent(r.userId, z.string().uuid().parse(id), approve, scopes, days); } + + @Get('drafts') drafts(@Req() r: UserRequest, @Query('cursor') cursor?: string) { + return this.operations.drafts(r.userId, cursor ? z.string().uuid().parse(cursor) : undefined); + } + @Post('operations/confirm-batch') async confirmBatch( + @Req() r: UserRequest, + @Body() raw: unknown, + @Res({ passthrough: true }) res: Response, + ) { + this.auth.limit(r); + return this.operations.confirmBatch(r, raw, res); + } @Get('operations/:id') preview(@Req() r: UserRequest, @Param('id') id: string) { return this.operations.preview(r.userId, z.string().uuid().parse(id)); } diff --git a/apps/api/src/mcp/operations.ts b/apps/api/src/mcp/operations.ts index 2044886..52b424b 100644 --- a/apps/api/src/mcp/operations.ts +++ b/apps/api/src/mcp/operations.ts @@ -361,6 +361,86 @@ export class AgentOperations { ); }, 300000); } + + async drafts(userId: string, cursor?: string) { + const rows = await this.db.agentOperation.findMany({ + where: { userId, status: 'pending', expiresAt: { gt: new Date() } }, + orderBy: { id: 'asc' }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + take: 51, + }); + const page = rows.slice(0, 50); + const items = await Promise.all( + page.map(async (row) => { + try { + return await this.preview(userId, row.id); + } catch { + return { + operationId: row.id, + tool: row.tool, + status: 'unavailable', + expiresAt: row.expiresAt, + error: '连接已失效,请取消或重新授权后刷新', + }; + } + }), + ); + return { items, nextCursor: rows.length > 50 ? page[49].id : null }; + } + async confirmBatch(r: UserRequest, raw: unknown, res: Response) { + const input = z + .object({ + operationIds: z + .array(z.string().uuid()) + .min(1) + .max(100) + .refine((ids) => new Set(ids).size === ids.length, '草稿编号不能重复'), + approve: z.boolean(), + }) + .strict() + .parse(raw); + return this.db.atomic(async () => { + await this.db.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`); + const rows = await this.db.agentOperation.findMany({ + where: { userId: r.userId, id: { in: input.operationIds } }, + }); + if (rows.length !== input.operationIds.length) throw new NotFoundException('操作不存在'); + // A lost success response may be retried without executing anything twice. + if (input.approve && rows.every((row) => row.status === 'completed')) + return { + operations: input.operationIds.map((id) => this.view(rows.find((row) => row.id === id)!)), + }; + if (!input.approve) { + if (rows.some((row) => row.status !== 'pending' || row.expiresAt <= new Date())) + throw new ConflictException('部分草稿已完成或失效,请刷新后重新选择'); + await this.db.agentOperation.updateMany({ + where: { userId: r.userId, id: { in: input.operationIds } }, + data: { status: 'cancelled', completedAt: new Date() }, + }); + return { operations: rows.map((row) => this.view({ ...row, status: 'cancelled' })) }; + } + const snapshot = await this.state(r.userId); + for (const row of rows) { + if (row.status !== 'pending' || row.expiresAt <= new Date()) + throw new ConflictException('部分草稿已完成或失效,请刷新后重新选择'); + if (row.snapshot !== snapshot) + throw new ConflictException('账目已变化,请取消并重新创建操作'); + const grant = await this.oauth.grant(row.grantId, r.userId); + await this.permission(grant, this.get(row.tool), row.parameters); + } + const operations = []; + for (const id of input.operationIds) { + // All selected drafts were reviewed against the same baseline above. + // Only this atomic, manually confirmed batch may advance their snapshots. + await this.db.agentOperation.update({ + where: { id }, + data: { snapshot: await this.state(r.userId) }, + }); + operations.push(await this.confirm(r, id, { approve: true }, res)); + } + return { operations }; + }, 300000); + } async uploadRequest(grantId: string, kind: 'icon') { const grant = await this.oauth.grant(grantId); const selected = grant.scopes as string[]; diff --git a/apps/api/test/mcp.test.ts b/apps/api/test/mcp.test.ts index 5b83c96..8d7c4f1 100644 --- a/apps/api/test/mcp.test.ts +++ b/apps/api/test/mcp.test.ts @@ -271,6 +271,126 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol 409, ); + const reviewedState = (await call(d, 'state_get')).state; + const reviewedCreates = await Promise.all( + [0, 1, 2].map((i) => + call(d, 'position_create', { + ...position, + name: 'atomic batch ' + i, + expectedState: reviewedState, + idempotencyKey: randomUUID(), + }), + ), + ); + const reviewedBalances = await Promise.all( + ['11.86420975', '12.86420975'].map((amount) => + call(d, 'balance_record', { + id: applied.result.id, + data: { amount, date: day }, + expectedState: reviewedState, + idempotencyKey: randomUUID(), + }), + ), + ); + const batchBody = { + approve: true, + operationIds: [...reviewedCreates, ...reviewedBalances].map((o) => o.operationId), + }; + const confirmedBatch = await web( + d.cookie, + '/agent/operations/confirm-batch', + 'POST', + batchBody, + ); + assert.equal(confirmedBatch.status, 201, JSON.stringify(confirmedBatch.data)); + assert.equal(confirmedBatch.data.operations.length, 5); + assert.ok(confirmedBatch.data.operations.every((o: any) => o.status === 'completed')); + assert.equal((await call(d, 'position_get', { id: applied.result.id })).amount, '12.86420975'); + assert.equal( + (await web(d.cookie, '/agent/operations/confirm-batch', 'POST', batchBody)).status, + 201, + ); + assert.equal((await call(d, 'positions_list', { q: 'atomic batch' })).total, 3); + assert.equal( + (await web(b.cookie, '/agent/operations/confirm-batch', 'POST', batchBody)).status, + 404, + ); + assert.equal( + ( + await web(d.cookie, '/agent/operations/confirm-batch', 'POST', { + approve: true, + operationIds: [batchBody.operationIds[0], batchBody.operationIds[0]], + }) + ).status, + 400, + ); + const rollbackState = (await call(d, 'state_get')).state; + const rollbackCreate = await call(d, 'position_create', { + ...position, + name: 'must roll back', + expectedState: rollbackState, + idempotencyKey: randomUUID(), + }); + const invalidMovement = await call(d, 'movement_create', { + sourceId: applied.result.id, + targetId: randomUUID(), + amount: '1', + received: '1', + date: day, + expectedState: rollbackState, + idempotencyKey: randomUUID(), + }); + const rejectedBatch = await web(d.cookie, '/agent/operations/confirm-batch', 'POST', { + approve: true, + operationIds: [rollbackCreate.operationId, invalidMovement.operationId], + }); + assert.equal(rejectedBatch.status, 400); + assert.equal((await call(d, 'positions_list', { q: 'must roll back' })).total, 0); + assert.equal( + (await call(d, 'operation_get', { operationId: rollbackCreate.operationId })).status, + 'pending', + ); + assert.equal((await call(d, 'state_get')).state, rollbackState); + assert.equal( + ( + await web(d.cookie, '/agent/operations/confirm-batch', 'POST', { + approve: true, + operationIds: [webStale.operationId], + }) + ).status, + 409, + ); + // All live drafts are paginated; the management panel's recent-100 cap is not used. + const pagesState = (await call(d, 'state_get')).state; + const pageDrafts = []; + for (let i = 0; i < 53; i++) + pageDrafts.push( + await call(d, 'position_create', { + ...position, + name: 'page draft ' + i, + expectedState: pagesState, + idempotencyKey: randomUUID(), + }), + ); + const pageIds: string[] = []; + let cursor: string | null = null; + do { + const page = await web(d.cookie, '/agent/drafts' + (cursor ? '?cursor=' + cursor : '')); + assert.equal(page.status, 200); + assert.ok(page.data.items.length <= 50); + pageIds.push(...page.data.items.map((o: any) => o.operationId)); + cursor = page.data.nextCursor; + } while (cursor); + assert.equal(new Set(pageIds).size, pageIds.length); + assert.ok(pageDrafts.every((o) => pageIds.includes(o.operationId))); + const cancelledBatch = await web(d.cookie, '/agent/operations/confirm-batch', 'POST', { + approve: false, + operationIds: [rollbackCreate.operationId, invalidMovement.operationId], + }); + assert.equal(cancelledBatch.status, 201); + assert.ok(cancelledBatch.data.operations.every((o: any) => o.status === 'cancelled')); + assert.equal((await call(d, 'positions_list', { q: 'must roll back' })).total, 0); + const expired = await write(d, 'position_create', { ...position, name: 'expired' }); await db.agentOperation.update({ where: { id: expired.operationId }, diff --git a/apps/web/src/AgentConnections.tsx b/apps/web/src/AgentConnections.tsx index fd3b284..13180e6 100644 --- a/apps/web/src/AgentConnections.tsx +++ b/apps/web/src/AgentConnections.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { api } from './api'; +import { AgentDrafts } from './AgentDrafts'; import { toolLabel, statusLabel, permissionLabel } from './agent-display'; import { showToast, useToast } from './Toast'; @@ -116,7 +117,8 @@ function codexSetupPrompt(url: string, level: string, hiddenRead = false, hidden } const connectionExpiry = (g: Connection) => (g.clientId ? g.refreshExpiresAt : g.expiresAt); -export function AgentConnections() { +export function AgentConnections({ changed }: { changed?: () => void }) { + const [review, setReview] = useState(null); const [data, setData] = useState(null), [view, setView] = useState('connect'), [busy, setBusy] = useState(false), @@ -479,6 +481,9 @@ export function AgentConnections() { {data && view === 'operations' && (

待确认草稿

+ {!pending.length &&

没有等待确认的修改。

} {pending.map((o) => (
@@ -486,9 +491,9 @@ export function AgentConnections() { {toolLabel(o.tool)}

有效至 {new Date(o.expiresAt).toLocaleTimeString()}

- + ))}
@@ -507,7 +512,9 @@ export function AgentConnections() { · {new Date(o.createdAt).toLocaleString()}

- 查看详情 + ))}
@@ -584,6 +591,16 @@ export function AgentConnections() {
)} + {review !== null && ( + setReview(null)} + changed={() => { + void load(); + changed?.(); + }} + /> + )} ); } diff --git a/apps/web/src/AgentDrafts.tsx b/apps/web/src/AgentDrafts.tsx new file mode 100644 index 0000000..3a5055e --- /dev/null +++ b/apps/web/src/AgentDrafts.tsx @@ -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(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( +
{ + if (e.target === e.currentTarget && !busy) close(); + }} + > +
{ + if (e.key === 'Escape' && !busy) { + e.preventDefault(); + close(); + } + if (e.key === 'Tab') { + const nodes = Array.from( + ref.current?.querySelectorAll( + '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(); + } + } + }} + > +
+

审阅草稿

+ +
+ {children} +
+
, + document.body, + ); +} +type Draft = Preview & { error?: string }; +export function AgentDrafts({ + initialId, + close, + changed, +}: { + initialId?: string; + close: () => void; + changed: () => void; +}) { + const [items, setItems] = useState([]); + const [selected, setSelected] = useState([]); + 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 ( + + {loading ? ( +

正在读取全部草稿…

+ ) : detail ? ( + + ) : ( + <> +

核对每项修改后选择。一次最多同意 100 项,任一失败整批回滚。

+
+ + + + + 已选择 {eligible.length} / {items.length} 项 + +
+ {!items.length &&

没有等待确认的修改。

} +
+ {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 ( +
+ + {o.error ? ( +
+

{o.error}

+ +
+ ) : ( + <> + {o.destructive && ( +

删除记录并重算余额,请仔细核对。

+ )} + {o.impact.current && ( +
+ 当前记录 + +
+ )} + + !['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' && ( +
+ 查看全部字段 + +
+ )} + + 有效至 {new Date(o.expiresAt).toLocaleString()} + + + )} +
+ ); + })} +
+
+ + + +
+ + )} + {error && ( +

+ {error} + +

+ )} +
+ ); +} diff --git a/apps/web/src/AgentReview.tsx b/apps/web/src/AgentReview.tsx index 4de7112..4b24420 100644 --- a/apps/web/src/AgentReview.tsx +++ b/apps/web/src/AgentReview.tsx @@ -16,7 +16,7 @@ type Consent = { redirectUri: string; resource: string; }; -type Preview = { +export type Preview = { operationId: string; tool: string; status: string; @@ -222,7 +222,7 @@ export function AgentAuthorization({ id }: { id: string | null }) { ); } -function Fields({ +export function Fields({ data, references, currency, @@ -283,7 +283,7 @@ function Fields({ ); } -export function AgentOperation({ id }: { id: string | null }) { +export function AgentOperation({ id, changed }: { id: string | null; changed?: () => void }) { const [preview, setPreview] = useState(null), [error, setError] = useState(''), [busy, setBusy] = useState(false), @@ -318,6 +318,7 @@ export function AgentOperation({ id }: { id: string | null }) { try { await api('/agent/operations/' + preview.operationId, 'POST', { approve }); setPreview(await api('/agent/operations/' + preview.operationId)); + changed?.(); showToast(approve ? '修改已完成,Agent 可以查询结果' : '草稿已取消', 'success'); } catch (e) { showToast(e instanceof Error ? e.message : '操作失败', 'error'); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 849c56b..89f992f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -55,7 +55,9 @@ import { orderedGroups, mergeGroupOrder } from './group-order'; import { Calendar } from './Calendar'; import { SchedulePanel } from './SchedulePanel'; import { AgentConnections } from './AgentConnections'; -import { AgentAuthorization, AgentOperation } from './AgentReview'; +import { AgentAuthorization } from './AgentReview'; +import { AgentDrafts } from './AgentDrafts'; +import { savedLogin, rememberLogin } from './login-preferences'; import { PositionDeletion, deletionLabel } from './PositionDeletion'; import { IconPicker } from './IconPicker'; import { MetalPanel } from './MetalPanel'; @@ -93,6 +95,7 @@ const categories: Record = { ['other', '其他'], ], asset: [ + ['cash', '现金'], ['property', '房产'], ['vehicle', '车辆'], ['gold', '贵金属'], @@ -175,10 +178,7 @@ export default function App() { [boot, setBoot] = useState(true), [register, setRegister] = useState(false), [page, setPage] = useState( - new URLSearchParams(location.search).has('agent_authorization') || - new URLSearchParams(location.search).has('agent_operation') - ? 'agent-review' - : 'overview', + new URLSearchParams(location.search).has('agent_authorization') ? 'agent-review' : 'overview', ), [positions, setPositions] = useState([]), [overview, setOverview] = useState(null), @@ -212,6 +212,11 @@ export default function App() { const [hideBooked, setHideBooked] = useState(false); const [unpaidOnly, setUnpaidOnly] = useState(false); const [assetQuickMode, setAssetQuickMode] = useState(false); + const [assetValueMode, setAssetValueMode] = useState(false); + const [remember, setRemember] = useState(() => savedLogin().remember); + const [draftId, setDraftId] = useState(() => + new URLSearchParams(location.search).get('agent_operation'), + ); const [businessDay, setBusinessDay] = useState(today()); const [repaymentMonth, setRepaymentMonth] = useState(today().slice(0, 7)); const quickGeneration = useRef(0); @@ -829,9 +834,9 @@ export default function App() { password: f.get('password'), }); if (session !== sessionGeneration.current) return; - const agentReturn = - new URLSearchParams(location.search).has('agent_authorization') || - new URLSearchParams(location.search).has('agent_operation'); + if (!register) + void rememberLogin(String(f.get('username')), String(f.get('password')), remember); + const agentReturn = new URLSearchParams(location.search).has('agent_authorization'); setPage(agentReturn ? 'agent-review' : 'overview'); if (agentReturn) setSettingsSection('agent'); setSelected(null); @@ -911,10 +916,11 @@ export default function App() { {register ? tr('建立属于你的私人资产空间') : tr('登录以查看你的资产与负债')}

-
+ + {!register && ( + <> + +

{tr('密码由浏览器密码管理器保存,下次登录可自动填充。')}

+ + )} - {agentParams.has('agent_authorization') || location.pathname === '/agent/authorize' ? ( - - ) : ( - - )} + ); } @@ -1733,11 +1743,30 @@ export default function App() { className={assetQuickMode ? 'primary' : 'secondary'} disabled={busy} aria-pressed={assetQuickMode} - onClick={() => setAssetQuickMode(!assetQuickMode)} + onClick={() => { + setAssetQuickMode(!assetQuickMode); + setAssetValueMode(false); + }} > {tr(assetQuickMode ? '结束统计设置' : '快速设置计入总资产')} + + {assetValueMode && ( +

+ {tr('点击资产卡片填写最新估值;贵金属按报价自动估值。')} +

+ )} {assetQuickMode && (

{tr('点击资产卡片,切换是否计入总资产。')}

)} @@ -1816,6 +1845,9 @@ export default function App() { } disabled={ busy || + (page === 'asset' && + assetValueMode && + (p.archived || p.category === 'gold')) || (!!quickTransfer && p.archived) || (quickMode && quickMarkMode && @@ -1824,19 +1856,26 @@ export default function App() { } key={p.id} onClick={() => - page === 'asset' && assetQuickMode - ? void act(() => toggleAssetInclusion(p), tr('统计设置已保存')) - : quickMode && quickBookedMode && page === 'account' - ? void act(() => markQuickBooked(p), tr('记账标记已保存')) - : quickMode && quickMarkMode && page === 'account' - ? p.kind === 'account' && p.side === 'liability' && !p.archived - ? void act(() => markQuickRepayment(p), tr('还款标记已保存')) - : undefined - : quickTransfer !== null && page === 'account' && quickMode - ? chooseQuickAccount(p) - : page === 'account' && quickMode && !p.archived - ? setModal({ kind: 'revision', p, quick: true }) - : setSelected(p.id) + page === 'asset' && assetValueMode + ? setModal({ kind: 'revision', p }) + : page === 'asset' && assetQuickMode + ? void act(() => toggleAssetInclusion(p), tr('统计设置已保存')) + : quickMode && quickBookedMode && page === 'account' + ? void act(() => markQuickBooked(p), tr('记账标记已保存')) + : quickMode && quickMarkMode && page === 'account' + ? p.kind === 'account' && + p.side === 'liability' && + !p.archived + ? void act( + () => markQuickRepayment(p), + tr('还款标记已保存'), + ) + : undefined + : quickTransfer !== null && page === 'account' && quickMode + ? chooseQuickAccount(p) + : page === 'account' && quickMode && !p.archived + ? setModal({ kind: 'revision', p, quick: true }) + : setSelected(p.id) } >
@@ -2119,7 +2158,7 @@ export default function App() { )} - {settingsSection === 'agent' && } + {settingsSection === 'agent' && void load()} />} {settingsSection === 'security' && (

{tr('账号与密码')}

@@ -3357,6 +3396,19 @@ export default function App() { )} )} + {draftId && ( + { + setDraftId(null); + const url = new URL(location.href); + url.searchParams.delete('agent_operation'); + if (url.pathname === '/agent/operation') url.pathname = '/'; + history.replaceState(null, '', url.pathname + url.search + url.hash); + }} + changed={() => void load()} + /> + )}
, ); } diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index 5a21b52..e7b3c9b 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -617,5 +617,10 @@ "已隐藏全部折线,点击上方名称可重新显示。": "All series are hidden. Click a name above to show it again.", "点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。": "Click a name to toggle a series. Hover or tap the chart to inspect a date; use arrow keys to move between dates.", "当天趋势数据": "Data for selected date", - "当天估值或汇率不完整,无法显示完整总额。": "Valuations or exchange rates are incomplete for this date; full totals are unavailable." + "当天估值或汇率不完整,无法显示完整总额。": "Valuations or exchange rates are incomplete for this date; full totals are unavailable.", + "记住账号密码": "Remember account and password", + "密码由浏览器密码管理器保存,下次登录可自动填充。": "Your browser password manager saves the password for autofill next time.", + "快速设置估值": "Quick valuation", + "结束快速估值": "Finish quick valuation", + "点击资产卡片填写最新估值;贵金属按报价自动估值。": "Select an asset to enter its latest value; precious metals use automatic quotes." } diff --git a/apps/web/src/locales/zh-Hant.json b/apps/web/src/locales/zh-Hant.json index f02c885..35ec4e9 100644 --- a/apps/web/src/locales/zh-Hant.json +++ b/apps/web/src/locales/zh-Hant.json @@ -617,5 +617,10 @@ "已隐藏全部折线,点击上方名称可重新显示。": "已隱藏全部折線,點擊上方名稱可重新顯示。", "点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。": "點擊名稱切換折線;懸停或點擊圖表查看當天數據,方向鍵切換日期。", "当天趋势数据": "當天趨勢數據", - "当天估值或汇率不完整,无法显示完整总额。": "當天估值或匯率不完整,無法顯示完整總額。" + "当天估值或汇率不完整,无法显示完整总额。": "當天估值或匯率不完整,無法顯示完整總額。", + "记住账号密码": "記住帳號密碼", + "密码由浏览器密码管理器保存,下次登录可自动填充。": "密碼由瀏覽器密碼管理器保存,下次登入可自動填入。", + "快速设置估值": "快速設定估值", + "结束快速估值": "結束快速估值", + "点击资产卡片填写最新估值;贵金属按报价自动估值。": "點擊資產卡片填寫最新估值;貴金屬按報價自動估值。" } diff --git a/apps/web/src/login-preferences.ts b/apps/web/src/login-preferences.ts new file mode 100644 index 0000000..1476258 --- /dev/null +++ b/apps/web/src/login-preferences.ts @@ -0,0 +1,33 @@ +export function savedLogin(): { username: string; remember: boolean } { + try { + const value = JSON.parse(localStorage.getItem('worthpath.login') || '{}'); + return { + username: typeof value.username === 'string' ? value.username : '', + remember: value.remember === true, + }; + } catch { + return { username: '', remember: false }; + } +} +export async function rememberLogin(username: string, password: string, remember: boolean) { + try { + if (remember) + localStorage.setItem('worthpath.login', JSON.stringify({ username, remember: true })); + else localStorage.removeItem('worthpath.login'); + } catch { + /* Login remains usable when local storage is unavailable. */ + } + if (!remember) return; + const Password = ( + window as unknown as { + PasswordCredential?: new (data: { id: string; password: string; name: string }) => Credential; + } + ).PasswordCredential; + if (Password && navigator.credentials && window.isSecureContext) { + try { + await navigator.credentials.store(new Password({ id: username, password, name: username })); + } catch { + /* The browser can still offer its normal password save prompt. */ + } + } +} diff --git a/apps/web/src/style.css b/apps/web/src/style.css index c350fe5..57561fa 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -2492,3 +2492,78 @@ textarea, transform: none; max-width: calc(100% - 24px); } + +.draft-dialog { + width: min(960px, calc(100vw - 32px)); + max-height: 90dvh; + overflow-y: auto; +} +.draft-toolbar { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; + padding: 12px 0; +} +.draft-list { + display: grid; + gap: 12px; +} +.draft-card { + padding: 16px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; +} +.draft-card > .check-line { + flex-wrap: wrap; + align-items: center; +} +.draft-card > .check-line span { + font-size: 12px; + color: #64748b; +} +.draft-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + position: sticky; + bottom: -24px; + background: #fff; + padding: 16px 0; + border-top: 1px solid #e2e8f0; + margin-top: 16px; +} +@media (max-width: 600px) { + .draft-dialog { + width: calc(100vw - 20px); + padding: 14px; + } + .draft-footer { + bottom: -14px; + } + .draft-card { + padding: 12px; + } +} + +.draft-card .agent-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 24px; + margin: 8px 0; +} +.draft-card .agent-field { + grid-template-columns: 90px minmax(0, 1fr); + gap: 8px; + padding: 8px 0; + font-size: 14px; +} +.draft-card .agent-field-section { + grid-column: 1 / -1; +} +@media (max-width: 600px) { + .draft-card .agent-fields { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/apps/web/test/login-preferences.test.ts b/apps/web/test/login-preferences.test.ts new file mode 100644 index 0000000..7721aa6 --- /dev/null +++ b/apps/web/test/login-preferences.test.ts @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { savedLogin, rememberLogin } from '../src/login-preferences'; + +test('remembered login keeps passwords out of application storage and delegates to the browser', async (t) => { + const values = new Map(); + const stored: any[] = []; + const old = new Map(); + for (const key of ['localStorage', 'window', 'navigator']) + old.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + t.after(() => { + for (const [key, descriptor] of old) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }); + const storage = { + getItem: (key: string) => values.get(key) || null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; + class BrowserPassword { + constructor(data: unknown) { + Object.assign(this, data); + } + } + Object.defineProperty(globalThis, 'localStorage', { value: storage, configurable: true }); + Object.defineProperty(globalThis, 'window', { + value: { isSecureContext: true, PasswordCredential: BrowserPassword }, + configurable: true, + }); + Object.defineProperty(globalThis, 'navigator', { + value: { + credentials: { + store: async (credential: unknown) => { + stored.push(credential); + }, + }, + }, + configurable: true, + }); + await rememberLogin('fixture account', 'synthetic-password-for-test', true); + assert.deepEqual(savedLogin(), { username: 'fixture account', remember: true }); + assert.equal( + Array.from(values.values()).some((v) => v.includes('synthetic-password-for-test')), + false, + ); + assert.equal(stored.length, 1); + assert.equal(stored[0].id, 'fixture account'); + await rememberLogin('fixture account', 'synthetic-password-for-test', false); + assert.equal(stored.length, 1); + assert.deepEqual(savedLogin(), { username: '', remember: false }); + // Browser support and storage failure cannot prevent a normal login. + (window as any).isSecureContext = false; + await rememberLogin('http fixture', 'synthetic-password-for-test', true); + assert.equal(stored.length, 1); + values.set('worthpath.login', 'bad json'); + assert.deepEqual(savedLogin(), { username: '', remember: false }); +}); diff --git a/docs/update-cash-drafts-login-2026-10-05.md b/docs/update-cash-drafts-login-2026-10-05.md new file mode 100644 index 0000000..25b8b95 --- /dev/null +++ b/docs/update-cash-drafts-login-2026-10-05.md @@ -0,0 +1,42 @@ +# 现金资产、草稿弹窗与登录记忆(2026-10-05) + +## 独立现金和快速估值 + +独立资产分类新增“现金”,使用原币十进制金额,与账户现金分开管理。独立资产页提供“快速设置估值”,开启后点击卡片即可填写最新估值和业务时间;保存会新增估值历史,并刷新卡片、总览和日历。快速估值与快速统计设置互斥。 + +已归档资产不能快速估值。贵金属继续按克数和报价自动估值,不提供手动覆盖入口。没有新增数据库字段或迁移。 + +## 草稿弹窗和批量审阅 + +“草稿与记录”中的审阅、全部审阅和历史详情均在原页面弹窗打开。MCP 返回的草稿链接也在资产空间中打开弹窗;关闭会移除链接参数,返回资产空间。OAuth 连接授权仍使用独立页面。 + +弹窗分页读取所有尚未过期的待确认草稿,每页 50 项,不受管理面板最近 100 条操作记录限制。显示名称、金额、币种、日期、属性和修改内容,非默认备注及其他字段保留;新增草稿还可展开完整字段。修改和删除展示当前记录及删除提示。 + +支持逐项选择、全选、取消全选、批量同意和取消。一次最多 100 项,超过时按钮明确为“选择前 100 项”。未经选择的草稿不会执行,加载失败、连接失效或过期的草稿不能批量同意。失效连接的草稿仍可单独取消。焦点限制在弹窗内,Esc 或关闭按钮可退出;提交期间阻止关闭。 + +新增网页接口: + +- GET /api/agent/drafts:仅当前用户的有效草稿,游标分页。 +- POST /api/agent/operations/confirm-batch:接收明确的 operationIds 和 approve,最多 100 个不同 UUID。 + +同意时在同一用户锁和数据库事务内核对所有草稿的原快照、权限、期限和归属。全部满足后依选择顺序执行,任一校验或业务操作失败整批回滚。所选草稿之间自身产生的状态变化在事务内推进,不绕过其他网页或连接造成的冲突。重复提交全部已完成草稿只返回结果,不重复入账。取消只变更草稿状态,不改变账目。 + +此接口仅用于用户在网页主动确认,未提供 MCP 自动确认工具。 + +## 记住账号密码 + +登录页新增“记住账号密码”。勾选后在登录成功时保存用户名和偏好;密码交给支持的浏览器密码管理器,网站不将密码写入 localStorage、数据库或配置文件。浏览器确认保存后可在下次登录自动填充。 + +支持 Credential Management API 的安全上下文会请求浏览器保存;其他环境依赖浏览器自身的密码保存与自动填充功能。浏览器或内嵌客户端可能禁用保存,因此显示选项并不代表密码已经保存。取消记住会删除网站的用户名偏好;浏览器已保存的密码需在浏览器密码管理器中管理。 + +注册使用 new-password,登录使用 current-password,保留常规自动填充语义。 + +## 验证 + +- 53 项单元测试通过,包括凭据交给浏览器、应用存储不含密码、不勾选不保存和不支持环境的回退。 +- 6 项真实 MCP/OAuth 集成测试通过,覆盖同快照新增与余额批量确认、完整分页、幂等重试、跨用户拒绝、重复编号拒绝、失效快照拒绝、后续转账失败回滚整批、批量取消。 +- 浏览器隔离账号验证三份新增草稿全选同意、账户列表即时刷新、两份草稿批量取消、MCP 链接弹窗和关闭返回。 +- 浏览器验证现金分类和快速估值,将测试资产从 85.86420975 更新为 99.86420975,卡片及估值历史即时更新。 +- 前后端类型检查和生产构建通过。 +- 数据库表、字段缺失注释均为 0。 +- 所有测试账号、授权及财务测试数据已清理,未修改实际用户账目。文档目录未保存图片。 diff --git a/update.md b/update.md index 97125ce..d52639f 100644 --- a/update.md +++ b/update.md @@ -93,4 +93,8 @@ - ~~快速记账时,可以出现点击当天已完成记账的按钮,点击可以快速设置某个账号当天已完成记账,同时在快速记账中可以显示或隐藏当天已完成记账~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md - ~~还款可以优惠功能(负数表示手续费)~~ — 已完成并验证:2026-10-04 22:18(UTC+8),见 docs/update-repayment-trend-2026-10-04.md - ~~净资产轨迹,可以设置隐藏某条折线,鼠标放线线可以查看当天数据~~ — 已完成并验证:2026-10-04 22:18(UTC+8),见 docs/update-repayment-trend-2026-10-04.md +- ~~独立资产增加现金,同时有快速设置估值的按钮~~ — 已完成并验证:2026-10-05 12:27(UTC+8),见 docs/update-cash-drafts-login-2026-10-05.md +- ~~审阅草稿使用弹窗的形式,不用出现新的页面~~ — 已完成并验证:2026-10-05 12:27(UTC+8),见 docs/update-cash-drafts-login-2026-10-05.md +- ~~优化审阅功能,快速阅读所有草稿并全选同意~~ — 已完成并验证:2026-10-05 12:27(UTC+8),见 docs/update-cash-drafts-login-2026-10-05.md +- ~~添加记住账号密码的功能~~ — 已完成并验证:2026-10-05 12:27(UTC+8),见 docs/update-cash-drafts-login-2026-10-05.md - ~~修复 MCP 同批新增多个账号时后续草稿提示账目已变化~~ — 已完成并验证:2026-10-04 22:53(UTC+8),见 docs/fix-mcp-create-drafts-2026-10-04.md