Show sync freshness status

This commit is contained in:
liuji
2026-10-10 17:59:30 +08:00
parent 913b601609
commit a0108a3c0e
3 changed files with 87 additions and 8 deletions
+4
View File
@@ -40,6 +40,8 @@ WORKBOOK_ID=your-workbook-id ./update.sh
- `last_success_at`:UTC ISO-8601 时间,使用 `Z` 表示 UTC。
- `last_success_at_shanghai`:`Asia/Shanghai` 时区的 ISO 时间。
- `last_data_update_at`:最近一次检测到 `data/merged.html` 内容变化的 UTC ISO-8601 时间,使用 `Z` 表示 UTC;数据内容未变化时保留上次成功记录,首次没有旧值时为 `null`。
- `last_data_update_at_shanghai`:`last_data_update_at` 对应的 `Asia/Shanghai` 时区 ISO 时间;没有数据更新时间时为 `null`。
- `worksheet_count`:本次读取的工作表数量。
- `record_count`:合并结果中的数据记录数量,不含标题行。
- `artifact_bytes`:本次生成的 `data/merged.html` 字节数。
@@ -57,6 +59,8 @@ WORKBOOK_ID=your-workbook-id ./update.sh
- **日期范围筛选**:支持当月、最近 30 天和全部日期;默认选择数据最新月份。
- **KPI**:显示 Total Commission 和 Total GMV,并提供记录数、LinkName 数等概览信息。
- **原始数据入口**:可从看板打开 `data/merged.html`。
- **同步状态提示**:顶部逐行显示最后成功运行时间和数据最后更新时间,均按 `Asia/Shanghai` 格式化到秒;缺少记录时显示“暂无记录”。最后成功运行超过 24 小时会提示检查自动任务。
- **数据过期提示**:`last_data_update_at` 超过 48 小时未更新时,顶部 warning 使用红色高亮并提示检查数据同步;没有数据更新时间记录时只提示“暂无记录”和缺少更新时间,不会按 48 小时误判。
### 汇率
+53 -8
View File
@@ -41,7 +41,8 @@
.status { color: var(--muted); text-align: right; }
.status[data-state="error"] { color: var(--error-text); }
.status[data-state="empty"] { color: var(--muted); }
.alert { margin: 0 0 18px; padding: 11px 14px; border: 1px solid var(--warning-border); border-radius: 10px; background: var(--warning-bg); color: var(--warning-text); }
.alert { margin: 0 0 18px; padding: 11px 14px; border: 1px solid var(--warning-border); border-radius: 10px; background: var(--warning-bg); color: var(--warning-text); white-space: pre-line; }
.alert[data-stale="true"] { border-color: var(--error-border); background: var(--error-bg); color: var(--error-text); }
.alert[hidden] { display: none; }
.filters { display: grid; gap: 12px; margin: 0 0 20px; padding: 16px 18px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
.filter-row { display: flex; flex-wrap: wrap; align-items: end; gap: 14px 20px; }
@@ -283,24 +284,68 @@
charts = [];
}
function formatShanghaiTime(value) {
if (!value) return null;
var timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return null;
var parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(timestamp));
var values = {};
parts.forEach(function (part) { values[part.type] = part.value; });
return values.year + '-' + values.month + '-' + values.day + ' ' + values.hour + ':' + values.minute + ':' + values.second;
}
function isOlderThan(value, milliseconds) {
var timestamp = Date.parse(value);
return !Number.isNaN(timestamp) && Date.now() - timestamp > milliseconds;
}
function updateNotice() {
var messages = [];
if (state.syncLog) messages.push('最后成功运行:' + state.syncLog.displayTime);
var log = state.syncLog;
var lastSuccessAt = log && log.lastSuccessAt ? formatShanghaiTime(log.lastSuccessAt) : null;
var lastDataUpdateAt = log && log.lastDataUpdateAt ? formatShanghaiTime(log.lastDataUpdateAt) : null;
var dataIsStale = false;
messages.push('最后成功运行:' + (lastSuccessAt || '暂无记录'));
messages.push('数据最后更新:' + (lastDataUpdateAt || '暂无记录'));
if (state.syncLogUnavailable) messages.push('运行日志暂不可用。');
if (log && log.lastSuccessAt && isOlderThan(log.lastSuccessAt, 24 * 60 * 60 * 1000)) {
messages.push('最后成功运行已超过24小时,请刷新页面或检查自动任务。');
}
if (log && log.lastDataUpdateAt) {
dataIsStale = isOlderThan(log.lastDataUpdateAt, 48 * 60 * 60 * 1000);
if (dataIsStale) messages.push('数据已超过48小时未更新,请检查数据同步。');
} else {
messages.push('没有数据更新时间记录。');
}
if (state.dateOnlyRows) messages.push('已过滤 ' + formatNumber(state.dateOnlyRows) + ' 条只有 Date、Commission 和 GMV 均为空的日期行。');
if (state.invalidCurrencyRows) messages.push('已排除 ' + formatNumber(state.invalidCurrencyRows) + ' 条币种为空、Unknown 或格式无效且包含金额的记录,请检查原始数据。');
if (state.sourceCurrencies.length > 1) messages.push('检测到多个已知源币种(' + state.sourceCurrencies.join('、') + '),已统一换算为 USD,不能切换到 IDR。');
elements.warning.textContent = messages.join(' ');
elements.warning.textContent = messages.join('\n');
elements.warning.dataset.stale = dataIsStale ? 'true' : 'false';
elements.warning.hidden = messages.length === 0;
}
function parseSyncLog(payload) {
if (!payload || typeof payload.last_success_at !== 'string' || !payload.last_success_at.trim() || Number.isNaN(Date.parse(payload.last_success_at))) {
throw new Error('运行日志缺少有效的 last_success_at。');
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('运行日志格式无效。');
}
var shanghaiTime = typeof payload.last_success_at_shanghai === 'string' ? payload.last_success_at_shanghai.trim() : '';
var displayTime = shanghaiTime && !Number.isNaN(Date.parse(shanghaiTime)) ? shanghaiTime : payload.last_success_at;
return { displayTime: displayTime };
function parseTimestamp(field) {
var value = payload[field];
if (value === null || value === undefined || value === '') return null;
if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) {
throw new Error('运行日志缺少有效的 ' + field + '。');
}
return value;
}
return {
lastSuccessAt: parseTimestamp('last_success_at'),
lastDataUpdateAt: parseTimestamp('last_data_update_at')
};
}
function setRateStatus(message) { elements.rateStatus.textContent = message; }
+30
View File
@@ -174,6 +174,18 @@ def run_git(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess
)
def read_sync_log() -> dict[str, object]:
"""Read the previously published sync log when it is available."""
path = ROOT / SYNC_LOG_PATH
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
def publish(content: bytes, sync_log: dict[str, object]) -> None:
log_content = (json.dumps(sync_log, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
targets = {
@@ -244,10 +256,28 @@ def sync() -> None:
sheets.append((name, headers, rows))
headers, rows = merge_sheets(sheets)
content = render_html(headers, rows)
previous_sync_log = read_sync_log()
previous_data_path = ROOT / PUBLISH_PATH
data_changed = (
not previous_data_path.exists()
or previous_data_path.read_bytes() != content
)
success_at = datetime.now(timezone.utc)
if data_changed:
last_data_update_at = success_at.isoformat().replace("+00:00", "Z")
last_data_update_at_shanghai = success_at.astimezone(ZoneInfo("Asia/Shanghai")).isoformat()
else:
last_data_update_at = previous_sync_log.get("last_data_update_at")
last_data_update_at_shanghai = previous_sync_log.get("last_data_update_at_shanghai")
if not isinstance(last_data_update_at, str) or not last_data_update_at:
last_data_update_at = None
if not isinstance(last_data_update_at_shanghai, str) or not last_data_update_at_shanghai:
last_data_update_at_shanghai = None
sync_log = {
"last_success_at": success_at.isoformat().replace("+00:00", "Z"),
"last_success_at_shanghai": success_at.astimezone(ZoneInfo("Asia/Shanghai")).isoformat(),
"last_data_update_at": last_data_update_at,
"last_data_update_at_shanghai": last_data_update_at_shanghai,
"worksheet_count": len(worksheet_names),
"record_count": len(rows),
"artifact_bytes": len(content),