feat: adopt Apple UI and four-tab mobile navigation
This commit is contained in:
1 parent
daf9b458af
commit
fb8cf1c938
20 files changed
+1635
-351
No files matched your search
@@ -36,7 +36,7 @@ pnpm db:status
|
||||
pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的文件
|
||||
```
|
||||
|
||||
当前功能:登录、首次改密与管理员账号管理、资产与负债账户、独立资产及债务、账户分组、快速记账与撤回、双边还款及转账、月份还款和当天记账标记、单项统计开关、余额历史管理、收支日历、按需执行的定时计划、隐藏资产、净资产趋势、多币种换算、贵金属按克估价、ZIP v9 备份恢复,以及 OAuth/MCP 助手接入。
|
||||
当前功能:登录、首次改密与管理员账号管理、资产与负债账户、独立资产及债务、账户分组、快速记账与撤回、双边还款及转账、月份还款标记和当天实际记录筛选、单项统计开关、余额历史管理、收支日历、按需执行的定时计划、隐藏资产、净资产趋势、多币种换算、贵金属按克估价、ZIP v9 备份恢复,以及 OAuth/MCP 助手接入。
|
||||
|
||||
公开注册已关闭,账号由管理员后台创建。`apps/api/.env` 可配置 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD`,未配置时均为 `admin`。仅在数据库没有管理员时创建初始管理员;不会提升同名已有用户,也不会在重启时覆盖管理员密码。同名已有用户冲突时请配置其他管理员用户名。首次登录必须修改初始密码,新密码至少 10 个字符、最多 72 字节。
|
||||
|
||||
@@ -72,7 +72,7 @@ pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的
|
||||
|
||||
支持简中、English、繁中,在登录页或顶部切换,语言保存在当前浏览器。用户名称和备注保留原文。账户/资产/债务按页查询当前余额,历史和往来使用最多 100 条的游标分页;趋势默认最近 90 天,支持日、周、月。查询实现、迁移步骤、实测性能和验证边界见 [性能与联动验收](docs/performance.md)。
|
||||
|
||||
设置分类使用下拉菜单;资产总览、账户、独立资产、债务、变化记录分别使用显示开关,设置入口始终保留。顶部重复导航已移除。“显示备注”关闭后隐藏列表、详情与表单备注,原有备注不删除,备份仍完整包含。图标通过弹窗检索、选择或上传,透明背景显示;接近纯白的背景像素会转为透明,彩色背景需上传透明 PNG。
|
||||
主导航为总览、日历、账号、设置四项;变化记录位于总览,账户、独立资产、债务位于账号,定时计划和管理员后台位于设置(仅管理员可见后台)。设置分类使用下拉菜单;子页面分别使用显示开关,设置入口始终保留。手机底栏适配安全区,操作按钮提供眼睛图标、当前页状态和触屏点击反馈。“显示备注”关闭后隐藏列表、详情与表单备注,原有备注不删除,备份仍完整包含。图标通过弹窗检索、选择或上传,透明背景显示;接近纯白的背景像素会转为透明,彩色背景需上传透明 PNG。
|
||||
|
||||
“清空本账号数据”以红色显示。先下载当前完整备份并确认已保存,再输入“确定清空”,依次完成两次确认弹窗;第一次取消在左、确认在右,第二次确认在左、取消在右,两次默认聚焦取消。服务端仍校验当前登录身份、近期备份凭据及数据完整性,财务数据发生变化后须重新下载备份。
|
||||
|
||||
@@ -106,7 +106,7 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json
|
||||
|
||||
账户页开启“快速记账”后使用紧凑卡片直接更新余额;转账按钮紧邻模式入口。右侧显示本次保存成功的余额、转账、还款及标记,支持逐项撤回;窄屏记录面板显示在账户列表下方。列表为当前页面会话,刷新或重新进入后清空,已保存账目仍在变化记录中。
|
||||
|
||||
欠款账户弹窗提供“还款”,通过付款账户与欠款账户双边记账更新余额。信用卡与独立债务还款支持优惠:正数减少扣款,负数表示手续费,欠款按本金减少。可手动标记“本月已还款”和“当天已记账”,并筛选未还款或隐藏当天已记账的账户。数据库保留最后完成月份/日期,跨月、跨日只按当前 UTC+8 时间判断是否显示,无需定时删除;标记不会改变金额。
|
||||
欠款账户弹窗提供“还款”,通过付款账户与欠款账户双边记账更新余额。信用卡与独立债务还款支持优惠:正数减少扣款,负数表示手续费,欠款按本金减少。可手动标记“本月已还款”并筛选未还款账户。当天状态自动依据 UTC+8 账目日期的实际余额记录显示,可隐藏或显示当天有记录的账户;不再提供当天手动标记。撤回、删除或修改记录后同步重新计算;跨天刷新不执行定时计划。
|
||||
|
||||
账户、独立资产和债务可在详情删除。存在配对往来或定时计划时需先处理依赖,避免破坏另一账户余额。详见 [快速记账更新与验收](docs/update-quick-entry-2026-10-04.md) 和 [项目删除说明](docs/update-position-deletion-2026-10-04.md)。
|
||||
|
||||
@@ -152,3 +152,5 @@ MCP 同一连接、同一账目快照下的多个新增账号草稿可依次审
|
||||
已授权的 OAuth 与个人令牌可在“我的连接”修改只读、草稿或直接写入权限和隐藏账户权限,验证当前密码后生效;不延长授权期限,实际变更会取消该连接未确认草稿。MCP `movement_create(operation=repay)` 支持信用卡等负债账户,并保留真正还款类型及优惠、编辑和撤销语义。详见 [连接权限、还款与账号删除更新](docs/update-connections-repayment-users-2026-10-05.md)。
|
||||
|
||||
OAuth 授权页始终提供只读、草稿和直接写入三档,普通用户可明确选择最终权限。Agent 教程和 Codex 配置提示词要求连接时核对一次当前权限并缓存,发生重连、权限变更通知、授权错误或缓存不足时才再次检查。详见 [OAuth 三档选择与权限缓存](docs/update-oauth-permissions-2026-10-05.md)。
|
||||
|
||||
移动端导航、实际记账筛选和滚动条已按原作者 Skills for Design Engineers 的 apple-design 及适用规范更新。界面采用 Apple 风格,支持浅色/深色、减少动画/透明度与高对比度,详见 [更新与验收说明](docs/update-mobile-navigation-2026-10-07.md)。
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
credentialChange,
|
||||
positionInput,
|
||||
repaymentMarkInput,
|
||||
bookedMarkInput,
|
||||
inclusionInput,
|
||||
positionMeta,
|
||||
revisionInput,
|
||||
@@ -46,7 +45,6 @@ export function setupOpenApi(app: INestApplication) {
|
||||
'POST /api/positions': positionInput,
|
||||
'PATCH /api/positions/{id}': positionMeta,
|
||||
'PATCH /api/positions/{id}/repayment-mark': repaymentMarkInput,
|
||||
'PATCH /api/positions/{id}/booked-mark': bookedMarkInput,
|
||||
'PATCH /api/positions/{id}/inclusion': inclusionInput,
|
||||
'POST /api/positions/{id}/revisions': revisionInput,
|
||||
'PUT /api/positions/{id}/revisions/{revisionId}': revisionInput,
|
||||
|
||||
+20
-22
@@ -19,7 +19,6 @@ import { UserRequest } from './auth';
|
||||
import {
|
||||
positionInput,
|
||||
repaymentMarkInput,
|
||||
bookedMarkInput,
|
||||
inclusionInput,
|
||||
positionMeta,
|
||||
revisionInput,
|
||||
@@ -28,7 +27,14 @@ import {
|
||||
pairedReasons,
|
||||
} from './validation';
|
||||
import { totals, trend } from './calculation';
|
||||
import { currentPositions, currentRates, historyPage, trendData, trendInput } from './queries';
|
||||
import {
|
||||
currentPositions,
|
||||
currentRates,
|
||||
historyPage,
|
||||
trendData,
|
||||
trendInput,
|
||||
accountActivityIds,
|
||||
} from './queries';
|
||||
import { z } from 'zod';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { IconsService } from './icons';
|
||||
@@ -54,6 +60,11 @@ export class PortfolioBusinessService {
|
||||
const kind = z.enum(['account', 'asset', 'debt']).optional().parse(inputKind);
|
||||
return this.db.$transaction(async (tx) => {
|
||||
const rows = await currentPositions(tx, r.userId, r.revealed, undefined, kind);
|
||||
const day = today();
|
||||
const activity =
|
||||
!kind || kind === 'account'
|
||||
? await accountActivityIds(tx, r.userId, r.revealed, day)
|
||||
: new Set<string>();
|
||||
const user = await tx.user.findUniqueOrThrow({
|
||||
where: { id: r.userId },
|
||||
select: { baseCurrency: true, includeIndependentAssets: true },
|
||||
@@ -70,6 +81,7 @@ export class PortfolioBusinessService {
|
||||
);
|
||||
return rows.map(({ revisions, userId, ...p }) => ({
|
||||
...p,
|
||||
activityDate: activity.has(p.id) ? day : null,
|
||||
...metalPerformance(p, revisions[0]?.amount.toString() || '0', !!revisions.length),
|
||||
amount: revisions[0]?.amount.toString() || '0',
|
||||
converted: amounts.get(p.id)!.converted,
|
||||
@@ -162,8 +174,8 @@ export class PortfolioBusinessService {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async quickMeta(r: UserRequest, id: string, raw: unknown, operation: 'booked' | 'included') {
|
||||
const v = operation === 'booked' ? bookedMarkInput.parse(raw) : inclusionInput.parse(raw);
|
||||
async quickMeta(r: UserRequest, id: string, raw: unknown) {
|
||||
const v = inclusionInput.parse(raw);
|
||||
return this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(
|
||||
Prisma.sql`SELECT id FROM Position WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`,
|
||||
@@ -172,23 +184,16 @@ export class PortfolioBusinessService {
|
||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (operation === 'booked' && (p.kind !== 'account' || p.archived))
|
||||
throw new BadRequestException('仅启用账户支持当天记账标记');
|
||||
if (operation === 'included' && p.kind !== 'asset')
|
||||
throw new BadRequestException('仅独立资产支持此快速统计开关');
|
||||
if (p.kind !== 'asset') throw new BadRequestException('仅独立资产支持此快速统计开关');
|
||||
if (v.expectedUpdatedAt && p.updatedAt.toISOString() !== v.expectedUpdatedAt)
|
||||
throw new ConflictException('项目已变化,请刷新后重试');
|
||||
if (
|
||||
('expectedDate' in v && p.lastBookedDate !== v.expectedDate) ||
|
||||
('expectedIncluded' in v && p.included !== v.expectedIncluded)
|
||||
)
|
||||
if (p.included !== v.expectedIncluded)
|
||||
throw new ConflictException('标记已变化,请刷新后重试');
|
||||
const updated = await tx.position.update({
|
||||
where: { id },
|
||||
data: 'date' in v ? { lastBookedDate: v.date } : { included: v.included },
|
||||
data: { included: v.included },
|
||||
});
|
||||
return {
|
||||
lastBookedDate: updated.lastBookedDate,
|
||||
included: updated.included,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
@@ -473,19 +478,12 @@ export class PortfolioController {
|
||||
) {
|
||||
return this.service.edit(r, id, b);
|
||||
}
|
||||
@Patch('positions/:id/booked-mark') async bookedMark(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
return this.service.quickMeta(r, id, b, 'booked');
|
||||
}
|
||||
@Patch('positions/:id/inclusion') async inclusion(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
return this.service.quickMeta(r, id, b, 'included');
|
||||
return this.service.quickMeta(r, id, b);
|
||||
}
|
||||
@Patch('positions/:id/repayment-mark') async repaymentMark(
|
||||
@Req() r: UserRequest,
|
||||
|
||||
@@ -31,6 +31,25 @@ export const encodeCursor = (time: Date, key: string | number) =>
|
||||
Buffer.from(JSON.stringify({ time: time.toISOString(), key: String(key) })).toString('base64url');
|
||||
export const endOfDay = (d: string) => new Date(+toBusinessDate(d) + 86400000);
|
||||
|
||||
// Use record business time, not creation time or the obsolete manual marker.
|
||||
export async function accountActivityIds(
|
||||
db: Reader,
|
||||
userId: string,
|
||||
revealed: boolean,
|
||||
day: string,
|
||||
) {
|
||||
const rows = await db.position.findMany({
|
||||
where: {
|
||||
userId,
|
||||
kind: 'account',
|
||||
...(revealed ? {} : { hidden: false }),
|
||||
revisions: { some: { effectiveDate: { gte: toBusinessDate(day), lt: endOfDay(day) } } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return new Set(rows.map((p) => p.id));
|
||||
}
|
||||
|
||||
export async function transferPageIds(
|
||||
db: Reader,
|
||||
userId: string,
|
||||
|
||||
@@ -235,13 +235,6 @@ export const repaymentMarkInput = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const bookedMarkInput = z
|
||||
.object({
|
||||
date: date.nullable(),
|
||||
expectedDate: date.nullable(),
|
||||
expectedUpdatedAt: z.iso.datetime().optional(),
|
||||
})
|
||||
.strict();
|
||||
export const inclusionInput = z
|
||||
.object({
|
||||
included: z.boolean(),
|
||||
|
||||
@@ -103,45 +103,21 @@ test('quick entries undo paired repayment and balance, persist monthly marks, pr
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
const bookedPath = '/positions/' + cash + '/booked-mark',
|
||||
day = today();
|
||||
const booked = await call(a, bookedPath, 'PATCH', { date: day, expectedDate: null });
|
||||
assert.equal(booked.status, 200);
|
||||
assert.equal((await call(a, '/positions/' + cash)).data.lastBookedDate, day);
|
||||
assert.equal(
|
||||
(await call(a, bookedPath, 'PATCH', { date: null, expectedDate: null })).status,
|
||||
409,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(b, bookedPath, 'PATCH', { date: null, expectedDate: day })).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(a, bookedPath, 'PATCH', { date: '2099-01-01', expectedDate: day })).status,
|
||||
400,
|
||||
);
|
||||
const day = today();
|
||||
// The obsolete marker remains in ZIP v9, but cannot be written through the retired route.
|
||||
await db.position.update({ where: { id: cash }, data: { lastBookedDate: day } });
|
||||
assert.equal(
|
||||
(
|
||||
await call(a, bookedPath, 'PATCH', {
|
||||
date: null,
|
||||
expectedDate: day,
|
||||
expectedUpdatedAt: booked.data.updatedAt,
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(a, bookedPath, 'PATCH', { date: day, expectedDate: null })).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(a, '/positions/' + coin.id + '/booked-mark', 'PATCH', {
|
||||
await call(a, '/positions/' + cash + '/booked-mark', 'PATCH', {
|
||||
date: day,
|
||||
expectedDate: null,
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(a, '/positions?kind=account')).data.find((p: any) => p.id === cash).activityDate,
|
||||
null,
|
||||
);
|
||||
const revision = await call(a, '/positions/' + cash + '/revisions', 'POST', {
|
||||
amount: '1050.87654321',
|
||||
@@ -301,6 +277,58 @@ test('quick entries undo paired repayment and balance, persist monthly marks, pr
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
// Derive daily status from surviving effective-time records; cover edits, deletes and both transfer sides.
|
||||
const dailySource = await create('daily source', 'account', 'asset', 'bank', '100');
|
||||
const dailyTarget = await create('daily target', 'account', 'asset', 'bank', '0');
|
||||
const activity = async (id: string) =>
|
||||
(await call(a, '/positions?kind=account')).data.find((p: any) => p.id === id).activityDate;
|
||||
const daily = await call(a, '/positions/' + dailySource + '/revisions', 'POST', {
|
||||
amount: '110',
|
||||
date: day + 'T00:00',
|
||||
});
|
||||
assert.equal(daily.status, 201);
|
||||
assert.equal(await activity(dailySource), day);
|
||||
const yesterday = new Date(+new Date(day) - 86400000).toISOString().slice(0, 10);
|
||||
assert.equal(
|
||||
(
|
||||
await call(a, '/positions/' + dailySource + '/revisions/' + daily.data.id, 'PUT', {
|
||||
amount: '110',
|
||||
date: yesterday + 'T23:59',
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(await activity(dailySource), null);
|
||||
assert.equal(
|
||||
(
|
||||
await call(a, '/positions/' + dailySource + '/revisions/' + daily.data.id, 'PUT', {
|
||||
amount: '110',
|
||||
date: day + 'T00:00',
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(await activity(dailySource), day);
|
||||
assert.equal(
|
||||
(await call(a, '/positions/' + dailySource + '/revisions/' + daily.data.id, 'DELETE')).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(await activity(dailySource), null);
|
||||
const dailyTransfer = await call(a, '/transfers', 'POST', {
|
||||
sourceId: dailySource,
|
||||
targetId: dailyTarget,
|
||||
amount: '5',
|
||||
received: '5',
|
||||
fee: '0',
|
||||
date: day + 'T00:01',
|
||||
requestId: randomUUID(),
|
||||
});
|
||||
assert.equal(dailyTransfer.status, 201);
|
||||
assert.equal(await activity(dailySource), day);
|
||||
assert.equal(await activity(dailyTarget), day);
|
||||
assert.equal((await call(a, '/transfers/' + dailyTransfer.data.id, 'DELETE')).status, 200);
|
||||
assert.equal(await activity(dailySource), null);
|
||||
assert.equal(await activity(dailyTarget), null);
|
||||
const backup = await call(a, '/backup');
|
||||
assert.equal(backup.status, 200);
|
||||
assert.equal(backup.data.positions.find((p: any) => p.id === card).lastRepaymentMonth, old);
|
||||
|
||||
+7
-1
@@ -2,7 +2,13 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
|
||||
/>
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f5f5f7" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>WorthPath · 资产之路</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+143
-109
@@ -26,6 +26,8 @@ import {
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
ShieldCheck,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
api,
|
||||
@@ -46,6 +48,14 @@ import {
|
||||
type PageResult,
|
||||
} from './api';
|
||||
import './style.css';
|
||||
import {
|
||||
accountPages,
|
||||
overviewPages,
|
||||
settingsPages,
|
||||
navigationGroup,
|
||||
availablePages,
|
||||
navigationDestination,
|
||||
} from './navigation';
|
||||
import { NetWorthChart } from './NetWorthChart';
|
||||
import { allOverviewCards, overviewCardLabels } from './overview-cards';
|
||||
import { useToast } from './Toast';
|
||||
@@ -140,14 +150,27 @@ function Modal({
|
||||
close: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
element?.showModal();
|
||||
return () => element?.close();
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
className="veil"
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}}
|
||||
className="veil native-modal"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) close();
|
||||
}}
|
||||
>
|
||||
<section role="dialog" aria-modal="true" aria-label={title} className="modal">
|
||||
<section className="modal">
|
||||
<header>
|
||||
<h2 className={title.includes(tr('清空本账号数据')) ? 'danger-text' : undefined}>
|
||||
{title}
|
||||
@@ -158,7 +181,7 @@ function Modal({
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
function Empty({ title, body, action }: { title: string; body: string; action?: ReactNode }) {
|
||||
@@ -173,6 +196,23 @@ function Empty({ title, body, action }: { title: string; body: string; action?:
|
||||
}
|
||||
export default function App() {
|
||||
const locale = useLocale();
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const pointer = () => {
|
||||
root.dataset.input = 'pointer';
|
||||
};
|
||||
const keyboard = () => {
|
||||
root.dataset.input = 'keyboard';
|
||||
};
|
||||
document.addEventListener('pointerdown', pointer, true);
|
||||
document.addEventListener('keydown', keyboard, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', pointer, true);
|
||||
document.removeEventListener('keydown', keyboard, true);
|
||||
delete root.dataset.input;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sessionGeneration = useRef(0),
|
||||
loadGeneration = useRef(0);
|
||||
const authCheck = useRef<Promise<User> | null>(null);
|
||||
@@ -209,7 +249,6 @@ export default function App() {
|
||||
} | null>(null);
|
||||
const [quickEntries, setQuickEntries] = useState<QuickEntry[]>([]);
|
||||
const [quickMarkMode, setQuickMarkMode] = useState(false);
|
||||
const [quickBookedMode, setQuickBookedMode] = useState(false);
|
||||
const [hideBooked, setHideBooked] = useState(false);
|
||||
const [unpaidOnly, setUnpaidOnly] = useState(false);
|
||||
const [assetQuickMode, setAssetQuickMode] = useState(false);
|
||||
@@ -237,7 +276,6 @@ export default function App() {
|
||||
quickGeneration.current++;
|
||||
setQuickEntries([]);
|
||||
setQuickMarkMode(false);
|
||||
setQuickBookedMode(false);
|
||||
setHideBooked(false);
|
||||
setUnpaidOnly(false);
|
||||
setAssetQuickMode(false);
|
||||
@@ -262,7 +300,6 @@ export default function App() {
|
||||
if (!quickMode) setQuickEntries([]);
|
||||
setQuickMode(!quickMode);
|
||||
setQuickMarkMode(false);
|
||||
setQuickBookedMode(false);
|
||||
setHideBooked(false);
|
||||
setUnpaidOnly(false);
|
||||
setQuickTransfer(null);
|
||||
@@ -356,39 +393,6 @@ export default function App() {
|
||||
);
|
||||
return result;
|
||||
}
|
||||
async function markQuickBooked(position: Position) {
|
||||
const previousDate = position.lastBookedDate ?? null,
|
||||
day = today();
|
||||
const nextDate = isBookedToday(position, day) ? null : day;
|
||||
const generation = quickGeneration.current,
|
||||
session = sessionGeneration.current;
|
||||
const result = await api<{ updatedAt: string }>(
|
||||
'/positions/' + position.id + '/booked-mark',
|
||||
'PATCH',
|
||||
{
|
||||
date: nextDate,
|
||||
expectedDate: previousDate,
|
||||
expectedUpdatedAt: position.updatedAt,
|
||||
},
|
||||
);
|
||||
rememberQuick(
|
||||
{
|
||||
key: 'booked:' + position.id + ':' + Date.now(),
|
||||
kind: 'booked',
|
||||
id: position.id,
|
||||
positionId: position.id,
|
||||
title: position.name,
|
||||
currency: position.currency,
|
||||
date: nowMinute(),
|
||||
previousDate,
|
||||
bookedDate: nextDate,
|
||||
markUpdatedAt: result.updatedAt,
|
||||
},
|
||||
generation,
|
||||
session,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
async function toggleAssetInclusion(position: Position) {
|
||||
return api('/positions/' + position.id + '/inclusion', 'PATCH', {
|
||||
included: position.included === false,
|
||||
@@ -401,12 +405,6 @@ export default function App() {
|
||||
if (entry.kind === 'balance')
|
||||
await api('/positions/' + entry.positionId + '/revisions/' + entry.id, 'DELETE');
|
||||
else if (entry.kind === 'transfer') await api('/transfers/' + entry.id, 'DELETE');
|
||||
else if (entry.kind === 'booked')
|
||||
await api('/positions/' + entry.positionId + '/booked-mark', 'PATCH', {
|
||||
date: entry.previousDate ?? null,
|
||||
expectedDate: entry.bookedDate ?? null,
|
||||
expectedUpdatedAt: entry.markUpdatedAt,
|
||||
});
|
||||
else
|
||||
await api('/positions/' + entry.positionId + '/repayment-mark', 'PATCH', {
|
||||
month: entry.previousMonth ?? null,
|
||||
@@ -467,7 +465,9 @@ export default function App() {
|
||||
[grain, setGrain] = useState('day');
|
||||
const [dataVersion, setDataVersion] = useState(0);
|
||||
const previousDataVersion = useRef(0);
|
||||
const viewKey = [page, selected, rangeFrom, rangeTo, grain, settingsSection].join('|');
|
||||
const viewKey = [page, selected, rangeFrom, rangeTo, grain, settingsSection, businessDay].join(
|
||||
'|',
|
||||
);
|
||||
const viewRef = useRef(viewKey);
|
||||
viewRef.current = viewKey;
|
||||
const userRef = useRef(user);
|
||||
@@ -706,19 +706,26 @@ export default function App() {
|
||||
};
|
||||
}, [modal]);
|
||||
const previousView = useRef('');
|
||||
const previousBusinessDay = useRef(businessDay);
|
||||
useEffect(() => {
|
||||
if (!user || user.mustChangePassword) return;
|
||||
const dayChanged = previousBusinessDay.current !== businessDay;
|
||||
previousBusinessDay.current = businessDay;
|
||||
const mutated = previousDataVersion.current !== dataVersion;
|
||||
previousDataVersion.current = dataVersion;
|
||||
const rangeOnly =
|
||||
!mutated && page === 'overview' && previousView.current.startsWith('overview|') && !!overview;
|
||||
!mutated &&
|
||||
!dayChanged &&
|
||||
page === 'overview' &&
|
||||
previousView.current.startsWith('overview|') &&
|
||||
!!overview;
|
||||
previousView.current = viewKey;
|
||||
if (!rangeOnly) setOverview(null);
|
||||
setHistoryRows([]);
|
||||
setHistoryCursor(null);
|
||||
setTransfers([]);
|
||||
setTransferCursor(null);
|
||||
void load(true, rangeOnly, !mutated);
|
||||
void load(true, rangeOnly, !mutated && !dayChanged);
|
||||
return () => {
|
||||
loadGeneration.current++;
|
||||
};
|
||||
@@ -1044,19 +1051,27 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
const nav = [
|
||||
['overview', LayoutDashboard],
|
||||
['account', Wallet],
|
||||
['asset', Landmark],
|
||||
['debt', HandCoins],
|
||||
['history', HistoryIcon],
|
||||
['calendar', CalendarDays],
|
||||
['schedules', HistoryIcon],
|
||||
['settings', Settings],
|
||||
['admin', ShieldCheck],
|
||||
['overview', LayoutDashboard, '总览'],
|
||||
['calendar', CalendarDays, '日历'],
|
||||
['account', Wallet, '账号'],
|
||||
['settings', Settings, '设置'],
|
||||
] as const;
|
||||
const visibleNav = nav.filter(([key]) =>
|
||||
key === 'admin' ? user.role === 'admin' : !(user.hiddenMenus || []).includes(key),
|
||||
);
|
||||
const hiddenMenus = user.hiddenMenus || [];
|
||||
const activeGroup = navigationGroup(page);
|
||||
const visibleNav = nav.filter(([key]) => navigationDestination(key, hiddenMenus, user.role));
|
||||
const sectionPages =
|
||||
activeGroup === 'account'
|
||||
? accountPages
|
||||
: activeGroup === 'overview'
|
||||
? overviewPages
|
||||
: activeGroup === 'settings'
|
||||
? settingsPages
|
||||
: [];
|
||||
function navigateTo(next: string) {
|
||||
setPage(next);
|
||||
setSelected(null);
|
||||
setPositions([]);
|
||||
}
|
||||
const groups = orderedGroups(
|
||||
positions.filter((p) => p.kind === 'account').map((p) => p.groupName || ''),
|
||||
user.accountGroupOrder,
|
||||
@@ -1072,18 +1087,22 @@ export default function App() {
|
||||
<span>W</span> WorthPath
|
||||
</div>
|
||||
<p className="nav-caption">{tr('我的资产空间')}</p>
|
||||
<nav style={{ gridTemplateColumns: `repeat(${visibleNav.length}, 1fr)` }}>
|
||||
{visibleNav.map(([key, Icon]) => (
|
||||
<nav
|
||||
className="primary-nav"
|
||||
aria-label={tr('主导航')}
|
||||
style={{ gridTemplateColumns: `repeat(${visibleNav.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{visibleNav.map(([key, Icon, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
className={page === key ? 'active' : ''}
|
||||
className={activeGroup === key ? 'active' : ''}
|
||||
aria-current={activeGroup === key ? 'page' : undefined}
|
||||
onClick={() => {
|
||||
setPage(key);
|
||||
setSelected(null);
|
||||
navigateTo(navigationDestination(key, hiddenMenus, user.role)!);
|
||||
}}
|
||||
>
|
||||
<Icon size={19} />
|
||||
<span>{tr(labels[key])}</span>
|
||||
<span>{tr(label)}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
@@ -1119,6 +1138,14 @@ export default function App() {
|
||||
{tr('本位币')}
|
||||
{user.baseCurrency}
|
||||
</span>
|
||||
<button
|
||||
className="icon mobile-logout"
|
||||
aria-label={tr('退出登录')}
|
||||
disabled={busy}
|
||||
onClick={() => void logout()}
|
||||
>
|
||||
<LogOut size={17} />
|
||||
</button>
|
||||
<button
|
||||
className="icon"
|
||||
aria-label={tr('刷新数据')}
|
||||
@@ -1134,6 +1161,20 @@ export default function App() {
|
||||
page === 'account' && quickMode && !selected ? 'content quick-content' : 'content'
|
||||
}
|
||||
>
|
||||
{sectionPages.length > 0 && (
|
||||
<nav className="section-nav" aria-label={tr('页面分类')}>
|
||||
{availablePages(sectionPages, hiddenMenus, user.role).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-current={page === key ? 'page' : undefined}
|
||||
onClick={() => navigateTo(key)}
|
||||
>
|
||||
{tr(labels[key])}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<div className="privacy-toolbar">
|
||||
<span>
|
||||
{user.revealed
|
||||
@@ -1151,6 +1192,11 @@ export default function App() {
|
||||
: setModal({ kind: 'reveal' })
|
||||
}
|
||||
>
|
||||
{user.revealed ? (
|
||||
<EyeOff size={17} aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={17} aria-hidden="true" />
|
||||
)}
|
||||
{user.revealed
|
||||
? tr('锁定隐藏资产')
|
||||
: user.requireHiddenPassword === false
|
||||
@@ -1636,7 +1682,6 @@ export default function App() {
|
||||
aria-pressed={quickMarkMode}
|
||||
onClick={() => {
|
||||
setQuickMarkMode(!quickMarkMode);
|
||||
setQuickBookedMode(false);
|
||||
setQuickTransfer(null);
|
||||
}}
|
||||
>
|
||||
@@ -1655,7 +1700,6 @@ export default function App() {
|
||||
}
|
||||
onClick={() => {
|
||||
setQuickMarkMode(false);
|
||||
setQuickBookedMode(false);
|
||||
setHideBooked(false);
|
||||
setUnpaidOnly(false);
|
||||
setQuickTransfer({});
|
||||
@@ -1663,18 +1707,6 @@ export default function App() {
|
||||
>
|
||||
{tr('转账')}
|
||||
</button>
|
||||
<button
|
||||
className={quickBookedMode ? 'primary' : 'secondary'}
|
||||
disabled={busy}
|
||||
aria-pressed={quickBookedMode}
|
||||
onClick={() => {
|
||||
setQuickBookedMode(!quickBookedMode);
|
||||
setQuickMarkMode(false);
|
||||
setQuickTransfer(null);
|
||||
}}
|
||||
>
|
||||
{tr(quickBookedMode ? '结束记账标记' : '标记当天已记账')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary"
|
||||
aria-pressed={unpaidOnly}
|
||||
@@ -1684,6 +1716,11 @@ export default function App() {
|
||||
setQuickTransfer(null);
|
||||
}}
|
||||
>
|
||||
{unpaidOnly ? (
|
||||
<Eye size={17} aria-hidden="true" />
|
||||
) : (
|
||||
<EyeOff size={17} aria-hidden="true" />
|
||||
)}
|
||||
{tr(unpaidOnly ? '显示全部账户' : '仅看本月未还款')}
|
||||
</button>
|
||||
<button
|
||||
@@ -1695,7 +1732,12 @@ export default function App() {
|
||||
setQuickTransfer(null);
|
||||
}}
|
||||
>
|
||||
{tr(hideBooked ? '显示当天已记账' : '隐藏当天已记账')}
|
||||
{hideBooked ? (
|
||||
<Eye size={17} aria-hidden="true" />
|
||||
) : (
|
||||
<EyeOff size={17} aria-hidden="true" />
|
||||
)}
|
||||
{tr(hideBooked ? '显示当天有记录' : '隐藏当天有记录')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -1727,9 +1769,7 @@ export default function App() {
|
||||
{quickMode && (
|
||||
<p className="muted">
|
||||
{tr(
|
||||
quickBookedMode
|
||||
? '点击账户卡片,标记或取消当天已记账。'
|
||||
: quickMarkMode
|
||||
quickMarkMode
|
||||
? '点击欠款账户卡片,标记或取消本月已还款。'
|
||||
: quickTransfer !== null
|
||||
? '快速转账中:依次点击转出和转入账户卡片。'
|
||||
@@ -1835,6 +1875,10 @@ export default function App() {
|
||||
<button
|
||||
className={
|
||||
'position-card' +
|
||||
(p.name.length > 40 ||
|
||||
money(positionAmount(p), p.currency).length > 18
|
||||
? ' position-wide'
|
||||
: '') +
|
||||
(quickTransfer?.sourceId === p.id
|
||||
? ' quick-source'
|
||||
: quickTransfer?.targetId === p.id
|
||||
@@ -1852,10 +1896,7 @@ export default function App() {
|
||||
assetValueMode &&
|
||||
(p.archived || p.category === 'gold')) ||
|
||||
(!!quickTransfer && p.archived) ||
|
||||
(quickMode &&
|
||||
quickMarkMode &&
|
||||
(p.side !== 'liability' || p.archived)) ||
|
||||
(quickMode && quickBookedMode && p.archived)
|
||||
(quickMode && quickMarkMode && (p.side !== 'liability' || p.archived))
|
||||
}
|
||||
key={p.id}
|
||||
onClick={() =>
|
||||
@@ -1863,16 +1904,9 @@ export default function App() {
|
||||
? 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('还款标记已保存'),
|
||||
)
|
||||
? p.kind === 'account' && p.side === 'liability' && !p.archived
|
||||
? void act(() => markQuickRepayment(p), tr('还款标记已保存'))
|
||||
: undefined
|
||||
: quickTransfer !== null && page === 'account' && quickMode
|
||||
? chooseQuickAccount(p)
|
||||
@@ -1900,7 +1934,7 @@ export default function App() {
|
||||
<span className="badge repayment-paid">{tr('本月已还款')}</span>
|
||||
)}
|
||||
{quickMode && page === 'account' && isBookedToday(p, businessDay) && (
|
||||
<span className="badge repayment-paid">{tr('当天已记账')}</span>
|
||||
<span className="badge repayment-paid">{tr('当天有记账记录')}</span>
|
||||
)}
|
||||
{assetQuickMode && page === 'asset' && p.included !== false && (
|
||||
<span className="badge repayment-paid">{tr('计入总资产')}</span>
|
||||
@@ -2134,7 +2168,12 @@ export default function App() {
|
||||
{page === 'admin' && user.role === 'admin' && <AdminPanel user={user} report={report} />}
|
||||
{page === 'settings' && (
|
||||
<>
|
||||
<nav className="settings-tabs" aria-label={tr('设置分类')}>
|
||||
<label className="settings-category">
|
||||
<span>{tr('设置分类')}</span>
|
||||
<select
|
||||
value={settingsSection}
|
||||
onChange={(event) => setSettingsSection(event.target.value)}
|
||||
>
|
||||
{Object.entries({
|
||||
general: '常规设置',
|
||||
agent: '连接 Agent',
|
||||
@@ -2146,17 +2185,12 @@ export default function App() {
|
||||
backup: '备份与恢复',
|
||||
clear: '清空本账号数据',
|
||||
}).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-current={settingsSection === key ? 'page' : undefined}
|
||||
className={key === 'clear' ? 'danger-text' : undefined}
|
||||
onClick={() => setSettingsSection(key)}
|
||||
>
|
||||
<option key={key} value={key}>
|
||||
{tr(label)}
|
||||
</button>
|
||||
</option>
|
||||
))}
|
||||
</nav>
|
||||
</select>
|
||||
</label>
|
||||
{settingsSection === 'icons' && (
|
||||
<section className="panel">
|
||||
<IconLibrary />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { money, positionAmount, type Position } from './api';
|
||||
import { t as tr } from './i18n';
|
||||
export type QuickEntry = {
|
||||
key: string;
|
||||
kind: 'balance' | 'transfer' | 'mark' | 'booked';
|
||||
kind: 'balance' | 'transfer' | 'mark';
|
||||
id: string;
|
||||
positionId: string;
|
||||
title: string;
|
||||
@@ -21,8 +21,6 @@ export type QuickEntry = {
|
||||
previousMonth?: string | null;
|
||||
month?: string | null;
|
||||
markUpdatedAt?: string;
|
||||
previousDate?: string | null;
|
||||
bookedDate?: string | null;
|
||||
undone?: boolean;
|
||||
};
|
||||
export const isPaidForMonth = (
|
||||
@@ -32,8 +30,8 @@ export const isPaidForMonth = (
|
||||
position.kind === 'account' &&
|
||||
position.side === 'liability' &&
|
||||
position.lastRepaymentMonth === month;
|
||||
export const isBookedToday = (position: Pick<Position, 'kind' | 'lastBookedDate'>, day: string) =>
|
||||
position.kind === 'account' && position.lastBookedDate === day;
|
||||
export const isBookedToday = (position: Pick<Position, 'kind' | 'activityDate'>, day: string) =>
|
||||
position.kind === 'account' && position.activityDate === day;
|
||||
export const quickAccountVisible = (
|
||||
position: Position,
|
||||
month: string,
|
||||
@@ -96,9 +94,6 @@ export function QuickJournal({
|
||||
{entry.kind === 'mark' && (
|
||||
<p>{tr(entry.month ? '已标记本月还款' : '已取消本月标记')}</p>
|
||||
)}
|
||||
{entry.kind === 'booked' && (
|
||||
<p>{tr(entry.bookedDate ? '已标记当天记账' : '已取消当天标记')}</p>
|
||||
)}
|
||||
<small>{entry.date.replace('T', ' ')} · UTC+8</small>
|
||||
{entry.undone ? (
|
||||
<span className="badge">{tr('已撤回')}</span>
|
||||
|
||||
+18
-9
@@ -17,22 +17,31 @@ export function useToast(message: string, kind: Kind) {
|
||||
}, [message, kind]);
|
||||
}
|
||||
function ToastItem({ notice, remove }: { notice: Notice; remove: (id: number) => void }) {
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const dismiss = () => {
|
||||
if (document.documentElement.dataset.input === 'keyboard') remove(notice.id);
|
||||
else setExiting(true);
|
||||
};
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => remove(notice.id), notice.kind === 'error' ? 12000 : 6000);
|
||||
const timer = setTimeout(() => setExiting(true), notice.kind === 'error' ? 12000 : 6000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [notice, remove]);
|
||||
}, [notice]);
|
||||
useEffect(() => {
|
||||
if (!exiting) return;
|
||||
const timer = setTimeout(() => remove(notice.id), 120);
|
||||
return () => clearTimeout(timer);
|
||||
}, [exiting, notice.id, remove]);
|
||||
return (
|
||||
<div className={'toast ' + notice.kind} role={notice.kind === 'error' ? 'alert' : 'status'}>
|
||||
<div
|
||||
className={'toast ' + notice.kind}
|
||||
data-ending={exiting || undefined}
|
||||
role={notice.kind === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
<span className="toast-symbol" aria-hidden="true">
|
||||
{notice.kind === 'error' ? '!' : '✓'}
|
||||
</span>
|
||||
<p>{tr(notice.message)}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="icon"
|
||||
aria-label={tr('关闭')}
|
||||
onClick={() => remove(notice.id)}
|
||||
>
|
||||
<button type="button" className="icon" aria-label={tr('关闭')} onClick={dismiss}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ export type History = {
|
||||
};
|
||||
export type Position = {
|
||||
lastRepaymentMonth?: string | null;
|
||||
lastBookedDate?: string | null;
|
||||
activityDate?: string | null;
|
||||
updatedAt?: string;
|
||||
included?: boolean;
|
||||
metalType?: string | null;
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"欢迎回来": "Welcome back",
|
||||
"建立属于你的私人资产空间": "Create your private asset workspace",
|
||||
"登录以查看你的资产与负债": "Sign in to view your assets and liabilities",
|
||||
"账号": "Username",
|
||||
"账号": "Accounts",
|
||||
"输入账号(至少 3 个字符)": "Enter a username (at least 3 characters)",
|
||||
"密码": "Password",
|
||||
"至少 10 个字符,最多 72 字节": "At least 10 characters, up to 72 bytes",
|
||||
@@ -666,5 +666,13 @@
|
||||
"不能删除当前登录的管理员账号": "You cannot delete the currently signed-in administrator.",
|
||||
"仅支持删除管理员账号": "Only administrator accounts can be deleted.",
|
||||
"确认账号名称不一致,请重新核对": "The confirmation username does not match. Check it again.",
|
||||
"请先设置另一位已完成改密且未封禁的管理员": "First set up another unbanned administrator who has changed their initial password."
|
||||
"请先设置另一位已完成改密且未封禁的管理员": "First set up another unbanned administrator who has changed their initial password.",
|
||||
"总览": "Overview",
|
||||
"日历": "Calendar",
|
||||
"设置": "Settings",
|
||||
"主导航": "Main navigation",
|
||||
"页面分类": "Page sections",
|
||||
"当天有记账记录": "Records today",
|
||||
"显示当天有记录": "Show today’s recorded accounts",
|
||||
"隐藏当天有记录": "Hide today’s recorded accounts"
|
||||
}
|
||||
@@ -666,5 +666,13 @@
|
||||
"不能删除当前登录的管理员账号": "不能刪除當前登錄的管理員賬號",
|
||||
"仅支持删除管理员账号": "僅支持刪除管理員賬號",
|
||||
"确认账号名称不一致,请重新核对": "確認賬號名稱不一致,請重新核對",
|
||||
"请先设置另一位已完成改密且未封禁的管理员": "請先設置另一位已完成改密且未封禁的管理員"
|
||||
"请先设置另一位已完成改密且未封禁的管理员": "請先設置另一位已完成改密且未封禁的管理員",
|
||||
"总览": "總覽",
|
||||
"日历": "日曆",
|
||||
"设置": "設置",
|
||||
"主导航": "主導航",
|
||||
"页面分类": "頁面分類",
|
||||
"当天有记账记录": "當天有記賬記錄",
|
||||
"显示当天有记录": "顯示當天有記錄",
|
||||
"隐藏当天有记录": "隱藏當天有記錄"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export const accountPages = ['account', 'asset', 'debt'] as const;
|
||||
export const overviewPages = ['overview', 'history'] as const;
|
||||
export const settingsPages = ['settings', 'schedules', 'admin'] as const;
|
||||
|
||||
export function navigationGroup(page: string) {
|
||||
if ((accountPages as readonly string[]).includes(page)) return 'account';
|
||||
if ((overviewPages as readonly string[]).includes(page)) return 'overview';
|
||||
if ((settingsPages as readonly string[]).includes(page)) return 'settings';
|
||||
return page;
|
||||
}
|
||||
|
||||
export function availablePages(pages: readonly string[], hidden: string[], role?: string) {
|
||||
return pages.filter((page) =>
|
||||
page === 'settings' ? true : page === 'admin' ? role === 'admin' : !hidden.includes(page),
|
||||
);
|
||||
}
|
||||
|
||||
export function navigationDestination(group: string, hidden: string[], role?: string) {
|
||||
const pages =
|
||||
group === 'account'
|
||||
? accountPages
|
||||
: group === 'overview'
|
||||
? overviewPages
|
||||
: group === 'settings'
|
||||
? settingsPages
|
||||
: [group];
|
||||
return availablePages(pages, hidden, role)[0];
|
||||
}
|
||||
+864
-141
File diff suppressed because it is too large.
Load diff
@@ -57,13 +57,14 @@ test('session journal displays exact balances and distinguishes saved versus und
|
||||
assert.doesNotMatch(undone, /<button/);
|
||||
});
|
||||
|
||||
test('daily badges expire and filters combine month and day without mutating markers', () => {
|
||||
test('actual record badges expire and filters ignore obsolete manual markers', () => {
|
||||
const p = {
|
||||
id: 'test',
|
||||
kind: 'account',
|
||||
side: 'liability',
|
||||
lastRepaymentMonth: '2026-10',
|
||||
lastBookedDate: '2026-10-04',
|
||||
activityDate: '2026-10-04',
|
||||
lastBookedDate: '2026-10-05',
|
||||
archived: false,
|
||||
} as any;
|
||||
assert.equal(isBookedToday(p, '2026-10-04'), true);
|
||||
@@ -75,7 +76,9 @@ test('daily badges expire and filters combine month and day without mutating mar
|
||||
quickAccountVisible({ ...p, side: 'asset' }, '2026-11', '2026-10-05', true, false),
|
||||
false,
|
||||
);
|
||||
assert.equal(p.lastBookedDate, '2026-10-04');
|
||||
assert.equal(p.lastBookedDate, '2026-10-05');
|
||||
assert.equal(isBookedToday({ ...p, activityDate: null }, '2026-10-05'), false);
|
||||
assert.equal(isBookedToday({ ...p, kind: 'asset' }, '2026-10-04'), false);
|
||||
});
|
||||
|
||||
test('repayment discount uses opposite fee sign without decimal precision loss', () => {
|
||||
@@ -85,3 +88,23 @@ test('repayment discount uses opposite fee sign without decimal precision loss',
|
||||
assert.equal(repaymentFee('0'), '0');
|
||||
assert.throws(() => repaymentFee('1e3'));
|
||||
});
|
||||
|
||||
import {
|
||||
accountPages,
|
||||
overviewPages,
|
||||
settingsPages,
|
||||
navigationGroup,
|
||||
availablePages,
|
||||
navigationDestination,
|
||||
} from '../src/navigation';
|
||||
test('navigation retains access to visible children and restricts admin to administrators', () => {
|
||||
for (const page of accountPages) assert.equal(navigationGroup(page), 'account');
|
||||
for (const page of overviewPages) assert.equal(navigationGroup(page), 'overview');
|
||||
for (const page of settingsPages) assert.equal(navigationGroup(page), 'settings');
|
||||
assert.equal(navigationDestination('account', ['account']), 'asset');
|
||||
assert.equal(navigationDestination('account', ['account', 'asset', 'debt']), undefined);
|
||||
assert.equal(navigationDestination('overview', ['overview']), 'history');
|
||||
assert.equal(navigationDestination('settings', ['settings', 'schedules'], 'user'), 'settings');
|
||||
assert.deepEqual(availablePages(settingsPages, [], 'user'), ['settings', 'schedules']);
|
||||
assert.deepEqual(availablePages(settingsPages, [], 'admin'), ['settings', 'schedules', 'admin']);
|
||||
});
|
||||
@@ -54,4 +54,4 @@ User.hiddenMenus 保存受枚举校验的菜单键,settings 不可隐藏;Use
|
||||
|
||||
## 快速记账标记与统计开关(2026-10-04)
|
||||
|
||||
Position.lastRepaymentMonth 和 lastBookedDate 分别记录 UTC+8 最后已还款月份与已记账日期,可空且仅保存最后一次人工标记。页面根据当前业务年月/日期显示状态,不通过定时任务删除旧值。标记接口使用用户归属、隐藏可见性、行锁及期望状态检查;网页使用 updatedAt 防止覆盖新操作。资产快速统计开关仅修改 included,不覆盖其他项目元数据。ZIP v9 保存新字段,金额历史不受标记影响。详细操作、接口和验收见 [快速记账更新](update-quick-entry-2026-10-04.md)。
|
||||
Position.lastRepaymentMonth 记录 UTC+8 最后已还款月份,页面按当前业务月份显示,不通过定时任务删除旧值。还款标记接口使用用户归属、隐藏可见性、行锁及期望状态检查;网页使用 updatedAt 防止覆盖新操作。2026-10-07 起当天记账状态使用账号列表的派生 activityDate,从当天有效日期的 Revision 记录查询;lastBookedDate 仅为数据库和 ZIP v9 保留,不再参与筛选,booked-mark 接口已撤下。资产快速统计开关仅修改 included,不覆盖其他项目元数据。详细操作和验收见 [移动端导航与当天记录筛选](update-mobile-navigation-2026-10-07.md)。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 移动端导航与当天记录筛选
|
||||
|
||||
完成时间:2026-10-07 18:06(UTC+8)。
|
||||
|
||||
## 页面入口
|
||||
|
||||
主导航统一为总览、日历、账号、设置四项,手机底栏不再平铺所有页面。总览内保留变化记录;账号内提供账户、独立资产、债务;设置内提供设置与备份、定时计划、管理员后台。管理员入口仅对管理员显示,隐藏菜单偏好继续作用于子页面;若账户被隐藏但独立资产可见,点击账号会进入第一个可见子页面。设置入口始终保留。
|
||||
|
||||
设置的九类内容改用带文字标签的原生选择框,减少按钮堆叠。手机提供退出登录按钮,底部预留安全区,启用 `viewport-fit=cover`。新增导航显示当前页状态和键盘焦点;手机操作按钮至少 44px 高,底栏入口至少 56px 高。
|
||||
|
||||
## 使用的开源设计 skill
|
||||
|
||||
使用原作者 [emilkowalski/skills](https://github.com/emilkowalski/skills),读取版本 `e8a175de22ae1e49370fc144c1f3bb9aeedf988d`,项目为 [MIT 许可](https://github.com/emilkowalski/skills/blob/main/LICENSE)。已读取并应用 [apple-design](https://github.com/emilkowalski/skills/blob/main/skills/apple-design/SKILL.md)、emil-design-eng、mobile-native、animate、review-animations(含 STANDARDS)、break-ui(含 CATALOG),并检查 pick-ui-library 的已有依赖原则。设计规范可直接用于本项目的 React/CSS,无需新增运行时依赖或安装全局工具。
|
||||
|
||||
Apple 风格落实为系统字体、蓝色主操作、浅灰分组、圆角卡片、半透明导航、明确当前页和浅色/深色主题。保留绿色余额和红色欠款语义;数值等宽,不截断金额。所有悬停效果按精细指针能力启用,触摸输入字号至少 16px,使用 dvh、安全区、原生 dialog 焦点锁定与 Escape 关闭。支持减少动画、减少透明度、高对比度;正文可选中,控制元素不选中。
|
||||
|
||||
其他仓库 skill 按适用范围执行:pick-ui-library 要求先复用现有组件,因此保留现有 Toast 并修正动效,不为 Sonner 增加依赖。animate-expo、write-swift 面向本项目未使用的原生技术;ask-sonner 面向未安装的 Sonner;prototype 的多版本原型与本次确定的风格不符;animation-vocabulary 用于动效命名,find-animation-opportunities/improve-animations 为建议或只读评审工作流。它们不作为额外功能、原生迁移或依赖更换要求。
|
||||
|
||||
| Before | After | Why |
|
||||
| ------------------------ | -------------------------------------------------------------------- | ------------------------------ |
|
||||
| 底栏平铺多个入口、小字号 | 四个主入口、12px 标签、二级导航 | 减少移动端视觉与点击负担 |
|
||||
| 设置按钮铺满屏幕 | 单个分类选择框 | 渐进展示设置内容 |
|
||||
| 高频页面切换 | 立即切换,无页面入场动画 | 不阻碍频繁记账 |
|
||||
| 点击缺少触觉式反馈 | pointerdown 即时反馈,低频按钮 `scale(0.97)`;按下 140ms、释放 100ms | 明确反馈,避免布局动画 |
|
||||
| 触屏沿用悬停动画 | hover 仅用于精细指针;减少动画时禁用位移,键盘和高频控制无动效 | 尊重输入方式与系统偏好 |
|
||||
| Toast 不易中断的入场 | transform/opacity 180ms 入场、120ms 同方向离场,精确 ease-out | 可中断、速度不对称,不动画布局 |
|
||||
| 长名称/极大金额挤在窄列 | 长卡片跨整行、分组文字换行、保留完整金额 | 保护真实内容可读性 |
|
||||
|
||||
review-animations 结论:**Approve(本次代码与浏览器范围)**。`apps/web/src/style.css:2951` 为输入反馈,`:3220` 为高频控制禁用过渡,`:1681` 与 Toast 结尾样式为可中断过渡,`apps/web/src/Toast.tsx:19` 为退出生命周期,`apps/web/src/App.tsx:156` 为原生模态框。仅 transform/opacity 动效、时长小于 300ms;实体 iOS/Android 的安全区、软键盘与滑动体验待硬件验收。
|
||||
|
||||
## 滚动条
|
||||
|
||||
使用细圆角灰色滚动条和透明轨道,浅色/深色各自配置滑块颜色;精细指针悬停加深。高对比度恢复标准宽度并增强颜色,系统强制颜色模式使用浏览器颜色,不隐藏滚动能力。
|
||||
|
||||
## 当天记录状态
|
||||
|
||||
账号列表接口新增派生字段 `activityDate`:当天有至少一条有效余额历史时返回当天 `YYYY-MM-DD`,否则为 `null`。查询使用用户归属、账户类型、隐藏资产可见性,以及 UTC+8 当天起点(包含)到次日起点(不包含)的 `Revision.effectiveDate` 区间。使用已有索引与关系存在性查询,不把历史记录全部传回浏览器,不增加数据库字段或迁移。
|
||||
|
||||
余额调整、初始余额、转账及还款的账户历史均属于记账数据;当天零差额记录也计入。这里与日历的收入支出统计口径不同:日历仍排除初始余额和转账等非收入支出。历史补录按账目有效日期判断,不能按保存时间或项目更新时间判断。
|
||||
|
||||
移除当天手动记账模式、其会话撤回类型和 `PATCH /api/positions/:id/booked-mark` 接口及 OpenAPI 声明。`lastBookedDate` 仅为现有数据库和 ZIP v9 保留,不参与状态或筛选;本月还款标记保持现有语义。删除、撤回或修改记录后重新读取状态;午夜/恢复窗口焦点检测日期变化后刷新,日期变化本身不执行到期计划。
|
||||
|
||||
快速记账提供“隐藏当天有记录 / 显示当天有记录”,并显示眼睛图标和 `aria-pressed` 状态;隐藏资产、本月未还款筛选也带眼睛图标。
|
||||
|
||||
## 验证
|
||||
|
||||
- 前后端 TypeScript 检查、前端生产构建通过。
|
||||
- 项目单元回归 58 项通过,包括子页归属、隐藏菜单回退、管理员可见性、派生状态不读取旧手动标记。
|
||||
- 在临时 MySQL 数据库运行快速记账集成测试通过:有效日期修改跨天、余额记录删除、转账双方状态和撤回、旧标记不影响实际记录状态、废弃接口返回 404、权限隔离与 ZIP v9 恢复。临时数据库和 API 已自动清理。
|
||||
- 使用虚构账号数据,在 Edge 浏览器验证 320、375、390、430、768、1440px 布局;四个入口及账号/设置子页可达,当天筛选正常,未发现横向溢出或浏览器错误。
|
||||
- break-ui 检查 Demo、Worst case、Empty、One、1,000 rows,最坏数据使用接口允许的 100 字名称、超长英文、单字、极大金额/负数/零。检查三种语言、浅色/深色、200% 字体,修正长卡片和放大后的头栏。开发数据选择器仅注入验收浏览器,不进入生产应用。
|
||||
- 原生模态焦点和 Escape、键盘无动画、减少动画/透明度、高对比度、滚动条偏好与 Toast 实际错误/关闭路径通过。
|
||||
- 截图仅用于本地视觉检查,未保存到 docs。保留有回归价值的 `scripts/check-mobile-ui.cjs`,删除临时命名脚本。先启动 Vite,再执行 `node scripts/check-mobile-ui.cjs`;需本地 Playwright,可用 `WORTHPATH_PLAYWRIGHT_MODULE` 指定模块路径、`WORTHPATH_UI_URL` 指定开发地址、`WORTHPATH_BROWSER_CHANNEL` 指定已安装浏览器(默认 msedge)。截图默认写入系统临时目录,可用 `WORTHPATH_UI_OUTPUT` 覆盖。
|
||||
|
||||
本次完成本地代码和文档更新,未推送或部署线上。移动端检查为浏览器模拟尺寸,未在实体手机验证安全区和软键盘。
|
||||
@@ -0,0 +1,352 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
// Reusable UI regression with synthetic API data; never connects to a live ledger.
|
||||
// Start Vite first. Set WORTHPATH_PLAYWRIGHT_MODULE if Playwright is not resolvable.
|
||||
const { chromium } = require(process.env.WORTHPATH_PLAYWRIGHT_MODULE || 'playwright');
|
||||
const baseUrl = process.env.WORTHPATH_UI_URL || 'http://127.0.0.1:5175';
|
||||
const output =
|
||||
process.env.WORTHPATH_UI_OUTPUT || fs.mkdtempSync(path.join(os.tmpdir(), 'worthpath-ui-'));
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const day = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Hong_Kong' }).format(new Date());
|
||||
const user = {
|
||||
username: 'UI预览',
|
||||
role: 'admin',
|
||||
baseCurrency: 'CNY',
|
||||
hiddenMenus: [],
|
||||
showNotes: true,
|
||||
idleMinutes: 0,
|
||||
revealed: false,
|
||||
requireHiddenPassword: false,
|
||||
};
|
||||
let failNext = false;
|
||||
let accounts = ['日常账户', '储蓄账户', '信用卡'].map((name, index) => ({
|
||||
id: `fixture-${index}`,
|
||||
name,
|
||||
kind: 'account',
|
||||
side: index === 2 ? 'liability' : 'asset',
|
||||
category: index === 2 ? 'credit_card' : 'bank',
|
||||
currency: 'CNY',
|
||||
amount: String((index + 1) * 100),
|
||||
groupName: index === 2 ? '信用账户' : '日常',
|
||||
hidden: false,
|
||||
archived: false,
|
||||
included: true,
|
||||
outgoing: [],
|
||||
notes: '',
|
||||
converted: '100',
|
||||
activityDate: index === 0 ? day : null,
|
||||
}));
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
channel: process.env.WORTHPATH_BROWSER_CHANNEL || 'msedge',
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.route('**/api/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const endpoint = url.pathname.replace('/api', '');
|
||||
if (failNext && endpoint === '/positions') {
|
||||
failNext = false;
|
||||
await route.fulfill({ status: 400, json: { error: '浏览器验收错误' } });
|
||||
return;
|
||||
}
|
||||
let body = {};
|
||||
if (endpoint === '/auth/me') body = user;
|
||||
else if (endpoint === '/settings') body = { ...user, rates: [], fxStatus: null };
|
||||
else if (endpoint === '/overview')
|
||||
body = {
|
||||
date: day,
|
||||
assets: '600',
|
||||
liabilities: '300',
|
||||
net: '300',
|
||||
complete: true,
|
||||
missing: [],
|
||||
items: [],
|
||||
baseCurrency: 'CNY',
|
||||
revealed: false,
|
||||
};
|
||||
else if (endpoint === '/positions')
|
||||
body = url.searchParams.get('kind') === 'account' ? accounts : [];
|
||||
else if (endpoint === '/schedules/run') body = { executed: 0, errors: [], hasMore: false };
|
||||
else if (endpoint === '/schedules') body = [];
|
||||
else if (endpoint === '/admin/users') body = { items: [], total: 0 };
|
||||
else if (endpoint === '/calendar')
|
||||
body = {
|
||||
items: Array.from({ length: 31 }, (_, i) => ({
|
||||
date: day.slice(0, 7) + '-' + String(i + 1).padStart(2, '0'),
|
||||
income: '0',
|
||||
expense: '0',
|
||||
complete: true,
|
||||
})),
|
||||
baseCurrency: 'CNY',
|
||||
};
|
||||
else if (['/trend', '/history', '/transfers', '/calendar/day'].includes(endpoint))
|
||||
body = { items: [], nextCursor: null, revealed: false };
|
||||
await route.fulfill({ json: body });
|
||||
});
|
||||
await page.goto(baseUrl);
|
||||
await page.locator('.primary-nav').waitFor();
|
||||
const nav = page.locator('.primary-nav');
|
||||
assert.deepEqual(await nav.locator('button').allTextContents(), [
|
||||
'总览',
|
||||
'日历',
|
||||
'账号',
|
||||
'设置',
|
||||
]);
|
||||
const checkWidth = async (label) => {
|
||||
await page
|
||||
.locator('[role="status"]')
|
||||
.filter({ hasText: '正在刷新数据' })
|
||||
.waitFor({ state: 'hidden' });
|
||||
const widths = await page.evaluate(() => ({
|
||||
page: document.documentElement.scrollWidth,
|
||||
viewport: innerWidth,
|
||||
}));
|
||||
assert.ok(
|
||||
widths.page <= widths.viewport,
|
||||
`${label} horizontal overflow: ${JSON.stringify(widths)}`,
|
||||
);
|
||||
};
|
||||
await checkWidth('overview');
|
||||
await nav.getByRole('button', { name: '账号', exact: true }).click();
|
||||
await page.getByRole('heading', { name: '日常账户', exact: true }).waitFor();
|
||||
await checkWidth('accounts');
|
||||
await page.screenshot({
|
||||
path: path.join(output, 'worthpath-mobile-accounts.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByRole('button', { name: '快速记账', exact: true }).click();
|
||||
assert.equal(
|
||||
await page.getByRole('button', { name: '标记当天已记账', exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await page.getByRole('button', { name: '隐藏当天有记录', exact: true }).click();
|
||||
assert.equal(await page.getByRole('heading', { name: '日常账户', exact: true }).count(), 0);
|
||||
await page.getByRole('button', { name: '显示当天有记录', exact: true }).click();
|
||||
assert.equal(await page.getByRole('heading', { name: '日常账户', exact: true }).count(), 1);
|
||||
for (const width of [320, 375, 390, 430, 768, 1440]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await checkWidth(`quick ${width}`);
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.screenshot({
|
||||
path: path.join(output, 'worthpath-mobile-quick.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await page
|
||||
.locator('.section-nav')
|
||||
.getByRole('button', { name: '独立资产', exact: true })
|
||||
.click();
|
||||
assert.equal(await nav.locator('[aria-current="page"]').textContent(), '账号');
|
||||
await page.locator('.section-nav').getByRole('button', { name: '债务', exact: true }).click();
|
||||
await checkWidth('debt');
|
||||
await nav.getByRole('button', { name: '设置', exact: true }).click();
|
||||
await page.locator('.settings-category select').selectOption('privacy');
|
||||
await checkWidth('settings');
|
||||
await page.screenshot({
|
||||
path: path.join(output, 'worthpath-mobile-settings.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await page
|
||||
.locator('.section-nav')
|
||||
.getByRole('button', { name: '定时计划', exact: true })
|
||||
.click();
|
||||
await checkWidth('plans');
|
||||
assert.equal(await nav.locator('[aria-current="page"]').textContent(), '设置');
|
||||
await page
|
||||
.locator('.section-nav')
|
||||
.getByRole('button', { name: '管理员后台', exact: true })
|
||||
.click();
|
||||
await checkWidth('admin');
|
||||
await nav.getByRole('button', { name: '日历', exact: true }).click();
|
||||
await page.locator('.calendar-day').first().waitFor();
|
||||
await checkWidth('calendar');
|
||||
await page.screenshot({
|
||||
path: path.join(output, 'worthpath-mobile-calendar.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
assert.equal(
|
||||
await nav
|
||||
.locator('button')
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).transitionDuration),
|
||||
'0s',
|
||||
);
|
||||
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference', colorScheme: 'light' });
|
||||
await nav.getByRole('button', { name: '账号', exact: true }).click();
|
||||
await page.getByRole('button', { name: '退出快速记账', exact: true }).click();
|
||||
await page.getByRole('button', { name: '快速记账', exact: true }).click();
|
||||
await page.locator('.position-card').first().click();
|
||||
const modal = page.locator('dialog.native-modal[open]');
|
||||
await modal.waitFor();
|
||||
assert.equal(await modal.evaluate((el) => el.contains(document.activeElement)), true);
|
||||
await page.keyboard.press('Escape');
|
||||
await modal.waitFor({ state: 'hidden' });
|
||||
assert.equal(await page.evaluate(() => document.documentElement.dataset.input), 'keyboard');
|
||||
assert.equal(
|
||||
await nav
|
||||
.locator('button')
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).transitionDuration),
|
||||
'0s',
|
||||
);
|
||||
const demo = accounts;
|
||||
const worst = accounts.map((account, i) => ({
|
||||
...account,
|
||||
name: [
|
||||
'家庭长期退休储备与跨境投资专用账户(用于记录定期存款、货币基金、证券组合及年度家庭应急备用金,请勿与日常消费账户混用)'
|
||||
.repeat(2)
|
||||
.slice(0, 100),
|
||||
'Christopher Alexander Montgomery III · Hong Kong investment reserve',
|
||||
'J',
|
||||
][i],
|
||||
groupName: ['跨境投资与长期退休储备账户(不参与日常消费流水)', '消费', '信用'][i],
|
||||
amount: ['9999999999999999.98765432', '-42.5', '0'][i],
|
||||
notes:
|
||||
i === 0
|
||||
? 'https://example.com/workspaces/household/accounts/international-reserve?tab=transactions&filter=long-term'
|
||||
: '',
|
||||
}));
|
||||
const fixtures = {
|
||||
Demo: demo,
|
||||
'Worst case': worst,
|
||||
Empty: [],
|
||||
One: [worst[0]],
|
||||
'1,000 rows': Array.from({ length: 1000 }, (_, i) => ({
|
||||
...demo[i % 3],
|
||||
id: 'bulk-' + i,
|
||||
name: '家庭账户 ' + (i + 1),
|
||||
})),
|
||||
};
|
||||
// Dev-only fixture picker: injected into this isolated browser session, never production.
|
||||
await page.exposeFunction('chooseFixture', async (name) => {
|
||||
accounts = fixtures[name];
|
||||
});
|
||||
await page.addInitScript(() =>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'fixture-picker';
|
||||
panel.style.cssText =
|
||||
'position:fixed;bottom:100px;left:50%;transform:translateX(-50%);z-index:9999;background:#ddd;padding:4px;border-radius:8px;display:flex;gap:3px;font:11px system-ui;max-width:95%;';
|
||||
for (const name of ['Demo', 'Worst case', 'Empty', 'One', '1,000 rows']) {
|
||||
const button = document.createElement('button');
|
||||
button.textContent = name;
|
||||
button.style.cssText =
|
||||
'font:inherit;border:0;border-radius:5px;padding:6px;background:white;color:#1d1d1f;';
|
||||
button.onclick = async () => {
|
||||
await window.chooseFixture(name);
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('data', name);
|
||||
location.href = url;
|
||||
};
|
||||
panel.append(button);
|
||||
}
|
||||
document.body.append(panel);
|
||||
}),
|
||||
);
|
||||
for (const [name, values] of Object.entries(fixtures)) {
|
||||
accounts = values;
|
||||
await page.evaluate(() => localStorage.setItem('worthpath.locale', 'zh-Hans'));
|
||||
await page.goto(baseUrl + '/?data=' + encodeURIComponent(name));
|
||||
await page.locator('.primary-nav').waitFor();
|
||||
await page.locator('.primary-nav').getByRole('button', { name: '账号', exact: true }).click();
|
||||
await page.getByRole('button', { name: '快速记账', exact: true }).click();
|
||||
await page
|
||||
.locator('[role="status"]')
|
||||
.filter({ hasText: '正在刷新数据' })
|
||||
.waitFor({ state: 'hidden' });
|
||||
for (const width of [320, 390, 1440]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await checkWidth(name + ' ' + width);
|
||||
}
|
||||
assert.equal(await page.locator('.position-card').count(), values.length);
|
||||
if (name === 'Worst case') {
|
||||
for (const scheme of ['light', 'dark']) {
|
||||
await page.emulateMedia({ colorScheme: scheme });
|
||||
for (const locale of ['zh-Hans', 'zh-Hant', 'en']) {
|
||||
await page.locator('.language-picker select').selectOption(locale);
|
||||
await page.setViewportSize({ width: 320, height: 844 });
|
||||
await checkWidth(scheme + ' ' + locale);
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.fontSize = '200%';
|
||||
});
|
||||
await checkWidth('200% ' + scheme + ' ' + locale);
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.fontSize = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.locator('#fixture-picker').evaluate((el) => {
|
||||
el.style.visibility = 'hidden';
|
||||
});
|
||||
await page.screenshot({
|
||||
path: path.join(output, 'worthpath-mobile-worst-dark.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.locator('#fixture-picker').evaluate((el) => {
|
||||
el.style.visibility = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
'PASS: native dialog focus/Escape, keyboard no motion; plausible schema-limit names and amounts, empty/one/1,000 records, light/dark, three locales, 200% text.',
|
||||
);
|
||||
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Emulation.setEmulatedMedia', {
|
||||
features: [{ name: 'prefers-reduced-transparency', value: 'reduce' }],
|
||||
});
|
||||
assert.equal(
|
||||
await page.locator('.sidebar').evaluate((el) => getComputedStyle(el).backdropFilter),
|
||||
'none',
|
||||
);
|
||||
await cdp.send('Emulation.setEmulatedMedia', {
|
||||
features: [{ name: 'prefers-contrast', value: 'more' }],
|
||||
});
|
||||
assert.equal(
|
||||
await page.locator('.topbar').evaluate((el) => getComputedStyle(el).backdropFilter),
|
||||
'none',
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('html').evaluate((el) => getComputedStyle(el).scrollbarWidth),
|
||||
'auto',
|
||||
);
|
||||
await cdp.send('Emulation.setEmulatedMedia', { features: [] });
|
||||
assert.equal(
|
||||
await page.locator('html').evaluate((el) => getComputedStyle(el).scrollbarWidth),
|
||||
'thin',
|
||||
);
|
||||
failNext = true;
|
||||
await page.locator('.topbar [aria-label="刷新数据"]').click();
|
||||
const toast = page.locator('.toast').last();
|
||||
await toast.waitFor();
|
||||
assert.equal(await toast.evaluate((el) => getComputedStyle(el).animationName), 'none');
|
||||
await toast.getByRole('button').click();
|
||||
await toast.waitFor({ state: 'hidden' });
|
||||
console.log(
|
||||
'PASS: reduced transparency, higher contrast and interruptible toast entry/dismissal.',
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: four entries, child navigation, daily activity filtering, six viewport widths, calendar/settings/admin and reduced motion; zero browser errors. Screenshots use synthetic data.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
要求:
|
||||
|
||||
> * 完成重要任务自行git commit,不需要push。
|
||||
> * 完成一项任务后添加为这项目任务添加删除线,并写上完成时间
|
||||
> * 这份文档可能随时更新,在完成所有任务之后应判断文档是否加入新的更新要求
|
||||
> * 同时需要继续更新docs文件夹或里面的文档
|
||||
> * 数据库的表和字段需要有注释
|
||||
> * 在docs目录不要保存任何图片
|
||||
> * 完成任务后完善本项目的README.md
|
||||
> * 完成任务后主动删除以后不需要的脚本和测试文件
|
||||
> - 完成重要任务自行git commit,不需要push。
|
||||
> - 完成一项任务后添加为这项目任务添加删除线,并写上完成时间
|
||||
> - 这份文档可能随时更新,在完成所有任务之后应判断文档是否加入新的更新要求
|
||||
> - 同时需要继续更新docs文件夹或里面的文档
|
||||
> - 数据库的表和字段需要有注释
|
||||
> - 在docs目录不要保存任何图片
|
||||
> - 完成任务后完善本项目的README.md
|
||||
> - 完成任务后主动删除以后不需要的脚本和测试文件
|
||||
|
||||
更新要求:
|
||||
|
||||
@@ -105,3 +105,8 @@
|
||||
- ~~余额显示绿色,欠款为红色~~ — 已完成并验证:2026-10-05 20:23(UTC+8),见 docs/update-connections-repayment-users-2026-10-05.md
|
||||
- ~~使用OAuth的进行连接时应该可以设置三种权限,但是现在授权页只有一种~~ — 已完成并验证:2026-10-05 21:34(UTC+8),见 docs/update-oauth-permissions-2026-10-05.md
|
||||
- ~~修改 Agent 提示词:连接时检查一次当前权限,权限可能变更,仅在需要时重新检查~~ — 已完成:2026-10-05 21:34(UTC+8),见 docs/update-oauth-permissions-2026-10-05.md
|
||||
- ~~快速记账时当天完成记账标记功能删除,改成可以直接显示或隐藏当天有记账数据,不在主动标记~~ — 已完成并验证:2026-10-07 17:41(UTC+8),见 docs/update-mobile-navigation-2026-10-07.md
|
||||
- ~~快速记账时隐藏和显示按钮有一个眼睛那种图标~~ — 已完成并验证:2026-10-07 17:41(UTC+8),见 docs/update-mobile-navigation-2026-10-07.md
|
||||
- ~~手机端主导航改为总览、日历、账号、设置四项,合并独立资产/债务与定时/管理员入口,并应用开源设计 skill~~ — 已完成并验证:2026-10-07 17:41(UTC+8),见 docs/update-mobile-navigation-2026-10-07.md
|
||||
- ~~优化滚动条UI~~ — 已完成并验证:2026-10-07 18:06(UTC+8),见 docs/update-mobile-navigation-2026-10-07.md
|
||||
- ~~严格按照原作者仓库 apple-design 及适用的其他设计 skill 更新 Apple 风格 UI~~ — 已完成并验证:2026-10-07 18:06(UTC+8),见 docs/update-mobile-navigation-2026-10-07.md
|
||||
Reference in new issue
Block a user