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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user