initI18n 之前用 spread 浅合并:
{ ...zh_cn, ...(extra['zh-cn'] || {}) }
业务侧只要定义同名一级键(如 'user'),框架自带 user.* 全部被覆盖,
表现是控制台 [intlify] Not found 'user.operationLog' / 'user.xxx' 之类。
改成递归 deepMerge,框架 locale 在底,业务侧叠在上,同 leaf key 业务覆盖框架。
412 lines
15 KiB
TypeScript
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])
|
|
}
|
|
}
|
|
}
|