feat: shrimp-admin v0.1.0 — 从 hand-admin 抽离的框架前端
Shrimp 框架前端 — 一种消费 ShrimpServer + ShrimpTable 契约的经典 Vue3 +
Element Plus 实现。不是框架本身,只是按 shrimp-server 约定写的一份参考解法。
包含:
- ShrimpClient + VueAdmin(factory:createShrimpAdmin)
- 5 个 Shrimp* 组件 + 10 个内置 Field*.vue + PowerTree + LangSwitch
- 4 个内置模块:user / role / operationLog / FieldRelativeTime
- 完整后台壳 views(layout/login/sideM/topbar/userbar/navMenu/App/404/logo)
- composables/useTableUrlSync, locales(zh-cn/en 框架默认), style 全套
API 改造:
- createShrimpAdmin({ config, moduleSources, customFields, locales }) factory
- vueAdmin 改为 Proxy 单例,createShrimpAdmin 调用前访问会抛错
- import.meta.glob 业务字段/模块由消费侧传入(vite 静态扫描机制限制)
- 框架默认 locales 与消费侧 locales 在 i18n 初始化时合并
源码 ship 不打包,消费侧靠 vite 直接吃 .ts/.vue。
This commit is contained in:
+395
@@ -0,0 +1,395 @@
|
||||
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 'shrimp-server/types'
|
||||
|
||||
import { ShrimpClient, handleShrimpResponse, type IShrimpTipper, type IRouteInfo } from './ShrimpClient'
|
||||
import FieldString from './components/fields/FieldString.vue'
|
||||
|
||||
// ============== 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>) {
|
||||
const merged: Record<string, any> = {
|
||||
'zh-cn': { ...zh_cn, ...(extraLocales?.['zh-cn'] || {}) },
|
||||
'en': { ...en, ...(extraLocales?.['en'] || {}) },
|
||||
}
|
||||
if (extraLocales) {
|
||||
for (const key in extraLocales) {
|
||||
if (!(key in merged)) merged[key] = extraLocales[key]
|
||||
}
|
||||
}
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user