- 拷 shrimp-server/src/IShrimpType.ts 到 src/IShrimpType.ts,头改 @syncedFrom shrimp-server contract 1.0.0 - 全部内部 import 'shrimp-server/types' → 本地 './IShrimpType'(各文件按相对路径) - src/index.ts 加上类型契约的 re-export(ShrimpCode/ShrimpError/枚举/全部 interface),消费侧从 'shrimp-admin' 一处拿到 - 删 dependencies.shrimp-server,前端 bundle 不再牵 mongoose/express 为什么不直接 import 'shrimp-server': shrimp-server v0.6.0 收单入口后,入口含 mongoose/express,前端会被 vite 牵进 bundle。 契约副本 + 版本号对照是低频改动场景的更干净选择。
373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
/**
|
||
* ShrimpClient — Shrimp 框架前端基类
|
||
* 框架无关层,包含协议通信、模块管理、菜单树操作
|
||
*/
|
||
import { ShrimpCode, ShrimpRequestFn, IShrimpResponse, IModuleInfo, IMenuItem, ILoginResult, IMeResult, IShrimpClient, ILabel } from './IShrimpType'
|
||
|
||
/** 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()
|
||
}
|
||
}
|
||
}
|