diff --git a/REAMME.md b/REAMME.md index 696f67b..868000b 100755 --- a/REAMME.md +++ b/REAMME.md @@ -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 小时误判。 ### 汇率 diff --git a/index.html b/index.html index 4cfcfa9..d0ab1d9 100755 --- a/index.html +++ b/index.html @@ -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; } diff --git a/sync.py b/sync.py index 700086b..c1fe686 100755 --- a/sync.py +++ b/sync.py @@ -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),