diff --git a/README.md b/README.md index 37329b8..6ef348f 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,8 @@ git config remote.pushDefault github 'github','gitee','wyh','cy' | ForEach-Object { git push $_ main } ``` -账户工具栏、悬浮毛玻璃底栏与紧凑手机日历已更新,金额在日期详情中完整显示;详见 [界面更新与验收](docs/update-glass-calendar-2026-10-08.md)。 +账户工具栏、悬浮毛玻璃底栏与紧凑手机日历已更新,手机格内显示小字号收支金额(大额使用带近似标识的紧凑单位),日期详情显示完整金额;详见 [界面更新与验收](docs/update-glass-calendar-2026-10-08.md)。 Docker 默认使用 Gitea `latest` 镜像;本地构建推送后,服务器拉取并重建即可更新,见 [Gitea 镜像部署](docs/deploy-gitea-latest.md)。 + +手机毛玻璃底栏支持左右拖动高亮选择入口,松手切换,点击与键盘操作仍可用;见 [滑动底栏与日历金额说明](docs/update-swipe-tabbar-2026-10-08.md)。 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6fe75f0..8fe88eb 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,3 +1,4 @@ +import { PrimaryNav } from './PrimaryNav'; import { t as tr, useLocale, LanguagePicker } from './i18n'; import { useState, @@ -1087,25 +1088,16 @@ export default function App() { W WorthPath

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

- + ({ + key, + label: tr(label), + icon: , + select: () => navigateTo(navigationDestination(key, hiddenMenus, user.role)!), + }))} + />
{user.username.slice(0, 1).toUpperCase()}
diff --git a/apps/web/src/Calendar.tsx b/apps/web/src/Calendar.tsx index fcb8f67..f660d5b 100644 --- a/apps/web/src/Calendar.tsx +++ b/apps/web/src/Calendar.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { api, money, today } from './api'; import { useToast } from './Toast'; -import { t as tr } from './i18n'; +import { t as tr, useLocale } from './i18n'; type Day = { date: string; income: string; expense: string; complete: boolean }; type Entry = { id: string; @@ -63,6 +63,18 @@ export function Calendar({ active = false; }; }, [selected, revealed, refreshVersion]); + const locale = useLocale(); + const compactPrice = (amount: string) => { + const value = Number(amount); + return new Intl.NumberFormat( + locale === 'en' ? 'en' : locale === 'zh-Hant' ? 'zh-TW' : 'zh-CN', + { + notation: Math.abs(value) >= 10000 ? 'compact' : 'standard', + maximumFractionDigits: Math.abs(value) >= 10000 ? 1 : 2, + useGrouping: false, + }, + ).format(value); + }; const offset = (new Date(month + '-01T00:00:00Z').getUTCDay() + 6) % 7; const day = days.find((v) => v.date === selected); return ( @@ -101,43 +113,64 @@ export function Calendar({ {tr('支出')}
-
- {['一', '二', '三', '四', '五', '六', '日'].map((v) => ( - - {tr(v)} - - ))} - {Array.from({ length: offset }, (_, i) => ( - - ))} - {days.map((d) => ( - - ))} + ))} + {Array.from({ length: offset }, (_, i) => ( + + ))} + {days.map((d) => ( + + ))} +
diff --git a/apps/web/src/PrimaryNav.tsx b/apps/web/src/PrimaryNav.tsx new file mode 100644 index 0000000..f0862e8 --- /dev/null +++ b/apps/web/src/PrimaryNav.tsx @@ -0,0 +1,140 @@ +import { useLayoutEffect, useRef, type ReactNode } from 'react'; + +type Item = { key: string; label: string; icon: ReactNode; select: () => void }; +// Native pointer tracking: preserve the grab offset and never animate a gesture. +export function PrimaryNav({ + items, + active, + label, +}: { + items: Item[]; + active: string; + label: string; +}) { + const nav = useRef(null); + const lens = useRef(null); + const drag = useRef<{ + id: number; + startX: number; + startY: number; + left: number; + moved: boolean; + } | null>(null); + const suppressClick = useRef(false); + const buttons = () => + Array.from(nav.current?.querySelectorAll('button') || []); + const place = (left: number, width: number, height: number) => { + const el = lens.current; + if (el) { + el.style.transform = 'translateX(' + left + 'px)'; + el.style.width = width + 'px'; + el.style.height = height + 'px'; + } + }; + const reset = () => { + const button = buttons().find((b) => b.dataset.key === active); + if (button) place(button.offsetLeft, button.offsetWidth, button.offsetHeight); + nav.current?.removeAttribute('data-dragging'); + buttons().forEach((b) => b.removeAttribute('data-preview')); + }; + useLayoutEffect(() => { + reset(); + const observer = new ResizeObserver(() => { + if (!drag.current) reset(); + }); + if (nav.current) observer.observe(nav.current); + return () => observer.disconnect(); + }, [active, items.length]); + const finish = (cancelled: boolean) => { + const gesture = drag.current; + drag.current = null; + if (gesture?.moved && !cancelled) { + const preview = buttons().find((b) => b.dataset.preview === 'true'); + const item = items.find((i) => i.key === preview?.dataset.key); + suppressClick.current = true; + item?.select(); + } + reset(); + }; + return ( + + ); +} diff --git a/apps/web/src/style.css b/apps/web/src/style.css index ead4712..4ba7a9b 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -3550,3 +3550,122 @@ html[data-input='pointer'] .calendar-day { .calendar-page-description { display: none; } + +/* A soft glass material and a directly draggable selection lens. */ +.nav-glass-lens { + display: none; +} +@media (max-width: 700px) { + .sidebar { + background: rgb(255 255 255 / 48%); + backdrop-filter: blur(28px) saturate(160%); + -webkit-backdrop-filter: blur(28px) saturate(160%); + border-color: #ffffffaa; + box-shadow: + 0 8px 28px #00000014, + inset 0 1px 1px #ffffffcc; + } + .primary-nav { + position: relative; + touch-action: pan-y; + isolation: isolate; + } + .primary-nav button { + z-index: 1; + touch-action: pan-y; + } + .primary-nav button.active { + background: transparent; + } + .nav-glass-lens { + display: block; + position: absolute; + left: 0; + top: 5px; + border-radius: 28px; + pointer-events: none; + z-index: 0; + background: rgb(0 122 255 / 10%); + border: 1px solid #ffffff88; + box-shadow: + inset 0 1px 1px #ffffff99, + 0 2px 8px #00000008; + } + .primary-nav[data-dragging] button.active { + color: var(--muted); + } + .primary-nav[data-dragging] button[data-preview='true'] { + color: var(--accent); + } +} +@media (max-width: 700px) and (prefers-color-scheme: dark) { + .sidebar { + background: rgb(28 28 30 / 58%); + border-color: #ffffff26; + box-shadow: + 0 8px 28px #00000055, + inset 0 1px 1px #ffffff1a; + } + .nav-glass-lens { + background: rgb(10 132 255 / 18%); + border-color: #ffffff22; + box-shadow: inset 0 1px 1px #ffffff22; + } +} +@media (max-width: 700px) and (prefers-reduced-transparency: reduce), + (max-width: 700px) and (prefers-contrast: more) { + .sidebar { + background: var(--surface); + backdrop-filter: none; + -webkit-backdrop-filter: none; + border-color: var(--line); + } + .nav-glass-lens { + background: var(--active); + border-color: var(--accent); + } +} +@media (max-width: 700px) and (forced-colors: active) { + .sidebar { + background: Canvas; + } + .nav-glass-lens { + background: Canvas; + border: 2px solid Highlight; + } +} + +.calendar-mobile-price { + display: none; +} +@media (max-width: 700px) { + .calendar-day { + min-height: 76px; + gap: 2px; + } + .calendar-dots { + display: none; + } + .calendar-mobile-price { + display: block; + font-size: clamp(0.5rem, 2.3vw, 0.625rem); + line-height: 1.45; + letter-spacing: -0.03em; + white-space: nowrap; + overflow-wrap: normal; + font-variant-numeric: tabular-nums; + } + .calendar-day.selected .calendar-mobile-price { + color: white !important; + } +} + +.calendar-scroll { + overflow-x: auto; + overscroll-behavior-x: contain; +} +@media (max-width: 700px) { + .calendar-grid { + grid-template-columns: repeat(7, minmax(max-content, 1fr)); + } +} diff --git a/docs/update-swipe-tabbar-2026-10-08.md b/docs/update-swipe-tabbar-2026-10-08.md new file mode 100644 index 0000000..f365dbb --- /dev/null +++ b/docs/update-swipe-tabbar-2026-10-08.md @@ -0,0 +1,17 @@ +# 滑动毛玻璃底栏与手机日历金额 + +完成时间:2026-10-08 14:15(UTC+8)。继续按 apple-design、mobile-native、emil-design-eng 和 review-animations 的适用规范实施,来源见 [规范说明](update-mobile-navigation-2026-10-07.md)。 + +| Before | After | Why | +| ------------------------ | ------------------------------------------------------------------------------------- | ---------------------------- | +| 底栏背景偏实、只支持点击 | 浅/深色半透明材料、28px 模糊、柔和边缘与阴影;玻璃选中胶囊可拖动 | 更柔和的层次与直接操控 | +| 底栏手势无响应 | 水平移动超过 8px 后捕获指针,高亮保留抓取偏移并 1:1 跟随,松手选择最近入口 | 避免误触,支持途中反向和取消 | +| 手机日历只显示收支小点 | 格内恢复小字号收入/支出,普通金额最多两位小数,大额使用近似紧凑单位,详情保留完整金额 | 不点开也能看到收支规模 | + +PrimaryNav 复用原有四入口、隐藏菜单和权限判断。纵向手势保留浏览器滚动;触摸取消恢复当前高亮;点击、键盘以及桌面导航继续可用。减少透明度/高对比度回退实色。高频导航不添加弹跳或固定时长过渡;手势中直接更新 transform,松手即时归位,无输入锁定或依赖新增。 + +格内大额使用“≈”提示近似,例如 160501.27 显示约 16.1 万;币种和完整数值保留在日期详情及无障碍标签中,英文使用本地化紧凑单位。缺少汇率显示警示符号。极大金额或放大文字时日历允许内部横向滚动,避免数字重叠或裁掉金额;页面整体保持宽度。 + +验收:TypeScript、前端生产构建通过;浏览器脚本加入真实 touchStart/touchMove/touchEnd 双向切换及 touchCancel 取消验证。保留四入口点击、手机格内金额与完整详情、大额收支、六种窗口宽度、浅色/深色、200% 字体、减少透明度、高对比度、键盘与模态回归,无浏览器错误。 + +review-animations:**Approve(代码与浏览器)**。手势跟随无过渡,高频页面切换即时完成。实体 iPhone Safari 的材质、触摸与安全区尚未验证。截图不写入 docs;保留可复用回归脚本,无新增临时脚本。本地提交,不推送或部署。 diff --git a/scripts/check-mobile-ui.cjs b/scripts/check-mobile-ui.cjs index 37b1940..f446b7c 100644 --- a/scripts/check-mobile-ui.cjs +++ b/scripts/check-mobile-ui.cjs @@ -118,6 +118,52 @@ let accounts = ['日常账户', '储蓄账户', '信用卡'].map((name, index) = ); }; await checkWidth('overview'); + const touch = await page.context().newCDPSession(page); + const tabCenters = await nav.locator('button').evaluateAll((bs) => + bs.map((b) => { + const r = b.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }), + ); + const swipe = async (from, to) => { + await touch.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ ...from, id: 1 }], + }); + for (let i = 1; i <= 8; i++) + await touch.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x: from.x + ((to.x - from.x) * i) / 8, y: from.y, id: 1 }], + }); + assert.equal(await nav.getAttribute('data-dragging'), 'true'); + await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + }; + await swipe(tabCenters[0], tabCenters[1]); + await page.waitForFunction( + () => + document.querySelector('.primary-nav [aria-current="page"]')?.getAttribute('data-key') === + 'calendar', + ); + await swipe(tabCenters[1], tabCenters[0]); + await page.waitForFunction( + () => + document.querySelector('.primary-nav [aria-current="page"]')?.getAttribute('data-key') === + 'overview', + ); + await touch.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ ...tabCenters[0], id: 1 }], + }); + await touch.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ ...tabCenters[2], id: 1 }], + }); + await touch.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] }); + assert.equal(await nav.locator('[aria-current="page"]').getAttribute('data-key'), 'overview'); + assert.equal(await nav.getAttribute('data-dragging'), null); + console.log( + 'PASS: real touch swipe both directions, direct selection tracking and cancellation.', + ); await nav.getByRole('button', { name: '账号', exact: true }).click(); await page.getByRole('heading', { name: '日常账户', exact: true }).waitFor(); await checkWidth('accounts'); @@ -194,6 +240,10 @@ let accounts = ['日常账户', '储蓄账户', '信用卡'].map((name, index) = await activeDay.click(); assert.ok((await page.locator('.calendar-totals').textContent()).includes('160,501.27')); assert.equal(await activeDay.locator('.calendar-amount').first().isVisible(), false); + assert.equal(await activeDay.locator('.calendar-mobile-price').first().isVisible(), true); + assert.ok( + (await activeDay.locator('.calendar-mobile-price').first().textContent()).includes('16.1'), + ); assert.equal( await page .locator('.sidebar') diff --git a/update.md b/update.md index db613a7..6c35aae 100644 --- a/update.md +++ b/update.md @@ -114,3 +114,5 @@ - ~~优化账户页工具栏 UI、手机底栏采用悬浮毛玻璃效果、改善手机日历显示~~ — 已完成并验证:2026-10-08 13:02(UTC+8),见 docs/update-glass-calendar-2026-10-08.md - ~~Docker Compose 改用 Gitea latest 镜像,更新发布与服务器拉取说明~~ — 已完成配置与文档:2026-10-08 13:28(UTC+8);尚未实际构建、推送或部署,见 docs/deploy-gitea-latest.md + +- ~~手机底栏优化 Apple 毛玻璃效果并支持滑动选择;手机日历格内恢复小字号收支金额~~ — 已完成并验证:2026-10-08 14:15(UTC+8),见 docs/update-swipe-tabbar-2026-10-08.md