diff --git a/scripts/vite-plugin-miniapps.ts b/scripts/vite-plugin-miniapps.ts index fc7f4e887..004ac75c0 100644 --- a/scripts/vite-plugin-miniapps.ts +++ b/scripts/vite-plugin-miniapps.ts @@ -324,7 +324,11 @@ function scanRemoteMiniappsForBuild( ): Array { if (!existsSync(miniappsPath)) return []; - const configByDirName = new Map(remoteConfigs.map((c) => [c.dirName, c])); + const configByDirName = new Map( + remoteConfigs + .map((c) => (c.build?.locale ? [c.build.locale.dirName, c] : null)) + .filter((item): item is [string, RemoteMiniappConfig] => item !== null), + ); const remoteApps: Array< MiniappManifest & { url: string; runtime?: MiniappRuntime; wujieConfig?: WujieRuntimeConfig } > = []; diff --git a/scripts/vite-plugin-remote-miniapps.ts b/scripts/vite-plugin-remote-miniapps.ts index 2215201f3..ecdec8a93 100644 --- a/scripts/vite-plugin-remote-miniapps.ts +++ b/scripts/vite-plugin-remote-miniapps.ts @@ -20,12 +20,26 @@ import type { WujieRuntimeConfig } from '../src/services/ecosystem/types'; type MiniappRuntime = 'iframe' | 'wujie'; +interface MiniappLocaleConfig { + metadataUrl: string; + dirName: string; +} + +interface MiniappRemoteSourceConfig { + name: string; + sourceUrl: string; +} + interface MiniappServerConfig { + locale?: MiniappLocaleConfig; + remote?: MiniappRemoteSourceConfig; runtime?: MiniappRuntime; wujieConfig?: WujieRuntimeConfig; } interface MiniappBuildConfig { + locale?: MiniappLocaleConfig; + remote?: MiniappRemoteSourceConfig; runtime?: MiniappRuntime; wujieConfig?: WujieRuntimeConfig; /** @@ -39,8 +53,6 @@ interface MiniappBuildConfig { } export interface RemoteMiniappConfig { - metadataUrl: string; - dirName: string; server?: MiniappServerConfig; build?: MiniappBuildConfig; } @@ -105,26 +117,49 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug const servers: RemoteMiniappServer[] = []; const downloadFailures: string[] = []; + const resolveLocale = (config: RemoteMiniappConfig, target: 'server' | 'build'): MiniappLocaleConfig | null => { + const section = target === 'build' ? config.build : config.server; + if (!section) return null; + if (section.locale && section.remote) { + throw new Error(`[remote-miniapps] ${target} config can only set one of locale or remote`); + } + return section.locale ?? null; + }; + + const getLocaleTargets = (target: 'server' | 'build') => + miniapps + .map((config) => { + const locale = resolveLocale(config, target); + return locale ? { config, locale } : null; + }) + .filter((item): item is { config: RemoteMiniappConfig; locale: MiniappLocaleConfig } => item !== null); + return { name: 'vite-plugin-remote-miniapps', configResolved(config) { root = config.root; isBuild = config.command === 'build'; + + for (const cfg of miniapps) { + resolveLocale(cfg, 'server'); + resolveLocale(cfg, 'build'); + } }, async buildStart() { - if (miniapps.length === 0) return; + const targets = getLocaleTargets(isBuild ? 'build' : 'server'); + if (targets.length === 0) return; const miniappsPath = resolve(root, miniappsDir); - for (const config of miniapps) { + for (const { config, locale } of targets) { try { - await downloadAndExtract(config, miniappsPath, fetchOptions); + await downloadAndExtract(locale, miniappsPath, fetchOptions); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); - console.error(`[remote-miniapps] ❌ Failed to download ${config.dirName}: ${errorMsg}`); - downloadFailures.push(config.dirName); + console.error(`[remote-miniapps] ❌ Failed to download ${locale.dirName}: ${errorMsg}`); + downloadFailures.push(locale.dirName); } } @@ -143,20 +178,21 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug const miniappsOutputDir = resolve(outputOptions.dir, 'miniapps'); const missing: string[] = []; - for (const config of miniapps) { - const srcDir = join(miniappsPath, config.dirName); - const destDir = join(miniappsOutputDir, config.dirName); + const targets = getLocaleTargets('build'); + for (const { config, locale } of targets) { + const srcDir = join(miniappsPath, locale.dirName); + const destDir = join(miniappsOutputDir, locale.dirName); if (existsSync(srcDir)) { mkdirSync(destDir, { recursive: true }); cpSync(srcDir, destDir, { recursive: true }); - console.log(`[remote-miniapps] ✅ Copied ${config.dirName} to dist`); + console.log(`[remote-miniapps] ✅ Copied ${locale.dirName} to dist`); if (config.build?.injectBaseTag) { const basePath = typeof config.build.injectBaseTag === 'string' ? config.build.injectBaseTag - : `/miniapps/${config.dirName}/`; + : `/miniapps/${locale.dirName}/`; rewriteHtmlBase(destDir, basePath); } @@ -169,7 +205,7 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug } } } else { - missing.push(config.dirName); + missing.push(locale.dirName); } } @@ -182,27 +218,28 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug }, async configureServer(server) { - if (miniapps.length === 0) return; + const targets = getLocaleTargets('server'); + if (targets.length === 0) return; const miniappsPath = resolve(root, miniappsDir); - for (const config of miniapps) { + for (const { locale } of targets) { try { - await downloadAndExtract(config, miniappsPath, fetchOptions); + await downloadAndExtract(locale, miniappsPath, fetchOptions); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); - console.warn(`[remote-miniapps] ⚠️ Failed to download ${config.dirName} (dev mode): ${errorMsg}`); + console.warn(`[remote-miniapps] ⚠️ Failed to download ${locale.dirName} (dev mode): ${errorMsg}`); continue; } } // 启动静态服务器为每个远程 miniapp - for (const config of miniapps) { - const miniappDir = join(miniappsPath, config.dirName); + for (const { config, locale } of targets) { + const miniappDir = join(miniappsPath, locale.dirName); const manifestPath = join(miniappDir, 'manifest.json'); if (!existsSync(manifestPath)) { - console.warn(`[remote-miniapps] ${config.dirName}: manifest.json not found, skipping`); + console.warn(`[remote-miniapps] ${locale.dirName}: manifest.json not found, skipping`); continue; } @@ -214,7 +251,7 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug const serverInfo: RemoteMiniappServer = { id: manifest.id, - dirName: config.dirName, + dirName: locale.dirName, port, server: httpServer, baseUrl, @@ -250,15 +287,15 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug // ==================== Helpers ==================== async function downloadAndExtract( - config: RemoteMiniappConfig, + locale: MiniappLocaleConfig, miniappsPath: string, fetchOptions: FetchWithEtagOptions = {}, ): Promise { - const targetDir = join(miniappsPath, config.dirName); + const targetDir = join(miniappsPath, locale.dirName); - console.log(`[remote-miniapps] Syncing ${config.dirName}...`); + console.log(`[remote-miniapps] Syncing ${locale.dirName}...`); - const metadataBuffer = await fetchWithEtag(config.metadataUrl, fetchOptions); + const metadataBuffer = await fetchWithEtag(locale.metadataUrl, fetchOptions); const metadata = JSON.parse(metadataBuffer.toString('utf-8')) as RemoteMetadata; const localManifestPath = join(targetDir, 'manifest.json'); @@ -267,17 +304,17 @@ async function downloadAndExtract( _zipEtag?: string; }; if (localManifest.version === metadata.version && localManifest._zipEtag) { - const baseUrl = config.metadataUrl.replace(/\/[^/]+$/, ''); + const baseUrl = locale.metadataUrl.replace(/\/[^/]+$/, ''); const zipUrl = metadata.zipUrl.startsWith('.') ? `${baseUrl}/${metadata.zipUrl.slice(2)}` : metadata.zipUrl; try { const headResponse = await fetch(zipUrl, { method: 'HEAD' }); const remoteEtag = headResponse.headers.get('etag') || ''; if (remoteEtag === localManifest._zipEtag) { - console.log(`[remote-miniapps] ${config.dirName} is up-to-date (v${metadata.version}, etag match)`); + console.log(`[remote-miniapps] ${locale.dirName} is up-to-date (v${metadata.version}, etag match)`); return; } console.log( - `[remote-miniapps] ${config.dirName} zip changed (etag: ${localManifest._zipEtag} -> ${remoteEtag})`, + `[remote-miniapps] ${locale.dirName} zip changed (etag: ${localManifest._zipEtag} -> ${remoteEtag})`, ); } catch { // HEAD request failed, continue with download @@ -285,7 +322,7 @@ async function downloadAndExtract( } } - const baseUrl = config.metadataUrl.replace(/\/[^/]+$/, ''); + const baseUrl = locale.metadataUrl.replace(/\/[^/]+$/, ''); const manifestUrl = metadata.manifestUrl.startsWith('.') ? `${baseUrl}/${metadata.manifestUrl.slice(2)}` : metadata.manifestUrl; @@ -316,10 +353,10 @@ async function downloadAndExtract( } } - const manifestWithDir = { ...manifest, dirName: config.dirName, _zipEtag: zipEtag }; + const manifestWithDir = { ...manifest, dirName: locale.dirName, _zipEtag: zipEtag }; writeFileSync(localManifestPath, JSON.stringify(manifestWithDir, null, 2)); - console.log(`[remote-miniapps] ${config.dirName} updated to v${manifest.version} (etag: ${zipEtag})`); + console.log(`[remote-miniapps] ${locale.dirName} updated to v${manifest.version} (etag: ${zipEtag})`); } function rewriteHtmlBase(targetDir: string, basePath: string): void { diff --git a/src/stores/ecosystem.ts b/src/stores/ecosystem.ts index 5f3298ef6..f36416396 100644 --- a/src/stores/ecosystem.ts +++ b/src/stores/ecosystem.ts @@ -66,13 +66,35 @@ export interface EcosystemState { const STORAGE_KEY = 'ecosystem_store'; const getDefaultSourceName = () => i18n.t('ecosystem:sources.defaultName'); +interface InjectedSource { + name: string; + url: string; + icon?: string; +} + +function getInjectedSources(): SourceRecord[] { + if (!Array.isArray(__ECOSYSTEM_SOURCES__)) return []; + const now = new Date().toISOString(); + + return __ECOSYSTEM_SOURCES__ + .filter((source): source is InjectedSource => Boolean(source?.url && source?.name)) + .map((source) => ({ + url: source.url, + name: source.name, + lastUpdated: now, + enabled: true, + status: 'idle' as const, + icon: source.icon, + })); +} + function arraysEqual(a: T[], b: T[]): boolean { return a.length === b.length && a.every((v, i) => v === b[i]); } function getDefaultSources(): SourceRecord[] { const defaultName = getDefaultSourceName(); - return [ + const defaults: SourceRecord[] = [ { url: `${import.meta.env.BASE_URL}miniapps/ecosystem.json`, name: defaultName, @@ -81,6 +103,14 @@ function getDefaultSources(): SourceRecord[] { status: 'idle' as const, }, ]; + + for (const injected of getInjectedSources()) { + if (!defaults.some((source) => source.url === injected.url)) { + defaults.push(injected); + } + } + + return defaults; } function mergeSourcesWithDefault(sources: SourceRecord[]): SourceRecord[] { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 6829f1e3f..d6729eba5 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -11,6 +11,9 @@ declare global { /** App 版本号 - 通过 vite.config.ts define 配置 */ const __APP_VERSION__: string + /** 默认生态源列表 - 通过 vite.config.ts define 配置 */ + const __ECOSYSTEM_SOURCES__: Array<{ name: string; url: string; icon?: string }> + /** API Keys 映射 - 通过 vite.config.ts / Storybook viteFinal define 配置 */ const __API_KEYS__: Record diff --git a/vite.config.ts b/vite.config.ts index 69e415516..8a58a9b9f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,19 +13,47 @@ import { buildCheckPlugin } from './scripts/vite-plugin-build-check'; const remoteMiniappsConfig: RemoteMiniappConfig[] = [ { - metadataUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/metadata.json', - dirName: 'rwa-hub', server: { + locale: { + metadataUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/metadata.json', + dirName: 'rwa-hub', + }, runtime: 'wujie', wujieConfig: { rewriteAbsolutePaths: true }, }, build: { + remote: { + name: 'RWA', + sourceUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/source.json', + }, runtime: 'wujie', wujieConfig: { rewriteAbsolutePaths: true }, }, }, ]; +type EcosystemSourceConfig = { name: string; url: string }; + +function collectEcosystemSources(configs: RemoteMiniappConfig[]): EcosystemSourceConfig[] { + const sources: EcosystemSourceConfig[] = []; + const seen = new Set(); + + for (const config of configs) { + const candidates = [config.server?.remote, config.build?.remote].filter( + (item): item is NonNullable<(typeof config.server)['remote']> => Boolean(item), + ); + for (const remote of candidates) { + if (!remote?.sourceUrl || seen.has(remote.sourceUrl)) continue; + seen.add(remote.sourceUrl); + sources.push({ name: remote.name, url: remote.sourceUrl }); + } + } + + return sources; +} + +const ecosystemSources = collectEcosystemSources(remoteMiniappsConfig); + function getPreferredLanIPv4(): string | undefined { const ifaces = networkInterfaces(); const ips: string[] = []; @@ -177,6 +205,8 @@ export default defineConfig(({ mode }) => { __DEV_MODE__: JSON.stringify((env.VITE_DEV_MODE ?? process.env.VITE_DEV_MODE) === 'true'), // App 版本号(stable=package.json,dev=追加 -MMDDHH,UTC) __APP_VERSION__: JSON.stringify(appVersion), + // 默认生态源列表(用于订阅源管理展示) + __ECOSYSTEM_SOURCES__: JSON.stringify(ecosystemSources), // API Keys 对象(用于动态读取环境变量) __API_KEYS__: JSON.stringify({ TRONGRID_API_KEY: tronGridApiKey,