Files
shrimp-admin/src/VueAdmin.ts
T
wc24 f7ef29a928 feat(relation): 显示名可点 → 跳目标模块并过滤到该 _id(单行)
- VueAdmin: 记录 moduleKey→IRouteInfo(moduleRoutes),加 routeForModule(判已注册+非子模块)
- FieldRelation.display: 目标可达时名字渲成链接,点击 router.push 到目标模块
  + ?filter={_id},落成过滤到单条的列表(框架无详情页,单项查看即此形态)
- 子模块目标/无权限目标不可点(routeForModule 返回 undefined)
2026-06-29 20:38:21 +08:00

421 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'
// ============== 工具 ==============
/** 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
}
type VueLoader = () => Promise<any>
export interface ShrimpAdminOptions {
config: ShrimpAdminConfig
/**
* 消费侧 .vue 源,推荐 `import.meta.glob('./shrimp/**\/*.vue')`。
* - `shrimp/components/<name>.vue` 同后缀覆盖框架内置(login/layout/topbar/ShrimpTable/...)
* - `shrimp/components/fields/Field<X>.vue` 注册字段类型 `<x>`(首字母小写),同名覆盖内置
* - `shrimp/module/<key>.vue` 业务模块组件(routed by moduleKey)
*/
sources?: Record<string, VueLoader>
/** 语言资源,与框架自带的合并(消费侧同 key 覆盖框架默认) */
locales?: Record<string, any>
/** 语言列表,默认 [{ lang: 'zh-cn', name: '简体中文' }] */
langList?: { lang: string; name: string }[]
}
// ============== 源注册表 ==============
//
// 框架在 3 个入口点用消费侧 sources 覆盖内置(按路径后缀同后缀覆盖):
// 1. router: components/{layout,login,404}.vue
// 2. registerRoute: module/<key>.vue 等业务模块
// 3. fieldRegistry: components/fields/Field<X>.vue (按文件名导出的字段类型)
// 框架内 .vue 之间的 peer 引用是硬编码静态 import,override 颗粒度就停在上面这些"入口"。
const internalSources = import.meta.glob<Component>('./{components,module}/**/*.vue')
const externalSources: Record<string, VueLoader> = {}
/**
* 路径后缀匹配解析: 消费侧优先,内置兜底。
* 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<X>.vue` → 字段类型 `<x>`(首字母小写)。
// 后端 moduleInfo 的 field.type 命中这里的 key 就用对应组件,否则回退到 string。
const fieldRegistry = new Map<string, Component>()
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<string, any> = reactive({})
userName = ''
config: ShrimpAdminConfig
langList: { lang: string; name: string }[]
/** 已注册成功的模块路由(moduleKey → IRouteInfo),供 relation 跳转/单项查看解析路由 */
moduleRoutes = new Map<string, IRouteInfo>()
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<typeof axios.create>
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<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
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])
}
}
}