Files
shrimp-admin/src/VueAdmin.ts
T
wc24 9746be581d v0.2.1: i18n messages 改成深合并
initI18n 之前用 spread 浅合并:
  { ...zh_cn, ...(extra['zh-cn'] || {}) }

业务侧只要定义同名一级键(如 'user'),框架自带 user.* 全部被覆盖,
表现是控制台 [intlify] Not found 'user.operationLog' / 'user.xxx' 之类。

改成递归 deepMerge,框架 locale 在底,业务侧叠在上,同 leaf key 业务覆盖框架。
2026-05-23 22:48:10 +08:00

412 lines
15 KiB
TypeScript

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<string, any>, src: Record<string, any>): Record<string, any> {
const out: Record<string, any> = { ...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<string, string>
APP_VER?: string
LS_ENCRYPTION?: string
LS_ENCRYPTION_key?: string
}
export interface ShrimpAdminOptions {
config: ShrimpAdminConfig
/** 业务模块组件:`import.meta.glob('./module/**\/*.vue')` 的结果 */
moduleSources?: Record<string, () => Promise<{ default: Component } | Component>>
/** 业务字段组件:`import.meta.glob('./module/fields/Field*.vue')`,文件名 FieldXxx → type='fieldXxx' */
customFields?: Record<string, () => Promise<{ default: Component } | Component>>
/** 语言资源,与框架自带的合并(消费侧同 key 覆盖框架默认) */
locales?: Record<string, any>
/** 语言列表,默认 [{ lang: 'zh-cn', name: '简体中文' }] */
langList?: { lang: string; name: string }[]
}
// ============== 字段组件注册表 ==============
const fieldRegistry = new Map<string, Component>()
const fieldOverrides = new Map<string, string>()
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<Component>('./module/**/*.vue')
// 业务模块由消费侧通过 createShrimpAdmin({ moduleSources }) 注册
const externalModules: Record<string, () => Promise<any>> = {}
function resolveModuleComponent(componentPath: string): (() => Promise<any>) | 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<string, any> = 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<typeof axios.create>
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<string, any>) {
// 深合并:框架自带 locale 在底,业务侧叠在上。
// 旧版用 spread 浅合并,业务侧只要定义了同名一级键(如 user)就会把框架的 user.* 全部替换,
// 表现是控制台 [intlify] Not found 'user.operationLog' key 之类的报错。
const builtins: Record<string, any> = { 'zh-cn': zh_cn, 'en': en }
const merged: Record<string, any> = {}
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<string, any>): Record<string, any> {
if (!params) return {}
const out: Record<string, any> = {}
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<T = any>(method: string, url: string, data?: any): Promise<T> {
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])
}
}
}