feat: add draggable glass tab bar and mobile calendar prices
This commit is contained in:
1 parent
40b8d4dc99
commit
f101415722
8 files changed
+377
-22
No files matched your search
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;保留可复用回归脚本,无新增临时脚本。本地提交,不推送或部署。
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user