From b0b64b90749aa812602e41d133262d6b9f3e3c2e Mon Sep 17 00:00:00 2001 From: gary-Shen Date: Wed, 13 May 2026 22:49:42 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A4=9A=E8=AE=BE=E5=A4=87=20Gist=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=BC=95=E5=85=A5=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=88=B3=E5=9F=BA=E7=BA=BF=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E6=8B=89=E5=8F=96-=E7=BC=96=E8=BE=91=E7=AB=9E?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧实现完全用客户端 Date.now() 作为同步决策依据,存在两个致命问题: 1. 拉取-编辑竞态:B 设备拉取期间用户先开始编辑,本地 updatedAt 被 打上"新于远程"的时间戳,导致拉到的远程版本被识别为过时不覆盖; 接着 debounce 推送时反过来覆盖远程,A 设备的修改被静默吞掉。 2. 没有任何并发校验:从不查远程是否被别人改过,直接 last-write-wins, 谁后保存谁赢,与真正的时间先后无关。 引入 GitHub Gist 的服务端 updated_at 作为权威基线 remoteUpdatedAt, 配合两条不变量: - settings.updatedAt > settings.remoteUpdatedAt ⇔ 本地有未推送修改 - remote.updated_at > settings.remoteUpdatedAt ⇔ 远程被其他设备改过 主要改动: - types/setting.type.ts: 新增 remoteUpdatedAt 字段 - hooks/useSettings.ts: 用服务端时间戳决策合并;冲突时远程优先并 toast 警告;导出 patchSettings(不自增 updatedAt);updateSettings 改为 max(Date.now(), prev+1) 单调递增以抗时钟漂移 - hooks/useGistSync.ts: 推送前 await refetch() 校验远程是否被改过, 若被改则放弃推送让拉取流程接管;推送成功后用 GitHub 返回的 updated_at 写回 remoteUpdatedAt;移除 isFirstMount 跳过;修复 candi_tab_settings.json 拼写错误 - context/settings.context.ts & components/SettingProvider.tsx: 暴露 patchSettings - partials/Setting/OAuth/index.tsx: 创建/切换 gist 流程改用 patchSettings 写入 remoteUpdatedAt,避免切换 gist 后反向用本地内容 覆盖远程;去掉重复的 updateSettings 调用 - locales/*.json: 5 种语言的冲突提示翻译 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/SettingProvider.tsx | 7 +- src/context/settings.context.ts | 5 ++ src/hooks/useGistSync.ts | 75 ++++++++++++++------ src/hooks/useSettings.ts | 100 ++++++++++++++++++++++----- src/locales/en-US.json | 2 + src/locales/ja-JP.json | 2 + src/locales/ko-KR.json | 2 + src/locales/zh-CN.json | 2 + src/locales/zh-TR.json | 2 + src/partials/Setting/OAuth/index.tsx | 71 ++++++++++--------- src/types/setting.type.ts | 9 +++ 11 files changed, 203 insertions(+), 74 deletions(-) diff --git a/src/components/SettingProvider.tsx b/src/components/SettingProvider.tsx index 6836d3c..6971325 100644 --- a/src/components/SettingProvider.tsx +++ b/src/components/SettingProvider.tsx @@ -15,19 +15,20 @@ import SettingsContext from '../context/settings.context' export default function SettingProvider({ children }: PropsWithChildren) { const [accessToken, setAccessToken] = useStorage('accessToken') - const [settings, updateSettings] = useSettings() + const [settings, updateSettings, patchSettings] = useSettings() const queryClient = useQueryClient() - useGistSync(settings) + useGistSync(settings, patchSettings) const value = useMemo(() => { return { updateSettings, + patchSettings, settings, accessToken, updateAccessToken: setAccessToken, } - }, [updateSettings, settings, accessToken, setAccessToken]) + }, [updateSettings, patchSettings, settings, accessToken, setAccessToken]) useEffect(() => { if (!accessToken) { diff --git a/src/context/settings.context.ts b/src/context/settings.context.ts index 986206c..f015f84 100644 --- a/src/context/settings.context.ts +++ b/src/context/settings.context.ts @@ -4,6 +4,11 @@ import { createContext } from 'react' export interface SettingsContextType { updateSettings: (newSettings: Setting) => void + /** + * 局部更新 settings 字段(不触发 updatedAt 自增)。 + * 用于写入同步元数据(如切换 gist 后写入 remoteUpdatedAt),避免被识别为"本地有未推送修改"。 + */ + patchSettings: (patch: Partial) => void settings: Setting accessToken: string updateAccessToken: (token: string) => void diff --git a/src/hooks/useGistSync.ts b/src/hooks/useGistSync.ts index 0d01ad0..7d2c553 100644 --- a/src/hooks/useGistSync.ts +++ b/src/hooks/useGistSync.ts @@ -8,29 +8,42 @@ import parseGistContent from '@/utils/parseGistContent' import { useGistUpdate } from './useGistMutation' import { useGistOne } from './useGistQuery' -export function useGistSync(settings: Setting | null) { +function toMs(iso: string | undefined): number { + if (!iso) { + return 0 + } + const ms = new Date(iso).getTime() + return Number.isFinite(ms) ? ms : 0 +} + +const DEFAULT_FILE_NAME = 'candi-tab-settings.json' + +export function useGistSync( + settings: Setting | null, + patchSettings: (patch: Partial) => void, +) { const { t } = useTranslation() const gistId = settings?.gist?.id || settings?.gistId const mutation = useGistUpdate(gistId) const oneGist = useGistOne(gistId) - // oneGist.data 经过 select: data => data?.data 已是 GistObject, - // 但 TS 因 options?: any 无法推断,需手动断言 + // oneGist.data 经过 select: data => data?.data 已是 GistObject const gistFiles = (oneGist.data as any)?.files - // Create refs to hold latest values to avoid recreating the debounced function + // Refs 保持最新引用,避免在 debounce 闭包内拿到旧值 const mutationRef = useRef(mutation) const gistFilesRef = useRef(gistFiles) const gistIdRef = useRef(gistId) const tRef = useRef(t) const oneGistRef = useRef(oneGist) + const patchSettingsRef = useRef(patchSettings) - // Update refs on every render via effect useEffect(() => { mutationRef.current = mutation gistFilesRef.current = gistFiles gistIdRef.current = gistId tRef.current = t oneGistRef.current = oneGist + patchSettingsRef.current = patchSettings }) const syncRef = useRef(null) @@ -42,18 +55,25 @@ export function useGistSync(settings: Setting | null) { const _gistFiles = gistFilesRef.current const _t = tRef.current const _oneGist = oneGistRef.current + const _patchSettings = patchSettingsRef.current if (!currentSettings || !_gistId || _mutation.isPending) { return } + // 核心不变量 1:本地存在未推送修改 ⇔ updatedAt > remoteUpdatedAt + const localRemoteUpdatedAt = currentSettings.remoteUpdatedAt ?? 0 + if ((currentSettings.updatedAt ?? 0) <= localRemoteUpdatedAt) { + return + } + let fileName = currentSettings.gist?.fileName - // Attempt to find filename if missing (legacy support) + // 兼容旧配置:未记录 fileName 时回退(注意:默认文件名是 candi-tab-settings.json) if (!fileName && _gistFiles) { const keys = Object.keys(_gistFiles) - if ('candi_tab_settings.json' in _gistFiles) { - fileName = 'candi_tab_settings.json' + if (DEFAULT_FILE_NAME in _gistFiles) { + fileName = DEFAULT_FILE_NAME } else if (keys.length === 1) { fileName = keys[0] @@ -64,17 +84,32 @@ export function useGistSync(settings: Setting | null) { return } - // 只有当本地比远程更新时才推送 - // 如果远程更新或相等,不需要推送 + // 推送前主动刷新远程,做并发冲突校验:避免覆盖其他设备的最新修改 + let refetchedRemoteUpdatedAt = toMs((_oneGist.data as any)?.updated_at) + try { + const refetched = await _oneGist.refetch() + refetchedRemoteUpdatedAt = toMs((refetched.data as any)?.updated_at) || refetchedRemoteUpdatedAt + } + catch (err) { + console.warn('[sync] refetch before push failed', err) + } + + // 远程比我们记录的基线更新 → 另一台设备改过;不推送,把决策权交给 useSettings 的合并 effect + if (refetchedRemoteUpdatedAt && refetchedRemoteUpdatedAt > localRemoteUpdatedAt) { + toast.error(_t('Remote has newer changes, please retry')) + return + } + + // 同时校验:refetched 内容里的 updatedAt 也不能比本地新(防御性兜底) const remoteSettings = parseGistContent(_oneGist.data, fileName) - if (!remoteSettings || currentSettings.updatedAt <= remoteSettings.updatedAt) { + if (remoteSettings && remoteSettings.updatedAt > currentSettings.updatedAt) { return } const toastId = toast.loading(_t('syncing')) try { - await _mutation.mutateAsync({ + const result = await _mutation.mutateAsync({ gist_id: _gistId, description: currentSettings.gist?.description, files: { @@ -83,6 +118,11 @@ export function useGistSync(settings: Setting | null) { }, }, }) + + // 推送成功后:用 GitHub 返回的服务端 updated_at 更新本地基线,避免下次重复推送 / 错误地认为远程被外部改过 + const pushedAt = toMs((result.data as any)?.updated_at) || Date.now() + _patchSettings({ remoteUpdatedAt: pushedAt }) + toast.success(_t('sync success'), { id: toastId }) } catch (error) { @@ -96,20 +136,11 @@ export function useGistSync(settings: Setting | null) { } }, []) - // Use a ref to track if it's the first mount to avoid syncing on load - const isFirstMount = useRef(true) - + // 首次挂载也允许触发 sync:之前的 isFirstMount 跳过会让"本地有未推送修改但还没改过"的场景永远不上行 useEffect(() => { - if (isFirstMount.current) { - isFirstMount.current = false - return - } - if (settings) { syncRef.current?.(settings) } - - // Cleanup debounce on unmount return () => { syncRef.current?.cancel() } diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 011e1f9..c653ed2 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -3,6 +3,7 @@ import _ from 'lodash' import update from 'lodash/fp/update' import { useCallback, useEffect, useRef, useState } from 'react' +import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { gid } from '@/utils/gid' import parseGistContent from '@/utils/parseGistContent' @@ -46,70 +47,133 @@ const setIds = update('links')((blocks: Block[]) => }), ) +function toMs(iso: string | undefined): number { + if (!iso) { + return 0 + } + const ms = new Date(iso).getTime() + return Number.isFinite(ms) ? ms : 0 +} + +// 排除瞬态/同步字段后比较内容是否相同,避免"自己刚推送的回声"触发覆盖 +function isSameContent(a: Setting | null | undefined, b: Setting | null | undefined): boolean { + if (!a || !b) { + return false + } + const omitKeys: (keyof Setting)[] = ['updatedAt', 'createdAt', 'remoteUpdatedAt', 'gist'] + return _.isEqual(_.omit(a, omitKeys as string[]), _.omit(b, omitKeys as string[])) +} + export default function useSettings(): [ Setting | null, (settings: Setting) => void, + (patch: Partial) => void, ] { - const { i18n } = useTranslation() + const { i18n, t } = useTranslation() const [settings, setSettings] = useState(null) + const settingsRef = useRef(null) const localLoadedRef = useRef(false) const gist = settings?.gist || ({} as any) const oneGist = useGistOne(gist.id || settings?.gistId) + // 始终保持 ref 与最新 settings 同步,给异步逻辑读取 + useEffect(() => { + settingsRef.current = settings + }, [settings]) + useEffect(() => { load().then((result) => { const newSettings = setIds({ ...defaultSettings, ...result }) localLoadedRef.current = true + settingsRef.current = newSettings setSettings(newSettings) }) }, []) - // 远程 gist 配置合并:仅在本地加载完成后才比较时间戳 + // 远程 Gist → 本地的合并:以 GitHub 服务端 updated_at 作为权威时间戳,避免本地时钟漂移导致漏同步 useEffect(() => { if (!oneGist.isSuccess || !localLoadedRef.current) { return } - const newSettings = parseGistContent(oneGist.data!, settings?.gist?.fileName) - if (!newSettings) { + const current = settingsRef.current + const remoteServerUpdatedAt = toMs((oneGist.data as any)?.updated_at) + if (!remoteServerUpdatedAt) { + return + } + + const localRemoteUpdatedAt = current?.remoteUpdatedAt ?? 0 + + // 远程 updated_at 与本地记录的"上次同步时的服务端时间戳"一致,说明远程未变更 + if (remoteServerUpdatedAt === localRemoteUpdatedAt) { return } - // 如果本地更新或时间戳相等,不覆盖本地设置 - if (settings?.updatedAt && newSettings.updatedAt <= settings?.updatedAt) { + const parsed = parseGistContent(oneGist.data!, current?.gist?.fileName) + if (!parsed) { return } - else if (settings?.createdAt && newSettings.createdAt && newSettings.createdAt <= settings?.createdAt) { - // 兼容旧配置 + + // 内容与本地一致(典型场景:刚推送完成后远程回来的同一份数据),只刷新基线 + if (isSameContent(parsed, current)) { + const next = { ...current!, remoteUpdatedAt: remoteServerUpdatedAt } + settingsRef.current = next + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setSettings(next) + save(next) return } - newSettings.gist = { - ...newSettings.gist, + // 本地存在未推送修改且与远程内容不同 → 真正的冲突。远程优先,避免静默丢弃他人在远程的修改。 + const hasLocalUnpushed = (current?.updatedAt ?? 0) > localRemoteUpdatedAt + if (hasLocalUnpushed) { + toast.error(t('Remote changes detected, local edits discarded')) + } + + const merged: Setting = { + ...parsed, + remoteUpdatedAt: remoteServerUpdatedAt, + } + merged.gist = { + ...(parsed.gist || {}), ..._.pick(oneGist.data, ['description', 'id']), } + settingsRef.current = merged // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect - setSettings(newSettings) - save(newSettings) + setSettings(merged) + save(merged) // eslint-disable-next-line react-hooks/exhaustive-deps }, [oneGist.data, oneGist.isSuccess]) const updateSettings = useCallback( async (newSettings: Setting) => { i18n.changeLanguage(newSettings?.general?.language || chrome?.i18n?.getUILanguage() || 'en-US') - const _value = { + // 单调递增的本地业务时间戳:max(Date.now(), prev+1),避免时钟回拨/同毫秒覆盖 + const prevUpdatedAt = settingsRef.current?.updatedAt ?? 0 + const _value: Setting = { ...newSettings, - updatedAt: Date.now(), + updatedAt: Math.max(Date.now(), prevUpdatedAt + 1), } - setSettings(() => { - return _value - }) + settingsRef.current = _value + setSettings(() => _value) save(_value) }, [i18n], ) - return [settings, updateSettings] + // 局部更新(不动 updatedAt):用于推送/拉取流程回写 remoteUpdatedAt 等同步元数据 + const patchSettings = useCallback((patch: Partial) => { + const current = settingsRef.current + if (!current) { + return + } + const next = { ...current, ...patch } + settingsRef.current = next + setSettings(next) + save(next) + }, []) + + return [settings, updateSettings, patchSettings] } diff --git a/src/locales/en-US.json b/src/locales/en-US.json index d296340..65601f9 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -48,6 +48,8 @@ "sync success": "Synchronized", "sync failed": "Synchronization failed", "syncing": "Synchronizing...", + "Remote changes detected, local edits discarded": "Remote changes detected. Your unsaved local edits were discarded.", + "Remote has newer changes, please retry": "Remote has newer changes; pulling latest, please retry your edit.", "built in color": "Built-in color", "custom color": "Custom color", "proceed": "Proceed", diff --git a/src/locales/ja-JP.json b/src/locales/ja-JP.json index 1ff1468..bf6f27b 100644 --- a/src/locales/ja-JP.json +++ b/src/locales/ja-JP.json @@ -48,6 +48,8 @@ "sync success": "同期しました", "sync failed": "同期に失敗しました", "syncing": "同期中...", + "Remote changes detected, local edits discarded": "リモートに更新があります。ローカルの未保存の編集は破棄されました。", + "Remote has newer changes, please retry": "リモートに新しい更新があります。最新を取得しました。再度編集してください。", "built in color": "プリセットカラー", "custom color": "カスタムカラー", "proceed": "続行", diff --git a/src/locales/ko-KR.json b/src/locales/ko-KR.json index 2864c40..4e3b95a 100644 --- a/src/locales/ko-KR.json +++ b/src/locales/ko-KR.json @@ -48,6 +48,8 @@ "sync success": "동기화 성공", "sync failed": "동기화 실패", "syncing": "동기화 중...", + "Remote changes detected, local edits discarded": "원격에 변경 사항이 감지되었습니다. 저장되지 않은 로컬 편집은 폐기되었습니다.", + "Remote has newer changes, please retry": "원격에 새로운 변경 사항이 있습니다. 최신을 가져왔습니다. 다시 시도해 주세요.", "built in color": "기본 색상", "custom color": "사용자 지정 색상", "proceed": "계속", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 0cbd39c..0b35322 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -49,6 +49,8 @@ "sync success": "已同步", "sync failed": "同步失败", "syncing": "同步中...", + "Remote changes detected, local edits discarded": "检测到远程有更新,本地未提交的修改已被覆盖", + "Remote has newer changes, please retry": "远程已有更新,正在拉取,请稍后重试修改", "built in color": "内置颜色", "custom color": "自定义颜色", "proceed": "继续", diff --git a/src/locales/zh-TR.json b/src/locales/zh-TR.json index ecb09e5..3bdc585 100644 --- a/src/locales/zh-TR.json +++ b/src/locales/zh-TR.json @@ -49,6 +49,8 @@ "sync success": "已同步", "sync failed": "同步失敗", "syncing": "同步中...", + "Remote changes detected, local edits discarded": "偵測到遠端有更新,本機未提交的修改已被覆蓋", + "Remote has newer changes, please retry": "遠端已有更新,正在拉取,請稍後重試修改", "built in color": "內建顏色", "custom color": "自訂顏色", "proceed": "繼續", diff --git a/src/partials/Setting/OAuth/index.tsx b/src/partials/Setting/OAuth/index.tsx index 0b54bee..1e7c854 100644 --- a/src/partials/Setting/OAuth/index.tsx +++ b/src/partials/Setting/OAuth/index.tsx @@ -2,7 +2,6 @@ import type { IGist } from '@/types/gist.type' import { Octokit } from '@octokit/rest' import classNames from 'classnames' import _ from 'lodash' -import { set } from 'lodash/fp' import { ExternalLink } from 'lucide-react' import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react' import toast from 'react-hot-toast' @@ -44,8 +43,16 @@ interface GistListProps { onSave: () => void } +function toMs(iso: string | undefined): number { + if (!iso) { + return 0 + } + const ms = new Date(iso).getTime() + return Number.isFinite(ms) ? ms : 0 +} + function GistList({ onSave }: GistListProps) { - const { settings, updateSettings, accessToken } = useContext(SettingsContext) + const { settings, patchSettings, accessToken } = useContext(SettingsContext) const allGist = useGistAll(accessToken) const [selectedGist, setSelectedGist] = useState() const [selectedFile, setSelectedFile] = useState('') @@ -63,17 +70,18 @@ function GistList({ onSave }: GistListProps) { const gistUpdate = useGistUpdate(selectedGist?.id || settings?.gist?.id) const gistCreation = useGistCreation({ onSuccess: (data: any) => { - const newSettings = { + const remoteUpdatedAt = toMs(data?.data?.updated_at) || Date.now() + // 用 patchSettings 完整替换内容,避免 updateSettings 自增 updatedAt 触发不必要的回推 + patchSettings({ ...parseGistContent(data.data, newGist.fileName), gistId: data.data.id, - } - - newSettings.gist = { - ..._.pick(data.data, ['description', 'id']), - fileName: newGist.fileName, - } + gist: { + ..._.pick(data.data, ['description', 'id']), + fileName: newGist.fileName, + }, + remoteUpdatedAt, + }) allGist.refetch() - updateSettings(newSettings) setIsCreateGist(false) }, }) @@ -145,22 +153,17 @@ function GistList({ onSave }: GistListProps) { settings, }) + // 注意:settings 的更新已在 gistCreation.onSuccess 中通过 patchSettings 写入服务端时间戳。 + // 这里不再调用 updateSettings 避免再次自增 updatedAt 触发回推。 allGist.refetch() // @ts-expect-error Library type mismatch setSelectedGist(gistResponse.data) setIsCreateGist(false) - - updateSettings( - set('gist')({ - ...newGist, - id: gistResponse.data.id, - })(settings), - ) } catch (err: any) { toast.error(err.toString()) } - }, [allGist, gistCreation, newGist, settings, updateSettings]) + }, [allGist, gistCreation, newGist, settings]) const handleFileOnChange = useCallback((changedFileName: string) => { setSelectedFile(changedFileName) @@ -169,25 +172,26 @@ function GistList({ onSave }: GistListProps) { // eslint-disable-next-line react-hooks/preserve-manual-memoization const handleFileSelect = useCallback(() => { if (oneGist?.data && selectedGist?.id) { - const newSettings = { + const remoteUpdatedAt = toMs((oneGist.data as any)?.updated_at) || Date.now() + // 切换到指定远程版本:用 patchSettings 完整覆盖内容并写入服务端基线 + // 关键:保留远程内容里的 updatedAt(避免被 updateSettings 自增后误判为本地有未推送修改 → 回推覆盖远程) + patchSettings({ ...parseGistContent(oneGist.data!, selectedFile), gistId: selectedGist.id, - } - - newSettings.gist = { - ..._.pick(oneGist.data, ['description', 'id']), - fileName: selectedFile, - } - - updateSettings(newSettings) + gist: { + ..._.pick(oneGist.data, ['description', 'id']), + fileName: selectedFile, + }, + remoteUpdatedAt, + }) onSave?.() } - }, [onSave, oneGist.data, selectedFile, selectedGist?.id, updateSettings]) + }, [onSave, oneGist.data, selectedFile, selectedGist?.id, patchSettings]) // eslint-disable-next-line react-hooks/preserve-manual-memoization const handleUpdateGist = useCallback(async () => { try { - await gistUpdate.mutateAsync({ + const result = await gistUpdate.mutateAsync({ gist_id: selectedGist?.id || settings?.gist?.id || settings?.gistId || '', description: settings?.gist?.description, files: { @@ -197,15 +201,20 @@ function GistList({ onSave }: GistListProps) { }, }) + const remoteUpdatedAt = toMs((result?.data as any)?.updated_at) || Date.now() oneGist.refetch() setSelectedFile(newFileName) setIsCreateFile(false) - updateSettings(set('gist.fileName', newFileName)(settings)) + // 推送了新文件后更新本地的 fileName 指向 + 服务端时间戳基线 + patchSettings({ + gist: { ...(settings.gist || {} as any), fileName: newFileName }, + remoteUpdatedAt, + }) } catch (err: any) { toast.error(err.toString()) } - }, [gistUpdate, selectedGist?.id, settings, newFileName, oneGist, updateSettings]) + }, [gistUpdate, selectedGist?.id, settings, newFileName, oneGist, patchSettings]) const disabled = useMemo(() => { // @ts-expect-error Preserving logic despite type mismatch diff --git a/src/types/setting.type.ts b/src/types/setting.type.ts index d4887eb..3b571f6 100644 --- a/src/types/setting.type.ts +++ b/src/types/setting.type.ts @@ -29,8 +29,17 @@ export interface Block { export interface Setting { links: Block[] + /** + * 本地业务时间戳:每次用户改动时单调递增(max(Date.now(), prev+1))。 + * 用途:判断"本地是否有未推送修改"——当 updatedAt > remoteUpdatedAt 时即为有。 + */ updatedAt: number createdAt: number + /** + * 上一次成功 pull/push 完成时 GitHub Gist 的 updated_at(毫秒)。 + * 作为同步基线:服务端权威时间戳,避免依赖客户端时钟。 + */ + remoteUpdatedAt?: number general: { language: string }