Compare commits

..
33 Commits
Author SHA1 Message Date
wc24 064bc108d7 0.8.0: express 收紧到 ^5;契约副本改为 build 时自动同步
破坏性:peerDependencies.express 从 "^4 || ^5" 收成 "^5"。
buildShrimpRouter 的 router.post("/*path") 是 Express 5 才有的写法,
声明支持 4 一直是假的。devDeps 同步升到 express 5.1 / @types/express 5,32 个测试通过。

契约同步:新增 postbuild,把 src/IShrimpType.ts 覆盖到同级 shrimp-admin 的副本
(scripts/check-admin-contract.mjs;找不到同级仓库就跳过,不影响 CI 与消费侧 prepare)。
起因是那份副本靠人工复制,漂了一整代没人发现——停在旧的 json 类型,
缺 object/array/fields/item/parse/refs,而前端对未注册类型静默回落成 string,
漂移不报错、只悄悄渲染错。

删掉 @contractVersion 版本轴:代码无人读取,且从 1.0.0 起从未移动,
期间契约实际改了好几处,数字全程说谎。副本新不新改由字节比对回答,
消费侧看钉的 tag 即可,版本收敛成 package version 一根轴。
2026-08-10 19:42:52 +08:00
wc24 19afdf3615 fix(relation refs): buildRefs 跳过空串关联 id,避免可选 Relation 存 "" 时 $in 转 ObjectId 抛 CastError 整页 500
v0.7.11 误改在废弃的 _label/populateItems(无调用方)上:列表实际走 list()->buildRefs()。
本次回退 populateItems,改在真正活路径 collectOne。bump 0.7.12
2026-07-29 15:34:13 +08:00
wc24 31b723839e fix(populate): 跳过空串关联 id,避免可选 Relation 存 "" 时 $in 转 ObjectId 抛 CastError 整页 500
bump 0.7.11
2026-07-29 14:56:43 +08:00
wc24 1f48a02083 test+chore: 修 stale 测试(_label→refs 新契约)+ bump 0.7.10
crudRegistry.test 还在断言已退场的 tagIds_label,改断言整页 refs 字典。
两端版本归一到 0.7.10。
2026-06-30 17:47:08 +08:00
wc24 0f2b4d6428 fix(pkg): 删掉 shrimp-server 对自身的 git 依赖(致 npm install OOM)
dependencies 误列了 shrimp-server 自引用(#v0.7.4)→ arborist 顺着自引用无限解析
依赖树、堆爆 OOM。真正依赖全在 peerDependencies,该块整体删除。含 0.7.6 的
isEmptyValue 递归判空。
2026-06-30 17:22:39 +08:00
wc24 06aec7f0dc feat(required): 按类型递归判定空(tab/object/array 各自语义)
isEmptyValue(field,value) 递归: tab=没选type / object=所有子字段都空 / array=无元素或
所有元素都空(多维递归) / 标量=null|''/空数组(0、false 不算空)。required=!isEmpty 自动
对所有类型生效。validateNode: 空的复合(非必填)不下钻校验子(整个结构缺席)。
2026-06-30 16:52:08 +08:00
wc24 172c49816e feat(filter): 嵌套 filterable 叶子 → 点路径过滤(object/tab)
collectFilterablePaths 递归收集标了 filterable 的字段(object 子/tab 分支子)连同点路径;
buildQuery 白名单从顶层字段扩成路径(点路径本就是 mongoose 嵌套查询,翻译逻辑不变)。
array 下钻、tab 判别值过滤留后续。
2026-06-30 16:01:20 +08:00
wc24 a8cd07f851 fix(validate): 错误信息不再拼开发用的 [path],回归干净字段名+i18n
校验错只发 {t:i18nKey, params:{field:label}}(不能为空走语言包 key、字段名走 label),
不把 groups.0.title 这种路径塞进可见文案。path 仍递归传递,留给将来 inline 标红结构化携带。
2026-06-30 15:17:58 +08:00
wc24 24afc42de9 feat(validate): 递归校验引擎(校验下沉到任意深度)
childrenOf(field,value): tab 选中分支 fields / object fields / array 元素套 item → 子(字段,值)对。
validateNode 递归: 每节点跑 validateField,出错即抛(带 path),再下钻复合。
接入 prepareAddItem/prepareEditItem(替原顶层 validateField)。第一版 fail-fast,
嵌套错 message 缀 [path] 定位。默认/transform 下沉、collect-all、inline 标红留后续。
2026-06-30 14:49:48 +08:00
wc24 6383aade98 fix(crud): 响应统一剥掉 mongoose 版本键 __v
filterHidden 出口加剥 __v(连同 hidden 字段),list/get/add/edit 全覆盖。
前端无用的 __v 不再下发。
2026-06-30 14:09:44 +08:00
wc24 d12877f846 fix(crud): add/edit 返回普通对象(toObject/lean),不再抖出 mongoose Document 内部
add 用 create 返回 Document、edit 用 findByIdAndUpdate 返回 Document,下游 processResItem
的 {...item} 会把 $__/_doc/$isNew 等内部属性展开、真数据埋进 _doc。改:add 转 toObject、
edit 加 .lean()(与 list/get 一致)。响应回归干净 POJO。
2026-06-30 14:06:40 +08:00
wc24 c264ec6673 feat!: 去掉 json 类型(object 取代) + 版本统一 0.7.0
- object 字段已能声明式表达嵌套结构,freeform 的 json 类型不再需要:
  删 ShrimpFieldTypes.Json + defaultTypeHandlers[Json](reqFilter/resFilter)。
- 源码无任何 type:json 使用,安全移除。
- 前后端版本自此统一:一起推的版本用相同版本号,本批 = 0.7.0。
2026-06-29 22:25:58 +08:00
wc24 f437e2e5e0 feat(composite): 新增 object/array 字段类型(refs 下钻 + 类型常量/配置)
- ShrimpFieldTypes 加 Object/Array
- IFieldConfig 加 fields(object 嵌套字段) + item(array 元素配置,递归)
- collectRefs 重构成 collectOne(单字段+值),加 object(遍历 fields)/array(遍历元素套 item)
  分支 → refs 下钻覆盖 object/array 里的嵌套 relation。
显示/编辑组件在 shrimp-admin。第一版从简:无 min/max、无排序、无递归校验引擎。
2026-06-29 22:21:26 +08:00
wc24 ad5f7f18f5 refactor(export): 去掉后端可逆序列化(resLabel + export stringify),序列化交前端
export 只返回原始数据;结构化字段的字符串化交前端(生成 xlsx 时)。
删: tab resLabel、IFieldTypeHandler.resLabel、processResItem 的 _label 段、
export() 的 stringify 折叠、IFieldConfig.format/stringify。保留 tab reqFilter(import parse)
+ IFieldConfig.parse。彻底清掉 tab 显示搬前端后那个 _label 遗留。
2026-06-29 21:47:08 +08:00
wc24 e04d01a80c feat(refs): buildRefs 递归下钻 tab,覆盖嵌套 relation
collectRefs 递归收集:relation 叶子直接收,tab 按 value.type 进选中分支继续下钻
(顺带覆盖 tab 套 tab)。refs 字典现含嵌套关联名(如 sellType.goods),
为 tab 显示搬前端(子组件渲染 + relation 查 refs)铺好地基。
2026-06-29 21:28:32 +08:00
wc24 eb339ccc49 feat(filter): buildQuery 放行 _id 精确过滤(单项查看/relation 跳转)
filter 原本只认 filterable 字段,_id 会被丢弃。新增: key==='_id' 时始终以 eq 放行,
让 ?filter={_id} 能过滤到单条。_id 是主键精确匹配、用户已有列表权限,无安全顾虑。
2026-06-29 20:40:28 +08:00
wc24 8f9dfd40e0 feat(relation): 用整页 refs 字典替代逐行 _label
list 响应新增 refs: { moduleKey: { id: 显示名 } },整页一次性按模块批量查、去重;
去掉 relation 的 populateItems→${key}_label 逐行注入。前端显示时按 id 查 refs。
tab 的 resLabel/_label 不动(留待复合那步)。这是嵌套引用的地基:collectRefs 以后加
tab 的 children() 下钻即可覆盖任意深度。
2026-06-29 20:17:26 +08:00
wc24 ee35b47f69 revert(tab): 撤回 v0.6.5 嵌套 relation 解析,回到 format 纯展示
populateTabRelations / format 第二参是 tab 专属特例,太狭隘(object/array 同类问题未覆盖),
通用复合解析另当专门事做。嵌套关联名(如商品名)的展示需求改放业务侧(模块重写 list/get)。
框架只保留: format(同步纯展示) + stringify/parse(可逆往返)。
2026-06-29 17:33:52 +08:00
wc24 bc54b475cf feat(tab): format 可拿到子 relation 解析名(批量,无 N+1)
tab 字段的 relation 子字段(如 sellType.goods)原只有 id,format 显示不了关联名。
新增 populateTabRelations:批量(每个关联模块一次 $in via crud.list)解析子 relation 的
labelKey 名,挂到 item.__tabRel[fieldKey][childKey],作为 format 第二参 relLabels 传入;
不污染 tab 本体(避免回灌进库),响应末尾删除 __tabRel。
2026-06-29 16:56:45 +08:00
wc24 4b51d31e9f feat(tab): 拆开展示与序列化 — field.format(展示) vs stringify/parse(可逆往返)
v0.6.3 用同一个 stringify 同时扛表格展示和 export 序列化,二者诉求冲突:
展示要好看可有损,export 要可逆完整。现拆为:
- field.format → _label 展示文本(给人看,可有损),默认 JSON.stringify
- field.stringify/parse → export/import 可逆对(机器往返),默认 JSON
export 改用 stringify(可逆)而非 _label,import 的 parse 能还原。
2026-06-29 16:42:11 +08:00
wc24 c5c06bee13 feat(tab): 结构化字段端到端传对象,给人看的边界走 _label/stringify
tab 字段原把对象 JSON.stringify 成字符串下发(复用 json 的 resFilter),
导致前端 FieldTab 收到字符串:表格 display 显示 '-'、编辑回退默认值。

改为:
- 库/响应/编辑全程对象(去掉 tab 的 resFilter stringify)
- 新增 resLabel 钩子 → 写入 ${key}_label,承载给人看的字符串文本
- export 边界用 _label 文本替换对象列(可读且可被 import 的 parse 还原)
- 字段级可配 stringify/parse,默认 JSON
2026-06-29 15:45:58 +08:00
wc24 f94a01e0a1 fix(shrimp): 修复 multiple 字段类型校验逻辑 2026-06-15 14:34:36 +08:00
wc24 5ecbf2cc01 docs: array/object 字段类型设计草案(搁置待需求,含前后端方案) 2026-06-10 15:47:57 +08:00
wc24 9fff8c472d v0.6.2: powers 字段改自定义类型,绕过前置验证的标量类型校验 2026-06-08 19:22:56 +08:00
wc24 187c6f6030 v0.6.1: 改为编译 ship,通过 npm prepare hook 自动构建
- 加 tsconfig.build.json,排除 test,rootDir 设为 src
- package.json: main/types/exports 指向 dist/main/index.{js,d.ts}
- prepare hook: 消费侧 git 安装后自动 tsc,产物不进 git
- 解决消费侧 "tsc + 裸 node" 链路 (theseus-server 等) 找不到 .js 的问题
2026-06-01 19:32:48 +08:00
wc24 c99ea0e18c v0.6.0: 收单入口
- 类型契约/Express 装配/MongoShrimp 全部合并进 src/main/index.ts
- 删 src/main/expmon.ts、src/main/types.ts
- package.json exports 收成只剩 "."(和 ./package.json)
- IShrimpType.ts 头加 @contractVersion 1.0.0(供 shrimp-admin 副本对照)

未上生产,直接断掉 shrimp-server/expmon、shrimp-server/types 两个子入口。
2026-05-23 22:27:36 +08:00
wc24 652b31052e docs(README): 约定按作用域分层(全局 vs 条件)
shrimp 不是单体协议而是可拼装件集合,每个件有自己的契约。
作用域 = 你用了哪个件就遵守哪个件的契约,不用不必遵守。

- 🌐 全局约定:IModuleInfo / IFieldConfig / Shrimp 树 / Power-Role / ILabel
- 🔌 条件约定:按宿主件分组(ShrimpTable / ShrimpServer / MongoShrimp /
  OperationLogModule / 默认 Field*.vue / 子模块机制)

这一层概念是 shrimp-admin 抽包前要先共识的:shrimp-admin 不是框架,
是"消费 ShrimpServer + ShrimpTable 这两套条件契约"的经典前端实现。
2026-05-23 20:47:35 +08:00
wc24 ca283c6733 v0.5.0: 重新组织目录结构,按职责拆分
- Shrimp.ts (875 行) 按类拆分到 Shrimp/(7 文件: Shrimp, ShrimpModule, ShrimpTableModule, ShrimpPower, ShrimpCrud, UserRoleModule, OperationLogModule)
- forMongo/: MongoShrimpCrud + MongoShrimp(原 ShrimpServer 的 mongo 装配部分,无 express)
- forExpress/: buildShrimpRouter + jwtAuth + types
- main/: index(0 依赖核心) + expmon(express+mongo 整合,导出 ShrimpServer) + types(纯协议)
- 入口子路径 server → expmon(express+mongo 整合方案命名更准确)
- 删除冗余的 index.ts / core.ts(合并为 main/index.ts)
2026-05-23 19:44:02 +08:00
wc24 e8fc126fa6 feat: 新增 ./types 子入口,只 ship IShrimpType.ts
前端 / shrimp-admin / 任何"只要协议、不要框架运行时"的消费方装上
shrimp-server 后,import from 'shrimp-server/types' 就能拿到 types
+ ShrimpCode + ShrimpError,不会触发 Shrimp 主类 / Mongoose / express。

IShrimpType.ts 无外部 import,exports 字段把它单独切出来,vite/webpack
打包到前端时不会顺手拉走 Mongo 链。

v0.4.0
2026-05-23 19:03:04 +08:00
wc24 d8e0095a28 docs: bump install snippet to v0.3.0 + 加 ts-node/tsc 配置说明
迁移 hand-server-2 时撞到两个坑,补到 README:
  - ts-node 默认 ignore node_modules,要在 tsconfig 加 "ts-node": { "ignore": ["(?!)"] }
  - tsc 默认 moduleResolution "node" 不读 exports 字段,要升 node16 或加 paths
2026-05-23 17:18:47 +08:00
wc24 416eb84db7 refactor(server): 拆分 ShrimpServer → buildShrimpRouter + tokenAuth + accessor
ShrimpServer 之前一锅端 Express routes / JWT / Mongoose / 用户字段访问。
现在拆成独立部件,消费方可以单独换掉任何一块:

  - src/server/types.ts: ITokenAuth, IUserAccessor, IShrimpRouterDeps
  - src/server/jwtAuth.ts: createJwtTokenAuth(secret | options) 默认 JWT 适配器
  - src/server/buildShrimpRouter.ts: 纯函数,不直接 import jsonwebtoken/mongoose
  - src/server/ShrimpServer.ts: 装配体,protected create* factory 保留,
    子类化扩展不破坏(hand-server-2 的 AdminApi extends ShrimpServer 仍能用)
  - src/server/index.ts: 'shrimp-server/server' entry,额外暴露低阶 API

test/buildShrimpRouter.test.ts: 用 MemoryShrimpCrud + 假 ITokenAuth 拼出
独立 Router,跑通 /config /login /me 全流程 — 保证 buildShrimpRouter 不
偷偷耦合回 ShrimpServer。

v0.3.0
2026-05-23 17:10:03 +08:00
wc24 36b71bd38e feat: passwordHasher 可注入 — bcrypt 不再是 Shrimp 核心硬依赖
新增 IPasswordHasher 抽象 + bcryptHasher(默认,lazy import)+ plainHasher
(测试用)。Password 字段 reqFilter 和 UserModule.verifyPassword 都走
`Shrimp.passwordHasher`,消费方在启动期换实现就能彻底跳过 bcrypt。

  import { Shrimp, plainHasher } from "shrimp-server"
  Shrimp.passwordHasher = plainHasher

测试覆盖默认值、plain 端到端登录、自定义 hasher 被调用。
2026-05-23 16:57:27 +08:00
wc24 36326ac26b chore: 删除 examples/ — git+http install 会把它一起拖给消费方
包内自带的 Vue demo 不是 shrimp 框架的一部分,git+http 安装时
`files: [...]` 不生效,examples/(尤其 web/ Vue 子项目)会进消费方
node_modules,污染 install 体积并触发 tsc 噪音。

完整接入示例去 hand-server-2 主项目看;basic-admin 那种零依赖
demo 可以以后单独 pin 在 docs/ 或独立仓库。
2026-05-23 16:54:58 +08:00
107 changed files with 2357 additions and 10831 deletions
+110 -26
View File
@@ -2,41 +2,85 @@
声明式字段驱动的 Node.js 后台管理框架(Express + Mongoose + JWT)。后台只声明"表 + 字段",前端自动渲染表格、表单、搜索、菜单。
## 快速跑通(不需要 MongoDB)
```sh
git clone http://git.zeromvc.com/wc24/shrimp-server.git
cd shrimp-server
npm install
cd examples/basic-admin && npm install && npm start
# 另一窗口 curl http://localhost:3100/api/product/list -X POST -d '[{}]' -H 'content-type: application/json'
```
## 在你的项目里用
```sh
# 推荐: HTTP(任何能访问 git.zeromvc.com 的机器都能用)
npm install git+http://git.zeromvc.com/wc24/shrimp-server.git#v0.2.0
npm install git+http://git.zeromvc.com/wc24/shrimp-server.git#v0.5.0
# 也可以 SSH(需要 ssh key 配好)
npm install git+ssh://git@git.zeromvc.com:wc24/shrimp-server.git#v0.2.0
npm install git+ssh://git@git.zeromvc.com:wc24/shrimp-server.git#v0.5.0
```
> 仓库 ship `.ts` 源码,消费方项目需要 TS 运行器(`tsx` / `ts-node`)或在打包工具(vite / webpack / esbuild)里能解析 `.ts`。
### 两个入口,按需选择
### ts-node / ts-node-dev 用户:让它编译 node_modules 内的 .ts
包内提供了**核心**和**完整 server** 两个 entry,**peer deps 全部 optional**——你只装自己用得到的那一份:
`ts-node` 默认会把 `node_modules/` 当成"已编译"跳过,直接把 shrimp-server 的 `.ts`
源码丢给 Node — Node 22+ 看到 `export` 会按 ESM 加载然后炸 `Unexpected token 'export'`。
在 `tsconfig.json` 里把 ignore 覆盖掉:
```jsonc
{
"compilerOptions": { /* ... */ },
"ts-node": {
// 默认是 /(?:^|/)node_modules// — 这里清空,让 ts-node 也编译 shrimp-server
"ignore": ["(?!)"]
}
}
```
`tsx` 用户不需要这一步(tsx 默认就编译 node_modules 内的 .ts)。
### TypeScript 用户:让 tsc 识别子路径
包用 package.json `exports` 字段划分了 `'shrimp-server'`(0 重依赖核心)、
`'shrimp-server/expmon'`(Express + Mongoose 整合方案)和 `'shrimp-server/types'`
(纯类型)三个入口。**TS 默认的 `moduleResolution: "node"` 不读 `exports`,会找不到子路径模块。**
两个解法二选一:
```jsonc
// 方案 A: 升 moduleResolution(配合 module: node16,严格但纯净)
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16"
}
```
```jsonc
// 方案 B: 保留 commonjs,显式 paths 指过去(兼容 ts-node-dev 老版)
"compilerOptions": {
"module": "commonjs",
"baseUrl": ".",
"paths": {
"shrimp-server": ["node_modules/shrimp-server/src/main/index.ts"],
"shrimp-server/expmon": ["node_modules/shrimp-server/src/main/expmon.ts"],
"shrimp-server/types": ["node_modules/shrimp-server/src/main/types.ts"]
}
}
```
Node 22+ 的 CJS 加载器 **会**读 `exports`,所以运行时不需要额外配置 — 只是 TS 的
类型解析在 `moduleResolution: "node"` 模式下落后于 Node 实际行为,需要 paths 兜底。
### 三个入口,按需选择
包内提供**核心**、**expmon(Express+Mongo 整合)**和**纯类型**三个 entry,**peer deps 全部 optional**——你只装自己用得到的那一份:
```ts
// 1) 核心: 0 重依赖(只需 node + tsx),适合自己接 HTTP / 只用内存 CRUD / 嵌入其它框架
import { Shrimp, ShrimpTableModule, MemoryShrimpCrud, ShrimpFieldTypes } from "shrimp-server"
// 2) 完整 server: 自带 Express Router + Mongoose + JWT,需要装上面 4 个 peer
import { ShrimpServer, MongoShrimpCrud } from "shrimp-server/server"
// 2) expmon: Express + Mongoose + JWT 整合方案,需要装上面 4 个 peer
import { ShrimpServer, MongoShrimpCrud, MongoShrimp } from "shrimp-server/expmon"
// 3) 纯类型: 只含 IShrimpType.ts(types + ShrimpCode enum + ShrimpError class)
// 给前端 / shrimp-admin / 任何"只要协议、不要框架运行时"的消费方
import { ShrimpCode, ShrimpError } from "shrimp-server/types"
import type { IFieldConfig, IMenuItem, IMeResult } from "shrimp-server/types"
```
只用 (1) 时,**不要**在自己的 package.json 里写 express/mongoose/bcrypt/jsonwebtoken,可以省下大量 install 时间(尤其是 mongoose 的 native binding)。需要 (2) 时按 peer 范围装齐即可。
只用 (1) 时,**不要**在自己的 package.json 里写 express/mongoose/bcrypt/jsonwebtoken,可以省下大量 install 时间(尤其是 mongoose 的 native binding)。需要 (2) 时按 peer 范围装齐即可。(3) 完全独立,既不会拉 Shrimp 主类也不会拉 mongoose,前端 bundle 安全。
> bcrypt 在 core 里走的是动态 `import('bcrypt')`,不触发 Password 字段 hash / `UserModule.verifyPassword` 就完全不会去 require 它。所以即便用 core 也注册了 password 字段,只要你不真正调用 `add/edit` 写入 Password 或 `getUser` 校验密码,bcrypt 仍是可不装的。
@@ -52,6 +96,33 @@ import { ShrimpServer, MongoShrimpCrud } from "shrimp-server/server"
## 约定
shrimp 不是铁板一块的协议,它是**一组可选拼装件**。每个件对外有自己的契约,你用了哪个件就被哪个件的契约绑住,不用就不必遵守。盘点时按**作用域**分两层。
### 🌐 全局约定(只要你说"我在写 shrimp",就跑不掉)
| 契约 | 说明 |
|---|---|
| `IModuleInfo` 形状 | 任何模块声明都得这么写 — 见 [src/IShrimpType.ts](src/IShrimpType.ts) `IModuleInfo` |
| `IFieldConfig` 形状 | 字段就这样描述(`name` / `label` / `type` / `relation` / `rules` …) |
| Shrimp 树结构 | `parent`/`children` 嵌套、`getFullPath()` 路径语义 |
| Power / Role 声明 | `power` 树形 + `role` 持 `powers[]` |
| `ILabel` 多语言形态 | 见下方 §国际化 — 6 选 1 的标签类型 |
### 🔌 条件约定(用了某个件,才被它的契约绑住)
| 启用前提 | 它的契约 |
|---|---|
| **使用 `ShrimpTable`(前端表格组件)** | • list 响应必须是 `IPageResult` = `{ list, total, page, pageSize, totalPages }`<br>• 字段 `type` 必须落在内置枚举:`string` / `number` / `boolean` / `date` / `select` / `textarea` / `password` / `relation` / `json` / `tab` —— 或自行注册 `IFieldTypeHandler`<br>• populate 字段旁挂 `${key}_label`(数组关联挂数组) |
| **使用 `buildShrimpRouter` / `ShrimpServer`** | • HTTP 路由形状:`GET /config`、`POST /login`、`GET /me`、`POST /<modulePath>/<event>`<br>• JWT 携带:`Authorization: Bearer <token>`<br>• 后端需实现 `IUserAccessor`(getJwtPayload / isUserActive / getUserRoleIds / isRoleSuperAdmin / getRolePowers / getUserDisplayName) — 见 [src/forExpress/types.ts](src/forExpress/types.ts) |
| **使用 `MongoShrimp`** | • User/Role/OperationLog 的 mongoose Schema 形状<br>• `onDbConnected` 钩子时序:连接就绪后才能注册需要 DB 的模块 |
| **挂 `OperationLogModule`** | • CRUD 动作自动写日志的字段集(operator / module / action / target / time) |
| **使用默认 `Field*.vue` 渲染**(前端) | • 字段 `type` 必须是内置枚举之一,否则需注册 `typeHandler` 才能渲染 |
| **使用 Shrimp 子模块机制** | • 子表 `ownerKey` 是后端注册侧独占,**不进 fields[]**<br>• 前端只发通用 `ownerId`,不感知 `ownerKey` 的实际字段名 |
> 替换某个件,这个件的条件契约自动失效。例如:你换掉 `ShrimpTable` 用 AG-Grid,list 响应格式和字段 type 枚举两条对你都不再生效;你不挂 `OperationLogModule`,日志契约就不存在。
---
### 国际化 (ILabel)
所有前端可见文本字段(`label` / `tip` / `menu.label` / `power.label` / `field.label` / `rule.message` …)统一类型 `ILabel`。**用哪种形态取决于你写在哪一层**。
@@ -276,6 +347,27 @@ ShrimpTableModule.registerTypeHandler('phone', {
### 自定义 type 的前端渲染
后端只声明 `type: 'fieldGoodsPicker'` 即可,前端在 `hand-admin/src/module/fields/` 放一个 `FieldGoodsPicker.vue`,会按命名约定自动注册(`Field*.vue` → `'field' + *`)。
### 替换密码哈希(passwordHasher)
默认 Password 字段写入时走 bcrypt,登录时 `UserModule.verifyPassword` 也走 bcrypt。bcrypt 是 lazy import 的——不真正调用 hash/verify 就不会 require,所以**不需要**密码功能的项目可以根本不装 bcrypt。
需要替换(测试用明文 / 改 argon2 / 接入老系统的 SHA256)时,在启动期改一行:
```ts
import { Shrimp, plainHasher } from "shrimp-server"
// 测试 / 开发
Shrimp.passwordHasher = plainHasher
// 或自定义
Shrimp.passwordHasher = {
async hash(plain) { /* ... */ return hashed },
async verify(plain, hashed) { /* ... */ return ok },
}
```
`Password` 字段 reqFilter 和 `UserModule.verifyPassword` 都会走新 hasher。**plainHasher 仅用于测试/开发**,不要在生产环境用。
---
## 6. 用 operator 拿到当前登录用户
@@ -611,15 +703,7 @@ class MyAdmin extends ShrimpServer {
---
## 14. 完整接入示例
参考本仓库:
- [examples/basic-admin/index.ts](examples/basic-admin/index.ts) — 零 Mongo 依赖,用 MemoryShrimpCrud 跑起一个 admin
- [examples/basic-admin/README.md](examples/basic-admin/README.md) — curl 示例 + 跑法
---
## 15. 常见踩坑
## 14. 常见踩坑
- **`relation.moduleKey` 必须能在 crudRegistry 找到。** 如果改成了非默认数据源,确认 `(dataSource as any).model` 存在,否则自动 populate 不生效。
- **`searchable` 只对字符串型字段生效。** 关键词搜索走 LIKE,数字/布尔字段加了也没用,要做精确筛选用 `filterable`。
+74
View File
@@ -0,0 +1,74 @@
# Array / Object 字段类型 —— 设计草案(暂未实现)
> 状态:**搁置待需求**。当前没有字段需要它(powers 已用自定义类型 `powers` 解决,dropList 用自定义 type + Mixed 解决)。
> 等出现「既要结构化数组/对象、又要框架级递归校验 + 自动表单渲染」的真实字段时,再按本文档实现。
## 背景:为什么有这份文档
### 触发点:powers 字段类型错误
角色权限字段 `powers` 实际存的是权限 key 数组(`string[]`),但 `roleFields` 里错标成 `type: "string"`。在「字段前置验证」(`ShrimpTableModule.validateField`)加上类型校验后,String 分支 `typeof value !== 'string'` 把数组判为类型错误。
### 已采用的解法(v0.6.2,已上线)
把 powers 改成自定义类型 `type: "powers"`。原理:`validateField` 的类型校验是 switch-case,只认 string/number/boolean/date 四个内置类型,**任何自定义 type 不匹配任何 case → 自动跳过类型校验**;且未注册 typeHandler → 无 reqFilter/resFilter → 数组原样存取,不破坏前端权限树。required/rules 仍生效。
这是最小解,powers 问题已闭环。**本文档的 array/object 不是为 powers——powers 不需要它**。
## 何时才该实现本设计
满足以下条件之一再做,否则属于过度设计:
- 出现一个字段,值是**结构化数组**(如 `[{itemId,count,weight}]`),且希望**框架级递归校验** + **自动按子字段渲染表单**(而非像 dropList 那样手写专属前端组件 + 跳过校验)。
- 多个字段重复出现「对象数组」形态,自定义类型方案的手写成本变得不划算。
判别:需求会把"要不要去重 / 要不要嵌套 / 摘要怎么显示"这些问题自然定下来,比现在凭空猜更准。
## 设计要点(实现时参考)
### 与 multiple 的关系(关键,A 方案)
`multiple` 与 `array` 是**两个正交维度,不合并**:
- `multiple`:**选择基数修饰符**,依附 select/relation,含义"从固定候选集里选几个",**集合语义(不可重复)**。保持现状不动。
- `array`:**值形态类型**,开放列表,元素类型由 `item` 定,可嵌套。
- 判别标准:值来自**固定选项集**→ select/relation + multiple;值是**开放列表**→ array。
### 后端 shrimp-server
**1. `IShrimpType.ts`**
```ts
// ShrimpFieldTypes 增加
Array: "array", Object: "object"
// IFieldConfig 增加(复用 IFieldConfig 自身递归 —— tab 的 tabs[].fields 已有先例)
item?: IFieldConfig // array 元素的类型定义
fields?: IFieldConfig[] // object 的子字段定义
unique?: boolean // array 是否去重(集合语义,默认 false)
```
**2. `ShrimpTableModule.validateField` 递归校验**
- `array` 分支:值必须是数组 → 对每个元素递归 `validateField(field.item, 元素, item)`;`unique:true` 查重报错;min/max 已支持数组长度。
- `object` 分支:值必须是对象 → 对 `field.fields` 每个子字段递归 `validateField(子字段, value[子key], value)`。
- **multiple 特殊处理**:`multiple:true` 字段值是数组,但元素校验走字段自己的 options/relation;在类型 switch 里 multiple 字段**跳过标量类型检查**(防止 powers 那类问题复发)。
**3. typeHandler**
- array/object **不注册 resFilter**(不同于 json 会 `JSON.stringify`)——结构原样进出,前端直接拿到结构化数据。
### 前端 shrimp-admin
字段渲染机制:约定式自动注册 —— `components/fields/Field<X>.vue` → 自动注册类型 `<x>`(首字母小写)。`vueAdmin.getFieldComponent(moduleKey, type, key)` 按 type 取组件,未知 type 兜底为 string。**范本见 `FieldTab.vue`**:用 `getFieldComponent(childField.type)` 递归渲染子字段。
**1. `components/fields/FieldArray.vue`(新)** 类型 `array`
- edit:列表渲染,每项 `getFieldComponent(field.item.type)` 递归 + 增/删行。
- display:摘要(JSON 截断,或「N 项」)。
- props 标准接口:`{field, modelValue, mode:'display'|'edit'|'filter', disabled}`。
**2. `components/fields/FieldObject.vue`(新)** 类型 `object`
- edit:按 `field.fields` 逐个 `el-form-item` + 递归组件(≈ FieldTab 去掉 tab 选择层)。
- display:摘要。
**递归天然支持嵌套**:array 的 `item` 可以是 `{type:"object",fields:[...]}`(对象数组),object 的某字段可以是 `{type:"array",...}`,任意层级。
### powers 若迁移到 array(可选,非必须)
`{ key:"powers", type:"array", item:{type:"string"}, unique:true }`(集合去重)。但 powers 现用 `"powers"` 自定义类型已工作,**无强需求迁移**。
## 发版
实现并前后端验证(后端 `tsc --noEmit`、前端 `vue-tsc`/build)通过后,shrimp-server + shrimp-admin 一起升 **0.7.0**,业务项目引用同步更新。
## 关联
- 另一个已知待办:`field.validate`(字段级自定义验证函数,`IFieldConfig.validate`)在 `IShrimpType.ts` 定义了,但 `prepareAddItem`/`prepareEditItem` 只调了 `handler.validate`(typeHandler 级),**`field.validate` 从未被调用** —— 漏接,待补。
-52
View File
@@ -1,52 +0,0 @@
# basic-admin demo
零外部依赖的 Shrimp demo,**只用内存 CRUD,不依赖 MongoDB**。
证明:Shrimp 的核心(模块注册 / RPC dispatch / 字段配置)在没有 mongoose、没有 JWT、没有用户表的情况下也能跑。
## 起服务
从 demo 目录起(本地链 shrimp-server):
```sh
cd examples/basic-admin
npm install
npm start
```
或者从仓库根目录直接 `npm run demo`(由根 package.json 提供)。
服务起在 `http://localhost:3100`。
## 试一下
```sh
# 列商品
curl -s -X POST http://localhost:3100/api/product/list \
-H 'content-type: application/json' -d '[{}]' | jq
# 列标签
curl -s -X POST http://localhost:3100/api/tag/list \
-H 'content-type: application/json' -d '[{}]' | jq
# 新增商品
curl -s -X POST http://localhost:3100/api/product/add \
-H 'content-type: application/json' \
-d '[{"code":"P003","name":"橙子","price":4,"tagIds":[]}]' | jq
# 改某个商品
curl -s -X POST http://localhost:3100/api/product/edit \
-H 'content-type: application/json' -d '["1",{"price":99}]' | jq
# 删除商品
curl -s -X POST http://localhost:3100/api/product/delete \
-H 'content-type: application/json' -d '["1"]' | jq
```
请求体永远是参数数组,按位置传给事件 handler。
## 这个 demo 暴露了哪些 70→90 的问题
- **`Shrimp.ts:322` 的 `if ((dataSource as any).model)`** 让 MemoryShrimpCrud 不能注册到 `crudRegistry`,relation populate 自动联表对内存数据源不生效
- **没有现成的 RPC mount helper** — 这段 `app.post("/api/*splat", ...)` 应该是框架提供的,而不是 demo 里手写
- **要登录 / 鉴权要么自己写,要么用 ShrimpServer 强吃 Mongo+JWT**,中间没有"我只想要 RPC + 模块,自己接 auth"这条路径
-168
View File
@@ -1,168 +0,0 @@
/**
* Shrimp basic-admin demo
*
* 用 MemoryShrimpCrud 跑一个零依赖的 admin server,展示:
* - Shrimp 核心(模块注册 / RPC dispatch / 权限树)
* - ShrimpTableModule + MemoryShrimpCrud 的 CRUD 自动事件
* - 通过 Express 暴露成完整的 hand-admin 协议:
* GET /admin-api/config
* POST /admin-api/login
* GET /admin-api/me
* POST /admin-api/<module>[/<sub>...]/<event>
*
* 不使用 ShrimpServer/Mongoose/JWT,刻意暴露 ShrimpServer 把太多东西耦合在
* Express+Mongo+Auth 一起的事实,作为后续重构的对照样本。
*
* 单一 mock super-admin operator,前端登录直接放行,token 是固定字符串。
*/
import express, { type Request, type Response } from "express"
import {
Shrimp,
ShrimpTableModule,
MemoryShrimpCrud,
ShrimpFieldTypes,
ShrimpError,
ShrimpCode,
} from "shrimp-server"
import type {
IFieldConfig,
WithId,
IShrimpOperator,
IModuleInfo,
IMenuItem,
IMeResult,
} from "shrimp-server"
interface ITag { _id: string; name: string }
interface IProduct { _id: string; code: string; name: string; price: number; tagIds: string[] }
const tagFields: IFieldConfig[] = [
{ key: "name", label: "标签名", type: ShrimpFieldTypes.String, required: true, searchable: true },
]
const productFields: IFieldConfig[] = [
{ key: "code", label: "编码", type: ShrimpFieldTypes.String, required: true, editable: "addOnly" },
{ key: "name", label: "名称", type: ShrimpFieldTypes.String, required: true, searchable: true },
{ key: "price", label: "价格", type: ShrimpFieldTypes.Number, required: true },
{
key: "tagIds", label: "标签", type: ShrimpFieldTypes.Relation,
relation: { moduleKey: "tag", labelKey: "name" }, multiple: true, defaultValue: [],
},
]
const FAKE_TOKEN = "demo-super-admin-token"
const superAdmin: IShrimpOperator = {
userId: "demo-admin",
roleId: ["demo-super"],
isSuperAdmin: true,
}
async function bootstrap() {
const shrimp = new Shrimp()
const tagCrud = new MemoryShrimpCrud<WithId<ITag>>()
new ShrimpTableModule(shrimp, "tag", tagCrud, tagFields, "标签管理").initCrud()
const productCrud = new MemoryShrimpCrud<WithId<IProduct>>()
new ShrimpTableModule(shrimp, "product", productCrud, productFields, "商品管理").initCrud()
// 种子数据
const t1 = await tagCrud.add({ name: "热门" } as any)
const t2 = await tagCrud.add({ name: "促销" } as any)
await productCrud.add({ code: "P001", name: "苹果", price: 5, tagIds: [t1._id] } as any)
await productCrud.add({ code: "P002", name: "香蕉", price: 3, tagIds: [t1._id, t2._id] } as any)
// super admin 看得到所有模块
const allKeys = (await shrimp.allPower()).map(p => p.key)
const superPowers = new Set(allKeys)
const app = express()
app.use(express.json())
// 简单认证: 仅检查 Authorization,通过即注入 super-admin operator
function authedRunner(req: Request, res: Response, run: () => Promise<any>) {
const auth = req.headers.authorization || ""
const token = auth.replace(/^Bearer\s+/i, "")
if (token !== FAKE_TOKEN) {
res.json({ code: ShrimpCode.UNAUTHORIZED, tip: { t: "shrimp.relogin" } })
return
}
Shrimp.asyncLocalStorage.run(superAdmin, async () => {
try {
const data = await run()
res.json({ code: ShrimpCode.OK, data })
} catch (e: any) {
if (e instanceof ShrimpError) {
res.json({ code: e.code, tip: e.tip })
} else {
console.error(e)
res.json({ code: ShrimpCode.SERVER_ERROR, log: String(e?.message || e) })
}
}
})
}
// --- 公开接口 ---
app.get("/admin-api/config", (_req, res) => {
res.json({ code: ShrimpCode.OK, data: { title: "Shrimp Basic Admin Demo" } })
})
app.post("/admin-api/login", (req, res) => {
const { username, password } = req.body || {}
// demo 模式不校验密码,任意账号都给同一个 token
if (!username || !password) {
res.json({ code: ShrimpCode.UNAUTHORIZED, tip: { t: "shrimp.loginFailed" } })
return
}
res.json({ code: ShrimpCode.OK, data: { token: FAKE_TOKEN } })
})
// --- 需要登录的接口 ---
app.get("/admin-api/me", (req, res) => {
authedRunner(req, res, async () => {
const moduleInfo: IModuleInfo[] = shrimp.getModuleInfo(superPowers)
const fullMenu = shrimp.getMenuTree()
const menuTree: IMenuItem[] = shrimp.filterMenuTree(fullMenu, allKeys)
const result: IMeResult = {
nickname: "Demo Admin",
moduleInfo,
menuTree,
options: {},
}
return result
})
})
// RPC dispatch: POST /admin-api/<...path> body 是参数数组
app.post(/^\/admin-api\/.+/, (req, res) => {
const rawPath = req.path.replace(/^\/admin-api\//, "")
// 不要把 login/config 也接进来
if (rawPath === "login" || rawPath === "config" || rawPath === "me") {
res.status(404).end()
return
}
authedRunner(req, res, async () => {
const path = rawPath.split("/").filter(Boolean)
const args = Array.isArray(req.body) ? req.body : []
return shrimp.call(path, ...args)
})
})
const port = 3100
app.listen(port, () => {
console.log(`Shrimp basic-admin demo: http://localhost:${port}`)
console.log("")
console.log("# 配合前端: cd web && npm install && npm run dev,然后访问 http://localhost:5173")
console.log("# 登录: 任意账号密码")
console.log("")
console.log("# 也可以直接 curl:")
console.log(` TOKEN=${FAKE_TOKEN}`)
console.log(` curl -s -X POST http://localhost:${port}/admin-api/product/list -H 'content-type: application/json' -H "Authorization: Bearer $TOKEN" -d '[{}]' | jq`)
})
}
bootstrap().catch(err => {
console.error("bootstrap failed:", err)
process.exit(1)
})
File diff suppressed because it is too large Load Diff
-17
View File
@@ -1,17 +0,0 @@
{
"name": "shrimp-basic-admin-example",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "tsx index.ts"
},
"dependencies": {
"shrimp-server": "file:../..",
"express": "^4.19.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html >
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
</head>
<body>
<div id="app" class="aminui">
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,27 +0,0 @@
{
"name": "shrimp-basic-admin-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"axios": "1.3.4",
"element-plus": "^2.10.4",
"vue": "^3.3.4",
"vue-i18n": "^9.1.9",
"vue-router": "4.1.6"
},
"devDependencies": {
"@types/node": "^20.8.3",
"@vitejs/plugin-vue": "^4.2.3",
"sass": "^1.89.2",
"typescript": "^5.0.2",
"vite": "^4.4.5",
"vue-tsc": "^1.8.5"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 B

@@ -1,372 +0,0 @@
/**
* ShrimpClient — Shrimp 框架前端基类
* 框架无关层,包含协议通信、模块管理、菜单树操作
*/
import { ShrimpCode, ShrimpRequestFn, IShrimpResponse, IModuleInfo, IMenuItem, ILoginResult, IMeResult, IShrimpClient, ILabel } from '@shrimp/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()
}
}
}
-425
View File
@@ -1,425 +0,0 @@
import * as elIcons from '@element-plus/icons-vue'
import { type App, type Component, 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 config from '@/config'
import { IShrimpResponse, ILabel } from '@shrimp/IShrimpType'
import { ShrimpClient, handleShrimpResponse, type IShrimpTipper, type IRouteInfo } from './ShrimpClient'
import FieldString from '@/components/fields/FieldString.vue'
// ============== 字段组件注册表 ==============
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')))
// 业务字段组件(自动加载 module/fields 目录)
const fieldModules = import.meta.glob<Component>('@/module/fields/Field*.vue')
for (const path in fieldModules) {
const match = path.match(/\/Field(.+)\.vue$/)
if (match) {
const name = 'field' + match[1] // FieldGoodsList → fieldGoodsList
fieldRegistry.set(name, defineAsyncComponent(fieldModules[path]))
}
}
// 模块页面组件(支持嵌套,顶层 module/{key}.vue,子模块 module/{parent}/{sub}.vue)
const moduleComponents = import.meta.glob<Component>('@/module/**/*.vue')
function resolveModuleComponent(componentPath: string): (() => Promise<Component>) | null {
const suffix = `/module/${componentPath}.vue`
for (const key in moduleComponents) {
if (key.endsWith(suffix)) return moduleComponents[key]
}
console.warn('[VueAdmin] available module keys:', Object.keys(moduleComponents))
return null
}
/** 注册自定义字段组件 */
export function registerField(type: string, comp: Component) {
fieldRegistry.set(type, comp)
}
/** 为指定模块的指定字段覆盖组件 */
export function overrideField(moduleKey: string, fieldKey: string, comp: Component) {
const registryKey = `__override_${moduleKey}_${fieldKey}`
fieldRegistry.set(registryKey, comp)
fieldOverrides.set(`${moduleKey}.${fieldKey}`, registryKey)
}
/** 根据模块+字段获取对应组件,优先 override,再按 type,最后回退 FieldString */
export function 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
}
// ============== LocalData(localStorage 封装) ==============
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(Vue 整合层) ==============
export class VueAdmin extends ShrimpClient {
router!: Router
localData = new LocalData()
i18n!: I18n
adminConfig: Record<string, any> = reactive({})
userName = ''
// ---- 字段组件注册 ----
/** 注册自定义字段组件 */
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,
})
// ---- token(跨刷新持久化) ----
private _token: string | null = null
get token() { return this._token }
set token(value: string | null) {
this.localData.set("token", value)
this._token = value
}
langList = [
{ lang: 'zh-cn', name: '简体中文' },
// { lang: 'en', name: 'English' },
]
/** 可用语言数量,<=1 时不需要显示切换按钮 */
get langCount() { return this.langList.length }
// ---- locale(跨刷新持久化) ----
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
}
// ---- HTTP ----
request = axios.create({
baseURL: config.API_URL,
timeout: config.TIMEOUT
})
constructor() {
super()
this.initHttp()
this.initI18n()
this.initRouter()
this.initMobileCheck()
this._token = this.localData.get("token")
}
// ---- i18n ----
initI18n() {
this.i18n = createI18n({
locale: this.localData.get("lang") || 'zh-cn',
globalInjection: true,
legacy: false,
messages: { 'zh-cn': zh_cn, 'en': en }
})
}
/**
* 通用翻译方法,处理 ILabel union
* - string → 字面量,原样返回
* - { t, params } → vue-i18n t(),params 递归翻译
* - { tc, count } → vue-i18n tc(),复数
* - { d, format } → vue-i18n d(),日期格式化
* - { n, format } → vue-i18n n(),数字格式化
* - { zh, en, ... }→ 内联多语言,按当前 locale 取
*/
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
}
// ---- ShrimpClient 抽象方法实现 ----
/** 覆盖:保存用户名和全局选项 */
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
}
/** 底层 HTTP 传输:所有请求都走这里 */
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
}
/** 注册 Vue 路由 */
registerRoute(info: IRouteInfo) {
const componentPath = info.componentPath || info.moduleKey
const importer = resolveModuleComponent(componentPath)
if (!importer) {
console.warn(`[VueAdmin] 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 }
})
}
// ---- Router ----
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 {
// /me 失败(token 过期等),跳登录
this.token = null
next({ path: '/login' })
}
} else {
next()
}
})
}
// ---- Tipper(Shrimp 协议提示接口) ----
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,
}),
}
// ---- HTTP 拦截器 ----
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
// 兼容非 Shrimp 标准格式的接口
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()
}
}
export let vueAdmin = new VueAdmin()
export default {
install(app: App) {
app.use(ElementPlus)
app.use(vueAdmin.i18n)
app.use(vueAdmin.router)
for (const key in elIcons) {
app.component(`ElIcon${key}`, (elIcons as any)[key])
// app.component(`el-icon-${key}`, (elIcons as any)[key])
}
},
}
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M258.389333 354.133333a299.093333 299.093333 0 0 1 8.490667-12.8h490.24c2.944 4.181333 5.76 8.490667 8.490667 12.8l86.186666-49.749333 42.666667 73.898667-94.421333 54.528c6.912 25.173333 10.624 51.754667 10.624 79.189333v42.666667h128v85.333333h-128a296.96 296.96 0 0 1-22.869334 114.773333l106.666667 61.610667-42.666667 73.898667-107.776-62.208A298.325333 298.325333 0 0 1 554.666667 935.637333V597.333333h-85.333334v338.346667a298.325333 298.325333 0 0 1-189.354666-107.605333l-107.776 62.208-42.666667-73.898667 106.666667-61.568A297.770667 297.770667 0 0 1 213.333333 640H85.333333v-85.333333h128v-42.666667c0-27.434667 3.712-53.973333 10.624-79.189333L129.536 378.282667l42.666667-73.898667L258.389333 354.133333zM341.333333 256a170.666667 170.666667 0 1 1 341.333334 0H341.333333z" p-id="26147"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M554.666667 849.066667a213.418667 213.418667 0 0 0 170.666666-209.066667v-128a212.48 212.48 0 0 0-17.706666-85.333333h-391.253334A212.48 212.48 0 0 0 298.666667 512v128a213.418667 213.418667 0 0 0 170.666666 209.066667V597.333333h85.333334v251.733334z m-318.464-94.293334A297.770667 297.770667 0 0 1 213.333333 640H85.333333v-85.333333h128v-42.666667c0-27.434667 3.712-53.973333 10.624-79.189333L129.536 378.282667l42.666667-73.898667L258.389333 354.133333a299.093333 299.093333 0 0 1 8.490667-12.8h490.24c2.944 4.181333 5.76 8.490667 8.490667 12.8l86.186666-49.749333 42.666667 73.898667-94.421333 54.528c6.912 25.173333 10.624 51.754667 10.624 79.189333v42.666667h128v85.333333h-128a296.96 296.96 0 0 1-22.869334 114.773333l106.666667 61.610667-42.666667 73.898667-107.776-62.208A298.069333 298.069333 0 0 1 512 938.666667a298.069333 298.069333 0 0 1-232.021333-110.592l-107.776 62.208-42.666667-73.898667 106.666667-61.568zM341.333333 256a170.666667 170.666667 0 1 1 341.333334 0H341.333333z" p-id="25873"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M981.333333 512l-301.696 301.696-60.330666-60.330667L860.672 512l-241.365333-241.365333 60.330666-60.330667L981.333333 512zM163.328 512l241.365333 241.365333-60.330666 60.330667L42.666667 512l301.696-301.696 60.330666 60.330667L163.328 512z" p-id="4503"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M554.666667 426.666667h213.333333l-256 256-256-256h213.333333V128h85.333334v298.666667z m-384 384h682.666666v-298.666667h85.333334v341.333333a42.666667 42.666667 0 0 1-42.666667 42.666667H128a42.666667 42.666667 0 0 1-42.666667-42.666667v-341.333333h85.333334v298.666667z" p-id="26056"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M121.984 122.752l536.32-76.586667a21.333333 21.333333 0 0 1 24.362667 21.12v889.429334a21.333333 21.333333 0 0 1-24.32 21.12L121.941333 901.248a42.666667 42.666667 0 0 1-36.650666-42.24V164.992a42.666667 42.666667 0 0 1 36.650666-42.24zM725.333333 128h170.666667a42.666667 42.666667 0 0 1 42.666667 42.666667v682.666666a42.666667 42.666667 0 0 1-42.666667 42.666667h-170.666667V128z m-290.133333 384L554.666667 341.333333h-102.4L384 438.869333 315.733333 341.333333H213.333333l119.466667 170.666667L213.333333 682.666667h102.4L384 585.130667 452.266667 682.666667H554.666667l-119.466667-170.666667z" p-id="3794"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M725.333333 128h170.666667a42.666667 42.666667 0 0 1 42.666667 42.666667v682.666666a42.666667 42.666667 0 0 1-42.666667 42.666667h-170.666667V128zM121.984 122.752l536.32-76.586667a21.333333 21.333333 0 0 1 24.362667 21.12v889.429334a21.333333 21.333333 0 0 1-24.32 21.12L121.941333 901.248a42.666667 42.666667 0 0 1-36.650666-42.24V164.992a42.666667 42.666667 0 0 1 36.650666-42.24zM213.333333 341.333333v341.333334h85.333334v-85.333334h256V341.333333H213.333333z m85.333334 85.333334h170.666666v85.333333H298.666667v-85.333333z" p-id="3925"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M725.333333 128h170.666667a42.666667 42.666667 0 0 1 42.666667 42.666667v682.666666a42.666667 42.666667 0 0 1-42.666667 42.666667h-170.666667V128zM121.984 122.752l536.32-76.586667a21.333333 21.333333 0 0 1 24.362667 21.12v889.429334a21.333333 21.333333 0 0 1-24.32 21.12L121.941333 901.248a42.666667 42.666667 0 0 1-36.650666-42.24V164.992a42.666667 42.666667 0 0 1 36.650666-42.24zM469.333333 341.333333v212.864L384 469.333333l-84.906667 85.333334L298.666667 341.333333H213.333333v341.333334h85.333334l85.333333-85.333334 85.333333 85.333334h85.333334V341.333333h-85.333334z" p-id="3663"></path></svg>
</template>
@@ -1,21 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
width="1em"
height="1em"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 512 512"
>
<path
d="M478.33 433.6l-90-218a22 22 0 0 0-40.67 0l-90 218a22 22 0 1 0 40.67 16.79L316.66 406h102.67l18.33 44.39A22 22 0 0 0 458 464a22 22 0 0 0 20.32-30.4zM334.83 362L368 281.65L401.17 362z"
fill="currentColor"
></path>
<path
d="M267.84 342.92a22 22 0 0 0-4.89-30.7c-.2-.15-15-11.13-36.49-34.73c39.65-53.68 62.11-114.75 71.27-143.49H330a22 22 0 0 0 0-44H214V70a22 22 0 0 0-44 0v20H54a22 22 0 0 0 0 44h197.25c-9.52 26.95-27.05 69.5-53.79 108.36c-31.41-41.68-43.08-68.65-43.17-68.87a22 22 0 0 0-40.58 17c.58 1.38 14.55 34.23 52.86 83.93c.92 1.19 1.83 2.35 2.74 3.51c-39.24 44.35-77.74 71.86-93.85 80.74a22 22 0 1 0 21.07 38.63c2.16-1.18 48.6-26.89 101.63-85.59c22.52 24.08 38 35.44 38.93 36.1a22 22 0 0 0 30.75-4.9z"
fill="currentColor"
></path>
</svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M640 128a42.666667 42.666667 0 0 1 42.666667 42.666667v170.666666a42.666667 42.666667 0 0 1-42.666667 42.666667h-85.333333v85.333333h170.666666a42.666667 42.666667 0 0 1 42.666667 42.666667v128h85.333333a42.666667 42.666667 0 0 1 42.666667 42.666667v170.666666a42.666667 42.666667 0 0 1-42.666667 42.666667h-256a42.666667 42.666667 0 0 1-42.666666-42.666667v-170.666666a42.666667 42.666667 0 0 1 42.666666-42.666667h85.333334v-85.333333H341.333333v85.333333h85.333334a42.666667 42.666667 0 0 1 42.666666 42.666667v170.666666a42.666667 42.666667 0 0 1-42.666666 42.666667H170.666667a42.666667 42.666667 0 0 1-42.666667-42.666667v-170.666666a42.666667 42.666667 0 0 1 42.666667-42.666667h85.333333v-128a42.666667 42.666667 0 0 1 42.666667-42.666667h170.666666V384H384a42.666667 42.666667 0 0 1-42.666667-42.666667V170.666667a42.666667 42.666667 0 0 1 42.666667-42.666667h256zM384 725.333333H213.333333v85.333334h170.666667v-85.333334z m426.666667 0h-170.666667v85.333334h170.666667v-85.333334zM597.333333 213.333333h-170.666666v85.333334h170.666666V213.333333z" p-id="51975"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M170.666667 810.666667h682.666666v-298.666667h85.333334v341.333333a42.666667 42.666667 0 0 1-42.666667 42.666667H128a42.666667 42.666667 0 0 1-42.666667-42.666667v-341.333333h85.333334v298.666667z m384-426.666667v298.666667h-85.333334V384H256l256-256 256 256h-213.333333z" p-id="25917"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M42.666667 128h170.666666l298.666667 512 298.666667-512h170.666666L512 938.666667 42.666667 128z m369.792 0L512 298.666667l99.541333-170.666667h172.16L512 597.333333 240.298667 128h172.16z" p-id="4634"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M792.490667 585.002667a38.826667 38.826667 0 0 0 38.314666-38.314667c0-21.248-17.024-38.314667-38.314666-38.314667s-38.314667 17.066667-38.314667 38.314667c0 21.333333 17.066667 38.314667 38.314667 38.314667z m-188.8 0a38.826667 38.826667 0 0 0 38.314666-38.314667c0-21.248-17.066667-38.314667-38.314666-38.314667-21.333333 0-38.314667 17.066667-38.314667 38.314667 0 21.333333 17.024 38.314667 38.314667 38.314667z m280.192 215.04a14.805333 14.805333 0 0 0-7.338667 15.786666c0 2.048 0 4.138667 1.066667 6.272 4.181333 17.792 12.544 46.122667 12.544 47.189334 0 3.114667 1.066667 5.205333 1.066666 7.338666a9.386667 9.386667 0 0 1-9.429333 9.386667c-2.133333 0-3.157333-1.024-5.248-2.048l-61.824-35.669333a34.090667 34.090667 0 0 0-14.677333-4.181334c-3.114667 0-6.272 0-8.362667 1.024-29.354667 8.405333-59.733333 12.586667-92.202667 12.586667-156.16 0-281.898667-104.832-281.898666-234.88 0-130.005333 125.738667-234.88 281.898666-234.88 156.117333 0 281.856 104.874667 281.856 234.88 0 70.272-37.717333 134.229333-97.450666 177.237333zM711.381333 345.557333a388.48 388.48 0 0 0-11.946666-0.213333c-178.090667 0-324.522667 122.026667-324.522667 277.546667 0 23.637333 3.413333 46.506667 9.728 68.266666h-3.797333a425.088 425.088 0 0 1-110.250667-15.701333c-3.157333-1.066667-6.314667-1.066667-9.472-1.066667a35.498667 35.498667 0 0 0-17.834667 5.248l-74.581333 42.88c-2.133333 1.066667-4.224 2.133333-6.314667 2.133334a11.648 11.648 0 0 1-11.52-11.52c0-3.157333 1.024-5.248 2.090667-8.405334 1.024-1.024 10.496-35.584 15.744-56.490666 0-2.133333 1.024-5.248 1.024-7.338667a23.722667 23.722667 0 0 0-9.429333-18.858667C87.808 570.709333 42.666667 494.336 42.666667 409.514667 42.666667 253.653333 194.986667 128 381.866667 128c160.64 0 295.68 92.544 329.514666 217.514667z m-219.904 17.834667c24.448 0 43.776-20.352 43.776-43.776 0-24.448-19.328-43.776-43.776-43.776s-43.776 19.328-43.776 43.776 19.328 43.776 43.776 43.776z m-224.426666 0c24.448 0 43.818667-20.352 43.818666-43.776 0-24.448-19.370667-43.776-43.818666-43.776-24.405333 0-43.776 19.328-43.776 43.776s19.370667 43.776 43.776 43.776z" p-id="4372"></path></svg>
</template>
@@ -1,12 +0,0 @@
export { default as Vue } from './Vue.vue'
export { default as Code } from './Code.vue'
export { default as Wechat } from './Wechat.vue'
export { default as BugFill } from './BugFill.vue'
export { default as BugLine } from './BugLine.vue'
export { default as FileWord } from './FileWord.vue'
export { default as FileExcel } from './FileExcel.vue'
export { default as FilePpt } from './FilePpt.vue'
export { default as Organization } from './Organization.vue'
export { default as Upload } from './Upload.vue'
export { default as Download } from './Download.vue'
export { default as Lang } from './Lang.vue'
@@ -1,47 +0,0 @@
<template>
<el-dropdown v-if="vueAdmin.langCount > 1" trigger="click" @command="setLang">
<div class="lang-switch">
<el-icon><Lang /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in vueAdmin.langList" :key="item.lang" :command="item.lang"
:class="{ 'is-active': item.lang === vueAdmin.locale }">{{
item.name }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script lang="ts" setup>
import { vueAdmin } from "@/VueAdmin";
import Lang from "@/assets/icons/Lang.vue";
function setLang(lang: string) {
vueAdmin.locale = lang
}
</script>
<style scoped>
.lang-switch {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 4px;
color: #999;
}
.lang-switch .el-icon {
font-size: 16px;
}
</style>
<style>
.el-dropdown-menu .is-active {
color: var(--el-color-primary) !important;
font-weight: bold;
}
</style>
@@ -1,122 +0,0 @@
<template>
<div class="power-tree-wrap">
<el-tree ref="treeRef" :data="treeData" show-checkbox node-key="key" default-expand-all
:props="{ label: 'label', children: 'children' }" @check="onCheck" />
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick } from 'vue'
import { vueAdmin } from '@/VueAdmin'
import type { IMenuItem, ILabel } from '@shrimp/IShrimpType'
interface IPowerItem {
key: string
label: ILabel
}
const props = defineProps<{
modelValue: string[]
allPowers: IPowerItem[]
menuTree: IMenuItem[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
const treeRef = ref<any>(null)
interface PowerNode { key: string; label: string; children?: PowerNode[] }
const MENU_PREFIX = '_menu:'
const treeData = computed(() => {
function walkMenu(items: IMenuItem[]): PowerNode[] {
return items.reduce<PowerNode[]>((acc, item) => {
if (item.moduleKey) {
const modulePowers = props.allPowers.filter(p => p.key === item.moduleKey)
const children = props.allPowers
.filter(p => p.key.startsWith(item.moduleKey + '.'))
.map(p => ({ key: p.key, label: vueAdmin.t(p.label) }))
// 模块本身(view 权限)
if (modulePowers.length > 0) {
children.unshift({
key: item.moduleKey,
label: vueAdmin.t({ t: "power.view" }),
})
}
const node: PowerNode = {
key: MENU_PREFIX + item.moduleKey,
label: vueAdmin.t(item.label),
children: children.length ? children : undefined,
}
acc.push(node)
} else if (item.children?.length) {
const children = walkMenu(item.children)
if (children.length) {
acc.push({
key: MENU_PREFIX + (item.name || ''),
label: vueAdmin.t(item.label),
children,
})
}
}
return acc
}, [])
}
return walkMenu(props.menuTree)
})
function onCheck(data: any, state: any) {
const allKeys: string[] = state.checkedKeys
let keys = allKeys.filter((k: string) => !k.startsWith(MENU_PREFIX))
const toggledKey: string = data.key
// 菜单分组节点的子节点已由 el-tree 自动处理,直接返回
if (toggledKey.startsWith(MENU_PREFIX)) {
emit('update:modelValue', keys)
return
}
const checked = new Set(keys)
const isChecked = checked.has(toggledKey)
if (isChecked) {
// 勾选子权限时,自动勾选所有祖先
let ancestor = toggledKey
while (ancestor.includes('.')) {
ancestor = ancestor.substring(0, ancestor.lastIndexOf('.'))
checked.add(ancestor)
}
} else {
// 取消父权限时,自动取消所有后代
const prefix = toggledKey + '.'
keys = keys.filter(key => !key.startsWith(prefix))
emit('update:modelValue', keys)
return
}
emit('update:modelValue', [...checked])
}
watch(() => [props.modelValue, props.allPowers], () => {
nextTick(() => treeRef.value?.setCheckedKeys(props.modelValue || []))
}, { immediate: true })
</script>
<style scoped>
.power-tree-wrap {
border: 1px solid var(--el-border-color);
border-radius: 4px;
background: var(--el-bg-color);
padding: 8px 12px;
width: 100%;
}
.power-tree-wrap :deep(.el-tree) {
background: transparent;
}
</style>
@@ -1,126 +0,0 @@
<template>
<el-drawer class="shrimp-drawer" :model-value="visible" @update:model-value="$emit('update:visible', $event)" :title="title" size="400px">
<el-form ref="formRef" :model="formData" :rules="formRules" @submit.prevent>
<template v-for="field in fields" :key="field.key">
<el-form-item :label="vueAdmin.t(field.label)" :prop="field.key" :required="field.required">
<slot :name="`form-${field.key}`" :field="field" :data="formData" :isEdit="isEdit">
<component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)" :field="field"
:model-value="formData[field.key]"
@update:model-value="onFieldChange(field.key, $event)"
mode="edit"
:disabled="isEdit && (field.editable === 'readonly' || field.editable === 'addOnly')" />
</slot>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="$emit('update:visible', false)">{{ $t('base.cancel') }}</el-button>
<el-button type="primary" :disabled="!canSave" @click="$emit('save')">{{ $t('base.confirm') }}</el-button>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { provide, computed, ref } from 'vue'
import { IFieldConfig, IFieldRule } from '@shrimp/IShrimpType'
import { vueAdmin } from '@/VueAdmin'
import type { FormInstance, FormItemRule } from 'element-plus'
interface Props {
visible: boolean
title: string
moduleKey: string
fields: IFieldConfig[]
formData: Record<string, any>
isEdit: boolean
canSave?: boolean
}
const props = defineProps<Props>()
provide('shrimpModuleKey', props.moduleKey)
const emit = defineEmits<{
'update:visible': [value: boolean]
'update:formData': [value: Record<string, any>]
save: []
}>()
const formRef = ref<FormInstance>()
function buildRules(field: IFieldConfig): FormItemRule[] {
const rules: FormItemRule[] = []
if (field.required) {
rules.push({
required: true,
message: vueAdmin.t('shrimp.fieldRequired'),
trigger: 'change',
...(field.type === 'string' || field.type === 'textarea' || field.type === 'password' ? { trigger: 'blur' } : {}),
})
}
if (field.rules) {
for (const r of field.rules) {
const rule: FormItemRule = { trigger: 'change' }
if (r.min !== undefined) rule.min = r.min
if (r.max !== undefined) rule.max = r.max
if (r.pattern) rule.pattern = new RegExp(r.pattern)
if (r.message) rule.message = vueAdmin.t(r.message)
rules.push(rule)
}
}
return rules
}
const formRules = computed(() => {
const rules: Record<string, FormItemRule[]> = {}
for (const field of props.fields) {
const list = buildRules(field)
if (list.length > 0) {
rules[field.key] = list
}
}
return rules
})
function onFieldChange(key: string, value: any) {
emit('update:formData', { ...props.formData, [key]: value })
}
async function validate(): Promise<boolean> {
if (!formRef.value) return true
try {
await formRef.value.validate()
return true
} catch {
return false
}
}
defineExpose({ validate })
</script>
<style>
.shrimp-drawer .el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.shrimp-drawer .el-drawer__body {
padding: 20px;
}
.shrimp-drawer .el-drawer__footer {
padding: 12px 20px;
border-top: 1px solid var(--el-border-color-lighter);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.shrimp-drawer .el-drawer__body .el-form-item {
margin-bottom: 20px;
}
</style>
@@ -1,104 +0,0 @@
<template>
<el-drawer class="shrimp-drawer" :model-value="visible" @update:model-value="$emit('update:visible', $event)"
:title="title" size="400px">
<el-form :model="localFilterData">
<template v-for="field in filterableFields" :key="field.key">
<el-form-item :label="vueAdmin.t(field.label)">
<slot :name="`filter-${field.key}`" :field="field" :data="localFilterData">
<component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)" :field="field"
:model-value="localFilterData[field.key]"
@update:model-value="onFieldChange(field.key, $event)" mode="filter" />
</slot>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="handleReset">{{ $t('base.reset') }}</el-button>
<el-button type="primary" @click="handleConfirm">{{ $t('base.confirm') }}</el-button>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { IFieldConfig } from '@shrimp/IShrimpType'
import { vueAdmin } from '@/VueAdmin'
interface Props {
visible: boolean
title: string
moduleKey: string
filterableFields: IFieldConfig[]
filterData: Record<string, any>
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:visible': [value: boolean]
confirm: [value: Record<string, any>]
reset: []
}>()
const localFilterData = ref<Record<string, any>>({})
const closedByReset = ref(false)
function hasChanged(): boolean {
return JSON.stringify(localFilterData.value) !== JSON.stringify(props.filterData)
}
watch(() => props.visible, (val, oldVal) => {
if (oldVal === false && val === true) {
closedByReset.value = false
localFilterData.value = { ...props.filterData }
}
if (oldVal === true && val === false) {
if (closedByReset.value) {
closedByReset.value = false
emit('reset')
return
}
if (hasChanged()) {
emit('confirm', { ...localFilterData.value })
}
}
})
function onFieldChange(key: string, value: any) {
localFilterData.value = { ...localFilterData.value, [key]: value }
}
function handleReset() {
closedByReset.value = true
localFilterData.value = {}
emit('update:visible', false)
}
function handleConfirm() {
emit('update:visible', false)
}
</script>
<style>
.shrimp-drawer .el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.shrimp-drawer .el-drawer__body {
padding: 20px;
}
.shrimp-drawer .el-drawer__footer {
padding: 12px 20px;
border-top: 1px solid var(--el-border-color-lighter);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.shrimp-drawer .el-drawer__body .el-form-item {
margin-bottom: 20px;
}
</style>
@@ -1,22 +0,0 @@
<template>
<el-icon :size="18" style="cursor:pointer"
:style="{ color: active ? 'var(--el-color-primary)' : '' }" @click="$emit('click')">
<Filter />
</el-icon>
</template>
<script lang="ts" setup>
import { Filter } from '@element-plus/icons-vue'
interface Props {
active?: boolean
}
withDefaults(defineProps<Props>(), {
active: false
})
defineEmits<{
click: []
}>()
</script>
@@ -1,88 +0,0 @@
<template>
<div class="header-search" :class="{ show: visible }">
<el-icon :size="18" @click.stop="toggleSearch"
:style="{ color: active ? 'var(--el-color-primary)' : '' }"
><el-icon-search /></el-icon>
<el-input ref="searchInput" :model-value="keyword" class="header-search-input" clearable
@update:model-value="$emit('update:keyword', $event)"
@keyup.enter="onSearch" @blur="onSearchBlur" @clear="onSearch" />
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
interface Props {
keyword: string
visible: boolean
active?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:keyword': [value: string]
'update:visible': [value: boolean]
search: []
}>()
const searchInput = ref()
function toggleSearch() {
const next = !props.visible
emit('update:visible', next)
if (next) {
setTimeout(() => searchInput.value?.focus(), 200)
}
}
function onSearchBlur() {
if (!props.keyword) {
emit('update:visible', false)
}
}
function onSearch() {
emit('search')
}
</script>
<style scoped>
.header-search {
font-size: 0 !important;
display: flex;
align-items: center;
}
.header-search .el-icon {
cursor: pointer;
font-size: 18px;
vertical-align: middle;
}
.header-search-input {
font-size: 14px;
transition: width 0.2s;
width: 0;
overflow: hidden;
background: transparent;
border-radius: 0;
display: inline-block;
vertical-align: middle;
}
.header-search-input :deep(.el-input__inner) {
border-radius: 0;
border: 0;
padding-left: 0;
padding-right: 0;
box-shadow: none !important;
border-bottom: 1px solid var(--el-border-color);
vertical-align: middle;
}
.header-search.show .header-search-input {
width: 200px;
margin-left: 10px;
}
</style>
@@ -1,583 +0,0 @@
<template>
<el-container class="shrimp-table" direction="vertical">
<!-- 顶部区域:左侧操作按钮 + 右侧搜索 -->
<div class="aside" v-if="hasActions || searchableFields.length > 0 || filterableFields.length > 0">
<div class="aside-left">
<el-button v-if="can('add')" type="primary" :icon="Plus" circle size="large" @click="handleAdd" />
<el-button v-if="can('clear')" :icon="DeleteIcon" circle size="large" @click="handleClear" />
<el-button v-if="can('export')" :icon="Download" circle size="large" @click="handleExport" />
<el-button v-if="can('import')" :icon="Upload" circle size="large" @click="handleImportClick" />
<input ref="fileInput" type="file" accept=".xlsx,.xls,.json" style="display: none" @change="handleFileChange" />
<slot name="actions"></slot>
</div>
<div class="aside-right">
<ShrimpSearchInput v-if="searchableFields.length > 0" v-model:keyword="keyword"
v-model:visible="searchVisible" :active="!!searchKeyword" @search="onSearch" />
<ShrimpFilterTrigger v-if="filterableFields.length > 0" :active="hasFilter"
@click="filterDrawerVisible = true" />
</div>
</div>
<!-- 表格主体(可滚动) -->
<el-main>
<el-table :data="data" :loading="loading" stripe border style="width: 100%" v-bind="$attrs"
@sort-change="onSortChange">
<template v-for="field in visibleFields" :key="field.key">
<el-table-column :prop="field.key" :label="vueAdmin.t(field.label)"
:sortable="field.sortable ? 'custom' : false">
<template #default="{ row }">
<slot :name="`column-${field.key}`" :row="row" :field="field">
<component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)" :field="field"
:model-value="row[field.key]" mode="display"
v-bind="field.type === 'relation' || field.type === 'textarea' ? { displayLabel: row[field.key + '_label'] } : {}" />
</slot>
</template>
</el-table-column>
</template>
<el-table-column :label="$t('base.operation')" :width="rowActionsWidth" v-if="hasRowActions">
<template #default="{ row }">
<el-button v-if="hasActiveField && can('edit')" link :type="row.active ? 'warning' : 'success'" :icon="row.active ? VideoPause : VideoPlay" size="large" @click="handleToggleActive(row)" />
<el-button v-if="can('edit')" link type="primary" :icon="Edit" size="large" @click="handleEdit(row)" />
<el-button v-if="can('delete')" link type="danger" :icon="DeleteIcon" size="large" @click="handleDelete(row)" />
<el-tooltip v-for="sub in subModules" :key="sub.moduleKey" :content="vueAdmin.t(sub.label)">
<el-button link type="primary" :icon="resolveIcon(sub.icon)" size="large"
@click="handleSubJump(sub, row)" />
</el-tooltip>
<slot name="row-actions" :row="row"></slot>
</template>
</el-table-column>
</el-table>
</el-main>
<!-- 底部区域:分页 + 扩展(固定在容器底部) -->
<el-footer height="auto" v-if="total > 0 || $slots.footer">
<el-pagination v-if="total > 0" v-model:current-page="currentPage" v-model:page-size="currentPageSize"
:total="total" @current-change="refresh" />
<slot name="footer"></slot>
</el-footer>
<!-- 新增/编辑抽屉 -->
<ShrimpEditDrawer ref="drawerRef" v-model:visible="dialogVisible" :title="isEdit ? $t('base.edit') : $t('base.add')"
:moduleKey="props.moduleKey" :fields="formFields" v-model:formData="formData" :isEdit="isEdit"
:canSave="canSave" @save="handleSave">
<template v-for="name in formSlotNames" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps" />
</template>
</ShrimpEditDrawer>
<!-- 筛选抽屉 -->
<ShrimpFilterDrawer v-model:visible="filterDrawerVisible" :title="$t('base.filter')"
:moduleKey="props.moduleKey" :filterableFields="filterableFields" :filterData="filterData"
@confirm="handleFilterConfirm" @reset="handleFilterReset">
<template v-for="name in filterSlotNames" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps" />
</template>
</ShrimpFilterDrawer>
</el-container>
</template>
<script lang="ts" setup>
import { ref, computed, useSlots, onMounted, type Component } from 'vue'
import { ITableCrudProto, IFieldConfig, IModuleInfo, IPageQuery } from '@shrimp/IShrimpType'
import { vueAdmin } from '@/VueAdmin'
import { Plus, Delete as DeleteIcon, Download, Upload, Edit, VideoPlay, VideoPause, Operation } from '@element-plus/icons-vue'
import * as ElIcons from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useTableUrlSync } from '@/composables/useTableUrlSync'
import ShrimpEditDrawer from './ShrimpEditDrawer.vue'
import ShrimpFilterDrawer from './ShrimpFilterDrawer.vue'
import ShrimpSearchInput from './ShrimpSearchInput.vue'
import ShrimpFilterTrigger from './ShrimpFilterTrigger.vue'
import * as XLSX from 'xlsx'
const { t } = useI18n()
interface Props {
moduleKey: string
pageSize?: number
/** 显式锁定的筛选条件(覆盖自动 ownerKey 推断);用户无法修改,会合并进 filter 并在筛选抽屉中隐藏 */
baseFilter?: Record<string, any>
}
const props = withDefaults(defineProps<Props>(), {
pageSize: 20,
baseFilter: () => ({})
})
const emit = defineEmits<{
export: []
import: []
}>()
const api = vueAdmin.getModule<ITableCrudProto<any>>(props.moduleKey)
const slots = useSlots()
// ---- URL 同步 ----
const {
currentPage,
currentPageSize,
keyword,
searchKeyword,
currentSortBy,
currentSortOrder,
filterData,
restore,
} = useTableUrlSync({ pageSize: props.pageSize })
// ---- 搜索 ----
const searchVisible = ref(false)
function onSearch() {
searchKeyword.value = keyword.value
currentPage.value = 1
refresh()
}
// ---- 筛选 ----
const filterDrawerVisible = ref(false)
const hasFilter = computed(() => {
return Object.values(filterData.value).some(val => {
if (val === '' || val === null || val === undefined) return false
if (Array.isArray(val) && val.length === 0) return false
return true
})
})
async function handleFilterConfirm(newFilterData: Record<string, any>) {
filterData.value = newFilterData
currentPage.value = 1
await refresh()
}
async function handleFilterReset() {
filterData.value = {}
currentPage.value = 1
await refresh()
}
// ---- 远程排序 ----
function onSortChange({ prop, order }: { prop: string; order: string | null }) {
if (order) {
currentSortBy.value = prop
currentSortOrder.value = order === 'ascending' ? 'asc' : 'desc'
} else {
currentSortBy.value = ''
}
refresh()
}
// ---- 字段配置 ----
const moduleInfo = computed<IModuleInfo | undefined>(() =>
vueAdmin.moduleInfo.find(m => m.moduleKey === props.moduleKey)
)
const fields = computed<IFieldConfig[]>(() => moduleInfo.value?.fields || [])
// ---- 子表 ownerId 推断 ----
// 子模块约定: moduleInfo.parent 存在 → 从路由 ?id= 取父记录 id,作为 ownerId 顶层参数发给后端
// 后端按自己的 ownerKey 配置翻译为 filter 注入(前端不构造 {ownerKey: id} 这种业务字段名 payload)
const route = useRoute()
const router = useRouter()
const ownerId = computed<string | undefined>(() => {
if (!moduleInfo.value?.parent) return undefined
return (route.query.id as string | undefined) || undefined
})
const visibleFields = computed(() => fields.value.filter(f => !f.hidden && !f.tableHide))
const formFields = computed(() => {
const list = fields.value.filter(f => !f.hidden)
if (isEdit.value) {
return list.filter(f => f.editable !== 'readonly')
}
return list.filter(f => f.editable !== 'readonly')
})
const searchableFields = computed(() => fields.value.filter(f => f.searchable))
const filterableFields = computed(() => {
const lockedKeys = new Set(Object.keys(props.baseFilter || {}))
return fields.value.filter(f => f.filterable && !lockedKeys.has(f.key))
})
const formSlotNames = computed(() => Object.keys(slots).filter(name => name.startsWith('form-')))
const filterSlotNames = computed(() => Object.keys(slots).filter(name => name.startsWith('filter-')))
// ---- 表格状态 ----
const data = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
// ---- 对话框状态 ----
const dialogVisible = ref(false)
const isEdit = ref(false)
const currentId = ref('')
const formData = ref<Record<string, any>>({})
const originalData = ref<Record<string, any>>({})
const drawerRef = ref<any>(null)
// ---- 权限 ----
const can = vueAdmin.can(props.moduleKey)
const hasActions = computed(() => can('add') || can('clear') || can('export') || can('import') || !!slots.actions)
// ---- 是否有 active 字段 ----
const hasActiveField = computed(() => {
return fields.value.some(f => f.key === 'active')
})
// ---- 子模块跳转 ----
const subModules = computed<IModuleInfo[]>(() =>
vueAdmin.moduleInfo.filter(m => m.parent === props.moduleKey)
)
function resolveIcon(name?: string): Component {
if (name && (ElIcons as any)[name]) return (ElIcons as any)[name]
return Operation
}
function handleSubJump(sub: IModuleInfo, row: any) {
// 跳子模块: 把当前 ?id 追加进 pids 链, row._id 作为新的 ?id
// 这样多层嵌套下面包屑/返回回到上层时能恢复对应父记录 id
const currentPids = ((route.query.pids as string) || '').split(',').filter(Boolean)
const currentId = route.query.id as string | undefined
const newPids = currentId ? [...currentPids, currentId] : currentPids
const query: Record<string, string> = { id: row._id }
if (newPids.length > 0) query.pids = newPids.join(',')
router.push({ path: `${route.path}/${sub.moduleKey}`, query })
}
const hasRowActions = computed(() => can('edit') || can('delete') || hasActiveField.value || subModules.value.length > 0 || !!slots['row-actions'])
const rowActionsWidth = computed(() => {
let n = 0
if (hasActiveField.value && can('edit')) n++
if (can('edit')) n++
if (can('delete')) n++
n += subModules.value.length
return Math.max(120, 40 + n * 40)
})
// ---- 编辑时是否有改动 ----
const canSave = computed(() => {
if (!isEdit.value) return true
return formFields.value.some(field => {
if (field.editable === 'readonly') return false
if (field.editable === 'addOnly') return false
return formData.value[field.key] !== originalData.value[field.key]
})
})
// ---- CRUD ----
async function handleAdd() {
isEdit.value = false
currentId.value = ''
formData.value = {}
const baseKeys = new Set(Object.keys(props.baseFilter || {}))
formFields.value.forEach(field => {
if (baseKeys.has(field.key)) {
formData.value[field.key] = props.baseFilter![field.key]
} else if (field.defaultValue !== undefined) {
formData.value[field.key] = field.defaultValue
} else if (field.type === 'boolean') {
formData.value[field.key] = false
} else if (field.type === 'number') {
formData.value[field.key] = 0
} else if (field.multiple) {
formData.value[field.key] = []
} else {
formData.value[field.key] = ''
}
})
dialogVisible.value = true
}
async function handleEdit(row: any) {
isEdit.value = true
currentId.value = row._id
originalData.value = { ...row }
formData.value = { ...row }
dialogVisible.value = true
}
async function handleSave() {
const valid = await drawerRef.value?.validate()
if (!valid) return
try {
const submitData: Record<string, any> = {}
if (isEdit.value) {
// 编辑:只提交修改过的字段
formFields.value.forEach(field => {
if (field.editable === 'readonly') return
if (field.editable === 'addOnly') return
const newValue = formData.value[field.key]
const oldValue = originalData.value[field.key]
if (newValue === oldValue) return
submitData[field.key] = newValue
})
} else {
// 新增:提交所有非 readonly 字段
formFields.value.forEach(field => {
if (field.editable === 'readonly') return
submitData[field.key] = formData.value[field.key]
})
}
if (isEdit.value) {
if (Object.keys(submitData).length === 0) {
dialogVisible.value = false
return
}
await api.edit(currentId.value, submitData)
} else {
await api.add(submitData, ownerId.value)
}
dialogVisible.value = false
ElMessage.success(isEdit.value ? t('table.editSuccess') : t('table.addSuccess'))
await refresh()
} catch (e: any) {
console.warn(`[ShrimpTable]`, e.message)
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(t('table.confirmDelete'), t('base.confirm'), {
confirmButtonText: t('base.confirm'),
cancelButtonText: t('base.cancel'),
type: 'warning',
})
await api.delete(row._id)
await refresh()
} catch {
// 用户取消
}
}
async function handleToggleActive(row: any) {
try {
await api.edit(row._id, { active: !row.active })
await refresh()
ElMessage.success(t('base.success'))
} catch (e: any) {
console.warn(`[ShrimpTable]`, e.message)
}
}
async function handleClear() {
try {
await ElMessageBox.confirm(t('table.confirmClear'), t('base.delete'), {
confirmButtonText: t('base.confirm'),
cancelButtonText: t('base.cancel'),
type: 'warning',
})
const query: Record<string, any> = {}
if (searchKeyword.value) query.keyword = searchKeyword.value
const filter = buildFilter()
if (filter) query.filter = filter
if (ownerId.value) query.ownerId = ownerId.value
await api.clear(query)
await refresh()
} catch {
// 用户取消
}
}
// ---- 导出 ----
function buildFilter(): Record<string, any> | undefined {
const filter: Record<string, any> = {}
for (const [key, val] of Object.entries(filterData.value)) {
if (val === '' || val === null || val === undefined) continue
if (Array.isArray(val) && val.length === 0) continue
if (typeof val === 'object' && !Array.isArray(val)) {
const range: Record<string, any> = {}
if (val.min !== undefined && val.min !== null) range.min = val.min
if (val.max !== undefined && val.max !== null) range.max = val.max
if (Object.keys(range).length > 0) filter[key] = range
} else {
filter[key] = val
}
}
// baseFilter 优先级最高,覆盖用户选择
Object.assign(filter, props.baseFilter || {})
return Object.keys(filter).length > 0 ? filter : undefined
}
async function handleExport() {
emit('export')
try {
const query: Record<string, any> = {}
if (searchKeyword.value) query.keyword = searchKeyword.value
const filter = buildFilter()
if (filter) query.filter = filter
if (currentSortBy.value) {
query.sortBy = currentSortBy.value
query.sortOrder = currentSortOrder.value
}
if (ownerId.value) query.ownerId = ownerId.value
const list = await api.export(query)
const ws = XLSX.utils.json_to_sheet(list)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, props.moduleKey)
XLSX.writeFile(wb, `${props.moduleKey}_export_${Date.now()}.xlsx`)
ElMessage.success(t('base.success'))
} catch (e: any) {
console.warn(`[ShrimpTable] export failed`, e.message)
}
}
// ---- 导入 ----
const fileInput = ref<HTMLInputElement | null>(null)
function handleImportClick() {
emit('import')
fileInput.value?.click()
}
async function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
try {
let data: any[]
if (file.name.endsWith('.json')) {
const text = await file.text()
data = JSON.parse(text)
} else {
const buf = await file.arrayBuffer()
const wb = XLSX.read(buf, { type: 'array' })
const ws = wb.Sheets[wb.SheetNames[0]]
data = XLSX.utils.sheet_to_json(ws)
}
await api.import(data, ownerId.value)
ElMessage.success(t('base.success'))
await refresh()
} catch (e: any) {
// if (!e.handled) {
// ElMessage.error(t('base.fail'))
// }
// console.warn(`[ShrimpTable] import failed`, e.message)
} finally {
target.value = ''
}
}
// ---- 数据加载 ----
async function refresh(query?: Record<string, any>) {
loading.value = true
try {
const pageQuery: IPageQuery & Record<string, any> = {
page: currentPage.value,
pageSize: currentPageSize.value,
}
if (searchKeyword.value) {
pageQuery.keyword = searchKeyword.value
}
if (currentSortBy.value) {
pageQuery.sortBy = currentSortBy.value
pageQuery.sortOrder = currentSortOrder.value
}
// 筛选条件:仅传有值的字段,放到 filter 对象中
const filter = buildFilter()
if (filter) {
pageQuery.filter = filter
}
// 子表场景: 顶层 ownerId 由后端按 ownerKey 配置翻译
if (ownerId.value) {
pageQuery.ownerId = ownerId.value
}
Object.assign(pageQuery, query)
const res = await api.list(pageQuery)
data.value = res.list || []
total.value = res.total
currentPage.value = res.page
currentPageSize.value = res.pageSize
} finally {
loading.value = false
}
}
// ---- 暴露 ----
defineExpose({
data,
loading,
refresh,
can,
api,
keyword,
})
onMounted(() => {
restore()
if (keyword.value) {
searchVisible.value = true
}
refresh()
})
</script>
<style scoped>
.shrimp-table {
width: 100%;
height: 100%;
}
/* 顶部区域:左右分布 */
.aside {
padding: 4px 10px;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.aside-left {
display: flex;
gap: 10px;
align-items: center;
}
.aside-right {
display: flex;
gap: 10px;
align-items: center;
}
/* 表格主体:内部纵向滚动(分页固定在 footer,不参与滚动) */
.shrimp-table :deep(.el-main) {
padding: 0;
overflow: auto;
flex: 1;
min-height: 0;
}
/* 底部分页:固定在容器底,不随内容滚动 */
.shrimp-table :deep(.el-footer) {
padding: 12px 0;
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
/* 强制放大操作栏图标 */
.shrimp-table :deep(.el-button .el-icon) {
font-size: 20px;
width: 20px;
height: 20px;
}
</style>
@@ -1,36 +0,0 @@
<template>
<el-tag v-if="mode === 'display'" :type="modelValue ? 'success' : 'danger'" size="small">
{{ modelValue ? $t('table.yes') : $t('table.no') }}
</el-tag>
<el-select
v-else-if="mode === 'filter'"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
clearable
:placeholder="$t('table.unlimited')"
style="width: 100%"
>
<el-option :label="$t('table.yes')" :value="true" />
<el-option :label="$t('table.no')" :value="false" />
</el-select>
<el-switch v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" />
</template>
<script lang="ts" setup>
import { watch } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
watch(() => props.field.multiple, (v) => {
if (v) console.warn('[FieldBoolean] multiple is not supported, fallback to single value')
}, { immediate: true })
</script>
@@ -1,137 +0,0 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)">
<span v-if="!modelValue.length">-</span>
<span v-else class="dt-group">
<template v-for="(p, i) in parsedArray" :key="i">
<el-tooltip v-if="p" :content="p.full" placement="top" :show-after="800">
<span class="dt">
<el-icon class="ico"><Calendar /></el-icon>
<span class="d">{{ p.day }}</span>
<span class="t">{{ p.time }}</span>
</span>
</el-tooltip>
<span v-else>-</span>
</template>
</span>
</template>
<el-tooltip v-else-if="parsed" :content="parsed.full" placement="top" :show-after="800">
<span class="dt">
<el-icon class="ico"><Calendar /></el-icon>
<span class="d">{{ parsed.day }}</span>
<span class="t">{{ parsed.time }}</span>
</span>
</el-tooltip>
<span v-else>-</span>
</template>
<el-date-picker
v-else-if="mode === 'filter'"
:model-value="filterModelValue"
@update:model-value="onFilterChange($event)"
type="datetimerange"
:start-placeholder="$t('table.startTime')"
:end-placeholder="$t('table.endTime')"
style="width: 100%"
/>
<el-date-picker
v-else-if="field.multiple"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:disabled="disabled"
type="dates"
style="width: 100%"
/>
<el-date-picker v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled"
type="datetime" style="width: 100%" />
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { Calendar } from '@element-plus/icons-vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
function pad(n: number) { return n < 10 ? '0' + n : String(n) }
function parse(v: any): { day: string; time: string; full: string } | null {
if (v == null || v === '') return null
const d = new Date(v)
if (isNaN(d.getTime())) return null
const y = d.getFullYear()
const mo = pad(d.getMonth() + 1)
const da = pad(d.getDate())
const h = pad(d.getHours())
const mi = pad(d.getMinutes())
const se = pad(d.getSeconds())
const day = da
const time = `${h}:${mi}`
const full = `${y}-${mo}-${da} ${h}:${mi}:${se}`
return { day, time, full }
}
const parsed = computed(() => parse(props.modelValue))
const parsedArray = computed(() => Array.isArray(props.modelValue) ? props.modelValue.map(parse) : [])
const filterModelValue = computed(() => {
if (!props.modelValue || typeof props.modelValue !== 'object') return null
const { min, max } = props.modelValue
if (min != null && max != null) {
return [new Date(min), new Date(max)]
}
return null
})
function onFilterChange(dates: Date[] | null) {
if (dates && dates.length === 2) {
emit('update:modelValue', { min: dates[0].getTime(), max: dates[1].getTime() })
} else {
emit('update:modelValue', null)
}
}
</script>
<style scoped>
.dt-group {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
}
.dt {
display: inline-flex;
align-items: flex-end;
width: 100px;
height: 24px;
box-sizing: border-box;
padding: 0 10px 3px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
font-variant-numeric: tabular-nums;
white-space: nowrap;
font-size: 13px;
line-height: 1;
color: var(--el-text-color-regular);
vertical-align: middle;
}
.ico {
color: var(--el-text-color-placeholder);
font-size: 16px;
margin-right: 4px;
}
.d {
color: var(--el-text-color-regular);
}
.t {
margin-left: auto;
color: var(--el-text-color-regular);
}
</style>
@@ -1,48 +0,0 @@
<template>
<template v-if="mode === 'display'">
<el-tooltip v-if="modelValue != null" :content="displayValue" placement="top">
<span class="json-display">{{ displayValue }}</span>
</el-tooltip>
<span v-else>-</span>
</template>
<el-input
v-else
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
type="textarea"
:rows="4"
:disabled="disabled"
placeholder="请输入 JSON"
/>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const displayValue = computed(() => {
if (props.modelValue == null) return ''
if (typeof props.modelValue === 'string') return props.modelValue
return JSON.stringify(props.modelValue, null, 2)
})
</script>
<style scoped>
.json-display {
display: inline-block;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -1,82 +0,0 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)">{{ modelValue.join(', ') || 0 }}</template>
<template v-else>{{ modelValue ?? 0 }}</template>
</template>
<div v-else-if="mode === 'filter'" style="display: flex; gap: 8px; align-items: center; width: 100%">
<el-input-number
:model-value="modelValue?.min"
@update:model-value="emitRange('min', $event)"
:placeholder="$t('table.min')"
controls-position="right"
style="flex: 1"
/>
<span style="color: #999">~</span>
<el-input-number
:model-value="modelValue?.max"
@update:model-value="emitRange('max', $event)"
:placeholder="$t('table.max')"
controls-position="right"
style="flex: 1"
/>
</div>
<div v-else-if="field.multiple" class="tags-input">
<el-tag v-for="(num, index) in (modelValue || [])" :key="num + '-' + index" closable @close="removeNum(index)">
{{ num }}
</el-tag>
<el-input-number
v-model="inputValue"
@keyup.enter="addNum"
@blur="addNum"
:placeholder="$t('table.pressEnterForNumber')"
:controls="false"
style="width: 120px; flex: 1; min-width: 120px"
/>
</div>
<el-input-number v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" style="width: 100%" />
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const inputValue = ref<number | undefined>(undefined)
function emitRange(key: 'min' | 'max', val: number | undefined) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [key]: val })
}
function addNum() {
if (inputValue.value == null) return
const current = Array.isArray(props.modelValue) ? props.modelValue : []
emit('update:modelValue', [...current, inputValue.value])
inputValue.value = undefined
}
function removeNum(index: number) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current.splice(index, 1)
emit('update:modelValue', current)
}
</script>
<style scoped>
.tags-input {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
width: 100%;
}
</style>
@@ -1,20 +0,0 @@
<template>
<template v-if="mode === 'display'">******</template>
<template v-else-if="field.multiple">
<el-alert type="warning" :title="$t('table.passwordMultipleNotSupported')" :closable="false" />
</template>
<el-input v-else type="password" show-password :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" />
</template>
<script lang="ts" setup>
defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
</script>
@@ -1,94 +0,0 @@
<template>
<template v-if="mode === 'display'">
<template v-if="field.multiple && Array.isArray(displayLabel)">{{ displayLabel.filter(Boolean).join(', ') || '-' }}</template>
<template v-else>{{ displayLabel || modelValue || '-' }}</template>
</template>
<el-select
v-else-if="mode === 'filter'"
:model-value="stringValue"
@update:model-value="$emit('update:modelValue', $event)"
multiple clearable filterable remote :remote-method="onSearch" :loading="loading"
style="width: 100%"
>
<el-option v-for="opt in options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-select v-else :model-value="stringValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" :multiple="field.multiple" filterable remote :remote-method="onSearch" :loading="loading" style="width: 100%">
<el-option v-for="opt in options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</template>
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue'
import { vueAdmin } from '@/VueAdmin'
import { ITableCrudProto } from '@shrimp/IShrimpType'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
displayLabel?: string | string[]
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const options = ref<{ label: string; value: string }[]>([])
const loading = ref(false)
/** 统一转为字符串值,解决 ObjectId 与 el-select 的匹配问题 */
const stringValue = computed(() => {
// filter 模式强制 multiple,始终返回数组
if (props.mode === 'filter' || props.field.multiple) {
return Array.isArray(props.modelValue) ? props.modelValue.map(String) : []
}
return props.modelValue != null ? String(props.modelValue) : undefined
})
async function onSearch(query: string) {
if (!props.field.relation) return
loading.value = true
try {
const api = vueAdmin.getModule<ITableCrudProto<any>>(props.field.relation.moduleKey)
const listQuery: any = { keyword: query, pageSize: 20 }
if (props.field.filter) {
listQuery.filter = props.field.filter
}
const res = await api.list(listQuery)
const list = res.list || res as any
options.value = list.map((item: any) => ({
label: item[props.field.relation.labelKey],
value: String(item._id),
}))
} catch (e) {
console.warn('[FieldRelation] search failed', e)
} finally {
loading.value = false
}
}
onMounted(async () => {
if ((props.mode === 'edit' || props.mode === 'filter') && props.field.relation) {
await onSearch('')
// 确保当前值在选项中
const currentValues = props.field.multiple
? (Array.isArray(props.modelValue) ? props.modelValue.map(String) : [])
: (props.modelValue ? [String(props.modelValue)] : [])
for (const val of currentValues) {
if (val && !options.value.some(o => o.value === val)) {
try {
const api = vueAdmin.getModule<ITableCrudProto<any>>(props.field.relation.moduleKey)
const current = await api.get(val)
if (current) {
options.value.unshift({
label: (current as any)[props.field.relation.labelKey],
value: String(val),
})
}
} catch { }
}
}
}
})
</script>
@@ -1,65 +0,0 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)"
>{{ displayLabels.join(', ') || '-' }}</template
>
<template v-else>{{ displayText }}</template>
</template>
<el-select
v-else-if="mode === 'filter'"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
multiple
clearable
style="width: 100%"
>
<el-option v-for="opt in resolvedOptions" :key="opt.value" :label="vueAdmin.t(opt.label)" :value="opt.value" />
</el-select>
<el-select
v-else
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:disabled="disabled"
:multiple="field.multiple"
style="width: 100%"
>
<el-option v-for="opt in resolvedOptions" :key="opt.value" :label="vueAdmin.t(opt.label)" :value="opt.value" />
</el-select>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { vueAdmin } from '@/VueAdmin'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const resolvedOptions = computed(() => {
const opts = props.field.options
if (typeof opts === 'string') {
return vueAdmin.globalOptions[opts] || []
}
return opts || []
})
const displayText = computed(() => {
const label = resolvedOptions.value.find((o: any) => o.value === props.modelValue)?.label
return label ? vueAdmin.t(label) : (props.modelValue ?? '-')
})
const displayLabels = computed(() => {
if (!Array.isArray(props.modelValue)) return []
return props.modelValue.map((val: any) => {
const opt = resolvedOptions.value.find((o: any) => o.value === val)
return opt ? vueAdmin.t(opt.label) : val
})
})
</script>
@@ -1,59 +0,0 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)">{{ modelValue.join(', ') || '-' }}</template>
<template v-else>{{ modelValue ?? '-' }}</template>
</template>
<div v-else-if="field.multiple" class="tags-input">
<el-tag v-for="(tag, index) in (modelValue || [])" :key="tag + index" closable @close="removeTag(index)">
{{ tag }}
</el-tag>
<el-input v-model="inputValue" @keyup.enter="addTag" @blur="addTag" :placeholder="$t('base.pressEnter')" />
</div>
<el-input v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" clearable />
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const inputValue = ref('')
function addTag() {
const val = inputValue.value.trim()
if (!val) return
const current = Array.isArray(props.modelValue) ? props.modelValue : []
emit('update:modelValue', [...current, val])
inputValue.value = ''
}
function removeTag(index: number) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current.splice(index, 1)
emit('update:modelValue', current)
}
</script>
<style scoped>
.tags-input {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
width: 100%;
}
.tags-input .el-input {
width: 120px;
flex: 1;
min-width: 120px;
}
</style>
@@ -1,120 +0,0 @@
<template>
<template v-if="mode === 'display'">
<span v-if="!modelValue || typeof modelValue !== 'object'">-</span>
<el-tooltip v-else :content="JSON.stringify(modelValue, null, 2)" placement="top">
<span class="tab-display">{{ displayText }}</span>
</el-tooltip>
</template>
<div v-else class="field-tab">
<el-form-item :label="vueAdmin.t(field.label) + ' - 类型'" :required="field.required">
<el-select :model-value="innerValue.type" @update:model-value="onTypeChange" :disabled="disabled">
<el-option v-for="tab in field.tabs" :key="tab.key" :label="vueAdmin.t(tab.label)" :value="tab.key" />
</el-select>
</el-form-item>
<div v-if="selectedTab?.fields?.length" class="tab-fields">
<template v-for="childField in selectedTab.fields" :key="childField.key">
<el-form-item :label="vueAdmin.t(childField.label)" :required="childField.required">
<component :is="vueAdmin.getFieldComponent(moduleKey, childField.type, childField.key)"
:field="childField"
:model-value="innerValue[childField.key]"
@update:model-value="onChildChange(childField.key, $event)"
mode="edit"
:disabled="disabled" />
</el-form-item>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, inject, onMounted } from 'vue'
import { vueAdmin } from '@/VueAdmin'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const moduleKey = inject('shrimpModuleKey', '')
const displayText = computed(() => {
if (!props.modelValue || typeof props.modelValue !== 'object') return '-'
const type = props.modelValue.type
const tab = props.field.tabs?.find((t: any) => t.key === type)
return tab ? vueAdmin.t(tab.label) : String(type)
})
const selectedTab = computed(() => {
const type = props.modelValue?.type
return props.field.tabs?.find((t: any) => t.key === type)
})
const innerValue = computed(() => {
const val = props.modelValue
if (val != null && typeof val === 'object' && Object.keys(val).length > 0) {
return val
}
return initValue()
})
function initValue() {
const firstTab = props.field.tabs?.[0]
if (!firstTab) return {}
const obj: any = { type: firstTab.key }
firstTab.fields?.forEach((f: any) => {
if (f.defaultValue !== undefined) {
obj[f.key] = typeof f.defaultValue === 'function' ? f.defaultValue() : f.defaultValue
}
})
return obj
}
function onTypeChange(type: any) {
const tab = props.field.tabs?.find((t: any) => t.key === type)
const obj: any = { type }
tab?.fields?.forEach((f: any) => {
if (f.defaultValue !== undefined) {
obj[f.key] = typeof f.defaultValue === 'function' ? f.defaultValue() : f.defaultValue
}
})
emit('update:modelValue', obj)
}
function onChildChange(key: string, value: any) {
emit('update:modelValue', { ...innerValue.value, [key]: value })
}
onMounted(() => {
const val = props.modelValue
if (val == null || val === '' || (typeof val === 'object' && Object.keys(val).length === 0)) {
emit('update:modelValue', initValue())
}
})
</script>
<style scoped>
.tab-display {
display: inline-block;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.field-tab {
display: flex;
flex-direction: column;
gap: 8px;
}
.tab-fields {
padding-left: 16px;
border-left: 2px solid var(--el-border-color-lighter);
}
</style>
@@ -1,78 +0,0 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)">{{ modelValue.join('\n') || '-' }}</template>
<template v-else-if="!isLong">{{ text }}</template>
<div v-else class="textarea-cell">
<span class="text" :class="{ collapsed: !expanded }">{{ text }}</span>
<el-link type="primary" underline="never" class="toggle" @click.stop="expanded = !expanded">
{{ expanded ? $t('base.collapse') : $t('base.expand') }}
</el-link>
</div>
</template>
<template v-else-if="field.multiple">
<el-alert type="warning" :title="$t('table.textareaMultipleNotSupported')" :closable="false" />
</template>
<el-input
v-else
:type="mode === 'filter' ? 'text' : 'textarea'"
:rows="mode === 'filter' ? undefined : 3"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:disabled="disabled"
clearable
/>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
displayLabel?: string
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const text = computed(() => {
const v = props.displayLabel ?? props.modelValue
return v == null || v === '' ? '-' : String(v)
})
const isLong = computed(() => {
const s = text.value
return s.length > 40 || s.includes('\n')
})
const expanded = ref(false)
</script>
<style scoped>
.textarea-cell {
display: flex;
align-items: flex-start;
gap: 6px;
}
.text {
flex: 1;
white-space: pre-wrap;
word-break: break-all;
min-width: 0;
}
.text.collapsed {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.toggle {
flex-shrink: 0;
font-size: 12px;
line-height: 1.5;
}
</style>
@@ -1,98 +0,0 @@
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export interface UseTableUrlSyncOptions {
pageSize: number
}
export function useTableUrlSync(options: UseTableUrlSyncOptions) {
const route = useRoute()
const router = useRouter()
const currentPage = ref(1)
const currentPageSize = ref(options.pageSize)
const keyword = ref('')
const searchKeyword = ref('')
const currentSortBy = ref('')
const currentSortOrder = ref<'asc' | 'desc'>('asc')
const filterData = ref<Record<string, any>>({})
function restoreFromQuery() {
const q = route.query
if (q.page) currentPage.value = Number(q.page) || 1
if (q.pageSize) currentPageSize.value = Number(q.pageSize) || options.pageSize
if (q.keyword) {
keyword.value = String(q.keyword)
searchKeyword.value = String(q.keyword)
}
if (q.sortBy) currentSortBy.value = String(q.sortBy)
if (q.sortOrder && (q.sortOrder === 'asc' || q.sortOrder === 'desc')) {
currentSortOrder.value = q.sortOrder
}
if (q.filter) {
try {
const parsed = JSON.parse(String(q.filter))
if (typeof parsed === 'object' && parsed !== null) {
filterData.value = parsed
}
} catch { /* ignore invalid JSON */ }
}
}
// 表格自身管理的 query key,sync 时只覆盖这几个,其它保留(如 ?id= 子表父记录、自定义参数)
const TABLE_QUERY_KEYS = ['page', 'pageSize', 'keyword', 'sortBy', 'sortOrder', 'filter']
function syncToQuery() {
const preserved: Record<string, any> = {}
for (const [k, v] of Object.entries(route.query)) {
if (!TABLE_QUERY_KEYS.includes(k)) preserved[k] = v
}
const query: Record<string, any> = { ...preserved }
if (currentPage.value > 1) query.page = String(currentPage.value)
if (currentPageSize.value !== options.pageSize) query.pageSize = String(currentPageSize.value)
if (searchKeyword.value) query.keyword = searchKeyword.value
if (currentSortBy.value) {
query.sortBy = currentSortBy.value
query.sortOrder = currentSortOrder.value
}
const cleanFilter: Record<string, any> = {}
for (const [key, val] of Object.entries(filterData.value)) {
if (val === '' || val === null || val === undefined) continue
if (Array.isArray(val) && val.length === 0) continue
cleanFilter[key] = val
}
if (Object.keys(cleanFilter).length > 0) {
query.filter = JSON.stringify(cleanFilter)
}
router.replace({ query })
}
let isRestoring = false
watch(
[currentPage, currentPageSize, searchKeyword, currentSortBy, currentSortOrder, filterData],
() => {
if (!isRestoring) {
syncToQuery()
}
},
{ deep: true }
)
function restore() {
isRestoring = true
restoreFromQuery()
isRestoring = false
}
return {
currentPage,
currentPageSize,
keyword,
searchKeyword,
currentSortBy,
currentSortOrder,
filterData,
restore,
}
}
@@ -1,42 +0,0 @@
// 配置可能有分以下几种 每种可能数据来源方式有所不同
// 程序配置
// 项目配置
// 客户配置
// 客户定义
// 临时选择
const DEFAULT_CONFIG = {
//追加其他头
HEADERS: {},
//版本号
APP_VER: "0.0.1",
//接口地址
API_URL: "/admin-api",
//请求超时
TIMEOUT: 10000,
//请求是否开启缓存
REQUEST_CACHE: false,
//是否加密localStorage, 为空不加密,可填写AES(模式ECB,移位Pkcs7)加密
LS_ENCRYPTION: '',
//localStorageAES加密秘钥,位数建议填写8的倍数
LS_ENCRYPTION_key: '2XNN4K8LC0ELVWN4',
}
const CONFIG = DEFAULT_CONFIG
export default CONFIG
@@ -1,131 +0,0 @@
{
"base": {
"name": "Name",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"search": "Search",
"filter": "Filter",
"reset": "Reset",
"confirm": "Confirm",
"cancel": "Cancel",
"close": "Close",
"save": "Save",
"submit": "Submit",
"operation": "Operation",
"exportExcel": "Export Excel",
"importExcel": "Import Excel",
"success": "Success",
"fail": "Failed",
"noNavMenu": "No submenu",
"clearCache": "Clear Cache",
"logout": "Logout",
"netError": "Network error",
"disabled": "Disabled",
"enabled": "Enabled",
"pressEnter": "Press Enter to add",
"expand": "Expand",
"collapse": "Collapse"
},
"user": {
"username": "Username",
"password": "Password",
"nickname": "Nickname",
"roleId": "Role",
"active": "Active",
"createdAt": "Created At",
"updatedAt": "Updated At",
"menuManage": "User",
"operationLog": "Operation Log"
},
"operationLog": {
"ip": "IP",
"method": "Action",
"args": "Args",
"success": "Success",
"errorMsg": "Error",
"createdAt": "Time"
},
"role": {
"name": "Role Name",
"description": "Description",
"isSuperAdmin": "Super Admin",
"powers": "Permissions",
"createdAt": "Created At",
"updatedAt": "Updated At",
"menuManage": "Role",
"menuList": "Role List",
"empty": "No roles",
"addRole": "Add Role",
"confirmDelete": "Are you sure you want to delete this role?",
"selectOrCreate": "Please select or create a role",
"selectAll": "Select All",
"newRole": "New Role"
},
"table": {
"addSuccess": "Added successfully",
"editSuccess": "Updated successfully",
"confirmDelete": "Are you sure you want to delete this record?",
"confirmClear": "Are you sure you want to clear all data? This cannot be undone!",
"yes": "Yes",
"no": "No",
"unlimited": "Any",
"startTime": "Start Time",
"endTime": "End Time",
"min": "Min",
"max": "Max",
"justNow": "Just now",
"minutesAgo": "{n} min ago",
"hoursAgo": "{n} hr ago",
"daysAgo": "{n} days ago",
"textareaMultipleNotSupported": "Textarea does not support multiple selection",
"passwordMultipleNotSupported": "Password does not support multiple selection",
"pressEnterForNumber": "Type number and press Enter"
},
"login": {
"userPlaceholder": "Username / Phone / Email",
"PWPlaceholder": "Please enter password",
"signIn": "Sign In",
"userError": "Please enter username",
"PWError": "Please enter password"
},
"power": {
"user": "User",
"role": "Role",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"list": "List",
"get": "Detail",
"importExcel": "Import Excel",
"exportExcel": "Export Excel",
"view": "View",
"clear": "Clear",
"export": "Export",
"import": "Import"
},
"shrimp": {
"loginFailed": "Incorrect username or password",
"userNotFound": "User not found",
"relogin": "Please log in again",
"userDisabled": "User has been disabled",
"emptyPath": "Request path cannot be empty",
"forbidden": "Insufficient permissions",
"serverError": "Server error",
"cannotDisableSelf": "Cannot disable yourself",
"cannotDeleteSelf": "Cannot delete yourself",
"cannotChangeOwnRole": "Cannot change your own role",
"cannotEditOwnRole": "Cannot edit your own role",
"cannotDeleteOwnRole": "Cannot delete your own role",
"cannotClearAllUsers": "Cannot clear all users",
"cannotClearAllRoles": "Cannot clear all roles",
"fieldRequired": "Field \"{field}\" is required",
"fieldTypeError": "Field \"{field}\" has invalid type",
"fieldInvalid": "Field \"{field}\" has invalid value",
"fieldMin": "Field \"{field}\" minimum value is {min}",
"fieldMax": "Field \"{field}\" maximum value is {max}",
"fieldMinLength": "Field \"{field}\" minimum length is {min}",
"fieldMaxLength": "Field \"{field}\" maximum length is {max}",
"invalidJson": "Invalid JSON format"
}
}
@@ -1,130 +0,0 @@
{
"base": {
"name": "名称",
"add": "新增",
"edit": "编辑",
"delete": "删除",
"search": "搜索",
"filter": "筛选",
"reset": "重置",
"confirm": "确定",
"cancel": "取消",
"close": "关闭",
"save": "保存",
"submit": "提交",
"operation": "操作",
"exportExcel": "导出Excel",
"importExcel": "导入Excel",
"success": "成功",
"fail": "失败",
"noNavMenu": "没有子菜单",
"clearCache": "清除缓存",
"logout": "退出登录",
"netError": "网络出错",
"disabled": "禁用",
"enabled": "启用",
"pressEnter": "按回车添加",
"back": "返回",
"refresh": "刷新",
"notFound": "未找到数据",
"expand": "展开",
"collapse": "收起"
},
"user": {
"username": "用户名",
"password": "密码",
"nickname": "昵称",
"roleId": "角色",
"active": "启用状态",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"operationLog": "操作日志"
},
"operationLog": {
"ip": "IP",
"method": "操作",
"args": "参数",
"success": "成功",
"errorMsg": "错误信息",
"createdAt": "时间"
},
"role": {
"name": "角色名",
"description": "描述",
"isSuperAdmin": "超级管理员",
"powers": "权限",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"menuList": "角色列表",
"empty": "暂无角色",
"addRole": "新增角色",
"confirmDelete": "确定删除此角色吗?",
"selectOrCreate": "请选择或新建角色",
"selectAll": "全选",
"newRole": "新角色"
},
"table": {
"addSuccess": "新增成功",
"editSuccess": "编辑成功",
"confirmDelete": "确定删除此记录吗?",
"confirmClear": "确定清空数据吗?此操作不可恢复!",
"yes": "是",
"no": "否",
"unlimited": "不限",
"startTime": "开始时间",
"endTime": "结束时间",
"min": "最小值",
"max": "最大值",
"justNow": "刚刚",
"minutesAgo": "{n} 分钟前",
"hoursAgo": "{n} 小时前",
"daysAgo": "{n} 天前",
"textareaMultipleNotSupported": "文本域暂不支持多选",
"passwordMultipleNotSupported": "密码暂不支持多选",
"pressEnterForNumber": "输入数字后按回车"
},
"login": {
"userPlaceholder": "用户名 / 手机 / 邮箱",
"PWPlaceholder": "请输入密码",
"signIn": "登录",
"userError": "请输入用户名",
"PWError": "请输入密码"
},
"power": {
"add": "新增",
"edit": "编辑",
"delete": "删除",
"list": "列表",
"get": "详情",
"importExcel": "导入Excel",
"exportExcel": "导出Excel",
"view": "查看",
"clear": "清除",
"export": "导出",
"import": "导入"
},
"shrimp": {
"loginFailed": "用户名或密码错误",
"userNotFound": "用户不存在",
"relogin": "请重新登录",
"userDisabled": "用户已被禁用",
"emptyPath": "请求路径不能为空",
"forbidden": "权限不足",
"serverError": "服务器错误",
"cannotDisableSelf": "不能禁用自己",
"cannotDeleteSelf": "不能删除自己",
"cannotChangeOwnRole": "不能修改自己的角色",
"cannotEditOwnRole": "不能编辑自己的角色",
"cannotDeleteOwnRole": "不能删除自己的角色",
"cannotClearAllUsers": "不能清空所有用户",
"cannotClearAllRoles": "不能清空所有角色",
"fieldRequired": "字段\"{field}\"不能为空",
"fieldTypeError": "字段\"{field}\"类型错误",
"fieldInvalid": "字段\"{field}\"值不合法",
"fieldMin": "字段\"{field}\"最小值为 {min}",
"fieldMax": "字段\"{field}\"最大值为 {max}",
"fieldMinLength": "字段\"{field}\"最小长度为 {min}",
"fieldMaxLength": "字段\"{field}\"最大长度为 {max}",
"invalidJson": "JSON格式错误"
}
}
-10
View File
@@ -1,10 +0,0 @@
import { createApp } from 'vue'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/display.css'
import App from './views/App.vue'
import vueAdmin from './VueAdmin'
const app = createApp(App)
app.use(vueAdmin)
app.mount('#app')
@@ -1,42 +0,0 @@
<template>
<template v-if="mode === 'display'">
<el-tooltip :content="fullTime" placement="top">
<span>{{ relativeTime }}</span>
</el-tooltip>
</template>
<el-date-picker v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" type="datetime" style="width: 100%" />
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { vueAdmin } from '@/VueAdmin'
const { t } = useI18n()
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const fullTime = computed(() => {
if (!props.modelValue) return '-'
return new Date(props.modelValue).toLocaleString(vueAdmin.locale === 'en' ? 'en-US' : 'zh-CN')
})
const relativeTime = computed(() => {
if (!props.modelValue) return '-'
const diff = Date.now() - props.modelValue
if (diff < 60000) return t('table.justNow')
if (diff < 3600000) return t('table.minutesAgo', { n: Math.floor(diff / 60000) })
if (diff < 86400000) return t('table.hoursAgo', { n: Math.floor(diff / 3600000) })
if (diff < 2592000000) return t('table.daysAgo', { n: Math.floor(diff / 86400000) })
return fullTime.value
})
</script>
@@ -1,7 +0,0 @@
<template>
<ShrimpTable moduleKey="product" />
</template>
<script lang="ts" setup>
import ShrimpTable from '@/components/ShrimpTable.vue'
</script>
@@ -1,238 +0,0 @@
<template>
<el-container class="role-page">
<!-- 左侧:角色列表 -->
<el-aside width="260px">
<div class="role-list-card">
<div class="role-list-header">{{ $t('role.menuList') }}</div>
<div class="role-list-body">
<div v-for="role in roleList" :key="role._id" class="role-item"
:class="{ active: activeId === role._id }" @click="onSelect(role._id)">
{{ role.name }}
</div>
<div v-if="roleList.length === 0" class="empty-tip">{{ $t('role.empty') }}</div>
</div>
<div class="role-list-footer" v-if="can('add')">
<el-button type="primary" @click="handleAdd" class="add-btn">{{ $t('role.addRole') }}</el-button>
</div>
</div>
</el-aside>
<!-- 右侧:角色编辑 -->
<el-main>
<div v-if="currentRole" class="role-form">
<el-form :model="formData" label-width="80px">
<el-form-item :label="$t('role.name')">
<el-input v-model="formData.name" />
</el-form-item>
<el-form-item :label="$t('role.description')">
<el-input v-model="formData.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item :label="$t('role.powers')">
<PowerTree :modelValue="formData.powers || []" :allPowers="allPowers" :menuTree="vueAdmin.menuTree"
@update:modelValue="val => formData.powers = val" />
</el-form-item>
</el-form>
<div class="form-footer">
<el-button v-if="can('edit')" type="primary" @click="handleSave">{{ $t('base.save') }}</el-button>
<el-popconfirm v-if="can('delete')" :title="$t('role.confirmDelete')" :confirm-button-text="$t('base.delete')"
:cancel-button-text="$t('base.cancel')" confirm-button-type="danger" @confirm="handleDelete">
<template #reference>
<el-button type="danger">{{ $t('base.delete') }}</el-button>
</template>
</el-popconfirm>
</div>
</div>
<el-empty v-else :description="$t('role.selectOrCreate')" />
</el-main>
</el-container>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { vueAdmin } from '@/VueAdmin'
import { IRole, IRoleProto, WithId, ILabel } from '@shrimp/IShrimpType'
import { ElMessage } from 'element-plus'
import PowerTree from '@/components/PowerTree.vue'
interface IPowerItem {
key: string
label: ILabel
}
const { t } = useI18n()
const props = defineProps<{
moduleKey: string
events?: string[]
}>()
const roleApi = vueAdmin.getModule<IRoleProto>('role')
const roleList = ref<WithId<IRole>[]>([])
const activeId = ref('')
const allPowers = ref<IPowerItem[]>([])
const currentRole = ref<WithId<IRole> | null>(null)
const formData = ref<Partial<Omit<IRole, 'createdAt' | 'updatedAt'>>>({
name: '',
description: '',
powers: [],
})
const can = vueAdmin.can('role')
async function loadRoles() {
const res = await roleApi.list()
roleList.value = res.list || res as any
}
function onSelect(id: string) {
activeId.value = id
const role = roleList.value.find(r => r._id === id)
if (role) {
currentRole.value = { ...role }
formData.value = {
name: role.name,
description: role.description,
powers: [...(role.powers || [])],
}
}
}
async function handleAdd() {
try {
const created = await roleApi.add({ name: t('role.newRole'), description: '', powers: [] } as any)
await loadRoles()
onSelect(created._id)
} catch (e: any) {
console.warn('[Role]', e.message)
}
}
async function handleSave() {
try {
await roleApi.edit(currentRole.value!._id, formData.value)
await loadRoles()
onSelect(currentRole.value!._id)
ElMessage.success(t('base.success'))
} catch (e: any) {
console.warn('[Role]', e.message)
}
}
async function handleDelete() {
try {
await roleApi.delete(currentRole.value!._id)
currentRole.value = null
activeId.value = ''
await loadRoles()
if (roleList.value.length > 0) {
onSelect(roleList.value[0]._id)
}
} catch (e: any) {
console.warn('[Role]', e.message)
}
}
onMounted(async () => {
allPowers.value = await roleApi.allPower()
await loadRoles()
if (roleList.value.length > 0) {
onSelect(roleList.value[0]._id)
}
})
</script>
<style scoped>
.role-page {
height: 100%;
padding: 16px;
}
.el-aside {
margin-right: 16px;
border: none;
}
@media (max-width: 992px) {
.el-aside {
margin-right: 0;
}
.role-list-card {
margin-bottom: 16px;
}
}
/* 角色列表卡片 */
.role-list-card {
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-bg-color);
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.role-list-header {
padding: 12px 16px;
font-weight: 600;
font-size: 14px;
border-bottom: 1px solid var(--el-border-color);
}
.role-list-body {
flex: 1;
overflow-y: auto;
}
.role-item {
padding: 10px 16px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.role-item:hover {
background: var(--el-fill-color-light);
}
.role-item.active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-weight: 500;
}
.role-list-footer {
border-top: 1px solid var(--el-border-color);
}
.add-btn {
width: 100%;
height: 40px;
border-radius: 0 0 8px 8px;
border: none !important;
font-size: 14px;
}
/* 角色表单 */
.role-form {
max-width: 700px;
}
.form-footer {
padding-top: 16px;
border-top: 1px solid var(--el-border-color);
margin-top: 16px;
}
.empty-tip {
padding: 20px;
text-align: center;
color: var(--el-text-color-placeholder);
font-size: 14px;
}
</style>
@@ -1,7 +0,0 @@
<template>
<ShrimpTable moduleKey="tag" />
</template>
<script lang="ts" setup>
import ShrimpTable from '@/components/ShrimpTable.vue'
</script>
@@ -1,14 +0,0 @@
<template>
<ShrimpTable moduleKey="user" />
</template>
<script lang="ts" setup>
import ShrimpTable from '@/components/ShrimpTable.vue'
import { vueAdmin } from '@/VueAdmin'
import FieldRelativeTime from './FieldRelativeTime.vue'
vueAdmin.overrideField('user', 'updatedAt', FieldRelativeTime)
</script>
<style scoped>
</style>
@@ -1,67 +0,0 @@
<template>
<div class="op-log-page">
<div v-if="user" class="page-header">
<span class="page-subtitle">{{ user.nickname || user.username }}</span>
</div>
<div class="page-body">
<ShrimpTable v-if="userId" :key="userId" moduleKey="operationLog" />
<el-empty v-else :description="t('base.notFound')" />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ShrimpTable from '@/components/ShrimpTable.vue'
import { vueAdmin } from '@/VueAdmin'
const route = useRoute()
const { t } = useI18n()
const userId = computed(() => route.query.id as string)
const user = ref<any>(null)
async function loadUser() {
if (!userId.value) {
user.value = null
return
}
try {
const api = vueAdmin.getModule<any>('user')
user.value = await api.get(userId.value)
} catch (e: any) {
console.warn('[operationLog] load user failed', e?.message || e)
}
}
watch(userId, loadUser)
onMounted(loadUser)
</script>
<style scoped>
.op-log-page {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.page-header {
padding: 8px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
}
.page-subtitle {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.page-body {
flex: 1;
overflow: auto;
min-height: 0;
}
</style>
-521
View File
@@ -1,521 +0,0 @@
/* 全局 */
#app,
body,
html {
width: 100%;
height: 100%;
background-color: #f6f8f9;
font-size: 12px;
font-family: "sans-serif";
}
a {
color: #333;
text-decoration: none;
}
a:hover,
a:focus {
color: #000;
text-decoration: none;
}
a:link {
text-decoration: none;
}
a:-webkit-any-link {
text-decoration: none;
}
a,
button,
input,
textarea {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
box-sizing: border-box;
outline: none !important;
-webkit-appearance: none;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
outline: none;
}
/* 大布局样式 */
.aminui {
display: flex;
flex-flow: column;
}
.aminui-wrapper {
display: flex;
flex: 1;
overflow: auto;
}
/* 全局滚动条样式 */
.scrollabel {
-webkit-overflow-scrolling: touch;
}
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-thumb {
background-color: rgba(50, 50, 50, 0.3);
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(50, 50, 50, 0.6);
}
::-webkit-scrollbar-track {
background-color: rgba(50, 50, 50, 0.1);
}
::-webkit-scrollbar-track:hover {
background-color: rgba(50, 50, 50, 0.2);
}
/*布局设置*/
.layout-setting {
position: fixed;
width: 40px;
height: 40px;
border-radius: 3px 0 0 3px;
bottom: 100px;
right: 0px;
z-index: 100;
background: #409EFF;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
}
.layout-setting i {
font-size: 18px;
color: #fff;
}
/* 头部 */
.adminui-header {
height: 58px;
background: #222b45;
color: #fff;
display: flex;
justify-content: space-between;
}
.adminui-header-left {
display: flex;
align-items: center;
padding-left: 20px;
}
.adminui-header-right {
display: flex;
align-items: center;
}
.adminui-header .logo-bar {
font-size: 20px;
font-weight: bold;
display: flex;
align-items: center;
}
.adminui-header .logo-bar .logo {
margin-right: 10px;
width: 35px;
height: 35px;
}
.adminui-header .nav {
display: flex;
height: 100%;
margin-left: 40px;
}
.adminui-header .nav li {
padding: 0 10px;
margin: 0 10px 0 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
list-style: none;
height: 100%;
display: flex;
align-items: center;
cursor: pointer;
}
.adminui-header .nav li i {
margin-right: 5px;
}
.adminui-header .nav li:hover {
color: #fff;
}
.adminui-header .nav li.active {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.adminui-header .user-bar .panel-item:hover {
background: rgba(255, 255, 255, 0.1) !important;
}
.adminui-header .user-bar .user label {
color: #fff;
}
/* 左侧菜单 */
.aminui-side-split {
width: 65px;
flex-shrink: 0;
background: #222b45;
display: flex;
flex-flow: column;
}
.aminui-side-split-top {
height: 49px;
}
.aminui-side-split-top a {
display: inline-block;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.aminui-side-split-top .logo {
height: 30px;
vertical-align: bottom;
}
.adminui-side-split-scroll {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
flex: 1;
}
.aminui-side-split li {
cursor: pointer;
width: 65px;
height: 65px;
color: #fff;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.aminui-side-split li i {
font-size: 18px;
}
.aminui-side-split li p {
margin-top: 5px;
padding: 0 4px;
font-size: 12px;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.aminui-side-split li:hover {
background: rgba(255, 255, 255, 0.1);
}
.aminui-side-split li.active {
background: #409EFF;
}
.adminui-side-split-scroll::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.4);
border-radius: 5px;
}
.adminui-side-split-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.5);
}
.adminui-side-split-scroll::-webkit-scrollbar-track {
background-color: rgba(255, 255, 255, 0);
}
.adminui-side-split-scroll::-webkit-scrollbar-track:hover {
background-color: rgba(255, 255, 255, 0);
}
.aminui-side {
display: flex;
flex-flow: column;
flex-shrink: 0;
width: 210px;
background: #fff;
box-shadow: 2px 0 8px 0 rgba(29, 35, 41, .05);
border-right: 1px solid #e6e6e6;
transition: width 0.3s;
}
.adminui-side-top {
border-bottom: 1px solid #ebeef5;
height: 50px;
line-height: 50px;
}
.adminui-side-top h2 {
padding: 0 20px;
font-size: 17px;
color: #3c4a54;
}
.adminui-side-scroll {
overflow: auto;
overflow-x: hidden;
flex: 1;
}
.adminui-side-bottom {
border-top: 1px solid #ebeef5;
height: 51px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.adminui-side-bottom i {
font-size: 16px;
}
.adminui-side-bottom:hover {
color: var(--el-color-primary);
}
.aminui-side.isCollapse {
width: 65px;
}
.el-menu .menu-tag {
position: absolute;
height: 18px;
line-height: 18px;
background: var(--el-color-danger);
font-size: 12px;
color: #fff;
right: 20px;
border-radius: 18px;
padding: 0 6px;
}
.el-menu .el-sub-menu__title .menu-tag {
right: 40px;
}
.el-menu--horizontal>li .menu-tag {
display: none;
}
/* 右侧内容 */
.aminui-body {
flex: 1;
display: flex;
flex-flow: column;
}
.adminui-topbar {
height: 50px;
border-bottom: 1px solid #ebeef5;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, .08);
display: flex;
justify-content: space-between;
}
.adminui-topbar .left-panel {
display: flex;
align-items: center;
}
.adminui-topbar .right-panel {
display: flex;
align-items: center;
}
.right-panel-search {
display: flex;
align-items: center;
}
.right-panel-search>*+* {
margin-left: 10px;
}
.adminui-tags {
height: 35px;
background: #fff;
border-bottom: 1px solid #e6e6e6;
}
.adminui-tags ul {
display: flex;
overflow: hidden;
}
.adminui-tags li {
cursor: pointer;
display: inline-block;
float: left;
height: 34px;
line-height: 34px;
position: relative;
flex-shrink: 0;
}
.adminui-tags li::after {
content: " ";
width: 1px;
height: 100%;
position: absolute;
right: 0px;
background-image: linear-gradient(#fff, #e6e6e6);
}
.adminui-tags li a {
display: inline-block;
padding: 0 10px;
width: 100%;
height: 100%;
color: #999;
text-decoration: none;
display: flex;
align-items: center;
}
.adminui-tags li i {
margin-left: 10px;
border-radius: 3px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.adminui-tags li i:hover {
background: rgba(0, 0, 0, .2);
color: #fff;
}
.adminui-tags li:hover {
background: #ecf5ff;
}
.adminui-tags li.active {
background: #409EFF;
}
.adminui-tags li.active a {
color: #fff;
}
.adminui-tags li.sortable-ghost {
opacity: 0;
}
.adminui-main {
overflow: hidden;
background-color: #f6f8f9;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* 让路由内容铺满 .adminui-main,使内部的滚动容器(如 ShrimpTable 的 .el-main)能拿到正确高度 */
.adminui-main > * {
flex: 1;
min-height: 0;
}
/*页面最大化*/
.aminui.main-maximize {
.main-maximize-exit {
display: block;
}
.aminui-side-split,
.aminui-side,
.adminui-header,
.adminui-topbar,
.adminui-tags {
display: none;
}
}
.main-maximize-exit {
display: none;
position: fixed;
z-index: 3000;
top: -20px;
left: 50%;
margin-left: -20px;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
background: rgba(0, 0, 0, 0.2);
text-align: center;
}
.main-maximize-exit i {
font-size: 14px;
margin-top: 22px;
color: #fff;
}
.main-maximize-exit:hover {
background: rgba(0, 0, 0, 0.4);
}
/*定宽页面*/
.sc-page {
width: 1230px;
margin: 0 auto;
}
.el-drawer .el-form-item {
display: block;
}
// .el-drawer.rtl {width: min(600px, 100%)!important;}
//新增全局字体样式
.el-tag+.el-tag {
margin-left: 5px;
}
.el-button+.el-button {
margin-left: 5px;
}
.el-table .el-table__cell {
padding: 5px;
}
@@ -1,42 +0,0 @@
@use 'element-plus/theme-chalk/src/dark/css-vars.scss';
html.dark {
//变量
--el-text-color-primary: #d0d0d0;
--el-color-primary-dark-2: var(--el-color-primary-light-2) !important;
--el-color-primary-light-9: var(--el-color-primary-dark-8) !important;
--el-color-primary-light-8: var(--el-color-primary-dark-7) !important;
--el-color-primary-light-7: var(--el-color-primary-dark-6) !important;
--el-color-primary-light-5: var(--el-color-primary-dark-4) !important;
--el-color-primary-light-3: var(--el-color-primary-dark-3) !important;
//背景
#app {background: var(--el-bg-color);}
//登录背景
.login_bg {background: var(--el-bg-color);}
//框架
.adminui-header {background: var(--el-bg-color-overlay);border-bottom: 1px solid var(--el-border-color-light);height:59px;}
.aminui-side-split {background: var(--el-bg-color);}
.aminui-side-split li {color: var(--el-text-color-primary);}
.aminui-side {background: var(--el-bg-color-overlay);border-color: var(--el-border-color-light);}
.adminui-side-top, .adminui-side-bottom {border-color: var(--el-border-color-light);}
.adminui-side-top h2 {color: var(--el-text-color-primary);}
.adminui-topbar, .adminui-tags {background: var(--el-bg-color-overlay);border-color: var(--el-border-color-light);}
.adminui-main {background: var(--el-bg-color);}
.drawerBG {background: var(--el-bg-color);}
.adminui-header-menu .el-menu {--el-menu-bg-color:var(--el-bg-color-overlay) !important;--el-menu-hover-bg-color: #171819 !important;}
.adminui-header-menu .el-menu .el-sub-menu__title {background-color:transparent !important;}
//全局滚动条样式
::-webkit-scrollbar-thumb {background-color: rgba(163, 166, 173, 0.3);}
::-webkit-scrollbar-thumb:hover {background-color: rgba(163, 166, 173, 0.5);}
//组件
.el-header, .el-main.nopadding, .el-footer {background: var(--el-bg-color-overlay);border-color: var(--el-border-color-light);}
.el-main {background: var(--el-bg-color);}
.el-aside {background: var(--el-bg-color-overlay);border-color: var(--el-border-color-light);}
.el-table .el-table__body-wrapper {background: var(--el-bg-color);}
.el-table th.is-sortable:hover {background: #111;}
}
@@ -1,91 +0,0 @@
/* 覆盖element-plus样式 */
:root {
--el-color-primary: #409EFF;
--el-color-primary-light-1: #53a7ff;
--el-color-primary-light-2: #66b1ff;
--el-color-primary-light-3: #79bbff;
--el-color-primary-light-4: #8cc4ff;
--el-color-primary-light-5: #9fceff;
--el-color-primary-light-6: #b2d8ff;
--el-color-primary-light-7: #c5e1ff;
--el-color-primary-light-8: #d8ebff;
--el-color-primary-light-9: #ebf5ff;
--el-color-primary-dark-1: #398ee5;
--el-color-primary-dark-2: #337ecc;
--el-color-primary-dark-3: #2c6eb2;
--el-color-primary-dark-4: #265e99;
--el-color-primary-dark-5: #204f7f;
--el-color-primary-dark-6: #193f66;
--el-color-primary-dark-7: #132f4c;
--el-color-primary-dark-8: #0c1f32;
--el-color-primary-dark-9: #060f19;
}
.el-menu {border: none!important;}
.el-menu .el-menu-item a {color: inherit;text-decoration: none;display: block;width:100%;height:100%;position: absolute;top:0px;left:0px;}
.el-form-item-msg {font-size: 12px;color: #999;clear: both;width: 100%;}
.el-container {height: 100%;}
.el-aside {border-right: 1px solid var(--el-border-color-light);}
.el-container + .el-aside {border-right: 0;border-left: 1px solid var(--el-border-color-light);}
.el-header {background: #fff;border-bottom: 1px solid var(--el-border-color-light);padding:13px 15px;display: flex;align-items: center;}
.el-header .left-panel {display: flex;align-items: center;}
.el-header .right-panel {display: flex;align-items: center;}
.el-header .right-panel > * + * {margin-left:10px;}
.el-footer {background: #fff;border-top: 1px solid var(--el-border-color-light);padding:13px 15px;}
.el-main {padding:15px;}
.el-main.nopadding {padding:0;background: #fff;}
.el-drawer__body {overflow: auto;padding:0;}
.el-popconfirm__main {margin: 14px 0;}
// .el-card__header {border-bottom: 0;font-size: 17px;font-weight: bold;padding:15px 20px 0px 20px;}
// .el-card__header {padding:5px 20px 5px 20px;}
.el-dialog__title {font-size: 17px;font-weight: bold;}
.el-drawer__header>:first-child {font-size: 17px;font-weight: bold;}
.el-tree.menu .el-tree-node__content {height:36px;}
.el-tree.menu .el-tree-node__content .el-tree-node__label .icon {margin-right: 5px;}
.el-progress__text {font-size: 12px!important;}
.el-progress__text i {font-size: 14.4px!important;}
.el-step.is-horizontal .el-step__line {height:1px;}
.el-step__title {font-size: 14px;}
.drawerBG {background: #f6f8f9;}
.el-button+.el-dropdown {margin-left: 10px;}
.el-button-group+.el-dropdown {margin-left: 10px;}
.el-button-group+.el-button-group {margin-left: 10px;}
.el-tabs__nav-wrap::after {height: 1px;}
.el-table th.is-sortable {transition: .1s;}
.el-table th.is-sortable:hover {background: #eee;}
.el-table .el-table__body-wrapper {background: #f6f8f9;}
.el-col .el-card {margin-bottom: 15px;}
.el-main {flex-basis: 100%;}
.el-main > .scTable .el-table--border::before {display: none;}
.el-main > .scTable .el-table--border::after {display: none;}
.el-main > .scTable .el-table--border .el-table__inner-wrapper::after {display: none;}
.el-main > .scTable .el-table__border-left-patch {display: none;}
.el-main > .scTable .el-table--border .el-table__inner-wrapper tr:first-child td:first-child {border-left: 0;}
.el-main > .scTable .el-table--border .el-table__inner-wrapper tr:first-child th:first-child {border-left: 0;}
.el-table.el-table--large {font-size: 14px;}
.el-table.el-table--small {font-size: 12px;}
.el-table {font-size: 12px;}
.el-radio-button__inner {font-size: 12px;}
.el-checkbox-button__inner {font-size: 12px;}
.el-sub-menu .el-icon {font-size: 17px;}
.el-sub-menu .el-sub-menu__icon-arrow {font-size: 12px;}
.aminui-side-split li.active {background-color: var(--el-color-primary);}
.adminui-tags li:hover {background-color: var(--el-color-primary-light-9);}
.adminui-tags li.active {background-color: var(--el-color-primary)!important;}
.contextmenu li:hover {background-color: var(--el-color-primary-light-9)!important;color: var(--el-color-primary-light-2)!important;}
.data-box .item-background {background-color: var(--el-color-primary)!important;}
.layout-setting,.diy-grid-setting {background-color: var(--el-color-primary)!important;}
/* 全部禁用el-tag动画 */
.el-tag {transition: all 0s !important;}
/* 覆盖tinymce样式 */
.sceditor .tox-tinymce {border: 1px solid #DCDFE6;border-radius: 0;}
body .tox-tinymce-aux {z-index: 5700;}
//新增
.el-table .cell{
word-break: break-word;
}
@@ -1,174 +0,0 @@
@media (max-width: 992px) {
// 移动端样式覆盖
.el-form-item {
display: block;
}
.el-form-item__label {
margin: 5px !important;
}
.el-dialog {
width: 90% !important;
}
.el-dialog.is-fullscreen {
width: 100% !important;
}
.el-drawer.rtl {
width: min(400px, 100%) !important;
}
.el-form-item__content {
margin: 5px !important;
}
// .arrayInput{
// width: 100% !important;
// }
.adminui-main {
>.el-container {
display: block;
height: auto;
}
>.el-container>.el-aside {
width: 100% !important;
border: 0
}
}
.scTable {
.el-table,
.el-table__body-wrapper {
display: block !important;
height: auto !important;
}
.el-scrollbar__wrap {
height: auto !important;
}
.scTable-page {
padding: 0 5px !important;
}
.el-pagination__total,
.el-pagination__jump,
.el-pagination__sizes {
display: none !important;
}
}
.headerPublic {
height: auto !important;
display: block;
.left-panel {
overflow: auto;
}
.left-panel::-webkit-scrollbar {
display: none;
}
.right-panel {
display: block;
border-top: 1px solid var(--el-border-color-light);
margin-top: 15px;
}
.right-panel .right-panel-search {
display: block;
}
.right-panel .right-panel-search>* {
width: 100%;
margin: 0;
margin-top: 15px;
}
}
.adminui-main>.el-container>*:first-child:not(.el-aside):not(.el-header) {
border: 0;
margin-top: 0;
}
.adminui-main>.el-container>*:first-child:not(.el-aside):not(.el-header)+.el-aside {
margin-top: 0;
}
.adminui-main>.el-container>.el-aside {
border-bottom: 1px solid var(--el-border-color-light) !important;
}
.adminui-main>.el-container>.el-container {
border-top: 1px solid var(--el-border-color-light);
border-bottom: 1px solid var(--el-border-color-light);
margin-top: 15px;
}
.adminui-main>.el-container>.el-container+.el-aside {
border-top: 1px solid var(--el-border-color-light);
margin-top: 15px;
}
.adminui-main>.el-container>.el-header {
@extend .headerPublic;
}
.adminui-main>.el-container>.el-main.nopadding {
border-top: 1px solid var(--el-border-color-light);
border-bottom: 1px solid var(--el-border-color-light);
margin-top: 15px;
}
.adminui-main>.el-container>.el-main+.el-aside {
border-left: 0 !important;
border-top: 1px solid var(--el-border-color-light);
margin-top: 15px;
}
.adminui-main>.el-container>.el-footer {
margin-top: 15px;
border-bottom: 1px solid var(--el-border-color-light);
}
.adminui-main>.el-container>.el-container>.el-header {
@extend .headerPublic
}
.adminui-main>.el-container>.el-container>.el-header .left-panel {
display: block;
}
.adminui-main>.el-container>.el-container>.el-header .right-panel {
display: block;
margin-top: 15px;
}
.sc-page {
width: 100%;
margin: 0;
}
.common-main .el-form {
width: 100% !important;
}
.common-header-logo label {
display: none;
}
.common-header-title {
display: none;
}
//手机端没有左右边距 新增
.el-main {
padding: 15px 0px;
}
}
@@ -1,44 +0,0 @@
/* USERCENTER */
.page-user {
.user-info-top {text-align: center;}
.user-info-top h2 {font-size: 18px;margin-top: 5px;}
.user-info-top p {margin: 8px 0 10px 0;}
.menu {background: none;}
.menu .el-menu-item {font-size: 12px;--el-menu-item-height:50px;}
.menu .el-menu-item-group {border-top: 1px solid var(--el-border-color-light);}
.menu .el-menu-item-group:first-child {border: 0;}
}
/*static-table*/
.static-table {border-collapse: collapse;width: 100%;font-size: 14px;margin-bottom: 45px;line-height: 1.5em;}
.static-table th {text-align: left;white-space: nowrap;color: #909399;font-weight: 400;border-bottom: 1px solid #dcdfe6;padding: 15px;max-width: 250px;}
.static-table td {border-bottom: 1px solid #dcdfe6;padding: 15px;max-width: 250px;color: #606266;}
/*header-tabs*/
.header-tabs {padding:10px 0 0 0;display:block;border:0!important;height:50px;background: none;}
.header-tabs .el-tabs__header {padding-left:10px;margin: 0;}
.header-tabs .el-tabs__content {display: none;}
.header-tabs .el-tabs__nav {border-radius: 0 !important;}
.header-tabs .el-tabs__item {font-size: 13px;}
.header-tabs .el-tabs__item.is-active {background-color: var(--el-bg-color-overlay);}
/*common-page*/
.common-page {}
.common-header-left {display: flex;align-items: center;}
.common-header-logo {display: flex;align-items: center;}
.common-header-logo img {height:30px;margin-right: 10px;vertical-align: bottom;}
.common-header-logo label {font-size: 20px;}
.common-header-title {font-size: 16px;border-left: 1px solid var(--el-border-color-light);margin-left: 15px;padding-left: 15px;}
.common-header-right {display: flex;align-items: center;}
.common-header-right a {font-size: 14px;color: var(--el-color-primary);cursor: pointer;}
.common-header-right a:hover {color: var(--el-color-primary-light-3);}
.common-container {max-width: 1240px;margin:30px auto 30px auto;}
.common-main {padding:20px;}
.common-title {font-size: 26px;margin-bottom: 20px;font-weight: normal;}
.common-main .el-form {width: 500px;margin:30px auto;}
.common-main .el-steps .el-step__title {font-size: 14px;}
.common-main .el-steps .el-step__icon {border: 1px solid;}
.common-main .yzm {display: flex;width: 100%;}
.common-main .yzm .el-button {margin-left: 10px;}
.common-main .link {color: var(--el-color-primary);cursor: pointer;}
.common-main .link:hover {color: var(--el-color-primary-light-3);}
@@ -1,6 +0,0 @@
@use 'app.scss';
@use 'fix.scss';
@use 'pages.scss';
@use 'media.scss';
@use 'dark.scss';
@use 'zd.scss';
@@ -1,48 +0,0 @@
.zd-flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.zd-flex-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.zd-flex-column {
display: flex;
flex-direction: column;
}
.zd-padding-0 {
padding: 0px;
}
.zd-padding-1 {
padding: 1px;
}
.zd-padding-2 {
padding: 2px;
}
.zd-padding-3 {
padding: 3px;
}
.zd-padding-5 {
padding: 5px;
}
.zd-padding-8 {
padding: 8px;
}
.zd-padding-10 {
padding: 10px;
}
.zd-aside+.zd-aside {
margin-left: 10px;
}
-110
View File
@@ -1,110 +0,0 @@
export enum GameState {
READYING,
PLAYING,
ENDING,
}
export enum RoomState {
WAITING,
READYING,
PLAYING,
ENDING,
}
export enum PlayerState {
ALIVE,
DEAD,
SUCCESS,
}
export interface IPlayer {
name: string
uid: string
sid: string
playerId: string
vip: number
face: string
score: number
exCount: {
bag: number
save: number
}
}
export interface IThingVo {
thingId: string
configId: string
}
export interface IBagVO {
things: IThingVo[]
count: number
addCount: number
}
export interface ITeamInfo {
playerInfo: IPlayer
team: {
teamId: number
birthplace: number
exitPlane: number
roomId: number
sitId: number
isOnline: boolean
state: PlayerState
}
bag: IBagVO
saveBag: IBagVO
}
export interface IRoomInfo {
birthplaceList: { b: number; e: number }[]
index: number
state: RoomState
lockInfo: { [key: string]: boolean }
}
export interface IRoomsInfo {
rooms: IRoomInfo[]
roomIndex: number
endTime: number
}
export interface IBaseInfo {
playerId?: string
bag?: string
clothingId?: number
}
export interface IBaseSC {
baseInfo(value: IBaseInfo): void
upLockSit(roomId: number, index: number, value: boolean): void
upRoom(value: IRoomsInfo): void
addUser(value: ITeamInfo): void
removeUser(value: ITeamInfo): void
playerInfo(value: ITeamInfo): void
upPlayer(value: ITeamInfo): void
kick(): void
eliminate(): void
addThing(value: IThingVo[]): void
addSaveThing(value: IThingVo[]): void
removeThing(value: number): void
}
export interface IBossRouter {
iamboss(): Promise<void>
changeSit(uid: string, sitId: number, teamId: number, roomIndex?: number): Promise<void>
lockSit(roomId: number, index: number, lock: boolean): Promise<void>
kill(uid: string): Promise<void>
ready(): Promise<void>
start(): Promise<void>
end(): Promise<void>
}
export interface LoginVo {
userId: string
}
export interface IWebCS {
getToken(vo: LoginVo): string
}
@@ -1,10 +0,0 @@
<template>
<div>
<h1>404</h1>
</div>
</template>
<script lang="ts" setup>
</script>
<style scoped></style>
@@ -1,24 +0,0 @@
<template>
<el-config-provider :locale="locale">
<router-view></router-view>
</el-config-provider>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { vueAdmin } from "../VueAdmin";
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
const locale = computed(() => {
let curLang = (vueAdmin.i18n.global.locale as any).value
if (curLang == "en") {
return en;
} else {
return zhCn;
}
});
</script>
<style lang="scss">
@use "../style/style.scss";
</style>
@@ -1,110 +0,0 @@
<template>
<section class="aminui-wrapper">
<div v-if="!vueAdmin.state.isMobile" class="aminui-side-split">
<div class="aminui-side-split-top">
<logo />
</div>
<div class="adminui-side-split-scroll">
<div class="sidebar-menu-wrap">
<el-scrollbar>
<ul>
<li v-for="item in vueAdmin.menuTree"
:class="pmenu?.name == item.name ? 'active' : ''"
@click="showMenu(item)">
<el-icon>
<component :is="item.icon || 'el-icon-menu'" />
</el-icon>
<p>{{ vueAdmin.t(item.label) }}</p>
</li>
</ul>
</el-scrollbar>
</div>
</div>
</div>
<div v-if="!vueAdmin.state.isMobile && nextMenu.length > 0" class="aminui-side">
<div class="adminui-side-scroll">
<el-scrollbar>
<el-menu :default-active="$route.fullPath" router>
<navMenu :navMenus="nextMenu"></navMenu>
</el-menu>
</el-scrollbar>
</div>
</div>
<SideM v-if="vueAdmin.state.isMobile"></SideM>
<div class="aminui-body el-container">
<Topbar>
<userbar></userbar>
</Topbar>
<div class="adminui-main" id="adminui-main">
<router-view :key="$route.path" />
</div>
</div>
</section>
</template>
<script lang="ts" setup>
import SideM from "./sideM.vue";
import { ref, watch } from "vue";
import navMenu from "./navMenu.vue";
import Topbar from "./topbar.vue";
import Userbar from "./userbar.vue";
import { vueAdmin } from "@/VueAdmin";
import { type IMenuItem } from "@shrimp/IShrimpType";
import Logo from "./logo.vue";
// 初始化选中菜单
let currentPath = vueAdmin.router.currentRoute.value.path;
let pick = vueAdmin.getMenuIndex(currentPath);
let menu = pick >= 0 ? vueAdmin.menuTree[pick] : vueAdmin.menuTree[0];
let pmenu = ref(menu);
let nextMenu = ref(getMenu(vueAdmin.isGroupMenu(menu) ? menu.children : []));
function getMenu(children?: IMenuItem[]) {
if (children == null) return [];
return children.slice()
}
function getFirstLeafPath(item: IMenuItem): string {
if (item.path) return item.path
if (item.moduleKey) return '/' + item.moduleKey
if (item.children && item.children.length > 0) {
return getFirstLeafPath(item.children[0])
}
return '/' + item.name
}
function showMenu(route: any) {
pmenu.value = route;
nextMenu.value = getMenu(route.children);
if (route.children == null || route.children.length == 0) {
const path = route.path || '/' + route.name
vueAdmin.router.push(path)
} else {
// 展开菜单的同时,跳到第一个可跳转的叶子节点
const path = getFirstLeafPath(route.children[0])
vueAdmin.router.push(path)
}
}
// 路由变化时同步更新 pmenu 和 nextMenu
watch(() => vueAdmin.router.currentRoute.value.path, (path) => {
const idx = vueAdmin.getMenuIndex(path)
if (idx >= 0) {
const m = vueAdmin.menuTree[idx]
if (pmenu.value?.name !== m.name) {
pmenu.value = m
nextMenu.value = getMenu(vueAdmin.isGroupMenu(m) ? m.children : [])
}
}
})
</script>
<style scoped>
.sidebar-menu-wrap {
flex: 1;
overflow: hidden;
}
.sidebar-menu-wrap .el-scrollbar {
height: 100%;
}
</style>
@@ -1,130 +0,0 @@
<template>
<div class="login_bg">
<LangSwitch class="login-lang" />
<div class="login_main">
<div class="login-form">
<div class="login-header">
<div class="logo">
<label>{{ vueAdmin.adminConfig.title || 'Admin' }}</label>
</div>
</div>
<el-form ref="loginForm" :model="form" :rules="rules" label-width="0" size="large" @keyup.enter="login">
<el-form-item prop="user">
<el-input v-model="form.user" prefix-icon="el-icon-user" clearable
:placeholder="$t('login.userPlaceholder')">
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" prefix-icon="el-icon-lock" clearable show-password
:placeholder="$t('login.PWPlaceholder')"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" style="width: 100%" :loading="info.islogin" round @click="login">{{
$t("login.signIn") }}</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { vueAdmin } from "@/VueAdmin";
import { reactive, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import LangSwitch from "@/components/LangSwitch.vue";
vueAdmin.token = null;
let { t } = useI18n();
let info = reactive({ islogin: false });
let form = reactive({ user: import.meta.env.DEV ? "admin" : "", password: import.meta.env.DEV ? "admin" : "" });
let rules = computed(() => ({
user: [{ required: true, message: t("login.userError"), trigger: "blur" }],
password: [{ required: true, message: t("login.PWError"), trigger: "blur" }],
}));
// 页面加载时获取后台配置
onMounted(() => {
vueAdmin.fetchConfig().then(data => {
Object.assign(vueAdmin.adminConfig, data)
}).catch(() => {})
})
function login() {
info.islogin = true;
vueAdmin.fetchLogin({ username: form.user, password: form.password })
.then(async (data) => {
vueAdmin.token = data.token;
await vueAdmin.loadUserInfo();
info.islogin = false;
vueAdmin.router.push(vueAdmin.getFirstMenuPath());
})
.catch((e: any) => {
console.warn('[Login]', e.message)
info.islogin = false;
});
}
</script>
<style scoped>
.login_bg {
width: 100%;
height: 100%;
background: #fff;
display: flex;
position: relative;
}
.login_main {
flex: 1;
overflow: auto;
display: flex;
}
.login-form {
width: 400px;
margin: auto;
padding: 20px 0;
}
.login-header {
margin-bottom: 40px;
}
.login-header .logo {
display: flex;
align-items: center;
justify-content: center;
}
.login-header .logo label {
font-size: 26px;
font-weight: bold;
}
.login-lang {
position: absolute;
top: 16px;
right: 16px;
}
@media (max-width: 1200px) {
.login-form {
width: 340px;
}
}
@media (max-width: 1000px) {
.login_main {
display: block;
}
.login-form {
width: 100%;
padding: 20px 40px;
}
}
</style>
@@ -1,17 +0,0 @@
<template>
<router-link :to="vueAdmin.getFirstMenuPath()">
<el-icon class="logo" size="20">
<el-icon-home-filled />
</el-icon>
</router-link>
</template>
<script setup lang="ts">
import { vueAdmin } from "../VueAdmin";
</script>
<style scoped>
.logo {
color: #fff;
}
</style>
@@ -1,37 +0,0 @@
<template>
<div v-if="navMenus.length <= 0" style="padding: 20px">
<el-alert :title="$t('base.noNavMenu')" center type="info" :closable="false"></el-alert>
</div>
<template v-for="navMenu in navMenus" v-bind:key="navMenu">
<el-menu-item v-if="!hasChildren(navMenu)" :index="'/' + navMenu.name">
<el-icon v-if="navMenu.icon">
<component :is="navMenu.icon || 'el-icon-menu'" />
</el-icon>
<template #title>
<span>{{ vueAdmin.t(navMenu.label) }}</span>
</template>
</el-menu-item>
<el-sub-menu v-else :index="'/' + navMenu.name">
<template #title>
<el-icon v-if="navMenu.icon">
<component :is="navMenu.icon || 'el-icon-menu'" />
</el-icon>
<span>{{ vueAdmin.t(navMenu.label) }}</span>
</template>
<NavMenu :navMenus="navMenu.children"></NavMenu>
</el-sub-menu>
</template>
</template>
<script lang="ts" setup>
import { vueAdmin } from "@/VueAdmin";
defineProps({
navMenus: {
type: [Object],
required: true,
},
});
function hasChildren(item: any) {
return item.children && item.children.length > 0;
}
</script>
@@ -1,83 +0,0 @@
<template>
<div class="mobile-nav-button" @click="showMobileNav">
<el-icon><el-icon-menu /></el-icon>
</div>
<el-drawer
:size="240"
v-model="nav"
direction="ltr"
:with-header="false"
destroy-on-close
>
<el-container class="mobile-nav">
<el-header>
<logo/>
</el-header>
<el-main>
<el-scrollbar>
<el-menu
:default-active="$route.fullPath"
@select="select"
router
background-color="#212d3d"
text-color="#fff"
active-text-color="#409EFF"
>
<navMenu :navMenus="vueAdmin.menuTree"></navMenu>
</el-menu>
</el-scrollbar>
</el-main>
</el-container>
</el-drawer>
</template>
<script lang="ts" setup>
import navMenu from "./navMenu.vue";
import Logo from "./logo.vue";
import { ref } from "vue";
import { vueAdmin } from '@/VueAdmin';
let nav = ref(false);
function showMobileNav() { nav.value = true; }
function select() {}
</script>
<style scoped>
.mobile-nav-button {
position: fixed;
bottom: 10px;
left: 10px;
z-index: 10;
width: 50px;
height: 50px;
background: #409eff;
box-shadow: 0 2px 12px 0 rgba(64, 158, 255, 1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-nav-button i {
color: #fff;
font-size: 20px;
}
.mobile-nav {
background: #212d3d;
}
.mobile-nav .el-header {
background: transparent;
border: 0;
}
.mobile-nav .el-main {
padding: 0;
}
.mobile-nav .logo-bar {
display: flex;
align-items: center;
font-weight: bold;
font-size: 20px;
color: #fff;
}
</style>
@@ -1,102 +0,0 @@
<template>
<div class="adminui-topbar">
<div class="left-panel">
<el-button v-if="canBack" :icon="ArrowLeft" link size="large" class="back-btn" @click="handleBack">{{ $t('base.back') }}</el-button>
<el-breadcrumb separator-icon="el-icon-arrow-right">
<template v-for="(item, index) in breadcrumb" :key="index">
<el-breadcrumb-item v-if="item.to && index < breadcrumb.length - 1" :to="item.to">
{{ vueAdmin.t(item.label) }}
</el-breadcrumb-item>
<el-breadcrumb-item v-else>{{ vueAdmin.t(item.label) }}</el-breadcrumb-item>
</template>
</el-breadcrumb>
</div>
<div class="center-panel"></div>
<div class="right-panel">
<slot></slot>
</div>
</div>
</template>
<script lang="ts" setup>
import { vueAdmin } from "@/VueAdmin";
import { computed } from "vue";
import { ArrowLeft } from "@element-plus/icons-vue";
import type { RouteLocationRaw } from "vue-router";
interface BreadcrumbItem {
key: string;
label: any;
to?: RouteLocationRaw;
}
// 把 ?id + ?pids 复原成 root→immediate-parent 的 id 数组
function getAncestorIds(): string[] {
const route = vueAdmin.router.currentRoute.value;
const pids = ((route.query.pids as string) || '').split(',').filter(Boolean);
const id = route.query.id as string | undefined;
return id ? [...pids, id] : pids;
}
const breadcrumb = computed<BreadcrumbItem[]>(() => {
const route = vueAdmin.router.currentRoute.value;
const raw = vueAdmin.getBreadcrumb(route.path);
const ancestors = getAncestorIds();
return raw.map(item => {
if (!item.path) return { key: item.key, label: item.label };
// path 段数 - 1 = 模块链深度 (0=顶层, >=1=子模块)
const depth = item.path.split('/').filter(Boolean).length - 1;
if (depth <= 0) return { key: item.key, label: item.label, to: item.path };
// 该 depth 的页面需要 ?id = ancestors[depth-1], pids = ancestors[0..depth-2]
const id = ancestors[depth - 1];
if (!id) return { key: item.key, label: item.label, to: item.path };
const newPids = ancestors.slice(0, depth - 1);
const query: Record<string, string> = { id };
if (newPids.length > 0) query.pids = newPids.join(',');
return { key: item.key, label: item.label, to: { path: item.path, query } };
});
});
// 倒数第二个可点的 item 即"上一级"
const backTarget = computed<RouteLocationRaw | undefined>(() => {
const list = breadcrumb.value;
for (let i = list.length - 2; i >= 0; i--) {
if (list[i].to) return list[i].to;
}
return undefined;
});
const canBack = computed(() => !!backTarget.value);
function handleBack() {
// 优先用浏览器历史(保留来时的完整 query);无历史则按 breadcrumb 计算的目标 push
if (window.history.length > 1) {
vueAdmin.router.back();
} else if (backTarget.value) {
vueAdmin.router.push(backTarget.value);
}
}
</script>
<style scoped>
.left-panel {
display: flex;
align-items: center;
}
.back-btn {
margin-left: 10px;
margin-right: 16px;
}
.el-breadcrumb {
margin-left: 8px;
}
/* 小屏: 只保留最后一段(当前页);返回按钮和菜单覆盖上层导航 */
@media (max-width: 768px) {
.el-breadcrumb :deep(.el-breadcrumb__item):not(:last-child) {
display: none;
}
}
</style>
@@ -1,90 +0,0 @@
<template>
<div class="user-bar">
<LangSwitch class="panel-item" />
<div class="panel-item" @click="screen">
<el-icon><el-icon-full-screen /></el-icon>
</div>
<el-dropdown class="user" trigger="click" @command="handleUser">
<div class="user-avatar dropdown-panel-item">
<label>{{ vueAdmin.userName || 'Admin' }}</label>
<el-icon class="el-icon--right"><el-icon-arrow-down /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="clearCache">{{ $t("base.clearCache") }}</el-dropdown-item>
<el-dropdown-item divided command="outLogin">{{ $t("base.logout") }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script lang="ts" setup>
import { vueAdmin } from "@/VueAdmin";
import LangSwitch from "@/components/LangSwitch.vue";
function screen() {
vueAdmin.fullscreen();
}
function handleUser(command: string) {
switch (command) {
case "clearCache":
vueAdmin.clearCache();
vueAdmin.reset();
break;
case "outLogin":
vueAdmin.reset();
break;
}
}
</script>
<style scoped>
.user-bar {
display: flex;
align-items: center;
height: 100%;
}
.user-bar .panel-item {
padding: 0 10px;
cursor: pointer;
height: 100%;
display: flex;
align-items: center;
}
.user-bar .panel-item i {
font-size: 16px;
}
.user-bar .panel-item:hover {
background: rgba(0, 0, 0, 0.1);
}
.dropdown-panel-item {
padding: 0 10px;
cursor: pointer;
height: 100%;
display: flex;
align-items: center;
}
.dropdown-panel-item:hover {
background: rgba(0, 0, 0, 0.1);
}
.user-bar .user-avatar {
height: 49px;
display: flex;
align-items: center;
}
.user-bar .user-avatar label {
display: inline-block;
margin-left: 5px;
font-size: 12px;
cursor: pointer;
}
</style>
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-41
View File
@@ -1,41 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"noLib": false,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"strictFunctionTypes": false,
"jsx": "preserve",
"baseUrl": ".",
"allowJs": true,
"sourceMap": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"experimentalDecorators": true,
"lib": ["dom", "esnext"],
"noImplicitAny": false,
"skipLibCheck": true,
"types": ["vite/client"],
"removeComments": true,
"paths": {
"@/*": ["src/*"],
"@shrimp/*": ["../../../src/*"],
"#/*": ["types/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue",
"types/**/*.d.ts",
"types/**/*.ts",
"vite.config.ts"
],
"exclude": ["node_modules", "dist", "**/*.js"]
}
@@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
-36
View File
@@ -1,36 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import * as path from "path";
// shrimp-server/examples/basic-admin/web/vite.config.ts
// 这是 examples 临时演示前端,@shrimp 直接指向同仓 src/
export default defineConfig({
base: '/',
resolve: {
alias:
[
{
find: '@',
replacement: path.resolve(__dirname, './src')
},
{
find: '@shrimp',
replacement: path.resolve(__dirname, '../../../src')
},
]
},
plugins: [vue({ reactivityTransform: true })],
server: {
port: 5173,
proxy: {
"/admin-api": {
target: "http://localhost:3100",
changeOrigin: true,
},
"/api": {
target: "http://localhost:3100",
changeOrigin: true,
},
}
}
})
+294 -398
View File
File diff suppressed because it is too large Load Diff
+28 -22
View File
@@ -1,50 +1,56 @@
{
"name": "shrimp-server",
"version": "0.2.0",
"private": true,
"version": "0.8.0",
"description": "Shrimp 框架后端 — 声明式字段驱动的 Node.js 管理后台框架(Express + Mongoose + JWT)",
"main": "src/index.ts",
"types": "src/index.ts",
"main": "dist/main/index.js",
"types": "dist/main/index.d.ts",
"exports": {
".": {
"types": "./src/core.ts",
"default": "./src/core.ts"
},
"./server": {
"types": "./src/server.ts",
"default": "./src/server.ts"
"types": "./dist/main/index.d.ts",
"default": "./dist/main/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"src",
"README.md"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"postbuild": "node scripts/check-admin-contract.mjs",
"prepare": "tsc -p tsconfig.build.json",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"demo": "tsx examples/basic-admin/index.ts"
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"express": "^4 || ^5",
"mongoose": "^6 || ^7 || ^8",
"bcrypt": "^5 || ^6",
"express": "^5",
"jsonwebtoken": "^8 || ^9",
"bcrypt": "^5 || ^6"
"mongoose": "^6 || ^7 || ^8"
},
"peerDependenciesMeta": {
"express": { "optional": true },
"mongoose": { "optional": true },
"jsonwebtoken": { "optional": true },
"bcrypt": { "optional": true }
"express": {
"optional": true
},
"mongoose": {
"optional": true
},
"jsonwebtoken": {
"optional": true
},
"bcrypt": {
"optional": true
}
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.0",
"@types/bcrypt": "^5.0.0",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^20.0.0",
"bcrypt": "^5.1.1",
"express": "^4.19.0",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.0.0",
"tsx": "^4.7.0",
+31
View File
@@ -0,0 +1,31 @@
/**
* build 后把契约同步给同级的 shrimp-admin。
*
* 契约的真身在这边,shrimp-admin 那份是派生物——所以是单向覆盖,跟 codegen 一个性质,
* 不是"两份文件互相对齐"。admin 那份的注释头是它自己的(声明自己是副本),同步时保留。
*
* 只在本地开发时生效:找不到同级 shrimp-admin(CI、别人只 clone 了这个仓库)就跳过。
* 挂在 postbuild 而不是 prepare:消费侧从 git 装本包时跑的是 prepare,不该被这事打扰。
*
* 为什么要自动做:上次靠人工复制,漂了一整代没人发现——
* 副本停在旧的 json 类型、缺 object/array/fields/item/parse/refs,
* 而前端对未注册类型静默回落到 string,漂移不报错、只悄悄渲染错。
*/
import { existsSync } from "node:fs"
import { execFileSync } from "node:child_process"
import { fileURLToPath } from "node:url"
const syncer = fileURLToPath(new URL("../../shrimp-admin/scripts/check-contract.mjs", import.meta.url))
if (!existsSync(syncer)) {
console.log("skip: 同级没有 shrimp-admin,契约副本无从同步")
process.exit(0)
}
try {
execFileSync(process.execPath, [syncer, "--fix"], { stdio: "inherit" })
} catch {
// 同步脚本自己挂了(权限、文件被占)。契约漂着不能当没事发生,让 build 失败。
console.error("❌ 契约副本同步失败,见上方输出")
process.exit(1)
}
+21 -3
View File
@@ -1,6 +1,16 @@
/**
* Shrimp 前后端共用类型定义
* 定义错误码、协议、通用数据格式
* Shrimp 类型契约 — 前后端 wire protocol 定义
*
* 这份是契约本身。shrimp-admin/src/IShrimpType.ts 是它的副本,
* 由本仓库 postbuild 自动覆盖过去(scripts/check-admin-contract.mjs),不用手工同步。
*
* 改动此文件 = 改 wire protocol,所以:
* 1. 发版升 package version(0.x 里破坏性改动升 minor),消费侧按 tag 钉包
* 2. 通知消费侧对齐
*
* 这里原本另有一个 @contractVersion 轴,删了:它从 1.0.0 起没动过,
* 期间契约丢了 json、多了 object/array/fields/item/parse/refs,数字全程说谎。
* 副本新不新改由字节比对回答(shrimp-admin 的 npm run check:contract)。
*/
/** 业务错误码 */
@@ -229,8 +239,9 @@ export const ShrimpFieldTypes = {
Textarea: "textarea" as ShrimpFieldType,
Password: "password" as ShrimpFieldType,
Relation: "relation" as ShrimpFieldType,
Json: "json" as ShrimpFieldType,
Tab: "tab" as ShrimpFieldType,
Object: "object" as ShrimpFieldType,
Array: "array" as ShrimpFieldType,
}
/** 字段可编辑状态 */
@@ -270,6 +281,12 @@ export interface IFieldConfig {
rules?: IFieldRule[] // 通用验证规则(前后端共用)
/** tab 类型专用:每个 type 对应的子字段配置 */
tabs?: { key: any; label: ILabel; fields: IFieldConfig[] }[]
/** object 类型专用:嵌套字段组 */
fields?: IFieldConfig[]
/** array 类型专用:每个元素的字段配置(递归 IFieldConfig;元素无需 key,label 用作每项标题) */
item?: IFieldConfig
/** tab 类型专用:import 反序列化(把字符串还原成对象,如 xlsx 导入),默认 JSON.parse */
parse?: (text: string) => any
}
/** 通用验证规则(前后端共用) */
@@ -306,6 +323,7 @@ export interface IPageResult<T> {
page: number // 当前页码
pageSize: number // 每页数量
totalPages: number // 总页数
refs?: Record<string, Record<string, any>> // relation 显示名字典: { moduleKey: { id: 显示名 } },前端显示时查名,替代逐行 _label
}
// ============== 数据源查询协议 ==============
+1 -1
View File
@@ -1,4 +1,4 @@
import { ShrimpCrud } from './Shrimp'
import type { ShrimpCrud } from './Shrimp/ShrimpCrud'
import { IShrimpQuery, IShrimpCondition, IShrimpPopulate, IPageResult, isShrimpCondition } from './IShrimpType'
export class MemoryShrimpCrud<T extends { _id: string }> implements ShrimpCrud<T> {
+51
View File
@@ -0,0 +1,51 @@
/**
* 密码哈希抽象 — 把 bcrypt 从 Shrimp 核心彻底剥离
*
* 默认仍用 bcrypt(行业标准,Shrimp 大多数场景都需要),但通过抽象接口
* 让消费方可以:
* - 完全不装 bcrypt(走 plainHasher / 自己写一个 argon2 / scrypt 等)
* - 在测试里跳过哈希计算开销
* - 接入已有的密码系统(SHA256 + salt 之类)
*
* 配置方式:
* import { Shrimp, plainHasher } from "shrimp-server"
* Shrimp.passwordHasher = plainHasher // 或自己实现的 IPasswordHasher
*/
export interface IPasswordHasher {
/** 把明文密码加密成存储字符串 */
hash(plain: string): Promise<string>
/** 校验明文密码是否匹配已存储的哈希 */
verify(plain: string, hashed: string): Promise<boolean>
}
/**
* 默认实现 — bcrypt
*
* 通过动态 import 加载,确保只有真正调用 hash/verify 时才 require('bcrypt')。
* 这样消费方:不创建 Password 字段 + 不登录的话,可以完全不装 bcrypt。
*/
export const bcryptHasher: IPasswordHasher = {
async hash(plain: string): Promise<string> {
const bcrypt = await import("bcrypt")
return bcrypt.hash(plain, 10)
},
async verify(plain: string, hashed: string): Promise<boolean> {
const bcrypt = await import("bcrypt")
return bcrypt.compare(plain, hashed)
},
}
/**
* 明文实现 — 仅用于测试 / 开发环境 / 自己已经在上层做了加密
*
* **生产环境严禁使用**。存储明文密码的后果你懂的。
*/
export const plainHasher: IPasswordHasher = {
async hash(plain: string): Promise<string> {
return plain
},
async verify(plain: string, hashed: string): Promise<boolean> {
return plain === hashed
},
}
-875
View File
@@ -1,875 +0,0 @@
/**
* Shrimp
* 后台管理系统快速开发框架
*/
import { ShrimpCode, IShrimpResponse, ITableCrudProto, IModuleInfo, IUser, IRole, WithId, IRoleProto, IPageQuery, IPageResult, IFieldConfig, IFieldRule, IFieldTypeHandler, IShrimpQuery, IShrimpCondition, IShrimpPopulate, ShrimpConfig, IShrimpOperator, IMenuItem, ShrimpError, ShrimpFieldTypes, ILabel } from "./IShrimpType"
import { AsyncLocalStorage } from 'async_hooks'
// 懒加载 bcrypt: 只有走到 Password 字段 hash 或 verifyPassword 才 import,
// 让纯核心(MemoryShrimpCrud + 业务模块)的消费方可以不装 bcrypt
async function loadBcrypt(): Promise<typeof import("bcrypt")> {
return await import("bcrypt")
}
export class Shrimp {
root: Shrimp = this
modules: Map<string, ShrimpModule> = new Map()
/** 全局 CRUD 注册表,用于 MongoShrimpCrud 自动解析 relation */
crudRegistry: Map<string, ShrimpCrud<any>> = new Map()
/** 请求级操作者上下文,通过 AsyncLocalStorage 避免并发覆盖 */
static asyncLocalStorage = new AsyncLocalStorage<IShrimpOperator | null>()
get operator(): IShrimpOperator | null {
return Shrimp.asyncLocalStorage.getStore() ?? null
}
async call(path: string[], ...args: any[]): Promise<any> {
let module = this.modules.get(path[0])
if (module == null) {
throw new Error(`Module "${path[0]}" not found`)
}
return await module.call(path.slice(1), ...args)
}
checkEvent(powers: Set<string>, path: string[]): boolean {
let module = this.modules.get(path[0])
if (module == null) return false
return module.checkEvent(powers, path.slice(1))
}
getModuleInfo(powers: Set<string>): IModuleInfo[] {
const list: IModuleInfo[] = []
this.collectModuleInfo(this.modules, powers, undefined, list)
return list
}
private collectModuleInfo(modules: Map<string, ShrimpModule>, powers: Set<string>, parent: string | undefined, out: IModuleInfo[]) {
modules.forEach(module => {
if (!powers.has(module.power.key)) return
const info: IModuleInfo = {
moduleKey: module.moduleKey,
events: module.getEventInfo(powers),
label: module.label,
fields: module instanceof ShrimpTableModule ? module.getFields() : [],
}
if (module.icon) info.icon = module.icon
if (parent) info.parent = parent
out.push(info)
if (module.modules.size > 0) {
this.collectModuleInfo(module.modules, powers, module.moduleKey, out)
}
})
}
/** 成功响应 */
success<T>(data: T, tip?: ILabel, log?: string): IShrimpResponse<T> {
const r: IShrimpResponse<T> = { code: ShrimpCode.OK, data }
if (tip) r.tip = tip
if (log) r.log = log
return r
}
/** 失败响应 */
fail<T = any>(code: ShrimpCode, tip?: ILabel, log?: string): IShrimpResponse<T> {
const r: IShrimpResponse<T> = { code }
if (tip) r.tip = tip
if (log) r.log = log
return r
}
async allPower(): Promise<{ key: string; label: ILabel }[]> {
const powers: { key: string; label: ILabel }[] = []
const collect = (module: any) => {
// 模块权限
powers.push({ key: module.power.key, label: module.power.label })
// 事件权限(children 已有 key 和 label)
powers.push(...module.power.children)
// 递归子模块
module.modules.forEach(collect)
}
this.root.modules.forEach(collect)
// 去重
const map = new Map<string, ILabel>()
powers.forEach(p => map.set(p.key, p.label))
return Array.from(map.entries()).map(([key, label]) => ({ key, label }))
}
/** 菜单树,默认平铺所有模块(label 用模块注册时声明的 label),子类可覆盖为分组结构 */
getMenuTree(): IMenuItem[] {
const items: IMenuItem[] = []
this.modules.forEach(module => {
items.push({
name: module.moduleKey,
label: module.label,
moduleKey: module.moduleKey
})
})
return items
}
/** 根据权限过滤菜单树(递归处理 children;children 全部被过滤后,分组节点也一并剔除) */
filterMenuTree(fullMenuTree: IMenuItem[], rolePower: string[]): IMenuItem[] {
const allowedModules = new Set(rolePower.map(p => p.split('.')[0]))
const filter = (nodes: IMenuItem[]): IMenuItem[] => {
const result: IMenuItem[] = []
for (const node of nodes) {
if (node.moduleKey) {
if (allowedModules.has(node.moduleKey)) {
result.push(node)
}
continue
}
if (!node.children || node.children.length === 0) {
continue
}
const filteredChildren = filter(node.children)
if (filteredChildren.length === 0) continue
result.push({ ...node, children: filteredChildren })
}
return result
}
return filter(fullMenuTree)
}
}
type ShrimpEventHandler = (...args: any[]) => Promise<any>
export class ShrimpEvent {
constructor(public name: string, public handler: ShrimpEventHandler, public power: RootShrimpPower) {
}
}
export interface IShrimpModuleOptions {
icon?: string // 前端子模块跳转按钮图标(element-plus icon 名)
ownerKey?: string // 子表→父记录归属字段名(子模块专用,语义: 主人/上级/归属)。前端据此自动过滤和回填默认值
}
export class ShrimpModule<P extends Record<string, any> = Record<string, any>> {
power: RootShrimpPower
root: Shrimp
modules: Map<string, ShrimpModule> = new Map()
events: Map<string, ShrimpEvent> = new Map()
icon?: string
ownerKey?: string
constructor(public parent: Shrimp | ShrimpModule, public moduleKey: string, public label: ILabel, power?: RootShrimpPower, options?: IShrimpModuleOptions) {
if (power == null) {
if (parent instanceof Shrimp) {
power = new RootShrimpPower(moduleKey, label)
}
else {
power = new ShrimpPower(parent.power, moduleKey, label)
}
}
this.power = power
this.icon = options?.icon
this.ownerKey = options?.ownerKey
parent.modules.set(this.moduleKey, this)
this.root = parent.root
}
addEvent<K extends keyof P & string>(name: K, label: ILabel): this
addEvent<K extends keyof P & string>(name: K, power: RootShrimpPower): this
addEvent<K extends keyof P & string>(name: K, powerOrLabel: RootShrimpPower | ILabel) {
const handler = (this as any)[name]
if (typeof handler !== 'function') {
throw new Error(`Method "${name}" not found on ${this.moduleKey}`)
}
let power: RootShrimpPower
if (powerOrLabel instanceof RootShrimpPower) {
power = powerOrLabel
} else {
power = new ShrimpPower(this.power, name, powerOrLabel)
}
this.events.set(name, new ShrimpEvent(name, handler.bind(this), power))
return this
}
async call(path: string[], ...args: any[]): Promise<any> {
if (path.length > 1) {
let module = this.modules.get(path[0])
if (module == null) {
throw new Error(`Module "${path[0]}" not found`)
}
return await module.call(path.slice(1), ...args)
} else {
const event = this.events.get(path[0])
if (event == null) {
throw new Error(`Event "${path[0]}" not found`)
}
return await event.handler(...args)
}
}
getEventInfo(powers: Set<string>): string[] {
let events: string[] = []
this.events.forEach(event => {
if (powers.has(event.power.key)) {
events.push(event.name)
}
})
return events
}
checkEvent(powers: Set<string>, path: string[]): boolean {
if (path.length > 1) {
let module = this.modules.get(path[0])
if (module == null) return false
return module.checkEvent(powers, path.slice(1))
} else {
const event = this.events.get(path[0])
if (event == null) return false
return powers.has(event.power.key)
}
}
}
export class RootShrimpPower {
children: ShrimpPower[] = []
constructor(public powerKey: string, public label: ILabel) {
}
get key(): string {
return this.powerKey
}
}
export class ShrimpPower extends RootShrimpPower {
constructor(public parent: RootShrimpPower, public powerKey: string, public label: ILabel) {
super(powerKey, label)
parent.children.push(this)
}
get key(): string {
return this.parent.key + "." + this.powerKey
}
}
// CRUD 接口
export interface ShrimpCrud<T> {
findOne(query?: any): Promise<T | null>
list(query?: IShrimpQuery): Promise<IPageResult<T>>
get(id: string): Promise<T | null>
add(item: Omit<T, '_id'>): Promise<T>
edit(id: string, item: Partial<T>): Promise<T | null>
delete(id: string): Promise<boolean>
clear(): Promise<boolean>
/** 为记录填充关联 _label 字段 */
populateItems(items: any[], populates: IShrimpPopulate[], crudRegistry?: Map<string, ShrimpCrud<any>>): Promise<void>
}
/** 内置字段类型处理器 */
export const defaultTypeHandlers: Record<string, IFieldTypeHandler> = {
[ShrimpFieldTypes.Password]: {
reqFilter: async (value) => {
if (typeof value === 'string' && value.length > 0) {
const bcrypt = await loadBcrypt()
return bcrypt.hash(value, 10)
}
return value
},
resFilter: () => "***",
},
[ShrimpFieldTypes.Date]: {
defaultValue: () => Date.now(),
},
[ShrimpFieldTypes.Number]: {
defaultValue: 0,
},
[ShrimpFieldTypes.Boolean]: {
defaultValue: false,
},
[ShrimpFieldTypes.Json]: {
reqFilter: (value) => {
if (value == null || value === "") return undefined
if (typeof value === "string") {
try {
return JSON.parse(value)
} catch {
throw new ShrimpError({ t: 'shrimp.invalidJson' })
}
}
return value
},
resFilter: (value) => {
if (value == null) return value
if (typeof value === "object") {
return JSON.stringify(value)
}
return value
},
},
[ShrimpFieldTypes.Tab]: {
reqFilter: (value) => {
if (value == null || value === "") return undefined
if (typeof value === "string") {
try {
return JSON.parse(value)
} catch {
throw new ShrimpError({ t: 'shrimp.invalidJson' })
}
}
return value
},
resFilter: (value) => {
if (value == null) return value
if (typeof value === "object") {
return JSON.stringify(value)
}
return value
},
},
}
export class ShrimpTableModule<T, P extends ITableCrudProto<T> = ITableCrudProto<T>> extends ShrimpModule<P> implements ITableCrudProto<T> {
fields: IFieldConfig[] = []
constructor(public parent: Shrimp | ShrimpModule, moduleKey: string, public dataSource: ShrimpCrud<T>, fields?: IFieldConfig[], label?: ILabel, power?: RootShrimpPower, options?: IShrimpModuleOptions) {
super(parent, moduleKey, label || { t: moduleKey }, power, options)
if (fields) {
this.fields = fields
}
// 自动注册 CRUD 到 root.crudRegistry,populateItems 据此解析 relation
this.root.crudRegistry.set(moduleKey, dataSource)
}
getFields(): IFieldConfig[] {
return this.fields
}
static typeHandlers: Map<string, IFieldTypeHandler> = new Map(Object.entries(defaultTypeHandlers))
static registerTypeHandler(type: string, handler: IFieldTypeHandler) {
this.typeHandlers.set(type, handler)
}
private getTypeHandler(field: IFieldConfig): IFieldTypeHandler | undefined {
return ShrimpTableModule.typeHandlers.get(field.type)
}
/** 前置验证:基于 IFieldConfig 的通用校验 */
private async validateField(field: IFieldConfig, value: any, item: Record<string, any>): Promise<ILabel | null> {
const isEmpty = value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)
// 1. required
if (field.required && isEmpty) {
return field.rules?.find(r => r.required)?.message || { t: 'shrimp.fieldRequired', params: { field: field.label } }
}
if (isEmpty) return null
// 2. options 校验
if (Array.isArray(field.options)) {
const validValues = new Set(field.options.map(o => o.value))
const values = field.multiple && Array.isArray(value) ? value : [value]
for (const v of values) {
if (!validValues.has(v)) {
return field.rules?.find(r => r.message)?.message || { t: 'shrimp.fieldInvalid', params: { field: field.label } }
}
}
}
// 3. type 基础校验
switch (field.type) {
case ShrimpFieldTypes.String:
if (typeof value !== 'string') return { t: 'shrimp.fieldTypeError', params: { field: field.label } }
break
case ShrimpFieldTypes.Number:
if (typeof value !== 'number' || Number.isNaN(value)) return { t: 'shrimp.fieldTypeError', params: { field: field.label } }
break
case ShrimpFieldTypes.Boolean:
if (typeof value !== 'boolean') return { t: 'shrimp.fieldTypeError', params: { field: field.label } }
break
case ShrimpFieldTypes.Date:
if (typeof value !== 'number' && !(value instanceof Date) && isNaN(Date.parse(value))) {
return { t: 'shrimp.fieldTypeError', params: { field: field.label } }
}
break
}
// 4. rules 校验
if (field.rules) {
for (const rule of field.rules) {
const msg = rule.message || { t: 'shrimp.fieldInvalid', params: { field: field.label } }
if (rule.min !== undefined) {
if (typeof value === 'number' && value < rule.min) return { t: 'shrimp.fieldMin', params: { field: field.label, min: rule.min } }
if (typeof value === 'string' && value.length < rule.min) return { t: 'shrimp.fieldMinLength', params: { field: field.label, min: rule.min } }
if (Array.isArray(value) && value.length < rule.min) return { t: 'shrimp.fieldMinLength', params: { field: field.label, min: rule.min } }
}
if (rule.max !== undefined) {
if (typeof value === 'number' && value > rule.max) return { t: 'shrimp.fieldMax', params: { field: field.label, max: rule.max } }
if (typeof value === 'string' && value.length > rule.max) return { t: 'shrimp.fieldMaxLength', params: { field: field.label, max: rule.max } }
if (Array.isArray(value) && value.length > rule.max) return { t: 'shrimp.fieldMaxLength', params: { field: field.label, max: rule.max } }
}
if (rule.pattern) {
const regex = new RegExp(rule.pattern)
if (!regex.test(String(value))) return msg
}
}
}
return null
}
/** 准备新增数据:过滤 readonly → 默认值 → reqFilter → validate */
private async prepareAddItem(item: Record<string, any>): Promise<Record<string, any>> {
const result = { ...item }
for (const field of this.fields) {
if (field.editable === "readonly") {
delete result[field.key]
continue
}
const handler = this.getTypeHandler(field)
if (result[field.key] === undefined) {
const dv = field.defaultValue !== undefined ? field.defaultValue : handler?.defaultValue
if (dv !== undefined) {
result[field.key] = typeof dv === 'function' ? (dv as () => any)() : dv
}
}
if (handler?.reqFilter && result[field.key] !== undefined) {
result[field.key] = await handler.reqFilter(result[field.key], field, result)
}
const fieldErr = await this.validateField(field, result[field.key], result)
if (fieldErr) throw new ShrimpError(fieldErr)
if (handler?.validate && result[field.key] !== undefined) {
const err = await handler.validate(result[field.key], field, result)
if (err) throw new ShrimpError(err)
}
}
return result
}
/** 准备编辑数据:过滤 readonly/addOnly → reqFilter → validate */
private async prepareEditItem(item: Record<string, any>): Promise<Record<string, any>> {
const result = { ...item }
for (const field of this.fields) {
if (field.editable === "readonly" || field.editable === "addOnly") {
delete result[field.key]
continue
}
// 编辑时 undefined 表示未修改,跳过校验并从结果中移除
if (result[field.key] === undefined) {
delete result[field.key]
continue
}
const handler = this.getTypeHandler(field)
if (handler?.reqFilter) {
result[field.key] = await handler.reqFilter(result[field.key], field, result)
}
const fieldErr = await this.validateField(field, result[field.key], result)
if (fieldErr) throw new ShrimpError(fieldErr)
if (handler?.validate) {
const err = await handler.validate(result[field.key], field, result)
if (err) throw new ShrimpError(err)
}
}
return result
}
/** 处理响应数据:resFilter */
private async processResItem(item: Record<string, any>): Promise<Record<string, any>> {
const result = { ...item }
for (const field of this.fields) {
const handler = this.getTypeHandler(field)
if (handler?.resFilter && result[field.key] !== undefined) {
result[field.key] = await handler.resFilter(result[field.key], field, result)
}
}
return result
}
/** 填充关联 _label 并应用 resFilter(多条) */
private async prepareResponseItems(items: any[]): Promise<any[]> {
if (items.length === 0) return items
const populates = this.getRelationPopulates()
if (populates.length > 0 && this.dataSource.populateItems) {
await this.dataSource.populateItems(items, populates, this.root.crudRegistry)
}
return Promise.all(items.map(item => this.processResItem(item)))
}
/** 填充关联 _label 并应用 resFilter(单条) */
private async prepareResponseItem(item: any): Promise<any> {
if (!item) return item
const populates = this.getRelationPopulates()
if (populates.length > 0 && this.dataSource.populateItems) {
await this.dataSource.populateItems([item], populates, this.root.crudRegistry)
}
return this.processResItem(item)
}
private excludeKeys(item: any, keys: string[]): any {
if (keys.length === 0) return item
const result = { ...item }
for (const key of keys) {
delete result[key]
}
return result
}
private filterHidden(item: any): any {
return this.excludeKeys(item, this.fields.filter(f => f.hidden).map(f => f.key))
}
private getRelationFields(): IFieldConfig[] {
return this.fields.filter(f => f.type === "relation" && f.relation)
}
private getRelationPopulates(): IShrimpPopulate[] {
return this.getRelationFields().map(f => ({
key: f.key,
moduleKey: f.relation!.moduleKey,
labelKey: f.relation!.labelKey,
}))
}
/** 将前端 IPageQuery 翻译为数据源 IShrimpQuery */
private buildQuery(query?: IPageQuery & any): IShrimpQuery {
const { keyword, page, pageSize, sortBy, sortOrder, filter, ownerId } = query || {}
const p = page || 1
const ps = pageSize || ShrimpConfig.defaultPageSize
const shrimpQuery: IShrimpQuery = {
offset: (p - 1) * ps,
limit: ps,
}
// filter → where
if (filter && typeof filter === 'object') {
const filterableFields = new Map(this.fields.filter(f => f.filterable).map(f => [f.key, f]))
const stringLikeTypes = new Set(['string', 'textarea'])
const where: Record<string, IShrimpCondition> = {}
for (const [key, value] of Object.entries(filter)) {
const field = filterableFields.get(key)
if (!field) continue
if (value == null) continue
if (Array.isArray(value) && value.length === 0) continue
if (typeof value === 'string' && value === '') continue
const obj = value as any
if (typeof obj === 'object' && !Array.isArray(obj) && obj.min === undefined && obj.max === undefined) continue
if (Array.isArray(value)) {
where[key] = { in: value }
} else if (typeof value === 'object') {
const cond: IShrimpCondition = {}
if (obj.min !== undefined) cond.gte = obj.min
if (obj.max !== undefined) cond.lte = obj.max
where[key] = cond
} else if (typeof value === 'boolean') {
where[key] = { eq: value }
} else if (typeof value === 'string' && stringLikeTypes.has(field.type)) {
where[key] = { like: value }
} else {
where[key] = { eq: value }
}
}
if (Object.keys(where).length > 0) shrimpQuery.where = where
}
// 子表场景: ownerId + 模块 ownerKey 配置 → 强制注入过滤(用户无法绕过)
if (ownerId != null && ownerId !== '' && this.ownerKey) {
shrimpQuery.where = shrimpQuery.where || {}
shrimpQuery.where[this.ownerKey] = { eq: ownerId }
}
// keyword → orWhere(仅对字符串类型字段做模糊匹配)
if (keyword) {
const stringLikeTypes = new Set(['string', 'textarea'])
const searchFields = this.fields
.filter(f => f.searchable && stringLikeTypes.has(f.type))
.map(f => f.key)
if (searchFields.length > 0) {
shrimpQuery.orWhere = searchFields.map(key => ({ [key]: { like: keyword } }))
}
}
// 排序
if (sortBy) {
shrimpQuery.order = [[sortBy, sortOrder === 'desc' ? 'desc' : 'asc']]
}
return shrimpQuery
}
async list(query?: IPageQuery & any): Promise<IPageResult<T>> {
const shrimpQuery = this.buildQuery(query)
const result = await this.dataSource.list(shrimpQuery)
result.list = await this.prepareResponseItems(result.list)
result.list = result.list.map(item => this.filterHidden(item))
return result
}
async get(id: string): Promise<T | null> {
const item = await this.dataSource.get(id)
if (item) {
const processed = await this.prepareResponseItem(item)
return this.filterHidden(processed) as T
}
return item
}
async add(item: Omit<T, '_id'>, ownerId?: string): Promise<T> {
if (ownerId && this.ownerKey) {
(item as any)[this.ownerKey] = ownerId
}
const prepared = await this.prepareAddItem(item as any)
const result = await this.dataSource.add(prepared as Omit<T, '_id'>)
const processed = await this.prepareResponseItem(result)
return this.filterHidden(processed) as T
}
async edit(id: string, item: Partial<T>): Promise<T | null> {
const prepared = await this.prepareEditItem(item as any)
const result = await this.dataSource.edit(id, prepared as Partial<T>)
if (result) {
const processed = await this.prepareResponseItem(result)
return this.filterHidden(processed) as T
}
return result
}
async delete(id: string): Promise<boolean> {
return await this.dataSource.delete(id)
}
async clear(query?: IPageQuery & any): Promise<boolean> {
const hasFilter = query && (
(query.keyword) ||
(query.filter && typeof query.filter === 'object' && Object.keys(query.filter).length > 0)
)
if (hasFilter) {
const shrimpQuery = this.buildQuery(query)
delete shrimpQuery.offset
delete shrimpQuery.limit
const result = await this.dataSource.list(shrimpQuery)
for (const item of result.list as any[]) {
await this.dataSource.delete(item._id)
}
return true
}
return await this.dataSource.clear()
}
async export(query?: IPageQuery & any): Promise<T[]> {
const shrimpQuery = this.buildQuery(query)
delete shrimpQuery.offset
delete shrimpQuery.limit
const result = await this.dataSource.list(shrimpQuery)
result.list = await this.prepareResponseItems(result.list)
return result.list.map(item => this.filterHidden(item))
}
async import(data?: T[], ownerId?: string): Promise<void> {
if (!data || data.length === 0) return
for (const raw of data) {
const item = { ...raw } as Record<string, any>
if (ownerId && this.ownerKey) item[this.ownerKey] = ownerId
const id = item._id ? String(item._id) : undefined
if (id) {
// 有 _id 且数据库里已存在 → 编辑覆盖
const existing = await this.dataSource.get(id)
if (existing) {
const prepared = await this.prepareEditItem(item)
await this.dataSource.edit(id, prepared as Partial<T>)
continue
}
}
// 无 _id,或 _id 在库里找不到 → 当作新增
const prepared = await this.prepareAddItem(item)
await this.dataSource.add(prepared as Omit<T, '_id'>)
}
}
initCrud() {
return this
.addEvent("list", this.power)
.addEvent("get", this.power)
.addEvent("add", { t: "power.add" })
.addEvent("edit", { t: "power.edit" })
.addEvent("delete", { t: "power.delete" })
}
}
/** 用户模块扩展 - 添加登录和获取用户信息功能 */
const userFields: IFieldConfig[] = [
{ key: "username", label: { t: "user.username" }, type: "string", required: true, searchable: true, editable: "addOnly" },
{ key: "password", label: { t: "user.password" }, type: "password", tableHide: true },
{ key: "nickname", label: { t: "user.nickname" }, type: "string", searchable: true },
{ key: "roleId", label: { t: "user.roleId" }, type: "relation", relation: { moduleKey: "role", labelKey: "name" }, filterable: true, multiple: true, defaultValue: [] },
// { key: "status", label: { t: "user.status" }, type: "select", options: [{ label: { t: "base.disabled" }, value: 0 }, { label: { t: "base.enabled" }, value: 1 }], filterable: true, defaultValue: 1 },
{ key: "active", label: { t: "user.active" }, type: "boolean", filterable: true, defaultValue: true },
{ key: "createdAt", label: { t: "user.createdAt" }, type: "date", editable: "readonly", sortable: true, filterable: true },
{ key: "updatedAt", label: { t: "user.updatedAt" }, type: "date", editable: "readonly", sortable: true },
]
export class UserModule extends ShrimpTableModule<WithId<IUser>> {
constructor(public parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud<WithId<IUser>>, fields?: IFieldConfig[]) {
super(parent, "user", dataSource, fields || userFields, { t: "user.menuManage" })
this.initCrud()
}
/** 校验密码,子类可覆盖 */
protected async verifyPassword(storedPassword: string, plainPassword: string): Promise<boolean> {
const bcrypt = await loadBcrypt()
return bcrypt.compare(plainPassword, storedPassword)
}
/** 从用户对象提取角色 ID 列表,子类可覆盖 */
protected getUserRoleIds(user: WithId<IUser>): string[] {
return Array.isArray(user.roleId) ? user.roleId.map(String) : user.roleId ? [String(user.roleId)] : []
}
/** 用户登录 */
async getUser(username: string, password: string): Promise<WithId<IUser>> {
const user = await this.dataSource.findOne({ username, active: true })
if (!user || !(await this.verifyPassword(user.password, password))) {
throw new ShrimpError({ t: 'shrimp.loginFailed' })
}
return user
}
/** 获取当前用户信息 */
async getModule(userId: string, roleModule: RoleModule): Promise<IModuleInfo[]> {
const user = await this.dataSource.get(userId)
if (!user) {
throw new ShrimpError({ t: 'shrimp.userNotFound' })
}
const roleIds = this.getUserRoleIds(user)
const roles = await Promise.all(roleIds.map(id => roleModule.dataSource.get(id)))
const isSuperAdmin = roles.some(r => r?.isSuperAdmin)
const powerKeys = isSuperAdmin
? (await this.root.allPower()).map(p => p.key)
: roles.flatMap(r => r?.powers || [])
return this.root.getModuleInfo(new Set(powerKeys))
}
/** 覆盖编辑 - 禁止禁用自己、禁止修改自己的角色 */
async edit(id: string, item: Partial<IUser>): Promise<WithId<IUser> | null> {
const op = this.root.operator
if (op && String(op.userId) === String(id)) {
if (item.active === false) {
throw new ShrimpError({ t: 'shrimp.cannotDisableSelf' })
}
if (item.roleId != null) {
const newRoleIds = Array.isArray(item.roleId) ? item.roleId.map(String) : [String(item.roleId)]
const oldUser = await this.dataSource.get(id)
const oldRoleIds = oldUser ? this.getUserRoleIds(oldUser) : []
if (JSON.stringify([...newRoleIds].sort()) !== JSON.stringify([...oldRoleIds].sort())) {
throw new ShrimpError({ t: 'shrimp.cannotChangeOwnRole' })
}
}
}
return super.edit(id, item)
}
/** 覆盖删除 - 禁止删除自己 */
async delete(id: string): Promise<boolean> {
const op = this.root.operator
if (op && String(op.userId) === String(id)) {
throw new ShrimpError({ t: 'shrimp.cannotDeleteSelf' })
}
return super.delete(id)
}
/** 覆盖清空 - 禁止清空所有用户 */
async clear(): Promise<boolean> {
if (this.root.operator) {
throw new ShrimpError({ t: 'shrimp.cannotClearAllUsers' })
}
return super.clear()
}
}
/** 角色模块 */
const roleFields: IFieldConfig[] = [
{ key: "name", label: { t: "role.name" }, type: "string", required: true, searchable: true },
{ key: "description", label: { t: "role.description" }, type: "textarea" },
{ key: "isSuperAdmin", label: { t: "role.isSuperAdmin" }, type: "boolean" },
{ key: "powers", label: { t: "role.powers" }, type: "string" },
{ key: "createdAt", label: { t: "role.createdAt" }, type: "date", editable: "readonly", sortable: true },
{ key: "updatedAt", label: { t: "role.updatedAt" }, type: "date", editable: "readonly", sortable: true },
]
export class RoleModule extends ShrimpTableModule<WithId<IRole>, IRoleProto> {
constructor(public parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud<WithId<IRole>>, fields?: IFieldConfig[]) {
super(parent, "role", dataSource, fields || roleFields, { t: "role.menuManage" })
this.initCrud()
this.addEvent("clear", { t: "power.clear" })
this.addEvent("export", { t: "power.export" })
this.addEvent("import", { t: "power.import" })
this.addEvent("allPower", this.power)
}
async allPower(): Promise<{ key: string; label: ILabel }[]> {
return this.root.allPower()
}
/** 从操作者上下文提取角色 ID 列表,子类可覆盖 */
protected getOperatorRoleIds(op: IShrimpOperator): string[] {
return Array.isArray(op.roleId) ? op.roleId.map(String) : op.roleId ? [String(op.roleId)] : []
}
/** 覆盖编辑 - 禁止编辑自己的任一角色(防止锁死自己) */
async edit(id: string, item: Partial<IRole>): Promise<WithId<IRole> | null> {
const op = this.root.operator
if (op) {
const opRoleIds = this.getOperatorRoleIds(op)
if (opRoleIds.includes(String(id))) {
throw new ShrimpError({ t: 'shrimp.cannotEditOwnRole' })
}
}
return super.edit(id, item)
}
/** 填充超级管理员权限(展示用) */
private async fillSuperAdminPowers(role: WithId<IRole> | null): Promise<WithId<IRole> | null> {
if (role && role.isSuperAdmin) {
const allPowers = await this.allPower()
role.powers = allPowers.map(p => p.key)
}
return role
}
async list(query?: IPageQuery & any): Promise<IPageResult<WithId<IRole>>> {
const result = await super.list(query)
for (const item of result.list) {
if (item.isSuperAdmin) {
const allPowers = await this.allPower()
item.powers = allPowers.map(p => p.key)
}
}
return result
}
async get(id: string): Promise<WithId<IRole> | null> {
const result = await super.get(id)
return this.fillSuperAdminPowers(result)
}
/** 覆盖删除 - 禁止删除自己的任一角色 */
async delete(id: string): Promise<boolean> {
const op = this.root.operator
if (op) {
const opRoleIds = this.getOperatorRoleIds(op)
if (opRoleIds.includes(String(id))) {
throw new ShrimpError({ t: 'shrimp.cannotDeleteOwnRole' })
}
}
return super.delete(id)
}
/** 覆盖清空 - 禁止清空所有角色 */
async clear(): Promise<boolean> {
if (this.root.operator) {
throw new ShrimpError({ t: 'shrimp.cannotClearAllRoles' })
}
return super.clear()
}
}
@@ -2,19 +2,20 @@
* OperationLogModule
* 操作日志子表 - 挂在 userModule 下,记录"某用户做了什么"
* - 默认只记 mutation(add/edit/delete/clear/import) + login
* - args 自动脱敏(password/pwd/token/secret → "***") + JSON.stringify + 截断到 2000 字符
* - args 自动脱敏 + JSON.stringify + 截断到 2000 字符
* - 日志失败不影响主调用
* - 只暴露 list/get,不开 add/edit/delete 防止误改
*/
import { ShrimpModule, ShrimpTableModule, ShrimpCrud } from "./Shrimp"
import { IFieldConfig, IOperationLog, WithId, ILabel } from "../IShrimpType"
import { Shrimp } from "./Shrimp"
import { IFieldConfig, IOperationLog, WithId, ILabel } from "./IShrimpType"
import { ShrimpModule } from "./ShrimpModule"
import { ShrimpTableModule } from "./ShrimpTableModule"
import type { ShrimpCrud } from "./ShrimpCrud"
const SENSITIVE_KEY_RE = /(password|pwd|token|secret)/i
const ARGS_MAX_LEN = 2000
// operatorId 是子表→父记录(user)的外键,纯数据库字段,不进 fields(框架按 ownerKey 自动注入 filter)
const operationLogFields: IFieldConfig[] = [
{ key: "ip", label: { t: "operationLog.ip" }, type: "string", searchable: true, editable: "readonly" },
{ key: "method", label: { t: "operationLog.method" }, type: "string", searchable: true, filterable: true, editable: "readonly" },
@@ -25,21 +26,17 @@ const operationLogFields: IFieldConfig[] = [
]
export class OperationLogModule extends ShrimpTableModule<WithId<IOperationLog>> {
/** 触发记录的事件名集合,可被外部 add/delete 扩展 */
logEvents: Set<string> = new Set(['add', 'edit', 'delete', 'clear', 'import', 'login'])
constructor(parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud<WithId<IOperationLog>>, fields?: IFieldConfig[], label?: ILabel) {
super(parent, "operationLog", dataSource, fields || operationLogFields, label || { t: "user.operationLog" }, undefined, {
icon: "Tickets",
// 声明 operatorId 是父记录(user)的归属字段,框架据此自动做子表过滤
ownerKey: "operatorId",
})
// 只挂只读事件 — 不暴露 add/edit/delete,防止外部改日志
this.addEvent("list", this.power)
this.addEvent("get", this.power)
}
/** 路径最后一段在 logEvents 内,且不是 operationLog 自身的调用(防递归) */
shouldLog(path: string[]): boolean {
if (path.length === 0) return false
if (path.includes('operationLog')) return false
@@ -47,7 +44,6 @@ export class OperationLogModule extends ShrimpTableModule<WithId<IOperationLog>>
return this.logEvents.has(last)
}
/** 深度遍历替换敏感字段值,再 JSON.stringify 并截断 */
sanitizeArgs(args: any[]): string {
const walk = (val: any): any => {
if (val == null) return val
@@ -70,7 +66,6 @@ export class OperationLogModule extends ShrimpTableModule<WithId<IOperationLog>>
return str.length > ARGS_MAX_LEN ? str.substring(0, ARGS_MAX_LEN) + '...' : str
}
/** 写一条日志,内部吞错(日志失败不能搞挂主流程)。operatorId 强制 String 化,避免 ObjectId 与字符串查询不匹配 */
async record(operatorId: any, ip: string, path: string[], args: any[], success: boolean, errorMsg: string = ''): Promise<void> {
try {
await this.dataSource.add({
+126
View File
@@ -0,0 +1,126 @@
import { AsyncLocalStorage } from 'async_hooks'
import {
ShrimpCode, IShrimpResponse, IModuleInfo, IShrimpOperator, IMenuItem, ILabel,
} from "../IShrimpType"
import { IPasswordHasher, bcryptHasher } from '../PasswordHasher'
import type { ShrimpModule } from "./ShrimpModule"
import type { ShrimpCrud } from "./ShrimpCrud"
export class Shrimp {
root: Shrimp = this
modules: Map<string, ShrimpModule> = new Map()
/** 全局 CRUD 注册表,用于自动解析 relation */
crudRegistry: Map<string, ShrimpCrud<any>> = new Map()
/**
* 密码哈希器,默认 bcryptHasher(lazy import bcrypt)。
* 消费方可在启动时替换:`Shrimp.passwordHasher = plainHasher`
*/
static passwordHasher: IPasswordHasher = bcryptHasher
/** 请求级操作者上下文 */
static asyncLocalStorage = new AsyncLocalStorage<IShrimpOperator | null>()
get operator(): IShrimpOperator | null {
return Shrimp.asyncLocalStorage.getStore() ?? null
}
async call(path: string[], ...args: any[]): Promise<any> {
let module = this.modules.get(path[0])
if (module == null) {
throw new Error(`Module "${path[0]}" not found`)
}
return await module.call(path.slice(1), ...args)
}
checkEvent(powers: Set<string>, path: string[]): boolean {
let module = this.modules.get(path[0])
if (module == null) return false
return module.checkEvent(powers, path.slice(1))
}
getModuleInfo(powers: Set<string>): IModuleInfo[] {
const list: IModuleInfo[] = []
this.collectModuleInfo(this.modules, powers, undefined, list)
return list
}
private collectModuleInfo(modules: Map<string, ShrimpModule>, powers: Set<string>, parent: string | undefined, out: IModuleInfo[]) {
modules.forEach(module => {
if (!powers.has(module.power.key)) return
const info: IModuleInfo = {
moduleKey: module.moduleKey,
events: module.getEventInfo(powers),
label: module.label,
fields: module.getFields(),
}
if (module.icon) info.icon = module.icon
if (parent) info.parent = parent
out.push(info)
if (module.modules.size > 0) {
this.collectModuleInfo(module.modules, powers, module.moduleKey, out)
}
})
}
success<T>(data: T, tip?: ILabel, log?: string): IShrimpResponse<T> {
const r: IShrimpResponse<T> = { code: ShrimpCode.OK, data }
if (tip) r.tip = tip
if (log) r.log = log
return r
}
fail<T = any>(code: ShrimpCode, tip?: ILabel, log?: string): IShrimpResponse<T> {
const r: IShrimpResponse<T> = { code }
if (tip) r.tip = tip
if (log) r.log = log
return r
}
async allPower(): Promise<{ key: string; label: ILabel }[]> {
const powers: { key: string; label: ILabel }[] = []
const collect = (module: any) => {
powers.push({ key: module.power.key, label: module.power.label })
powers.push(...module.power.children)
module.modules.forEach(collect)
}
this.root.modules.forEach(collect)
const map = new Map<string, ILabel>()
powers.forEach(p => map.set(p.key, p.label))
return Array.from(map.entries()).map(([key, label]) => ({ key, label }))
}
/** 菜单树,默认平铺所有模块;子类可覆盖为分组结构 */
getMenuTree(): IMenuItem[] {
const items: IMenuItem[] = []
this.modules.forEach(module => {
items.push({
name: module.moduleKey,
label: module.label,
moduleKey: module.moduleKey
})
})
return items
}
/** 根据权限过滤菜单树 */
filterMenuTree(fullMenuTree: IMenuItem[], rolePower: string[]): IMenuItem[] {
const allowedModules = new Set(rolePower.map(p => p.split('.')[0]))
const filter = (nodes: IMenuItem[]): IMenuItem[] => {
const result: IMenuItem[] = []
for (const node of nodes) {
if (node.moduleKey) {
if (allowedModules.has(node.moduleKey)) {
result.push(node)
}
continue
}
if (!node.children || node.children.length === 0) {
continue
}
const filteredChildren = filter(node.children)
if (filteredChildren.length === 0) continue
result.push({ ...node, children: filteredChildren })
}
return result
}
return filter(fullMenuTree)
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { IShrimpQuery, IShrimpPopulate, IPageResult } from "../IShrimpType"
export interface ShrimpCrud<T> {
findOne(query?: any): Promise<T | null>
list(query?: IShrimpQuery): Promise<IPageResult<T>>
get(id: string): Promise<T | null>
add(item: Omit<T, '_id'>): Promise<T>
edit(id: string, item: Partial<T>): Promise<T | null>
delete(id: string): Promise<boolean>
clear(): Promise<boolean>
populateItems(items: any[], populates: IShrimpPopulate[], crudRegistry?: Map<string, ShrimpCrud<any>>): Promise<void>
}
+93
View File
@@ -0,0 +1,93 @@
import type { IFieldConfig, ILabel } from "../IShrimpType"
import { Shrimp } from "./Shrimp"
import { RootShrimpPower, ShrimpPower } from "./ShrimpPower"
type ShrimpEventHandler = (...args: any[]) => Promise<any>
export class ShrimpEvent {
constructor(public name: string, public handler: ShrimpEventHandler, public power: RootShrimpPower) {
}
}
export interface IShrimpModuleOptions {
icon?: string
ownerKey?: string
}
export class ShrimpModule<P extends Record<string, any> = Record<string, any>> {
power: RootShrimpPower
root: Shrimp
modules: Map<string, ShrimpModule> = new Map()
events: Map<string, ShrimpEvent> = new Map()
icon?: string
ownerKey?: string
constructor(public parent: Shrimp | ShrimpModule, public moduleKey: string, public label: ILabel, power?: RootShrimpPower, options?: IShrimpModuleOptions) {
if (power == null) {
if (parent instanceof Shrimp) {
power = new RootShrimpPower(moduleKey, label)
}
else {
power = new ShrimpPower(parent.power, moduleKey, label)
}
}
this.power = power
this.icon = options?.icon
this.ownerKey = options?.ownerKey
parent.modules.set(this.moduleKey, this)
this.root = parent.root
}
addEvent<K extends keyof P & string>(name: K, label: ILabel): this
addEvent<K extends keyof P & string>(name: K, power: RootShrimpPower): this
addEvent<K extends keyof P & string>(name: K, powerOrLabel: RootShrimpPower | ILabel) {
const handler = (this as any)[name]
if (typeof handler !== 'function') {
throw new Error(`Method "${name}" not found on ${this.moduleKey}`)
}
let power: RootShrimpPower
if (powerOrLabel instanceof RootShrimpPower) {
power = powerOrLabel
} else {
power = new ShrimpPower(this.power, name, powerOrLabel)
}
this.events.set(name, new ShrimpEvent(name, handler.bind(this), power))
return this
}
async call(path: string[], ...args: any[]): Promise<any> {
if (path.length > 1) {
let module = this.modules.get(path[0])
if (module == null) {
throw new Error(`Module "${path[0]}" not found`)
}
return await module.call(path.slice(1), ...args)
} else {
const event = this.events.get(path[0])
if (event == null) {
throw new Error(`Event "${path[0]}" not found`)
}
return await event.handler(...args)
}
}
getEventInfo(powers: Set<string>): string[] {
let events: string[] = []
this.events.forEach(event => {
if (powers.has(event.power.key)) {
events.push(event.name)
}
})
return events
}
checkEvent(powers: Set<string>, path: string[]): boolean {
if (path.length > 1) {
let module = this.modules.get(path[0])
if (module == null) return false
return module.checkEvent(powers, path.slice(1))
} else {
const event = this.events.get(path[0])
if (event == null) return false
return powers.has(event.power.key)
}
}
getFields(): IFieldConfig[] {
return []
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { ILabel } from "../IShrimpType"
export class RootShrimpPower {
children: ShrimpPower[] = []
constructor(public powerKey: string, public label: ILabel) {
}
get key(): string {
return this.powerKey
}
}
export class ShrimpPower extends RootShrimpPower {
constructor(public parent: RootShrimpPower, public powerKey: string, public label: ILabel) {
super(powerKey, label)
parent.children.push(this)
}
get key(): string {
return this.parent.key + "." + this.powerKey
}
}
+497
View File
@@ -0,0 +1,497 @@
import {
IFieldConfig, IFieldTypeHandler, IShrimpQuery, IShrimpCondition, IShrimpPopulate,
IPageQuery, IPageResult, ITableCrudProto, ILabel, ShrimpConfig, ShrimpError, ShrimpFieldTypes,
} from "../IShrimpType"
import { Shrimp } from "./Shrimp"
import { ShrimpModule, IShrimpModuleOptions } from "./ShrimpModule"
import type { ShrimpCrud } from "./ShrimpCrud"
import type { RootShrimpPower } from "./ShrimpPower"
/** 内置字段类型处理器 */
export const defaultTypeHandlers: Record<string, IFieldTypeHandler> = {
[ShrimpFieldTypes.Password]: {
reqFilter: async (value) => {
if (typeof value === 'string' && value.length > 0) {
return Shrimp.passwordHasher.hash(value)
}
return value
},
resFilter: () => "***",
},
[ShrimpFieldTypes.Date]: {
defaultValue: () => Date.now(),
},
[ShrimpFieldTypes.Number]: {
defaultValue: 0,
},
[ShrimpFieldTypes.Boolean]: {
defaultValue: false,
},
[ShrimpFieldTypes.Tab]: {
// tab 是结构化字段:库里/响应/编辑全程是对象,只在"给人看"的边界(import/export/label)走字符串。
reqFilter: (value, field) => {
if (value == null || value === "") return undefined
if (typeof value === "string") {
try {
return (field.parse ?? JSON.parse)(value)
} catch {
throw new ShrimpError({ t: 'shrimp.invalidJson' })
}
}
return value
},
},
}
export class ShrimpTableModule<T, P extends ITableCrudProto<T> = ITableCrudProto<T>> extends ShrimpModule<P> implements ITableCrudProto<T> {
fields: IFieldConfig[] = []
constructor(public parent: Shrimp | ShrimpModule, moduleKey: string, public dataSource: ShrimpCrud<T>, fields?: IFieldConfig[], label?: ILabel, power?: RootShrimpPower, options?: IShrimpModuleOptions) {
super(parent, moduleKey, label || { t: moduleKey }, power, options)
if (fields) {
this.fields = fields
}
this.root.crudRegistry.set(moduleKey, dataSource)
}
getFields(): IFieldConfig[] {
return this.fields
}
static typeHandlers: Map<string, IFieldTypeHandler> = new Map(Object.entries(defaultTypeHandlers))
static registerTypeHandler(type: string, handler: IFieldTypeHandler) {
this.typeHandlers.set(type, handler)
}
private getTypeHandler(field: IFieldConfig): IFieldTypeHandler | undefined {
return ShrimpTableModule.typeHandlers.get(field.type)
}
/** 按类型递归判定"空":tab=没选type / object=所有子字段都空 / array=无元素或所有元素都空(多维递归)/ 标量=null|'' / 空数组 */
private isEmptyValue(field: IFieldConfig, value: any): boolean {
if (value === undefined || value === null) return true
switch (field.type) {
case ShrimpFieldTypes.Tab:
return typeof value !== "object" || value.type == null
case ShrimpFieldTypes.Object:
if (typeof value !== "object" || !Array.isArray(field.fields)) return true
return field.fields.every(f => this.isEmptyValue(f, value[f.key]))
case ShrimpFieldTypes.Array:
if (!Array.isArray(value) || value.length === 0) return true
return field.item ? value.every(el => this.isEmptyValue(field.item!, el)) : false
default:
// 标量/relation/select:'' 或空数组(multiple)算空;0、false 不算空
return value === "" || (Array.isArray(value) && value.length === 0)
}
}
/** 前置验证 */
private async validateField(field: IFieldConfig, value: any, item: Record<string, any>): Promise<ILabel | null> {
const isEmpty = this.isEmptyValue(field, value)
if (field.required && isEmpty) {
return field.rules?.find(r => r.required)?.message || { t: 'shrimp.fieldRequired', params: { field: field.label } }
}
if (isEmpty) return null
if (Array.isArray(field.options)) {
const validValues = new Set(field.options.map(o => o.value))
const values = field.multiple && Array.isArray(value) ? value : [value]
for (const v of values) {
if (!validValues.has(v)) {
return field.rules?.find(r => r.message)?.message || { t: 'shrimp.fieldInvalid', params: { field: field.label } }
}
}
}
// multiple 字段值是数组,类型校验需逐元素进行(与上面 options 校验保持一致);
// 非 multiple 则整体校验单值。否则数组会被标量类型分支整体判为类型错误。
const checkType = (v: any): boolean => {
switch (field.type) {
case ShrimpFieldTypes.String: return typeof v === 'string'
case ShrimpFieldTypes.Number: return typeof v === 'number' && !Number.isNaN(v)
case ShrimpFieldTypes.Boolean: return typeof v === 'boolean'
case ShrimpFieldTypes.Date: return typeof v === 'number' || v instanceof Date || !isNaN(Date.parse(v))
default: return true // 非内置标量类型(relation/json/自定义等)不在此校验
}
}
const typeTargets = field.multiple && Array.isArray(value) ? value : [value]
for (const v of typeTargets) {
if (!checkType(v)) return { t: 'shrimp.fieldTypeError', params: { field: field.label } }
}
if (field.rules) {
for (const rule of field.rules) {
const msg = rule.message || { t: 'shrimp.fieldInvalid', params: { field: field.label } }
if (rule.min !== undefined) {
if (typeof value === 'number' && value < rule.min) return { t: 'shrimp.fieldMin', params: { field: field.label, min: rule.min } }
if (typeof value === 'string' && value.length < rule.min) return { t: 'shrimp.fieldMinLength', params: { field: field.label, min: rule.min } }
if (Array.isArray(value) && value.length < rule.min) return { t: 'shrimp.fieldMinLength', params: { field: field.label, min: rule.min } }
}
if (rule.max !== undefined) {
if (typeof value === 'number' && value > rule.max) return { t: 'shrimp.fieldMax', params: { field: field.label, max: rule.max } }
if (typeof value === 'string' && value.length > rule.max) return { t: 'shrimp.fieldMaxLength', params: { field: field.label, max: rule.max } }
if (Array.isArray(value) && value.length > rule.max) return { t: 'shrimp.fieldMaxLength', params: { field: field.label, max: rule.max } }
}
if (rule.pattern) {
const regex = new RegExp(rule.pattern)
if (!regex.test(String(value))) return msg
}
}
}
return null
}
private async prepareAddItem(item: Record<string, any>): Promise<Record<string, any>> {
const result = { ...item }
for (const field of this.fields) {
if (field.editable === "readonly") {
delete result[field.key]
continue
}
const handler = this.getTypeHandler(field)
if (result[field.key] === undefined) {
const dv = field.defaultValue !== undefined ? field.defaultValue : handler?.defaultValue
if (dv !== undefined) {
result[field.key] = typeof dv === 'function' ? (dv as () => any)() : dv
}
}
if (handler?.reqFilter && result[field.key] !== undefined) {
result[field.key] = await handler.reqFilter(result[field.key], field, result)
}
await this.validateNode(field, result[field.key], [field.key], result)
if (handler?.validate && result[field.key] !== undefined) {
const err = await handler.validate(result[field.key], field, result)
if (err) throw new ShrimpError(err)
}
}
return result
}
private async prepareEditItem(item: Record<string, any>): Promise<Record<string, any>> {
const result = { ...item }
for (const field of this.fields) {
if (field.editable === "readonly" || field.editable === "addOnly") {
delete result[field.key]
continue
}
if (result[field.key] === undefined) {
delete result[field.key]
continue
}
const handler = this.getTypeHandler(field)
if (handler?.reqFilter) {
result[field.key] = await handler.reqFilter(result[field.key], field, result)
}
await this.validateNode(field, result[field.key], [field.key], result)
if (handler?.validate) {
const err = await handler.validate(result[field.key], field, result)
if (err) throw new ShrimpError(err)
}
}
return result
}
/** 递归校验:本节点 validateField,出错即抛(带 path);再下钻复合(tab/object/array)子节点。第一版 fail-fast */
private async validateNode(field: IFieldConfig, value: any, path: (string | number)[], rootItem: Record<string, any>): Promise<void> {
const err = await this.validateField(field, value, rootItem)
// 出错只发字段名(label) + i18n key 的 message,不把开发用的 path 拼进可见文案。
// path 仍递归往下传(供将来 inline 标红按结构化方式携带,不在这里塞进 message)。
if (err) throw new ShrimpError(err)
// 空的复合(走到这必是非必填)→ 不下钻校验子:整个结构缺席,里面的 required 不该触发
if (this.isEmptyValue(field, value)) return
for (const child of this.childrenOf(field, value)) {
await this.validateNode(child.field, child.value, [...path, child.key], rootItem)
}
}
/** 复合类型吐出子 (字段, 值) 对:tab 选中分支的 fields / object 的 fields / array 每个元素套 item。叶子或空 → [] */
private childrenOf(field: IFieldConfig, value: any): { key: string | number; field: IFieldConfig; value: any }[] {
if (value == null || typeof value !== "object") return []
if (field.type === ShrimpFieldTypes.Tab && Array.isArray(field.tabs)) {
const branch = field.tabs.find(t => t.key === value.type)
return branch ? branch.fields.map(f => ({ key: f.key, field: f, value: value[f.key] })) : []
}
if (field.type === ShrimpFieldTypes.Object && Array.isArray(field.fields)) {
return field.fields.map(f => ({ key: f.key, field: f, value: value[f.key] }))
}
if (field.type === ShrimpFieldTypes.Array && field.item && Array.isArray(value)) {
return value.map((el, i) => ({ key: i as number, field: field.item!, value: el }))
}
return []
}
private async processResItem(item: Record<string, any>): Promise<Record<string, any>> {
const result = { ...item }
for (const field of this.fields) {
const handler = this.getTypeHandler(field)
if (handler?.resFilter && result[field.key] !== undefined) {
result[field.key] = await handler.resFilter(result[field.key], field, result)
}
}
return result
}
private async prepareResponseItems(items: any[]): Promise<any[]> {
if (items.length === 0) return items
return Promise.all(items.map(item => this.processResItem(item)))
}
private async prepareResponseItem(item: any): Promise<any> {
if (!item) return item
return this.processResItem(item)
}
/**
* relation 字段(含 tab 分支里的嵌套 relation)批量解析成 refs 字典: { moduleKey: { id: 显示名 } }。
* 整页一次性、按模块各查一次 $in,去重;替代逐行注入 ${key}_label。前端显示时按 id 查名。
*/
private async buildRefs(rows: any[]): Promise<Record<string, Record<string, any>>> {
if (rows.length === 0) return {}
const buckets = new Map<string, { labelKey: string; ids: Set<string> }>()
const add = (moduleKey: string, labelKey: string, id: any) => {
let b = buckets.get(moduleKey)
if (!b) buckets.set(moduleKey, (b = { labelKey, ids: new Set() }))
b.ids.add(String(id))
}
for (const row of rows) {
for (const f of this.fields) this.collectOne(f, row[f.key], add)
}
if (buckets.size === 0) return {}
const refs: Record<string, Record<string, any>> = {}
for (const [moduleKey, b] of buckets) {
const dict: Record<string, any> = {}
const crud = this.root.crudRegistry.get(moduleKey)
if (crud && b.ids.size > 0) {
const res = await crud.list({ where: { _id: { in: Array.from(b.ids) } }, limit: b.ids.size })
for (const d of res.list as any[]) dict[String(d._id)] = d[b.labelKey]
}
refs[moduleKey] = dict
}
return refs
}
/** 递归收集 relation 引用:relation 叶子直接收;tab 按 value.type 进选中分支继续下钻(覆盖嵌套/tab套tab) */
/** 单个 (字段,值) 收集 relation 引用,递归复合(tab/object/array)。array 的 item 无 key —— 元素即值 */
private collectOne(f: IFieldConfig, v: any, add: (moduleKey: string, labelKey: string, id: any) => void): void {
if (v == null) return
if (f.type === "relation" && f.relation) {
// 跳过空串:可选 Relation 常存 "",混进 { _id: { $in } } 会让 Mongoose 转 ObjectId 抛 CastError、整页 500。
;(Array.isArray(v) ? v : [v]).forEach((id: any) => id != null && id !== "" && add(f.relation!.moduleKey, f.relation!.labelKey, id))
} else if (f.type === ShrimpFieldTypes.Tab && Array.isArray(f.tabs) && typeof v === "object") {
const branch = f.tabs.find(t => t.key === v.type)
if (branch) for (const c of branch.fields) this.collectOne(c, v[c.key], add)
} else if (f.type === ShrimpFieldTypes.Object && Array.isArray(f.fields) && typeof v === "object") {
for (const c of f.fields) this.collectOne(c, v[c.key], add)
} else if (f.type === ShrimpFieldTypes.Array && f.item && Array.isArray(v)) {
for (const el of v) this.collectOne(f.item, el, add)
}
}
private excludeKeys(item: any, keys: string[]): any {
if (keys.length === 0) return item
const result = { ...item }
for (const key of keys) {
delete result[key]
}
return result
}
private filterHidden(item: any): any {
// __v 是 mongoose 版本键,前端无用,统一剥掉(连同 hidden 字段)
return this.excludeKeys(item, ["__v", ...this.fields.filter(f => f.hidden).map(f => f.key)])
}
/** 递归收集标了 filterable 的字段 → 点路径(object 子 / tab 分支子)。array 暂不下钻(后续) */
private collectFilterablePaths(fields: IFieldConfig[], base: string[], out: Map<string, IFieldConfig>): void {
for (const f of fields) {
const path = [...base, f.key]
if (f.filterable) out.set(path.join("."), f)
if (f.type === ShrimpFieldTypes.Tab && Array.isArray(f.tabs)) {
for (const t of f.tabs) this.collectFilterablePaths(t.fields, path, out)
} else if (f.type === ShrimpFieldTypes.Object && Array.isArray(f.fields)) {
this.collectFilterablePaths(f.fields, path, out)
}
}
}
private buildQuery(query?: IPageQuery & any): IShrimpQuery {
const { keyword, page, pageSize, sortBy, sortOrder, filter, ownerId } = query || {}
const p = page || 1
const ps = pageSize || ShrimpConfig.defaultPageSize
const shrimpQuery: IShrimpQuery = {
offset: (p - 1) * ps,
limit: ps,
}
if (filter && typeof filter === 'object') {
// filterable 路径(含 object/tab 嵌套叶子,点路径如 condition.level)→ 白名单。点路径本就是 mongoose 嵌套查询,翻译逻辑不变
const filterableFields = new Map<string, IFieldConfig>()
this.collectFilterablePaths(this.fields, [], filterableFields)
const stringLikeTypes = new Set(['string', 'textarea'])
const where: Record<string, IShrimpCondition> = {}
for (const [key, value] of Object.entries(filter)) {
// _id 始终放行精确过滤(单项查看/relation 跳转用),无需配 filterable
if (key === '_id') {
if (value != null && value !== '') where['_id'] = { eq: value }
continue
}
const field = filterableFields.get(key)
if (!field) continue
if (value == null) continue
if (Array.isArray(value) && value.length === 0) continue
if (typeof value === 'string' && value === '') continue
const obj = value as any
if (typeof obj === 'object' && !Array.isArray(obj) && obj.min === undefined && obj.max === undefined) continue
if (Array.isArray(value)) {
where[key] = { in: value }
} else if (typeof value === 'object') {
const cond: IShrimpCondition = {}
if (obj.min !== undefined) cond.gte = obj.min
if (obj.max !== undefined) cond.lte = obj.max
where[key] = cond
} else if (typeof value === 'boolean') {
where[key] = { eq: value }
} else if (typeof value === 'string' && stringLikeTypes.has(field.type)) {
where[key] = { like: value }
} else {
where[key] = { eq: value }
}
}
if (Object.keys(where).length > 0) shrimpQuery.where = where
}
// 子表场景: ownerId + ownerKey 强制注入过滤
if (ownerId != null && ownerId !== '' && this.ownerKey) {
shrimpQuery.where = shrimpQuery.where || {}
shrimpQuery.where[this.ownerKey] = { eq: ownerId }
}
if (keyword) {
const stringLikeTypes = new Set(['string', 'textarea'])
const searchFields = this.fields
.filter(f => f.searchable && stringLikeTypes.has(f.type))
.map(f => f.key)
if (searchFields.length > 0) {
shrimpQuery.orWhere = searchFields.map(key => ({ [key]: { like: keyword } }))
}
}
if (sortBy) {
shrimpQuery.order = [[sortBy, sortOrder === 'desc' ? 'desc' : 'asc']]
}
return shrimpQuery
}
async list(query?: IPageQuery & any): Promise<IPageResult<T>> {
const shrimpQuery = this.buildQuery(query)
const result = await this.dataSource.list(shrimpQuery)
result.list = await this.prepareResponseItems(result.list)
result.list = result.list.map(item => this.filterHidden(item))
result.refs = await this.buildRefs(result.list)
return result
}
async get(id: string): Promise<T | null> {
const item = await this.dataSource.get(id)
if (item) {
const processed = await this.prepareResponseItem(item)
return this.filterHidden(processed) as T
}
return item
}
async add(item: Omit<T, '_id'>, ownerId?: string): Promise<T> {
if (ownerId && this.ownerKey) {
(item as any)[this.ownerKey] = ownerId
}
const prepared = await this.prepareAddItem(item as any)
const result = await this.dataSource.add(prepared as Omit<T, '_id'>)
const processed = await this.prepareResponseItem(result)
return this.filterHidden(processed) as T
}
async edit(id: string, item: Partial<T>): Promise<T | null> {
const prepared = await this.prepareEditItem(item as any)
const result = await this.dataSource.edit(id, prepared as Partial<T>)
if (result) {
const processed = await this.prepareResponseItem(result)
return this.filterHidden(processed) as T
}
return result
}
async delete(id: string): Promise<boolean> {
return await this.dataSource.delete(id)
}
async clear(query?: IPageQuery & any): Promise<boolean> {
const hasFilter = query && (
(query.keyword) ||
(query.filter && typeof query.filter === 'object' && Object.keys(query.filter).length > 0)
)
if (hasFilter) {
const shrimpQuery = this.buildQuery(query)
delete shrimpQuery.offset
delete shrimpQuery.limit
const result = await this.dataSource.list(shrimpQuery)
for (const item of result.list as any[]) {
await this.dataSource.delete(item._id)
}
return true
}
return await this.dataSource.clear()
}
async export(query?: IPageQuery & any): Promise<T[]> {
const shrimpQuery = this.buildQuery(query)
delete shrimpQuery.offset
delete shrimpQuery.limit
const result = await this.dataSource.list(shrimpQuery)
result.list = await this.prepareResponseItems(result.list)
// export 只取全量原始数据,不做序列化;结构化字段(如 tab)的字符串化交前端(生成 xlsx 时)。
// import 端如传字符串,由各类型 reqFilter(如 tab 的 parse)还原。
return result.list.map(item => this.filterHidden(item))
}
async import(data?: T[], ownerId?: string): Promise<void> {
if (!data || data.length === 0) return
for (const raw of data) {
const item = { ...raw } as Record<string, any>
if (ownerId && this.ownerKey) item[this.ownerKey] = ownerId
const id = item._id ? String(item._id) : undefined
if (id) {
const existing = await this.dataSource.get(id)
if (existing) {
const prepared = await this.prepareEditItem(item)
await this.dataSource.edit(id, prepared as Partial<T>)
continue
}
}
const prepared = await this.prepareAddItem(item)
await this.dataSource.add(prepared as Omit<T, '_id'>)
}
}
initCrud() {
return this
.addEvent("list", this.power)
.addEvent("get", this.power)
.addEvent("add", { t: "power.add" })
.addEvent("edit", { t: "power.edit" })
.addEvent("delete", { t: "power.delete" })
}
}
+172
View File
@@ -0,0 +1,172 @@
import {
IFieldConfig, IUser, IRole, WithId, IRoleProto, IPageQuery, IPageResult,
IShrimpOperator, IModuleInfo, ILabel, ShrimpError,
} from "../IShrimpType"
import { Shrimp } from "./Shrimp"
import { ShrimpModule } from "./ShrimpModule"
import { ShrimpTableModule } from "./ShrimpTableModule"
import type { ShrimpCrud } from "./ShrimpCrud"
/** 用户模块扩展 - 添加登录和获取用户信息功能 */
const userFields: IFieldConfig[] = [
{ key: "username", label: { t: "user.username" }, type: "string", required: true, searchable: true, editable: "addOnly" },
{ key: "password", label: { t: "user.password" }, type: "password", tableHide: true },
{ key: "nickname", label: { t: "user.nickname" }, type: "string", searchable: true },
{ key: "roleId", label: { t: "user.roleId" }, type: "relation", relation: { moduleKey: "role", labelKey: "name" }, filterable: true, multiple: true, defaultValue: [] },
{ key: "active", label: { t: "user.active" }, type: "boolean", filterable: true, defaultValue: true },
{ key: "createdAt", label: { t: "user.createdAt" }, type: "date", editable: "readonly", sortable: true, filterable: true },
{ key: "updatedAt", label: { t: "user.updatedAt" }, type: "date", editable: "readonly", sortable: true },
]
export class UserModule extends ShrimpTableModule<WithId<IUser>> {
constructor(public parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud<WithId<IUser>>, fields?: IFieldConfig[]) {
super(parent, "user", dataSource, fields || userFields, { t: "user.menuManage" })
this.initCrud()
}
protected async verifyPassword(storedPassword: string, plainPassword: string): Promise<boolean> {
return Shrimp.passwordHasher.verify(plainPassword, storedPassword)
}
protected getUserRoleIds(user: WithId<IUser>): string[] {
return Array.isArray(user.roleId) ? user.roleId.map(String) : user.roleId ? [String(user.roleId)] : []
}
async getUser(username: string, password: string): Promise<WithId<IUser>> {
const user = await this.dataSource.findOne({ username, active: true })
if (!user || !(await this.verifyPassword(user.password, password))) {
throw new ShrimpError({ t: 'shrimp.loginFailed' })
}
return user
}
async getModule(userId: string, roleModule: RoleModule): Promise<IModuleInfo[]> {
const user = await this.dataSource.get(userId)
if (!user) {
throw new ShrimpError({ t: 'shrimp.userNotFound' })
}
const roleIds = this.getUserRoleIds(user)
const roles = await Promise.all(roleIds.map(id => roleModule.dataSource.get(id)))
const isSuperAdmin = roles.some(r => r?.isSuperAdmin)
const powerKeys = isSuperAdmin
? (await this.root.allPower()).map(p => p.key)
: roles.flatMap(r => r?.powers || [])
return this.root.getModuleInfo(new Set(powerKeys))
}
async edit(id: string, item: Partial<IUser>): Promise<WithId<IUser> | null> {
const op = this.root.operator
if (op && String(op.userId) === String(id)) {
if (item.active === false) {
throw new ShrimpError({ t: 'shrimp.cannotDisableSelf' })
}
if (item.roleId != null) {
const newRoleIds = Array.isArray(item.roleId) ? item.roleId.map(String) : [String(item.roleId)]
const oldUser = await this.dataSource.get(id)
const oldRoleIds = oldUser ? this.getUserRoleIds(oldUser) : []
if (JSON.stringify([...newRoleIds].sort()) !== JSON.stringify([...oldRoleIds].sort())) {
throw new ShrimpError({ t: 'shrimp.cannotChangeOwnRole' })
}
}
}
return super.edit(id, item)
}
async delete(id: string): Promise<boolean> {
const op = this.root.operator
if (op && String(op.userId) === String(id)) {
throw new ShrimpError({ t: 'shrimp.cannotDeleteSelf' })
}
return super.delete(id)
}
async clear(): Promise<boolean> {
if (this.root.operator) {
throw new ShrimpError({ t: 'shrimp.cannotClearAllUsers' })
}
return super.clear()
}
}
/** 角色模块 */
const roleFields: IFieldConfig[] = [
{ key: "name", label: { t: "role.name" }, type: "string", required: true, searchable: true },
{ key: "description", label: { t: "role.description" }, type: "textarea" },
{ key: "isSuperAdmin", label: { t: "role.isSuperAdmin" }, type: "boolean" },
{ key: "powers", label: { t: "role.powers" }, type: "powers" },
{ key: "createdAt", label: { t: "role.createdAt" }, type: "date", editable: "readonly", sortable: true },
{ key: "updatedAt", label: { t: "role.updatedAt" }, type: "date", editable: "readonly", sortable: true },
]
export class RoleModule extends ShrimpTableModule<WithId<IRole>, IRoleProto> {
constructor(public parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud<WithId<IRole>>, fields?: IFieldConfig[]) {
super(parent, "role", dataSource, fields || roleFields, { t: "role.menuManage" })
this.initCrud()
this.addEvent("clear", { t: "power.clear" })
this.addEvent("export", { t: "power.export" })
this.addEvent("import", { t: "power.import" })
this.addEvent("allPower", this.power)
}
async allPower(): Promise<{ key: string; label: ILabel }[]> {
return this.root.allPower()
}
protected getOperatorRoleIds(op: IShrimpOperator): string[] {
return Array.isArray(op.roleId) ? op.roleId.map(String) : op.roleId ? [String(op.roleId)] : []
}
async edit(id: string, item: Partial<IRole>): Promise<WithId<IRole> | null> {
const op = this.root.operator
if (op) {
const opRoleIds = this.getOperatorRoleIds(op)
if (opRoleIds.includes(String(id))) {
throw new ShrimpError({ t: 'shrimp.cannotEditOwnRole' })
}
}
return super.edit(id, item)
}
private async fillSuperAdminPowers(role: WithId<IRole> | null): Promise<WithId<IRole> | null> {
if (role && role.isSuperAdmin) {
const allPowers = await this.allPower()
role.powers = allPowers.map(p => p.key)
}
return role
}
async list(query?: IPageQuery & any): Promise<IPageResult<WithId<IRole>>> {
const result = await super.list(query)
for (const item of result.list) {
if (item.isSuperAdmin) {
const allPowers = await this.allPower()
item.powers = allPowers.map(p => p.key)
}
}
return result
}
async get(id: string): Promise<WithId<IRole> | null> {
const result = await super.get(id)
return this.fillSuperAdminPowers(result)
}
async delete(id: string): Promise<boolean> {
const op = this.root.operator
if (op) {
const opRoleIds = this.getOperatorRoleIds(op)
if (opRoleIds.includes(String(id))) {
throw new ShrimpError({ t: 'shrimp.cannotDeleteOwnRole' })
}
}
return super.delete(id)
}
async clear(): Promise<boolean> {
if (this.root.operator) {
throw new ShrimpError({ t: 'shrimp.cannotClearAllRoles' })
}
return super.clear()
}
}
-362
View File
@@ -1,362 +0,0 @@
/**
* ShrimpServer
* Shrimp 的服务端实现,接入 express + mongoose + JWT
* 提供通用的后台管理 API(登录、鉴权、菜单、事件调度)
*/
import { Router, Request, Response } from "express"
import jsonwebtoken from "jsonwebtoken"
import mongoose, { Schema, Model } from "mongoose"
import { Shrimp, ShrimpCrud, UserModule, RoleModule, RootShrimpPower } from "./Shrimp"
import { MongoShrimpCrud } from "./MongoShrimpCrud"
import { OperationLogModule } from "./OperationLogModule"
import { ShrimpCode, IMeResult, IUser, IRole, IOperationLog, WithId, IMenuItem, IShrimpOperator, ShrimpError } from "./IShrimpType"
declare global {
namespace Express {
interface Request {
user: { _id: string; name: string; power: Set<string>; roleId: string[]; isSuperAdmin: boolean }
}
}
}
// ============== 默认 Mongoose Schema(可导出,也可被子类覆盖) ==============
export const UserSchema = new Schema({
username: { type: String, required: true },
password: { type: String, required: true },
nickname: { type: String, default: '' },
roleId: { type: [Schema.Types.ObjectId], ref: 'Role', default: [] },
active: { type: Boolean, default: true }
}, { timestamps: true })
export const RoleSchema = new Schema({
name: { type: String, required: true },
description: { type: String, default: '' },
isSuperAdmin: { type: Boolean, default: false },
powers: { type: [String], default: [] },
}, { timestamps: true })
export const OperationLogSchema = new Schema({
operatorId: { type: String, default: null },
ip: { type: String, default: '' },
method: { type: String, required: true },
args: { type: String, default: '' },
success: { type: Boolean, default: true },
errorMsg: { type: String, default: '' },
createdAt: { type: Number, default: () => Date.now() },
}, { timestamps: false })
// ============== ShrimpServer ==============
export class ShrimpServer extends Shrimp {
userModule: UserModule
roleModule: RoleModule
operationLogModule: OperationLogModule
router: Router = Router()
jwtSecret: string
config: Record<string, any>
globalOptions: Record<string, any> = {}
forcedOptionAccess = new Map<string, RootShrimpPower>()
registerOptions(key: string, options: any, access?: RootShrimpPower) {
this.globalOptions[key] = options
if (access) {
this.forcedOptionAccess.set(key, access)
}
}
/** 缓存的 Mongoose Model,避免重复注册 */
protected _userModel?: Model<IUser>
protected _roleModel?: Model<IRole>
protected _operationLogModel?: Model<IOperationLog>
constructor(jwtSecret: string, config: Record<string, any> = {}, mongodbUri: string = 'mongodb://localhost:27017/shrimp') {
super()
this.jwtSecret = jwtSecret
this.config = config
// 初始化数据源
const userCrud = this.createUserCrud()
const roleCrud = this.createRoleCrud()
const operationLogCrud = this.createOperationLogCrud()
// 初始化模块
this.userModule = this.createUserModule(userCrud)
this.roleModule = this.createRoleModule(roleCrud)
this.operationLogModule = this.createOperationLogModule(operationLogCrud)
// 连接数据库
mongoose.connect(mongodbUri).then(() => {
console.log(mongodbUri + ' MongoDB connected')
this.onDbConnected()
})
// ---- 路由 ----
// 后台配置接口(不需要鉴权)
this.router.get('/config', (_req: Request, res: Response) => {
res.json(this.success(this.config))
})
// 登录接口(不需要鉴权)
this.router.post('/login', async (req: Request, res: Response) => {
const ip = req.ip || ''
const { username, password } = req.body
const args = [{ username, password }]
try {
const user = await this.userModule.getUser(username, password)
const token = jsonwebtoken.sign(this.getJwtPayload(user), this.jwtSecret, { expiresIn: '7d' })
this.operationLogModule.record(user._id, ip, ['login'], args, true)
res.json(this.success({ token }))
} catch (error: any) {
const msg = error instanceof ShrimpError ? error.tip : { t: 'shrimp.loginFailed' }
const errMsg = error instanceof ShrimpError ? JSON.stringify(error.tip) : (error?.message || '')
this.operationLogModule.record(null, ip, ['login'], args, false, errMsg)
res.json(this.fail(ShrimpCode.FAIL, msg))
}
})
// 鉴权中间件
this.router.use(async (req: Request, res: Response, next) => {
try {
const auth = req.headers.authorization
if (!auth || !auth.startsWith('Bearer ')) {
return res.json(this.fail(ShrimpCode.UNAUTHORIZED, { t: 'shrimp.relogin' }, 'No token'))
}
const token = auth.split(' ')[1]
const decoded = jsonwebtoken.verify(token, this.jwtSecret) as any
const user = await this.userModule.dataSource.get(decoded._id)
if (!user || !this.isUserActive(user)) {
return res.json(this.fail(ShrimpCode.UNAUTHORIZED, { t: 'shrimp.userDisabled' }, 'User disabled'))
}
const roleIds = this.getUserRoleIds(user)
const roles = await Promise.all(roleIds.map(id => this.roleModule.dataSource.get(id)))
const isSuperAdmin = roles.some(r => this.isRoleSuperAdmin(r))
const powers = isSuperAdmin
? new Set((await this.allPower()).map(p => p.key))
: new Set(roles.flatMap(r => this.getRolePowers(r)))
req.user = { _id: user._id, name: this.getUserDisplayName(user), power: powers, roleId: roleIds, isSuperAdmin }
next()
} catch (error: any) {
const msg = error instanceof ShrimpError ? error.tip : { t: 'shrimp.relogin' }
return res.json(this.fail(ShrimpCode.UNAUTHORIZED, msg))
}
})
// 获取当前用户信息
this.router.get('/me', async (req: Request, res: Response) => {
try {
const moduleInfo = await this.userModule.getModule(req.user._id, this.roleModule)
const user = await this.userModule.dataSource.get(req.user._id)
if (!user) {
return res.json(this.fail(ShrimpCode.NOT_FOUND, { t: 'shrimp.userNotFound' }))
}
const roleIds = this.getUserRoleIds(user)
const roles = await Promise.all(roleIds.map(id => this.roleModule.dataSource.get(id)))
const isSuperAdmin = roles.some(r => this.isRoleSuperAdmin(r))
const powers = isSuperAdmin
? (await this.allPower()).map(p => p.key)
: roles.flatMap(r => this.getRolePowers(r))
const filteredMenuTree = this.filterMenuTree(this.getMenuTree(), powers)
// 按需收集该用户有权限的模块中引用的全局 options key(自动去重)
const neededKeys = new Set<string>()
for (const mod of moduleInfo) {
for (const field of mod.fields || []) {
if (typeof field.options === 'string') {
neededKeys.add(field.options)
}
}
}
const filteredOptions: Record<string, any> = {}
for (const key of neededKeys) {
if (this.globalOptions[key] != null) {
filteredOptions[key] = this.globalOptions[key]
}
}
for (const [key, access] of this.forcedOptionAccess) {
if (this.globalOptions[key] != null) {
if (isSuperAdmin || powers.includes(access.powerKey)) {
filteredOptions[key] = this.globalOptions[key]
}
}
}
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.json(this.success<IMeResult>({
nickname: this.getUserDisplayName(user),
moduleInfo,
menuTree: filteredMenuTree,
options: filteredOptions
}))
} catch (error: any) {
if (error instanceof ShrimpError) {
res.json(this.fail(error.code, error.tip))
} else {
res.json(this.fail(ShrimpCode.FAIL, { t: 'shrimp.serverError' }, error.message))
}
}
})
// 通用事件调用接口
this.router.post('/*path', async (req: Request, res: Response) => {
const raw = req.params.path
const path = (Array.isArray(raw) ? raw.join('/') : raw).split('/').filter(Boolean)
const args = Array.isArray(req.body) ? req.body : [req.body]
const ip = req.ip || ''
const shouldLog = this.operationLogModule.shouldLog(path)
try {
if (path.length === 0) {
return res.json(this.fail(ShrimpCode.FAIL, { t: 'shrimp.emptyPath' }))
}
const powers = req.user.power
if (!this.checkEvent(powers, path)) {
return res.json(this.fail(ShrimpCode.FORBIDDEN, { t: 'shrimp.forbidden' }))
}
const operator: IShrimpOperator = {
userId: req.user._id,
roleId: req.user.roleId,
isSuperAdmin: req.user.isSuperAdmin
}
const result = await Shrimp.asyncLocalStorage.run(operator, () => this.call(path, ...args))
if (shouldLog) this.operationLogModule.record(req.user._id, ip, path, args, true)
res.json(this.success(result))
} catch (error: any) {
if (shouldLog) {
const errMsg = error instanceof ShrimpError ? JSON.stringify(error.tip) : (error?.message || '')
this.operationLogModule.record(req.user?._id ?? null, ip, path, args, false, errMsg)
}
if (error instanceof ShrimpError) {
res.json(this.fail(error.code, error.tip))
} else {
res.json(this.fail(ShrimpCode.SERVER_ERROR, { t: 'shrimp.serverError' }, error.message))
}
}
})
}
// ============== 可覆盖的 Schema 工厂方法 ==============
protected createUserSchema(): mongoose.Schema {
return UserSchema
}
protected createRoleSchema(): mongoose.Schema {
return RoleSchema
}
protected createOperationLogSchema(): mongoose.Schema {
return OperationLogSchema
}
protected getUserModel(): Model<IUser> {
if (!this._userModel) {
this._userModel = mongoose.model<IUser>('shrimp_users', this.createUserSchema())
}
return this._userModel
}
protected getRoleModel(): Model<IRole> {
if (!this._roleModel) {
this._roleModel = mongoose.model<IRole>('shrimp_roles', this.createRoleSchema())
}
return this._roleModel
}
protected getOperationLogModel(): Model<IOperationLog> {
if (!this._operationLogModel) {
this._operationLogModel = mongoose.model<IOperationLog>('shrimp_operation_logs', this.createOperationLogSchema())
}
return this._operationLogModel
}
// ============== 可覆盖的 Crud 工厂方法 ==============
protected createUserCrud(): MongoShrimpCrud<WithId<IUser>> {
return new MongoShrimpCrud<WithId<IUser>>(this.getUserModel())
}
protected createRoleCrud(): MongoShrimpCrud<WithId<IRole>> {
return new MongoShrimpCrud<WithId<IRole>>(this.getRoleModel())
}
protected createOperationLogCrud(): MongoShrimpCrud<WithId<IOperationLog>> {
return new MongoShrimpCrud<WithId<IOperationLog>>(this.getOperationLogModel())
}
// ============== 可覆盖的 Module 工厂方法 ==============
protected createUserModule(dataSource: ShrimpCrud<WithId<IUser>>): UserModule {
return new UserModule(this, dataSource)
}
protected createRoleModule(dataSource: ShrimpCrud<WithId<IRole>>): RoleModule {
return new RoleModule(this, dataSource)
}
protected createOperationLogModule(dataSource: ShrimpCrud<WithId<IOperationLog>>): OperationLogModule {
return new OperationLogModule(this.userModule, dataSource)
}
// ============== 可覆盖的用户/角色字段访问钩子 ==============
/** 从用户对象生成 JWT payload,子类可覆盖 */
protected getJwtPayload(user: WithId<IUser>): Record<string, any> {
return { _id: user._id, roleId: user.roleId }
}
/** 判断用户是否可用,子类可覆盖 */
protected isUserActive(user: WithId<IUser>): boolean {
return user.active === true
}
/** 从用户对象提取角色 ID 列表,子类可覆盖 */
protected getUserRoleIds(user: WithId<IUser>): string[] {
return Array.isArray(user.roleId) ? user.roleId.map(String) : user.roleId ? [String(user.roleId)] : []
}
/** 判断角色是否为超级管理员,子类可覆盖 */
protected isRoleSuperAdmin(role: WithId<IRole> | null): boolean {
return !!role?.isSuperAdmin
}
/** 从角色对象提取权限列表,子类可覆盖 */
protected getRolePowers(role: WithId<IRole> | null): string[] {
return role?.powers || []
}
/** 从用户对象提取显示名称,子类可覆盖 */
protected getUserDisplayName(user: WithId<IUser>): string {
return user.nickname || ''
}
/** 数据库连接后的回调,子类可覆盖以执行连接后的逻辑 */
protected async onDbConnected(): Promise<void> {
}
/** 初始化默认数据(如默认角色、用户),子类可覆盖 */
protected async initDefaultData(name: string = "Administrator", description: "System administrator with all permissions"): Promise<void> {
const result = await this.userModule.dataSource.list({ limit: 1 })
if (result.total > 0) return
console.log("初始化默认数据")
const adminRole = await this.roleModule.dataSource.add({
name: name,
description: description,
isSuperAdmin: true,
powers: [],
createdAt: Date.now(),
updatedAt: Date.now()
})
await this.userModule.add({
username: "admin",
password: "admin",
nickname: "Administrator",
roleId: [adminRole._id],
active: true,
createdAt: Date.now(),
updatedAt: Date.now()
})
}
}
-78
View File
@@ -1,78 +0,0 @@
/**
* Shrimp 核心入口 — 0 重依赖
*
* 只暴露 Shrimp 框架本体 + 内存数据源 + 类型定义,不引入
* express / mongoose / jsonwebtoken。bcrypt 是 lazy import,
* 不走 Password 字段就不会触发。
*
* 适用场景:
* - 只想用 Shrimp 的 RPC dispatch + 模块/字段声明 + 内存 CRUD
* - 自己接 HTTP / 自己写存储层
* - 跑测试 / 跑无 DB 的 demo
*
* 需要完整后台(Express + Mongoose + JWT)请从 'shrimp-server/server' 导入。
*/
// ===== 类型 / 接口 / 枚举 =====
export {
ShrimpCode,
ShrimpError,
ShrimpFieldTypes,
ShrimpConfig,
isShrimpCondition,
} from "./IShrimpType"
export type {
ILabel,
IShrimpResponse,
IModuleInfo,
ILoginResult,
IMeResult,
IMenuItem,
ITableProto,
ITableCrudProto,
IUser,
IRole,
IOperationLog,
WithId,
IShrimpOperator,
IShrimpPowerData,
ShrimpRequestFn,
IShrimpClient,
ShrimpFieldType,
FieldEditable,
IFieldTypeHandler,
IFieldConfig,
IFieldRule,
IPageQuery,
IPageResult,
IShrimpCondition,
IShrimpPopulate,
IShrimpQuery,
IPowerItem,
IUserProto,
IRoleProto,
} from "./IShrimpType"
// ===== 框架核心 =====
export {
Shrimp,
ShrimpModule,
ShrimpTableModule,
ShrimpPower,
RootShrimpPower,
UserModule,
RoleModule,
defaultTypeHandlers,
} from "./Shrimp"
export type {
IShrimpModuleOptions,
ShrimpCrud,
} from "./Shrimp"
// ===== 数据源(纯内存,无外部依赖) =====
export { MemoryShrimpCrud } from "./MemoryShrimpCrud"
// ===== 可选模块(纯框架代码,无外部依赖) =====
export { OperationLogModule } from "./OperationLogModule"
+165
View File
@@ -0,0 +1,165 @@
/**
* 把 ShrimpServer 内联的 Express routes 抽成纯函数。
*
* routes 完全不知道:
* - 数据库类型(只通过 dataSource 抽象访问)
* - JWT(只通过 ITokenAuth 注入)
* - 自己的字段名(通过 IUserAccessor 适配)
*/
import { Router, Request, Response, NextFunction } from "express"
import { Shrimp } from "../Shrimp/Shrimp"
import { ShrimpCode, IMeResult, IShrimpOperator, ShrimpError } from "../IShrimpType"
import type { IShrimpRouterDeps } from "./types"
declare global {
namespace Express {
interface Request {
user: { _id: string; name: string; power: Set<string>; roleId: string[]; isSuperAdmin: boolean }
}
}
}
export function buildShrimpRouter(deps: IShrimpRouterDeps): Router {
const {
shrimp, userModule, roleModule, operationLogModule,
tokenAuth, config, globalOptions, forcedOptionAccess, userAccessor,
} = deps
const router = Router()
router.get("/config", (_req: Request, res: Response) => {
res.json(shrimp.success(config))
})
router.post("/login", async (req: Request, res: Response) => {
const ip = req.ip || ""
const { username, password } = req.body
const args = [{ username, password }]
try {
const user = await userModule.getUser(username, password)
const token = tokenAuth.sign(userAccessor.getJwtPayload(user))
operationLogModule.record(user._id, ip, ["login"], args, true)
res.json(shrimp.success({ token }))
} catch (error: any) {
const msg = error instanceof ShrimpError ? error.tip : { t: "shrimp.loginFailed" }
const errMsg = error instanceof ShrimpError ? JSON.stringify(error.tip) : (error?.message || "")
operationLogModule.record(null, ip, ["login"], args, false, errMsg)
res.json(shrimp.fail(ShrimpCode.FAIL, msg))
}
})
router.use(async (req: Request, res: Response, next: NextFunction) => {
try {
const auth = req.headers.authorization
if (!auth || !auth.startsWith("Bearer ")) {
return res.json(shrimp.fail(ShrimpCode.UNAUTHORIZED, { t: "shrimp.relogin" }, "No token"))
}
const token = auth.split(" ")[1]
const decoded = tokenAuth.verify(token)
const user = await userModule.dataSource.get(decoded._id)
if (!user || !userAccessor.isUserActive(user)) {
return res.json(shrimp.fail(ShrimpCode.UNAUTHORIZED, { t: "shrimp.userDisabled" }, "User disabled"))
}
const roleIds = userAccessor.getUserRoleIds(user)
const roles = await Promise.all(roleIds.map(id => roleModule.dataSource.get(id)))
const isSuperAdmin = roles.some(r => userAccessor.isRoleSuperAdmin(r))
const powers = isSuperAdmin
? new Set((await shrimp.allPower()).map(p => p.key))
: new Set(roles.flatMap(r => userAccessor.getRolePowers(r)))
req.user = { _id: user._id, name: userAccessor.getUserDisplayName(user), power: powers, roleId: roleIds, isSuperAdmin }
next()
} catch (error: any) {
const msg = error instanceof ShrimpError ? error.tip : { t: "shrimp.relogin" }
return res.json(shrimp.fail(ShrimpCode.UNAUTHORIZED, msg))
}
})
router.get("/me", async (req: Request, res: Response) => {
try {
const moduleInfo = await userModule.getModule(req.user._id, roleModule)
const user = await userModule.dataSource.get(req.user._id)
if (!user) {
return res.json(shrimp.fail(ShrimpCode.NOT_FOUND, { t: "shrimp.userNotFound" }))
}
const roleIds = userAccessor.getUserRoleIds(user)
const roles = await Promise.all(roleIds.map(id => roleModule.dataSource.get(id)))
const isSuperAdmin = roles.some(r => userAccessor.isRoleSuperAdmin(r))
const powers = isSuperAdmin
? (await shrimp.allPower()).map(p => p.key)
: roles.flatMap(r => userAccessor.getRolePowers(r))
const filteredMenuTree = shrimp.filterMenuTree(shrimp.getMenuTree(), powers)
const neededKeys = new Set<string>()
for (const mod of moduleInfo) {
for (const field of mod.fields || []) {
if (typeof field.options === "string") {
neededKeys.add(field.options)
}
}
}
const filteredOptions: Record<string, any> = {}
for (const key of neededKeys) {
if (globalOptions[key] != null) {
filteredOptions[key] = globalOptions[key]
}
}
for (const [key, access] of forcedOptionAccess) {
if (globalOptions[key] != null) {
if (isSuperAdmin || powers.includes(access.powerKey)) {
filteredOptions[key] = globalOptions[key]
}
}
}
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate")
res.json(shrimp.success<IMeResult>({
nickname: userAccessor.getUserDisplayName(user),
moduleInfo,
menuTree: filteredMenuTree,
options: filteredOptions,
}))
} catch (error: any) {
if (error instanceof ShrimpError) {
res.json(shrimp.fail(error.code, error.tip))
} else {
res.json(shrimp.fail(ShrimpCode.FAIL, { t: "shrimp.serverError" }, error.message))
}
}
})
router.post("/*path", async (req: Request, res: Response) => {
const raw = req.params.path
const path = (Array.isArray(raw) ? raw.join("/") : raw).split("/").filter(Boolean)
const args = Array.isArray(req.body) ? req.body : [req.body]
const ip = req.ip || ""
const shouldLog = operationLogModule.shouldLog(path)
try {
if (path.length === 0) {
return res.json(shrimp.fail(ShrimpCode.FAIL, { t: "shrimp.emptyPath" }))
}
const powers = req.user.power
if (!shrimp.checkEvent(powers, path)) {
return res.json(shrimp.fail(ShrimpCode.FORBIDDEN, { t: "shrimp.forbidden" }))
}
const operator: IShrimpOperator = {
userId: req.user._id,
roleId: req.user.roleId,
isSuperAdmin: req.user.isSuperAdmin,
}
const result = await Shrimp.asyncLocalStorage.run(operator, () => shrimp.call(path, ...args))
if (shouldLog) operationLogModule.record(req.user._id, ip, path, args, true)
res.json(shrimp.success(result))
} catch (error: any) {
if (shouldLog) {
const errMsg = error instanceof ShrimpError ? JSON.stringify(error.tip) : (error?.message || "")
operationLogModule.record(req.user?._id ?? null, ip, path, args, false, errMsg)
}
if (error instanceof ShrimpError) {
res.json(shrimp.fail(error.code, error.tip))
} else {
res.json(shrimp.fail(ShrimpCode.SERVER_ERROR, { t: "shrimp.serverError" }, error.message))
}
}
})
return router
}
+30
View File
@@ -0,0 +1,30 @@
/**
* JWT 实现 ITokenAuth — buildShrimpRouter 的默认 token 适配器
*
* 通过工厂函数注入 secret 和 expiresIn,routes 完全不需要 import jsonwebtoken。
*/
import jsonwebtoken from "jsonwebtoken"
import type { ITokenAuth } from "./types"
export interface IJwtTokenAuthOptions {
secret: string
/** 过期时间,默认 '7d' */
expiresIn?: string | number
}
export function createJwtTokenAuth(secretOrOptions: string | IJwtTokenAuthOptions): ITokenAuth {
const opts: IJwtTokenAuthOptions = typeof secretOrOptions === "string"
? { secret: secretOrOptions }
: secretOrOptions
const expiresIn = opts.expiresIn ?? "7d"
return {
sign(payload) {
return jsonwebtoken.sign(payload, opts.secret, { expiresIn } as jsonwebtoken.SignOptions)
},
verify(token) {
return jsonwebtoken.verify(token, opts.secret) as Record<string, any>
},
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* forExpress 子层共用接口
*
* 让 buildShrimpRouter 不直接依赖 ShrimpServer 类。
*/
import type { Router } from "express"
import type { Shrimp } from "../Shrimp/Shrimp"
import type { UserModule, RoleModule } from "../Shrimp/UserRoleModule"
import type { OperationLogModule } from "../Shrimp/OperationLogModule"
import type { RootShrimpPower } from "../Shrimp/ShrimpPower"
import type { IUser, IRole, WithId } from "../IShrimpType"
/**
* Token 鉴权抽象 — 替代 JWT 直接调用
* 默认 createJwtTokenAuth 用 jsonwebtoken;可换成 session / cookie / 自定义签名。
*/
export interface ITokenAuth {
sign(payload: Record<string, any>): string
verify(token: string): Record<string, any>
}
/**
* 用户/角色字段访问器 — 让 routes 不直接知道 user/role 的字段名。
* 默认实现读 IUser / IRole 标准字段;消费方覆盖即可适配自己的 schema。
*/
export interface IUserAccessor {
getJwtPayload(user: WithId<IUser>): Record<string, any>
isUserActive(user: WithId<IUser>): boolean
getUserRoleIds(user: WithId<IUser>): string[]
isRoleSuperAdmin(role: WithId<IRole> | null): boolean
getRolePowers(role: WithId<IRole> | null): string[]
getUserDisplayName(user: WithId<IUser>): string
}
/** buildShrimpRouter 需要的全部协作者 */
export interface IShrimpRouterDeps {
shrimp: Shrimp
userModule: UserModule
roleModule: RoleModule
operationLogModule: OperationLogModule
tokenAuth: ITokenAuth
config: Record<string, any>
globalOptions: Record<string, any>
forcedOptionAccess: Map<string, RootShrimpPower>
userAccessor: IUserAccessor
}
export type { Router }
+167
View File
@@ -0,0 +1,167 @@
/**
* MongoShrimp — Shrimp + Mongoose 装配体(无 HTTP 层)
*
* 提供:
* - 默认 Mongoose Schema(User / Role / OperationLog),子类可覆盖
* - Mongoose Model 工厂 + 缓存
* - 默认 CRUD / Module 工厂,绑定 MongoShrimpCrud
* - mongoose.connect + onDbConnected 钩子
* - initDefaultData 默认角色/用户播种
*
* 不知道:express / jwt / 任何 HTTP 概念。
* 上层把 HTTP 装到这之上,见 main/expmon.ts 的 ShrimpServer。
*/
import mongoose, { Schema, Model } from "mongoose"
import { Shrimp } from "../Shrimp/Shrimp"
import { UserModule, RoleModule } from "../Shrimp/UserRoleModule"
import { OperationLogModule } from "../Shrimp/OperationLogModule"
import type { ShrimpCrud } from "../Shrimp/ShrimpCrud"
import { IUser, IRole, IOperationLog, WithId } from "../IShrimpType"
import { MongoShrimpCrud } from "./MongoShrimpCrud"
export const UserSchema = new Schema({
username: { type: String, required: true },
password: { type: String, required: true },
nickname: { type: String, default: "" },
roleId: { type: [Schema.Types.ObjectId], ref: "Role", default: [] },
active: { type: Boolean, default: true },
}, { timestamps: true })
export const RoleSchema = new Schema({
name: { type: String, required: true },
description: { type: String, default: "" },
isSuperAdmin: { type: Boolean, default: false },
powers: { type: [String], default: [] },
}, { timestamps: true })
export const OperationLogSchema = new Schema({
operatorId: { type: String, default: null },
ip: { type: String, default: "" },
method: { type: String, required: true },
args: { type: String, default: "" },
success: { type: Boolean, default: true },
errorMsg: { type: String, default: "" },
createdAt: { type: Number, default: () => Date.now() },
}, { timestamps: false })
export class MongoShrimp extends Shrimp {
userModule: UserModule
roleModule: RoleModule
operationLogModule: OperationLogModule
protected _userModel?: Model<IUser>
protected _roleModel?: Model<IRole>
protected _operationLogModel?: Model<IOperationLog>
constructor(mongodbUri: string = "mongodb://localhost:27017/shrimp") {
super()
const userCrud = this.createUserCrud()
const roleCrud = this.createRoleCrud()
const operationLogCrud = this.createOperationLogCrud()
this.userModule = this.createUserModule(userCrud)
this.roleModule = this.createRoleModule(roleCrud)
this.operationLogModule = this.createOperationLogModule(operationLogCrud)
mongoose.connect(mongodbUri).then(() => {
console.log(mongodbUri + " MongoDB connected")
this.onDbConnected()
})
}
// ============== 可覆盖的 Schema 工厂 ==============
protected createUserSchema(): mongoose.Schema {
return UserSchema
}
protected createRoleSchema(): mongoose.Schema {
return RoleSchema
}
protected createOperationLogSchema(): mongoose.Schema {
return OperationLogSchema
}
protected getUserModel(): Model<IUser> {
if (!this._userModel) {
this._userModel = mongoose.model<IUser>("shrimp_users", this.createUserSchema())
}
return this._userModel
}
protected getRoleModel(): Model<IRole> {
if (!this._roleModel) {
this._roleModel = mongoose.model<IRole>("shrimp_roles", this.createRoleSchema())
}
return this._roleModel
}
protected getOperationLogModel(): Model<IOperationLog> {
if (!this._operationLogModel) {
this._operationLogModel = mongoose.model<IOperationLog>("shrimp_operation_logs", this.createOperationLogSchema())
}
return this._operationLogModel
}
// ============== 可覆盖的 Crud 工厂 ==============
protected createUserCrud(): MongoShrimpCrud<WithId<IUser>> {
return new MongoShrimpCrud<WithId<IUser>>(this.getUserModel())
}
protected createRoleCrud(): MongoShrimpCrud<WithId<IRole>> {
return new MongoShrimpCrud<WithId<IRole>>(this.getRoleModel())
}
protected createOperationLogCrud(): MongoShrimpCrud<WithId<IOperationLog>> {
return new MongoShrimpCrud<WithId<IOperationLog>>(this.getOperationLogModel())
}
// ============== 可覆盖的 Module 工厂 ==============
protected createUserModule(dataSource: ShrimpCrud<WithId<IUser>>): UserModule {
return new UserModule(this, dataSource)
}
protected createRoleModule(dataSource: ShrimpCrud<WithId<IRole>>): RoleModule {
return new RoleModule(this, dataSource)
}
protected createOperationLogModule(dataSource: ShrimpCrud<WithId<IOperationLog>>): OperationLogModule {
return new OperationLogModule(this.userModule, dataSource)
}
// ============== 启动钩子 ==============
protected async onDbConnected(): Promise<void> {
}
/** 初始化默认数据(如默认角色、用户),子类可覆盖 */
protected async initDefaultData(name: string = "Administrator", description: string = "System administrator with all permissions"): Promise<void> {
const result = await this.userModule.dataSource.list({ limit: 1 })
if (result.total > 0) return
console.log("初始化默认数据")
const adminRole = await this.roleModule.dataSource.add({
name: name,
description: description,
isSuperAdmin: true,
powers: [],
createdAt: Date.now(),
updatedAt: Date.now(),
})
await this.userModule.add({
username: "admin",
password: "admin",
nickname: "Administrator",
roleId: [adminRole._id],
active: true,
createdAt: Date.now(),
updatedAt: Date.now(),
})
}
}
@@ -1,19 +1,17 @@
import { Types, Model, FilterQuery } from 'mongoose';
import { ShrimpCrud } from './Shrimp';
import { IShrimpQuery, IShrimpCondition, IShrimpPopulate, IPageResult, ShrimpConfig, isShrimpCondition } from './IShrimpType';
import { Model, FilterQuery } from 'mongoose'
import type { ShrimpCrud } from "../Shrimp/ShrimpCrud"
import { IShrimpQuery, IShrimpCondition, IShrimpPopulate, IPageResult, ShrimpConfig, isShrimpCondition } from "../IShrimpType"
export class MongoShrimpCrud<T> implements ShrimpCrud<T> {
constructor(private model: Model<any>) { }
/** 获取关联模块的 Mongoose Model(用于 populate / $lookup) */
/** 获取关联模块的 Mongoose Model(用于 populate / $lookup) */
getRelatedModel?: (moduleKey: string) => Model<any> | undefined
/** 判断值是否为 IShrimpCondition 对象 */
private isCondition = isShrimpCondition
/** 将 IShrimpCondition 翻译为 MongoDB 查询片段 */
private translateCondition(cond: IShrimpCondition | any): any {
if (!this.isCondition(cond)) return cond // 简写: 原始值 = eq
if (!this.isCondition(cond)) return cond
const q: any = {}
if (cond.eq !== undefined) q.$eq = cond.eq
if (cond.in !== undefined) q.$in = cond.in
@@ -23,18 +21,15 @@ export class MongoShrimpCrud<T> implements ShrimpCrud<T> {
return q
}
/** 将 IShrimpQuery 翻译为 Mongoose 查询 */
private buildMongooseQuery(query?: IShrimpQuery): { filter: FilterQuery<T>, sort: any, skip: number, limit: number } {
const filter: FilterQuery<T> = {}
// where → AND 条件
if (query?.where) {
for (const [key, cond] of Object.entries(query.where)) {
(filter as any)[key] = this.translateCondition(cond)
}
}
// orWhere → $or
if (query?.orWhere && query.orWhere.length > 0) {
filter.$or = query.orWhere.map(group => {
const obj: any = {}
@@ -45,7 +40,6 @@ export class MongoShrimpCrud<T> implements ShrimpCrud<T> {
})
}
// 排序
const sort: any = {}
if (query?.order) {
for (const [key, dir] of query.order) {
@@ -75,7 +69,6 @@ export class MongoShrimpCrud<T> implements ShrimpCrud<T> {
async populateItems(items: any[], populates: IShrimpPopulate[], crudRegistry?: Map<string, any>): Promise<void> {
for (const pop of populates) {
// 优先用 getRelatedModel,其次用 crudRegistry(自动解析 relation)
let relatedModel = this.getRelatedModel?.(pop.moduleKey)
if (!relatedModel && crudRegistry) {
const relatedCrud = crudRegistry.get(pop.moduleKey) as any
@@ -111,20 +104,21 @@ export class MongoShrimpCrud<T> implements ShrimpCrud<T> {
}
async get(id: string): Promise<T | null> {
return await this.model.findById(id).lean() as T | null;
return await this.model.findById(id).lean() as T | null
}
async add(item: Omit<T, '_id'>): Promise<T> {
return await this.model.create(item);
const doc = await this.model.create(item)
return doc.toObject() as T // 转普通对象,否则下游 {...item} 展开的是 mongoose Document 内部($__/_doc/$isNew)
}
async edit(id: string, item: Partial<T>): Promise<T | null> {
return await this.model.findByIdAndUpdate(id, item, { new: true });
return await this.model.findByIdAndUpdate(id, item, { new: true }).lean() as T | null
}
async delete(id: string): Promise<boolean> {
const result = await this.model.findByIdAndDelete(id);
return !!result;
const result = await this.model.findByIdAndDelete(id)
return !!result
}
async clear(): Promise<boolean> {
await this.model.deleteMany({});
return true;
await this.model.deleteMany({})
return true
}
}
-10
View File
@@ -1,10 +0,0 @@
/**
* 默认入口 = 核心 (0 重依赖)
*
* - import ... from 'shrimp-server' => 这个文件 / src/core.ts(纯框架,无 peer)
* - import ... from 'shrimp-server/server' => src/server.ts(再带 ShrimpServer + Mongo)
*
* 该文件存在的意义是兼容不识别 `package.json#exports` 的旧工具链(npm < 7、
* 部分 bundler 的 legacy resolve),让 `main: src/index.ts` 也能正常工作。
*/
export * from "./core"
+155
View File
@@ -0,0 +1,155 @@
/**
* Shrimp 单一入口 — 完整后端栈
*
* 包含框架核心 + 类型契约 + Express + Mongoose + JWT 装配体。
*
* 安装 peer:express / mongoose / jsonwebtoken / bcrypt。
*
* 前端只想要类型契约,**不要**从这里 import — 见 shrimp-admin 的
* IShrimpType.ts(契约副本),避免把 mongoose/express 牵进前端 bundle。
*
* 想换掉其中一块的高级用法:
* - 自己造 ITokenAuth(看 createJwtTokenAuth 实现)
* - 自己造 dataSource(实现 ShrimpCrud 接口)
* - 直接 buildShrimpRouter(deps) 拼自己的 Express app,绕开 ShrimpServer
*/
import { Router } from "express"
import { MongoShrimp } from "../forMongo/MongoShrimp"
import { buildShrimpRouter } from "../forExpress/buildShrimpRouter"
import { createJwtTokenAuth } from "../forExpress/jwtAuth"
import type { ITokenAuth, IUserAccessor } from "../forExpress/types"
import type { RootShrimpPower } from "../Shrimp/ShrimpPower"
import type { IUser, IRole, WithId } from "../IShrimpType"
// ===== 类型契约 / 枚举 =====
export {
ShrimpCode,
ShrimpError,
ShrimpFieldTypes,
ShrimpConfig,
isShrimpCondition,
} from "../IShrimpType"
export type {
ILabel,
IShrimpResponse,
IModuleInfo,
ILoginResult,
IMeResult,
IMenuItem,
ITableProto,
ITableCrudProto,
IUser,
IRole,
IOperationLog,
WithId,
IShrimpOperator,
IShrimpPowerData,
ShrimpRequestFn,
IShrimpClient,
ShrimpFieldType,
FieldEditable,
IFieldTypeHandler,
IFieldConfig,
IFieldRule,
IPageQuery,
IPageResult,
IShrimpCondition,
IShrimpPopulate,
IShrimpQuery,
IPowerItem,
IUserProto,
IRoleProto,
} from "../IShrimpType"
// ===== 框架核心 =====
export { Shrimp } from "../Shrimp/Shrimp"
export { ShrimpModule, ShrimpEvent } from "../Shrimp/ShrimpModule"
export type { IShrimpModuleOptions } from "../Shrimp/ShrimpModule"
export { ShrimpTableModule, defaultTypeHandlers } from "../Shrimp/ShrimpTableModule"
export { ShrimpPower, RootShrimpPower } from "../Shrimp/ShrimpPower"
export type { ShrimpCrud } from "../Shrimp/ShrimpCrud"
export { UserModule, RoleModule } from "../Shrimp/UserRoleModule"
export { OperationLogModule } from "../Shrimp/OperationLogModule"
// ===== 密码哈希 =====
export { bcryptHasher, plainHasher } from "../PasswordHasher"
export type { IPasswordHasher } from "../PasswordHasher"
// ===== 数据源 =====
export { MemoryShrimpCrud } from "../MemoryShrimpCrud"
export { MongoShrimp, UserSchema, RoleSchema, OperationLogSchema } from "../forMongo/MongoShrimp"
export { MongoShrimpCrud } from "../forMongo/MongoShrimpCrud"
// ===== Express 装配 =====
export { buildShrimpRouter } from "../forExpress/buildShrimpRouter"
export { createJwtTokenAuth } from "../forExpress/jwtAuth"
export type { IJwtTokenAuthOptions } from "../forExpress/jwtAuth"
export type { ITokenAuth, IUserAccessor, IShrimpRouterDeps } from "../forExpress/types"
// ============== ShrimpServer ==============
export class ShrimpServer extends MongoShrimp implements IUserAccessor {
router: Router
jwtSecret: string
config: Record<string, any>
globalOptions: Record<string, any> = {}
forcedOptionAccess = new Map<string, RootShrimpPower>()
constructor(jwtSecret: string, config: Record<string, any> = {}, mongodbUri: string = "mongodb://localhost:27017/shrimp") {
super(mongodbUri)
this.jwtSecret = jwtSecret
this.config = config
this.router = buildShrimpRouter({
shrimp: this,
userModule: this.userModule,
roleModule: this.roleModule,
operationLogModule: this.operationLogModule,
tokenAuth: this.createTokenAuth(),
config: this.config,
globalOptions: this.globalOptions,
forcedOptionAccess: this.forcedOptionAccess,
userAccessor: this,
})
}
registerOptions(key: string, options: any, access?: RootShrimpPower) {
this.globalOptions[key] = options
if (access) {
this.forcedOptionAccess.set(key, access)
}
}
/** 创建 Token 鉴权,默认 JWT。子类可覆盖换成 session 之类。 */
protected createTokenAuth(): ITokenAuth {
return createJwtTokenAuth(this.jwtSecret)
}
// ============== 可覆盖的用户/角色字段访问钩子(实现 IUserAccessor) ==============
getJwtPayload(user: WithId<IUser>): Record<string, any> {
return { _id: user._id, roleId: user.roleId }
}
isUserActive(user: WithId<IUser>): boolean {
return user.active === true
}
getUserRoleIds(user: WithId<IUser>): string[] {
return Array.isArray(user.roleId) ? user.roleId.map(String) : user.roleId ? [String(user.roleId)] : []
}
isRoleSuperAdmin(role: WithId<IRole> | null): boolean {
return !!role?.isSuperAdmin
}
getRolePowers(role: WithId<IRole> | null): string[] {
return role?.powers || []
}
getUserDisplayName(user: WithId<IUser>): string {
return user.nickname || ""
}
}
-25
View File
@@ -1,25 +0,0 @@
/**
* Shrimp 服务端入口 — 包含 Express + Mongoose + JWT 实现
*
* 在 core 之上额外暴露:
* - ShrimpServer:Express Router + JWT 鉴权 + Mongo 连接
* - MongoShrimpCrud:Mongoose 数据源
* - 内置 User/Role/OperationLog 的 Mongoose Schema
*
* 这一入口需要消费方安装 peer 依赖:express / mongoose / jsonwebtoken / bcrypt。
* 不需要这些时请改从 'shrimp-server' 导入,不会触发任何 peer。
*/
// 再导出所有 core 的符号,'shrimp-server/server' 单一入口就够用
export * from "./core"
// ===== 服务端实现(Express + Mongoose + JWT) =====
export {
ShrimpServer,
UserSchema,
RoleSchema,
OperationLogSchema,
} from "./ShrimpServer"
// ===== Mongoose 数据源 =====
export { MongoShrimpCrud } from "./MongoShrimpCrud"
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest"
import { ShrimpError, ShrimpCode } from "../src"
import type { ILabel } from "../src"
import { ShrimpError, ShrimpCode } from "../src/main"
import type { ILabel } from "../src/main"
describe("ILabel", () => {
it("ShrimpError 接受字面量 string", () => {

Some files were not shown because too many files have changed in this diff Show More