niucloud/admin/src/utils/addon-lang.ts
wangchen14709853322 5ed9a0ba81 v2.0-beta-20260626
v2框架公测版测试流程请看v2.0-beta.md
2026-07-01 12:25:30 +08:00

256 lines
8.6 KiB
TypeScript

/**
* 插件语言与 manifest 初始化(供 core shared admin-lang 与 addon-loader 共用)
*/
export interface AddonManifest {
key: string
version?: string
sharedVersion?: string
views?: Record<string, string>
components?: Record<string, string>
layout?: string
langBase?: string
}
let prodManifests: Record<string, AddonManifest> = {}
let installedAddonKeys: string[] = []
let prodReady = false
let prodInitPromise: Promise<void> | null = null
const addonModuleCache: Record<string, Promise<Record<string, unknown> | null>> = {}
const ADMIN_PREFIX = '/admin'
export function adminUrl(relative: string) {
if (!relative) return relative
if (relative.startsWith('http') || relative.startsWith('/admin/')) return relative
if (relative.startsWith('/assets/')) return `${ADMIN_PREFIX}${relative}`
if (relative.startsWith('./')) return `${ADMIN_PREFIX}/assets/addons/${relative.slice(2)}`
return `${ADMIN_PREFIX}/${relative.replace(/^\//, '')}`
}
function addonAssetUrl(addon: string, ...segments: string[]) {
const rel = ['assets', 'addons', addon, ...segments].join('/').replace(/\/+/g, '/')
return `${ADMIN_PREFIX}/${rel}`
}
export function resolveLangFile(app: string, path: string): string {
if (path === '/') return 'index'
let view = path.replace(/^(\/admin\/|\/site\/|\/)/, '').replace(/\.vue$/, '')
if (view.startsWith('views/')) view = view.slice('views/'.length)
if (app) {
if (view.startsWith(`${app}/`)) view = view.slice(app.length + 1)
else if (view === app) view = 'index'
}
return view.replaceAll('/', '.')
}
export function resolveRouteAddon(route: { meta: Record<string, unknown>; matched: Array<{ meta: Record<string, unknown> }> }): string {
if (route.meta.addon) return String(route.meta.addon)
for (let i = route.matched.length - 1; i >= 0; i--) {
const addon = route.matched[i].meta.addon
if (addon) return String(addon)
}
return ''
}
export function resolveRouteView(route: { meta: Record<string, unknown>; matched: Array<{ meta: Record<string, unknown> }>; path: string }): string {
if (route.meta.view) return String(route.meta.view)
for (let i = route.matched.length - 1; i >= 0; i--) {
const view = route.matched[i].meta.view
if (view) return String(view)
}
return route.path
}
function unwrapLangPack(pack: unknown): Record<string, string> {
if (!pack || typeof pack !== 'object') return {}
const obj = pack as Record<string, unknown>
if (obj.default && typeof obj.default === 'object') {
return unwrapLangPack(obj.default)
}
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(obj)) {
if (key === 'default' || key === '__esModule') continue
if (typeof value === 'string') result[key] = value
}
return Object.keys(result).length ? result : (obj as Record<string, string>)
}
function flattenLangPack(file: string, pack: Record<string, string>): Record<string, string> {
const data: Record<string, string> = {}
for (const [key, value] of Object.entries(pack)) {
if (typeof value !== 'string') continue
data[`${file}.${key}`] = value
if (file === 'common') data[key] = value
}
return data
}
export function inferAddonFromPath(path: string, knownAddons?: string[]): string {
const view = path.replace(/^(\/admin\/|\/site\/|\/)/, '')
const first = view.split('/').filter(Boolean)[0]
if (!first) return ''
const keys = knownAddons?.length ? knownAddons : getInstalledAddonKeys()
return keys.includes(first) ? first : ''
}
async function isAddonEntryAvailable(key: string): Promise<boolean> {
try {
const res = await fetch(adminUrl(`/assets/addons/${key}/index.js`), { method: 'HEAD' })
if (!res.ok) return false
const ct = (res.headers.get('content-type') || '').toLowerCase()
return !ct.includes('text/html')
} catch {
return false
}
}
export function isAddonInstalled(addon: string): boolean {
return !!addon && getInstalledAddonKeys().includes(addon)
}
export async function loadAddonModule(addon: string): Promise<Record<string, unknown> | null> {
if (!(addon in addonModuleCache)) {
addonModuleCache[addon] = (async () => {
try {
const url = adminUrl(`/assets/addons/${addon}/index.js`)
return await import(/* @vite-ignore */ url) as Record<string, unknown>
} catch {
return null
}
})()
}
return addonModuleCache[addon]
}
/** 清除插件模块缓存(布局切换时调用,确保样式重新注入) */
export function clearAddonModuleCache() {
for (const key of Object.keys(addonModuleCache)) {
delete addonModuleCache[key]
}
}
export function isAddonProdReady() {
return prodReady
}
export async function ensureAddonProdReady() {
if (!prodReady) await initAddonManifests()
}
export async function initAddonManifests(keys?: string[]) {
if (import.meta.env.DEV) {
prodReady = true
return
}
if (prodInitPromise) return prodInitPromise
prodInitPromise = (async () => {
prodManifests = {}
installedAddonKeys = []
let addonKeys = keys
if (!addonKeys?.length) {
try {
const indexUrl = adminUrl('/assets/addons/index.json')
const res = await fetch(indexUrl)
if (res.ok) {
const data = await res.json()
addonKeys = Array.isArray(data.keys) ? data.keys : []
}
} catch {
addonKeys = []
}
}
const verifiedKeys: string[] = []
for (const key of addonKeys || []) {
if (!(await isAddonEntryAvailable(key))) continue
verifiedKeys.push(key)
try {
const url = adminUrl(`/assets/addons/${key}/manifest.json`)
const res = await fetch(url)
if (res.ok) {
prodManifests[key] = await res.json()
}
} catch {
// skip missing manifest
}
}
installedAddonKeys = verifiedKeys
prodReady = true
})()
return prodInitPromise
}
export function getInstalledAddonKeys(): string[] {
if (installedAddonKeys.length) return installedAddonKeys
return Object.keys(prodManifests)
}
export function getProdManifests(): Record<string, AddonManifest> {
return prodManifests
}
export async function preloadAllAddonLangs(
merge: (locale: string, messages: Record<string, string>) => void
) {
if (import.meta.env.DEV) return
if (!prodReady) await initAddonManifests()
for (const addon of getInstalledAddonKeys()) {
try {
const mod = await loadAddonModule(addon)
if (!mod) continue
const langs = mod.langs as Record<string, Record<string, unknown>> | undefined
if (!langs) continue
for (const [locale, files] of Object.entries(langs)) {
const merged: Record<string, string> = {}
for (const [file, pack] of Object.entries(files || {})) {
Object.assign(merged, flattenLangPack(file, unwrapLangPack(pack)))
}
if (Object.keys(merged).length) merge(locale, merged)
}
} catch {
// skip broken addon module
}
}
}
export function registerAddonManifest(manifest: AddonManifest) {
prodManifests[manifest.key] = manifest
prodReady = true
}
export async function loadAddonLang(addon: string, locale: string, file: string): Promise<Record<string, string>> {
if (import.meta.env.DEV) {
try {
const messages = await import(/* @vite-ignore */ `@/addon/${addon}/lang/${locale}/${file}.json`)
return messages.default || {}
} catch {
return {}
}
}
if (!prodReady) await initAddonManifests()
try {
const mod = await loadAddonModule(addon)
if (mod) {
const langs = mod.langs as Record<string, Record<string, Record<string, unknown>>> | undefined
const pack = langs?.[locale]?.[file]
const messages = unwrapLangPack(pack)
if (Object.keys(messages).length) return messages
}
} catch {
// fallback to static lang files
}
const url = addonAssetUrl(addon, 'lang', locale, `${file}.json`)
try {
const res = await fetch(url)
if (res.ok) return await res.json()
} catch {
// ignore
}
return {}
}