feat: add draggable glass tab bar and mobile calendar prices

This commit is contained in:
陈煜 committed 2026-10-08 14:14:07 +08:00
1 parent 40b8d4dc99
commit f101415722
8 files changed
+377 -22

No files matched your search

+3 -1
View File
@@ -243,6 +243,8 @@ git config remote.pushDefault github
'github','gitee','wyh','cy' | ForEach-Object { git push $_ main } '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)。 Docker 默认使用 Gitea `latest` 镜像;本地构建推送后,服务器拉取并重建即可更新,见 [Gitea 镜像部署](docs/deploy-gitea-latest.md)。
手机毛玻璃底栏支持左右拖动高亮选择入口,松手切换,点击与键盘操作仍可用;见 [滑动底栏与日历金额说明](docs/update-swipe-tabbar-2026-10-08.md)。
+11 -19
View File
@@ -1,3 +1,4 @@
import { PrimaryNav } from './PrimaryNav';
import { t as tr, useLocale, LanguagePicker } from './i18n'; import { t as tr, useLocale, LanguagePicker } from './i18n';
import { import {
useState, useState,
@@ -1087,25 +1088,16 @@ export default function App() {
<span>W</span> WorthPath <span>W</span> WorthPath
</div> </div>
<p className="nav-caption">{tr('我的资产空间')}</p> <p className="nav-caption">{tr('我的资产空间')}</p>
<nav <PrimaryNav
className="primary-nav" active={activeGroup}
aria-label={tr('主导航')} label={tr('主导航')}
style={{ gridTemplateColumns: `repeat(${visibleNav.length}, minmax(0, 1fr))` }} items={visibleNav.map(([key, Icon, label]) => ({
> key,
{visibleNav.map(([key, Icon, label]) => ( label: tr(label),
<button icon: <Icon size={19} />,
key={key} select: () => navigateTo(navigationDestination(key, hiddenMenus, user.role)!),
className={activeGroup === key ? 'active' : ''} }))}
aria-current={activeGroup === key ? 'page' : undefined} />
onClick={() => {
navigateTo(navigationDestination(key, hiddenMenus, user.role)!);
}}
>
<Icon size={19} />
<span>{tr(label)}</span>
</button>
))}
</nav>
<div className="side-bottom"> <div className="side-bottom">
<div className="avatar">{user.username.slice(0, 1).toUpperCase()}</div> <div className="avatar">{user.username.slice(0, 1).toUpperCase()}</div>
<div> <div>
+35 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api, money, today } from './api'; import { api, money, today } from './api';
import { useToast } from './Toast'; 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 Day = { date: string; income: string; expense: string; complete: boolean };
type Entry = { type Entry = {
id: string; id: string;
@@ -63,6 +63,18 @@ export function Calendar({
active = false; active = false;
}; };
}, [selected, revealed, refreshVersion]); }, [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 offset = (new Date(month + '-01T00:00:00Z').getUTCDay() + 6) % 7;
const day = days.find((v) => v.date === selected); const day = days.find((v) => v.date === selected);
return ( return (
@@ -101,6 +113,7 @@ export function Calendar({
{tr('支出')} {tr('支出')}
</span> </span>
</div> </div>
<div className="calendar-scroll">
<div className="calendar-grid"> <div className="calendar-grid">
{['一', '二', '三', '四', '五', '六', '日'].map((v) => ( {['一', '二', '三', '四', '五', '六', '日'].map((v) => (
<span key={v} className="weekday"> <span key={v} className="weekday">
@@ -125,20 +138,40 @@ export function Calendar({
> >
<strong>{Number(d.date.slice(-2))}</strong> <strong>{Number(d.date.slice(-2))}</strong>
{Number(d.income) > 0 && ( {Number(d.income) > 0 && (
<>
<small className="calendar-amount income-text">+{money(d.income, base)}</small> <small className="calendar-amount income-text">+{money(d.income, base)}</small>
<small className="calendar-mobile-price income-text" aria-hidden="true">
+{Number(d.income) >= 10000 ? '≈' : ''}
{compactPrice(d.income)}
</small>
</>
)} )}
{Number(d.expense) > 0 && ( {Number(d.expense) > 0 && (
<>
<small className="calendar-amount danger-text">−{money(d.expense, base)}</small> <small className="calendar-amount danger-text">−{money(d.expense, base)}</small>
<small className="calendar-mobile-price danger-text" aria-hidden="true">
−{Number(d.expense) >= 10000 ? '≈' : ''}
{compactPrice(d.expense)}
</small>
</>
)} )}
<span className="calendar-dots" aria-hidden="true"> <span className="calendar-dots" aria-hidden="true">
{Number(d.income) > 0 && <i className="income-dot" />} {Number(d.income) > 0 && <i className="income-dot" />}
{Number(d.expense) > 0 && <i className="expense-dot" />} {Number(d.expense) > 0 && <i className="expense-dot" />}
{!d.complete && <i className="incomplete-dot" />} {!d.complete && <i className="incomplete-dot" />}
</span> </span>
{!d.complete && <small className="calendar-amount">{tr('缺少汇率')}</small>} {!d.complete && (
<>
<small className="calendar-amount">{tr('缺少汇率')}</small>
<small className="calendar-mobile-price" aria-hidden="true">
!
</small>
</>
)}
</button> </button>
))} ))}
</div> </div>
</div>
<div className="calendar-detail"> <div className="calendar-detail">
<div className="panel-title"> <div className="panel-title">
<h3> <h3>
+140
View File
@@ -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<HTMLElement>(null);
const lens = useRef<HTMLSpanElement>(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<HTMLButtonElement>('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 (
<nav
ref={nav}
className="primary-nav"
aria-label={label}
style={{ gridTemplateColumns: 'repeat(' + items.length + ', minmax(0, 1fr))' }}
onPointerDown={(e) => {
if (!e.isPrimary || e.button !== 0 || !matchMedia('(max-width: 700px)').matches) return;
suppressClick.current = false;
const current = buttons().find((b) => b.dataset.key === active);
if (current)
drag.current = {
id: e.pointerId,
startX: e.clientX,
startY: e.clientY,
left: current.offsetLeft,
moved: false,
};
}}
onPointerMove={(e) => {
const g = drag.current,
root = nav.current;
if (!g || g.id !== e.pointerId || !root) return;
const dx = e.clientX - g.startX;
if (!g.moved) {
if (Math.abs(e.clientY - g.startY) > Math.abs(dx) && Math.abs(e.clientY - g.startY) > 8) {
finish(true);
return;
}
if (Math.abs(dx) < 8) return;
g.moved = true;
root.setPointerCapture(e.pointerId);
root.dataset.dragging = 'true';
}
const all = buttons(),
first = all[0],
last = all[all.length - 1];
if (!first || !last) return;
const left = Math.max(first.offsetLeft, Math.min(last.offsetLeft, g.left + dx));
place(left, first.offsetWidth, first.offsetHeight);
const center = left + first.offsetWidth / 2;
const closest = all.reduce((best, b) =>
Math.abs(b.offsetLeft + b.offsetWidth / 2 - center) <
Math.abs(best.offsetLeft + best.offsetWidth / 2 - center)
? b
: best,
);
all.forEach((b) => {
b.dataset.preview = String(b === closest);
});
}}
onPointerUp={(e) => {
if (drag.current?.id === e.pointerId) finish(false);
}}
onPointerCancel={() => finish(true)}
onLostPointerCapture={(e) => {
if (e.target === e.currentTarget && drag.current) finish(true);
}}
onClickCapture={(e) => {
if (suppressClick.current && e.detail > 0) {
e.preventDefault();
e.stopPropagation();
suppressClick.current = false;
}
}}
>
<span ref={lens} className="nav-glass-lens" aria-hidden="true" />
{items.map((item) => (
<button
key={item.key}
data-key={item.key}
className={active === item.key ? 'active' : ''}
aria-current={active === item.key ? 'page' : undefined}
onClick={item.select}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</nav>
);
}
+119
View File
@@ -3550,3 +3550,122 @@ html[data-input='pointer'] .calendar-day {
.calendar-page-description { .calendar-page-description {
display: none; 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));
}
}
+17
View File
@@ -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;保留可复用回归脚本,无新增临时脚本。本地提交,不推送或部署。
+50
View File
@@ -118,6 +118,52 @@ let accounts = ['日常账户', '储蓄账户', '信用卡'].map((name, index) =
); );
}; };
await checkWidth('overview'); 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 nav.getByRole('button', { name: '账号', exact: true }).click();
await page.getByRole('heading', { name: '日常账户', exact: true }).waitFor(); await page.getByRole('heading', { name: '日常账户', exact: true }).waitFor();
await checkWidth('accounts'); await checkWidth('accounts');
@@ -194,6 +240,10 @@ let accounts = ['日常账户', '储蓄账户', '信用卡'].map((name, index) =
await activeDay.click(); await activeDay.click();
assert.ok((await page.locator('.calendar-totals').textContent()).includes('160,501.27')); 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-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( assert.equal(
await page await page
.locator('.sidebar') .locator('.sidebar')
+2
View File
@@ -114,3 +114,5 @@
- ~~优化账户页工具栏 UI、手机底栏采用悬浮毛玻璃效果、改善手机日历显示~~ — 已完成并验证:2026-10-08 13:02(UTC+8),见 docs/update-glass-calendar-2026-10-08.md - ~~优化账户页工具栏 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 - ~~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