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' // ============== 工具 ============== /** 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 } type VueLoader = () => Promise export interface ShrimpAdminOptions { config: ShrimpAdminConfig /** * 消费侧 .vue 源,推荐 `import.meta.glob('./shrimp/**\/*.vue')`。 * - `shrimp/components/.vue` 同后缀覆盖框架内置(login/layout/topbar/ShrimpTable/...) * - `shrimp/components/fields/Field.vue` 注册字段类型 ``(首字母小写),同名覆盖内置 * - `shrimp/module/.vue` 业务模块组件(routed by moduleKey) */ sources?: Record /** 语言资源,与框架自带的合并(消费侧同 key 覆盖框架默认) */ locales?: Record /** 语言列表,默认 [{ lang: 'zh-cn', name: '简体中文' }] */ langList?: { lang: string; name: string }[] } // ============== 源注册表 ============== // // 框架在 3 个入口点用消费侧 sources 覆盖内置(按路径后缀同后缀覆盖): // 1. router: components/{layout,login,404}.vue // 2. registerRoute: module/.vue 等业务模块 // 3. fieldRegistry: components/fields/Field.vue (按文件名导出的字段类型) // 框架内 .vue 之间的 peer 引用是硬编码静态 import,override 颗粒度就停在上面这些"入口"。 const internalSources = import.meta.glob('./{components,module}/**/*.vue') const externalSources: Record = {} /** * 路径后缀匹配解析: 消费侧优先,内置兜底。 * suffix 形如 `components/login.vue` / `module/user.vue` / `module/user/operationLog.vue`。 */ export function resolveVue(suffix: string): VueLoader { const tail = '/' + suffix for (const key in externalSources) { if (key.endsWith(tail)) return externalSources[key] } for (const key in internalSources) { if (key.endsWith(tail)) return internalSources[key] as any } throw new Error(`[shrimp-admin] resolveVue: ${suffix} not found in sources`) } // ============== 字段组件注册表 ============== // // 约定:`components/fields/Field.vue` → 字段类型 ``(首字母小写)。 // 后端 moduleInfo 的 field.type 命中这里的 key 就用对应组件,否则回退到 string。 const fieldRegistry = new Map() function deriveFieldType(path: string): string | null { const m = path.match(/\/components\/fields\/Field([A-Z][A-Za-z0-9]*)\.vue$/) if (!m) return null return m[1][0].toLowerCase() + m[1].slice(1) } for (const path in internalSources) { const type = deriveFieldType(path) if (type) fieldRegistry.set(type, defineAsyncComponent(internalSources[path] as any)) } // ============== 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 }[] /** 已注册成功的模块路由(moduleKey → IRouteInfo),供 relation 跳转/单项查看解析路由 */ moduleRoutes = new Map() getFieldComponent(_moduleKey: string, type: string, _fieldKey?: string): Component { return fieldRegistry.get(type) || fieldRegistry.get('string')! } /** 目标模块能否作为「单项查看」跳转目标:已按权限注册路由 且 非子模块。返回其路由信息或 undefined(不可跳) */ routeForModule(moduleKey: string): IRouteInfo | undefined { if (this.moduleInfo.find(m => m.moduleKey === moduleKey)?.parent) return undefined return this.moduleRoutes.get(moduleKey) } 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.sources) { Object.assign(externalSources, opts.sources) for (const path in opts.sources) { const type = deriveFieldType(path) if (type) fieldRegistry.set(type, defineAsyncComponent(opts.sources[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 let importer: VueLoader try { importer = resolveVue(`module/${componentPath}.vue`) } catch { 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 } }) this.moduleRoutes.set(info.moduleKey, info) } initRouter() { this.router = createRouter({ history: createWebHashHistory(), routes: [ { name: "layout", path: "/", component: resolveVue('components/layout.vue'), children: [] }, { path: "/login", component: resolveVue('components/login.vue'), meta: { title: { t: 'login.signIn' } } }, { path: "/:pathMatch(.*)", component: resolveVue('components/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]) } } }