fix: 返回键失效的根因 + 同步页重做 + 连接池会永久阻塞线程

返回键
- 根因是 Framework7 的页面过渡由动画事件驱动:push 时把 allowPageChange
  置 false,等动画报告结束再恢复。这个报告不来,路由就永久卡住,之后每次
  导航都被静默丢弃,back() 还会把上一页重建一份而不是弹出。
  实测对照:navigate({animate:false}) 前后状态完全正确,带动画则必卡。
  因此关掉页面过渡动画——导航同步完成,处处正确。动效改由内容承担
  (卡片入场、hero 揭示、顶部进度条),这个取舍里正确性优先。
- Screen 的返回改为显式 handler,先清掉残留过渡状态再 back(),
  不依赖路由自己的闸门。注意只清视图上的 router-transition 类:
  页面自身的 page-previous 是 F7 判断「回到哪一页」的依据,
  一并清掉会导致重建出一个重复的页面(中途踩过这个坑)。

同步页
- .btn 系列样式原本只定义在 pages/Pages.css,而那个文件只被一个没有路由的
  遗留页面引用,所以真实页面上按钮全都退化成 Framework7 的默认样式——
  就是你看到的三条链接。样式移进每个界面都会加载的 Screen.css。
- 主次分明:一个填充主按钮 + 两个带副标题的次按钮;补上「同步会取哪些数据」
  说明,页面不再是一大片空白。
- 进度条显示当前阶段(每日数据 2026-08-01 / 运动详情 12/174 / 身体成分…),
  原来只有「0 / 730 天」,几分钟里完全看不出在做什么。

后端
- MariaDB 连接池:_mariadb_release 用的是阻塞 put(),而队列 maxsize=10,
  _mariadb_acquire 在池空时又会新建连接。并发超过 10 之后,归还的线程会
  永久停在 put() 上,请求就此挂死。改为 put_nowait,多出来的连接直接关闭。
- 进程重启会带走同步线程却留下 status=syncing 的行,界面上是一个永远不动
  的进度条,还拒绝开始新同步。启动时清理。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-24 08:41:48 +08:00
parent 67a7c4b87e
commit 6ad87115ab
11 changed files with 250 additions and 786 deletions

View File

@@ -13,6 +13,7 @@ import db
from config import CORS_ORIGINS, PORT, STATIC_DIR
from routes import auth, garmin, health, analysis, settings
from services import scheduler
from services import garmin as garmin_svc
def create_app():
@@ -22,6 +23,10 @@ def create_app():
# Create tables once at startup (idempotent).
db.init_db()
# A sync that was running when the previous process stopped left its
# status behind; clear it before anything reads it.
garmin_svc.reset_stale_syncs()
# Keeps the database current without the user pressing anything. Safe to
# call in every worker: the job is claimed through the database, so only
# one of them actually runs a given tick.

View File

@@ -336,14 +336,31 @@ def _mariadb_acquire():
def _mariadb_release(conn):
"""Return a connection to the pool, or close it if the pool is full.
`put()` blocks when the queue is at maxsize — and the queue can be full,
because `_mariadb_acquire` opens an extra connection whenever the pool is
empty rather than waiting. Under enough concurrency (a long sync plus
ordinary requests) a thread would park here forever holding its request
open. `put_nowait` plus closing the surplus keeps the pool bounded and the
thread free.
"""
try:
conn.ping(reconnect=False)
_mariadb_pool.put(conn)
except Exception:
try:
conn.close()
except Exception:
pass
return
try:
_mariadb_pool.put_nowait(conn)
except queue.Full:
try:
conn.close()
except Exception:
pass
# --- SQLite connection ------------------------------------------------------
@@ -401,6 +418,9 @@ MIGRATIONS = {
("progress_current", "INT"),
("progress_total", "INT"),
("started_at", "DATETIME"),
# Which part of the sync is running. "0 / 730 天" says nothing about
# what is actually happening for the several minutes of it.
("stage", "VARCHAR(64)"),
],
"health_data": [
# activity / energy

View File

@@ -68,9 +68,23 @@ def get_sync_status(user_id):
"progressCurrent": row.get("progress_current"),
"progressTotal": row.get("progress_total"),
"startedAt": row.get("started_at"),
"stage": row.get("stage"),
}
def reset_stale_syncs():
"""Clear a "syncing" status left behind by a process that went away.
The status lives in the database but the work lives in a thread. A restart
(or a crash) takes the thread and leaves the row, so the UI shows a
progress bar that will never move and refuses to start a new sync.
"""
execute(
"UPDATE sync_status SET status = 'idle', stage = NULL "
"WHERE status = 'syncing'"
)
class MFARequired(RuntimeError):
"""Raised when a password login needs a code this process cannot obtain."""
@@ -761,6 +775,7 @@ def sync_data(user_id, creds, days=None, client=None):
_set_sync_status(
user_id, "syncing", now,
records_synced=0, progress_current=0, progress_total=days,
stage="连接 Garmin",
)
try:
@@ -809,10 +824,13 @@ def sync_data(user_id, creds, days=None, client=None):
_set_sync_status(
user_id, "syncing", now,
records_synced=days_synced, progress_current=i + 1,
progress_total=days,
progress_total=days, stage=f"每日数据 {date_str}",
)
activities_synced = 0
_set_sync_status(user_id, "syncing", now, records_synced=days_synced,
progress_current=days, progress_total=days,
stage="运动记录")
try:
activities_synced = _sync_activities(
client, user_id, start_date, today.isoformat()
@@ -824,13 +842,23 @@ def sync_data(user_id, creds, days=None, client=None):
# opening one later is a local read.
details_synced = 0
try:
details_synced = sync_activity_details(client, user_id)
details_synced = sync_activity_details(
client, user_id,
on_progress=lambda d, n: _set_sync_status(
user_id, "syncing", now, records_synced=days_synced,
progress_current=days, progress_total=days,
stage=f"运动详情 {d}/{n}"),
)
except Exception as e:
day_errors.append(f"activity_details: {describe(e)}")
# Everything else Garmin holds: body composition, blood pressure, race
# predictions, challenges and devices. Account-wide, so once per sync.
extra_counts = {}
stage_names = {
"bodyComposition": "身体成分", "bloodPressure": "血压",
"racePredictions": "成绩预测", "challenges": "挑战赛", "devices": "设备",
}
for name, call in (
("bodyComposition",
lambda: extras.sync_body_composition(client, user_id, start_date,
@@ -843,6 +871,9 @@ def sync_data(user_id, creds, days=None, client=None):
("challenges", lambda: extras.sync_challenges(client, user_id)),
("devices", lambda: extras.sync_devices(client, user_id)),
):
_set_sync_status(user_id, "syncing", now, records_synced=days_synced,
progress_current=days, progress_total=days,
stage=stage_names.get(name, name))
try:
extra_counts[name] = call()
except Exception as e: # noqa: BLE001 - one section must not fail the sync
@@ -871,7 +902,7 @@ def sync_data(user_id, creds, days=None, client=None):
_set_sync_status(
user_id, "idle", now, records_synced=days_synced,
progress_current=days, progress_total=days,
progress_current=days, progress_total=days, stage=None,
last_error="; ".join(day_errors[:3]) if day_errors else None,
)
message = (

View File

@@ -184,14 +184,15 @@ function useRouterWatchdog() {
if (!since) { stuckSince.set(el, Date.now()); return; }
if (Date.now() - since < STUCK_AFTER_MS) return;
// Clear the leftovers F7 would have cleared itself.
// Only the view's transition flags. A page's own page-previous /
// page-next class is how F7 identifies the page to go back to;
// removing those makes back() rebuild the screen and stack a
// duplicate rather than popping.
el.classList.remove(
'router-transition', 'router-transition-forward',
'router-transition-backward', 'router-transition-css-forward',
'router-transition-css-backward'
);
el.querySelectorAll('.page').forEach((page) =>
page.classList.remove('page-next', 'page-previous'));
}
stuckSince.delete(el);
@@ -264,6 +265,18 @@ function App() {
// `url`, so all five loaded the root page.
browserHistory={tab.id === 'today'}
browserHistorySeparator=""
/* Framework7's page transition is animation-driven: the router
sets allowPageChange = false on push and restores it when the
animation reports it ended. When that report never arrives the
router stays blocked and every later navigation — including
every back button — is silently dropped, and back() rebuilds
the previous screen instead of popping it.
Turning the transition off makes each page change complete
synchronously, which is correct everywhere. The motion comes
from the content instead: cards and heroes animate in, and the
route progress bar covers the load. */
animate={false}
/>
))}
</Views>

View File

@@ -1,3 +1,47 @@
/* Buttons -----------------------------------------------------------------
These lived in pages/Pages.css, which only one unrouted leftover page
imported — so on every real screen the .btn classes resolved to nothing and
Framework7's default button styling showed through instead. They belong
here, in the stylesheet every screen loads. */
.btn {
padding: 0.7rem 1.1rem;
border: 1px solid transparent;
border-radius: 11px;
font-size: 0.92rem;
font-weight: 550;
font-family: inherit;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
width: auto;
transition: transform 0.15s var(--ease), filter 0.15s var(--ease),
border-color 0.15s var(--ease), background 0.15s var(--ease);
}
.btn:active:not(:disabled) { transform: scale(0.975); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--accent-solid); color: #fff; }
.btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
.btn-plain {
background: var(--surface-1);
border-color: var(--border);
color: var(--text-primary);
}
.btn-plain:hover:not(:disabled) { border-color: var(--border-strong); }
.btn-large { width: 100%; padding: 0.85rem 1.5rem; font-size: 1rem; }
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
.btn:active:not(:disabled) { transform: none; }
}
/* Sections ---------------------------------------------------------------- */
.sec {
margin-bottom: 1.5rem;

View File

@@ -1,5 +1,5 @@
import { ReactNode, useEffect, useState } from 'react';
import { Page, Navbar, NavRight, f7 } from 'framework7-react';
import { Page, Navbar, NavLeft, NavRight, Link, f7 } from 'framework7-react';
import { apiClient } from '../services/api';
import './Screen.css';
@@ -16,6 +16,34 @@ interface ScreenProps {
children: ReactNode;
}
/**
* Go back without depending on the router's own guard.
*
* Framework7 sets `router.allowPageChange = false` for the duration of a page
* transition and restores it when the transition reports it has ended. When
* that report never arrives the flag stays false and `router.back()` becomes a
* no-op — a back button that does nothing, with a reload as the only way out.
* Clearing the leftover transition state first makes the button work whether
* or not the router got stuck.
*/
function goBack() {
const router = f7.views.current?.router;
if (!router) return;
// Only the view's transition flags are cleared. The pages' own
// page-previous / page-next classes are how F7 knows which page to return
// to — stripping those makes `back()` rebuild the previous screen from
// scratch and stack a duplicate instead of popping.
const el = router.view?.el as HTMLElement | undefined;
el?.classList.remove(
'router-transition', 'router-transition-forward',
'router-transition-backward', 'router-transition-css-forward',
'router-transition-css-backward'
);
router.allowPageChange = true;
router.back();
}
/**
* Common page chrome.
*
@@ -44,7 +72,18 @@ function Screen({
return (
<Page>
<Navbar large={large} transparent={large} title={title} subtitle={subtitle} backLink={backLink ? '返回' : undefined}>
<Navbar large={large} transparent={large} title={title} subtitle={subtitle}>
{backLink && (
<NavLeft>
<Link
className="back-link"
onClick={goBack}
iconIos="f7:chevron_left"
iconMd="f7:chevron_left"
aria-label="返回"
/>
</NavLeft>
)}
{/* Rendered only when the screen actually has an action: an empty
NavRight still paints a container, which showed up as a stray
grey box beside the title. */}

View File

@@ -1,215 +0,0 @@
import { useEffect, useState } from 'react';
import {
apiClient, Activity, Badge, errorMessage, PersonalRecord,
} from '../services/api';
import StatTile from '../components/charts/StatTile';
import Skeleton from '../components/Skeleton';
import './Pages.css';
type Tab = 'badges' | 'records' | 'activities';
const ACTIVITY_LABEL: Record<string, string> = {
running: '跑步',
cycling: '骑行',
walking: '步行',
hiking: '徒步',
swimming: '游泳',
table_tennis: '乒乓球',
strength_training: '力量训练',
indoor_cycling: '室内骑行',
treadmill_running: '跑步机',
fitness_equipment: '健身器械',
};
const label = (key: string | null) =>
key ? ACTIVITY_LABEL[key] ?? key.replace(/_/g, ' ') : '—';
const date = (value: string | null) => (value ? value.slice(0, 10) : '—');
function Achievements() {
const [tab, setTab] = useState<Tab>('badges');
const [badges, setBadges] = useState<Badge[]>([]);
const [records, setRecords] = useState<PersonalRecord[]>([]);
const [activities, setActivities] = useState<Activity[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
const [b, r, a] = await Promise.all([
apiClient.getBadges(),
apiClient.getPersonalRecords(),
apiClient.getActivities(),
]);
setBadges(b);
setRecords(r);
setActivities(a);
} catch (err: any) {
setError(errorMessage(err, '加载失败'));
} finally {
setLoading(false);
}
};
load();
}, []);
if (loading) {
return (
<div className="page">
<h2></h2>
<Skeleton count={4} />
</div>
);
}
// Badges cluster heavily by year, which is the only grouping that reads.
const byYear = badges.reduce<Record<string, Badge[]>>((acc, b) => {
const year = b.earned_date ? b.earned_date.slice(0, 4) : '未知';
(acc[year] ||= []).push(b);
return acc;
}, {});
const years = Object.keys(byYear).sort().reverse();
const totalPoints = badges.reduce((sum, b) => sum + (b.points ?? 0), 0);
return (
<div className="page">
<header className="page-head">
<div>
<h2></h2>
<p className="subtitle"></p>
</div>
</header>
{error && <div className="error-message">{error}</div>}
<div className="tile-grid" style={{ marginBottom: '1.5rem' }}>
<StatTile label="奖励徽章" value={badges.length} unit="个" />
<StatTile
label="徽章积分"
value={totalPoints || null}
detail={totalPoints ? undefined : '该账号未记录积分'}
/>
<StatTile label="个人纪录" value={records.length} unit="项" />
<StatTile label="运动记录" value={activities.length} unit="条" />
</div>
<div className="metric-tabs">
{([
['badges', `奖励 (${badges.length})`],
['records', `个人纪录 (${records.length})`],
['activities', `运动 (${activities.length})`],
] as Array<[Tab, string]>).map(([id, text]) => (
<button
key={id}
className={`metric-tab ${tab === id ? 'active' : ''}`}
onClick={() => setTab(id)}
>
{text}
</button>
))}
</div>
{tab === 'badges' && (
badges.length === 0 ? (
<p className="placeholder"></p>
) : (
years.map((year) => (
<section className="section" key={year}>
<h3 className="section-title">
{year === '未知' ? '未知年份' : `${year}`}
<span className="section-count">{byYear[year].length} </span>
</h3>
<div className="badge-grid">
{byYear[year].map((b) => (
<div className="badge" key={b.id}>
<div className="badge-name">{b.name || b.badge_key}</div>
<div className="badge-meta">
{date(b.earned_date)}
{b.earned_count && b.earned_count > 1 && (
<span className="badge-count">×{b.earned_count}</span>
)}
</div>
</div>
))}
</div>
</section>
))
)
)}
{tab === 'records' && (
records.length === 0 ? (
<p className="placeholder"></p>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id}>
<th scope="row">{r.activity_name || '—'}</th>
<td>{label(r.activity_type)}</td>
<td className="num">
{r.value != null ? r.value.toLocaleString(undefined, {
maximumFractionDigits: 2,
}) : '—'}
</td>
<td>{date(r.achieved_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
{tab === 'activities' && (
activities.length === 0 ? (
<p className="placeholder"></p>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{activities.map((a) => (
<tr key={a.id}>
<th scope="row">{a.start_time?.slice(0, 16).replace('T', ' ')}</th>
<td>{label(a.activity_type)}</td>
<td className="num">
{a.duration != null ? `${Math.round(a.duration / 60)}` : '—'}
</td>
<td className="num">
{a.distance ? `${(a.distance / 1000).toFixed(2)} km` : '—'}
</td>
<td className="num">{a.calories != null ? `${Math.round(a.calories)}` : '—'}</td>
<td className="num">{a.heart_rate_average ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</div>
);
}
export default Achievements;

View File

@@ -238,3 +238,46 @@
color: var(--status-critical);
word-break: break-word;
}
/* Sync actions ------------------------------------------------------------- */
.sync-primary { margin-bottom: 0.55rem; }
.sync-secondary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr));
gap: 0.55rem;
margin-bottom: 1rem;
}
/* The secondary buttons carry a second line, so they are stacked rather than
the single-line row .btn assumes. */
.sync-secondary .btn {
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
padding: 0.7rem 0.9rem;
text-align: left;
}
.sync-btn-label { font-size: 0.9rem; font-weight: 550; color: var(--text-primary); }
.sync-btn-sub { font-size: 0.73rem; color: var(--text-muted); font-weight: 400; }
.sync-note {
margin-top: 1.4rem;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem 1.1rem;
}
.sync-note .sec-title { margin-bottom: 0.6rem; }
.sync-list {
margin: 0 0 0.8rem;
padding-left: 1.1rem;
color: var(--text-secondary);
font-size: 0.83rem;
line-height: 1.85;
}
.sync-list li { margin-bottom: 0.2rem; }

View File

@@ -1,543 +0,0 @@
.page {
animation: fadeIn 0.25s ease-out;
color: var(--text-primary);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.page h2 {
margin: 0;
font-size: 1.4rem;
font-weight: 680;
color: var(--text-primary);
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--text-muted);
font-size: 0.85rem;
}
.section {
margin-bottom: 1.75rem;
}
.section-title {
display: flex;
align-items: baseline;
gap: 0.6rem;
font-size: 0.8rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin: 0 0 0.7rem;
}
.section-count {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.section-link {
margin: 0.7rem 0 0;
font-size: 0.82rem;
}
.section-link a,
.page a {
color: var(--accent);
text-decoration: none;
}
.section-link a:hover,
.page a:hover {
text-decoration: underline;
}
/* Filters sit in one row above the charts. */
.range-tabs,
.metric-tabs {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.metric-tabs {
margin-bottom: 1.25rem;
padding-bottom: 0.9rem;
border-bottom: 1px solid var(--border);
}
.range-tab,
.metric-tab {
padding: 0.34rem 0.8rem;
border: 1px solid var(--border);
background: var(--surface-1);
border-radius: 999px;
font-size: 0.82rem;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
font-family: inherit;
white-space: nowrap;
}
.range-tab:hover,
.metric-tab:hover {
border-color: var(--accent);
color: var(--accent);
}
.range-tab.active,
.metric-tab.active {
background: var(--accent-solid);
border-color: var(--accent-solid);
color: #fff;
}
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.chart-grid.one-col {
grid-template-columns: 1fr;
}
.page-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
color: var(--text-muted);
font-size: 0.95rem;
}
.placeholder {
text-align: center;
padding: 2rem;
color: var(--text-muted);
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.9rem;
}
.empty-state {
text-align: center;
padding: 3rem 2rem;
background: var(--surface-1);
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
color: var(--text-secondary);
}
.empty-state p {
margin: 0 0 1.1rem;
}
.error-message {
background: color-mix(in srgb, var(--status-critical) 10%, var(--surface-1));
border: 1px solid color-mix(in srgb, var(--status-critical) 35%, transparent);
color: var(--status-critical);
padding: 0.85rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 0.88rem;
}
.success-message {
background: color-mix(in srgb, var(--status-good) 10%, var(--surface-1));
border: 1px solid color-mix(in srgb, var(--status-good) 35%, transparent);
color: var(--text-primary);
padding: 0.85rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 0.88rem;
}
/* Buttons ------------------------------------------------------------------ */
.btn {
padding: 0.55rem 1.1rem;
border: 1px solid transparent;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
transition: all 0.15s ease;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: var(--accent-solid);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-large {
width: 100%;
max-width: 300px;
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-plain {
background: var(--surface-1);
border-color: var(--border);
color: var(--text-secondary);
font-size: 0.84rem;
padding: 0.4rem 0.85rem;
}
.btn-plain:hover {
border-color: var(--border-strong);
color: var(--text-primary);
}
/* Badges ------------------------------------------------------------------- */
.badge-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 0.65rem;
}
.badge {
background: var(--surface-1);
border: 1px solid var(--border);
border-left: 3px solid var(--series-4);
border-radius: 8px;
padding: 0.7rem 0.85rem;
}
.badge-name {
font-size: 0.86rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1.4;
}
.badge-meta {
margin-top: 0.3rem;
font-size: 0.74rem;
color: var(--text-muted);
display: flex;
gap: 0.5rem;
}
.badge-count {
color: var(--accent);
font-weight: 600;
}
/* Tables ------------------------------------------------------------------- */
.table-wrap {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: auto;
background: var(--surface-1);
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.data-table th,
.data-table td {
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--border);
text-align: left;
color: var(--text-secondary);
white-space: nowrap;
}
.data-table thead th {
background: var(--surface-2);
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.04em;
position: sticky;
top: 0;
}
.data-table tbody th {
color: var(--text-primary);
font-weight: 550;
}
.data-table td.num {
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.data-table tbody tr:last-child th,
.data-table tbody tr:last-child td {
border-bottom: none;
}
/* Forms -------------------------------------------------------------------- */
.form-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1rem;
}
.form-group label {
font-size: 0.84rem;
font-weight: 600;
color: var(--text-secondary);
}
.form-group input {
padding: 0.65rem 0.8rem;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.95rem;
font-family: inherit;
background: var(--surface-2);
color: var(--text-primary);
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.field-hint {
font-size: 0.78rem;
color: var(--text-muted);
line-height: 1.7;
margin: 0;
}
@media (max-width: 640px) {
.chart-grid {
grid-template-columns: 1fr;
}
.page-head {
flex-direction: column;
}
}
/* Metric picker ------------------------------------------------------------ */
.metric-picker {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.85rem 1rem;
margin-bottom: 1.25rem;
}
.picker-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.6rem;
}
.picker-actions {
display: flex;
gap: 0.85rem;
}
.link-button {
background: none;
border: none;
color: var(--accent);
font-size: 0.78rem;
cursor: pointer;
padding: 0;
font-family: inherit;
}
.link-button:hover {
text-decoration: underline;
}
.picker-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
border: 1px solid var(--border);
transition: all 0.15s ease;
}
.chip.on {
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
font-weight: 600;
}
.chip.off {
background: var(--surface-0);
color: var(--text-muted);
}
.chip:hover {
border-color: var(--border-strong);
}
.chip-mark {
font-size: 0.72em;
opacity: 0.8;
}
.chart-stats {
display: flex;
gap: 0.9rem;
flex-wrap: wrap;
font-variant-numeric: tabular-nums;
}
.chart-stats b {
color: var(--text-primary);
font-weight: 620;
}
.control-stack {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.control-row {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.control-label {
font-size: 0.76rem;
color: var(--text-muted);
min-width: 2.4em;
}
/* Micro-interactions ------------------------------------------------------- */
.btn:active:not(:disabled),
.chip:active,
.range-tab:active,
.metric-tab:active {
transform: scale(0.97);
}
.btn,
.chip,
.range-tab,
.metric-tab {
transition: transform 0.12s var(--ease), background 0.15s var(--ease),
border-color 0.15s var(--ease), color 0.15s var(--ease);
}
.badge {
transition: transform 0.18s var(--ease), box-shadow 0.18s var(--ease),
border-color 0.18s var(--ease);
animation: tile-in 0.35s var(--ease) both;
}
.badge:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lift);
border-color: var(--border-strong);
}
.badge-grid > *:nth-child(2) { animation-delay: 30ms; }
.badge-grid > *:nth-child(3) { animation-delay: 60ms; }
.badge-grid > *:nth-child(4) { animation-delay: 90ms; }
.badge-grid > *:nth-child(n + 5) { animation-delay: 110ms; }
.data-table tbody tr {
transition: background 0.12s var(--ease);
}
.data-table tbody tr:hover {
background: var(--surface-0);
}
.metric-row {
transition: background 0.12s var(--ease);
}
.metric-row:hover {
background: var(--surface-0);
}
.page {
animation: page-in 0.3s var(--ease) both;
}
@keyframes page-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* The viewer asked for less movement; skip it rather than merely shortening
it — sweeping motion is what causes the discomfort, not its duration. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
.btn:active:not(:disabled),
.chip:active,
.badge:hover {
transform: none;
}
}
.disclaimer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
font-size: 0.76rem;
color: var(--text-muted);
text-align: center;
line-height: 1.7;
}

View File

@@ -333,7 +333,7 @@ function SyncPage() {
{syncing ? (
<section className="sync-card">
<div className="progress-head">
<span></span>
<span>{syncStatus?.stage || '正在同步…'}</span>
<span className="progress-count">{current} / {total} </span>
</div>
<div
@@ -351,27 +351,38 @@ function SyncPage() {
</p>
</section>
) : (
<div className="sync-actions-row">
<>
<button
className="btn btn-primary btn-large"
className="btn btn-primary btn-large sync-primary"
onClick={syncLatest}
disabled={busy}
>
{loading ? '同步中…' : '同步最新数据'}
</button>
<div className="sync-secondary">
<button className="btn btn-plain" onClick={syncHistory} disabled={busy}>
{historyLabel(settings?.historyDays ?? 365)}
<span className="sync-btn-label"></span>
<span className="sync-btn-sub">
{historyLabel(settings?.historyDays ?? 365)}
</span>
</button>
<button
className="btn btn-plain"
onClick={syncDetails}
disabled={busy || !!details?.running}
>
<span className="sync-btn-label">
{details?.running ? '补齐中…' : '补齐详细数据'}
</span>
<span className="sync-btn-sub">
{details?.running
? `正在补运动详情 ${details.done}/${details.total || '…'}`
: '补齐运动详情'}
? `${details.stage ?? ''} ${details.done}/${details.total || '…'}`
: '运动详情与全天曲线'}
</span>
</button>
</div>
</>
)}
<div className="sync-facts">
@@ -387,9 +398,9 @@ function SyncPage() {
{auto?.account?.autoSync === false
? '已关闭'
: auto?.account
? `${auto.account.intervalMinutes >= 60
? `${auto.account.intervalMinutes / 60} 小时`
: `${auto.account.intervalMinutes} 分钟`}`
? auto.account.intervalMinutes >= 60
? `${auto.account.intervalMinutes / 60} 小时`
: `${auto.account.intervalMinutes} 分钟`
: '—'}
</span>
</div>
@@ -399,6 +410,19 @@ function SyncPage() {
</div>
</div>
<section className="sync-note">
<h3 className="sec-title"></h3>
<ul className="sync-list">
<li>HRV</li>
<li>线</li>
<li>线</li>
<li></li>
</ul>
<p className="field-hint">
Garmin
</p>
</section>
{syncStatus?.lastError && !syncing && (
<p className="sync-lasterror">{syncStatus.lastError}</p>
)}

View File

@@ -111,6 +111,9 @@ export interface SyncStatus {
progressCurrent: number | null;
progressTotal: number | null;
startedAt: string | null;
/** Which part of the sync is running, e.g. 每日数据 2026-08-01. A bare
* "0 / 730 天" says nothing about what is happening for several minutes. */
stage?: string | null;
}
export interface SyncResult {