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

@@ -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>
<button className="btn btn-plain" onClick={syncHistory} disabled={busy}>
{historyLabel(settings?.historyDays ?? 365)}
</button>
<button
className="btn btn-plain"
onClick={syncDetails}
disabled={busy || !!details?.running}
>
{details?.running
? `正在补运动详情 ${details.done}/${details.total || '…'}`
: '补齐运动详情'}
</button>
</div>
<div className="sync-secondary">
<button className="btn btn-plain" onClick={syncHistory} disabled={busy}>
<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.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 {