Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
355f808294 | ||
|
|
064bc108d7 | ||
|
|
19afdf3615 | ||
|
|
31b723839e | ||
|
|
1f48a02083 | ||
|
|
0f2b4d6428 | ||
|
|
06aec7f0dc | ||
|
|
172c49816e | ||
|
|
a8cd07f851 | ||
|
|
24afc42de9 | ||
|
|
6383aade98 | ||
|
|
d12877f846 | ||
|
|
c264ec6673 | ||
|
|
f437e2e5e0 | ||
|
|
ad5f7f18f5 | ||
|
|
e04d01a80c | ||
|
|
eb339ccc49 | ||
|
|
8f9dfd40e0 | ||
|
|
ee35b47f69 | ||
|
|
bc54b475cf | ||
|
|
4b51d31e9f | ||
|
|
c5c06bee13 | ||
|
|
f94a01e0a1 | ||
|
|
5ecbf2cc01 | ||
|
|
9fff8c472d | ||
|
|
187c6f6030 | ||
|
|
c99ea0e18c | ||
|
|
652b31052e | ||
|
|
ca283c6733 | ||
|
|
e8fc126fa6 | ||
|
|
d8e0095a28 | ||
|
|
416eb84db7 | ||
|
|
36b71bd38e | ||
|
|
36326ac26b |
@@ -2,41 +2,85 @@
|
|||||||
|
|
||||||
声明式字段驱动的 Node.js 后台管理框架(Express + Mongoose + JWT)。后台只声明"表 + 字段",前端自动渲染表格、表单、搜索、菜单。
|
声明式字段驱动的 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
|
```sh
|
||||||
# 推荐: HTTP(任何能访问 git.zeromvc.com 的机器都能用)
|
# 推荐: 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 配好)
|
# 也可以 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`。
|
> 仓库 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
|
```ts
|
||||||
// 1) 核心: 0 重依赖(只需 node + tsx),适合自己接 HTTP / 只用内存 CRUD / 嵌入其它框架
|
// 1) 核心: 0 重依赖(只需 node + tsx),适合自己接 HTTP / 只用内存 CRUD / 嵌入其它框架
|
||||||
import { Shrimp, ShrimpTableModule, MemoryShrimpCrud, ShrimpFieldTypes } from "shrimp-server"
|
import { Shrimp, ShrimpTableModule, MemoryShrimpCrud, ShrimpFieldTypes } from "shrimp-server"
|
||||||
|
|
||||||
// 2) 完整 server: 自带 Express Router + Mongoose + JWT,需要装上面 4 个 peer
|
// 2) expmon: Express + Mongoose + JWT 整合方案,需要装上面 4 个 peer
|
||||||
import { ShrimpServer, MongoShrimpCrud } from "shrimp-server/server"
|
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 仍是可不装的。
|
> 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` 必须落在内置枚举(见下方 §字段类型)—— 或自行注册 `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)
|
### 国际化 (ILabel)
|
||||||
|
|
||||||
所有前端可见文本字段(`label` / `tip` / `menu.label` / `power.label` / `field.label` / `rule.message` …)统一类型 `ILabel`。**用哪种形态取决于你写在哪一层**。
|
所有前端可见文本字段(`label` / `tip` / `menu.label` / `power.label` / `field.label` / `rule.message` …)统一类型 `ILabel`。**用哪种形态取决于你写在哪一层**。
|
||||||
@@ -177,8 +248,31 @@ export class MyAdmin extends ShrimpServer {
|
|||||||
- `required: true` — 表单必填
|
- `required: true` — 表单必填
|
||||||
- `defaultValue: ...` — 新增时的默认值
|
- `defaultValue: ...` — 新增时的默认值
|
||||||
|
|
||||||
**字段 type 选这些:**
|
### 字段类型
|
||||||
`string` / `number` / `boolean` / `date` / `textarea` / `password` / `select` / `relation` / `json` / `tab`
|
|
||||||
|
内置就这 11 个,以 [src/IShrimpType.ts](src/IShrimpType.ts) 的 `ShrimpFieldTypes` 为准:
|
||||||
|
|
||||||
|
| type | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `string` | 单行文本 |
|
||||||
|
| `number` | 数字 |
|
||||||
|
| `boolean` | 开关 |
|
||||||
|
| `date` | 日期 |
|
||||||
|
| `textarea` | 多行文本 |
|
||||||
|
| `password` | 密码,列表不回显 |
|
||||||
|
| `select` | 枚举,配 `options: [{label, value}]`(属性名就叫 `options`) |
|
||||||
|
| `relation` | 关联别的模块,配 `relation: {moduleKey, labelKey}`,见 §3 |
|
||||||
|
| `tab` | 分组容器 |
|
||||||
|
| `object` | 嵌套对象,配 `fields: IFieldConfig[]` 描述内部字段 |
|
||||||
|
| `array` | 数组,配 `item: IFieldConfig` 描述元素 |
|
||||||
|
|
||||||
|
字符串数组用 `array` + `item`,不要用 `object`——`object` 要的是 `fields`(一组具名字段),装不下一个同构列表:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ key: 'tags', label: '标签', type: 'array', item: { key: '', label: '标签', type: 'string' } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**没有 `json` 这个类型。** 写了它不会报错:前端查不到处理器,静默回落成 `string` 渲染(0.8.2 起控制台留一条 warning)。需要别的形状就自行注册 `IFieldTypeHandler`,别借用不存在的名字。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -216,6 +310,15 @@ const orderFields: IFieldConfig[] = [
|
|||||||
|
|
||||||
`initCrud()` 注册了 5 个标准事件,如果你需要更多,自己加。
|
`initCrud()` 注册了 5 个标准事件,如果你需要更多,自己加。
|
||||||
|
|
||||||
|
**要注册得更少(只读模块):不调 `initCrud()` 就行。** 模块访问权本身就涵盖 `list` / `get`,不需要也不应该手动 `addEvent('list')`——`initCrud()` 加的是 `add` / `edit` / `delete` 这几个写入口:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 只能看,不能改。前端表格照常出,但没有新增/编辑/删除按钮
|
||||||
|
new ShrimpTableModule(this, 'item', new MongoShrimpCrud(itemModel), itemFields, '物品管理')
|
||||||
|
```
|
||||||
|
|
||||||
|
字段一律配 `editable: 'readonly'` 是另一件事——那管的是表单控件,不管有没有写入口。两个都要做。
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
class OrderTable extends ShrimpTableModule<IOrder> {
|
class OrderTable extends ShrimpTableModule<IOrder> {
|
||||||
constructor(parent, dataSource) {
|
constructor(parent, dataSource) {
|
||||||
@@ -276,6 +379,27 @@ ShrimpTableModule.registerTypeHandler('phone', {
|
|||||||
### 自定义 type 的前端渲染
|
### 自定义 type 的前端渲染
|
||||||
后端只声明 `type: 'fieldGoodsPicker'` 即可,前端在 `hand-admin/src/module/fields/` 放一个 `FieldGoodsPicker.vue`,会按命名约定自动注册(`Field*.vue` → `'field' + *`)。
|
后端只声明 `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 拿到当前登录用户
|
## 6. 用 operator 拿到当前登录用户
|
||||||
@@ -611,15 +735,7 @@ class MyAdmin extends ShrimpServer {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. 完整接入示例
|
## 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. 常见踩坑
|
|
||||||
|
|
||||||
- **`relation.moduleKey` 必须能在 crudRegistry 找到。** 如果改成了非默认数据源,确认 `(dataSource as any).model` 存在,否则自动 populate 不生效。
|
- **`relation.moduleKey` 必须能在 crudRegistry 找到。** 如果改成了非默认数据源,确认 `(dataSource as any).model` 存在,否则自动 populate 不生效。
|
||||||
- **`searchable` 只对字符串型字段生效。** 关键词搜索走 LIKE,数字/布尔字段加了也没用,要做精确筛选用 `filterable`。
|
- **`searchable` 只对字符串型字段生效。** 关键词搜索走 LIKE,数字/布尔字段加了也没用,要做精确筛选用 `filterable`。
|
||||||
|
|||||||
@@ -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` 从未被调用** —— 漏接,待补。
|
||||||
@@ -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"这条路径
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
Generated
-1516
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
-1993
File diff suppressed because it is too large
Load Diff
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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格式错误"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Generated
+294
-398
File diff suppressed because it is too large
Load Diff
+28
-22
@@ -1,50 +1,56 @@
|
|||||||
{
|
{
|
||||||
"name": "shrimp-server",
|
"name": "shrimp-server",
|
||||||
"version": "0.2.0",
|
"version": "0.8.1",
|
||||||
"private": true,
|
|
||||||
"description": "Shrimp 框架后端 — 声明式字段驱动的 Node.js 管理后台框架(Express + Mongoose + JWT)",
|
"description": "Shrimp 框架后端 — 声明式字段驱动的 Node.js 管理后台框架(Express + Mongoose + JWT)",
|
||||||
"main": "src/index.ts",
|
"main": "dist/main/index.js",
|
||||||
"types": "src/index.ts",
|
"types": "dist/main/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./src/core.ts",
|
"types": "./dist/main/index.d.ts",
|
||||||
"default": "./src/core.ts"
|
"default": "./dist/main/index.js"
|
||||||
},
|
|
||||||
"./server": {
|
|
||||||
"types": "./src/server.ts",
|
|
||||||
"default": "./src/server.ts"
|
|
||||||
},
|
},
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
"dist",
|
||||||
"src",
|
"src",
|
||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"postbuild": "node scripts/check-admin-contract.mjs",
|
||||||
|
"prepare": "tsc -p tsconfig.build.json",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit"
|
||||||
"demo": "tsx examples/basic-admin/index.ts"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"express": "^4 || ^5",
|
"bcrypt": "^5 || ^6",
|
||||||
"mongoose": "^6 || ^7 || ^8",
|
"express": "^5",
|
||||||
"jsonwebtoken": "^8 || ^9",
|
"jsonwebtoken": "^8 || ^9",
|
||||||
"bcrypt": "^5 || ^6"
|
"mongoose": "^6 || ^7 || ^8"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"express": { "optional": true },
|
"express": {
|
||||||
"mongoose": { "optional": true },
|
"optional": true
|
||||||
"jsonwebtoken": { "optional": true },
|
},
|
||||||
"bcrypt": { "optional": true }
|
"mongoose": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsonwebtoken": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"bcrypt": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/jsonwebtoken": "^9.0.0",
|
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.0",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"express": "^4.19.0",
|
"express": "^5.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mongoose": "^8.0.0",
|
"mongoose": "^8.0.0",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
|
|||||||
@@ -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
@@ -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,
|
Textarea: "textarea" as ShrimpFieldType,
|
||||||
Password: "password" as ShrimpFieldType,
|
Password: "password" as ShrimpFieldType,
|
||||||
Relation: "relation" as ShrimpFieldType,
|
Relation: "relation" as ShrimpFieldType,
|
||||||
Json: "json" as ShrimpFieldType,
|
|
||||||
Tab: "tab" as ShrimpFieldType,
|
Tab: "tab" as ShrimpFieldType,
|
||||||
|
Object: "object" as ShrimpFieldType,
|
||||||
|
Array: "array" as ShrimpFieldType,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 字段可编辑状态 */
|
/** 字段可编辑状态 */
|
||||||
@@ -270,6 +281,12 @@ export interface IFieldConfig {
|
|||||||
rules?: IFieldRule[] // 通用验证规则(前后端共用)
|
rules?: IFieldRule[] // 通用验证规则(前后端共用)
|
||||||
/** tab 类型专用:每个 type 对应的子字段配置 */
|
/** tab 类型专用:每个 type 对应的子字段配置 */
|
||||||
tabs?: { key: any; label: ILabel; fields: IFieldConfig[] }[]
|
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 // 当前页码
|
page: number // 当前页码
|
||||||
pageSize: number // 每页数量
|
pageSize: number // 每页数量
|
||||||
totalPages: number // 总页数
|
totalPages: number // 总页数
|
||||||
|
refs?: Record<string, Record<string, any>> // relation 显示名字典: { moduleKey: { id: 显示名 } },前端显示时查名,替代逐行 _label
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== 数据源查询协议 ==============
|
// ============== 数据源查询协议 ==============
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ShrimpCrud } from './Shrimp'
|
import type { ShrimpCrud } from './Shrimp/ShrimpCrud'
|
||||||
import { IShrimpQuery, IShrimpCondition, IShrimpPopulate, IPageResult, isShrimpCondition } from './IShrimpType'
|
import { IShrimpQuery, IShrimpCondition, IShrimpPopulate, IPageResult, isShrimpCondition } from './IShrimpType'
|
||||||
|
|
||||||
export class MemoryShrimpCrud<T extends { _id: string }> implements ShrimpCrud<T> {
|
export class MemoryShrimpCrud<T extends { _id: string }> implements ShrimpCrud<T> {
|
||||||
|
|||||||
@@ -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
@@ -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
|
* OperationLogModule
|
||||||
* 操作日志子表 - 挂在 userModule 下,记录"某用户做了什么"
|
* 操作日志子表 - 挂在 userModule 下,记录"某用户做了什么"
|
||||||
* - 默认只记 mutation(add/edit/delete/clear/import) + login
|
* - 默认只记 mutation(add/edit/delete/clear/import) + login
|
||||||
* - args 自动脱敏(password/pwd/token/secret → "***") + JSON.stringify + 截断到 2000 字符
|
* - args 自动脱敏 + JSON.stringify + 截断到 2000 字符
|
||||||
* - 日志失败不影响主调用
|
* - 日志失败不影响主调用
|
||||||
* - 只暴露 list/get,不开 add/edit/delete 防止误改
|
* - 只暴露 list/get,不开 add/edit/delete 防止误改
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ShrimpModule, ShrimpTableModule, ShrimpCrud } from "./Shrimp"
|
import { IFieldConfig, IOperationLog, WithId, ILabel } from "../IShrimpType"
|
||||||
import { Shrimp } from "./Shrimp"
|
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 SENSITIVE_KEY_RE = /(password|pwd|token|secret)/i
|
||||||
const ARGS_MAX_LEN = 2000
|
const ARGS_MAX_LEN = 2000
|
||||||
|
|
||||||
// operatorId 是子表→父记录(user)的外键,纯数据库字段,不进 fields(框架按 ownerKey 自动注入 filter)
|
|
||||||
const operationLogFields: IFieldConfig[] = [
|
const operationLogFields: IFieldConfig[] = [
|
||||||
{ key: "ip", label: { t: "operationLog.ip" }, type: "string", searchable: true, editable: "readonly" },
|
{ 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" },
|
{ 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>> {
|
export class OperationLogModule extends ShrimpTableModule<WithId<IOperationLog>> {
|
||||||
/** 触发记录的事件名集合,可被外部 add/delete 扩展 */
|
|
||||||
logEvents: Set<string> = new Set(['add', 'edit', 'delete', 'clear', 'import', 'login'])
|
logEvents: Set<string> = new Set(['add', 'edit', 'delete', 'clear', 'import', 'login'])
|
||||||
|
|
||||||
constructor(parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud<WithId<IOperationLog>>, fields?: IFieldConfig[], label?: ILabel) {
|
constructor(parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud<WithId<IOperationLog>>, fields?: IFieldConfig[], label?: ILabel) {
|
||||||
super(parent, "operationLog", dataSource, fields || operationLogFields, label || { t: "user.operationLog" }, undefined, {
|
super(parent, "operationLog", dataSource, fields || operationLogFields, label || { t: "user.operationLog" }, undefined, {
|
||||||
icon: "Tickets",
|
icon: "Tickets",
|
||||||
// 声明 operatorId 是父记录(user)的归属字段,框架据此自动做子表过滤
|
|
||||||
ownerKey: "operatorId",
|
ownerKey: "operatorId",
|
||||||
})
|
})
|
||||||
// 只挂只读事件 — 不暴露 add/edit/delete,防止外部改日志
|
|
||||||
this.addEvent("list", this.power)
|
this.addEvent("list", this.power)
|
||||||
this.addEvent("get", this.power)
|
this.addEvent("get", this.power)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 路径最后一段在 logEvents 内,且不是 operationLog 自身的调用(防递归) */
|
|
||||||
shouldLog(path: string[]): boolean {
|
shouldLog(path: string[]): boolean {
|
||||||
if (path.length === 0) return false
|
if (path.length === 0) return false
|
||||||
if (path.includes('operationLog')) return false
|
if (path.includes('operationLog')) return false
|
||||||
@@ -47,7 +44,6 @@ export class OperationLogModule extends ShrimpTableModule<WithId<IOperationLog>>
|
|||||||
return this.logEvents.has(last)
|
return this.logEvents.has(last)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 深度遍历替换敏感字段值,再 JSON.stringify 并截断 */
|
|
||||||
sanitizeArgs(args: any[]): string {
|
sanitizeArgs(args: any[]): string {
|
||||||
const walk = (val: any): any => {
|
const walk = (val: any): any => {
|
||||||
if (val == null) return val
|
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
|
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> {
|
async record(operatorId: any, ip: string, path: string[], args: any[], success: boolean, errorMsg: string = ''): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.dataSource.add({
|
await this.dataSource.add({
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 { Model, FilterQuery } from 'mongoose'
|
||||||
import { ShrimpCrud } from './Shrimp';
|
import type { ShrimpCrud } from "../Shrimp/ShrimpCrud"
|
||||||
import { IShrimpQuery, IShrimpCondition, IShrimpPopulate, IPageResult, ShrimpConfig, isShrimpCondition } from './IShrimpType';
|
import { IShrimpQuery, IShrimpCondition, IShrimpPopulate, IPageResult, ShrimpConfig, isShrimpCondition } from "../IShrimpType"
|
||||||
|
|
||||||
export class MongoShrimpCrud<T> implements ShrimpCrud<T> {
|
export class MongoShrimpCrud<T> implements ShrimpCrud<T> {
|
||||||
constructor(private model: Model<any>) { }
|
constructor(private model: Model<any>) { }
|
||||||
|
|
||||||
/** 获取关联模块的 Mongoose Model(用于 populate / $lookup) */
|
/** 获取关联模块的 Mongoose Model(用于 populate / $lookup) */
|
||||||
getRelatedModel?: (moduleKey: string) => Model<any> | undefined
|
getRelatedModel?: (moduleKey: string) => Model<any> | undefined
|
||||||
|
|
||||||
/** 判断值是否为 IShrimpCondition 对象 */
|
|
||||||
private isCondition = isShrimpCondition
|
private isCondition = isShrimpCondition
|
||||||
|
|
||||||
/** 将 IShrimpCondition 翻译为 MongoDB 查询片段 */
|
|
||||||
private translateCondition(cond: IShrimpCondition | any): any {
|
private translateCondition(cond: IShrimpCondition | any): any {
|
||||||
if (!this.isCondition(cond)) return cond // 简写: 原始值 = eq
|
if (!this.isCondition(cond)) return cond
|
||||||
const q: any = {}
|
const q: any = {}
|
||||||
if (cond.eq !== undefined) q.$eq = cond.eq
|
if (cond.eq !== undefined) q.$eq = cond.eq
|
||||||
if (cond.in !== undefined) q.$in = cond.in
|
if (cond.in !== undefined) q.$in = cond.in
|
||||||
@@ -23,18 +21,15 @@ export class MongoShrimpCrud<T> implements ShrimpCrud<T> {
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将 IShrimpQuery 翻译为 Mongoose 查询 */
|
|
||||||
private buildMongooseQuery(query?: IShrimpQuery): { filter: FilterQuery<T>, sort: any, skip: number, limit: number } {
|
private buildMongooseQuery(query?: IShrimpQuery): { filter: FilterQuery<T>, sort: any, skip: number, limit: number } {
|
||||||
const filter: FilterQuery<T> = {}
|
const filter: FilterQuery<T> = {}
|
||||||
|
|
||||||
// where → AND 条件
|
|
||||||
if (query?.where) {
|
if (query?.where) {
|
||||||
for (const [key, cond] of Object.entries(query.where)) {
|
for (const [key, cond] of Object.entries(query.where)) {
|
||||||
(filter as any)[key] = this.translateCondition(cond)
|
(filter as any)[key] = this.translateCondition(cond)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// orWhere → $or
|
|
||||||
if (query?.orWhere && query.orWhere.length > 0) {
|
if (query?.orWhere && query.orWhere.length > 0) {
|
||||||
filter.$or = query.orWhere.map(group => {
|
filter.$or = query.orWhere.map(group => {
|
||||||
const obj: any = {}
|
const obj: any = {}
|
||||||
@@ -45,7 +40,6 @@ export class MongoShrimpCrud<T> implements ShrimpCrud<T> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排序
|
|
||||||
const sort: any = {}
|
const sort: any = {}
|
||||||
if (query?.order) {
|
if (query?.order) {
|
||||||
for (const [key, dir] of 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> {
|
async populateItems(items: any[], populates: IShrimpPopulate[], crudRegistry?: Map<string, any>): Promise<void> {
|
||||||
for (const pop of populates) {
|
for (const pop of populates) {
|
||||||
// 优先用 getRelatedModel,其次用 crudRegistry(自动解析 relation)
|
|
||||||
let relatedModel = this.getRelatedModel?.(pop.moduleKey)
|
let relatedModel = this.getRelatedModel?.(pop.moduleKey)
|
||||||
if (!relatedModel && crudRegistry) {
|
if (!relatedModel && crudRegistry) {
|
||||||
const relatedCrud = crudRegistry.get(pop.moduleKey) as any
|
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> {
|
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> {
|
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> {
|
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> {
|
async delete(id: string): Promise<boolean> {
|
||||||
const result = await this.model.findByIdAndDelete(id);
|
const result = await this.model.findByIdAndDelete(id)
|
||||||
return !!result;
|
return !!result
|
||||||
}
|
}
|
||||||
async clear(): Promise<boolean> {
|
async clear(): Promise<boolean> {
|
||||||
await this.model.deleteMany({});
|
await this.model.deleteMany({})
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
|
||||||
@@ -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 || ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest"
|
import { describe, it, expect } from "vitest"
|
||||||
import { ShrimpError, ShrimpCode } from "../src"
|
import { ShrimpError, ShrimpCode } from "../src/main"
|
||||||
import type { ILabel } from "../src"
|
import type { ILabel } from "../src/main"
|
||||||
|
|
||||||
describe("ILabel", () => {
|
describe("ILabel", () => {
|
||||||
it("ShrimpError 接受字面量 string", () => {
|
it("ShrimpError 接受字面量 string", () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user