feat:cash-assets-batch-draft-review-and-login-memory
This commit is contained in:
1 parent
ce8609116b
commit
cfbba73791
16 files changed
+872
-46
No files matched your search
@@ -101,7 +101,7 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json
|
|||||||
|
|
||||||
## 连接 Agent / MCP
|
## 连接 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)。
|
密码修改、清空数据、备份恢复、共享图标发布、汇率及贵金属报价修改在网站完成,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 返回的参考价只用于持仓估值,不包含饰品工费、买卖价差或回收折价。外币价格使用最新可用公共日汇率,休市时两类报价时间可能不同;报价日期会显示在界面并随备份保存。自动估价为可选项,开启后新增估值历史,不改写历史记录。
|
上述接口由后端访问,浏览器业务请求使用本站 `/api`,第三方不会收到本应用登录 Cookie。Gold API 返回的参考价只用于持仓估值,不包含饰品工费、买卖价差或回收折价。外币价格使用最新可用公共日汇率,休市时两类报价时间可能不同;报价日期会显示在界面并随备份保存。自动估价为可选项,开启后新增估值历史,不改写历史记录。
|
||||||
|
|
||||||
MCP 同一连接、同一账目快照下的多个新增账号草稿可依次审阅确认;其他修改仍保留状态冲突检查。修复前已失效的草稿需只为尚未新增的账号重新生成,见 [多账号草稿确认修复](docs/fix-mcp-create-drafts-2026-10-04.md)。
|
MCP 同一连接、同一账目快照下的多个新增账号草稿可依次审阅确认;其他修改仍保留状态冲突检查。修复前已失效的草稿需只为尚未新增的账号重新生成,见 [多账号草稿确认修复](docs/fix-mcp-create-drafts-2026-10-04.md)。
|
||||||
|
|
||||||
|
独立资产支持现金分类和快速估值。登录页可记住账号并交由浏览器密码管理器保存密码。草稿弹窗支持全量分页、批量同意与取消,提交失败整批回滚。详见 [现金、草稿审阅与登录更新](docs/update-cash-drafts-login-2026-10-05.md)。
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"dev": "node scripts/dev.cjs",
|
"dev": "node scripts/dev.cjs",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"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:generate": "prisma generate",
|
||||||
"db:migrate": "node scripts/database.cjs deploy",
|
"db:migrate": "node scripts/database.cjs deploy",
|
||||||
"db:status": "node scripts/database.cjs status",
|
"db:status": "node scripts/database.cjs status",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Req,
|
Req,
|
||||||
Param,
|
Param,
|
||||||
|
Query,
|
||||||
Body,
|
Body,
|
||||||
Res,
|
Res,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
@@ -117,6 +118,18 @@ export class AgentManagementController {
|
|||||||
.parse(raw);
|
.parse(raw);
|
||||||
return this.oauth.consent(r.userId, z.string().uuid().parse(id), approve, scopes, days);
|
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) {
|
@Get('operations/:id') preview(@Req() r: UserRequest, @Param('id') id: string) {
|
||||||
return this.operations.preview(r.userId, z.string().uuid().parse(id));
|
return this.operations.preview(r.userId, z.string().uuid().parse(id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,6 +361,86 @@ export class AgentOperations {
|
|||||||
);
|
);
|
||||||
}, 300000);
|
}, 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') {
|
async uploadRequest(grantId: string, kind: 'icon') {
|
||||||
const grant = await this.oauth.grant(grantId);
|
const grant = await this.oauth.grant(grantId);
|
||||||
const selected = grant.scopes as string[];
|
const selected = grant.scopes as string[];
|
||||||
|
|||||||
@@ -271,6 +271,126 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
|
|||||||
409,
|
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' });
|
const expired = await write(d, 'position_create', { ...position, name: 'expired' });
|
||||||
await db.agentOperation.update({
|
await db.agentOperation.update({
|
||||||
where: { id: expired.operationId },
|
where: { id: expired.operationId },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
import { AgentDrafts } from './AgentDrafts';
|
||||||
import { toolLabel, statusLabel, permissionLabel } from './agent-display';
|
import { toolLabel, statusLabel, permissionLabel } from './agent-display';
|
||||||
import { showToast, useToast } from './Toast';
|
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);
|
const connectionExpiry = (g: Connection) => (g.clientId ? g.refreshExpiresAt : g.expiresAt);
|
||||||
export function AgentConnections() {
|
export function AgentConnections({ changed }: { changed?: () => void }) {
|
||||||
|
const [review, setReview] = useState<string | null>(null);
|
||||||
const [data, setData] = useState<Management | null>(null),
|
const [data, setData] = useState<Management | null>(null),
|
||||||
[view, setView] = useState('connect'),
|
[view, setView] = useState('connect'),
|
||||||
[busy, setBusy] = useState(false),
|
[busy, setBusy] = useState(false),
|
||||||
@@ -479,6 +481,9 @@ export function AgentConnections() {
|
|||||||
{data && view === 'operations' && (
|
{data && view === 'operations' && (
|
||||||
<section className="agent-card">
|
<section className="agent-card">
|
||||||
<h3>待确认草稿</h3>
|
<h3>待确认草稿</h3>
|
||||||
|
<button className="primary" disabled={busy} onClick={() => setReview('all')}>
|
||||||
|
审阅全部草稿
|
||||||
|
</button>
|
||||||
{!pending.length && <p className="agent-empty">没有等待确认的修改。</p>}
|
{!pending.length && <p className="agent-empty">没有等待确认的修改。</p>}
|
||||||
{pending.map((o) => (
|
{pending.map((o) => (
|
||||||
<div className="agent-row" key={o.id}>
|
<div className="agent-row" key={o.id}>
|
||||||
@@ -486,9 +491,9 @@ export function AgentConnections() {
|
|||||||
<strong>{toolLabel(o.tool)}</strong>
|
<strong>{toolLabel(o.tool)}</strong>
|
||||||
<p className="muted">有效至 {new Date(o.expiresAt).toLocaleTimeString()}</p>
|
<p className="muted">有效至 {new Date(o.expiresAt).toLocaleTimeString()}</p>
|
||||||
</div>
|
</div>
|
||||||
<a className="agent-review-link" href={'/agent/operation?agent_operation=' + o.id}>
|
<button className="agent-review-link" onClick={() => setReview(o.id)}>
|
||||||
审阅草稿
|
审阅草稿
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<details className="agent-details">
|
<details className="agent-details">
|
||||||
@@ -507,7 +512,9 @@ export function AgentConnections() {
|
|||||||
· {new Date(o.createdAt).toLocaleString()}
|
· {new Date(o.createdAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href={'/agent/operation?agent_operation=' + o.id}>查看详情</a>
|
<button className="text" onClick={() => setReview(o.id)}>
|
||||||
|
查看详情
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</details>
|
</details>
|
||||||
@@ -584,6 +591,16 @@ export function AgentConnections() {
|
|||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{review !== null && (
|
||||||
|
<AgentDrafts
|
||||||
|
initialId={review === 'all' ? undefined : review}
|
||||||
|
close={() => setReview(null)}
|
||||||
|
changed={() => {
|
||||||
|
void load();
|
||||||
|
changed?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ type Consent = {
|
|||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
resource: string;
|
resource: string;
|
||||||
};
|
};
|
||||||
type Preview = {
|
export type Preview = {
|
||||||
operationId: string;
|
operationId: string;
|
||||||
tool: string;
|
tool: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -222,7 +222,7 @@ export function AgentAuthorization({ id }: { id: string | null }) {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function Fields({
|
export function Fields({
|
||||||
data,
|
data,
|
||||||
references,
|
references,
|
||||||
currency,
|
currency,
|
||||||
@@ -283,7 +283,7 @@ function Fields({
|
|||||||
</dl>
|
</dl>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export function AgentOperation({ id }: { id: string | null }) {
|
export function AgentOperation({ id, changed }: { id: string | null; changed?: () => void }) {
|
||||||
const [preview, setPreview] = useState<Preview | null>(null),
|
const [preview, setPreview] = useState<Preview | null>(null),
|
||||||
[error, setError] = useState(''),
|
[error, setError] = useState(''),
|
||||||
[busy, setBusy] = useState(false),
|
[busy, setBusy] = useState(false),
|
||||||
@@ -318,6 +318,7 @@ export function AgentOperation({ id }: { id: string | null }) {
|
|||||||
try {
|
try {
|
||||||
await api('/agent/operations/' + preview.operationId, 'POST', { approve });
|
await api('/agent/operations/' + preview.operationId, 'POST', { approve });
|
||||||
setPreview(await api<Preview>('/agent/operations/' + preview.operationId));
|
setPreview(await api<Preview>('/agent/operations/' + preview.operationId));
|
||||||
|
changed?.();
|
||||||
showToast(approve ? '修改已完成,Agent 可以查询结果' : '草稿已取消', 'success');
|
showToast(approve ? '修改已完成,Agent 可以查询结果' : '草稿已取消', 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e instanceof Error ? e.message : '操作失败', 'error');
|
showToast(e instanceof Error ? e.message : '操作失败', 'error');
|
||||||
|
|||||||
+87
-35
@@ -55,7 +55,9 @@ import { orderedGroups, mergeGroupOrder } from './group-order';
|
|||||||
import { Calendar } from './Calendar';
|
import { Calendar } from './Calendar';
|
||||||
import { SchedulePanel } from './SchedulePanel';
|
import { SchedulePanel } from './SchedulePanel';
|
||||||
import { AgentConnections } from './AgentConnections';
|
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 { PositionDeletion, deletionLabel } from './PositionDeletion';
|
||||||
import { IconPicker } from './IconPicker';
|
import { IconPicker } from './IconPicker';
|
||||||
import { MetalPanel } from './MetalPanel';
|
import { MetalPanel } from './MetalPanel';
|
||||||
@@ -93,6 +95,7 @@ const categories: Record<string, [string, string][]> = {
|
|||||||
['other', '其他'],
|
['other', '其他'],
|
||||||
],
|
],
|
||||||
asset: [
|
asset: [
|
||||||
|
['cash', '现金'],
|
||||||
['property', '房产'],
|
['property', '房产'],
|
||||||
['vehicle', '车辆'],
|
['vehicle', '车辆'],
|
||||||
['gold', '贵金属'],
|
['gold', '贵金属'],
|
||||||
@@ -175,10 +178,7 @@ export default function App() {
|
|||||||
[boot, setBoot] = useState(true),
|
[boot, setBoot] = useState(true),
|
||||||
[register, setRegister] = useState(false),
|
[register, setRegister] = useState(false),
|
||||||
[page, setPage] = useState(
|
[page, setPage] = useState(
|
||||||
new URLSearchParams(location.search).has('agent_authorization') ||
|
new URLSearchParams(location.search).has('agent_authorization') ? 'agent-review' : 'overview',
|
||||||
new URLSearchParams(location.search).has('agent_operation')
|
|
||||||
? 'agent-review'
|
|
||||||
: 'overview',
|
|
||||||
),
|
),
|
||||||
[positions, setPositions] = useState<Position[]>([]),
|
[positions, setPositions] = useState<Position[]>([]),
|
||||||
[overview, setOverview] = useState<Overview | null>(null),
|
[overview, setOverview] = useState<Overview | null>(null),
|
||||||
@@ -212,6 +212,11 @@ export default function App() {
|
|||||||
const [hideBooked, setHideBooked] = useState(false);
|
const [hideBooked, setHideBooked] = useState(false);
|
||||||
const [unpaidOnly, setUnpaidOnly] = useState(false);
|
const [unpaidOnly, setUnpaidOnly] = useState(false);
|
||||||
const [assetQuickMode, setAssetQuickMode] = 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 [businessDay, setBusinessDay] = useState(today());
|
||||||
const [repaymentMonth, setRepaymentMonth] = useState(today().slice(0, 7));
|
const [repaymentMonth, setRepaymentMonth] = useState(today().slice(0, 7));
|
||||||
const quickGeneration = useRef(0);
|
const quickGeneration = useRef(0);
|
||||||
@@ -829,9 +834,9 @@ export default function App() {
|
|||||||
password: f.get('password'),
|
password: f.get('password'),
|
||||||
});
|
});
|
||||||
if (session !== sessionGeneration.current) return;
|
if (session !== sessionGeneration.current) return;
|
||||||
const agentReturn =
|
if (!register)
|
||||||
new URLSearchParams(location.search).has('agent_authorization') ||
|
void rememberLogin(String(f.get('username')), String(f.get('password')), remember);
|
||||||
new URLSearchParams(location.search).has('agent_operation');
|
const agentReturn = new URLSearchParams(location.search).has('agent_authorization');
|
||||||
setPage(agentReturn ? 'agent-review' : 'overview');
|
setPage(agentReturn ? 'agent-review' : 'overview');
|
||||||
if (agentReturn) setSettingsSection('agent');
|
if (agentReturn) setSettingsSection('agent');
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
@@ -911,10 +916,11 @@ export default function App() {
|
|||||||
{register ? tr('建立属于你的私人资产空间') : tr('登录以查看你的资产与负债')}
|
{register ? tr('建立属于你的私人资产空间') : tr('登录以查看你的资产与负债')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={auth}>
|
<form onSubmit={auth} autoComplete="on">
|
||||||
<Field label={tr('账号')}>
|
<Field label={tr('账号')}>
|
||||||
<input
|
<input
|
||||||
name="username"
|
name="username"
|
||||||
|
defaultValue={savedLogin().username}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
required
|
required
|
||||||
minLength={3}
|
minLength={3}
|
||||||
@@ -933,6 +939,19 @@ export default function App() {
|
|||||||
placeholder={tr('至少 10 个字符,最多 72 字节')}
|
placeholder={tr('至少 10 个字符,最多 72 字节')}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
{!register && (
|
||||||
|
<>
|
||||||
|
<label className="check-line">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={remember}
|
||||||
|
onChange={(e) => setRemember(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{tr('记住账号密码')}
|
||||||
|
</label>
|
||||||
|
<p className="muted">{tr('密码由浏览器密码管理器保存,下次登录可自动填充。')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button className="primary full" disabled={busy}>
|
<button className="primary full" disabled={busy}>
|
||||||
{busy ? tr('正在处理…') : register ? tr('注册并进入') : tr('登录')}
|
{busy ? tr('正在处理…') : register ? tr('注册并进入') : tr('登录')}
|
||||||
<ArrowUpRight size={18} />
|
<ArrowUpRight size={18} />
|
||||||
@@ -1018,12 +1037,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const agentParams = new URLSearchParams(location.search);
|
const agentParams = new URLSearchParams(location.search);
|
||||||
if (
|
if (agentParams.has('agent_authorization') || location.pathname === '/agent/authorize') {
|
||||||
agentParams.has('agent_authorization') ||
|
|
||||||
agentParams.has('agent_operation') ||
|
|
||||||
location.pathname === '/agent/authorize' ||
|
|
||||||
location.pathname === '/agent/operation'
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<main className="agent-review-page">
|
<main className="agent-review-page">
|
||||||
<header className="agent-review-header">
|
<header className="agent-review-header">
|
||||||
@@ -1033,11 +1047,7 @@ export default function App() {
|
|||||||
退出登录
|
退出登录
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
{agentParams.has('agent_authorization') || location.pathname === '/agent/authorize' ? (
|
<AgentAuthorization id={agentParams.get('agent_authorization')} />
|
||||||
<AgentAuthorization id={agentParams.get('agent_authorization')} />
|
|
||||||
) : (
|
|
||||||
<AgentOperation id={agentParams.get('agent_operation')} />
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1733,11 +1743,30 @@ export default function App() {
|
|||||||
className={assetQuickMode ? 'primary' : 'secondary'}
|
className={assetQuickMode ? 'primary' : 'secondary'}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
aria-pressed={assetQuickMode}
|
aria-pressed={assetQuickMode}
|
||||||
onClick={() => setAssetQuickMode(!assetQuickMode)}
|
onClick={() => {
|
||||||
|
setAssetQuickMode(!assetQuickMode);
|
||||||
|
setAssetValueMode(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{tr(assetQuickMode ? '结束统计设置' : '快速设置计入总资产')}
|
{tr(assetQuickMode ? '结束统计设置' : '快速设置计入总资产')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={assetValueMode ? 'primary' : 'secondary'}
|
||||||
|
disabled={busy}
|
||||||
|
aria-pressed={assetValueMode}
|
||||||
|
onClick={() => {
|
||||||
|
setAssetValueMode(!assetValueMode);
|
||||||
|
setAssetQuickMode(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tr(assetValueMode ? '结束快速估值' : '快速设置估值')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{assetValueMode && (
|
||||||
|
<p className="muted">
|
||||||
|
{tr('点击资产卡片填写最新估值;贵金属按报价自动估值。')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{assetQuickMode && (
|
{assetQuickMode && (
|
||||||
<p className="muted">{tr('点击资产卡片,切换是否计入总资产。')}</p>
|
<p className="muted">{tr('点击资产卡片,切换是否计入总资产。')}</p>
|
||||||
)}
|
)}
|
||||||
@@ -1816,6 +1845,9 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
busy ||
|
busy ||
|
||||||
|
(page === 'asset' &&
|
||||||
|
assetValueMode &&
|
||||||
|
(p.archived || p.category === 'gold')) ||
|
||||||
(!!quickTransfer && p.archived) ||
|
(!!quickTransfer && p.archived) ||
|
||||||
(quickMode &&
|
(quickMode &&
|
||||||
quickMarkMode &&
|
quickMarkMode &&
|
||||||
@@ -1824,19 +1856,26 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
page === 'asset' && assetQuickMode
|
page === 'asset' && assetValueMode
|
||||||
? void act(() => toggleAssetInclusion(p), tr('统计设置已保存'))
|
? setModal({ kind: 'revision', p })
|
||||||
: quickMode && quickBookedMode && page === 'account'
|
: page === 'asset' && assetQuickMode
|
||||||
? void act(() => markQuickBooked(p), tr('记账标记已保存'))
|
? void act(() => toggleAssetInclusion(p), tr('统计设置已保存'))
|
||||||
: quickMode && quickMarkMode && page === 'account'
|
: quickMode && quickBookedMode && page === 'account'
|
||||||
? p.kind === 'account' && p.side === 'liability' && !p.archived
|
? void act(() => markQuickBooked(p), tr('记账标记已保存'))
|
||||||
? void act(() => markQuickRepayment(p), tr('还款标记已保存'))
|
: quickMode && quickMarkMode && page === 'account'
|
||||||
: undefined
|
? p.kind === 'account' &&
|
||||||
: quickTransfer !== null && page === 'account' && quickMode
|
p.side === 'liability' &&
|
||||||
? chooseQuickAccount(p)
|
!p.archived
|
||||||
: page === 'account' && quickMode && !p.archived
|
? void act(
|
||||||
? setModal({ kind: 'revision', p, quick: true })
|
() => markQuickRepayment(p),
|
||||||
: setSelected(p.id)
|
tr('还款标记已保存'),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
: quickTransfer !== null && page === 'account' && quickMode
|
||||||
|
? chooseQuickAccount(p)
|
||||||
|
: page === 'account' && quickMode && !p.archived
|
||||||
|
? setModal({ kind: 'revision', p, quick: true })
|
||||||
|
: setSelected(p.id)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
@@ -2119,7 +2158,7 @@ export default function App() {
|
|||||||
<IconLibrary />
|
<IconLibrary />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{settingsSection === 'agent' && <AgentConnections />}
|
{settingsSection === 'agent' && <AgentConnections changed={() => void load()} />}
|
||||||
{settingsSection === 'security' && (
|
{settingsSection === 'security' && (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<h2>{tr('账号与密码')}</h2>
|
<h2>{tr('账号与密码')}</h2>
|
||||||
@@ -3357,6 +3396,19 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
{draftId && (
|
||||||
|
<AgentDrafts
|
||||||
|
initialId={draftId}
|
||||||
|
close={() => {
|
||||||
|
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()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -617,5 +617,10 @@
|
|||||||
"已隐藏全部折线,点击上方名称可重新显示。": "All series are hidden. Click a name above to show it again.",
|
"已隐藏全部折线,点击上方名称可重新显示。": "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.",
|
"点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。": "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",
|
"当天趋势数据": "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."
|
||||||
}
|
}
|
||||||
@@ -617,5 +617,10 @@
|
|||||||
"已隐藏全部折线,点击上方名称可重新显示。": "已隱藏全部折線,點擊上方名稱可重新顯示。",
|
"已隐藏全部折线,点击上方名称可重新显示。": "已隱藏全部折線,點擊上方名稱可重新顯示。",
|
||||||
"点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。": "點擊名稱切換折線;懸停或點擊圖表查看當天數據,方向鍵切換日期。",
|
"点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。": "點擊名稱切換折線;懸停或點擊圖表查看當天數據,方向鍵切換日期。",
|
||||||
"当天趋势数据": "當天趨勢數據",
|
"当天趋势数据": "當天趨勢數據",
|
||||||
"当天估值或汇率不完整,无法显示完整总额。": "當天估值或匯率不完整,無法顯示完整總額。"
|
"当天估值或汇率不完整,无法显示完整总额。": "當天估值或匯率不完整,無法顯示完整總額。",
|
||||||
|
"记住账号密码": "記住帳號密碼",
|
||||||
|
"密码由浏览器密码管理器保存,下次登录可自动填充。": "密碼由瀏覽器密碼管理器保存,下次登入可自動填入。",
|
||||||
|
"快速设置估值": "快速設定估值",
|
||||||
|
"结束快速估值": "結束快速估值",
|
||||||
|
"点击资产卡片填写最新估值;贵金属按报价自动估值。": "點擊資產卡片填寫最新估值;貴金屬按報價自動估值。"
|
||||||
}
|
}
|
||||||
@@ -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. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2492,3 +2492,78 @@ textarea,
|
|||||||
transform: none;
|
transform: none;
|
||||||
max-width: calc(100% - 24px);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string>();
|
||||||
|
const stored: any[] = [];
|
||||||
|
const old = new Map<string, PropertyDescriptor | undefined>();
|
||||||
|
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 });
|
||||||
|
});
|
||||||
@@ -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。
|
||||||
|
- 所有测试账号、授权及财务测试数据已清理,未修改实际用户账目。文档目录未保存图片。
|
||||||
@@ -93,4 +93,8 @@
|
|||||||
- ~~快速记账时,可以出现点击当天已完成记账的按钮,点击可以快速设置某个账号当天已完成记账,同时在快速记账中可以显示或隐藏当天已完成记账~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
|
- ~~快速记账时,可以出现点击当天已完成记账的按钮,点击可以快速设置某个账号当天已完成记账,同时在快速记账中可以显示或隐藏当天已完成记账~~ — 已完成并验证: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-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
|
- ~~修复 MCP 同批新增多个账号时后续草稿提示账目已变化~~ — 已完成并验证:2026-10-04 22:53(UTC+8),见 docs/fix-mcp-create-drafts-2026-10-04.md
|
||||||
Reference in new issue
Block a user