chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View File

@@ -24,8 +24,17 @@ import {
type SaaSModelInfo,
type SaaSLoginResponse,
type TotpSetupResponse,
type SyncConfigRequest,
} from '../lib/saas-client';
import { createLogger } from '../lib/logger';
import {
initTelemetryCollector,
stopTelemetryCollector,
} from '../lib/telemetry-collector';
import {
startPromptOTASync,
stopPromptOTASync,
} from '../lib/llm-service';
const log = createLogger('SaaSStore');
@@ -58,6 +67,12 @@ export interface SaaSStateSlice {
error: string | null;
totpRequired: boolean;
totpSetupData: TotpSetupResponse | null;
/** Whether SaaS backend is currently reachable */
saasReachable: boolean;
/** Consecutive heartbeat/health-check failures */
_consecutiveFailures: number;
_heartbeatTimer?: ReturnType<typeof setInterval>;
_healthCheckTimer?: ReturnType<typeof setInterval>;
}
export interface SaaSActionsSlice {
@@ -67,6 +82,8 @@ export interface SaaSActionsSlice {
logout: () => void;
setConnectionMode: (mode: ConnectionMode) => void;
fetchAvailableModels: () => Promise<void>;
syncConfigFromSaaS: () => Promise<void>;
pushConfigToSaaS: () => Promise<void>;
registerCurrentDevice: () => Promise<void>;
clearError: () => void;
restoreSession: () => void;
@@ -118,19 +135,21 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
error: null,
totpRequired: false,
totpSetupData: null,
saasReachable: true,
_consecutiveFailures: 0,
// === Actions ===
login: async (saasUrl: string, username: string, password: string) => {
set({ isLoading: true, error: null });
try {
const trimmedUrl = saasUrl.trim();
const trimmedUsername = username.trim();
const trimmedUrl = saasUrl.trim();
const trimmedUsername = username.trim();
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
const requestUrl = normalizedUrl || window.location.origin;
if (!trimmedUrl) {
throw new Error('请输入服务器地址');
}
try {
// 空 trimmedUrl 表示走 Vite proxy开发模式允许通过
if (!trimmedUsername) {
throw new Error('请输入用户名');
}
@@ -138,8 +157,6 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
throw new Error('请输入密码');
}
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
// Configure singleton client and attempt login
saasClient.setBaseUrl(normalizedUrl);
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
@@ -172,6 +189,22 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
get().fetchAvailableModels().catch((err: unknown) => {
log.warn('Failed to fetch models after login:', err);
});
// Auto-pull SaaS config in background (non-blocking)
get().syncConfigFromSaaS().then(() => {
// After pull, push any locally modified configs back to SaaS
get().pushConfigToSaaS().catch((err: unknown) => {
log.warn('Failed to push config to SaaS:', err);
});
}).catch((err: unknown) => {
log.warn('Failed to sync config after login:', err);
});
// Initialize telemetry collector
initTelemetryCollector(DEVICE_ID);
// Start Prompt OTA sync (background, non-blocking)
startPromptOTASync(DEVICE_ID);
} catch (err: unknown) {
// Check for TOTP required signal
if (err instanceof SaaSApiError && err.code === 'TOTP_ERROR' && err.status === 400) {
@@ -191,7 +224,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|| message.includes('timeout');
const userMessage = isNetworkError
? `无法连接到 SaaS 服务器: ${get().saasUrl}`
? `无法连接到 SaaS 服务器: ${requestUrl}`
: message;
set({ isLoading: false, error: userMessage });
@@ -232,6 +265,12 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
get().fetchAvailableModels().catch((err: unknown) => {
log.warn('Failed to fetch models:', err);
});
// Initialize telemetry collector
initTelemetryCollector(DEVICE_ID);
// Start Prompt OTA sync (background, non-blocking)
startPromptOTASync(DEVICE_ID);
} catch (err: unknown) {
const message = err instanceof SaaSApiError ? err.message
: err instanceof Error ? err.message : String(err);
@@ -245,9 +284,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
try {
const trimmedUrl = saasUrl.trim();
if (!trimmedUrl) {
throw new Error('请输入服务器地址');
}
// 空 trimmedUrl 表示走 Vite proxy开发模式允许通过
if (!username.trim()) {
throw new Error('请输入用户名');
}
@@ -293,6 +330,12 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
get().fetchAvailableModels().catch((err: unknown) => {
log.warn('Failed to fetch models after register:', err);
});
// Initialize telemetry collector
initTelemetryCollector(DEVICE_ID);
// Start Prompt OTA sync
startPromptOTASync(DEVICE_ID);
} catch (err: unknown) {
const message = err instanceof SaaSApiError
? err.message
@@ -309,6 +352,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
saasClient.setToken(null);
clearSaaSSession();
saveConnectionMode('tauri');
stopTelemetryCollector();
stopPromptOTASync();
set({
isLoggedIn: false,
@@ -354,6 +399,131 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
}
},
/**
* Push locally modified configs to SaaS (push direction of bidirectional sync).
* Collects all "dirty" config keys, computes diff, and syncs via merge.
*/
pushConfigToSaaS: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
if (!isLoggedIn || !authToken) return;
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
// Collect all dirty config keys
const dirtyKeys: string[] = [];
const dirtyValues: Record<string, unknown> = {};
let i = 0;
while (true) {
const key = localStorage.key(i);
if (!key) break;
i++;
if (key.startsWith('zclaw-config-dirty.') && localStorage.getItem(key) === '1') {
const configKey = key.replace('zclaw-config-dirty.', '');
const storageKey = `zclaw-config.${configKey}`;
const value = localStorage.getItem(storageKey);
if (value !== null) {
dirtyKeys.push(configKey);
dirtyValues[configKey] = value;
}
}
}
if (dirtyKeys.length === 0) return;
// Generate a client fingerprint
const fingerprint = DEVICE_ID;
const syncRequest = {
client_fingerprint: fingerprint,
action: 'merge' as const,
config_keys: dirtyKeys,
client_values: dirtyValues,
};
// Compute diff first (dry run)
const diff = await saasClient.computeConfigDiff(syncRequest as SyncConfigRequest);
if (diff.conflicts > 0) {
log.warn(`Config sync has ${diff.conflicts} conflicts, using merge strategy`);
}
// Perform actual sync
const result = await saasClient.syncConfig(syncRequest);
log.info(`Config push result: ${result.updated} updated, ${result.created} created, ${result.skipped} skipped`);
// Clear dirty flags for successfully synced keys
for (const key of dirtyKeys) {
localStorage.removeItem(`zclaw-config-dirty.${key}`);
}
} catch (err: unknown) {
log.warn('Failed to push config to SaaS:', err);
}
},
/** Pull SaaS config and apply to local storage (startup auto-sync) */
syncConfigFromSaaS: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
if (!isLoggedIn || !authToken) return;
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
// Read last sync timestamp from localStorage
const lastSyncKey = 'zclaw-config-last-sync';
const lastSync = localStorage.getItem(lastSyncKey) || undefined;
const result = await saasClient.pullConfig(lastSync);
if (result.configs.length === 0) {
log.info('No config updates from SaaS');
return;
}
// Apply SaaS config values to localStorage
// Each config is stored as zclaw-config.{category}.{key}
for (const config of result.configs) {
if (config.value === null) continue;
const storageKey = `zclaw-config.${config.category}.${config.key}`;
const existing = localStorage.getItem(storageKey);
// Diff check: skip if local was modified since last pull
const lastPullKey = `zclaw-config-pull-ts.${config.category}.${config.key}`;
const dirtyKey = `zclaw-config-dirty.${config.category}.${config.key}`;
const lastPulledValue = localStorage.getItem(`zclaw-config-pulled.${config.category}.${config.key}`);
if (dirtyKey && localStorage.getItem(dirtyKey) === '1') {
// Local was modified since last pull → keep local, skip overwrite
log.warn(`Config conflict, keeping local: ${config.key}`);
continue;
}
// If existing value differs from what we last pulled AND differs from SaaS, local was modified
if (existing !== null && lastPulledValue !== null && existing !== lastPulledValue && existing !== config.value) {
log.warn(`Config conflict (local modified), keeping local: ${config.key}`);
continue;
}
// Only update if the value has actually changed
if (existing !== config.value) {
localStorage.setItem(storageKey, config.value);
// Record the pulled value for future diff checks
localStorage.setItem(`zclaw-config-pulled.${config.category}.${config.key}`, config.value);
log.info(`Config synced: ${config.key}`);
}
}
// Update last sync timestamp
localStorage.setItem(lastSyncKey, result.pulled_at);
log.info(`Synced ${result.configs.length} config items from SaaS`);
} catch (err: unknown) {
log.warn('Failed to sync config from SaaS:', err);
}
},
registerCurrentDevice: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
@@ -368,21 +538,43 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
device_id: DEVICE_ID,
device_name: `${navigator.userAgent.split(' ').slice(0, 3).join(' ')}`,
platform: navigator.platform,
app_version: __APP_VERSION__ || 'unknown',
app_version: (typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown'),
});
log.info('Device registered successfully');
// Start periodic heartbeat (every 5 minutes)
// Start periodic heartbeat (every 5 minutes) with failure tracking
if (typeof window !== 'undefined' && !get()._heartbeatTimer) {
const timer = window.setInterval(() => {
const DEGRADE_AFTER_FAILURES = 3; // Degrade after 3 consecutive failures (~15 min)
const timer = window.setInterval(async () => {
const state = get();
if (state.isLoggedIn && state.authToken) {
saasClient.deviceHeartbeat(DEVICE_ID).catch(() => {});
} else {
if (!state.isLoggedIn || !state.authToken) {
window.clearInterval(timer);
return;
}
try {
await saasClient.deviceHeartbeat(DEVICE_ID);
// Reset failure count on success
if (state._consecutiveFailures > 0) {
log.info(`Heartbeat recovered after ${state._consecutiveFailures} failures`);
}
set({ _consecutiveFailures: 0, saasReachable: true } as unknown as Partial<SaaSStore>);
} catch (err) {
const failures = state._consecutiveFailures + 1;
log.warn(`Heartbeat failed (${failures}/${DEGRADE_AFTER_FAILURES}): ${err}`);
set({ _consecutiveFailures: failures } as unknown as Partial<SaaSStore>);
// Auto-degrade to local mode after threshold
if (failures >= DEGRADE_AFTER_FAILURES && state.connectionMode === 'saas') {
log.warn(`SaaS unreachable after ${failures} attempts — degrading to local mode`);
set({
saasReachable: false,
connectionMode: 'tauri',
} as unknown as Partial<SaaSStore>);
saveConnectionMode('tauri');
}
}
}, 5 * 60 * 1000);
set({ _heartbeatTimer: timer } as unknown as Partial<SaaSStore>);
set({ _heartbeatTimer: timer, _consecutiveFailures: 0 } as unknown as Partial<SaaSStore>);
}
} catch (err: unknown) {
log.warn('Failed to register device:', err);
@@ -406,6 +598,11 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
});
get().fetchAvailableModels().catch(() => {});
get().syncConfigFromSaaS().then(() => {
get().pushConfigToSaaS().catch(() => {});
}).catch(() => {});
initTelemetryCollector(DEVICE_ID);
startPromptOTASync(DEVICE_ID);
}
},