+
+ {['一', '二', '三', '四', '五', '六', '日'].map((v) => (
+
+ {tr(v)}
- {!d.complete && {tr('缺少汇率')}}
-
- ))}
+ ))}
+ {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