- VueAdmin: 记录 moduleKey→IRouteInfo(moduleRoutes),加 routeForModule(判已注册+非子模块)
- FieldRelation.display: 目标可达时名字渲成链接,点击 router.push 到目标模块
+ ?filter={_id},落成过滤到单条的列表(框架无详情页,单项查看即此形态)
- 子模块目标/无权限目标不可点(routeForModule 返回 undefined)
421 lines
15 KiB
TypeScript
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])
|
|
}
|
|
}
|
|
}
|