diff --git a/README.md b/README.md index d750fcb..c9fa153 100644 --- a/README.md +++ b/README.md @@ -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)。 diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 4498765..410adb0 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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, diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index 2c69981..ad3189b 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -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(); 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, diff --git a/apps/api/src/queries.ts b/apps/api/src/queries.ts index 0d06f80..7e5e238 100644 --- a/apps/api/src/queries.ts +++ b/apps/api/src/queries.ts @@ -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, diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index ed7094c..ae1d433 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -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(), diff --git a/apps/api/test/quick-entry-integration.test.ts b/apps/api/test/quick-entry-integration.test.ts index 678f866..7b8c0c6 100644 --- a/apps/api/test/quick-entry-integration.test.ts +++ b/apps/api/test/quick-entry-integration.test.ts @@ -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); diff --git a/apps/web/index.html b/apps/web/index.html index 5340c21..24230da 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,13 @@ - + + + + WorthPath · 资产之路 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 06fc125..775163d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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(null); + useEffect(() => { + const element = dialog.current; + element?.showModal(); + return () => element?.close(); + }, []); return ( -
{ + event.preventDefault(); + close(); + }} + className="veil native-modal" onClick={(e) => { if (e.target === e.currentTarget) close(); }} > -
+

{title} @@ -158,7 +181,7 @@ function Modal({

{children}
-
+ ); } 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 | null>(null); @@ -209,7 +249,6 @@ export default function App() { } | null>(null); const [quickEntries, setQuickEntries] = useState([]); 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() { W WorthPath

{tr('我的资产空间')}

- + )}
{user.revealed @@ -1151,6 +1192,11 @@ export default function App() { : setModal({ kind: 'reveal' }) } > + {user.revealed ? ( +