feat(sync-history): 新增同步结果查询(每次自动/手动/立即同步的记录)
后端: - db.py SCHEMA 新增 sync_history 表(不可变,每次同步尝试一行) + 索引 - garmin.py: _log_sync_history() 在 sync_data 全部出口记录; trigger 区分 auto(调度器)/manual(同步页开始同步)/quick(设置页立即同步); start_sync 限流拦截分支同样留档; 写入失败只告警不影响同步 - scheduler.py 自动同步传 trigger="auto"; routes 新增 GET /garmin/sync-history 按时间倒序返回(默认 50 条,上限 200) 前端: - api.ts 增加 SyncHistoryItem 类型 + getSyncHistory() - 新页面 /sync-history/ 同步记录: 卡片列表, 状态(成功/失败/被限流)chip 配色, 时间本地化(今天/昨天/X月X日), 触发类型标签, 范围与耗时 - 同步页与设置页同步区块均加入口链接
This commit is contained in:
@@ -299,3 +299,26 @@
|
||||
background: var(--danger, #d23b3b);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 同步记录 entry ----------------------------------------------------------- */
|
||||
.sync-records-link {
|
||||
margin-top: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 0.95rem;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sync-records-link > span:first-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
.sync-records-link:active {
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
@@ -280,6 +280,14 @@ function SettingsPage() {
|
||||
<span className="set-value">去同步页</span>
|
||||
<span className="set-chevron" aria-hidden="true">›</span>
|
||||
</Link>
|
||||
|
||||
<Link href="/sync-history/" className="set-row">
|
||||
<span className="set-label">
|
||||
同步记录
|
||||
<span className="set-sub">每次自动与手动同步的结果</span>
|
||||
</span>
|
||||
<span className="set-chevron" aria-hidden="true">›</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
70
client/src/pages/SyncHistory.css
Normal file
70
client/src/pages/SyncHistory.css
Normal file
@@ -0,0 +1,70 @@
|
||||
/* Sync history (同步记录) ------------------------------------------------ */
|
||||
.sh-list { display: grid; gap: 0.6rem; }
|
||||
|
||||
.sh-card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.95rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.sh-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sh-time {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sh-trigger {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.08rem 0.5rem;
|
||||
}
|
||||
|
||||
.sh-chip {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.55rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sh-chip-success { color: #0a7d0a; background: rgba(12, 163, 12, 0.12); }
|
||||
.sh-chip-error { color: var(--status-critical); background: rgba(208, 59, 59, 0.12); }
|
||||
.sh-chip-rate_limited { color: #9a6b00; background: rgba(250, 178, 25, 0.18); }
|
||||
.sh-chip-syncing { color: var(--accent-solid); background: var(--accent-soft); }
|
||||
|
||||
.sh-body {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sh-meta {
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sh-chip-success { color: #3fcf3f; background: rgba(12, 163, 12, 0.22); }
|
||||
.sh-chip-error { color: #ff7a7a; background: rgba(208, 59, 59, 0.22); }
|
||||
.sh-chip-rate_limited { color: #ffcf6b; background: rgba(250, 178, 25, 0.2); }
|
||||
}
|
||||
114
client/src/pages/SyncHistoryPage.tsx
Normal file
114
client/src/pages/SyncHistoryPage.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'framework7-react';
|
||||
import { apiClient, errorMessage, parseUtc, SyncHistoryItem } from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './SyncHistory.css';
|
||||
|
||||
const TRIGGER_LABEL: Record<SyncHistoryItem['triggerKind'], string> = {
|
||||
auto: '自动同步',
|
||||
manual: '手动同步',
|
||||
quick: '立即同步',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<SyncHistoryItem['status'], string> = {
|
||||
success: '成功',
|
||||
error: '失败',
|
||||
rate_limited: '被限流',
|
||||
syncing: '同步中',
|
||||
};
|
||||
|
||||
/** 2026-09-02T19:30:00 (UTC) → 今天 03:30 / 昨天 23:10 / 9/1 20:05 / 2025/12/1 … */
|
||||
const fmtTime = (value: string | null | undefined) => {
|
||||
const d = parseUtc(value);
|
||||
if (!d) return '—';
|
||||
const now = new Date();
|
||||
const hm = d.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
});
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
if (sameDay) return `今天 ${hm}`;
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) return `昨天 ${hm}`;
|
||||
const md = d.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
|
||||
const y = d.getFullYear() === now.getFullYear() ? '' : `${d.getFullYear()}/`;
|
||||
return `${y}${md} ${hm}`;
|
||||
};
|
||||
|
||||
const fmtDuration = (seconds: number) => {
|
||||
if (seconds < 1) return '1 秒内';
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return s > 0 ? `${m} 分 ${s} 秒` : `${m} 分钟`;
|
||||
};
|
||||
|
||||
/** days is what the run asked for: -1 增量 / 0 全部历史 / N 最近 N 天. */
|
||||
const rangeLabel = (days: number) =>
|
||||
days === -1 ? '增量'
|
||||
: days === 0 ? '全部历史'
|
||||
: `最近 ${days} 天`;
|
||||
|
||||
function SyncHistoryPage() {
|
||||
const [rows, setRows] = useState<SyncHistoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('');
|
||||
try {
|
||||
const items = await apiClient.getSyncHistory(100);
|
||||
setRows(items);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, '加载同步记录失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (loading) {
|
||||
return <Screen title="同步记录" backLink><Skeleton count={6} /></Screen>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen title="同步记录" backLink>
|
||||
{error && <div className="screen-error">{error}</div>}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="screen-empty">
|
||||
<p>还没有同步记录。自动或手动同步后,每次的结果都会出现在这里。</p>
|
||||
<Link href="/sync/" className="button button-fill button-round">去同步</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sh-list">
|
||||
{rows.map((r, i) => (
|
||||
<div className={`sh-card sh-${r.status}`} key={`${r.startedAt}-${i}`}>
|
||||
<div className="sh-head">
|
||||
<span className="sh-time">{fmtTime(r.startedAt)}</span>
|
||||
<span className="sh-trigger">{TRIGGER_LABEL[r.triggerKind]}</span>
|
||||
<span className={`sh-chip sh-chip-${r.status}`}>
|
||||
{STATUS_LABEL[r.status]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sh-body">{r.message || '—'}</div>
|
||||
<div className="sh-meta">
|
||||
<span>范围 {rangeLabel(r.days)}</span>
|
||||
{r.durationSeconds > 0 && <span>耗时 {fmtDuration(r.durationSeconds)}</span>}
|
||||
{r.status === 'success' && r.recordsSynced > 0 && (
|
||||
<span>更新 {r.recordsSynced} 天</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
export default SyncHistoryPage;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { f7 } from 'framework7-react';
|
||||
import { f7, Link } from 'framework7-react';
|
||||
import {
|
||||
apiClient, AutoSyncStatus, DetailSyncStatus, errorMessage, GarminLoginStatus,
|
||||
parseUtc, SettingsOptions, SyncStatus,
|
||||
@@ -508,6 +508,16 @@ function SyncPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href="/sync-history/" className="sync-records-link">
|
||||
<span>
|
||||
<span className="sync-btn-label">同步记录</span>
|
||||
<span className="sync-btn-sub">
|
||||
每次自动、手动与立即同步的结果,按时间倒序
|
||||
</span>
|
||||
</span>
|
||||
<span className="set-chevron" aria-hidden="true">›</span>
|
||||
</Link>
|
||||
|
||||
<section className="sync-note">
|
||||
<h3 className="sec-title">同步会取哪些数据</h3>
|
||||
<ul className="sync-list">
|
||||
|
||||
@@ -15,6 +15,7 @@ import DevicesPage from './pages/DevicesPage';
|
||||
import ExercisePage from './pages/ExercisePage';
|
||||
import SleepPage from './pages/SleepPage';
|
||||
import SyncPage from './pages/SyncPage';
|
||||
import SyncHistoryPage from './pages/SyncHistoryPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import AiQueuePage from './pages/AiQueuePage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
@@ -44,6 +45,7 @@ const SCREENS: Router.RouteParameters[] = [
|
||||
{ path: '/challenges/', component: ChallengesPage },
|
||||
{ path: '/devices/', component: DevicesPage },
|
||||
{ path: '/sync/', component: SyncPage },
|
||||
{ path: '/sync-history/', component: SyncHistoryPage },
|
||||
{ path: '/settings/', component: SettingsPage },
|
||||
{ path: '/ai-queue/', component: AiQueuePage },
|
||||
{ path: '/login/', component: LoginPage },
|
||||
@@ -86,7 +88,7 @@ const TAB_OWNERS: Array<[RegExp, string]> = [
|
||||
[/^\/metric\//, 'trends'],
|
||||
[/^\/(exercise|race|challenges)\/?$/, 'exercise'],
|
||||
[/^\/activity\//, 'exercise'],
|
||||
[/^\/(settings|sync|devices|rating-basis|ai-queue)\/?$/, 'settings'],
|
||||
[/^\/(settings|sync|sync-history|devices|rating-basis|ai-queue)\/?$/, 'settings'],
|
||||
];
|
||||
|
||||
/** The tab a cold URL should open in, or null for the root and unknown paths. */
|
||||
|
||||
@@ -129,6 +129,26 @@ export interface SyncResult {
|
||||
lastSyncTime: string;
|
||||
}
|
||||
|
||||
/** One recorded sync attempt, for the 同步记录 screen. */
|
||||
export interface SyncHistoryItem {
|
||||
/** What started the run: the scheduler, the sync page, or 立即同步. */
|
||||
triggerKind: 'auto' | 'manual' | 'quick';
|
||||
status: 'success' | 'error' | 'rate_limited' | 'syncing';
|
||||
days: number;
|
||||
recordsSynced: number;
|
||||
activitiesSynced: number;
|
||||
badgesSynced: number;
|
||||
personalRecordsSynced: number;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
durationSeconds: number;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface SyncHistoryResponse {
|
||||
items: SyncHistoryItem[];
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
id: string;
|
||||
category: string;
|
||||
@@ -664,6 +684,14 @@ class ApiClient {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Every recorded sync attempt, newest first (auto / manual / 立即同步). */
|
||||
async getSyncHistory(limit = 50) {
|
||||
const { data } = await this.client.get<SyncHistoryResponse>('/garmin/sync-history', {
|
||||
params: { limit },
|
||||
});
|
||||
return data.items;
|
||||
}
|
||||
|
||||
// --- health ---
|
||||
private range(startDate?: string, endDate?: string) {
|
||||
return { params: { startDate, endDate } };
|
||||
|
||||
Reference in New Issue
Block a user