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
+372
View File
@@ -0,0 +1,372 @@
/**
* ShrimpClient — Shrimp 框架前端基类
* 框架无关层,包含协议通信、模块管理、菜单树操作
*/
import { ShrimpCode, ShrimpRequestFn, IShrimpResponse, IModuleInfo, IMenuItem, ILoginResult, IMeResult, IShrimpClient, ILabel } from 'shrimp-server/types'
/** tip 展示接口(由框架层实现,如 Element Plus)。msg 是 ILabel,实现层负责翻译后展示 */
export interface IShrimpTipper {
success(msg: ILabel): void
error(msg: ILabel): void
alert(msg: ILabel): Promise<any>
}
/** 路由注册信息,子类 addRoute 的参数 */
export interface IRouteInfo {
moduleKey: string
name: string
path?: string
label: ILabel
events: string[]
/** 组件相对 module 目录的路径(不含 .vue),不传则默认 moduleKey。用于子模块嵌套目录定位 */
componentPath?: string
/** 子模块跳转按钮图标(element-plus icon 名,仅 sub 模块有意义) */
icon?: string
}
class ShrimpResponseError extends Error {
constructor(public data: IShrimpResponse) {
super(data.code.toString())
this.name = 'ShrimpResponseError'
}
}
/**
* 处理 Shrimp 响应中的 tip 和 log
* @returns 处理后的响应数据,或抛出错误
*/
export function handleShrimpResponse(
body: IShrimpResponse,
tipper: IShrimpTipper,
onUnauthorized?: () => void,
): any {
if (body.log) {
console.warn('[Shrimp]', body.log)
}
if (body.code === ShrimpCode.OK) {
if (body.tip) tipper.success(body.tip)
return body.data
}
// 业务错误:根据 code 决定展示方式
if (body.tip) {
if (body.code === ShrimpCode.UNAUTHORIZED) {
tipper.alert(body.tip).then(() => onUnauthorized?.())
} else {
tipper.error(body.tip)
}
} else {
tipper.alert("").then(() => onUnauthorized?.())
// tipper.error(t('base.fail'))
}
const err = new ShrimpResponseError(body) as any
err.code = body.code
err.handled = true
throw err
}
/**
* 根据协议类型生成类型安全的 API 调用器
* 用法:
* const userApi = getCaller<IUserProto>(request, "user")
* const subApi = getCaller<ISubProto>(request, ["parent", "sub"])
*/
export function getCaller<P extends Record<string, any>>(request: ShrimpRequestFn, moduleKey: string | string[]): P {
const segments = Array.isArray(moduleKey) ? moduleKey : [moduleKey]
return new Proxy({} as any, {
get: (target, prop, receiver) => {
if (typeof prop === 'symbol' || prop === 'valueOf' || prop === 'toString') {
return Reflect.get(target, prop, receiver)
}
return (...args: any[]) => request([...segments, prop as string], ...args)
}
})
}
/**
* ShrimpClient 基类
* 封装与 Shrimp 协议相关的公共方法,框架无关
* 业务层(如 HandAdmin)继承此类并实现底层请求
*/
export abstract class ShrimpClient implements IShrimpClient {
/** 当前用户的模块权限列表 */
moduleInfo: IModuleInfo[] = []
/** 当前用户的菜单树(含层级结构) */
menuTree: IMenuItem[] = []
/** 全局选项(/me 返回的 options) */
globalOptions: Record<string, any> = {}
private callers: Map<string, any> = new Map()
/** 子类必须实现:底层 HTTP 请求 */
abstract rawRequest<T = any>(method: string, url: string, data?: any): Promise<T>
/** 子类必须实现:路由注册(框架特定,如 Vue Router) */
abstract registerRoute(info: IRouteInfo): void
// ---- Shrimp RPC ----
/** Shrimp RPC 请求(POST /moduleKey/eventName) */
async doRequest(path: string[], data?: any[]): Promise<IShrimpResponse> {
return this.rawRequest<IShrimpResponse>('POST', '/' + path.join('/'), data)
}
/** 标准 Shrimp 请求函数(过滤 undefined 参数),供 getCaller 使用 */
async shrimpRequest(path: string[], ...args: any[]): Promise<IShrimpResponse> {
const filteredArgs = args.filter(arg => arg !== undefined)
return await this.doRequest(path, filteredArgs.length > 0 ? filteredArgs : undefined)
}
// ---- REST 接口 ----
/** 获取当前用户信息(权限 + 菜单) */
async fetchMe(): Promise<IMeResult> {
return this.rawRequest<IMeResult>('GET', '/me')
}
/** 用户登录 */
async fetchLogin(data: { username: string; password: string }): Promise<ILoginResult> {
return this.rawRequest<ILoginResult>('POST', '/login', data)
}
/** 获取后台配置 */
async fetchConfig(): Promise<Record<string, any>> {
return this.rawRequest<Record<string, any>>('GET', '/config')
}
// ---- 用户信息 ----
/** 获取当前用户信息(moduleInfo + menuTree)并注册路由 */
async loadUserInfo(): Promise<IMeResult> {
const data = await this.fetchMe()
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)
}
// 子模块永远走 moduleInfo 递归注册(不进 menu)
this.registerSubModuleRoutes(this.moduleInfo)
return data
}
// ---- 路由注册 ----
/**
* 根据 moduleInfo 扁平注册路由
* 适用于无层级菜单的场景:moduleKey 即路由名,path 为 /moduleKey
* 子模块(带 parent)不会作为顶层路由,由 registerSubModuleRoutes 处理
*/
registerRoutesFromModuleInfo(moduleInfo: IModuleInfo[]) {
moduleInfo.forEach(info => {
if (info.parent) return
this.registerRoute({
moduleKey: info.moduleKey,
name: info.moduleKey,
label: info.label,
path: '/' + info.moduleKey,
events: info.events,
})
})
}
/**
* 根据 menuTree 层级注册路由
* 遍历菜单树,对每个带 moduleKey 的项调用 addRoute
*/
registerRoutesFromMenuTree(menuTree: IMenuItem[]) {
const walk = (items: IMenuItem[]) => {
items.forEach(item => {
if (item.moduleKey) {
this.registerRoute({
moduleKey: item.moduleKey,
name: item.name,
path: item.path,
label: item.label,
events: this.getEvents(item.moduleKey),
})
}
if (item.children) {
walk(item.children)
}
})
}
walk(menuTree)
}
/**
* 注册子模块路由(扁平 moduleInfo,通过 parent 链接)
* 子模块路径 = /<parent_path>/<sub>,组件文件 = @/module/<parent_path>/<sub>.vue
*/
registerSubModuleRoutes(moduleInfo: IModuleInfo[]) {
const byKey = new Map(moduleInfo.map(m => [m.moduleKey, m]))
const buildSegments = (info: IModuleInfo): string[] => {
const segs = [info.moduleKey]
let p = info.parent
while (p) {
segs.unshift(p)
p = byKey.get(p)?.parent
}
return segs
}
moduleInfo.forEach(info => {
if (!info.parent) return
const segments = buildSegments(info)
this.registerRoute({
moduleKey: info.moduleKey,
name: segments.join('.'),
path: '/' + segments.join('/'),
label: info.label,
events: info.events,
componentPath: segments.join('/'),
icon: info.icon,
})
})
}
// ---- 菜单树操作 ----
/** 获取第一个菜单路径(用于默认跳转) */
getFirstMenuPath(): string {
const findFirst = (items: IMenuItem[]): string | null => {
for (const item of items) {
if (item.path) return item.path
if (item.moduleKey) return '/' + item.moduleKey
if (item.children) {
const found = findFirst(item.children)
if (found) return found
}
}
return null
}
const path = findFirst(this.menuTree)
if (path) return path
const top = this.moduleInfo.find(m => !m.parent)
return '/' + (top?.moduleKey || '')
}
/** 根据 path 获取所在顶级菜单的索引 */
getMenuIndex(path: string): number {
const findIndex = (items: IMenuItem[], targetPath: string): number => {
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.path === targetPath || '/' + item.name === targetPath) return i
if (item.children) {
const childIndex = findIndex(item.children, targetPath)
if (childIndex !== -1) return i
}
}
return -1
}
return findIndex(this.menuTree, path)
}
/** 判断是否为分组菜单(有 children) */
isGroupMenu(item?: IMenuItem): boolean {
return item != null && item.children != null && item.children.length > 0
}
/** 根据 path 生成面包屑路径(支持子模块: 走 moduleInfo.parent 链) */
getBreadcrumb(path: string): { key: string; label: ILabel; path?: string }[] {
const result: { key: string; label: ILabel; path?: string }[] = []
const findInMenu = (items: IMenuItem[], targetPath: string, out: typeof result): boolean => {
for (const item of items) {
const itemPath = item.path || '/' + item.name
if (itemPath === targetPath) {
out.unshift({ key: item.name, label: item.label, path: itemPath })
return true
}
if (item.children) {
if (findInMenu(item.children, targetPath, out)) {
out.unshift({ key: item.name, label: item.label, path: item.path })
return true
}
}
}
return false
}
if (findInMenu(this.menuTree, path, result)) return result
// 子模块 fallback: 沿 moduleInfo.parent 链构建,带累积路径
const segments = path.split('/').filter(Boolean)
if (segments.length === 0) return result
const byKey = new Map(this.moduleInfo.map(m => [m.moduleKey, m]))
const chain: IModuleInfo[] = []
let info = byKey.get(segments[segments.length - 1])
while (info) {
chain.unshift(info)
info = info.parent ? byKey.get(info.parent) : undefined
}
if (chain.length === 0) return result
// 顶层模块如果在 menu 分组下, 先把分组上下文放进来
findInMenu(this.menuTree, '/' + chain[0].moduleKey, result)
const lastKey = result.length > 0 ? result[result.length - 1].key : ''
let acc = ''
chain.forEach(m => {
acc += '/' + m.moduleKey
if (m.moduleKey === lastKey) return // 已被 menu 添加
result.push({ key: m.moduleKey, label: m.label, path: acc })
})
return result
}
// ---- 模块权限 ----
/** 获取模块的权限事件列表 */
getEvents(moduleKey: string): string[] {
const mod = this.moduleInfo.find(m => m.moduleKey === moduleKey)
return mod?.events || []
}
/** 判断是否有某个事件的权限 */
hasEvent(moduleKey: string, eventName: string): boolean {
return this.getEvents(moduleKey).includes(eventName)
}
/** 获取绑定到指定模块的权限检查器:const can = client.can('shopAdmin'); can('apply') */
can(moduleKey: string): (event: string) => boolean {
return (event: string) => this.hasEvent(moduleKey, event)
}
// ---- 模块 API ----
/** 获取类型安全的模块 API(带缓存,自动沿 parent 链解析嵌套模块路径) */
getModule<P extends Record<string, any>>(moduleKey: string): P {
let caller = this.callers.get(moduleKey)
if (!caller) {
const segments = this.resolveModulePath(moduleKey)
caller = getCaller<P>(this.shrimpRequest.bind(this), segments)
this.callers.set(moduleKey, caller)
}
return caller
}
/** 从 moduleInfo 沿 parent 链回溯出完整路径(根→自身);找不到时退化为单段 */
private resolveModulePath(moduleKey: string): string[] {
const byKey = new Map(this.moduleInfo.map(m => [m.moduleKey, m]))
const info = byKey.get(moduleKey)
if (!info) return [moduleKey]
const segs = [info.moduleKey]
let p = info.parent
while (p) {
segs.unshift(p)
p = byKey.get(p)?.parent
}
return segs
}
/** 清除模块 API 缓存 */
clearModuleCache(moduleKey?: string) {
if (moduleKey) {
this.callers.delete(moduleKey)
} else {
this.callers.clear()
}
}
}