[阶段10] 前端以 Framework7 重建,采用 iOS 原生形态
按要求废弃手写外壳,改用 Framework7 React(theme=ios)。参照 PeakWatch 的信息架构与卡片语言。 保留(这些是资产,不该重来): - 数据层 services/api.ts、聚合 lib/aggregate.ts、参考区间 lib/ranges.ts - 图表组件 Chart / Ring / Sparkline / BandBar / MetricCard / MetricStrip - 经校验的配色令牌(色盲安全 + 对比度,浅深两档) 替换: - 路由与外壳交给 F7:五个 Tab 各自独立导航栈,推入详情页不影响其他 Tab - 页面转场、橡皮筋滚动、大标题折叠、半透明栏 —— 这些正是换框架的理由, 手写做不像 - 底部标签栏改用 F7 Toolbar,触控目标与安全区由框架处理 配色接入: - 新增 f7theme.css 把我们的令牌映射到 F7 的 CSS 变量,让它的导航栏/ 列表/面板与我们的图表同属一套设计,而不是两种视觉打架 - F7 的深色靠 .dark 类,我们的靠 data-theme,两者在 App 里同步切换 fix: 图标显示为原始名称(squ/hea/cale…) - iconIos/iconMd 引用的是 framework7-icons 字体,没装就只会渲染出名字 其他: - tsconfig moduleResolution 改为 bundler —— F7 用 exports 映射, node 解析方式找不到它的类型 - 登录页不套 Tab 外壳,未登录时不该出现导航 桌面与手机都要好看:内容在宽屏收进 1100px 居中列并加密卡片列数, 窄屏走底部标签栏;两档都已实机核对。 bundle 190KB -> 399KB,是换取原生手感的代价。 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
366
client/src/pages/SyncPage.tsx
Normal file
366
client/src/pages/SyncPage.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import Screen from '../components/Screen';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
apiClient, errorMessage, GarminLoginStatus, parseUtc, SyncStatus,
|
||||
} from '../services/api';
|
||||
import './DataSync.css';
|
||||
|
||||
const POLL_MS = 2000;
|
||||
|
||||
function SyncPage() {
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||
const [hasToken, setHasToken] = useState<boolean | null>(null);
|
||||
|
||||
// Garmin login (only needed until a token is stored)
|
||||
const [password, setPassword] = useState('');
|
||||
const [session, setSession] = useState<string | null>(null);
|
||||
const [loginState, setLoginState] = useState<GarminLoginStatus | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [codeSubmitted, setCodeSubmitted] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSyncStatus = useCallback(async () => {
|
||||
try {
|
||||
setSyncStatus(await apiClient.getGarminSyncStatus());
|
||||
} catch (err) {
|
||||
// A failed status poll should not blank the page.
|
||||
console.error('Failed to load sync status:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// A backfill outlives the page, so a reload must pick the progress back up.
|
||||
apiClient.getGarminSyncStatus().then((s) => {
|
||||
setSyncStatus(s);
|
||||
if (s.status === 'syncing') beginSyncPolling();
|
||||
}).catch(() => undefined);
|
||||
apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false));
|
||||
return stopPolling;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// --- Garmin login -------------------------------------------------------
|
||||
const startLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setMessage('');
|
||||
if (!password) {
|
||||
setError('请输入 Garmin 密码');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const sid = await apiClient.startGarminLogin(password);
|
||||
// The password is only ever needed for this one request.
|
||||
setPassword('');
|
||||
setSession(sid);
|
||||
setLoginState('starting');
|
||||
setCodeSubmitted(false);
|
||||
beginPolling(sid);
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '登录失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const beginPolling = (sid: string) => {
|
||||
stopPolling();
|
||||
pollRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const { status, error: loginError } = await apiClient.getGarminLoginStatus(sid);
|
||||
setLoginState(status);
|
||||
|
||||
if (status === 'done') {
|
||||
stopPolling();
|
||||
setSession(null);
|
||||
setHasToken(true);
|
||||
setMessage('Garmin 登录成功,之后同步不再需要密码或验证码。');
|
||||
} else if (status === 'failed') {
|
||||
stopPolling();
|
||||
setSession(null);
|
||||
setCodeSubmitted(false);
|
||||
setError(loginError || '登录失败,请重试');
|
||||
}
|
||||
} catch (err: any) {
|
||||
stopPolling();
|
||||
setSession(null);
|
||||
setError(errorMessage(err, '登录状态查询失败'));
|
||||
}
|
||||
}, POLL_MS);
|
||||
};
|
||||
|
||||
const submitCode = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!session || !code.trim()) return;
|
||||
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const { ok, message: msg } = await apiClient.submitGarminMfa(session, code.trim());
|
||||
if (ok) {
|
||||
setCodeSubmitted(true);
|
||||
setCode('');
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '验证码提交失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelLogin = async () => {
|
||||
if (session) {
|
||||
try {
|
||||
await apiClient.cancelGarminLogin(session);
|
||||
} catch {
|
||||
// Cancelling is best-effort; the session expires on its own anyway.
|
||||
}
|
||||
}
|
||||
stopPolling();
|
||||
setSession(null);
|
||||
setLoginState(null);
|
||||
setCode('');
|
||||
setCodeSubmitted(false);
|
||||
};
|
||||
|
||||
// --- sync ---------------------------------------------------------------
|
||||
const handleSync = async (days: number) => {
|
||||
setError('');
|
||||
setMessage('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiClient.syncGarminData(days);
|
||||
await loadSyncStatus();
|
||||
beginSyncPolling();
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '同步失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// The sync runs in the background, so the page follows it by polling
|
||||
// rather than by holding a request open for the whole backfill.
|
||||
const beginSyncPolling = () => {
|
||||
stopPolling();
|
||||
pollRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const s = await apiClient.getGarminSyncStatus();
|
||||
setSyncStatus(s);
|
||||
if (s.status !== 'syncing') {
|
||||
stopPolling();
|
||||
if (s.status === 'error') setError(s.lastError || '同步失败');
|
||||
else setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`);
|
||||
}
|
||||
} catch {
|
||||
stopPolling();
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
idle: '就绪',
|
||||
syncing: '正在同步…',
|
||||
error: '上次同步失败',
|
||||
};
|
||||
|
||||
const syncing = syncStatus?.status === 'syncing';
|
||||
const busy = loading || syncing;
|
||||
const awaitingCode = loginState === 'awaiting_code' || codeSubmitted;
|
||||
const current = syncStatus?.progressCurrent ?? 0;
|
||||
const total = syncStatus?.progressTotal ?? 0;
|
||||
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Screen title="数据同步">
|
||||
<h2>数据同步</h2>
|
||||
<p className="subtitle">从 Garmin Connect 拉取最近 7 天的健康数据</p>
|
||||
|
||||
<div className="sync-container">
|
||||
<section className="status-card">
|
||||
<h3>同步状态</h3>
|
||||
{syncStatus ? (
|
||||
<div className="status-info">
|
||||
<div className="status-item">
|
||||
<span className="label">状态</span>
|
||||
<span className={`value status-${syncStatus.status}`}>
|
||||
{statusLabel[syncStatus.status] ?? syncStatus.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
<span className="label">最后同步</span>
|
||||
<span className="value">
|
||||
{parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN')
|
||||
?? '从未同步'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
<span className="label">已同步天数</span>
|
||||
<span className="value">{syncStatus.recordsSynced}</span>
|
||||
</div>
|
||||
{syncStatus.lastError && (
|
||||
<div className="status-item error">
|
||||
<span className="label">错误</span>
|
||||
<span className="value">{syncStatus.lastError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="screen-note">加载中…</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Step 1 — link the Garmin account, once. */}
|
||||
{hasToken === false && !session && (
|
||||
<form className="sync-actions" onSubmit={startLogin}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="garmin-password">Garmin 密码</label>
|
||||
<input
|
||||
id="garmin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
只需绑定一次。登录成功后保存的是 Garmin 授权令牌(有效期约一年),
|
||||
密码不会被存储。若账号开启了两步验证,下一步会让你填验证码。
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary btn-large" disabled={loading}>
|
||||
{loading ? '正在连接…' : '绑定 Garmin 账号'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Step 2 — the two-factor code. */}
|
||||
{session && (
|
||||
<section className="status-card mfa-card">
|
||||
{loginState === 'starting' && !codeSubmitted && (
|
||||
<p className="screen-note">正在连接 Garmin…</p>
|
||||
)}
|
||||
|
||||
{awaitingCode && (
|
||||
<form onSubmit={submitCode}>
|
||||
<h3>输入验证码</h3>
|
||||
<p className="field-hint" style={{ marginBottom: '1rem' }}>
|
||||
Garmin 已向你的手机或邮箱发送了 6 位验证码,请在下方填写。
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<input
|
||||
id="mfa-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={10}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="6 位数字"
|
||||
className="code-input"
|
||||
disabled={loading || codeSubmitted}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mfa-buttons">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading || codeSubmitted || !code.trim()}
|
||||
>
|
||||
{codeSubmitted ? '正在验证…' : '提交验证码'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-plain" onClick={cancelLogin}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loginState === 'finishing' && (
|
||||
<p className="screen-note">验证通过,正在完成登录…</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Step 3 — sync, once linked. */}
|
||||
{hasToken === true && (
|
||||
<section className="status-card">
|
||||
<h3>拉取数据</h3>
|
||||
<p className="field-hint" style={{ marginBottom: '0.9rem' }}>
|
||||
已绑定 Garmin 账号,同步无需密码。首次建议回补一段历史,
|
||||
之后日常只需拉最近 7 天。
|
||||
</p>
|
||||
|
||||
{syncing && total > 0 ? (
|
||||
<div className="progress-block">
|
||||
<div className="progress-head">
|
||||
<span>正在同步…</span>
|
||||
<span className="progress-count">{current} / {total} 天</span>
|
||||
</div>
|
||||
<div
|
||||
className="progress-bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<p className="field-hint">
|
||||
在后台运行,可以离开本页。约每天 3 秒,
|
||||
{total > 60 ? `预计 ${Math.ceil((total * 3) / 60)} 分钟左右。` : ''}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sync-choices">
|
||||
{[7, 30, 90, 365].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => handleSync(d)}
|
||||
className={`btn ${d === 7 ? 'btn-primary' : 'btn-plain'}`}
|
||||
disabled={busy}
|
||||
>
|
||||
{d === 365 ? '回补一年' : `最近 ${d} 天`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{error && <div className="screen-error">{error}</div>}
|
||||
{message && <div className="screen-ok">{message}</div>}
|
||||
|
||||
<section className="info-box">
|
||||
<h4>关于数据同步</h4>
|
||||
<ul>
|
||||
<li>每次同步获取最近 7 天的每日汇总与运动记录</li>
|
||||
<li>同一天重复同步会更新原有记录,不会产生重复数据</li>
|
||||
<li>数据保存在本机数据库,不经过第三方服务</li>
|
||||
<li>Garmin 授权令牌约一年过期,届时重新绑定一次即可</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
export default SyncPage;
|
||||
Reference in New Issue
Block a user