diff --git a/backend/app.py b/backend/app.py index febaf94..fe2270d 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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. diff --git a/backend/db.py b/backend/db.py index ee09df5..8db513c 100644 --- a/backend/db.py +++ b/backend/db.py @@ -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 diff --git a/backend/services/garmin.py b/backend/services/garmin.py index 153b26f..e8d4339 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -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 = ( diff --git a/client/src/App.tsx b/client/src/App.tsx index f2fd081..6a4d7ef 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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} /> ))} diff --git a/client/src/components/Screen.css b/client/src/components/Screen.css index bd264a1..b9a9f20 100644 --- a/client/src/components/Screen.css +++ b/client/src/components/Screen.css @@ -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; diff --git a/client/src/components/Screen.tsx b/client/src/components/Screen.tsx index 254a384..2e19a59 100644 --- a/client/src/components/Screen.tsx +++ b/client/src/components/Screen.tsx @@ -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 ( - + + {backLink && ( + + + + )} {/* 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. */} diff --git a/client/src/pages/Achievements.tsx b/client/src/pages/Achievements.tsx deleted file mode 100644 index 20a390f..0000000 --- a/client/src/pages/Achievements.tsx +++ /dev/null @@ -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 = { - 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('badges'); - const [badges, setBadges] = useState([]); - const [records, setRecords] = useState([]); - const [activities, setActivities] = useState([]); - 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 ( -
-

成就

- -
- ); - } - - // Badges cluster heavily by year, which is the only grouping that reads. - const byYear = badges.reduce>((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 ( -
-
-
-

成就

-

奖励徽章、个人纪录与运动记录

-
-
- - {error &&
{error}
} - -
- - - - -
- -
- {([ - ['badges', `奖励 (${badges.length})`], - ['records', `个人纪录 (${records.length})`], - ['activities', `运动 (${activities.length})`], - ] as Array<[Tab, string]>).map(([id, text]) => ( - - ))} -
- - {tab === 'badges' && ( - badges.length === 0 ? ( -

还没有同步到徽章。

- ) : ( - years.map((year) => ( -
-

- {year === '未知' ? '未知年份' : `${year} 年`} - {byYear[year].length} 个 -

-
- {byYear[year].map((b) => ( -
-
{b.name || b.badge_key}
-
- {date(b.earned_date)} - {b.earned_count && b.earned_count > 1 && ( - ×{b.earned_count} - )} -
-
- ))} -
-
- )) - ) - )} - - {tab === 'records' && ( - records.length === 0 ? ( -

还没有同步到个人纪录。

- ) : ( -
- - - - - - - - - - - {records.map((r) => ( - - - - - - - ))} - -
活动类型数值日期
{r.activity_name || '—'}{label(r.activity_type)} - {r.value != null ? r.value.toLocaleString(undefined, { - maximumFractionDigits: 2, - }) : '—'} - {date(r.achieved_at)}
-
- ) - )} - - {tab === 'activities' && ( - activities.length === 0 ? ( -

所选区间内没有运动记录。

- ) : ( -
- - - - - - - - - - - - - {activities.map((a) => ( - - - - - - - - - ))} - -
时间类型时长距离消耗平均心率
{a.start_time?.slice(0, 16).replace('T', ' ')}{label(a.activity_type)} - {a.duration != null ? `${Math.round(a.duration / 60)} 分` : '—'} - - {a.distance ? `${(a.distance / 1000).toFixed(2)} km` : '—'} - {a.calories != null ? `${Math.round(a.calories)}` : '—'}{a.heart_rate_average ?? '—'}
-
- ) - )} -
- ); -} - -export default Achievements; diff --git a/client/src/pages/DataSync.css b/client/src/pages/DataSync.css index d8ca12d..aefba40 100644 --- a/client/src/pages/DataSync.css +++ b/client/src/pages/DataSync.css @@ -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; } diff --git a/client/src/pages/Pages.css b/client/src/pages/Pages.css deleted file mode 100644 index 87e1164..0000000 --- a/client/src/pages/Pages.css +++ /dev/null @@ -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; -} diff --git a/client/src/pages/SyncPage.tsx b/client/src/pages/SyncPage.tsx index 9981300..c43e170 100644 --- a/client/src/pages/SyncPage.tsx +++ b/client/src/pages/SyncPage.tsx @@ -333,7 +333,7 @@ function SyncPage() { {syncing ? (
- 正在同步… + {syncStatus?.stage || '正在同步…'} {current} / {total} 天
) : ( -
+ <> - - -
+ +
+ + +
+ )}
@@ -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} 分钟` : '—'}
@@ -399,6 +410,19 @@ function SyncPage() { +
+

同步会取哪些数据

+
    +
  • 每日指标:步数、心率、HRV、压力、身体电量、血氧、呼吸、睡眠、训练准备度
  • +
  • 运动记录与每次运动的完整详情(分段、心率区间、采样曲线)
  • +
  • 全天曲线:心率、压力、身体电量、呼吸、血氧
  • +
  • 身体成分、成绩预测、爬坡分、饮水、挑战赛、设备
  • +
+

+ 数据只保存在自建数据库,打开页面时读的是本机,不会再回源 Garmin。 +

+
+ {syncStatus?.lastError && !syncing && (

上次错误:{syncStatus.lastError}

)} diff --git a/client/src/services/api.ts b/client/src/services/api.ts index b357804..293e554 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -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 {