import * as elIcons from '@element-plus/icons-vue' import { type App, type Component, type Plugin, defineAsyncComponent, reactive } from 'vue' import axios from 'axios' import { ElMessage, ElMessageBox } from 'element-plus' import ElementPlus from 'element-plus' import { createRouter, createWebHashHistory, Router } from 'vue-router' import zh_cn from './locales/zh-cn.json' import en from './locales/en.json' import { createI18n, I18n } from 'vue-i18n' import { IShrimpResponse, ILabel } from './IShrimpType' import { ShrimpClient, handleShrimpResponse, type IShrimpTipper, type IRouteInfo } from './ShrimpClient' import FieldString from './components/fields/FieldString.vue' // ============== 工具 ============== /** locale 深合并:嵌套对象按 key 递归 merge,非对象值 src 覆盖 dst。仅服务于 i18n messages,没必要拉 lodash。 */ function deepMerge(dst: Record, src: Record): Record { const out: Record = { ...dst } for (const key in src) { const sv = src[key], dv = out[key] if (sv && typeof sv === 'object' && !Array.isArray(sv) && dv && typeof dv === 'object' && !Array.isArray(dv)) { out[key] = deepMerge(dv, sv) } else { out[key] = sv } } return out } // ============== Options ============== export interface ShrimpAdminConfig { API_URL: string TIMEOUT?: number HEADERS?: Record APP_VER?: string LS_ENCRYPTION?: string LS_ENCRYPTION_key?: string } export interface ShrimpAdminOptions { config: ShrimpAdminConfig /** 业务模块组件:`import.meta.glob('./module/**\/*.vue')` 的结果 */ moduleSources?: Record Promise<{ default: Component } | Component>> /** 业务字段组件:`import.meta.glob('./module/fields/Field*.vue')`,文件名 FieldXxx → type='fieldXxx' */ customFields?: Record Promise<{ default: Component } | Component>> /** 语言资源,与框架自带的合并(消费侧同 key 覆盖框架默认) */ locales?: Record /** 语言列表,默认 [{ lang: 'zh-cn', name: '简体中文' }] */ langList?: { lang: string; name: string }[] } // ============== 字段组件注册表 ============== const fieldRegistry = new Map() const fieldOverrides = new Map() fieldRegistry.set('string', FieldString) fieldRegistry.set('boolean', defineAsyncComponent(() => import('./components/fields/FieldBoolean.vue'))) fieldRegistry.set('date', defineAsyncComponent(() => import('./components/fields/FieldDate.vue'))) fieldRegistry.set('number', defineAsyncComponent(() => import('./components/fields/FieldNumber.vue'))) fieldRegistry.set('password', defineAsyncComponent(() => import('./components/fields/FieldPassword.vue'))) fieldRegistry.set('relation', defineAsyncComponent(() => import('./components/fields/FieldRelation.vue'))) fieldRegistry.set('select', defineAsyncComponent(() => import('./components/fields/FieldSelect.vue'))) fieldRegistry.set('textarea', defineAsyncComponent(() => import('./components/fields/FieldTextarea.vue'))) fieldRegistry.set('json', defineAsyncComponent(() => import('./components/fields/FieldJson.vue'))) fieldRegistry.set('tab', defineAsyncComponent(() => import('./components/fields/FieldTab.vue'))) // 框架自带模块组件 — vite glob 相对本文件解析,扫到的是 shrimp-admin 内部的 module/ const internalModules = import.meta.glob('./module/**/*.vue') // 业务模块由消费侧通过 createShrimpAdmin({ moduleSources }) 注册 const externalModules: Record Promise> = {} function resolveModuleComponent(componentPath: string): (() => Promise) | null { const suffix = `/module/${componentPath}.vue` // 业务优先(允许覆盖同名内置) for (const key in externalModules) { if (key.endsWith(suffix)) return externalModules[key] } for (const key in internalModules) { if (key.endsWith(suffix)) return internalModules[key] as any } return null } // ============== LocalData ============== class LocalData { set(key: string, data: any) { localStorage.setItem(key, JSON.stringify(data)) } get(key: string) { try { const value = localStorage.getItem(key) return value ? JSON.parse(value) : null } catch { return null } } remove(key: string) { localStorage.removeItem(key) } clear() { localStorage.clear() } } // ============== VueAdmin ============== export class VueAdmin extends ShrimpClient { router!: Router localData = new LocalData() i18n!: I18n adminConfig: Record = reactive({}) userName = '' config: ShrimpAdminConfig langList: { lang: string; name: string }[] registerField(type: string, comp: Component) { fieldRegistry.set(type, comp) } overrideField(moduleKey: string, fieldKey: string, comp: Component) { const registryKey = `__override_${moduleKey}_${fieldKey}` fieldRegistry.set(registryKey, comp) fieldOverrides.set(`${moduleKey}.${fieldKey}`, registryKey) } getFieldComponent(moduleKey: string, type: string, fieldKey?: string): Component { if (fieldKey) { const override = fieldOverrides.get(`${moduleKey}.${fieldKey}`) if (override && fieldRegistry.has(override)) return fieldRegistry.get(override)! } return fieldRegistry.get(type) || FieldString } state = reactive({ isMobile: false }) private _token: string | null = null get token() { return this._token } set token(value: string | null) { this.localData.set("token", value) this._token = value } get langCount() { return this.langList.length } get locale(): string { return (this.i18n.global.locale as any).value } set locale(value: string) { (this.i18n.global.locale as any).value = value this.localData.set("lang", value) } toggleLang() { if (this.langCount <= 1) return const idx = this.langList.findIndex(l => l.lang === this.locale) const next = this.langList[(idx + 1) % this.langCount] this.locale = next.lang } get currentLangName() { return this.langList.find(l => l.lang === this.locale)?.name || this.locale } request: ReturnType constructor(opts: ShrimpAdminOptions) { super() this.config = opts.config this.langList = opts.langList || [{ lang: 'zh-cn', name: '简体中文' }] // 业务模块/字段注册 if (opts.moduleSources) Object.assign(externalModules, opts.moduleSources) if (opts.customFields) { for (const path in opts.customFields) { const match = path.match(/\/Field(.+)\.vue$/) if (match) { const name = 'field' + match[1] fieldRegistry.set(name, defineAsyncComponent(opts.customFields[path] as any)) } } } this.request = axios.create({ baseURL: this.config.API_URL, timeout: this.config.TIMEOUT ?? 10000, }) this.initHttp() this.initI18n(opts.locales) this.initRouter() this.initMobileCheck() this._token = this.localData.get("token") } initI18n(extraLocales?: Record) { // 深合并:框架自带 locale 在底,业务侧叠在上。 // 旧版用 spread 浅合并,业务侧只要定义了同名一级键(如 user)就会把框架的 user.* 全部替换, // 表现是控制台 [intlify] Not found 'user.operationLog' key 之类的报错。 const builtins: Record = { 'zh-cn': zh_cn, 'en': en } const merged: Record = {} const langs = new Set([...Object.keys(builtins), ...Object.keys(extraLocales || {})]) for (const lang of langs) { merged[lang] = deepMerge(builtins[lang] || {}, extraLocales?.[lang] || {}) } this.i18n = createI18n({ locale: this.localData.get("lang") || 'zh-cn', globalInjection: true, legacy: false, messages: merged, }) } t(value?: ILabel | null): string { if (value == null) return '' if (typeof value === 'string') return value const v = value as any if ('t' in v) return (this.i18n.global.t as any)(v.t, this.recurseParams(v.params)) if ('tc' in v) return ((this.i18n.global as any).t)(v.tc, this.recurseParams(v.params), v.count) if ('d' in v) return (this.i18n.global.d as any)(v.d, v.format) if ('n' in v) return (this.i18n.global.n as any)(v.n, v.format) return v[this.locale] || '' } private recurseParams(params?: Record): Record { if (!params) return {} const out: Record = {} for (const [k, val] of Object.entries(params)) { out[k] = (val !== null && typeof val === 'object') ? this.t(val as ILabel) : val } return out } async loadUserInfo() { const data = await this.fetchMe() this.userName = (data as any).nickname || '' this.moduleInfo = data.moduleInfo this.menuTree = data.menuTree this.globalOptions = data.options || {} if (this.menuTree.length > 0) { this.registerRoutesFromMenuTree(this.menuTree) } else { this.registerRoutesFromModuleInfo(this.moduleInfo) } this.registerSubModuleRoutes(this.moduleInfo) return data } async rawRequest(method: string, url: string, data?: any): Promise { const res = await this.request({ method, url, data }) return res.data as T } registerRoute(info: IRouteInfo) { const componentPath = info.componentPath || info.moduleKey const importer = resolveModuleComponent(componentPath) if (!importer) { console.warn(`[shrimp-admin] module component not found: module/${componentPath}.vue`) return } this.router.addRoute("layout", { path: info.path || "/" + info.name, name: info.name, component: importer, props: { moduleKey: info.moduleKey, events: info.events }, meta: { title: info.label } }) } initRouter() { this.router = createRouter({ history: createWebHashHistory(), routes: [ { name: "layout", path: "/", component: () => import('./views/layout.vue'), children: [] }, { path: "/login", component: () => import('./views/login.vue'), meta: { title: "t:login.signIn" } }, { path: "/:pathMatch(.*)", component: () => import('./views/404.vue'), }, ], }) this.router.afterEach((to) => { if (to.meta?.title) { document.title = this.t(to.meta.title as string) } else { document.title = this.adminConfig.title || 'Admin' } }) this.router.beforeEach(async (to, _from, next) => { if (to.path === "/login") { next() } else if (!this.token) { next({ path: '/login' }) } else if (this.moduleInfo.length === 0) { try { await this.loadUserInfo() const firstPath = this.getFirstMenuPath() next(to.path === "/" ? { path: firstPath, replace: true } : { ...to, replace: true }) } catch { this.token = null next({ path: '/login' }) } } else { next() } }) } tipper: IShrimpTipper = { success: (msg) => ElMessage.success(this.t(msg)), error: (msg) => ElMessage.error(this.t(msg)), alert: (msg) => ElMessageBox.alert(this.t(msg), { confirmButtonText: (this.i18n.global.t as any)('base.confirm'), showClose: true, }), } initHttp() { this.request.interceptors.request.use((reqConfig) => { if (this.token) { reqConfig.headers.Authorization = `Bearer ${this.token}` } return reqConfig }) this.request.interceptors.response.use( (res) => { const body = res.data as IShrimpResponse if (body.code === undefined) return res try { if (body.tip) body.tip = this.t(body.tip) res.data = handleShrimpResponse(body, this.tipper, () => { this.token = null this.router.push('/login') }) return res } catch (err) { return Promise.reject(err) } }, (error) => { ElMessage.error(this.t('t:base.netError')) throw error } ) } initMobileCheck() { const check = () => { this.state.isMobile = document.body.clientWidth <= 992 } window.addEventListener('resize', check) check() } fullscreen() { const doc = document as any const isFull = !!(doc.webkitIsFullScreen || doc.mozFullScreen || doc.msFullscreenElement || doc.fullscreenElement) if (isFull) { doc.exitFullscreen?.() || doc.msExitFullscreen?.() || doc.mozCancelFullScreen?.() || doc.webkitExitFullscreen?.() } else { const el = doc.documentElement el.requestFullscreen?.() || el.msRequestFullscreen?.() || el.mozRequestFullScreen?.() || el.webkitRequestFullscreen?.() } } clearCache() { this.localData.clear() } reset() { this.token = null location.reload() } } // ============== 单例 + factory ============== let _instance: VueAdmin | null = null export const vueAdmin = new Proxy({} as VueAdmin, { get(_t, prop) { if (!_instance) throw new Error('[shrimp-admin] vueAdmin accessed before createShrimpAdmin() was called') return Reflect.get(_instance, prop, _instance) }, set(_t, prop, value) { if (!_instance) throw new Error('[shrimp-admin] vueAdmin accessed before createShrimpAdmin() was called') return Reflect.set(_instance, prop, value, _instance) } }) export function createShrimpAdmin(opts: ShrimpAdminOptions): Plugin { if (_instance) { console.warn('[shrimp-admin] createShrimpAdmin called more than once; reusing existing instance') return shrimpAdminPlugin } _instance = new VueAdmin(opts) return shrimpAdminPlugin } const shrimpAdminPlugin: Plugin = { install(app: App) { if (!_instance) throw new Error('[shrimp-admin] install called before createShrimpAdmin()') app.use(ElementPlus) app.use(_instance.i18n) app.use(_instance.router) for (const key in elIcons) { app.component(`ElIcon${key}`, (elIcons as any)[key]) } } }