Files
shrimp-admin/src/ShrimpClient.ts
T
wc24 f210ce4582 v0.2.0: 解耦 shrimp-server,内置 IShrimpType 契约副本
- 拷 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。
  契约副本 + 版本号对照是低频改动场景的更干净选择。
2026-05-23 22:29:42 +08:00

373 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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()
}
}
}