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:
2026-05-23 21:01:33 +08:00
parent b4ee2e4b43
commit 4cb3bb8d75
50 changed files with 6551 additions and 0 deletions
+395
View File
@@ -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])
}
}
}