feat: 实现小程序离线巡检与可靠同步

This commit is contained in:
weijuesen
2026-08-14 09:03:48 +08:00
parent 6b222abb7b
commit 69ef5a0704
19 changed files with 543 additions and 19 deletions
+18 -4
View File
@@ -1,15 +1,17 @@
import Taro from '@tarojs/taro';
import { getApiBaseUrl } from './config';
import { get } from './request';
import { get, refreshSession } from './request';
import type { InspectionRecord } from '@/types';
export const uploadInspection = (
filePath: string,
roomId?: string,
idempotencyKey?: string,
retried?: boolean,
): Promise<InspectionRecord> => {
const token = Taro.getStorageSync('token');
const idempotencyKey = `insp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const header: Record<string, string> = { 'Idempotency-Key': idempotencyKey };
const key = idempotencyKey || `insp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const header: Record<string, string> = { 'Idempotency-Key': key };
if (token) {
header['Authorization'] = `Bearer ${token}`;
}
@@ -24,8 +26,20 @@ export const uploadInspection = (
success: (res) => {
try {
const body = JSON.parse(res.data || '{}');
if (res.statusCode === 401 && !retried) {
refreshSession().then((ok) => {
if (ok) {
uploadInspection(filePath, roomId, key, true).then(resolve, reject);
} else {
reject(new Error('登录已过期,请重新登录'));
}
});
return;
}
if (res.statusCode >= 400) {
reject(new Error(body?.error || `上传失败 (${res.statusCode})`));
const err = new Error(body?.error || `上传失败 (${res.statusCode})`) as Error & { statusCode?: number };
err.statusCode = res.statusCode;
reject(err);
return;
}
resolve(body as InspectionRecord);
+29
View File
@@ -7,6 +7,7 @@ export interface RequestOptions {
data?: Record<string, unknown>;
params?: Record<string, unknown>;
header?: Record<string, string>;
_retried?: boolean;
}
function buildQueryString(params?: Record<string, unknown>): string {
@@ -21,6 +22,28 @@ function buildQueryString(params?: Record<string, unknown>): string {
return `?${qs}`;
}
export async function refreshSession(): Promise<boolean> {
const refreshToken = Taro.getStorageSync('refreshToken');
if (!refreshToken) return false;
try {
const res = await Taro.request({
url: `${getApiBaseUrl()}/auth/refresh`,
method: 'POST',
data: { refreshToken },
header: { 'Content-Type': 'application/json' },
timeout: 15000,
});
const body = res.data as { accessToken?: string; refreshToken?: string; user?: unknown };
if (res.statusCode !== 200 || !body.accessToken) return false;
Taro.setStorageSync('token', body.accessToken);
if (body.refreshToken) Taro.setStorageSync('refreshToken', body.refreshToken);
if (body.user) Taro.setStorageSync('user', body.user);
return true;
} catch {
return false;
}
}
export async function request<T = unknown>(options: RequestOptions): Promise<T> {
const token = Taro.getStorageSync('token');
const baseUrl = getApiBaseUrl();
@@ -46,6 +69,12 @@ export async function request<T = unknown>(options: RequestOptions): Promise<T>
});
if (res.statusCode === 401) {
if (!options._retried) {
options._retried = true;
if (await refreshSession()) {
return request<T>(options);
}
}
console.warn('[Request] 401 未授权,清除登录状态');
Taro.removeStorageSync('token');
Taro.removeStorageSync('refreshToken');
@@ -120,6 +120,33 @@
margin: $spacing-lg 0 $spacing-md;
}
.queueMeta {
font-size: $font-size-xs;
color: $color-text-tertiary;
margin-bottom: $spacing-sm;
}
.offlineCard {
background: #fff8e6;
border-radius: $radius-md;
padding: $spacing-md;
margin-bottom: $spacing-sm;
box-shadow: $shadow-card;
}
.offlineStatus {
font-size: $font-size-xs;
color: $color-warning;
font-weight: $font-weight-semibold;
}
.offlineError {
display: block;
font-size: $font-size-xs;
color: $color-error;
margin-top: $spacing-xs;
}
.historyList {
display: flex;
flex-direction: column;
+66 -6
View File
@@ -2,7 +2,10 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Button, Image, ScrollView, Text, View } from '@tarojs/components';
import Taro, { usePullDownRefresh } from '@tarojs/taro';
import styles from './index.module.scss';
import { listInspections, uploadInspection } from '@/api/inspections';
import { listInspections } from '@/api/inspections';
import { offlineQueue } from '@/services/offlineQueueApp';
import type { OfflineInspectionItem } from '@/services/offlineQueue';
import { useStore } from '@/store/useStore';
import { formatRelativeTime } from '@/utils/format';
import type { InspectionRecord } from '@/types';
@@ -14,11 +17,19 @@ const CLASS_LABELS: Record<string, string> = {
const classLabel = (cls: string) => CLASS_LABELS[cls] || cls;
const InspectionPage: React.FC = () => {
const syncOfflineQueue = useStore((s) => s.syncOfflineQueue);
const [imagePath, setImagePath] = useState('');
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<InspectionRecord | null>(null);
const [history, setHistory] = useState<InspectionRecord[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [offlineItems, setOfflineItems] = useState<OfflineInspectionItem[]>([]);
const [queueStats, setQueueStats] = useState({ total: 0, pending: 0, synced: 0, failed: 0, conflict: 0, remaining: 30 });
const refreshQueue = useCallback(() => {
setOfflineItems(offlineQueue.list());
setQueueStats(offlineQueue.stats());
}, []);
const loadHistory = useCallback(async () => {
setLoadingHistory(true);
@@ -31,13 +42,38 @@ const InspectionPage: React.FC = () => {
}, []);
useEffect(() => {
refreshQueue();
loadHistory();
}, [loadHistory]);
syncOfflineQueue().then(() => {
refreshQueue();
loadHistory();
});
}, [loadHistory, refreshQueue, syncOfflineQueue]);
usePullDownRefresh(() => {
loadHistory().then(() => Taro.stopPullDownRefresh());
syncOfflineQueue()
.then(() => {
refreshQueue();
loadHistory();
})
.finally(() => Taro.stopPullDownRefresh());
});
const persistImage = (tempPath: string) =>
new Promise<string>((resolve) => {
const taroAny = Taro as any;
const fs = taroAny.getFileSystemManager?.();
if (!fs || !fs.saveFile) {
resolve(tempPath);
return;
}
fs.saveFile({
tempFilePath: tempPath,
success: (res: { savedFilePath: string }) => resolve(res.savedFilePath),
fail: () => resolve(tempPath),
});
});
const handleChooseImage = () => {
Taro.chooseImage({
count: 1,
@@ -60,9 +96,11 @@ const InspectionPage: React.FC = () => {
}
setUploading(true);
try {
const rec = await uploadInspection(imagePath);
setResult(rec);
Taro.showToast({ title: rec.aiStatus === 'done' ? '检测完成' : '检测失败', icon: 'none' });
const savedPath = await persistImage(imagePath);
offlineQueue.enqueue(savedPath);
Taro.showToast({ title: '已保存到离线队列', icon: 'none' });
await syncOfflineQueue();
refreshQueue();
loadHistory();
} catch (err) {
Taro.showToast({
@@ -136,6 +174,28 @@ const InspectionPage: React.FC = () => {
) : null}
<Text className={styles.sectionTitle}></Text>
<Text className={styles.sectionTitle}>线 {queueStats.remaining}</Text>
<View className={styles.queueMeta}>
{queueStats.pending} · {queueStats.failed} · {queueStats.conflict}
</View>
{offlineItems.map((item) => (
<View key={item.id} className={styles.offlineCard}>
<View className={styles.historyHeader}>
<Text className={styles.offlineStatus}>{item.state}</Text>
<Text className={styles.historyTime}>{item.idempotencyKey.slice(0, 18)}</Text>
</View>
{item.error ? <Text className={styles.offlineError}>{item.error}</Text> : null}
</View>
))}
<Button
className={styles.btnPrimary}
onClick={() => {
offlineQueue.clearSynced();
refreshQueue();
}}
>
</Button>
<View className={styles.historyList}>
{history.map((rec) => (
<View key={rec.id} className={styles.historyCard}>
+99
View File
@@ -0,0 +1,99 @@
import * as assert from 'node:assert/strict';
import { test } from 'node:test';
import { createOfflineQueue, type QueueStorage } from './offlineQueue';
function memoryStorage(): QueueStorage {
const data = new Map<string, string>();
return {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => void data.set(key, value),
removeItem: (key) => void data.delete(key),
};
}
test('offline queue persists and restores pending items', () => {
const storage = memoryStorage();
const queue = createOfflineQueue({
storage,
uploader: async () => ({ id: 'server-1' }),
now: () => 1000,
idGenerator: () => 'id-1',
});
queue.enqueue('/tmp/a.png', 'room-1');
const restored = createOfflineQueue({
storage,
uploader: async () => ({ id: 'server-1' }),
now: () => 2000,
idGenerator: () => 'id-2',
});
assert.equal(restored.list().length, 1);
assert.equal(restored.list()[0].idempotencyKey, 'insp-id-1');
});
test('offline queue deduplicates repeated clicks', () => {
const queue = createOfflineQueue({
storage: memoryStorage(),
uploader: async () => ({ id: 'server-1' }),
now: () => 1000,
idGenerator: () => 'id-1',
});
const first = queue.enqueue('/tmp/a.png', 'room-1');
const second = queue.enqueue('/tmp/a.png', 'room-1');
assert.equal(first.id, second.id);
assert.equal(queue.stats().total, 1);
});
test('offline queue marks permanent 4xx as failed', async () => {
const queue = createOfflineQueue({
storage: memoryStorage(),
uploader: async () => {
const err: Error & { statusCode?: number } = new Error('bad request');
err.statusCode = 400;
throw err;
},
now: () => 1000,
idGenerator: () => 'id-1',
});
queue.enqueue('/tmp/a.png');
const result = await queue.syncOnce();
assert.equal(result.synced, 0);
assert.equal(queue.list()[0].state, 'failed');
});
test('offline queue retries with backoff and fails permanently', async () => {
let attempts = 0;
let now = 1000;
const queue = createOfflineQueue({
storage: memoryStorage(),
uploader: async () => {
attempts += 1;
throw new Error('network');
},
now: () => now,
idGenerator: () => 'id-1',
maxAttempts: 2,
backoffBase: 1000,
});
queue.enqueue('/tmp/a.png');
await queue.syncOnce();
assert.equal(queue.list()[0].state, 'pending');
assert.equal(queue.list()[0].attempts, 1);
now = 5000;
await queue.syncOnce();
assert.equal(queue.list()[0].state, 'failed');
assert.equal(queue.list()[0].attempts, 2);
});
test('offline queue removes synced references', async () => {
const queue = createOfflineQueue({
storage: memoryStorage(),
uploader: async () => ({ id: 'server-1' }),
now: () => 1000,
idGenerator: () => 'id-1',
});
queue.enqueue('/tmp/a.png');
await queue.syncOnce();
assert.equal(queue.stats().synced, 1);
assert.equal(queue.clearSynced(), 1);
assert.equal(queue.stats().total, 0);
});
+196
View File
@@ -0,0 +1,196 @@
export type OfflineState = 'pending' | 'uploading' | 'synced' | 'conflict' | 'failed';
export interface OfflineInspectionItem {
id: string;
idempotencyKey: string;
imagePath: string;
roomId?: string;
state: OfflineState;
attempts: number;
nextAttemptAt: number;
error?: string;
serverId?: string;
createdAt: number;
updatedAt: number;
}
export interface QueueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export interface QueueUploadInput {
imagePath: string;
roomId?: string;
idempotencyKey: string;
}
export interface QueueUploader {
(input: QueueUploadInput): Promise<{ id: string }>;
}
export interface OfflineQueueDeps {
storage: QueueStorage;
uploader: QueueUploader;
now?: () => number;
idGenerator?: () => string;
maxItems?: number;
maxAttempts?: number;
backoffBase?: number;
}
const STORAGE_KEY = 'offline_inspection_queue';
export const OFFLINE_QUEUE_STORAGE_KEY = STORAGE_KEY;
function loadQueue(storage: QueueStorage): OfflineInspectionItem[] {
const raw = storage.getItem(STORAGE_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveQueue(storage: QueueStorage, items: OfflineInspectionItem[]): void {
storage.setItem(STORAGE_KEY, JSON.stringify(items));
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function createOfflineQueue(deps: OfflineQueueDeps) {
const now = deps.now || (() => Date.now());
const idGenerator = deps.idGenerator || (() => `offline-${now()}-${Math.random().toString(36).slice(2, 10)}`);
const maxItems = deps.maxItems || 30;
const maxAttempts = deps.maxAttempts || 8;
const backoffBase = deps.backoffBase || 30000;
const enqueue = (imagePath: string, roomId?: string): OfflineInspectionItem => {
const items = loadQueue(deps.storage);
const duplicate = items.find(
(item) =>
item.imagePath === imagePath &&
item.roomId === roomId &&
(item.state === 'pending' || item.state === 'uploading'),
);
if (duplicate) return duplicate;
if (items.length >= maxItems) {
throw new Error('离线巡检队列已满,请先清理已同步记录');
}
const timestamp = now();
const item: OfflineInspectionItem = {
id: idGenerator(),
idempotencyKey: `insp-${idGenerator()}`,
imagePath,
roomId,
state: 'pending',
attempts: 0,
nextAttemptAt: timestamp,
createdAt: timestamp,
updatedAt: timestamp,
};
items.unshift(item);
saveQueue(deps.storage, items);
return item;
};
const list = (): OfflineInspectionItem[] => loadQueue(deps.storage);
const remove = (id: string): void => {
saveQueue(deps.storage, loadQueue(deps.storage).filter((item) => item.id !== id));
};
const clearSynced = (): number => {
const items = loadQueue(deps.storage);
const before = items.length;
saveQueue(deps.storage, items.filter((item) => item.state !== 'synced'));
return before - loadQueue(deps.storage).length;
};
const retry = (id: string): OfflineInspectionItem | null => {
const items = loadQueue(deps.storage);
const item = items.find((entry) => entry.id === id);
if (!item || item.state === 'synced') return null;
item.state = 'pending';
item.error = undefined;
item.nextAttemptAt = now();
item.updatedAt = now();
saveQueue(deps.storage, items);
return item;
};
const syncOnce = async (): Promise<{
synced: number;
item?: OfflineInspectionItem;
error?: string;
}> => {
const items = loadQueue(deps.storage);
const item = items.find((entry) => entry.state === 'pending' && entry.nextAttemptAt <= now());
if (!item) return { synced: 0 };
item.state = 'uploading';
item.updatedAt = now();
saveQueue(deps.storage, items);
try {
const result = await deps.uploader({
imagePath: item.imagePath,
roomId: item.roomId,
idempotencyKey: item.idempotencyKey,
});
const updated = loadQueue(deps.storage);
const current = updated.find((entry) => entry.id === item.id);
if (current) {
current.state = 'synced';
current.serverId = result.id;
current.error = undefined;
current.updatedAt = now();
saveQueue(deps.storage, updated);
}
return { synced: 1, item: current || item };
} catch (err) {
const updated = loadQueue(deps.storage);
const current = updated.find((entry) => entry.id === item.id);
if (!current) return { synced: 0, error: errorMessage(err) };
current.attempts += 1;
current.error = errorMessage(err);
const status = (err as { statusCode?: number }).statusCode;
const permanent =
status !== undefined &&
status >= 400 &&
status < 500 &&
status !== 401 &&
status !== 408 &&
status !== 429;
if (permanent) {
current.state = status === 409 ? 'conflict' : 'failed';
} else if (current.attempts >= maxAttempts) {
current.state = 'failed';
} else {
current.state = 'pending';
current.nextAttemptAt = now() + backoffBase * Math.min(Math.pow(2, current.attempts - 1), 16);
}
current.updatedAt = now();
saveQueue(deps.storage, updated);
return { synced: 0, item: current, error: current.error };
}
};
const stats = () => {
const items = loadQueue(deps.storage);
return {
total: items.length,
pending: items.filter((item) => item.state === 'pending' || item.state === 'uploading').length,
synced: items.filter((item) => item.state === 'synced').length,
failed: items.filter((item) => item.state === 'failed').length,
conflict: items.filter((item) => item.state === 'conflict').length,
remaining: Math.max(0, maxItems - items.length),
};
};
return { enqueue, list, remove, clearSynced, retry, syncOnce, stats };
}
export type OfflineQueue = ReturnType<typeof createOfflineQueue>;
+19
View File
@@ -0,0 +1,19 @@
import Taro from '@tarojs/taro';
import { uploadInspection } from '@/api/inspections';
import { createOfflineQueue, OFFLINE_QUEUE_STORAGE_KEY } from './offlineQueue';
const storage = {
getItem: (key: string) => {
const value = Taro.getStorageSync(key);
return typeof value === 'string' && value ? value : null;
},
setItem: (key: string, value: string) => Taro.setStorageSync(key, value),
removeItem: (key: string) => Taro.removeStorageSync(key),
};
export const offlineQueue = createOfflineQueue({
storage,
uploader: (input) => uploadInspection(input.imagePath, input.roomId, input.idempotencyKey),
});
export { OFFLINE_QUEUE_STORAGE_KEY };
+9
View File
@@ -3,6 +3,7 @@ import Taro from '@tarojs/taro';
import type { User } from '@/types';
import { getCurrentUser } from '@/api/auth';
import { wsManager } from '@/utils/ws';
import { offlineQueue } from '@/services/offlineQueueApp';
interface AppState {
token: string | null;
@@ -13,6 +14,7 @@ interface AppState {
logout: () => void;
isLoggedIn: () => boolean;
restoreSession: () => Promise<void>;
syncOfflineQueue: () => Promise<void>;
}
export const useStore = create<AppState>((set, get) => ({
@@ -49,4 +51,11 @@ export const useStore = create<AppState>((set, get) => ({
}
set({ isReady: true });
},
syncOfflineQueue: async () => {
let synced = 0;
do {
const result = await offlineQueue.syncOnce();
synced = result.synced;
} while (synced > 0);
},
}));