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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user