import { fetchLabelCategoryPage } from '../services/labelCategory' import { fetchProductCategoryPage } from '../services/productCategory' import { usAppFetchLocationDetail, usAppFetchMyLocations, usAppFetchMyProfile } from '../services/usAppAuth' import { fetchUsAppLabelingTree, fetchUsAppPrintLogList, prefetchUsAppLabelPreviewsForLocation, } from '../services/usAppLabeling' import { fetchGlobalSupportContact } from '../services/locationSupport' import { getAccessToken, getBoundLocations, isLoggedIn, setBoundLocations, } from './authSession' import { getCurrentStoreId } from './stores' import { clearFailedOfflineMutations, getOfflineMutationById, getOfflineSyncSummary, getPendingOfflineMutations, getPendingOfflineMutationsPreview, initOfflineSqlite, isNetworkOnline, markOfflineMutationDone, markOfflineMutationFailed, removeOfflineMutationById, refreshOfflineAuthAccountSession, } from './sqliteSync' import type { MutationRow } from './sqliteSync' import { usAppApiRequest } from './usAppApiRequest' export type OfflineSyncStats = { online: boolean lastSyncAt: number labels: { total: number; synced: number; pending: number } photos: { total: number; synced: number; pending: number } pendingMutations: number } export type OfflinePendingMutationPreview = { id: number method: 'POST' | 'PUT' | 'DELETE' endpoint: string createdAt: number lastError: string | null } export type OfflineSyncExecutionDetail = { total: number success: number failed: number at: number } const KEY_LAST_SYNC_AT = '__offline_last_sync_at__' const KEY_LAST_SYNC_EXECUTION = '__offline_last_sync_execution__' function getLastSyncAt(): number { return Number(uni.getStorageSync(KEY_LAST_SYNC_AT) || 0) } function setLastSyncAt(value: number): void { uni.setStorageSync(KEY_LAST_SYNC_AT, value) } function getLastSyncExecutionDetail(): OfflineSyncExecutionDetail { try { const raw = String(uni.getStorageSync(KEY_LAST_SYNC_EXECUTION) || '').trim() if (!raw) return { total: 0, success: 0, failed: 0, at: 0 } const p = JSON.parse(raw) as Partial return { total: Number(p.total || 0), success: Number(p.success || 0), failed: Number(p.failed || 0), at: Number(p.at || 0), } } catch { return { total: 0, success: 0, failed: 0, at: 0 } } } function setLastSyncExecutionDetail(detail: OfflineSyncExecutionDetail): void { uni.setStorageSync(KEY_LAST_SYNC_EXECUTION, JSON.stringify(detail)) } export async function performInitialOfflineSync(): Promise { await initOfflineSqlite() const online = await isNetworkOnline() if (!online) return const profile = await usAppFetchMyProfile() if (profile.fullName?.trim()) { uni.setStorageSync('userName', profile.fullName.trim()) } const locations = await usAppFetchMyLocations() setBoundLocations(locations) const currentStoreId = getCurrentStoreId() || locations?.[0]?.id || '' const targetLocationIds = Array.from( new Set( [currentStoreId, ...locations.map((x) => x.id).filter(Boolean)].filter((x) => !!String(x).trim()) ) ) for (const locId of targetLocationIds) { // 每个门店一组缓存,确保离线切店后仍有可读数据 const [, treeResult] = await Promise.allSettled([ usAppFetchLocationDetail(locId), fetchUsAppLabelingTree({ locationId: locId }), fetchUsAppPrintLogList({ locationId: locId, skipCount: 1, maxResultCount: 20 }), ]) if (treeResult.status === 'fulfilled' && Array.isArray(treeResult.value)) { await prefetchUsAppLabelPreviewsForLocation(locId, treeResult.value) } } await Promise.allSettled([ fetchLabelCategoryPage({ skipCount: 1, maxResultCount: 100, state: true }), fetchProductCategoryPage({ skipCount: 1, maxResultCount: 100, state: true }), fetchGlobalSupportContact(), ]) if (isLoggedIn()) { const email = String(uni.getStorageSync('user_email') || '').trim() if (email) { await refreshOfflineAuthAccountSession({ email, token: getAccessToken(), refreshToken: String(uni.getStorageSync('refresh_token') || ''), locations: getBoundLocations(), displayName: String(uni.getStorageSync('userName') || email), }) } } setLastSyncAt(Date.now()) } export async function flushPendingMutations(): Promise { const online = await isNetworkOnline() if (!online) throw new Error('No network connection') await initOfflineSqlite() const list = await getPendingOfflineMutations() let ok = 0 for (const row of list) { try { const payload = JSON.parse(row.payloadJson || 'null') await usAppApiRequest({ path: row.endpoint, method: row.method as 'POST' | 'PUT' | 'DELETE', auth: true, data: payload, }) await markOfflineMutationDone(row.id) ok += 1 } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e) await markOfflineMutationFailed(row.id, msg) } } return ok } async function flushPendingMutationsWithDetail(): Promise { const online = await isNetworkOnline() if (!online) throw new Error('No network connection') await initOfflineSqlite() const list = await getPendingOfflineMutations() let success = 0 let failed = 0 for (const row of list) { try { const payload = JSON.parse(row.payloadJson || 'null') await usAppApiRequest({ path: row.endpoint, method: row.method as 'POST' | 'PUT' | 'DELETE', auth: true, data: payload, }) await markOfflineMutationDone(row.id) success += 1 } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e) await markOfflineMutationFailed(row.id, msg) failed += 1 } } const d: OfflineSyncExecutionDetail = { total: list.length, success, failed, at: Date.now(), } setLastSyncExecutionDetail(d) return d } export async function syncNowAndRefreshCaches(): Promise { // 先回放本地待同步变更,再拉最新云端数据刷新本地缓存,避免“刚同步又被旧缓存覆盖” await flushPendingMutationsWithDetail() await performInitialOfflineSync() setLastSyncAt(Date.now()) return getOfflineSyncStats() } export async function getOfflineSyncStats(): Promise { const online = await isNetworkOnline() const summary = await getOfflineSyncSummary() const lastSyncAt = getLastSyncAt() return { online, lastSyncAt, labels: { total: summary.cacheCount, synced: summary.cacheCount, pending: summary.pendingMutationCount, }, photos: { total: 0, synced: 0, pending: 0, }, pendingMutations: summary.pendingMutationCount, } } function toPreviewRow(x: MutationRow): OfflinePendingMutationPreview { return { id: x.id, method: x.method, endpoint: x.endpoint, createdAt: x.createdAt, lastError: x.lastError ?? null, } } export async function getOfflinePendingMutationPreviewList(limit = 20): Promise { const rows = await getPendingOfflineMutationsPreview(limit) return rows.map(toPreviewRow) } export function getOfflineLastSyncExecutionDetail(): OfflineSyncExecutionDetail { return getLastSyncExecutionDetail() } export async function retryOfflineMutationById(id: number): Promise<{ ok: boolean; message?: string }> { const online = await isNetworkOnline() if (!online) return { ok: false, message: 'No network connection' } await initOfflineSqlite() const row = await getOfflineMutationById(id) if (!row) return { ok: false, message: 'Queue item not found' } try { const payload = JSON.parse(row.payloadJson || 'null') await usAppApiRequest({ path: row.endpoint, method: row.method as 'POST' | 'PUT' | 'DELETE', auth: true, data: payload, }) await markOfflineMutationDone(row.id) return { ok: true } } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e) await markOfflineMutationFailed(row.id, msg) return { ok: false, message: msg || 'Retry failed' } } } export async function removeOfflineMutation(id: number): Promise { await removeOfflineMutationById(id) } export async function clearFailedOfflineMutationItems(): Promise { return clearFailedOfflineMutations() }