diff --git a/src/business/client.ts b/src/business/client.ts new file mode 100644 index 0000000..a23f90c --- /dev/null +++ b/src/business/client.ts @@ -0,0 +1,153 @@ +import { z } from "zod"; +import { Z } from "zod-class"; +import { CoreAPIClient, DBAPIClient } from "./api"; +import { makeStringProp, makeObjectProp } from "@/utils/vue-props"; + +export type ClientRef = string; +export const makeClientProp = (v?: any) => makeObjectProp(v); +export const makeClientRefProp = (v?: any) => + makeStringProp(v); +export const ClientRefZ = z.string().uuid(); + +/** + * Client 模型 + * 在这个系统中,每个节点都是平等的客户端 + * 不存在传统意义上的"后端",所有节点都可以相互通信 + */ +export class Client extends Z.class({ + id: ClientRefZ, + name: z.string(), + description: z.string().nullable().optional(), + rest_api_url: z.string().url(), + status: z.enum(["online", "offline", "unknown"]).default("unknown"), + created_at: z.string(), + updated_at: z.string(), + last_seen_at: z.string().nullable().optional(), +}) { + static coreApi: CoreAPIClient = new CoreAPIClient( + "/clients", + Client + ); + static dbApi: DBAPIClient = new DBAPIClient("clients", Client); + + /** + * 获取单个客户端 + */ + static async get(id: ClientRef): Promise { + return new Client( + (await Client.dbApi.from().select().eq("id", id).single()).data! + ); + } + + /** + * 获取所有客户端 + */ + static async list(): Promise { + const results = await Client.dbApi + .from() + .select() + .order("name", { ascending: true }); + return results.data!.map((item) => new Client(item)); + } + + /** + * Ping 客户端检查在线状态 + */ + async ping(): Promise<"online" | "offline"> { + try { + const response = await fetch(`${this.rest_api_url}/health`, { + method: "GET", + signal: AbortSignal.timeout(5000), // 5秒超时 + }); + return response.ok ? "online" : "offline"; + } catch (error) { + console.error(`[Client] Ping failed for ${this.id}:`, error); + return "offline"; + } + } + + /** + * 向远程客户端发送请求 + */ + async request(options: { + method: string; + path: string; + body?: any; + query?: Record; + }): Promise { + const { method, path, body, query } = options; + const url = new URL(`${this.rest_api_url}${path}`); + + if (query) { + Object.entries(query).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + } + + const config: RequestInit = { + method, + headers: { + "Content-Type": "application/json", + }, + }; + + if (body !== undefined) { + config.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, config); + + if (!response.ok) { + throw new Error( + `HTTP ${response.status}: ${response.statusText}` + ); + } + + return await response.json(); + } catch (error) { + console.error(`[Client] Request failed for ${this.id}:`, error); + throw error; + } + } + + /** + * 在远程客户端上启用插件 + */ + async enableExtension(extensionId: string): Promise { + await this.request({ + method: "POST", + path: `/extensions/${extensionId}/enable`, + body: { client_id: this.id }, + }); + } + + /** + * 在远程客户端上禁用插件 + */ + async disableExtension(extensionId: string): Promise { + await this.request({ + method: "POST", + path: `/extensions/${extensionId}/disable`, + body: { client_id: this.id }, + }); + } +} + +/** + * 创建客户端的表单 + */ +export class CreateClientForm extends Z.class({ + name: z.string().min(1), + description: z.string().optional(), + rest_api_url: z.string().url(), +}) { + async create(): Promise { + const result = await Client.coreApi.request({ + method: "POST", + path: "", + body: this, + }); + return new Client(result); + } +} diff --git a/src/business/extension.ts b/src/business/extension.ts index b5fc25e..997caf7 100644 --- a/src/business/extension.ts +++ b/src/business/extension.ts @@ -12,7 +12,7 @@ export const ExtensionRefZ = z.string(); export class Extension extends Z.class({ id: ExtensionRefZ, version: z.string(), - disabled: z.boolean().optional().default(false), + enabled: z.array(z.string()).optional().default([]), // uuid array for client IDs nickname: z.string().nullable(), config: z.looseObject({}).default({}), config_schema: z.looseObject({}).nullable(), @@ -40,20 +40,28 @@ export class Extension extends Z.class({ return results.data!.map((item) => new Extension(item)); } - async enable(): Promise { + async enable(clientId: string): Promise { + const updatedEnabled = [...this.enabled, clientId]; return Extension.coreApi.request({ method: "PUT", - path: `/${this.id}/disabled/false`, + path: `/${this.id}/enabled`, + body: updatedEnabled, }); } - async disable(): Promise { + async disable(clientId: string): Promise { + const updatedEnabled = this.enabled.filter(id => id !== clientId); return Extension.coreApi.request({ method: "PUT", - path: `/${this.id}/disabled/true`, + path: `/${this.id}/enabled`, + body: updatedEnabled, }); } + isEnabledForClient(clientId: string): boolean { + return this.enabled.includes(clientId); + } + async updateConfig(config?: Record): Promise { return Extension.coreApi.request({ method: "PUT", @@ -66,21 +74,19 @@ export class Extension extends Z.class({ export class InstallExtensionForm extends Z.class({ id: ExtensionRefZ, version: z.string().optional(), - disabled: z.boolean().optional(), + enabled: z.array(z.string()).optional(), // optional initial enabled client IDs }) { async install(): Promise { const params = new URLSearchParams(); if (this.version) { params.append("version", this.version); } - if (this.disabled !== undefined) { - params.append("disabled", String(this.disabled)); - } const path = `/${this.id}?${params.toString()}`; const result = await Extension.coreApi.request({ method: "POST", path: path, + body: this.enabled ? { enabled: this.enabled } : undefined, }); return new Extension(result); } diff --git a/src/business/extensionManager.ts b/src/business/extensionManager.ts new file mode 100644 index 0000000..7c845b3 --- /dev/null +++ b/src/business/extensionManager.ts @@ -0,0 +1,625 @@ +import { Extension, ExtensionRef } from "./extension"; +import { Client } from "./client"; +import { configUtils } from "@/config"; + +/** + * 插件生命周期状态 + */ +export enum ExtensionState { + DISCOVERED = "DISCOVERED", // 从数据库读取 + LOADING = "LOADING", // Module Federation 加载中 + LOADED = "LOADED", // 模块已加载,未初始化 + INITIALIZING = "INITIALIZING", // 调用 initialize 中 + READY = "READY", // 已初始化,等待激活 + ACTIVATING = "ACTIVATING", // 激活中 + ACTIVE = "ACTIVE", // 正在工作 + DEACTIVATING = "DEACTIVATING", // 停止工作,清理运行时资源 + UNLOADING = "UNLOADING", // 清理所有资源 + UNLOADED = "UNLOADED", // 已卸载 + ERROR = "ERROR", // 错误状态 +} + +/** + * 插件接口定义 + */ +export interface IExtension { + /** + * 初始化插件 + */ + initialize?(): Promise; + + /** + * 激活插件 + */ + activate?(): Promise; + + /** + * 停用插件 + */ + deactivate?(): Promise; + + /** + * 卸载插件 + */ + dispose?(): Promise; +} + +/** + * 插件实例,包含状态和元数据 + */ +export class ExtensionInstance { + extension: Extension; + state: ExtensionState; + module: IExtension | null = null; + error: Error | null = null; + retryCount: number = 0; + maxRetries: number = 3; + + constructor(extension: Extension) { + this.extension = extension; + this.state = ExtensionState.DISCOVERED; + } + + /** + * 设置状态 + */ + setState(state: ExtensionState): void { + console.log( + `[ExtensionManager] ${this.extension.id}: ${this.state} -> ${state}` + ); + this.state = state; + } + + /** + * 设置错误 + */ + setError(error: Error): void { + console.error(`[ExtensionManager] ${this.extension.id} error:`, error); + this.error = error; + this.state = ExtensionState.ERROR; + } + + /** + * 清除错误 + */ + clearError(): void { + this.error = null; + } + + /** + * 是否可以重试 + */ + canRetry(): boolean { + return this.retryCount < this.maxRetries; + } + + /** + * 增加重试计数 + */ + incrementRetry(): void { + this.retryCount++; + } + + /** + * 重置重试计数 + */ + resetRetry(): void { + this.retryCount = 0; + } +} + +/** + * ExtensionManager + * 负责插件的发现、加载、激活、停用、卸载 + */ +export class ExtensionManager { + private instances: Map = new Map(); + private clientId: string; + + constructor(clientId: string) { + this.clientId = clientId; + } + + /** + * 从数据库发现插件 + */ + async discoverExtensions(): Promise { + try { + const extensions = await Extension.list(); + for (const extension of extensions) { + if (!this.instances.has(extension.id)) { + this.instances.set(extension.id, new ExtensionInstance(extension)); + } + } + } catch (error) { + console.error("[ExtensionManager] Failed to discover extensions:", error); + throw error; + } + } + + /** + * 获取指定插件实例 + */ + getInstance(id: ExtensionRef): ExtensionInstance | undefined { + return this.instances.get(id); + } + + /** + * 获取所有插件实例 + */ + getAllInstances(): ExtensionInstance[] { + return Array.from(this.instances.values()); + } + + /** + * 获取当前客户端已启用的插件 + */ + getEnabledInstances(): ExtensionInstance[] { + return this.getAllInstances().filter((instance) => + instance.extension.isEnabledForClient(this.clientId) + ); + } + + /** + * 并行加载插件(使用 Module Federation Runtime) + * 使用 @module-federation/runtime 动态加载远程模块 + */ + private async loadExtensionModule( + instance: ExtensionInstance + ): Promise { + const extension = instance.extension; + + // 1. 动态注册远程模块 + const remoteName = `extension_${extension.id}`; + const remoteEntry = extension.config.entry_url as string; + + if (!remoteEntry) { + throw new Error(`Extension ${extension.id} has no entry_url configured`); + } + + console.log(`[ExtensionManager] Loading remote module: ${remoteName} from ${remoteEntry}`); + + // 2. 使用动态 import 加载远程入口 + // Module Federation Runtime 会自动处理远程模块的加载 + try { + // 检查是否已经加载过 + const existingScript = document.querySelector(`script[data-remote="${remoteName}"]`); + if (!existingScript) { + // 创建 script 标签加载远程入口 + await this.loadRemoteEntry(remoteName, remoteEntry); + } + + // 3. 导入远程模块的 Extension 导出 + const moduleUrl = `${remoteName}/Extension`; + console.log(`[ExtensionManager] Importing module: ${moduleUrl}`); + + // @ts-ignore - 动态 import 的类型无法推断 + const module = await import(/* @vite-ignore */ moduleUrl); + + // 返回默认导出或命名导出 + return module.default || module; + } catch (error) { + console.error(`[ExtensionManager] Failed to load module for extension ${extension.id}:`, error); + throw new Error(`Failed to load extension module: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * 加载远程入口文件 + * 动态创建 script 标签加载远程模块的入口文件 + */ + private loadRemoteEntry(remoteName: string, entry: string): Promise { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = entry; + script.type = 'module'; + script.setAttribute('data-remote', remoteName); + + script.onload = () => { + console.log(`[ExtensionManager] Remote entry loaded: ${entry}`); + resolve(); + }; + + script.onerror = (error) => { + console.error(`[ExtensionManager] Failed to load remote entry: ${entry}`, error); + reject(new Error(`Failed to load remote entry: ${entry}`)); + }; + + document.head.appendChild(script); + }); + } + + /** + * 加载单个插件 + */ + async loadExtension(id: ExtensionRef): Promise { + const instance = this.instances.get(id); + if (!instance) { + throw new Error(`Extension ${id} not found`); + } + + if (instance.state !== ExtensionState.DISCOVERED) { + console.warn( + `[ExtensionManager] Extension ${id} is not in DISCOVERED state` + ); + return; + } + + try { + instance.setState(ExtensionState.LOADING); + const module = await this.loadExtensionModule(instance); + instance.module = module; + instance.setState(ExtensionState.LOADED); + instance.clearError(); + instance.resetRetry(); + } catch (error) { + instance.setError(error as Error); + throw error; + } + } + + /** + * 初始化插件 + */ + async initializeExtension(id: ExtensionRef): Promise { + const instance = this.instances.get(id); + if (!instance) { + throw new Error(`Extension ${id} not found`); + } + + if (instance.state !== ExtensionState.LOADED) { + console.warn( + `[ExtensionManager] Extension ${id} is not in LOADED state` + ); + return; + } + + try { + instance.setState(ExtensionState.INITIALIZING); + if (instance.module?.initialize) { + await instance.module.initialize(); + } + instance.setState(ExtensionState.READY); + } catch (error) { + instance.setError(error as Error); + throw error; + } + } + + /** + * 激活插件 + */ + async activateExtension(id: ExtensionRef): Promise { + const instance = this.instances.get(id); + if (!instance) { + throw new Error(`Extension ${id} not found`); + } + + if (instance.state !== ExtensionState.READY) { + console.warn(`[ExtensionManager] Extension ${id} is not in READY state`); + return; + } + + try { + instance.setState(ExtensionState.ACTIVATING); + if (instance.module?.activate) { + await instance.module.activate(); + } + instance.setState(ExtensionState.ACTIVE); + } catch (error) { + instance.setError(error as Error); + throw error; + } + } + + /** + * 停用插件 + */ + async deactivateExtension(id: ExtensionRef): Promise { + const instance = this.instances.get(id); + if (!instance) { + throw new Error(`Extension ${id} not found`); + } + + if (instance.state !== ExtensionState.ACTIVE) { + console.warn( + `[ExtensionManager] Extension ${id} is not in ACTIVE state` + ); + return; + } + + try { + instance.setState(ExtensionState.DEACTIVATING); + if (instance.module?.deactivate) { + await instance.module.deactivate(); + } + instance.setState(ExtensionState.READY); + } catch (error) { + instance.setError(error as Error); + throw error; + } + } + + /** + * 卸载插件 + */ + async unloadExtension(id: ExtensionRef): Promise { + const instance = this.instances.get(id); + if (!instance) { + throw new Error(`Extension ${id} not found`); + } + + if (instance.state !== ExtensionState.READY) { + console.warn( + `[ExtensionManager] Extension ${id} is not in READY state` + ); + return; + } + + try { + instance.setState(ExtensionState.UNLOADING); + if (instance.module?.dispose) { + await instance.module.dispose(); + } + instance.module = null; + instance.setState(ExtensionState.UNLOADED); + } catch (error) { + instance.setError(error as Error); + throw error; + } + } + + /** + * 重试加载失败的插件 + */ + async retryExtension(id: ExtensionRef): Promise { + const instance = this.instances.get(id); + if (!instance) { + throw new Error(`Extension ${id} not found`); + } + + if (instance.state !== ExtensionState.ERROR) { + console.warn(`[ExtensionManager] Extension ${id} is not in ERROR state`); + return; + } + + if (!instance.canRetry()) { + throw new Error( + `Extension ${id} has exceeded max retry count (${instance.maxRetries})` + ); + } + + instance.incrementRetry(); + instance.clearError(); + instance.setState(ExtensionState.DISCOVERED); + + await this.loadExtension(id); + } + + /** + * 并行加载所有已启用的插件 + */ + async loadAllEnabled(): Promise { + const enabledInstances = this.getEnabledInstances().filter( + (instance) => instance.state === ExtensionState.DISCOVERED + ); + + console.log( + `[ExtensionManager] Loading ${enabledInstances.length} enabled extensions in parallel` + ); + + // 并行加载,错误隔离 + const results = await Promise.allSettled( + enabledInstances.map((instance) => this.loadExtension(instance.extension.id)) + ); + + // 统计结果 + const succeeded = results.filter((r) => r.status === "fulfilled").length; + const failed = results.filter((r) => r.status === "rejected").length; + + console.log( + `[ExtensionManager] Load completed: ${succeeded} succeeded, ${failed} failed` + ); + } + + /** + * 并行初始化所有已加载的插件 + */ + async initializeAllLoaded(): Promise { + const loadedInstances = this.getAllInstances().filter( + (instance) => instance.state === ExtensionState.LOADED + ); + + console.log( + `[ExtensionManager] Initializing ${loadedInstances.length} loaded extensions in parallel` + ); + + const results = await Promise.allSettled( + loadedInstances.map((instance) => + this.initializeExtension(instance.extension.id) + ) + ); + + const succeeded = results.filter((r) => r.status === "fulfilled").length; + const failed = results.filter((r) => r.status === "rejected").length; + + console.log( + `[ExtensionManager] Initialize completed: ${succeeded} succeeded, ${failed} failed` + ); + } + + /** + * 并行激活所有就绪的插件 + */ + async activateAllReady(): Promise { + const readyInstances = this.getEnabledInstances().filter( + (instance) => instance.state === ExtensionState.READY + ); + + console.log( + `[ExtensionManager] Activating ${readyInstances.length} ready extensions in parallel` + ); + + const results = await Promise.allSettled( + readyInstances.map((instance) => + this.activateExtension(instance.extension.id) + ) + ); + + const succeeded = results.filter((r) => r.status === "fulfilled").length; + const failed = results.filter((r) => r.status === "rejected").length; + + console.log( + `[ExtensionManager] Activate completed: ${succeeded} succeeded, ${failed} failed` + ); + } + + /** + * 启用插件(更新数据库并激活) + */ + async enableExtension(id: ExtensionRef): Promise { + const instance = this.instances.get(id); + if (!instance) { + throw new Error(`Extension ${id} not found`); + } + + // 更新数据库 + const updatedExtension = await instance.extension.enable(this.clientId); + instance.extension = updatedExtension; + + // 如果插件已经准备好,激活它 + if (instance.state === ExtensionState.READY) { + await this.activateExtension(id); + } + // 如果插件还未加载,加载并激活 + else if (instance.state === ExtensionState.DISCOVERED) { + await this.loadExtension(id); + await this.initializeExtension(id); + await this.activateExtension(id); + } + } + + /** + * 禁用插件(停用并更新数据库) + */ + async disableExtension(id: ExtensionRef): Promise { + const instance = this.instances.get(id); + if (!instance) { + throw new Error(`Extension ${id} not found`); + } + + // 如果插件正在运行,先停用 + if (instance.state === ExtensionState.ACTIVE) { + await this.deactivateExtension(id); + } + + // 更新数据库 + const updatedExtension = await instance.extension.disable(this.clientId); + instance.extension = updatedExtension; + } + + /** + * 为指定客户端启用插件 + * 如果是本地客户端,直接调用 enableExtension + * 如果是远程客户端,调用远程客户端的 REST API + */ + async enableExtensionForClient(id: ExtensionRef, clientId: string): Promise { + console.log(`[ExtensionManager] 为客户端 ${clientId} 启用插件 ${id}`); + + const instance = this.instances.get(id); + if (!instance) { + throw new Error(`Extension ${id} not found`); + } + + // 检查是否为本地客户端 + if (configUtils.isLocalClient(clientId)) { + // 本地客户端,直接激活 + await this.enableExtension(id); + } else { + // 远程客户端,调用远程 API + const client = await Client.get(clientId); + await client.enableExtension(id); + + // 更新本地实例的 extension 对象 + const updatedExtension = await instance.extension.enable(clientId); + instance.extension = updatedExtension; + } + + console.log(`[ExtensionManager] 插件 ${id} 已为客户端 ${clientId} 启用`); + } + + /** + * 为指定客户端禁用插件 + * 如果是本地客户端,直接调用 disableExtension + * 如果是远程客户端,调用远程客户端的 REST API + */ + async disableExtensionForClient(id: ExtensionRef, clientId: string): Promise { + console.log(`[ExtensionManager] 为客户端 ${clientId} 禁用插件 ${id}`); + + const instance = this.instances.get(id); + if (!instance) { + throw new Error(`Extension ${id} not found`); + } + + // 检查是否为本地客户端 + if (configUtils.isLocalClient(clientId)) { + // 本地客户端,直接停用 + await this.disableExtension(id); + } else { + // 远程客户端,调用远程 API + const client = await Client.get(clientId); + await client.disableExtension(id); + + // 更新本地实例的 extension 对象 + const updatedExtension = await instance.extension.disable(clientId); + instance.extension = updatedExtension; + } + + console.log(`[ExtensionManager] 插件 ${id} 已为客户端 ${clientId} 禁用`); + } + + /** + * 完整的启动流程 + */ + async startup(): Promise { + console.log("[ExtensionManager] Starting up..."); + + await this.discoverExtensions(); + await this.loadAllEnabled(); + await this.initializeAllLoaded(); + await this.activateAllReady(); + + console.log("[ExtensionManager] Startup completed"); + } + + /** + * 完整的关闭流程 + */ + async shutdown(): Promise { + console.log("[ExtensionManager] Shutting down..."); + + const activeInstances = this.getAllInstances().filter( + (instance) => instance.state === ExtensionState.ACTIVE + ); + + // 并行停用所有活动插件 + await Promise.allSettled( + activeInstances.map((instance) => + this.deactivateExtension(instance.extension.id) + ) + ); + + // 并行卸载所有就绪插件 + const readyInstances = this.getAllInstances().filter( + (instance) => instance.state === ExtensionState.READY + ); + + await Promise.allSettled( + readyInstances.map((instance) => + this.unloadExtension(instance.extension.id) + ) + ); + + console.log("[ExtensionManager] Shutdown completed"); + } +} diff --git a/src/components/extension/extensionCard/extensionCard.ts b/src/components/extension/extensionCard/extensionCard.ts index d4e18be..7516e0b 100644 --- a/src/components/extension/extensionCard/extensionCard.ts +++ b/src/components/extension/extensionCard/extensionCard.ts @@ -8,6 +8,11 @@ export const extensionCardProps = { required: true, default: () => Extension.parse({}), }, + clientId: { + type: String, + required: true, + default: "", + }, }; // --- Emits --- diff --git a/src/components/extension/extensionCard/extensionCard.vue b/src/components/extension/extensionCard/extensionCard.vue index 919b45a..20f8d13 100644 --- a/src/components/extension/extensionCard/extensionCard.vue +++ b/src/components/extension/extensionCard/extensionCard.vue @@ -31,15 +31,15 @@ const toggleModel = computed({ get: () => { return togglePromise.value ? togglePromise.value - : !props.extension.disabled; + : props.extension.isEnabledForClient(props.clientId); }, set: async (newValue: boolean) => { togglePromise.value = (async () => { - const updatedExtension = props.extension.disabled - ? await props.extension.enable() - : await props.extension.disable(); + const updatedExtension = props.extension.isEnabledForClient(props.clientId) + ? await props.extension.disable(props.clientId) + : await props.extension.enable(props.clientId); emit("toggle", updatedExtension); - return !updatedExtension.disabled; + return updatedExtension.isEnabledForClient(props.clientId); })(); }, }); diff --git a/src/config.ts b/src/config.ts index dd220f3..7a8af21 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,26 +1,140 @@ -import { reactive } from 'vue' +import { reactive, watch } from 'vue' +import { z } from 'zod' -export const CONFIG = reactive({ +/** + * 配置 Schema + */ +const ConfigSchema = z.object({ + INKCRE_CORE_URL: z.string().url(), + INKCRE_PGREST_URL: z.string().url(), + INKCRE_JWT_SECRET: z.string().min(1), + LOCAL_CLIENT_ID: z.string().uuid().nullable(), +}) + +type ConfigType = z.infer + +const CONFIG_STORAGE_KEY = 'inkcre_app_config' + +/** + * 从 localStorage 加载配置 + */ +function loadConfigFromStorage(): Partial { + try { + const stored = localStorage.getItem(CONFIG_STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) + console.log('[Config] 从 localStorage 加载配置') + return parsed + } + } catch (error) { + console.error('[Config] 加载配置失败:', error) + } + return {} +} + +/** + * 保存配置到 localStorage + */ +function saveConfigToStorage(config: Partial) { + try { + localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(config)) + console.log('[Config] 配置已保存到 localStorage') + } catch (error) { + console.error('[Config] 保存配置失败:', error) + } +} + +// 初始化配置对象 +export const CONFIG = reactive({ INKCRE_CORE_URL: "", INKCRE_PGREST_URL: "", INKCRE_JWT_SECRET: "", + LOCAL_CLIENT_ID: null, }) +// 从 localStorage 加载配置 +const storedConfig = loadConfigFromStorage() +Object.assign(CONFIG, storedConfig) + +// 从环境变量加载配置 if (import.meta.env.VITE_DEPLOY_TO === 'CLOUDFLARE') { try { const res = await fetch('/api/cf-env-vars') if (res.ok) { const env = await res.json() - CONFIG.INKCRE_CORE_URL = env.INKCRE_CORE_URL - CONFIG.INKCRE_PGREST_URL = env.INKCRE_PGREST_URL - CONFIG.INKCRE_JWT_SECRET = env.INKCRE_JWT_SECRET + CONFIG.INKCRE_CORE_URL = CONFIG.INKCRE_CORE_URL || env.INKCRE_CORE_URL + CONFIG.INKCRE_PGREST_URL = CONFIG.INKCRE_PGREST_URL || env.INKCRE_PGREST_URL + CONFIG.INKCRE_JWT_SECRET = CONFIG.INKCRE_JWT_SECRET || env.INKCRE_JWT_SECRET } } catch (e) { console.error('Failed to load CF env vars', e) } +} else { + CONFIG.INKCRE_CORE_URL = CONFIG.INKCRE_CORE_URL || import.meta.env.VITE_INKCRE_CORE_URL || "" + CONFIG.INKCRE_PGREST_URL = CONFIG.INKCRE_PGREST_URL || import.meta.env.VITE_INKCRE_PGREST_URL || "" + CONFIG.INKCRE_JWT_SECRET = CONFIG.INKCRE_JWT_SECRET || import.meta.env.VITE_INKCRE_JWT_SECRET || "" } -else { - CONFIG.INKCRE_CORE_URL = import.meta.env.VITE_INKCRE_CORE_URL - CONFIG.INKCRE_PGREST_URL = import.meta.env.VITE_INKCRE_PGREST_URL - CONFIG.INKCRE_JWT_SECRET = import.meta.env.VITE_INKCRE_JWT_SECRET + +// 监听配置变化并自动保存到 localStorage +watch(CONFIG, (newConfig) => { + saveConfigToStorage(newConfig) +}, { deep: true }) + +/** + * 配置管理工具函数 + */ +export const configUtils = { + /** + * 获取本地客户端 ID + */ + getLocalClientId(): string | null { + return CONFIG.LOCAL_CLIENT_ID + }, + + /** + * 设置本地客户端 ID + */ + setLocalClientId(clientId: string | null) { + CONFIG.LOCAL_CLIENT_ID = clientId + }, + + /** + * 检查指定的客户端 ID 是否为本地客户端 + */ + isLocalClient(clientId: string): boolean { + return CONFIG.LOCAL_CLIENT_ID === clientId + }, + + /** + * 重置配置 + */ + reset() { + CONFIG.INKCRE_CORE_URL = "" + CONFIG.INKCRE_PGREST_URL = "" + CONFIG.INKCRE_JWT_SECRET = "" + CONFIG.LOCAL_CLIENT_ID = null + localStorage.removeItem(CONFIG_STORAGE_KEY) + }, + + /** + * 导出配置(用于备份) + */ + export(): string { + return JSON.stringify(CONFIG, null, 2) + }, + + /** + * 导入配置(用于恢复) + */ + import(configJson: string) { + try { + const parsed = JSON.parse(configJson) + const validated = ConfigSchema.parse(parsed) + Object.assign(CONFIG, validated) + console.log('[Config] 配置导入成功') + } catch (error) { + console.error('[Config] 配置导入失败:', error) + throw new Error('无效的配置格式') + } + }, } diff --git a/src/views/extensions/extensions.scss b/src/views/extensions/extensions.scss index 188ddbe..8feb203 100644 --- a/src/views/extensions/extensions.scss +++ b/src/views/extensions/extensions.scss @@ -18,4 +18,14 @@ overflow-x: hidden; overflow-y: auto; } + + &__header { + width: 100%; + max-width: 380px; + display: flex; + flex-direction: row; + align-items: center; + gap: sys-var(space, md); + padding: sys-var(space, sm) 0; + } } diff --git a/src/views/extensions/extensions.vue b/src/views/extensions/extensions.vue index 6a138da..d9679d8 100644 --- a/src/views/extensions/extensions.vue +++ b/src/views/extensions/extensions.vue @@ -1,8 +1,9 @@