feat(saas): Phase 4 端到端完善 — 设备注册、离线支持、配置迁移、集成测试
- 后端: devices 表 + register/heartbeat/list 端点 (UPSERT 语义) - 桌面端: 设备 ID 持久化 + 5 分钟心跳 + 离线状态指示 - saas-client: 重试逻辑 (2 次指数退避) + isServerReachable 跟踪 - ConfigMigrationWizard: 3 步向导 (方向选择→冲突解决→结果) - SaaSSettings: 修改密码折叠面板 + 迁移向导入口 - 集成测试: 21 个测试全部通过 (含设备注册/UPSERT/心跳、密码修改、E2E 生命周期) - 修复 ConfigMigrationWizard merge 分支变量遮蔽 bug
This commit is contained in:
@@ -28,6 +28,20 @@ import { createLogger } from '../lib/logger';
|
||||
|
||||
const log = createLogger('SaaSStore');
|
||||
|
||||
// === Device ID ===
|
||||
|
||||
/** Generate or load a persistent device ID for this browser instance */
|
||||
function getOrCreateDeviceId(): string {
|
||||
const KEY = 'zclaw-device-id';
|
||||
const existing = localStorage.getItem(KEY);
|
||||
if (existing) return existing;
|
||||
const newId = crypto.randomUUID();
|
||||
localStorage.setItem(KEY, newId);
|
||||
return newId;
|
||||
}
|
||||
|
||||
const DEVICE_ID = getOrCreateDeviceId();
|
||||
|
||||
// === Types ===
|
||||
|
||||
export type ConnectionMode = 'tauri' | 'gateway' | 'saas';
|
||||
@@ -49,6 +63,7 @@ export interface SaaSActionsSlice {
|
||||
logout: () => void;
|
||||
setConnectionMode: (mode: ConnectionMode) => void;
|
||||
fetchAvailableModels: () => Promise<void>;
|
||||
registerCurrentDevice: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
restoreSession: () => void;
|
||||
}
|
||||
@@ -138,6 +153,11 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Register device and start heartbeat in background
|
||||
get().registerCurrentDevice().catch((err: unknown) => {
|
||||
log.warn('Failed to register device:', err);
|
||||
});
|
||||
|
||||
// Fetch available models in background (non-blocking)
|
||||
get().fetchAvailableModels().catch((err: unknown) => {
|
||||
log.warn('Failed to fetch models after login:', err);
|
||||
@@ -209,6 +229,10 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
error: null,
|
||||
});
|
||||
|
||||
get().registerCurrentDevice().catch((err: unknown) => {
|
||||
log.warn('Failed to register device after register:', err);
|
||||
});
|
||||
|
||||
get().fetchAvailableModels().catch((err: unknown) => {
|
||||
log.warn('Failed to fetch models after register:', err);
|
||||
});
|
||||
@@ -271,6 +295,41 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
registerCurrentDevice: async () => {
|
||||
const { isLoggedIn, authToken, saasUrl } = get();
|
||||
|
||||
if (!isLoggedIn || !authToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saasClient.setBaseUrl(saasUrl);
|
||||
saasClient.setToken(authToken);
|
||||
await saasClient.registerDevice({
|
||||
device_id: DEVICE_ID,
|
||||
device_name: `${navigator.userAgent.split(' ').slice(0, 3).join(' ')}`,
|
||||
platform: navigator.platform,
|
||||
app_version: __APP_VERSION__ || 'unknown',
|
||||
});
|
||||
log.info('Device registered successfully');
|
||||
|
||||
// Start periodic heartbeat (every 5 minutes)
|
||||
if (typeof window !== 'undefined' && !get()._heartbeatTimer) {
|
||||
const timer = window.setInterval(() => {
|
||||
const state = get();
|
||||
if (state.isLoggedIn && state.authToken) {
|
||||
saasClient.deviceHeartbeat(DEVICE_ID).catch(() => {});
|
||||
} else {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
set({ _heartbeatTimer: timer } as unknown as Partial<SaaSStore>);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log.warn('Failed to register device:', err);
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
set({ error: null });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user