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

@@ -146,6 +146,282 @@ export interface ConfigSyncResult {
skipped: number;
}
/** Paginated response wrapper */
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
page_size: number;
}
// === Prompt OTA Types ===
/** Prompt template info */
export interface PromptTemplateInfo {
id: string;
name: string;
category: string;
description: string | null;
source: string;
current_version: number;
status: string;
created_at: string;
updated_at: string;
}
/** Prompt version info */
export interface PromptVersionInfo {
id: string;
template_id: string;
version: number;
system_prompt: string;
user_prompt_template: string | null;
variables: PromptVariable[];
changelog: string | null;
min_app_version: string | null;
created_at: string;
}
/** Prompt variable definition */
export interface PromptVariable {
name: string;
type: string;
default_value?: string;
description?: string;
required?: boolean;
}
/** OTA update check result */
export interface PromptCheckResult {
updates: PromptUpdatePayload[];
server_time: string;
}
/** Single OTA update payload */
export interface PromptUpdatePayload {
name: string;
version: number;
system_prompt: string;
user_prompt_template: string | null;
variables: PromptVariable[];
source: string;
min_app_version: string | null;
changelog: string | null;
}
/** Provider info from GET /api/v1/providers */
export interface ProviderInfo {
id: string;
name: string;
display_name: string;
base_url: string;
api_protocol: string;
enabled: boolean;
rate_limit_rpm: number | null;
rate_limit_tpm: number | null;
created_at: string;
updated_at: string;
}
/** Create provider request */
export interface CreateProviderRequest {
name: string;
display_name: string;
base_url: string;
api_protocol?: string;
api_key?: string;
rate_limit_rpm?: number;
rate_limit_tpm?: number;
}
/** Update provider request */
export interface UpdateProviderRequest {
display_name?: string;
base_url?: string;
api_key?: string;
rate_limit_rpm?: number;
rate_limit_tpm?: number;
enabled?: boolean;
}
/** Model info from GET /api/v1/models */
export interface ModelInfo {
id: string;
provider_id: string;
model_id: string;
alias: string;
context_window: number;
max_output_tokens: number;
supports_streaming: boolean;
supports_vision: boolean;
enabled: boolean;
pricing_input: number;
pricing_output: number;
created_at: string;
updated_at: string;
}
/** Create model request */
export interface CreateModelRequest {
provider_id: string;
model_id: string;
alias: string;
context_window?: number;
max_output_tokens?: number;
supports_streaming?: boolean;
supports_vision?: boolean;
pricing_input?: number;
pricing_output?: number;
}
/** Update model request */
export interface UpdateModelRequest {
alias?: string;
context_window?: number;
max_output_tokens?: number;
supports_streaming?: boolean;
supports_vision?: boolean;
enabled?: boolean;
pricing_input?: number;
pricing_output?: number;
}
/** Account API key info */
export interface AccountApiKeyInfo {
id: string;
provider_id: string;
key_label: string | null;
permissions: string[];
enabled: boolean;
last_used_at: string | null;
created_at: string;
updated_at: string;
}
/** Create API key request */
export interface CreateApiKeyRequest {
provider_id: string;
key_value: string;
key_label?: string;
permissions?: string[];
}
/** Usage statistics */
export interface UsageStats {
total_input_tokens: number;
total_output_tokens: number;
total_requests: number;
by_provider: Record<string, { input_tokens: number; output_tokens: number; requests: number }>;
by_model: Record<string, { input_tokens: number; output_tokens: number; requests: number }>;
daily: Array<{ date: string; input_tokens: number; output_tokens: number; requests: number }>;
}
/** Account public info (extended) */
export interface AccountPublic {
id: string;
username: string;
email: string;
display_name: string;
role: string;
status: string;
totp_enabled: boolean;
last_login_at: string | null;
created_at: string;
}
/** Update account request */
export interface UpdateAccountRequest {
display_name?: string;
email?: string;
role?: string;
avatar_url?: string;
}
/** Token info */
export interface TokenInfo {
id: string;
name: string;
token_prefix: string;
permissions: string[];
last_used_at: string | null;
expires_at: string | null;
created_at: string;
token?: string;
}
/** Create token request */
export interface CreateTokenRequest {
name: string;
permissions: string[];
expires_days?: number;
}
/** Operation log info */
export interface OperationLogInfo {
id: number;
account_id: string | null;
action: string;
target_type: string | null;
target_id: string | null;
details: Record<string, unknown> | null;
ip_address: string | null;
created_at: string;
}
/** Dashboard statistics */
export interface DashboardStats {
total_accounts: number;
active_accounts: number;
tasks_today: number;
active_providers: number;
active_models: number;
tokens_today_input: number;
tokens_today_output: number;
}
/** Role info */
export interface RoleInfo {
id: string;
name: string;
description: string | null;
permissions: string[];
is_system: boolean;
created_at: string;
updated_at: string;
}
/** Create role request */
export interface CreateRoleRequest {
id: string;
name: string;
description?: string;
permissions: string[];
}
/** Update role request */
export interface UpdateRoleRequest {
name?: string;
description?: string;
permissions?: string[];
}
/** Permission template */
export interface PermissionTemplate {
id: string;
name: string;
description: string | null;
permissions: string[];
created_at: string;
updated_at: string;
}
/** Create template request */
export interface CreateTemplateRequest {
name: string;
description?: string;
permissions: string[];
}
// === Error Class ===
export class SaaSApiError extends Error {
@@ -258,6 +534,11 @@ export class SaaSClient {
return !!this.token;
}
/** Check if a path is an auth endpoint (avoid infinite refresh loop) */
private _isAuthEndpoint(path: string): boolean {
return path.includes('/auth/login') || path.includes('/auth/register') || path.includes('/auth/refresh');
}
// --- Core HTTP ---
/** Track whether the server appears reachable */
@@ -278,6 +559,7 @@ export class SaaSClient {
path: string,
body?: unknown,
timeoutMs = 15000,
_isRefreshRetry = false,
): Promise<T> {
const maxRetries = 2;
const baseDelay = 1000;
@@ -300,8 +582,31 @@ export class SaaSClient {
this._serverReachable = true;
// Handle 401 specially - caller may want to trigger re-auth
if (response.status === 401) {
// 401: 尝试刷新 Token 后重试 (防止递归)
if (response.status === 401 && !this._isAuthEndpoint(path) && !_isRefreshRetry) {
try {
const newToken = await this.refreshToken();
if (newToken) {
// Persist refreshed token to localStorage
try {
const raw = localStorage.getItem('zclaw-saas-session');
if (raw) {
const session = JSON.parse(raw);
session.token = newToken;
localStorage.setItem('zclaw-saas-session', JSON.stringify(session));
}
} catch { /* non-blocking */ }
return this.request<T>(method, path, body, timeoutMs, true);
}
} catch (refreshErr) {
// Token refresh failed — clear session and trigger logout
try {
const { clearSaaSSession } = require('./saas-client');
clearSaaSSession();
localStorage.removeItem('zclaw-connection-mode');
} catch { /* non-blocking */ }
throw new SaaSApiError(401, 'SESSION_EXPIRED', '会话已过期,请重新登录');
}
throw new SaaSApiError(401, 'UNAUTHORIZED', '认证已过期,请重新登录');
}
@@ -364,6 +669,8 @@ export class SaaSClient {
async login(username: string, password: string, totpCode?: string): Promise<SaaSLoginResponse> {
const body: Record<string, string> = { username, password };
if (totpCode) body.totp_code = totpCode;
// Clear stale token before login — avoid sending expired token on auth endpoint
this.token = null;
const data = await this.request<SaaSLoginResponse>(
'POST', '/api/v1/auth/login', body,
);
@@ -381,6 +688,8 @@ export class SaaSClient {
password: string;
display_name?: string;
}): Promise<SaaSLoginResponse> {
// Clear stale token before register
this.token = null;
const result = await this.request<SaaSLoginResponse>(
'POST', '/api/v1/auth/register', data,
);
@@ -449,10 +758,14 @@ export class SaaSClient {
/**
* Send a heartbeat to indicate the device is still active.
* Also sends platform and app_version so the backend can detect client upgrades.
*/
async deviceHeartbeat(deviceId: string): Promise<void> {
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown';
await this.request<unknown>('POST', '/api/v1/devices/heartbeat', {
device_id: deviceId,
platform: typeof navigator !== 'undefined' ? navigator.platform : undefined,
app_version: appVersion,
});
}
@@ -460,7 +773,8 @@ export class SaaSClient {
* List devices registered for the current account.
*/
async listDevices(): Promise<DeviceInfo[]> {
return this.request<DeviceInfo[]>('GET', '/api/v1/devices');
const res = await this.request<{ items: DeviceInfo[] }>('GET', '/api/v1/devices');
return res.items;
}
// --- Model Endpoints ---
@@ -501,6 +815,7 @@ export class SaaSClient {
* Send a chat completion request via the SaaS relay.
* Returns the raw Response object to support both streaming and non-streaming.
*
* Includes one retry on 401 (auto token refresh) and on network errors.
* The caller is responsible for:
* - Reading the response body (JSON or SSE stream)
* - Handling errors from the response
@@ -509,27 +824,59 @@ export class SaaSClient {
body: unknown,
signal?: AbortSignal,
): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
const maxAttempts = 2; // 1 initial + 1 retry
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
// Use caller's AbortSignal if provided, otherwise default 5min timeout
const effectiveSignal = signal ?? AbortSignal.timeout(300_000);
try {
const response = await fetch(
`${this.baseUrl}/api/v1/relay/chat/completions`,
{
method: 'POST',
headers,
body: JSON.stringify(body),
signal: effectiveSignal,
},
);
// On 401, attempt token refresh once
if (response.status === 401 && attempt === 0 && !this._isAuthEndpoint('/api/v1/relay/chat/completions')) {
try {
const newToken = await this.refreshToken();
if (newToken) continue; // Retry with refreshed token
} catch {
// Refresh failed, return the 401 response
}
}
this._serverReachable = true;
return response;
} catch (err: unknown) {
this._serverReachable = false;
const isNetworkError = err instanceof TypeError
&& (err.message.includes('Failed to fetch') || err.message.includes('NetworkError'));
if (isNetworkError && attempt < maxAttempts - 1) {
// Brief backoff before retry
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
continue;
}
throw err;
}
}
// Use caller's AbortSignal if provided, otherwise default 5min timeout
const effectiveSignal = signal ?? AbortSignal.timeout(300_000);
const response = await fetch(
`${this.baseUrl}/api/v1/relay/chat/completions`,
{
method: 'POST',
headers,
body: JSON.stringify(body),
signal: effectiveSignal,
},
);
return response;
// Unreachable but TypeScript needs it
throw new Error('chatCompletion: all attempts exhausted');
}
// --- Config Endpoints ---
@@ -539,7 +886,8 @@ export class SaaSClient {
*/
async listConfig(category?: string): Promise<SaaSConfigItem[]> {
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
return this.request<SaaSConfigItem[]>('GET', `/api/v1/config/items${qs}`);
const res = await this.request<{ items: SaaSConfigItem[] }>('GET', `/api/v1/config/items${qs}`);
return res.items;
}
/** Compute config diff between client and SaaS (read-only) */
@@ -551,6 +899,302 @@ export class SaaSClient {
async syncConfig(request: SyncConfigRequest): Promise<ConfigSyncResult> {
return this.request<ConfigSyncResult>('POST', '/api/v1/config/sync', request);
}
/**
* Pull all config items from SaaS (for startup auto-sync).
* Returns configs updated since the given timestamp, or all if since is omitted.
*/
async pullConfig(since?: string): Promise<{
configs: Array<{
key: string
category: string
value: string | null
value_type: string
default: string | null
updated_at: string
}>
pulled_at: string
}> {
const qs = since ? `?since=${encodeURIComponent(since)}` : '';
return this.request('GET', '/api/v1/config/pull' + qs);
}
// --- Provider Management (Admin) ---
/** List all providers */
async listProviders(): Promise<ProviderInfo[]> {
return this.request<ProviderInfo[]>('GET', '/api/v1/providers');
}
/** Get provider by ID */
async getProvider(id: string): Promise<ProviderInfo> {
return this.request<ProviderInfo>('GET', `/api/v1/providers/${id}`);
}
/** Create a new provider (admin only) */
async createProvider(data: CreateProviderRequest): Promise<ProviderInfo> {
return this.request<ProviderInfo>('POST', '/api/v1/providers', data);
}
/** Update a provider (admin only) */
async updateProvider(id: string, data: UpdateProviderRequest): Promise<ProviderInfo> {
return this.request<ProviderInfo>('PATCH', `/api/v1/providers/${id}`, data);
}
/** Delete a provider (admin only) */
async deleteProvider(id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/providers/${id}`);
}
// --- Model Management (Admin) ---
/** List models, optionally filtered by provider */
async listModelsAdmin(providerId?: string): Promise<ModelInfo[]> {
const qs = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
return this.request<ModelInfo[]>('GET', `/api/v1/models${qs}`);
}
/** Get model by ID */
async getModel(id: string): Promise<ModelInfo> {
return this.request<ModelInfo>('GET', `/api/v1/models/${id}`);
}
/** Create a new model (admin only) */
async createModel(data: CreateModelRequest): Promise<ModelInfo> {
return this.request<ModelInfo>('POST', '/api/v1/models', data);
}
/** Update a model (admin only) */
async updateModel(id: string, data: UpdateModelRequest): Promise<ModelInfo> {
return this.request<ModelInfo>('PATCH', `/api/v1/models/${id}`, data);
}
/** Delete a model (admin only) */
async deleteModel(id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/models/${id}`);
}
// --- Account API Keys ---
/** List account's API keys */
async listApiKeys(providerId?: string): Promise<AccountApiKeyInfo[]> {
const qs = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
return this.request<AccountApiKeyInfo[]>('GET', `/api/v1/keys${qs}`);
}
/** Create a new API key */
async createApiKey(data: CreateApiKeyRequest): Promise<AccountApiKeyInfo> {
return this.request<AccountApiKeyInfo>('POST', '/api/v1/keys', data);
}
/** Rotate an API key */
async rotateApiKey(id: string, newKeyValue: string): Promise<void> {
await this.request<void>('POST', `/api/v1/keys/${id}/rotate`, { new_key_value: newKeyValue });
}
/** Revoke an API key */
async revokeApiKey(id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/keys/${id}`);
}
// --- Usage Statistics ---
/** Get usage statistics for current account */
async getUsage(params?: { from?: string; to?: string; provider_id?: string; model_id?: string }): Promise<UsageStats> {
const qs = new URLSearchParams();
if (params?.from) qs.set('from', params.from);
if (params?.to) qs.set('to', params.to);
if (params?.provider_id) qs.set('provider_id', params.provider_id);
if (params?.model_id) qs.set('model_id', params.model_id);
const query = qs.toString();
return this.request<UsageStats>('GET', `/api/v1/usage${query ? '?' + query : ''}`);
}
// --- Account Management (Admin) ---
/** List all accounts (admin only) */
async listAccounts(params?: { page?: number; page_size?: number; role?: string; status?: string; search?: string }): Promise<PaginatedResponse<AccountPublic>> {
const qs = new URLSearchParams();
if (params?.page) qs.set('page', String(params.page));
if (params?.page_size) qs.set('page_size', String(params.page_size));
if (params?.role) qs.set('role', params.role);
if (params?.status) qs.set('status', params.status);
if (params?.search) qs.set('search', params.search);
const query = qs.toString();
return this.request<PaginatedResponse<AccountPublic>>('GET', `/api/v1/accounts${query ? '?' + query : ''}`);
}
/** Get account by ID (admin or self) */
async getAccount(id: string): Promise<AccountPublic> {
return this.request<AccountPublic>('GET', `/api/v1/accounts/${id}`);
}
/** Update account (admin or self) */
async updateAccount(id: string, data: UpdateAccountRequest): Promise<AccountPublic> {
return this.request<AccountPublic>('PATCH', `/api/v1/accounts/${id}`, data);
}
/** Update account status (admin only) */
async updateAccountStatus(id: string, status: 'active' | 'disabled' | 'suspended'): Promise<void> {
await this.request<void>('PATCH', `/api/v1/accounts/${id}/status`, { status });
}
// --- API Token Management ---
/** List API tokens for current account */
async listTokens(): Promise<TokenInfo[]> {
return this.request<TokenInfo[]>('GET', '/api/v1/tokens');
}
/** Create a new API token */
async createToken(data: CreateTokenRequest): Promise<TokenInfo> {
return this.request<TokenInfo>('POST', '/api/v1/tokens', data);
}
/** Revoke an API token */
async revokeToken(id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/tokens/${id}`);
}
// --- Operation Logs (Admin) ---
/** List operation logs (admin only) */
async listOperationLogs(params?: { page?: number; page_size?: number }): Promise<OperationLogInfo[]> {
const qs = new URLSearchParams();
if (params?.page) qs.set('page', String(params.page));
if (params?.page_size) qs.set('page_size', String(params.page_size));
const query = qs.toString();
return this.request<OperationLogInfo[]>('GET', `/api/v1/logs/operations${query ? '?' + query : ''}`);
}
// --- Dashboard Statistics (Admin) ---
/** Get dashboard statistics (admin only) */
async getDashboardStats(): Promise<DashboardStats> {
return this.request<DashboardStats>('GET', '/api/v1/stats/dashboard');
}
// --- Role Management (Admin) ---
/** List all roles */
async listRoles(): Promise<RoleInfo[]> {
return this.request<RoleInfo[]>('GET', '/api/v1/roles');
}
/** Get role by ID */
async getRole(id: string): Promise<RoleInfo> {
return this.request<RoleInfo>('GET', `/api/v1/roles/${id}`);
}
/** Create a new role (admin only) */
async createRole(data: CreateRoleRequest): Promise<RoleInfo> {
return this.request<RoleInfo>('POST', '/api/v1/roles', data);
}
/** Update a role (admin only) */
async updateRole(id: string, data: UpdateRoleRequest): Promise<RoleInfo> {
return this.request<RoleInfo>('PUT', `/api/v1/roles/${id}`, data);
}
/** Delete a role (admin only) */
async deleteRole(id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/roles/${id}`);
}
// --- Permission Templates ---
/** List permission templates */
async listPermissionTemplates(): Promise<PermissionTemplate[]> {
return this.request<PermissionTemplate[]>('GET', '/api/v1/permission-templates');
}
/** Get permission template by ID */
async getPermissionTemplate(id: string): Promise<PermissionTemplate> {
return this.request<PermissionTemplate>('GET', `/api/v1/permission-templates/${id}`);
}
/** Create a permission template (admin only) */
async createPermissionTemplate(data: CreateTemplateRequest): Promise<PermissionTemplate> {
return this.request<PermissionTemplate>('POST', '/api/v1/permission-templates', data);
}
/** Delete a permission template (admin only) */
async deletePermissionTemplate(id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/permission-templates/${id}`);
}
/** Apply permission template to accounts (admin only) */
async applyPermissionTemplate(templateId: string, accountIds: string[]): Promise<{ ok: boolean; applied_count: number }> {
return this.request<{ ok: boolean; applied_count: number }>('POST', `/api/v1/permission-templates/${templateId}/apply`, { account_ids: accountIds });
}
// === Prompt OTA ===
/** Check for prompt updates (OTA) */
async checkPromptUpdates(deviceId: string, currentVersions: Record<string, number>): Promise<PromptCheckResult> {
return this.request<PromptCheckResult>('POST', '/api/v1/prompts/check', {
device_id: deviceId,
versions: currentVersions,
});
}
/** List all prompt templates */
async listPrompts(params?: { category?: string; source?: string; status?: string; page?: number; page_size?: number }): Promise<PaginatedResponse<PromptTemplateInfo>> {
const qs = params ? '?' + new URLSearchParams(params as Record<string, string>).toString() : '';
return this.request<PaginatedResponse<PromptTemplateInfo>>('GET', `/api/v1/prompts${qs}`);
}
/** Get prompt template by name */
async getPrompt(name: string): Promise<PromptTemplateInfo> {
return this.request<PromptTemplateInfo>('GET', `/api/v1/prompts/${encodeURIComponent(name)}`);
}
/** List prompt versions */
async listPromptVersions(name: string): Promise<PromptVersionInfo[]> {
return this.request<PromptVersionInfo[]>('GET', `/api/v1/prompts/${encodeURIComponent(name)}/versions`);
}
/** Get specific prompt version */
async getPromptVersion(name: string, version: number): Promise<PromptVersionInfo> {
return this.request<PromptVersionInfo>('GET', `/api/v1/prompts/${encodeURIComponent(name)}/versions/${version}`);
}
// === Telemetry ===
/** Report anonymous usage telemetry (token counts only, no content) */
async reportTelemetry(data: {
device_id: string;
app_version: string;
entries: Array<{
model_id: string;
input_tokens: number;
output_tokens: number;
latency_ms?: number;
success: boolean;
error_type?: string;
timestamp: string;
connection_mode: string;
}>;
}): Promise<{ accepted: number; rejected: number }> {
return this.request<{ accepted: number; rejected: number }>(
'POST', '/api/v1/telemetry/report', data,
);
}
/** Report audit log summary (action types and counts only, no content) */
async reportAuditSummary(data: {
device_id: string;
entries: Array<{
action: string;
target: string;
result: string;
timestamp: string;
}>;
}): Promise<{ accepted: number; total: number }> {
return this.request<{ accepted: number; total: number }>(
'POST', '/api/v1/telemetry/audit', data,
);
}
}
// === Singleton ===