Browse Source

Initial commit: cool-admin-vue project

master
[yxf] 6 days ago
commit
e97978d0b7
  1. 305
      .cursor/rules/adv-search.mdc
  2. 1480
      .cursor/rules/crud.mdc
  3. 1820
      .cursor/rules/form.mdc
  4. 464
      .cursor/rules/module.mdc
  5. 743
      .cursor/rules/search.mdc
  6. 2117
      .cursor/rules/table.mdc
  7. 716
      .cursor/rules/upsert.mdc
  8. 74
      .cursorrules
  9. 5
      .dockerignore
  10. 7
      .editorconfig
  11. 5
      .env
  12. 4
      .gitattributes
  13. 20
      .gitignore
  14. 16
      .hintrc
  15. 10
      .prettierrc.json
  16. 15
      .vscode/config.code-snippets
  17. 100
      .vscode/crud.code-snippets
  18. 3
      .vscode/extensions.json
  19. 17
      .vscode/settings.json
  20. 14
      Dockerfile
  21. 33
      LICENSE
  22. 78
      README.md
  23. 1588
      build/cool/eps.d.ts
  24. 1
      build/cool/eps.json
  25. 10
      env.d.ts
  26. 73
      eslint.config.js
  27. 178
      index.html
  28. 72
      nginx.conf
  29. 5618
      package-lock.json
  30. 77
      package.json
  31. 23
      packages/crud/.gitignore
  32. 9
      packages/crud/.prettierrc
  33. 33
      packages/crud/README.md
  34. 811
      packages/crud/index.d.ts
  35. 29
      packages/crud/index.html
  36. 38
      packages/crud/package.json
  37. 2350
      packages/crud/pnpm-lock.yaml
  38. 208
      packages/crud/src/App.vue
  39. 21
      packages/crud/src/components/add-btn/index.tsx
  40. 31
      packages/crud/src/components/adv/btn.tsx
  41. 203
      packages/crud/src/components/adv/search.tsx
  42. 282
      packages/crud/src/components/context-menu/index.tsx
  43. 287
      packages/crud/src/components/crud/helper.ts
  44. 91
      packages/crud/src/components/crud/index.tsx
  45. 288
      packages/crud/src/components/dialog/index.tsx
  46. 17
      packages/crud/src/components/error-message/index.tsx
  47. 23
      packages/crud/src/components/filter/index.tsx
  48. 11
      packages/crud/src/components/flex1/index.tsx
  49. 51
      packages/crud/src/components/form-card/index.tsx
  50. 145
      packages/crud/src/components/form-tabs/index.tsx
  51. 146
      packages/crud/src/components/form/helper/action.ts
  52. 36
      packages/crud/src/components/form/helper/api.ts
  53. 85
      packages/crud/src/components/form/helper/index.ts
  54. 92
      packages/crud/src/components/form/helper/plugins.ts
  55. 151
      packages/crud/src/components/form/helper/tabs.ts
  56. 683
      packages/crud/src/components/form/index.tsx
  57. 50
      packages/crud/src/components/index.tsx
  58. 27
      packages/crud/src/components/multi-delete-btn/index.tsx
  59. 90
      packages/crud/src/components/pagination/index.tsx
  60. 23
      packages/crud/src/components/refresh-btn/index.tsx
  61. 11
      packages/crud/src/components/row/index.tsx
  62. 178
      packages/crud/src/components/search-key/index.tsx
  63. 21
      packages/crud/src/components/search/helper/plugins.ts
  64. 298
      packages/crud/src/components/search/index.tsx
  65. 35
      packages/crud/src/components/table/helper/data.ts
  66. 91
      packages/crud/src/components/table/helper/header.tsx
  67. 99
      packages/crud/src/components/table/helper/height.ts
  68. 43
      packages/crud/src/components/table/helper/index.ts
  69. 69
      packages/crud/src/components/table/helper/op.ts
  70. 22
      packages/crud/src/components/table/helper/plugins.ts
  71. 327
      packages/crud/src/components/table/helper/render.tsx
  72. 130
      packages/crud/src/components/table/helper/row.ts
  73. 16
      packages/crud/src/components/table/helper/selection.ts
  74. 86
      packages/crud/src/components/table/helper/sort.ts
  75. 165
      packages/crud/src/components/table/index.tsx
  76. 306
      packages/crud/src/components/upsert/index.tsx
  77. 27
      packages/crud/src/emitter.ts
  78. 30
      packages/crud/src/entry.ts
  79. 191
      packages/crud/src/hooks/crud.ts
  80. 74
      packages/crud/src/hooks/index.ts
  81. 1
      packages/crud/src/index.ts
  82. 33
      packages/crud/src/locale/en.ts
  83. 11
      packages/crud/src/locale/index.ts
  84. 32
      packages/crud/src/locale/ja.ts
  85. 32
      packages/crud/src/locale/zh-cn.ts
  86. 32
      packages/crud/src/locale/zh-tw.ts
  87. 28
      packages/crud/src/main.ts
  88. 129
      packages/crud/src/provide.ts
  89. 862
      packages/crud/src/static/index.scss
  90. 231
      packages/crud/src/test/service.ts
  91. 149
      packages/crud/src/utils/form-hook.ts
  92. 16
      packages/crud/src/utils/global.ts
  93. 164
      packages/crud/src/utils/index.ts
  94. 30
      packages/crud/src/utils/mitt.ts
  95. 52
      packages/crud/src/utils/parse.tsx
  96. 182
      packages/crud/src/utils/vnode.tsx
  97. 34
      packages/crud/tsconfig.json
  98. 2
      packages/crud/types/components/add-btn/index.d.ts
  99. 4
      packages/crud/types/components/adv/btn.d.ts
  100. 42
      packages/crud/types/components/adv/search.d.ts

305
.cursor/rules/adv-search.mdc

@ -0,0 +1,305 @@
---
description: cl-adv-search 组件示例
globs: *.tsx, *.ts, *.vue
---
## 起步 示例
```vue
<template>
<div class="scope">
<div class="h">
<el-tag size="small" effect="dark" disable-transitions>base</el-tag>
<span>起步</span>
</div>
<div class="c">
<el-button @click="open">预览</el-button>
<demo-code :files="['adv-search/base.vue']" />
<!-- 自定义表格组件 -->
<cl-dialog v-model="visible" title="起步" width="80%">
<cl-crud ref="Crud">
<cl-row>
<!--【很重要】高级搜索组件按钮 -->
<cl-adv-btn />
</cl-row>
<cl-row>
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<cl-pagination />
</cl-row>
<!--【很重要】高级搜索组件 -->
<cl-adv-search ref="AdvSearch" />
</cl-crud>
</cl-dialog>
</div>
<div class="f">
<span class="date">2024-01-01</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useCrud, useAdvSearch, useTable } from '@cool-vue/crud';
import { ref } from 'vue';
import { useDict } from '/$/dict';
const { dict } = useDict();
// cl-crud 配置
const Crud = useCrud(
{
service: 'test'
},
app => {
app.refresh();
}
);
// cl-table 配置
const Table = useTable({
autoHeight: false,
contextMenu: ['refresh'],
columns: [
{
label: '姓名',
prop: 'name',
minWidth: 140
},
{
label: '手机号',
prop: 'phone',
minWidth: 140
},
{
label: '工作',
prop: 'occupation',
dict: dict.get('occupation'),
minWidth: 140
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
sortable: 'desc'
}
]
});
// cl-adv-search 配置
//【很重要】该组件基于 cl-form 故很多示例都可复用
const AdvSearch = useAdvSearch({
// 配置如 cl-form 一样
items: [
{
label: '姓名',
prop: 'name',
component: {
name: 'el-input',
props: {
clearable: true
}
}
},
{
label: '手机号',
prop: 'phone',
component: {
name: 'el-input',
props: {
clearable: true
}
}
},
{
label: '工作',
prop: 'occupation',
component: {
name: 'cl-select',
props: {
tree: true,
checkStrictly: true,
options: dict.get('occupation')
}
}
}
]
});
function refresh(params?: any) {
Crud.value?.refresh(params);
}
const visible = ref(false);
function open() {
visible.value = true;
}
</script>
```
## 自定义 示例
```vue
<template>
<div class="scope">
<div class="h">
<el-tag size="small" effect="dark" disable-transitions>custom</el-tag>
<span>自定义</span>
</div>
<div class="c">
<el-button @click="open">预览</el-button>
<demo-code :files="['adv-search/custom.vue']" />
<!-- 自定义表格组件 -->
<cl-dialog v-model="visible" title="自定义" width="80%">
<cl-crud ref="Crud">
<cl-row>
<!--【很重要】高级搜索组件按钮 -->
<cl-adv-btn>更多搜索</cl-adv-btn>
</cl-row>
<cl-row>
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<cl-pagination />
</cl-row>
<!--【很重要】高级搜索组件 -->
<cl-adv-search ref="AdvSearch">
<!-- 自定义按钮 -->
<template #slot-btn>
<el-button @click="toSearch">自定义</el-button>
</template>
</cl-adv-search>
</cl-crud>
</cl-dialog>
</div>
<div class="f">
<span class="date">2024-01-01</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useCrud, useAdvSearch, useTable } from '@cool-vue/crud';
import { ref } from 'vue';
import { useDict } from '/$/dict';
const { dict } = useDict();
// cl-crud 配置
const Crud = useCrud(
{
service: 'test'
},
app => {
app.refresh();
}
);
// cl-table 配置
const Table = useTable({
autoHeight: false,
contextMenu: ['refresh'],
columns: [
{
label: '姓名',
prop: 'name',
minWidth: 140
},
{
label: '手机号',
prop: 'phone',
minWidth: 140
},
{
label: '工作',
prop: 'occupation',
dict: dict.get('occupation'),
minWidth: 140
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
sortable: 'desc'
}
]
});
// cl-adv-search 配置
//【很重要】该组件基于 cl-form 故很多示例都可复用
const AdvSearch = useAdvSearch({
// 配置如 cl-form 一样
items: [
{
label: '姓名',
prop: 'name',
component: {
name: 'el-input',
props: {
clearable: true
}
}
},
{
label: '手机号',
prop: 'phone',
component: {
name: 'el-input',
props: {
clearable: true
}
}
},
{
label: '工作',
prop: 'occupation',
component: {
name: 'cl-select',
props: {
tree: true,
checkStrictly: true,
options: dict.get('occupation')
}
}
}
],
title: '更多搜索',
size: '50%',
op: ['close', 'search', 'slot-btn']
});
function refresh(params?: any) {
Crud.value?.refresh(params);
}
// 自定义搜索
function toSearch() {
refresh({ page: 1 });
}
const visible = ref(false);
function open() {
visible.value = true;
}
</script>
```

1480
.cursor/rules/crud.mdc

File diff suppressed because it is too large

1820
.cursor/rules/form.mdc

File diff suppressed because it is too large

464
.cursor/rules/module.mdc

@ -0,0 +1,464 @@
---
description: module | plugins 模块、插件
globs:
---
# 模块/插件开发
## 目录结构
在 `src/modules` 或 `src/plugins` 下添加一个目录 `demo`:
```js
demo
├──pages // 页面路由
├──views // 视图路由
├──hooks // 常用函数
├──components // 常用组件
├──directives // 指令
├──static // 静态文件目录
├──store // 状态管理
├──... // 其他自定义文件
├──config.ts // 配置文件
└──index.ts // 入口文件
```
::: warning
约定的目录名称不可修改,但可自行添加或者删除。
:::
## pages、views
1. 页面参与权限控制,所以不主动注册目录下的路由,通过 `菜单列表` 中配置注册。或者在 `config.ts` 中手动配置:
```js
import { type ModuleConfig } from "/@/cool";
export default (): ModuleConfig => {
return {
views: [
{
path: "/demo",
meta: {
label: "测试",
},
component: () => import("./views/demo.vue"),
},
],
pages: [
{
path: "/demo2",
meta: {
label: "测试",
},
component: () => import("./pages/demo.vue"),
},
],
};
};
```
2. 使页面参与路由缓存,配置 `name` 参数
:::warning
`path` 与 `name` 的匹配规则:
- /demo/t1 = demo-t1
- /demo/t1-det = demo-t1-det
:::
方式 1:
```html
<script lang="ts" setup>
defineOptions({
name: "demo",
});
</script>
```
方式 2:
```html
<script lang="ts">
export default defineComponent({
name: "demo",
});
</script>
```
## components
目录下的组件,全局注册配置方法如下:
```js
import { ModuleConfig } from "/@/cool";
export default (): ModuleConfig => {
return {
components: [
import("./components/demo.vue"),
import("./components/demo1.vue"),
],
};
};
```
## directives
`directives` 会以目录下的文件名分别注册指令
```ts
// demo/directives/test.ts
export default {
created(el, binding) {},
mounted() {},
...
};
```
使用
```html
<div v-test></div>
```
## store
使用 `pinia` 的推荐写法:
```ts
import { defineStore } from "pinia";
import { ref } from "vue";
export const useTestStore = defineStore("test", function () {
const count = ref(0);
function add() {
count.value += 1;
}
return {
count,
add,
};
});
```
使用
```ts
import { useTestStore } from "/$/demo/store";
const test = useTestStore();
test.add();
console.log(test.count); // 1
```
::: tip
参考 `base` 模块下 `store` 的导出方式
:::
## config.ts
模块的配置,程序运行时会读取该文件。
- 全局组件、路由的导入
- 事件钩子
输入 `module-config` 关键字,`vscode` 中会自动生成:
```ts
import { ModuleConfig } from "/@/cool";
import { Vue } from "vue";
export default (): ModuleConfig => {
return {
// 是否启用
enable: true,
// 插件名称
label: "插件名称",
// 插件描述
description: "插件描述",
// 作者
author: "作者",
version: "1.0.0",
updateTime: "2024-02-02",
logo: "",
// 忽略
ignore: {
// 忽略进度条的请求
NProgress: [
"/base/open/eps",
"/base/comm/person",
"/base/comm/permmenu",
"/base/comm/upload",
"/base/comm/uploadMode",
],
// 忽略 token 的路由
token: ["/login", "/401", "/403", "/404", "/500", "/502"],
},
// 排序
order: 0,
// 配置参数
options: {
name: "神仙",
},
// 示例页面
demo: [
{
name: "基础用法",
component: () => import("..."),
},
],
// 注册全局组件
components: [],
// 视图路由
views: [],
// 页面路由
pages: [],
// 顶部工具栏
toolbar: {
order: 1,
pc: true, // 是否在 pc 端显示
h5: true, // 是否在 h5 端显示
component: import("./components/index.vue"),
},
// 注入全局组件
index: {
component: import("./components/index.vue"),
},
// 安装时触发
install(app: Vue) {},
// 加载时触发
onLoad(events) {},
};
};
```
- order 模块加载顺序,值越大越先
- options 提供给外部使用的参数配置:
```ts
import { ModuleConfig } from "/@/cool";
export default (): ModuleConfig => {
return {
options: {
// 尺寸
size: 120,
// 显示文案
text: "选择文件",
// 限制
limit: {
// 上传最大数量
upload: 9,
// 文件空间选择数
select: 9,
// 上传大小限制
size: 100,
},
},
};
};
```
获取方式:
```ts
import { module } from "/@/cool";
const config = module.config("模块名");
```
- components 提供全局的组件:
```ts
import type { ModuleConfig } from "/@/cool";
export default (): ModuleConfig => {
return {
components: [import("./components/test.vue")],
};
};
```
批量导入可以使用 [import.meta.glob](mdc:https:/vitejs.dev/guide/features.html#glob-import) 方法:
```ts
import { ModuleConfig } from "/@/cool";
export default (): ModuleConfig => {
return {
components: Object.values(import.meta.glob("./components/**/*")),
};
};
```
- views 全局注册的视图路由,存放在 `/` 中的子路由 `children`:
```ts
import { ModuleConfig } from "/@/cool";
export default (): ModuleConfig => {
return {
views: [
{
path: "/test",
meta: {
label: "测试中心",
},
component: () => import("./views/test.vue"),
},
],
};
};
```
- pages 全局注册的页面路由:
```ts
import { ModuleConfig } from "/@/cool";
export default (): ModuleConfig => {
return {
pages: [
{
path: "/test",
meta: {
label: "测试中心",
},
component: () => import("./views/test.vue"),
},
],
};
};
```
- install 模块安装时触发。用于预先处理:
```ts
import { ModuleConfig } from "/@/cool";
import { Vue } from "vue";
export default (): ModuleConfig => {
return {
install(app: Vue) {
// 注册组件
app.component("test", Test);
// 注册指令
app.directive("focus", {
created(el, bind) {},
});
},
};
};
```
- onLoad 模块安装时触发,预先加载数据,如菜单配置、用户信息:
1. 使用 `await` 等待加载完成后往下执行
2. 可往下模块导出某个方法和变量,如 `hasToken` 验证是否有登陆
```ts
import { ModuleConfig } from "/@/cool";
import { Vue } from "vue";
export default (): ModuleConfig => {
return {
async onLoad() {
const { user, menu } = useStore();
if (user.token) {
// 获取用户信息
user.get();
// 获取菜单权限
await menu.get();
}
return {
async hasToken(cb: () => Promise<any> | void) {
if (user.token) {
if (cb) await cb();
}
},
};
},
};
};
```
其他模块中接收 `hasToken` 方法:
```ts
import { ModuleConfig } from "/@/cool";
import { useDict } from "./index";
export default (): ModuleConfig => {
return {
onLoad({ hasToken }) {
const { dict } = useDict();
hasToken(() => {
dict.refresh();
});
},
};
};
```
## index.ts
该模块需要对外开放的变量及方法,方便于别人直接使用:
```ts
// modules/test/index.ts
import { useStore } from "./store";
export function useTest() {
return {
// 导出 pinia
...useStore(),
// 自定义方法
test() {},
// 自定义变量
data: {
description: "数据描述",
},
};
}
```
导出命名规则 `useBase` `useDemo` `useDict` use + 模块名
使用:
```ts
import { useTest } from "/$/test";
const { data, test } = useTest();
```

743
.cursor/rules/search.mdc

@ -0,0 +1,743 @@
---
description: cl-search 组件示例
globs: *.tsx, *.ts, *.vue
---
## 起步 示例
```vue
<template>
<div class="scope">
<div class="h">
<el-tag size="small" effect="dark" disable-transitions>base</el-tag>
<span>起步</span>
</div>
<div class="c">
<el-button @click="open">预览</el-button>
<demo-code :files="['search/base.vue']" />
<!-- 自定义表格组件 -->
<cl-dialog v-model="visible" title="起步" width="80%">
<cl-crud ref="Crud">
<cl-row>
<!--【很重要】搜索组件 -->
<cl-search ref="Search" />
</cl-row>
<cl-row>
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<cl-pagination />
</cl-row>
</cl-crud>
</cl-dialog>
</div>
<div class="f">
<span class="date">2024-01-01</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useCrud, useSearch, useTable } from '@cool-vue/crud';
import { ref } from 'vue';
import { useDict } from '/$/dict';
const { dict } = useDict();
// cl-crud 配置
const Crud = useCrud(
{
service: 'test'
},
app => {
app.refresh();
}
);
// cl-table 配置
const Table = useTable({
autoHeight: false,
contextMenu: ['refresh'],
columns: [
{
label: '姓名',
prop: 'name',
minWidth: 140
},
{
label: '手机号',
prop: 'phone',
minWidth: 140
},
{
label: '工作',
prop: 'occupation',
dict: dict.get('occupation'),
minWidth: 140
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
sortable: 'desc'
}
]
});
// cl-search 配置
//【很重要】该组件基于 cl-form 故很多示例都可复用
const Search = useSearch({
// 配置如 cl-form 一样
items: [
{
label: '姓名',
prop: 'name',
component: {
name: 'el-input',
props: {
clearable: true,
// 值改变的时候刷新列表
onChange(val: string) {
refresh({
name: val,
page: 1
});
}
}
}
},
{
label: '手机号',
prop: 'phone',
component: {
name: 'el-input',
props: {
clearable: true
}
}
},
{
label: '工作',
prop: 'occupation',
component: {
name: 'cl-select',
props: {
tree: true,
checkStrictly: true,
options: dict.get('occupation')
}
}
}
],
onChange(data, prop) {
console.log(data, prop);
}
});
function refresh(params?: any) {
Crud.value?.refresh(params);
}
const visible = ref(false);
function open() {
visible.value = true;
}
</script>
```
## 折叠 示例
```vue
<template>
<div class="scope">
<div class="h">
<el-tag size="small" effect="dark" disable-transitions>collapse</el-tag>
<span>折叠</span>
</div>
<div class="c">
<el-button @click="open">预览</el-button>
<demo-code :files="['search/collapse.vue']" />
<!-- 折叠表格组件 -->
<cl-dialog v-model="visible" title="折叠" width="80%">
<cl-crud ref="Crud">
<!--【collapse】折叠参数,【inline】是否行内 -->
<cl-search ref="Search" reset-btn collapse :inline="false" />
<cl-row>
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<cl-pagination />
</cl-row>
</cl-crud>
</cl-dialog>
</div>
<div class="f">
<span class="date">2024-12-26</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useCrud, useSearch, useTable } from '@cool-vue/crud';
import { ref } from 'vue';
import { useDict } from '/$/dict';
import { range } from 'lodash-es';
const { dict } = useDict();
// cl-crud 配置
const Crud = useCrud(
{
service: 'test'
},
app => {
app.refresh();
}
);
// cl-table 配置
const Table = useTable({
autoHeight: false,
contextMenu: ['refresh'],
columns: [
{
label: '姓名',
prop: 'name',
minWidth: 140
},
{
label: '手机号',
prop: 'phone',
minWidth: 140
},
{
label: '工作',
prop: 'occupation',
dict: dict.get('occupation'),
minWidth: 140
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
sortable: 'desc'
}
]
});
// cl-search 配置
const Search = useSearch({
items: [
...range(20).map(i => {
return {
label: '输入框',
prop: `T${i + 1}`,
component: {
name: 'el-input'
}
};
})
]
});
function refresh(params?: any) {
Crud.value?.refresh(params);
}
const visible = ref(false);
function open() {
visible.value = true;
}
</script>
x
```
## 自定义 示例
```vue
<template>
<div class="scope">
<div class="h">
<el-tag size="small" effect="dark" disable-transitions>custom</el-tag>
<span>自定义</span>
</div>
<div class="c">
<el-button @click="open">预览</el-button>
<demo-code :files="['search/custom.vue']" />
<!-- 自定义表格组件 -->
<cl-dialog v-model="visible" title="自定义" width="80%">
<cl-crud ref="Crud">
<cl-row>
<!--【很重要】搜索组件 -->
<cl-search
ref="Search"
:reset-btn="true"
:on-load="onLoad"
:on-search="onSearch"
>
<!-- 自定义按钮 -->
<template #buttons="scope">
<el-button @click="toSearch(scope)">自定义按钮</el-button>
</template>
</cl-search>
</cl-row>
<cl-row>
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<cl-pagination />
</cl-row>
</cl-crud>
</cl-dialog>
</div>
<div class="f">
<span class="date">2024-01-01</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useCrud, useSearch, useTable } from '@cool-vue/crud';
import { ref } from 'vue';
import { useDict } from '/$/dict';
import { ElMessage } from 'element-plus';
const { dict } = useDict();
// cl-crud 配置
const Crud = useCrud(
{
service: 'test'
},
app => {
app.refresh();
}
);
// cl-table 配置
const Table = useTable({
autoHeight: false,
contextMenu: ['refresh'],
columns: [
{
label: '姓名',
prop: 'name',
minWidth: 140
},
{
label: '手机号',
prop: 'phone',
minWidth: 140
},
{
label: '工作',
prop: 'occupation',
dict: dict.get('occupation'),
minWidth: 140
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
sortable: 'desc'
}
]
});
// cl-search 配置
//【很重要】该组件基于 cl-form 故很多示例都可复用
const Search = useSearch({
// 配置如 cl-form 一样
items: [
{
label: '姓名',
prop: 'name',
component: {
name: 'el-input',
props: {
clearable: true,
// 值改变的时候刷新列表
onChange(val: string) {
refresh({
name: val,
page: 1
});
}
}
}
},
{
label: '手机号',
prop: 'phone',
component: {
name: 'el-input',
props: {
clearable: true
}
}
},
{
label: '工作',
prop: 'occupation',
component: {
name: 'cl-select',
props: {
tree: true,
checkStrictly: true,
options: dict.get('occupation')
}
}
}
]
});
function refresh(params?: any) {
Crud.value?.refresh(params);
}
// cl-search 初始化
function onLoad(data: any) {
data.name = '白小纯';
}
// cl-search 配置 onSearch 后,必须使用 next 方法继续请求
function onSearch(data: any, { next }: { next: (data: any) => void }) {
ElMessage.info('开始搜索');
// 这边可以处理其他事务
next(data);
}
// 自定义搜索,data 为表单数据
function toSearch(data: any) {
ElMessage.info('自定义搜索');
refresh({
page: 1,
...data
});
}
const visible = ref(false);
function open() {
visible.value = true;
}
</script>
```
## 布局 示例
```vue
<template>
<div class="scope">
<div class="h">
<el-tag size="small" effect="dark" disable-transitions>layout</el-tag>
<span>布局</span>
</div>
<div class="c">
<el-button @click="open">预览</el-button>
<demo-code :files="['search/layout.vue']" />
<!-- 自定义表格组件 -->
<cl-dialog v-model="visible" title="布局" width="80%">
<cl-crud ref="Crud">
<!--【很重要】搜索组件 -->
<cl-search ref="Search" :reset-btn="true" />
<cl-row>
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<cl-pagination />
</cl-row>
</cl-crud>
</cl-dialog>
</div>
<div class="f">
<span class="date">2024-01-01</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useCrud, useSearch, useTable } from '@cool-vue/crud';
import { ref } from 'vue';
import { useDict } from '/$/dict';
const { dict } = useDict();
// cl-crud 配置
const Crud = useCrud(
{
service: 'test'
},
app => {
app.refresh();
}
);
// cl-table 配置
const Table = useTable({
autoHeight: false,
contextMenu: ['refresh'],
columns: [
{
label: '姓名',
prop: 'name',
minWidth: 140
},
{
label: '手机号',
prop: 'phone',
minWidth: 140
},
{
label: '工作',
prop: 'occupation',
dict: dict.get('occupation'),
minWidth: 140
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
sortable: 'desc'
}
]
});
// cl-search 配置
//【很重要】该组件基于 cl-form 故很多示例都可复用
const Search = useSearch({
// 取消行内表单模式
inline: false,
// 表单参数
props: {
labelPosition: 'top'
},
// 配置如 cl-form 一样
items: [
{
label: '姓名',
prop: 'name',
component: {
name: 'el-input',
props: {
clearable: true,
// 值改变的时候刷新列表
onChange(val: string) {
refresh({
name: val,
page: 1
});
}
}
}
},
{
label: '手机号',
prop: 'phone',
component: {
name: 'el-input',
props: {
clearable: true
}
}
},
{
label: '工作',
prop: 'occupation',
component: {
name: 'cl-select',
props: {
tree: true,
checkStrictly: true,
options: dict.get('occupation')
}
}
}
]
});
function refresh(params?: any) {
Crud.value?.refresh(params);
}
const visible = ref(false);
function open() {
visible.value = true;
}
</script>
```
## 使用插件 示例
```vue
<template>
<div class="scope">
<div class="h">
<el-tag size="small" effect="dark" disable-transitions>plugin</el-tag>
<span>使用插件</span>
</div>
<div class="c">
<el-button @click="open">预览</el-button>
<demo-code :files="['search/layout.vue']" />
<!-- 自定义表格组件 -->
<cl-dialog v-model="visible" title="使用插件" width="80%">
<cl-crud ref="Crud">
<cl-row>
<cl-flex1 />
<!--【很重要】搜索组件 -->
<cl-search ref="Search" />
</cl-row>
<cl-row>
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<cl-pagination />
</cl-row>
</cl-crud>
</cl-dialog>
</div>
<div class="f">
<span class="date">2024-01-01</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useCrud, useSearch, useTable } from '@cool-vue/crud';
import { ref } from 'vue';
import { useDict } from '/$/dict';
import { Plugins } from '/#/crud';
const { dict } = useDict();
// cl-crud 配置
const Crud = useCrud(
{
service: 'test'
},
app => {
app.refresh();
}
);
// cl-table 配置
const Table = useTable({
autoHeight: false,
contextMenu: ['refresh'],
columns: [
{
label: '姓名',
prop: 'name',
minWidth: 140
},
{
label: '手机号',
prop: 'phone',
minWidth: 140
},
{
label: '工作',
prop: 'occupation',
dict: dict.get('occupation'),
minWidth: 140
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
sortable: 'desc'
}
]
});
// cl-search 配置
const Search = useSearch({
// 【很重要】自动读取 service 下的 search 数据
plugins: [
Plugins.Search.setAuto({
customComponent(field) {
if (field.propertyName == 'name') {
return {
name: 'cl-select',
props: {
options: [
{
label: '张三',
value: '1'
},
{
label: '李四',
value: '2'
}
]
}
};
}
// null 则不操作,按系统默认操作
return null;
}
})
]
});
function refresh(params?: any) {
Crud.value?.refresh(params);
}
const visible = ref(false);
function open() {
visible.value = true;
}
</script>
```

2117
.cursor/rules/table.mdc

File diff suppressed because it is too large

716
.cursor/rules/upsert.mdc

@ -0,0 +1,716 @@
---
description: cl-upsert 组件示例
globs: *.tsx, *.ts, *.vue
---
## 起步 示例
```vue
<template>
<div class="scope">
<div class="h">
<el-tag size="small" effect="dark" disable-transitions>base</el-tag>
<span>起步</span>
</div>
<div class="c">
<el-button @click="open">预览</el-button>
<demo-code :files="['upsert/base.vue']" />
<!-- 自定义表格组件 -->
<cl-dialog v-model="visible" title="起步" width="80%">
<cl-crud ref="Crud">
<cl-row>
<!-- 打开新增表单的按钮 -->
<cl-add-btn />
</cl-row>
<cl-row>
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<cl-pagination />
</cl-row>
<!--【很重要】新增、编辑的表单组件 -->
<cl-upsert ref="Upsert" />
</cl-crud>
</cl-dialog>
</div>
<div class="f">
<span class="date">2024-01-01</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useCrud, useTable, useUpsert } from '@cool-vue/crud';
import { ref } from 'vue';
import { useDict } from '/$/dict';
const { dict } = useDict();
// cl-crud 配置
const Crud = useCrud(
{
service: 'test'
},
app => {
app.refresh();
}
);
// cl-table 配置
const Table = useTable({
autoHeight: false,
contextMenu: ['refresh'],
columns: [
{
label: '姓名',
prop: 'name',
minWidth: 140
},
{
label: '手机号',
prop: 'phone',
minWidth: 140
},
{
label: '工作',
prop: 'occupation',
dict: dict.get('occupation'),
minWidth: 140
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
sortable: 'desc'
},
{
type: 'op',
// edit 打开编辑表单
buttons: ['edit', 'delete']
}
]
});
// cl-upsert 配置
//【很重要】该组件基于 cl-form 故很多示例都可复用
const Upsert = useUpsert({
// 配置如 cl-form 一样
items: [
{
label: '姓名',
prop: 'name',
component: {
name: 'el-input'
}
},
{
label: '手机号',
prop: 'phone',
component: {
name: 'el-input'
}
},
{
label: '工作',
prop: 'occupation',
component: {
name: 'cl-select',
props: {
tree: true,
checkStrictly: true,
options: dict.get('occupation')
}
}
}
]
});
const visible = ref(false);
function open() {
visible.value = true;
}
</script>
```
## 打开、关闭、提交等事件 示例
```vue
<template>
<div class="scope">
<div class="h">
<el-tag size="small" effect="dark" disable-transitions>event</el-tag>
<span>打开、关闭、提交等事件</span>
</div>
<div class="c">
<el-button @click="open">预览</el-button>
<demo-code :files="['upsert/event.vue']" />
<!-- 自定义表格组件 -->
<cl-dialog v-model="visible" title="事件" width="80%">
<cl-crud ref="Crud">
<cl-row>
<!-- 打开新增表单的按钮 -->
<cl-add-btn />
</cl-row>
<cl-row>
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<cl-pagination />
</cl-row>
<!--【很重要】新增、编辑的表单组件 -->
<cl-upsert ref="Upsert" />
</cl-crud>
</cl-dialog>
</div>
<div class="f">
<span class="date">2024-01-01</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useCrud, useTable, useUpsert } from '@cool-vue/crud';
import { ref } from 'vue';
import { useDict } from '/$/dict';
import { useCool } from '/@/cool';
const { service } = useCool();
const { dict } = useDict();
// cl-crud 配置
const Crud = useCrud(
{
service: 'test'
},
app => {
app.refresh();
}
);
// cl-table 配置
const Table = useTable({
autoHeight: false,
contextMenu: ['refresh'],
columns: [
{
label: '姓名',
prop: 'name',
minWidth: 140
},
{
label: '手机号',
prop: 'phone',
minWidth: 140
},
{
label: '工作',
prop: 'occupation',
dict: dict.get('occupation'),
minWidth: 140
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
sortable: 'desc'
},
{
type: 'op',
// edit 打开编辑表单
buttons: ['edit', 'delete']
}
]
});
// cl-upsert 配置
const Upsert = useUpsert({
items: [
{
label: '姓名',
prop: 'name',
component: {
name: 'el-input'
}
},
{
label: '手机号',
prop: 'phone',
component: {
name: 'el-input'
}
},
{
label: '工作',
prop: 'occupation',
component: {
name: 'cl-select',
props: {
tree: true,
checkStrictly: true,
options: dict.get('occupation')
}
}
}
],
// 以下事件按顺序触发
// 弹窗打开的事件,这个时候还未有表单数据
onOpen() {
console.log('onOpen');
},
// 获取详情,编辑的时候会触发
async onInfo(data, { next, done }) {
// 不配置 onInfo 的时候默认执行 next(data),调用 service 的 info 接口获取详情
// next(data);
// 自定义,需要对请求数据进行处理或者返回处理后的数据
const res = await next({
id: data.id
});
done({
...res,
name: `[${res.name}]`
});
},
// 弹窗打开后,已经得到了表单数据
onOpened(data) {
// 判定是否编辑模式
if (Upsert.value?.mode == 'update') {
// 对数据处理
data.phone += '000';
}
},
// 提交事件的钩子
// data 表单提交数据
// next 继续往下执行
// done 关闭加载
// close 关闭弹窗
async onSubmit(data, { next, done, close }) {
// 不配置 onSubmit 的时候默认执行 next(data),提交后会去请求 service 的 update/add 接口
// next(data);
// 自定义如下
// 场景1:提交时对参数额外的处理
// next({
// ...data,
// status: 1,
// createTime: dayjs().format("YYYY-MM-DD")
// });
// 场景2:提交前、后的操作
// 之前,模拟获取 userId
const userId = await service.base.sys.user.info({ id: 1 });
// 返回值
const res = await next({
userId,
data
});
// 之后
// console.log(res);
},
// 关闭时触发
onClose(action, done) {
// action 关闭的类型
console.log('action,', action);
// 使用 done 关闭窗口
done();
},
// 关闭后触发
onClosed() {
console.log('onClosed');
}
});
const visible = ref(false);
function open() {
visible.value = true;
}
</script>
```
## Hook的使用 示例
```vue
<template>
<div class="scope">
<div class="h">
<el-tag size="small" effect="dark" disable-transitions>hook</el-tag>
<span>Hook的使用</span>
</div>
<div class="c">
<el-button @click="open">预览</el-button>
<demo-code :files="['upsert/hook/index.vue', 'upsert/hook/reg-pca2.ts']" />
<!-- 自定义表格组件 -->
<cl-dialog v-model="visible" title="Hook的使用" width="80%">
<cl-crud ref="Crud">
<cl-row>
<!-- 打开新增表单的按钮 -->
<cl-add-btn />
</cl-row>
<cl-row>
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<cl-pagination />
</cl-row>
<!--【很重要】新增、编辑的表单组件 -->
<cl-upsert ref="Upsert" />
</cl-crud>
</cl-dialog>
</div>
<div class="f">
<span class="date">2024-01-01</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useCrud, useTable, useUpsert } from '@cool-vue/crud';
import { ref } from 'vue';
import { useDict } from '/$/dict';
const { dict } = useDict();
// cl-crud 配置
const Crud = useCrud(
{
service: 'test'
},
app => {
app.refresh();
}
);
// cl-table 配置
const Table = useTable({
autoHeight: false,
contextMenu: ['refresh'],
columns: [
{
label: '姓名',
prop: 'name',
minWidth: 140
},
{
label: '手机号',
prop: 'phone',
minWidth: 140
},
{
label: '省市区',
prop: 'pca',
formatter(row) {
return row.province ? row.province + '-' + row.city + '-' + row.district : '-';
},
minWidth: 140
},
{
label: '工作',
prop: 'occupation',
dict: dict.get('occupation'),
minWidth: 140
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
sortable: 'desc'
},
{
type: 'op',
buttons: ['edit', 'delete']
}
]
});
// cl-upsert 配置
const Upsert = useUpsert({
items: [
{
label: '姓名',
prop: 'name',
component: {
name: 'el-input'
}
},
{
label: '手机号',
prop: 'phone',
component: {
name: 'el-input'
}
},
{
label: '省市区',
prop: 'pca2',
//【很重要】hook 参数配置
hook: {
bind(value, { form }) {
// 将3个参数合并成一个数组,带入级联选择器
return [form.province, form.city, form.district];
},
submit(value, { form, prop }) {
// 提交的时候将数组拆分成3个字段提交
const [province, city, district] = value || [];
form.province = province;
form.city = city;
form.district = district;
// 删除 prop 绑定值
form[prop] = undefined;
}
},
// 注册到全局后可直接使用,注册代码看 ./reg-pca2.ts
// hook: "pca2",
component: {
name: 'cl-distpicker'
}
},
{
label: '标签',
prop: 'labels',
//【很重要】使用内置方法,避免一些辣鸡后端要你这么传给他
hook: {
// labels 的数据为 1,2,3
// 绑定的时候将 labels 按 , 分割成数组
bind: ['split', 'number'],
// 提交的时候将 labels 拼接成字符串
submit: ['join']
},
component: {
name: 'el-select',
props: {
multiple: true
},
options: [
{
label: '帅气',
value: 1
},
{
label: '多金',
value: 2
},
{
label: '有才华',
value: 3
}
]
}
},
{
label: '工作',
prop: 'occupation',
component: {
name: 'cl-select',
props: {
tree: true,
checkStrictly: true,
options: dict.get('occupation')
}
}
}
]
});
const visible = ref(false);
function open() {
visible.value = true;
}
</script>
```
## 新增、编辑、详情模式 示例
```vue
<template>
<div class="scope">
<div class="h">
<el-tag size="small" effect="dark" disable-transitions>mode</el-tag>
<span>新增、编辑、详情模式</span>
</div>
<div class="c">
<el-button @click="open">预览</el-button>
<demo-code :files="['upsert/mode.vue']" />
<!-- 自定义表格组件 -->
<cl-dialog v-model="visible" title="不同模式" width="80%">
<cl-crud ref="Crud">
<cl-row>
<!-- 打开新增表单的按钮 -->
<cl-add-btn />
</cl-row>
<cl-row>
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<cl-pagination />
</cl-row>
<!--【很重要】新增、编辑的表单组件 -->
<cl-upsert ref="Upsert" />
</cl-crud>
</cl-dialog>
</div>
<div class="f">
<span class="date">2024-01-01</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useCrud, useTable, useUpsert } from '@cool-vue/crud';
import { ref } from 'vue';
import { useDict } from '/$/dict';
import { ElMessage } from 'element-plus';
const { dict } = useDict();
// cl-crud 配置
const Crud = useCrud(
{
service: 'test'
},
app => {
app.refresh();
}
);
// cl-table 配置
const Table = useTable({
autoHeight: false,
contextMenu: ['refresh'],
columns: [
{
label: '姓名',
prop: 'name',
minWidth: 140
},
{
label: '手机号',
prop: 'phone',
minWidth: 140
},
{
label: '工作',
prop: 'occupation',
dict: dict.get('occupation'),
minWidth: 140
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
sortable: 'desc'
},
{
type: 'op',
width: 240,
buttons: ['info', 'edit', 'delete']
}
]
});
// cl-upsert 配置
const Upsert = useUpsert({
items: [
{
label: '姓名',
prop: 'name',
component: {
name: 'el-input'
}
},
//【很重要】只有返回方法的时候才能使用 Upsert
() => {
return {
label: '手机号',
prop: 'phone',
// 新增的时候隐藏
// hidden: Upsert.value?.mode == "add",
component: {
name: 'el-input',
props: {
// 编辑的时候禁用
disabled: Upsert.value?.mode == 'update'
}
}
};
},
{
label: '工作',
prop: 'occupation',
component: {
name: 'cl-select',
props: {
tree: true,
checkStrictly: true,
options: dict.get('occupation')
}
}
}
],
onOpen() {
ElMessage.info(`当前模式:` + Upsert.value?.mode);
}
});
const visible = ref(false);
function open() {
visible.value = true;
}
</script>
```

74
.cursorrules

@ -0,0 +1,74 @@
# 项目背景
- 库:typescript、javaScript、scss、vue、tailwind
- 框架:cool-admin-vue
- 项目版本:8.x
# 项目目录
├── .vscode(代码片段,根据关键字可以快速地生成代码)
├── public(静态资源文件)
├── packages(源码包:@cool-vue/crud、@cool-vue/vite-plugin)
├── build
│ └── cool()
│ │ └── eps.json(Eps 配置文件)
│ │ └── eps.d.ts(Eps 描述文件)
├── src
│ └── cool(核心文件)
│ └── modules(项目模块)
│ │ └── base(基础模块)
│ │ └── demo(示例模块)
│ │ └── dict(字典模块)
│ │ └── helper(辅助模块)
│ │ └── recycle(回收站模块)
│ │ └── space(cl-upload-space 文件空间模块)
│ │ └── task(任务模块)
│ │ └── user(用户模块)
│ └── plugins(项目插件)
│ │ └── crud(cl-crud、@cool-vue/crud)
│ │ └── distpicker(cl-distpicker、省市区选择器)
│ │ └── echarts(图标)
│ │ └── editor-preview(编辑器预览组件)
│ │ └── editor-wange(wang富文本编辑器)
│ │ └── element-ui(element-plus 组件)
│ │ └── excel(excel导入、导出组件)
│ │ └── i18n(多语言)
│ │ └── iconfont(iconfont 图标)
│ │ └── theme(cl-theme 主题组件)
│ │ └── upload(cl-upload 文件上传组件)
│ │ └── view(cl-view-group、cl-view-head 视图组件)
│ └── config
│ │ └── index.ts(默认配置)
│ │ └── dev.ts(开发环境)
│ │ └── prod.ts(生产环境)
│ │ └── proxy.ts(代理配置)
│ └── App.vue(入口文件)
│ └── main.ts(入口文件)
├── package.json(依赖管理,项目信息)
└── ...
模块、插件目录
├── modules/plugins
│ └── base(模块名)
│ │ └── components(全局组件)
│ │ └── directives(全局指令)
│ │ └── locales(国际化)
│ │ └── router(路由)
│ │ └── store(状态管理)
│ │ └── utils(工具函数)
│ │ └── views(视图)
│ │ └── config.ts(必须,模块的配置)
│ │ └── index.ts(模块导出)
# 其它
- 文件、组件命名用 - 连接,如:student-info.vue
- service 的描述类型,查看 build/cool/eps.d.ts 描述文件
- 创建模块、插件代码需要读取.cursor/rules的module.mdc,其它的rules根据需要进行参考
# import 引用别名
- "/@" 对应 "./src"
- "/$" 对应 "./src/modules"
- "/#" 对应 "./src/plugins"
- "/~" 对应 "./packages"

5
.dockerignore

@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local

7
.editorconfig

@ -0,0 +1,7 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
end_of_line = lf
indent_style = tab
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true

5
.env

@ -0,0 +1,5 @@
# 应用名称
VITE_NAME = "COOL-ADMIN"
# 网络超时请求时间
VITE_TIMEOUT = 30000

4
.gitattributes

@ -0,0 +1,4 @@
*.js text eol=lf
*.json text eol=lf
*.ts text eol=lf
*.vue text eol=lf

20
.gitignore

@ -0,0 +1,20 @@
.DS_Store
node_modules/
/dist/
dist-ssr/
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.project
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
vite.config.ts.timestamp*

16
.hintrc

@ -0,0 +1,16 @@
{
"extends": [
"development"
],
"hints": {
"meta-viewport": "off",
"axe/text-alternatives": [
"default",
{
"document-title": "off"
}
],
"disown-opener": "off",
"css-prefix-order": "off"
}
}

10
.prettierrc.json

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"useTabs": true,
"tabWidth": 4,
"printWidth": 100,
"singleQuote": true,
"arrowParens": "avoid",
"trailingComma": "none"
}

15
.vscode/config.code-snippets

@ -0,0 +1,15 @@
{
"module-config": {
"prefix": "module-config",
"scope": "typescript",
"body": [
"import { type ModuleConfig } from \"/@/cool\";",
"",
"export default (): ModuleConfig => {",
" return {};",
"};",
""
],
"description": "module config snippets"
}
}

100
.vscode/crud.code-snippets

@ -0,0 +1,100 @@
{
"cl-crud": {
"prefix": "cl-crud",
"scope": "vue",
"body": [
"<template>",
" <cl-crud ref=\"Crud\">",
" <cl-row>",
" <!-- 刷新按钮 -->",
" <cl-refresh-btn />",
" <!-- 新增按钮 -->",
" <cl-add-btn />",
" <!-- 删除按钮 -->",
" <cl-multi-delete-btn />",
" <cl-flex1 />",
" <!-- 条件搜索 -->",
" <cl-search ref=\"Search\" />",
" </cl-row>",
"",
" <cl-row>",
" <!-- 数据表格 -->",
" <cl-table ref=\"Table\" />",
" </cl-row>",
"",
" <cl-row>",
" <cl-flex1 />",
" <!-- 分页控件 -->",
" <cl-pagination />",
" </cl-row>",
"",
" <!-- 新增、编辑 -->",
" <cl-upsert ref=\"Upsert\" />",
" </cl-crud>",
"</template>",
"",
"<script lang=\"ts\" setup>",
"defineOptions({",
" name: \"$1\"",
"});",
"",
"import { useCrud, useTable, useUpsert, useSearch } from \"@cool-vue/crud\";",
"import { useCool } from \"/@/cool\";",
"",
"const { service } = useCool();",
"",
"// cl-upsert",
"const Upsert = useUpsert({",
" items: []",
"});",
"",
"// cl-table",
"const Table = useTable({",
" columns: []",
"});",
"",
"// cl-search",
"const Search = useSearch();",
"",
"// cl-crud",
"const Crud = useCrud(",
" {",
" service: service$2",
" },",
" (app) => {",
" app.refresh();",
" }",
");",
"",
"// 刷新",
"function refresh(params?: any) {",
" Crud.value?.refresh(params);",
"}",
"</script>",
""
],
"description": "cl-crud snippets"
},
"cl-filter": {
"prefix": "cl-filter",
"scope": "html",
"body": [
"<cl-filter label=\"\">",
" <cl-select :options=\"[$1]\" prop=\"\" />",
"</cl-filter>"
],
"description": "cl-filter snippets"
},
"slot-item": {
"prefix": "slot item",
"scope": "html",
"body": ["<template #slot-$1=\"{ scope }\">", "</template>", ""],
"description": "slot snippets"
},
"slot-column": {
"prefix": "slot column",
"scope": "html",
"body": ["<template #column-$1=\"{ scope }\">", " ", "</template>", ""],
"description": "column slot snippets"
}
}

3
.vscode/extensions.json

@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}

17
.vscode/settings.json

@ -0,0 +1,17 @@
{
"editor.cursorSmoothCaretAnimation": "on",
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts",
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .prettier*, prettier*, .editorconfig"
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

14
Dockerfile

@ -0,0 +1,14 @@
FROM node:lts-alpine
WORKDIR /build
# 设置npm镜像
RUN npm config set registry https://registry.npmmirror.com
COPY package.json /build/package.json
RUN npm install
COPY ./ /build
RUN npm run build
FROM nginx
RUN mkdir /app
COPY --from=0 /build/dist /app
COPY --from=0 /build/nginx.conf /etc/nginx/nginx.conf
EXPOSE 80

33
LICENSE

@ -0,0 +1,33 @@
MIT License
Copyright (c) [2025] [厦门闪酷科技开发有限公司]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
MIT 许可证
版权所有 (c) [2025] [厦门闪酷科技开发有限公司]
特此免费授予获得本软件及相关文档文件(“软件”)副本的任何人无限制地处理本软件的权限,包括但不限于使用、复制、修改、合并、发布、分发、再许可和/或销售软件的副本,并允许软件提供给其的人员这样做,但须符合以下条件:
上述版权声明和本许可声明应包含在软件的所有副本或主要部分中。
本软件按“原样”提供,不提供任何明示或暗示的担保,包括但不限于对适销性、特定用途适用性和非侵权的担保。在任何情况下,作者或版权持有人均不对因软件或软件使用或其他交易而产生的任何索赔、损害或其他责任负责,无论是在合同诉讼、侵权诉讼或其他诉讼中。

78
README.md

@ -0,0 +1,78 @@
# cool-admin [vue3 - ts - vite]
<p align="center">
<a href="https://show.cool-admin.com/" target="blank"><img src="https://admin.cool-js.com/logo.png" width="200" alt="cool-admin Logo" /></a>
</p>
<p align="center">cool-admin 一个很酷的后台权限管理系统,开源免费,模块化、插件化、极速开发 CRUD,方便快速构建迭代后台管理系统, 到<a href="https://cool-js.com" target="_blank">文档</a> 进一步了解</p>
<p align="center">
<a href="https://github.com/cool-team-official/cool-admin-vue/blob/master/LICENSE" target="_blank"><img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="GitHub license" />
<a href=""><img src="https://img.shields.io/github/package-json/v/cool-team-official/cool-admin-vue?style=flat-square" alt="GitHub tag"></a>
<img src="https://img.shields.io/github/last-commit/cool-team-official/cool-admin-vue?style=flat-square" alt="GitHub tag"></a>
</p>
## 特性
Ai时代,很多老旧的框架已经无法满足现代化的开发需求,Cool-Admin开发了一系列的功能,让开发变得更简单、更快速、更高效。
- **Ai编码**:通过微调大模型学习框架特有写法,实现简单功能从Api接口到前端页面的一键生成
- **流程编排**:通过拖拽编排方式,即可实现类似像智能客服这样的功能
- **模块化**:代码是模块化的,清晰明了,方便维护
- **插件化**:插件化的设计,可以通过安装插件的方式扩展如:支付、短信、邮件等功能
![](https://cool-show.oss-cn-shanghai.aliyuncs.com/admin/flow.png)
## 地址
- [📌 v7 版本](https://github.com/cool-team-official/cool-admin-vue/tree/7.x)
- [🌐 码云仓库](https://gitee.com/cool-team-official/cool-admin-vue)
## 视频教程
[官方 B 站视频教程](https://www.bilibili.com/video/BV1j1421R7aB)
## 演示
[https://show.cool-admin.com](https://show.cool-admin.com)
账户:admin,密码:123456
<img src="https://cool-show.oss-cn-shanghai.aliyuncs.com/admin/home-mini.png" alt="Admin Home" ></a>
## 项目后端
[https://github.com/cool-team-official/cool-admin-midway](https://github.com/cool-team-official/cool-admin-midway)
[https://gitee.com/cool-team-official/cool-admin-midway](https://gitee.com/cool-team-official/cool-admin-midway)
[https://gitcode.com/cool_team/cool-admin-midway](https://gitcode.com/cool_team/cool-admin-midway)
## 微信群
<img width="260" src="https://cool-show.oss-cn-shanghai.aliyuncs.com/admin/wechat.jpeg" alt="Admin Wechat"></a>
## 安装项目依赖
推荐使用 `pnpm`
```shell
pnpm i
```
## 运行应用程序
安装过程完成后,运行以下命令启动服务。您可以在浏览器中预览网站 [http://localhost:9000](http://localhost:9000)
```shell
pnpm dev
```
### 低价服务器
[阿里云、腾讯云、华为云低价云服务器,不限新老](https://cool-js.com/service/cloud)

1588
build/cool/eps.d.ts

File diff suppressed because it is too large

1
build/cool/eps.json

File diff suppressed because one or more lines are too long

10
env.d.ts

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_NAME: string;
readonly VITE_TIMEOUT: number;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

73
eslint.config.js

@ -0,0 +1,73 @@
import pluginVue from 'eslint-plugin-vue';
import vueTsEslintConfig from '@vue/eslint-config-typescript';
import prettier from 'eslint-plugin-prettier';
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting';
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: [
'**/dist/**',
'**/dist-ssr/**',
'**/coverage/**',
'**/packages/**',
'**/build/**',
],
},
...pluginVue.configs['flat/recommended'],
...vueTsEslintConfig(),
skipFormatting,
{
languageOptions: {
parserOptions: {
ecmaVersion: 2020,
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
'@typescript-eslint/ban-ts-ignore': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'space-before-function-paren': 'off',
'no-unused-vars': 'off',
'no-use-before-define': 'off',
'no-self-assign': 'off',
'vue/no-mutating-props': 'off',
'vue/no-template-shadow': 'off',
'vue/no-v-html': 'off',
'vue/component-name-in-template-casing': ['error', 'kebab-case'],
'vue/component-definition-name-casing': ['error', 'kebab-case'],
'vue/attributes-order': 'off',
'vue/one-component-per-file': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/max-attributes-per-line': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/multi-word-component-names': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/attribute-hyphenation': 'off',
'vue/html-self-closing': 'off',
'vue/require-default-prop': 'off',
'vue/v-on-event-hyphenation': 'off',
'vue/block-lang': 'off',
},
},
];

178
index.html

@ -0,0 +1,178 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="referer" content="never" />
<meta name="renderer" content="webkit" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=0"
/>
<title></title>
<link rel="icon" href="./favicon.ico" />
<style>
html,
body,
#app {
height: 100%;
overflow: hidden;
}
* {
-webkit-font-smoothing: antialiased;
font-family:
'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
}
.preload__wrap {
display: flex;
flex-direction: column;
letter-spacing: 1px;
background-color: #2f3447;
position: fixed;
left: 0;
top: 0;
height: 100%;
width: 100%;
z-index: 9999;
transition: all 0.3s ease-in;
opacity: 1;
pointer-events: none;
}
.preload__wrap.is-hide {
opacity: 0;
}
.preload__container {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
width: 100%;
user-select: none;
-webkit-user-select: none;
flex-grow: 1;
}
.preload__name {
font-size: 30px;
color: #fff;
letter-spacing: 5px;
font-weight: bold;
margin-bottom: 30px;
min-height: 50px;
}
.preload__title {
color: #fff;
font-size: 14px;
margin: 30px 0 20px 0;
min-height: 20px;
}
.preload__sub-title {
color: #ababab;
font-size: 12px;
min-height: 20px;
}
.preload__name,
.preload__title,
.preload__sub-title {
animation: s 0.5s ease-in;
}
@keyframes s {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.preload__loading {
height: 44px;
width: 44px;
border-radius: 30px;
border: 7px solid currentColor;
border-bottom-color: #2f3447;
position: relative;
animation:
r 1s infinite cubic-bezier(0.17, 0.67, 0.83, 0.67),
bc 2s infinite ease-in;
transform: rotate(0deg);
box-sizing: border-box;
}
@keyframes r {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.preload__loading::after,
.preload__loading::before {
content: '';
display: inline-block;
position: absolute;
bottom: -2px;
height: 7px;
width: 7px;
border-radius: 10px;
background-color: currentColor;
}
.preload__loading::after {
left: -1px;
}
.preload__loading::before {
right: -1px;
}
@keyframes bc {
0% {
color: #689cc5;
}
25% {
color: #b3b7e2;
}
50% {
color: #93dbe9;
}
75% {
color: #abbd81;
}
100% {
color: #689cc5;
}
}
</style>
</head>
<body>
<div class="preload__wrap" id="Loading">
<div class="preload__container">
<div class="preload__name"></div>
<div class="preload__loading"></div>
<div class="preload__title"></div>
<div class="preload__sub-title"></div>
</div>
</div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

72
nginx.conf

@ -0,0 +1,72 @@
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
upstream cool {
server midway:8001;
}
server {
listen 80;
server_name localhost;
location / {
root /app;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://cool/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
#缓存相关配置
#proxy_cache cache_one;
#proxy_cache_key $host$request_uri$is_args$args;
#proxy_cache_valid 200 304 301 302 1h;
#持久化连接相关配置
proxy_connect_timeout 3000s;
proxy_read_timeout 86400s;
proxy_send_timeout 3000s;
#proxy_http_version 1.1;
#proxy_set_header Upgrade $http_upgrade;
#proxy_set_header Connection "upgrade";
add_header X-Cache $upstream_cache_status;
#expires 12h;
}
# socket需额外配置
location /socket {
proxy_pass http://cool/socket;
proxy_connect_timeout 3600s; #配置点1
proxy_read_timeout 3600s; #配置点2,如果没效,可以考虑这个时间配置长一点
proxy_send_timeout 3600s; #配置点3
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
#proxy_bind $remote_addr transparent;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
rewrite /socket/(.*) /$1 break;
proxy_redirect off;
}
}
}

5618
package-lock.json

File diff suppressed because it is too large

77
package.json

@ -0,0 +1,77 @@
{
"name": "cool-admin-vue",
"version": "8.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"build-static": "vite build --mode static",
"build-demo": "vite build --mode demo",
"preview": "vite preview",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"@cool-vue/crud": "^8.0.6",
"@element-plus/icons-vue": "^2.3.1",
"@vueuse/core": "^12.5.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.9",
"chardet": "^2.0.0",
"core-js": "^3.40.0",
"dayjs": "^1.11.13",
"echarts": "^5.6.0",
"element-plus": "2.10.2",
"file-saver": "^2.0.5",
"lodash-es": "^4.17.21",
"marked": "^14.1.3",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"pinia": "^2.3.1",
"socket.io-client": "^4.8.3",
"store": "^2.0.12",
"vue": "^3.5.13",
"vue-echarts": "^7.0.3",
"vue-i18n": "^11.0.1",
"vue-router": "^4.5.0",
"vuedraggable": "^4.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@cool-vue/vite-plugin": "^8.2.2",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@rushstack/eslint-patch": "^1.10.5",
"@tsconfig/node20": "^20.1.4",
"@types/file-saver": "^2.0.7",
"@types/lodash-es": "^4.17.12",
"@types/mockjs": "^1.0.10",
"@types/node": "^20.19.37",
"@types/nprogress": "^0.2.3",
"@types/store": "^2.0.5",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vue/compiler-sfc": "^3.5.13",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0",
"@vue/runtime-core": "^3.5.30",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-vue": "^9.32.0",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"rollup-plugin-visualizer": "^5.14.0",
"sass": "1.81.0",
"tailwindcss": "^3.4.17",
"terser": "^5.36.0",
"typescript": "~5.5.4",
"vite": "^5.4.14",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "^7.7.1",
"vue-tsc": "^2.2.0"
}
}

23
packages/crud/.gitignore

@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

9
packages/crud/.prettierrc

@ -0,0 +1,9 @@
{
"tabWidth": 4,
"useTabs": true,
"semi": true,
"jsxBracketSameLine": true,
"singleQuote": false,
"printWidth": 100,
"trailingComma": "none"
}

33
packages/crud/README.md

@ -0,0 +1,33 @@
# 介绍
**cool-admin for vue**是基于[Vue.js](https://v3.cn.vuejs.org)开发。
[cool-admin 官方文档](https://cool-js.com/)
尝试 `cool-admin` 最简单的方法就是查看文档及运行示例。
<img src='https://vue.cool-admin.com/show/admin.png' />
[Ai极速编码 🔥 在线体验](https://show.cool-admin.com/helper/ai-code)
<img src='https://vue.cool-admin.com/show/code.png' />
## 代码仓库
**cool-admin for vue** 是开源免费的,遵循[MIT](https://baike.baidu.com/item/MIT/10772952)开源协议,意味着您无需支付任何费用,也无需授权,即可将它应用到您的产品中。
开源免费,并不意味着您可以将 cool-admin 应用到非法的领域,比如涉及赌博,暴力等方面。如因此产生纠纷等法律问题,`cool-admin`不承担任何责任。
[https://github.com/cool-team-official/cool-admin-vue](https://github.com/cool-team-official/cool-admin-vue)
```shell
git clone https://github.com/cool-team-official/cool-admin-vue.git
```
## 技术选型
- [Vue.js](https://v3.cn.vuejs.org),基础框架;
- [VueRouter](https://router.vuejs.org),Vue.js 官方路由;
- [Pinia](https://pinia.vuejs.org),轻量级状态管理库;
- [ElementPlus](https://element-plus.gitee.io/zh-CN),桌面端组件库;
- [Vite](https://vitejs.cn),构建工具;

811
packages/crud/index.d.ts

@ -0,0 +1,811 @@
// vue
declare namespace Vue {
interface Ref<T = any> {
value: T;
}
type Emit = (name: any, ...args: any[]) => void;
}
// element-plus
declare namespace ElementPlus {
type Size = "large" | "default" | "small";
type Align = "left" | "center" | "right";
interface FormProps {
inline?: boolean;
labelPosition?: "left" | "right" | "top";
labelWidth?: string | number;
labelSuffix?: string;
hideRequiredAsterisk?: boolean;
showMessage?: boolean;
inlineMessage?: boolean;
statusIcon?: boolean;
validateOnRuleChange?: boolean;
size?: Size;
disabled?: boolean;
[key: string]: any;
}
}
// mitt
declare interface Mitt {
on(name: string, callback: (data: any) => void): void;
off(name: string, callback: (data: any) => void): void;
emit(name: string, data?: any): void;
}
// emitter
declare interface EmitterItem {
name: string;
callback(data: any, events: { refresh(params: any): void; crudList: ClCrud.Ref[] }): void;
}
declare interface Emitter {
list: EmitterItem[];
init(events: any): void;
on(name: string, callback: (data: any) => void): void;
emit(name: string, data?: any): void;
}
// 方法
declare type fn = () => void;
// 对象
declare type obj = {
[key: string]: any;
};
// 全部可选
declare type DeepPartial<T> = T extends Function
? T
: T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
// 合并
declare type Merge<A, B> = Omit<A, keyof B> & B;
// 移除 [key]
declare type RemoveIndex<T> = {
[P in keyof T as string extends P ? never : number extends P ? never : P]: T[P];
};
// 任用列表
declare type List<T> = Array<DeepPartial<T> | (() => DeepPartial<T>)>;
// 获取keys
declare type PropKey<T> = keyof RemoveIndex<T> | (string & {});
// 任意字符串
declare type AnyString = string & {};
// 类型或者 Ref 泛型
declare type RefData<T = any> = T | Vue.Ref<T>;
// browser
declare type Browser = {
screen: string;
isMini: boolean;
};
// 字典选项
declare type DictOptions = {
label?: string;
value?: any;
color?: string;
type?: string;
[key: string]: any;
}[];
// render
declare namespace Render {
type OpButton =
| `slot-${string}`
| {
label: string;
type?: string;
hidden?: boolean;
onClick(options: { scope: obj }): void;
[key: string]: any;
};
interface Props {
onChange?(value: any): void;
[key: string]: any;
}
interface Component {
name?: string;
options?: RefData<DictOptions>;
props?: RefData<Props>;
style?: obj;
slots?: {
[key: string]: (data?: any) => any;
};
vm?: any;
[key: string]: any;
}
}
// crud
declare namespace ClCrud {
declare interface Field {
comment: string;
source: string;
propertyName: string;
type: string;
dict: string | string[];
nullable: boolean;
}
interface Label {
op: string;
add: string;
delete: string;
multiDelete: string;
update: string;
refresh: string;
info: string;
search: string;
reset: string;
clear: string;
save: string;
close: string;
confirm: string;
advSearch: string;
searchKey: string;
placeholder: string;
placeholderSelect: string;
tips: string;
saveSuccess: string;
deleteSuccess: string;
deleteConfirm: string;
empty: string;
desc: string;
asc: string;
select: string;
deselect: string;
seeMore: string;
hideContent: string;
nonEmpty: string;
collapse: string;
expand: string;
[key: string]: string;
}
interface Dict {
primaryId: string;
api: {
list: string;
add: string;
update: string;
delete: string;
info: string;
page: string;
};
pagination: {
page: string;
size: string;
};
search: {
keyWord: string;
query: string;
};
sort: {
order: string;
prop: string;
};
label: Label;
}
interface Permission {
page?: boolean;
list?: boolean;
add?: boolean;
delete?: boolean;
update?: boolean;
info?: boolean;
[key: string]: any;
}
interface Params {
page: {
page?: number;
size?: number;
[key: string]: any;
};
list: obj;
add: obj;
delete: {
ids?: any[];
[key: string]: any;
};
update: {
id?: any;
[key: string]: any;
};
info: {
id?: any;
[key: string]: any;
};
}
interface Response {
page: {
list: any[];
pagination: {
total: number;
page: number;
size: number;
[key: string]: any;
};
[key: string]: any;
};
list: any[];
add: any;
update: any;
info: any;
delete: any;
}
interface Service {
api: {
page(params?: Params["page"]): Promise<Response["page"]>;
list(params?: Params["list"]): Promise<Response["list"]>;
add(params?: Params["add"]): Promise<Response["add"]>;
update(params?: Params["update"]): Promise<Response["update"]>;
info(params?: Params["info"]): Promise<Response["info"]>;
delete(params?: Params["delete"]): Promise<Response["delete"]>;
permission: Permission;
search: {
fieldEq: Field[];
fieldLike: Field[];
keyWordLikeFields: Field[];
};
[key: string]: any;
};
}
interface Config {
name: string;
service: Service["api"];
permission: Permission;
dict: Dict;
onRefresh(
params: obj,
event: {
done: fn;
next: Service["api"]["page"];
render: (data: any | any[], pagination?: Response["page"]["pagination"]) => void;
}
): void;
onDelete(
selection: obj[],
event: {
next: Service["api"]["delete"];
}
): void;
}
interface Ref {
"cl-table": ClTable.Ref;
"cl-upsert": ClUpsert.Ref;
id: number;
mitt: Mitt;
name: string;
routePath: string;
permission: Permission;
dict: Dict;
service: Service["api"];
loading: boolean;
params: obj;
selection: obj[];
set(key: "dict" | "style" | "service" | "permission", value: any): void;
done(): void;
getParams(): obj;
setParams(data: obj): void;
getPermission(key?: string): boolean;
rowInfo(data: obj): void;
rowAdd(): void;
rowEdit(data: obj): void;
rowAppend(data?: obj): void;
rowClose(): void;
rowDelete(...selection: obj[]): void;
proxy(name: string, data?: any[]): any;
paramsReplace(params: obj): obj;
refresh: Service["api"]["page"];
[key: string]: any;
}
interface Options extends DeepPartial<Config> {
service?: any;
}
}
declare namespace ClTable {
type OpButton = Array<"info" | "edit" | "delete" | AnyString | Render.OpButton>;
type ColumnType = "index" | "selection" | "expand" | "op" | AnyString;
interface Column<T = any> {
type: ColumnType;
hidden: RefData<boolean>;
component: Render.Component;
search: {
isInput: boolean;
value: any;
icon: () => any;
refreshOnChange: boolean;
component: Render.Component;
};
dict: RefData<DictOptions>;
dictFormatter: (values: DictOptions) => string;
dictColor: boolean;
dictSeparator: string;
dictAllLevels: boolean;
buttons: OpButton | ((options: { scope: T }) => OpButton);
align: ElementPlus.Align;
label: any;
renderLabel: (options: { column: any; $index: number }) => any;
className: string;
prop: PropKey<T>;
orderNum: number;
width: RefData<number | string>;
minWidth: RefData<number | string>;
renderHeader: (options: { column: any; $index: number }) => any;
sortable: boolean | "desc" | "descending" | "ascending" | "asc" | "custom";
sortMethod: fn;
sortBy: string | ((row: T, index: number) => any) | any[];
resizable: boolean;
columnKey: string;
headerAlign: ElementPlus.Align;
showOverflowTooltip: boolean;
fixed: boolean | string;
render: (row: T, column: any, value: any, index: number) => any;
formatter: (row: T, column: any, value: any, index: number) => any;
selectable: (row: T, index: number) => boolean;
reserveSelection: boolean;
filterMethod: fn;
filteredValue: unknown[];
filters: unknown[];
filterPlacement: string;
filterMultiple: boolean;
index: ((index: number) => number) | number;
sortOrders: unknown[];
children: Column<T>[];
[key: string]: any;
}
type ContextMenu = Array<
| ClContextMenu.Item
| ((row: obj, column: obj, event: PointerEvent) => ClContextMenu.Item)
| "refresh"
| "check"
| "update"
| "edit"
| "delete"
| "info"
| "order-desc"
| "order-asc"
>;
type Plugin = (options: { exposed: Ref }) => void;
interface Config<T = any> {
columns: Column<T>[];
autoHeight: boolean;
height: any;
contextMenu: ContextMenu;
defaultSort: {
prop: string;
order: "descending" | "ascending";
};
sortRefresh: boolean;
emptyText: string;
rowKey: string;
on?: {
[key: string]: (...args: any[]) => void;
};
props?: {
[key: string]: any;
};
plugins?: Plugin[];
onRowContextmenu?(row: T, column: any, event: any): void;
}
interface Ref<T = any> {
Table: any;
config: obj;
selection: T[];
data: T[];
columns: Column<T>[];
reBuild(cb?: fn): void;
calcMaxHeight(): void;
setData(data: T[]): void;
setColumns(columns: Column[]): void;
showColumn(props: PropKey<T> | PropKey<T>[], status?: boolean): void;
hideColumn(props: PropKey<T> | PropKey<T>[]): void;
changeSort(prop: PropKey<T>, order: string): void;
clearSelection(): void;
getSelectionRows(): any[];
toggleRowSelection(row: T, selected?: boolean): void;
toggleAllSelection(): void;
toggleRowExpansion(row: T, expanded?: boolean): void;
setCurrentRow(row: T): void;
clearSort(): void;
clearFilter(columnKeys: PropKey<T>[]): void;
doLayout(): void;
sort(prop: PropKey<T>, order: string): void;
scrollTo(position: { top?: number; left?: number }): void;
setScrollTop(top: number): void;
setScrollLeft(left: number): void;
updateKeyChildren(key: string, children: any[]): void;
}
interface Options<T = any> extends DeepPartial<Config<T>> {
columns?: List<ClTable.Column<T>>;
}
}
declare namespace ClFormTabs {
type labels = {
label: string;
value: string;
name?: string;
icon?: any;
lazy?: boolean;
[key: string]: any;
}[];
}
declare namespace ClForm {
type CloseAction = "close" | "save" | AnyString;
interface Rule {
type?:
| "string"
| "number"
| "boolean"
| "method"
| "regexp"
| "integer"
| "float"
| "array"
| "object"
| "enum"
| "date"
| "url"
| "hex"
| "email"
| "any";
required?: boolean;
message?: string;
min?: number;
max?: number;
trigger?: any;
validator?(rule: any, value: any, callback: (error?: Error) => void): void;
[key: string]: any;
}
interface Hook {
Fn: (value: any, options: { form: obj; prop: string; method: "submit" | "bind" }) => any;
Key:
| "number"
| "string"
| "split"
| "join"
| "boolean"
| "booleanNumber"
| "datetimeRange"
| "splitJoin"
| "json"
| "empty"
| AnyString;
Pipe: Hook["Key"] | Hook["Fn"];
Event: {
bind?: Hook["Pipe"] | Hook["Pipe"][];
submit?: Hook["Pipe"] | Hook["Pipe"][];
reset?: (prop: string) => string[];
};
}
interface Item<T = any> {
type?: "tabs";
prop?: PropKey<T>;
props?: {
labels?: ClFormTabs.labels;
justify?: "left" | "center" | "right";
color?: string;
mergeProp?: boolean;
labelWidth?: string;
error?: string;
showMessage?: boolean;
inlineMessage?: boolean;
size?: "medium" | "default" | "small";
[key: string]: any;
};
span?: number;
col?: {
span: number;
offset: number;
push: number;
pull: number;
xs: any;
sm: any;
md: any;
lg: any;
xl: any;
tag: string;
};
group?: string;
collapse?: boolean;
value?: any;
label?: string;
renderLabel?: any;
flex?: boolean;
hook?: Hook["Event"] | Hook["Key"];
hidden?: boolean | ((options: { scope: obj }) => boolean);
prepend?: Render.Component;
component?: Render.Component;
append?: Render.Component;
rules?: Rule | Rule[];
required?: boolean;
children?: Item[];
[key: string]: any;
}
interface Config<T = any> {
title?: any;
height?: any;
width?: any;
props: ElementPlus.FormProps;
items: Item[];
form: obj;
isReset?: boolean;
on?: {
open?(data: T): void;
close?(action: CloseAction, done: fn): void;
submit?(data: T, event: { close: fn; done: fn }): void;
change?(data: T, prop: string): void;
};
op: {
hidden?: boolean;
saveButtonText?: string;
closeButtonText?: string;
justify?: "flex-start" | "center" | "flex-end";
buttons?: Array<CloseAction | Render.OpButton>;
};
dialog: {
title?: any;
height?: string;
width?: string;
hideHeader?: boolean;
controls?: Array<"fullscreen" | "close" | AnyString>;
[key: string]: any;
};
[key: string]: any;
}
type Plugin = (options: {
exposed: Ref;
onOpen(cb: () => void): void;
onClose(cb: () => void): void;
onSubmit(cb: (data: obj) => obj): void;
}) => void;
type Items<T = any> = List<Item<T>>;
interface Ref<T = any> {
Form: any;
form: T;
config: {
items: Item[];
[key: string]: any;
};
open(options: Options<T>, plugins?: Plugin[]): void;
close(action?: CloseAction): void;
done(): void;
clear(): void;
reset(): void;
showLoading(): void;
hideLoading(): void;
setDisabled(flag?: boolean): void;
invokeData(data: any): void;
setData(prop: string, value: any): void;
bindForm(data: obj): void;
getForm(prop?: string): any;
setForm(prop: string, value: any): void;
setOptions(prop: string, list: DictOptions): void;
setProps(prop: string, value: any): void;
setConfig(path: string, value: any): void;
showItem(props: string[] | string): void;
hideItem(props: string[] | string): void;
toggleItem(prop: string, flag?: boolean): void;
resetFields(): void;
clearValidate(props?: string[] | string): void;
validateField(
props?: string[] | string,
callback?: (isValid: boolean, invalidFields: any[]) => void
): Promise<void>;
validate(callback: (isValid: boolean, invalidFields: any[]) => void): Promise<void>;
changeTab(value: any, valid?: boolean): Promise<any>;
setTitle(value: string): void;
submit(cb?: (data: obj) => void): void;
Tabs: {
active: RefData<string>;
list: ClFormTabs.labels;
change(value: any, valid?: boolean): Promise<any>;
[key: string]: any;
};
[key: string]: any;
}
interface Options<T = any> extends DeepPartial<Config> {
items?: Items<T>;
}
}
declare namespace ClUpsert {
interface Config<T = any> {
sync: boolean;
items: ClForm.Item[];
props: ClForm.Config["props"];
op: ClForm.Config["op"];
dialog: ClForm.Config["dialog"];
onOpen?(): void;
onOpened?(data: T): void;
onClose?(action: ClForm.CloseAction, done: fn): void;
onClosed?(): void;
onInfo?(
data: T,
event: { close: fn; done(data: T): void; next: ClCrud.Service["api"]["info"] }
): void;
onSubmit?(
data: T,
event: { close: fn; done: fn; next: ClCrud.Service["api"]["update"] }
): void;
plugins?: ClForm.Plugin[];
}
interface Ref<T = any> extends ClForm.Ref<T> {
mode: "add" | "update" | "info" | AnyString;
}
interface Options<T = any> extends DeepPartial<Config<T>> {
items?: ClForm.Items<T>;
}
}
declare namespace ClAdvSearch {
interface Config<T = any> {
items?: ClForm.Item[];
title?: string;
size?: string | number;
op?: ("clear" | "reset" | "close" | "search" | `slot-${string}`)[];
onSearch?(data: T, options: { next: ClCrud.Service["api"]["page"]; close(): void }): void;
}
interface Ref<T = any> extends ClForm.Ref<T> {}
interface Options<T = any> extends DeepPartial<Config<T>> {
items?: ClForm.Items<T>;
}
}
declare namespace ClSearch {
type Plugin = (options: { exposed: Ref }) => void;
interface Config<T = any> {
inline?: boolean;
items?: ClForm.Item[];
data?: T;
props?: ElementPlus.FormProps;
resetBtn?: boolean;
collapse?: boolean;
Form?: ClForm.Ref;
onChange?(data: T, prop: string): void;
onLoad?(data: T): void;
onSearch?(data: T, options: { next: ClCrud.Service["api"]["page"] }): void;
plugins?: Plugin[];
}
interface Ref<T = any> extends ClForm.Ref<T> {
search(params?: obj): void;
reset(): void;
}
interface Options<T = any> extends DeepPartial<Config<T>> {
items?: ClForm.Items<T>;
}
}
declare namespace ClContextMenu {
interface Item {
label: string;
prefixIcon?: any;
suffixIcon?: any;
ellipsis?: boolean;
disabled?: boolean;
hidden?: boolean;
children?: Item[];
showChildren?: boolean;
callback?(done: fn): void;
[key: string]: any;
}
interface Event {
pageX: number;
pageY: number;
[key: string]: any;
}
interface Options {
class?: string;
hover?:
| boolean
| {
target?: string;
className?: string;
};
list?: Item[];
}
interface Ref {
open(event: Event, options: Options): Exposed;
close(): void;
}
interface Exposed {
close(): void;
}
}
declare namespace ClDialog {
interface Provide {
visible: Vue.Ref<boolean>;
fullscreen: Vue.Ref<boolean>;
}
}
declare interface Config {
dict: ClCrud.Dict;
permission: ClCrud.Permission;
events: {
[key: string]: (...args: any[]) => any;
};
style: {
size: ElementPlus.Size;
colors: string[];
form: {
labelPosition: ElementPlus.FormProps["labelPosition"];
labelWidth: ElementPlus.FormProps["labelWidth"];
span: number;
plugins: ClForm.Plugin[];
};
table: {
stripe: boolean;
border: boolean;
highlightCurrentRow: boolean;
resizable: boolean;
autoHeight: boolean;
contextMenu: ClTable.ContextMenu;
column: {
minWidth: number | string;
align: ElementPlus.Align;
headerAlign: ElementPlus.Align;
opWidth: number | string;
};
plugins: ClTable.Plugin[];
};
search: {
plugins: ClSearch.Plugin[];
};
};
}
declare type Options = DeepPartial<Config>;
declare interface CrudOptions {
options: Options;
}

29
packages/crud/index.html

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="referer" content="never" />
<meta name="renderer" content="webkit" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=0"
/>
<title></title>
<link rel="icon" href="./favicon.ico" />
</head>
<body>
<div class="preload__wrap" id="Loading">
<div class="preload__container">
<p class="preload__name"></p>
<div class="preload__loading"></div>
<p class="preload__title"></p>
<p class="preload__sub-title"></p>
</div>
</div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

38
packages/crud/package.json

@ -0,0 +1,38 @@
{
"name": "@cool-vue/crud",
"version": "8.0.6",
"private": false,
"main": "./dist/index.umd.js",
"module": "./dist/index.es.js",
"types": "types/entry.d.ts",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vue/runtime-core": "^3.5.13",
"element-plus": "^2.10.4",
"lodash-es": "^4.17.21",
"vue": "^3.5.13"
},
"devDependencies": {
"@types/node": "^20.11.16",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"prettier": "^3.5.1",
"sass": "^1.85.0",
"sass-loader": "^16.0.5",
"typescript": "^5.3.3",
"vite": "^6.1.0",
"vite-plugin-dts": "^4.5.0",
"vue-tsc": "^2.2.2"
},
"files": [
"types",
"dist",
"index.d.ts",
"index.ts"
]
}

2350
packages/crud/pnpm-lock.yaml

File diff suppressed because it is too large

208
packages/crud/src/App.vue

@ -0,0 +1,208 @@
<template>
<div>
<div class="title">CRUD DEMO v8</div>
<cl-crud ref="Crud">
<div class="search">
<cl-search ref="Search" />
</div>
<cl-row>
<cl-add-btn />
<cl-flex1 />
<cl-search-key
field="name"
:field-list="[
{
label: '昵称',
value: 'name'
},
{
label: '手机号',
value: 'phone'
}
]"
refreshOnInput
/>
</cl-row>
<cl-row>
<cl-table ref="Table" :auto-height="false"></cl-table>
</cl-row>
<cl-row>
<cl-flex1 />
<cl-pagination />
</cl-row>
<cl-upsert ref="Upsert"></cl-upsert>
<cl-form ref="Form"></cl-form>
</cl-crud>
</div>
</template>
<script setup lang="tsx">
import { useTable, useForm, useUpsert, useCrud, useSearch } from "./hooks";
import { EditPen } from "@element-plus/icons-vue";
interface Data {
name?: string;
age?: number;
[key: string]: any;
}
const Upsert = useUpsert<Data>({
items: [
{
type: "tabs",
props: {
labels: [
{
label: "基础",
value: "A",
icon: EditPen
},
{
label: "高级",
value: "B"
}
]
}
},
{
group: "A",
prop: "age",
label: "年龄",
component: {
name: "el-input"
}
},
{
group: "A",
prop: "name",
label: "昵称",
component: {
name: "el-input"
},
hidden({ scope }) {
return scope.age < 18;
}
},
{
group: "B",
prop: "phone",
label: "手机",
component: {
name: "el-input"
},
hidden({ scope }) {
return scope.age < 18;
}
},
() => {
return {
group: "A",
hidden: Upsert.value?.mode == "add"
};
}
],
onOpened(data) {
console.log(data);
Upsert.value?.setForm("age", "18");
}
});
const Table = useTable<Data>(
{
contextMenu: [
{
label: "带图标",
prefixIcon: EditPen
},
{
label: "多层级",
children: [
{
label: "A",
children: [
{
label: "A-1"
}
]
},
{
label: "B"
}
]
}
],
columns: [
{
label: "姓名",
prop: "name",
search: {
component: {
name: "el-date-picker"
}
}
},
{
label: "手机号",
prop: "phone",
search: {
component: {
name: "el-date-picker"
}
}
},
{
type: "op"
}
]
},
(table) => {
console.log(table);
}
);
const Crud = useCrud(
{
service: "test"
},
(app) => {
app.refresh();
}
);
const Form = useForm<Data>();
const Search = useSearch({
collapse: true,
resetBtn: true,
items: [
{
label: "姓名",
prop: "name",
component: {
name: "el-input"
},
hook: {
reset() {
return [];
}
}
}
]
});
</script>
<style scoped>
.title {
text-align: center;
font-size: 14px;
font-weight: bold;
}
</style>

21
packages/crud/src/components/add-btn/index.tsx

@ -0,0 +1,21 @@
import { defineComponent } from "vue";
import { useConfig, useCore } from "../../hooks";
export default defineComponent({
name: "cl-add-btn",
setup(_, { slots }) {
const { crud } = useCore();
const { style } = useConfig();
return () => {
return (
crud.getPermission("add") && (
<el-button type="primary" size={style.size} onClick={crud.rowAdd}>
{slots.default?.() || crud.dict.label.add}
</el-button>
)
);
};
}
});

31
packages/crud/src/components/adv/btn.tsx

@ -0,0 +1,31 @@
import { useConfig, useCore } from "../../hooks";
import { defineComponent } from "vue";
import { Search } from "@element-plus/icons-vue";
export default defineComponent({
name: "cl-adv-btn",
components: {
Search
},
setup(_, { slots }) {
const { crud, mitt } = useCore();
const { style } = useConfig();
function open() {
mitt.emit("crud.openAdvSearch");
}
return () => {
return (
<el-button size={style.size} onClick={open} class="cl-adv-btn">
<el-icon>
<Search />
</el-icon>
{slots.default?.() || crud.dict.label.advSearch}
</el-button>
);
};
}
});

203
packages/crud/src/components/adv/search.tsx

@ -0,0 +1,203 @@
import { defineComponent, h, inject, mergeProps, nextTick, type PropType, reactive, ref } from "vue";
import { Close } from "@element-plus/icons-vue";
import { useBrowser, useConfig, useCore } from "../../hooks";
import { renderNode } from "../../utils/vnode";
import { useApi } from "../form/helper";
import { isArray } from "lodash-es";
export default defineComponent({
name: "cl-adv-search",
components: {
Close
},
props: {
// 表单项
items: {
type: Array as PropType<ClForm.Item[]>,
default: () => []
},
// 标题
title: String,
// 窗体大小
size: {
type: [Number, String],
default: "30%"
},
// 操作按钮
op: {
type: Array,
default: () => ["clear", "reset", "close", "search"]
},
// 搜索钩子
onSearch: Function
},
emits: ["reset", "clear"],
setup(props, { emit, slots, expose }) {
const { crud, mitt } = useCore();
const { style } = useConfig();
const browser = useBrowser();
// 配置
const config = reactive<ClAdvSearch.Config>(
mergeProps(props, inject("useAdvSearch__options") || {})
);
// cl-form
const Form = ref<ClForm.Ref>();
// el-drawer
const Drawer = ref();
// 是否可见
const visible = ref(false);
// 打开
function open() {
visible.value = true;
nextTick(() => {
Form.value?.open({
items: config.items || [],
op: {
hidden: true
},
isReset: false
});
});
}
// 关闭
function close() {
Drawer.value.handleClose();
}
// 重置数据
function reset() {
const d: any = {};
config.items?.map((e) => {
if (typeof e.hook != 'string' && e.hook?.reset) {
const props = e.hook.reset(e.prop!)
if (isArray(props)) {
props.forEach((prop) => {
d[prop] = undefined;
})
}
}
d[e.prop!] = undefined;
});
// 重置表单
Form.value?.reset();
// 列表刷新
search();
// 重置事件
emit("reset", d);
}
// 清空数据
function clear() {
Form.value?.clear();
emit("clear");
}
// 搜素请求
function search(params?: any) {
const form = Form.value?.getForm();
function next(data: any) {
Form.value?.done();
close();
return crud.refresh({
...data,
...params,
page: 1
});
}
if (config.onSearch) {
config.onSearch(form, { next, close });
} else {
next(form);
}
}
// 消息事件
mitt.on("crud.openAdvSearch", open);
// 渲染表单
function renderForm() {
return h(<cl-form ref={Form} inner enable-plugin={false} />, {}, slots);
}
// 渲染底部
function renderFooter() {
const fns = { search, reset, clear, close };
return config.op?.map((e: string) => {
switch (e) {
case "search":
case "reset":
case "clear":
case "close":
return h(
<el-button />,
{
type: e == "search" ? "primary" : null,
size: style.size,
onClick: () => {
fns[e]()
}
},
{ default: () => crud.dict.label[e] }
);
default:
return renderNode(e, {
scope: Form.value?.getForm(),
slots
});
}
});
}
expose({
open,
close,
clear,
...useApi({ Form }),
reset,
Form
});
return () => {
return (
<el-drawer
ref={Drawer}
modal-class="cl-adv-search"
v-model={visible.value}
direction="rtl"
with-header={false}
size={browser.isMini ? "100%" : config.size}>
<div class="cl-adv-search__header">
<span class="text">{config.title || crud.dict.label.advSearch}</span>
<el-icon size={20} onClick={close}>
<Close />
</el-icon>
</div>
<div class="cl-adv-search__container">{renderForm()}</div>
<div class="cl-adv-search__footer">{renderFooter()}</div>
</el-drawer>
);
};
}
});

282
packages/crud/src/components/context-menu/index.tsx

@ -0,0 +1,282 @@
import {
defineComponent,
nextTick,
onMounted,
reactive,
ref,
h,
render,
toRaw,
type PropType
} from "vue";
import { isString } from "lodash-es";
import { addClass, contains, removeClass } from "../../utils";
import { useRefs } from "../../hooks";
import { ElIcon } from "element-plus";
import { ArrowRight } from "@element-plus/icons-vue";
const ClContextMenu = defineComponent({
name: "cl-context-menu",
props: {
show: Boolean,
options: {
type: Object as PropType<ClContextMenu.Options>,
default: () => ({})
},
event: {
type: Object,
default: () => ({})
}
},
setup(props, { expose, slots }) {
const { refs, setRefs } = useRefs();
// 是否可见
const visible = ref(props.show || false);
// 按钮列表
const list = ref<ClContextMenu.Item[]>([]);
// 样式
const style = reactive({
left: "0px",
top: "0px"
});
// 选中值
const ids = ref("");
// 阻止默认事件
function stopDefault(e: any) {
if (e.preventDefault) {
e.preventDefault();
}
if (e.stopPropagation) {
e.stopPropagation();
}
}
// 解析列表
function parseList(list: ClContextMenu.Item[]) {
function deep(list: ClContextMenu.Item[]) {
list.forEach((e) => {
e.showChildren = false;
if (e.children) {
deep(e.children);
}
});
}
deep(list);
return list;
}
// 目标元素
let targetEl: any;
// 关闭
function close() {
visible.value = false;
ids.value = "";
if (targetEl) {
removeClass(targetEl, "cl-context-menu__target");
}
}
// 打开
function open(event: any, options: ClContextMenu.Options = {}) {
// 阻止默认事件
stopDefault(event);
// 显示
visible.value = true;
// 元素
const el = refs["context-menu"].querySelector(".cl-context-menu__box") as HTMLElement;
// 点击样式
if (options?.hover) {
const d = options.hover === true ? {} : options.hover;
targetEl = event.target;
if (targetEl && isString(targetEl.className)) {
if (d.target) {
while (!targetEl.className.includes(d.target)) {
targetEl = targetEl.parentNode;
}
}
addClass(targetEl, d.className || "cl-context-menu__target");
}
}
// 自定义样式
if (options?.class) {
addClass(el, options.class);
}
// 菜单列表
if (options?.list) {
list.value = parseList(options.list);
}
nextTick(() => {
// 计算位置
let left = event.pageX;
let top = event.pageY;
// 组件方式用 offset 计算
if (!props.show) {
left = event.offsetX;
top = event.offsetY;
}
const { clientHeight: h1, clientWidth: w1 } = event.target?.ownerDocument.body;
const { clientHeight: h2, clientWidth: w2 } = el;
if (top + h2 > h1) {
top = h1 - h2 - 5;
}
if (left + w2 > w1) {
left = w1 - w2 - 5;
}
style.left = left + "px";
style.top = top + "px";
})
return {
close
};
}
// 行点击
function rowClick(item: ClContextMenu.Item, id: string) {
ids.value = id;
if (item.disabled) {
return false;
}
if (item.callback) {
return item.callback(close);
}
if (item.children) {
item.showChildren = !item.showChildren;
} else {
close();
}
}
expose({
open,
close
});
onMounted(function () {
if (visible.value) {
const { body, documentElement } = props.event.target.ownerDocument;
// 添加到 body 下
body.appendChild(refs["context-menu"]);
// 关闭事件
(documentElement || body).addEventListener("mousedown", (e: any) => {
const el = refs["context-menu"];
if (!contains(el, e.target) && el != e.target) {
close();
}
});
// 默认打开
open(props.event, props?.options);
}
});
return () => {
function deep(list: ClContextMenu.Item[], pId: string, level: number) {
return (
<div class={["cl-context-menu__box", level > 1 && "is-append"]}>
{list
.filter((e) => !e.hidden)
.map((e, i) => {
const id = `${pId}-${i}`;
if (!e.suffixIcon) {
// 默认图标
if (e.children) {
e.suffixIcon = ArrowRight;
}
}
return (
<div
class={{
"is-active": ids.value.includes(id),
"is-ellipsis": e.ellipsis ?? true,
"is-disabled": e.disabled
}}
onClick={(ev: MouseEvent) => {
rowClick(e, id);
ev.stopPropagation();
}}
>
{/* 前缀图标 */}
{e.prefixIcon && <ElIcon>{h(toRaw(e.prefixIcon))}</ElIcon>}
{/* 标题 */}
<span>
{e.label}
</span>
{/* 后缀图标 */}
{e.suffixIcon && <ElIcon>{h(toRaw(e.suffixIcon))}</ElIcon>}
{/* 子集 */}
{e.children &&
e.showChildren &&
deep(e.children, id, level + 1)}
</div>
);
})}
</div>
);
}
return (
visible.value && (
<div
class="cl-context-menu"
ref={setRefs("context-menu")}
style={style}
onContextmenu={stopDefault}>
{slots.default ? slots.default() : deep(list.value, "0", 1)}
</div>
)
);
};
}
});
export const ContextMenu = {
open(event: any, options: ClContextMenu.Options) {
const vm = h(ClContextMenu, {
show: true,
event,
options
});
render(vm, event.target.ownerDocument.createElement("div"));
return vm.component?.exposed as ClContextMenu.Exposed;
}
};
export default ClContextMenu;

287
packages/crud/src/components/crud/helper.ts

@ -0,0 +1,287 @@
import { ElMessageBox, ElMessage } from "element-plus";
import { Mitt } from "../../utils/mitt";
import { ref } from "vue";
import { assign, isArray, isFunction } from "lodash-es";
import { merge } from "../../utils";
interface Options {
mitt: Mitt;
config: ClCrud.Config;
crud: ClCrud.Ref;
}
export function useHelper({ config, crud, mitt }: Options) {
// 刷新随机值,避免脏数据
const refreshRd = ref(0);
// 获取权限
function getPermission(key: "page" | "list" | "info" | "update" | "add" | "delete"): boolean {
return Boolean(crud.permission[key]);
}
// 根据字典替换请求参数
function paramsReplace(params: obj) {
const { pagination, search, sort } = crud.dict;
// 请求参数
const a: any = { ...params };
// 字典
const b: any = { ...pagination, ...search, ...sort };
for (const i in b) {
if (a[i]) {
if (i != b[i]) {
a[`_${b[i]}`] = a[i];
delete a[i];
}
}
}
for (const i in a) {
if (i[0] === "_") {
a[i.substr(1)] = a[i];
delete a[i];
}
}
return a;
}
// 刷新请求
function refresh(params?: obj) {
const { service, dict } = crud;
return new Promise((success, error) => {
// 合并请求参数
const reqParams = paramsReplace(assign(crud.params, params));
// Loading
crud.loading = true;
// 预防脏数据
const rd = (refreshRd.value = Math.random());
// 完成事件
function done() {
crud.loading = false;
}
// 渲染
function render(data: any | any[], pagination?: any) {
const res = isArray(data) ? { list: data, pagination } : data;
done();
success(res);
mitt.emit("crud.refresh", res);
}
// 下一步
function next(params: obj): Promise<any> {
return new Promise(async (resolve, reject) => {
await service[dict.api.page](params)
.then((res) => {
if (rd != refreshRd.value) {
return false;
}
if (isArray(res)) {
res = {
list: res,
pagination: {
total: res.length
}
};
}
render(res);
resolve(res);
})
.catch((err) => {
ElMessage.error(err.message);
error(err);
reject(err);
});
done();
});
}
// 刷新钩子
if (config.onRefresh) {
config.onRefresh(reqParams, { next, done, render });
} else {
next(reqParams);
}
});
}
// 打开详情
function rowInfo(data: any) {
mitt.emit("crud.proxy", {
name: "info",
data: [data]
});
}
// 打开新增
function rowAdd() {
mitt.emit("crud.proxy", {
name: "add"
});
}
// 打开编辑
function rowEdit(data: any) {
mitt.emit("crud.proxy", {
name: "edit",
data: [data]
});
}
// 打开追加
function rowAppend(data: any) {
mitt.emit("crud.proxy", {
name: "append",
data: [data]
});
}
// 关闭新增、编辑弹窗
function rowClose() {
mitt.emit("crud.proxy", {
name: "close"
});
}
// 删除请求
function rowDelete(...selection: any[]) {
const { service, dict } = crud;
// 参数
const params = {
ids: selection.map((e) => e[dict.primaryId])
};
// 下一步
async function next(data: obj) {
return new Promise((resolve, reject) => {
ElMessageBox({
type: "warning",
title: dict.label.tips,
message: dict.label.deleteConfirm,
confirmButtonText: dict.label.confirm,
cancelButtonText: dict.label.close,
showCancelButton: true,
async beforeClose(action, instance, done) {
if (action === "confirm") {
instance.confirmButtonLoading = true;
await service[dict.api.delete]({ ...params, ...data })
.then((res) => {
ElMessage.success(dict.label.deleteSuccess);
refresh();
resolve(res);
})
.catch((err) => {
ElMessage.error(err.message);
reject(err);
});
instance.confirmButtonLoading = false;
}
done();
}
}).catch(() => null);
});
}
// 删除钩子
if (config.onDelete) {
config.onDelete(selection, { next });
} else {
next(params);
}
}
// 代理
function proxy(name: string, data?: any[]) {
mitt.emit("crud.proxy", {
name,
data
});
}
// 获取请求参数
function getParams() {
return crud.params;
}
// 替换请求参数
function setParams(data: obj) {
merge(crud.params, data);
}
// 设置
function set(key: string, value: any) {
if (!value) {
return false;
}
switch (key) {
// 服务
case "service":
Object.assign(crud.service, value);
crud.service.__proto__ = value.__proto__;
if (value._permission) {
for (const i in value._permission) {
crud.permission[i] = value._permission[i];
}
}
break;
// 权限
case "permission":
if (isFunction(value)) {
merge(crud.permission, value(crud));
} else {
merge(crud.permission, value);
}
break;
default:
merge(crud[key], value);
break;
}
}
// 监听事件
function on(name: string, callback: fn) {
mitt.on(`${name}-${crud.id}`, callback);
}
// 默认值
set("dict", config.dict);
set("service", config.service);
set("permission", config.permission);
return {
proxy,
set,
on,
rowInfo,
rowAdd,
rowEdit,
rowAppend,
rowDelete,
rowClose,
refresh,
getPermission,
paramsReplace,
getParams,
setParams
};
}

91
packages/crud/src/components/crud/index.tsx

@ -0,0 +1,91 @@
import { defineComponent, getCurrentInstance, inject, provide, reactive } from "vue";
import { cloneDeep } from "lodash-es";
import { useHelper } from "./helper";
import { Mitt } from "../../utils/mitt";
import { mergeConfig, merge } from "../../utils";
import { crudList } from "../../emitter";
import { useConfig } from "../../hooks";
export default defineComponent({
name: "cl-crud",
props: {
// 组件名
name: String,
// 是否有边框
border: Boolean,
// 内间距
padding: {
type: String,
default: "10px"
}
},
setup(props, { slots, expose }) {
// 当前实例
const inst = getCurrentInstance();
// 配置
const config = reactive<ClCrud.Config>(mergeConfig(inject("useCrud__options") || {}));
// 事件
const mitt = new Mitt(inst?.uid);
// 全局配置
const { dict, permission } = useConfig();
// 参数
const crud = reactive(
merge(
{
id: props.name || inst?.uid,
// 绑定的路由地址
routePath: location.pathname || "/",
// 表格加载状态
loading: false,
// 表格已选列
selection: [],
// 请求参数
params: {
page: 1,
size: 20
},
// 请求服务
service: {},
// 字典
dict: {},
// 权限
permission: {},
// 事件
mitt,
// 配置
config
},
cloneDeep({ dict, permission })
)
);
// 追加参数
merge(crud, useHelper({ config, crud, mitt }));
// 集合
crudList.push(crud);
// 值穿透
provide("crud", crud);
provide("mitt", mitt);
// 导出
expose(crud);
return () => {
return (
<div
class={["cl-crud", { "is-border": props.border }]}
style={{ padding: props.padding }}>
{slots.default?.()}
</div>
);
};
}
});

288
packages/crud/src/components/dialog/index.tsx

@ -0,0 +1,288 @@
import { defineComponent, h, ref, watch, computed, provide } from "vue";
import { Close, FullScreen, Minus } from "@element-plus/icons-vue";
import { renderNode } from "../../utils/vnode";
import { isArray, isBoolean } from "lodash-es";
import { useBrowser } from "../../hooks";
export default defineComponent({
name: "cl-dialog",
components: {
Close,
FullScreen,
Minus
},
props: {
// 是否可见
modelValue: {
type: Boolean,
default: false
},
// Extraneous non-props attributes
props: Object,
// 标题
title: {
type: String,
default: "-"
},
// 高度
height: String,
// 宽度
width: {
type: String,
default: "50%"
},
// 內间距
padding: {
type: String,
default: "20px"
},
// 是否缓存
keepAlive: Boolean,
// 是否全屏
fullscreen: Boolean,
// 控制按钮
controls: {
type: Array,
default: () => ["fullscreen", "close"]
},
// 隐藏头部元素
hideHeader: Boolean,
// 关闭前
beforeClose: Function,
// 是否需要滚动条
scrollbar: {
type: Boolean,
default: true
},
// 背景透明
transparent: Boolean
},
emits: ["update:modelValue", "fullscreen-change"],
setup(props, { emit, expose, slots }) {
const browser = useBrowser();
// el-dialog
const Dialog = ref();
// 是否全屏
const fullscreen = ref(false);
// 是否可见
const visible = ref(false);
// 缓存数
const cacheKey = ref(0);
// 是否全屏
const isFullscreen = computed(() => {
return browser && browser.isMini ? true : fullscreen.value;
});
// 监听绑定值
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val && !props.keepAlive) {
cacheKey.value += 1;
}
},
{
immediate: true
}
);
// 监听 fullscreen 变化
watch(
() => props.fullscreen,
(val) => {
fullscreen.value = val;
},
{
immediate: true
}
);
// fullscreen-change 回调
watch(fullscreen, (val: boolean) => {
emit("fullscreen-change", val);
});
// 提供
provide("dialog", {
visible,
fullscreen: isFullscreen
});
// 打开
function open() {
fullscreen.value = true;
}
// 关闭
function close() {
function done() {
onClose();
}
if (props.beforeClose) {
props.beforeClose(done);
} else {
done();
}
}
// 关闭后
function onClose() {
emit("update:modelValue", false);
}
// 切换全屏
function changeFullscreen(val?: boolean) {
fullscreen.value = isBoolean(val) ? Boolean(val) : !fullscreen.value;
}
// 双击全屏
function dblClickFullscreen() {
if (isArray(props.controls) && props.controls.includes("fullscreen")) {
changeFullscreen();
}
}
// 渲染头部
function renderHeader() {
return (
props.hideHeader || (
<div class="cl-dialog__header" onDblclick={dblClickFullscreen}>
<span class="cl-dialog__title">{props.title}</span>
<div class="cl-dialog__controls">
{props.controls.map((e: any) => {
switch (e) {
//全屏按钮
case "fullscreen":
if (browser.screen === "xs") {
return null;
}
// 是否显示全屏按钮
if (isFullscreen.value) {
return (
<button
type="button"
class="minimize"
onClick={() => {
changeFullscreen(false);
}}>
<el-icon>
<Minus />
</el-icon>
</button>
);
} else {
return (
<button
type="button"
class="maximize"
onClick={() => {
changeFullscreen(true);
}}>
<el-icon>
<FullScreen />
</el-icon>
</button>
);
}
// 关闭按钮
case "close":
return (
<button type="button" class="close" onClick={close}>
<el-icon>
<Close />
</el-icon>
</button>
);
// 自定义按钮
default:
return renderNode(e, {
slots
});
}
})}
</div>
</div>
)
);
}
expose({
Dialog,
visible,
isFullscreen,
open,
close,
changeFullscreen
});
return () => {
return h(
<el-dialog
ref={Dialog}
class={["cl-dialog", { 'is-transparent': props.transparent }]}
width={props.width}
beforeClose={props.beforeClose}
show-close={false}
append-to-body
fullscreen={isFullscreen.value}
v-model={visible.value}
onClose={onClose}
/>,
{},
{
header() {
return renderHeader();
},
default() {
const height = isFullscreen.value ? "100%" : props.height;
const style = {
padding: props.padding,
height
};
function content() {
return (
<div class="cl-dialog__default" style={style} key={cacheKey.value}>
{slots.default?.()}
</div>
);
}
if (props.scrollbar) {
style.height = "auto";
return <el-scrollbar height={height}>{content()}</el-scrollbar>;
} else {
return content();
}
},
footer() {
const d = slots.footer?.();
if (d && d[0]?.shapeFlag) {
return <div class="cl-dialog__footer">{d}</div>;
}
return null;
}
}
);
};
}
});

17
packages/crud/src/components/error-message/index.tsx

@ -0,0 +1,17 @@
import { defineComponent } from "vue";
export default defineComponent({
name: "cl-error-message",
props: {
title: String
},
setup(props) {
return () => {
return <div class="cl-error-message">
{props.title}
</div>;
};
}
});

23
packages/crud/src/components/filter/index.tsx

@ -0,0 +1,23 @@
import { defineComponent } from "vue";
export default defineComponent({
name: "cl-filter",
props: {
label: String
},
setup(props, { slots }) {
return () => {
return (
<div class="cl-filter">
<span class="cl-filter__label" v-show={props.label}>
{props.label}
</span>
{slots.default?.()}
</div>
);
};
}
});

11
packages/crud/src/components/flex1/index.tsx

@ -0,0 +1,11 @@
import { defineComponent } from "vue";
export default defineComponent({
name: "cl-flex1",
setup() {
return () => {
return <div class="cl-flex1" />;
};
}
});

51
packages/crud/src/components/form-card/index.tsx

@ -0,0 +1,51 @@
import { defineComponent, ref } from "vue";
import { ArrowDown, ArrowUp } from "@element-plus/icons-vue";
export default defineComponent({
name: "cl-form-card",
components: {
ArrowDown,
ArrowUp
},
props: {
label: String,
// 展开状态
expand: {
type: Boolean,
default: true
},
// 是否能展开、收起
isExpand: {
type: Boolean,
default: true
}
},
setup(props, { slots }) {
const visible = ref(props.expand);
function toExpand() {
if (props.isExpand) {
visible.value = !visible.value;
}
}
return () => {
return (
<div class={["cl-form-card", { "is-expand": visible.value }]}>
<div class="cl-form-card__header" v-show={props.label} onClick={toExpand}>
<span>{props.label}</span>
<el-icon v-show={props.isExpand}>
<arrow-down v-show={!visible.value} />
<arrow-up v-show={visible.value} />
</el-icon>
</div>
<div class="cl-form-card__container">{slots.default?.()}</div>
</div>
);
};
}
});

145
packages/crud/src/components/form-tabs/index.tsx

@ -0,0 +1,145 @@
import {
defineComponent,
h,
nextTick,
onMounted,
PropType,
reactive,
ref,
toRaw,
watch
} from "vue";
import { isEmpty } from "lodash-es";
import { useRefs, useDialog } from "../../hooks";
export default defineComponent({
name: "cl-form-tabs",
props: {
modelValue: [String, Number],
labels: {
type: Array,
default: () => []
},
justify: {
type: String as PropType<
"start" | "end" | "left" | "right" | "center" | "justify" | "match-parent"
>,
default: "center"
},
type: {
type: String as PropType<"card" | "default">,
default: "default"
}
},
emits: ["update:modelValue", "change"],
setup(props, { emit, expose }) {
const { refs, setRefs } = useRefs();
// 标识
const active = ref("");
// 切换列表
const list = ref<any[]>([]);
// 下划线
const line = reactive({
width: "",
offsetLeft: "",
transform: "",
backgroundColor: ""
});
function update(val: any) {
if (!val) {
return false;
}
nextTick(() => {
const index = list.value.findIndex((e) => e.value === val);
const item = refs[`tab-${index}`];
if (item) {
// 下划线位置
line.width = item.offsetWidth + "px";
line.transform = `translateX(${item.offsetLeft}px)`;
// 靠左位置
let left = item.offsetLeft + item.clientWidth / 2 - 414 / 2 + 15;
if (left < 0) {
left = 0;
}
// 设置滚动距离
refs.tabs.scrollLeft = left;
}
});
active.value = val;
emit("update:modelValue", val);
}
// 监听绑定值变化
watch(() => props.modelValue, update);
// 监听值修改
watch(
() => active.value,
(val) => {
emit("change", val);
}
);
useDialog({
onFullscreen() {
update(active.value);
}
});
onMounted(function () {
if (!isEmpty(props.labels)) {
list.value = props.labels;
update(isEmpty(props.modelValue) ? list.value[0].value : props.modelValue);
}
});
expose({
active,
list,
line,
update
});
return () => {
return (
<div class={["cl-form-tabs", `cl-form-tabs--${props.type}`]}>
<div
class="cl-form-tabs__wrap"
style={{ textAlign: props.justify }}
ref={setRefs("tabs")}>
<ul>
{list.value.map((e, i) => {
return (
<li
ref={setRefs(`tab-${i}`)}
class={{ "is-active": e.value === active.value }}
onClick={() => {
update(e.value);
}}>
{e.icon && <el-icon>{h(toRaw(e.icon))}</el-icon>}
<span>{e.label}</span>
</li>
);
})}
{line.width && <div class="cl-form-tabs__line" style={line}></div>}
</ul>
</div>
</div>
);
};
}
});

146
packages/crud/src/components/form/helper/action.ts

@ -0,0 +1,146 @@
import { assign } from "lodash-es";
import { dataset } from "../../../utils";
export function useAction({
config,
form,
Form
}: {
config: ClForm.Config;
form: obj;
Form: Vue.Ref<any>;
}) {
// 设置数据
function set(
{
prop,
key,
path
}: { prop?: string; key?: "options" | "props" | "hidden" | "hidden-toggle"; path?: string },
data?: any
) {
const p: string = path || "";
if (path) {
dataset(config, p, data);
} else {
let d: any;
if (prop) {
function deep(arr: ClForm.Item[]) {
arr.forEach((e) => {
if (e.prop == prop) {
d = e;
} else {
if (e.children) {
deep(e.children);
}
}
});
}
deep(config.items);
}
if (d) {
switch (key) {
case "options":
d.component.options = data;
break;
case "props":
assign(d.component.props, data);
break;
case "hidden":
d.hidden = data;
break;
case "hidden-toggle":
d.hidden = data === undefined ? !d.hidden : !data;
break;
default:
assign(d, data);
break;
}
} else {
console.error(`[set] ${prop} is not found`);
}
}
}
// 获取表单值
function getForm(prop: string) {
return prop ? form[prop] : form;
}
// 设置表单值
function setForm(prop: string, value: any) {
form[prop] = value;
}
// 设置配置
function setConfig(path: string, value: any) {
set({ path }, value);
}
// 设置数据
function setData(prop: string, value: any) {
set({ prop }, value);
}
// 设置表单项的下拉数据列表
function setOptions(prop: string, value: any[]) {
set({ prop, key: "options" }, value);
}
// 设置表单项的组件参数
function setProps(prop: string, value: any) {
set({ prop, key: "props" }, value);
}
// 切换表单项的显示、隐藏
function toggleItem(prop: string, value?: boolean) {
set({ prop, key: "hidden-toggle" }, value);
}
// 对部分表单项隐藏
function hideItem(...props: string[]) {
props.forEach((prop) => {
set({ prop, key: "hidden" }, true);
});
}
// 对部分表单项显示
function showItem(...props: string[]) {
props.forEach((prop) => {
set({ prop, key: "hidden" }, false);
});
}
// 设置标题
function setTitle(value: string) {
config.title = value;
}
// 是否展开表单项
function collapseItem(e: any) {
Form.value?.clearValidate(e.prop);
e.collapse = !e.collapse;
}
return {
getForm,
setForm,
setData,
setConfig,
setOptions,
setProps,
toggleItem,
hideItem,
showItem,
setTitle,
collapseItem
};
}

36
packages/crud/src/components/form/helper/api.ts

@ -0,0 +1,36 @@
import { useElApi } from "../../../hooks";
export function useApi({ Form }: { Form: Vue.Ref<any> }) {
return useElApi(
[
"open",
"close",
"clear",
"reset",
"submit",
"bindForm",
"changeTab",
"setTitle",
"showLoading",
"hideLoading",
"collapseItem",
"getForm",
"setForm",
"invokeData",
"setData",
"setConfig",
"setOptions",
"setProps",
"toggleItem",
"hideItem",
"showItem",
"validate",
"validateField",
"resetFields",
"scrollToField",
"clearValidate",
"fields"
],
Form
);
}

85
packages/crud/src/components/form/helper/index.ts

@ -0,0 +1,85 @@
import { reactive, ref, watch } from "vue";
import { useConfig } from "../../../hooks";
import { cloneDeep } from "lodash-es";
export function useForm() {
const { dict } = useConfig();
// 表单配置
const config = reactive<ClForm.Config>({
title: "-",
height: undefined,
width: "50%",
props: {
labelWidth: 100
},
on: {},
op: {
hidden: false,
saveButtonText: dict.label.save,
closeButtonText: dict.label.close,
buttons: ["close", "save"]
},
dialog: {
closeOnClickModal: false,
appendToBody: true
},
items: [],
form: {},
_data: {}
});
const Form = ref();
// 表单数据
const form = reactive<obj>({});
// 表单数据备份
const oldForm = ref<obj>({});
// 表单是否可见
const visible = ref(false);
// 表单提交保存状态
const saving = ref(false);
// 表单加载状态
const loading = ref(false);
// 表单禁用状态
const disabled = ref(false);
// 监听表单变化
watch(
() => form,
(val) => {
if (config.on?.change) {
for (const i in val) {
if (form[i] !== oldForm.value[i]) {
config.on?.change(val, i);
}
}
}
oldForm.value = cloneDeep(val);
},
{
deep: true
}
);
return {
Form,
config,
form,
visible,
saving,
loading,
disabled
};
}
export * from "./action";
export * from "./api";
export * from "./plugins";
export * from "./tabs";

92
packages/crud/src/components/form/helper/plugins.ts

@ -0,0 +1,92 @@
import { type Ref, type WatchStopHandle, getCurrentInstance, watch } from "vue";
import { useConfig } from "../../../hooks";
import { uniqueFns } from "../../../utils";
export function usePlugins(enable: boolean, { visible }: { visible: Ref<boolean> }) {
const that: any = getCurrentInstance();
const { style } = useConfig();
interface Event {
onOpen: (() => void)[];
onClose: (() => void)[];
onSubmit: ((data: obj) => Promise<obj> | obj)[];
[key: string]: any;
}
// 事件
const ev: Event = {
onOpen: [],
onClose: [],
onSubmit: []
};
// 监听器
let timer: WatchStopHandle | null = null;
// 插件创建
function create(plugins: ClForm.Plugin[] = []) {
if (!enable) {
return false;
}
for (const i in ev) {
ev[i] = [];
}
// 停止监听
if (timer) {
timer();
}
// 执行
uniqueFns([...(style.form.plugins || []), ...plugins]).forEach((p) => {
const d: any = {
exposed: that.exposed
};
for (const i in ev) {
d[i] = (cb: any) => {
ev[i].push(cb);
};
}
p(d);
});
timer = watch(
visible,
(val) => {
if (val) {
setTimeout(() => {
ev.onOpen.forEach((e) => e());
}, 10);
} else {
ev.onClose.forEach((e) => e());
}
},
{
immediate: true
}
);
}
// 表单提交
async function submit(data: any) {
let d = data;
for (let i = 0; i < ev.onSubmit.length; i++) {
const d2 = await ev.onSubmit[i](d);
if (d2) {
d = d2;
}
}
return d;
}
return {
create,
submit
};
}

151
packages/crud/src/components/form/helper/tabs.ts

@ -0,0 +1,151 @@
import { computed, ref } from "vue";
export function useTabs({ config, Form }: { config: ClForm.Config; Form: Vue.Ref<any> }) {
// 选中
const active = ref<string | undefined>();
// 列表
const list = computed(() => {
return get()?.props?.labels || [];
});
// 获取选项
function getItem(value: any) {
return list.value.find((e) => e.value == value);
}
// 是否已加载
function isLoaded(value: any) {
const d = getItem(value);
return d?.lazy ? d.loaded : true;
}
// 加载后
function onLoad(value: any) {
const d = getItem(value);
d!.loaded = true;
}
// 查找分组
function toGroup(opts: { config: ClForm.Config; prop: string; refs: any }) {
if (active.value) {
let name;
// 查找标签上绑定的数据
const el = opts.refs.form.querySelector(`[data-prop="${opts.prop}"]`);
// 各自判断
if (el) {
name = el?.getAttribute("data-group");
} else {
function deep(d: ClForm.Item) {
if (d.prop == opts.prop) {
name = d.group;
} else {
if (d.children) {
d.children.forEach(deep);
}
}
}
config.items.forEach(deep);
}
if (name) {
set(name);
}
}
}
// 获取参数
function get() {
return config.items.find((e) => e.type === "tabs");
}
// 设置参数
function set(data: any) {
active.value = data;
}
// 清空
function clear() {
// 清空选中
active.value = undefined;
// 清空加载状态
list.value.forEach((e) => {
if (e.lazy && e.loaded) {
e.loaded = undefined;
}
});
}
// 切换
function change(value: any, isValid = true) {
return new Promise((resolve: Function, reject: Function) => {
function next() {
active.value = value;
resolve();
}
if (isValid) {
let isError = false;
const arr = config.items
.filter((e) => e.group == active.value && !e._hidden && e.prop)
.map((e) => {
return new Promise((r: Function) => {
// 验证表单
Form.value.validateField(e.prop, (valid: string) => {
if (valid) {
isError = true;
}
r(valid);
});
});
});
Promise.all(arr).then((msg) => {
if (isError) {
reject(msg.filter(Boolean));
} else {
next();
}
});
} else {
next();
}
});
}
// 合并
function mergeProp(item: ClForm.Item) {
const d = get();
if (d && d.props) {
const { mergeProp, labels = [] } = d.props;
if (mergeProp) {
const t = labels.find((e) => e.value == item.group);
if (t && t.name) {
item.prop = `${t.name}-${item.prop}`;
}
}
}
}
return {
active,
list,
isLoaded,
onLoad,
get,
set,
change,
clear,
mergeProp,
toGroup
};
}

683
packages/crud/src/components/form/index.tsx

@ -0,0 +1,683 @@
import { defineComponent, h, nextTick } from "vue";
import { assign, cloneDeep, isBoolean, isFunction, keys } from "lodash-es";
import { useAction, useForm, usePlugins, useTabs } from "./helper";
import { useBrowser, useConfig, useElApi, useRefs } from "../../hooks";
import { getValue, merge } from "../../utils";
import formHook from "../../utils/form-hook";
import { renderNode } from "../../utils/vnode";
export default defineComponent({
name: "cl-form",
props: {
name: String,
inner: Boolean,
inline: Boolean,
enablePlugin: {
type: Boolean,
default: true
}
},
setup(props, { expose, slots }) {
const { refs, setRefs } = useRefs();
const { style, dict } = useConfig();
const browser = useBrowser();
const { Form, config, form, visible, saving, loading, disabled } = useForm();
// 关闭的操作类型
let closeAction: ClForm.CloseAction = "close";
// 旧表单数据
let defForm: obj | undefined;
// 选项卡
const Tabs = useTabs({ config, Form });
// 操作
const Action = useAction({ config, form, Form });
// 方法
const ElFormApi = useElApi(
[
"validate",
"validateField",
"resetFields",
"scrollToField",
"clearValidate",
"fields"
],
Form
);
// 插件
const plugin = usePlugins(props.enablePlugin, { visible });
// 显示加载中
function showLoading() {
loading.value = true;
}
// 隐藏加载
function hideLoading() {
loading.value = false;
}
// 设置是否禁用
function setDisabled(val: boolean = true) {
disabled.value = val;
}
// 请求表单保存状态
function done() {
saving.value = false;
}
// 关闭表单
function close(action?: ClForm.CloseAction) {
if (action) {
closeAction = action;
}
beforeClose(() => {
visible.value = false;
done();
});
}
// 关闭前
function beforeClose(done: fn) {
if (config.on?.close) {
config.on.close(closeAction, done);
} else {
done();
}
}
// 关闭后
function onClosed() {
Tabs.clear();
Form.value?.clearValidate();
}
// 清空表单验证
function clear() {
for (const i in form) {
delete form[i];
}
setTimeout(() => {
Form.value?.clearValidate();
}, 0);
}
// 重置
function reset() {
if (defForm) {
for (const i in defForm) {
form[i] = cloneDeep(defForm[i]);
}
}
}
// 转换表单值,处理多层级等数据
function invokeData(d: any) {
for (const i in d) {
if (i.includes("-")) {
// 结构参数
const [a, ...arr] = i.split("-");
// 关键值的key
const k: string = arr.pop() || "";
if (!d[a]) {
d[a] = {};
}
let f: any = d[a];
// 设置默认值
arr.forEach((e) => {
if (!f[e]) {
f[e] = {};
}
f = f[e];
});
// 设置关键值
f[k] = d[i];
delete d[i];
}
}
}
// 表单提交
function submit(callback?: fn) {
// 验证表单
Form.value.validate(async (valid: boolean, error: any) => {
if (valid) {
saving.value = true;
// 拷贝表单值
const d = cloneDeep(form);
config.items.forEach((e) => {
function deep(e: ClForm.Item) {
if (e.prop) {
// 过滤隐藏的表单项
if (e._hidden) {
if (e.prop) {
delete d[e.prop];
}
}
// hook 提交处理
if (e.hook) {
formHook.submit({
...e,
value: e.prop ? d[e.prop] : undefined,
form: d
});
}
}
if (e.children) {
e.children.forEach(deep);
}
}
deep(e);
});
// 处理数据
invokeData(d);
const submit = callback || config.on?.submit;
// 提交事件
if (submit) {
submit(await plugin.submit(d), {
close() {
close("save");
},
done
});
} else {
done();
}
} else {
// 切换到对应的选项卡
Tabs.toGroup({
refs,
config,
prop: keys(error)[0]
});
}
});
}
// 打开表单
function open(options?: ClForm.Options, plugins?: ClForm.Plugin[]) {
if (!options) {
return console.error("Options is not null");
}
// 清空
if (options.isReset !== false) {
clear();
}
// 显示对话框
visible.value = true;
// 默认关闭方式
closeAction = "close";
// 合并配置
for (const i in config) {
switch (i) {
// 表单项
case "items":
function deep(arr: any[]): any[] {
return arr.map((e) => {
const d = getValue(e);
return {
...d,
children: d?.children ? deep(d.children) : undefined
};
});
}
config.items = deep(options.items || []);
break;
// 事件、参数、操作
case "on":
case "op":
case "props":
case "dialog":
case "_data":
merge(config[i], options[i] || {});
break;
// 其他
default:
config[i] = options[i];
break;
}
}
// 预设表单值
if (options?.form) {
for (const i in options.form) {
form[i] = options.form[i];
}
}
// 设置表单数据
config.items.forEach((e) => {
function deep(e: ClForm.Item) {
if (e.prop) {
// 解析 prop
if (e.prop.includes(".")) {
e.prop = e.prop.replace(/\./g, "-");
}
// prop 合并
Tabs.mergeProp(e);
// hook 绑定值
formHook.bind({
...e,
value: form[e.prop] !== undefined ? form[e.prop] : cloneDeep(e.value),
form
});
// 表单验证
if (e.required) {
e.rules = {
required: true,
message: dict.label.nonEmpty.replace("{label}", e.label || "")
};
}
}
// 设置 tabs 默认值
if (e.type == "tabs") {
Tabs.set(e.value);
}
// 子集
if (e.children) {
e.children.forEach(deep);
}
}
deep(e);
});
// 设置默认值
if (!defForm) {
defForm = cloneDeep(form);
}
// 创建插件
plugin.create(plugins);
// 打开回调
nextTick(() => {
setTimeout(() => {
// 打开事件
if (config.on?.open) {
config.on.open(form);
}
}, 10);
});
}
// 绑定表单数据
function bindForm(data: any) {
config.items.forEach((e) => {
function deep(e: ClForm.Item) {
formHook.bind({
...e,
value: e.prop ? data[e.prop] : undefined,
form: data
});
if (e.children) {
e.children.forEach(deep);
}
}
deep(e);
});
assign(form, data);
}
// 渲染表单项
function renderFormItem(e: ClForm.Item) {
const { isDisabled } = config._data;
if (e.type == "tabs") {
return (
<cl-form-tabs v-model={Tabs.active.value} {...e.props} onChange={Tabs.onLoad} />
);
}
// 是否隐藏
e._hidden = parseHidden(e.hidden, {
scope: form
});
// 分组显示
const inGroup = e.group ? e.group === Tabs.active.value : true;
// 是否已加载完成
const isLoaded = e.component && Tabs.isLoaded(e.group);
// 表单项
const FormItem = h(
<el-form-item
class={{
"no-label": !(e.renderLabel || e.label),
"has-children": !!e.children
}}
key={e.prop}
data-group={e.group || "-"}
data-prop={e.prop || "-"}
label-width={props.inline ? "auto" : ""}
label={e.label}
prop={e.prop}
rules={isDisabled ? null : e.rules}
required={e._hidden ? false : e.required}
v-show={inGroup && !e._hidden}
/>,
e.props,
{
label() {
if (e.renderLabel) {
return renderNode(e.renderLabel, {
scope: form,
render: "slot",
slots
});
} else {
return e.label;
}
},
default() {
return (
<div>
<div class="cl-form-item">
{["prepend", "component", "append"]
.filter((k) => e[k])
.map((name) => {
const children = e.children && (
<div class="cl-form-item__children">
<el-row gutter={10}>
{e.children.map(renderFormItem)}
</el-row>
</div>
);
const Item = renderNode(e[name], {
item: e,
prop: e.prop,
scope: form,
slots,
children,
_data: {
isDisabled
}
});
return (
<div
v-show={!e.collapse}
class={[
`cl-form-item__${name}`,
{
flex1: e.flex !== false
}
]}
style={e[name].style}>
{Item}
</div>
);
})}
</div>
{isBoolean(e.collapse) && (
<div
class="cl-form-item__collapse"
onClick={() => {
Action.collapseItem(e);
}}>
<el-divider content-position="center">
{e.collapse
? dict.label.seeMore
: dict.label.hideContent}
</el-divider>
</div>
)}
</div>
);
}
}
);
let span = e.span || style.form.span;
if (browser.isMini) {
span = 24;
}
// 是否行内
const Item = props.inline ? (
FormItem
) : (
<el-col span={span} {...e.col} v-show={inGroup && !e._hidden}>
{FormItem}
</el-col>
);
return isLoaded ? Item : null;
}
// 渲染表单
function renderContainer() {
// 表单项列表
const children = config.items.map(renderFormItem);
// 表单标签位置
const labelPosition =
browser.isMini && !props.inline
? "top"
: config.props.labelPosition || style.form.labelPosition;
return (
<div class="cl-form__container" ref={setRefs("form")}>
{h(
<el-form
ref={Form}
size={style.size}
label-width={style.form.labelWidth}
inline={props.inline}
require-asterisk-position="right"
disabled={saving.value}
scroll-to-error
model={form}
onSubmit={(e: Event) => {
submit();
e.preventDefault();
}}
/>,
{
...config.props,
labelPosition
},
{
default: () => {
const items = [
slots.prepend && slots.prepend({ scope: form }),
children,
slots.append && slots.append({ scope: form })
];
return (
<div class="cl-form__items" v-loading={loading.value}>
{props.inline ? (
items
) : (
<el-row gutter={10}>{items}</el-row>
)}
</div>
);
}
}
)}
</div>
);
}
// 渲染表单底部按钮
function renderFooter() {
const { hidden, buttons, saveButtonText, closeButtonText, justify } = config.op;
if (hidden) {
return null;
}
const Btns = buttons?.map((e: any) => {
switch (e) {
case "save":
return (
<el-button
type="success"
size={style.size}
disabled={loading.value}
loading={saving.value}
onClick={() => {
submit();
}}>
{saveButtonText}
</el-button>
);
case "close":
return (
<el-button
size={style.size}
onClick={() => {
close("close");
}}>
{closeButtonText}
</el-button>
);
default:
return renderNode(e, {
scope: form,
slots,
custom() {
return (
<el-button
type={e.type}
{...e.props}
onClick={() => {
e.onClick({ scope: form });
}}>
{e.label}
</el-button>
);
}
});
}
});
return (
<div
class="cl-form__footer"
style={{
justifyContent: justify || "flex-end"
}}>
{Btns}
</div>
);
}
// Tools
function parseHidden(value: any, { scope }: any) {
if (isBoolean(value)) {
return value;
} else if (isFunction(value)) {
return value({ scope });
}
return false;
}
const ctx = {
name: props.name,
refs,
Form,
visible,
saving,
form,
config,
loading,
disabled,
open,
close,
done,
clear,
reset,
submit,
invokeData,
bindForm,
showLoading,
hideLoading,
setDisabled,
Tabs,
...Action,
...ElFormApi
};
expose(ctx);
return () => {
if (props.inner) {
return (
visible.value && (
<div class="cl-form">
{renderContainer()}
{renderFooter()}
</div>
)
);
} else {
return h(
<cl-dialog v-model={visible.value} class="cl-form" />,
{
title: config.title,
height: config.height,
width: config.width,
...config.dialog,
beforeClose,
onClosed,
keepAlive: false
},
{
default() {
return renderContainer();
},
footer() {
return renderFooter();
}
}
);
}
};
}
});

50
packages/crud/src/components/index.tsx

@ -0,0 +1,50 @@
import { App } from "vue";
import Crud from "./crud";
import AddBtn from "./add-btn";
import AdvBtn from "./adv/btn";
import AdvSearch from "./adv/search";
import Flex from "./flex1";
import Form from "./form";
import FormTabs from "./form-tabs";
import FormCard from "./form-card";
import MultiDeleteBtn from "./multi-delete-btn";
import Pagination from "./pagination";
import RefreshBtn from "./refresh-btn";
import SearchKey from "./search-key";
import Table from "./table";
import Upsert from "./upsert";
import Dialog from "./dialog";
import Filter from "./filter";
import Search from "./search";
import ErrorMessage from "./error-message";
import Row from "./row";
import ContextMenu from "./context-menu";
export const components: { [key: string]: any } = {
Crud,
AddBtn,
AdvBtn,
AdvSearch,
Flex,
Form,
FormTabs,
FormCard,
MultiDeleteBtn,
Pagination,
RefreshBtn,
SearchKey,
Table,
Upsert,
Dialog,
Filter,
Search,
ErrorMessage,
Row,
ContextMenu
};
export function useComponent(app: App) {
for (const i in components) {
app.component(components[i].name, components[i]);
}
}

27
packages/crud/src/components/multi-delete-btn/index.tsx

@ -0,0 +1,27 @@
import { defineComponent } from "vue";
import { useConfig, useCore } from "../../hooks";
export default defineComponent({
name: "cl-multi-delete-btn",
setup(_, { slots }) {
const { crud } = useCore();
const { style } = useConfig();
return () => {
return (
crud.getPermission("delete") && (
<el-button
type="danger"
size={style.size}
disabled={crud.selection.length === 0}
onClick={() => {
crud.rowDelete(...crud.selection);
}}>
{slots.default?.() || crud.dict.label.multiDelete}
</el-button>
)
);
};
}
});

90
packages/crud/src/components/pagination/index.tsx

@ -0,0 +1,90 @@
import { defineComponent, h, onMounted, onUnmounted, ref } from "vue";
import { useBrowser, useConfig, useCore } from "../../hooks";
export default defineComponent({
name: "cl-pagination",
setup(_, { expose }) {
const { crud, mitt } = useCore();
const { style } = useConfig();
const browser = useBrowser();
// 总数
const total = ref(0);
// 当前页数
const currentPage = ref(1);
// 每页大小
const pageSize = ref(20);
// 页数发生变化
function onCurrentChange(index: number) {
crud.refresh({
page: index
});
}
// 条目发生变化
function onSizeChange(size: number) {
crud.refresh({
page: 1,
size
});
}
// 设置分页信息
function setPagination(res: obj) {
if (res) {
currentPage.value = res.currentPage || res.page || 1;
pageSize.value = res.pageSize || res.size || 20;
total.value = res.total || 0;
crud.params.size = pageSize.value;
}
}
// 数据刷新
function onRefresh(res: ClCrud.Response["page"]) {
setPagination(res.pagination);
}
// 监听刷新事件
onMounted(() => {
mitt.on("crud.refresh", onRefresh);
});
// 移除监听事件
onUnmounted(() => {
mitt.off("crud.refresh", onRefresh);
});
expose({
total,
currentPage,
pageSize,
setPagination
});
return () => {
return h(
<el-pagination
class="cl-pagination"
size={browser.isMini ? 'small' : style.size}
background
page-sizes={[10, 20, 30, 40, 50, 100]}
pager-count={browser.isMini ? 5 : 7}
layout={
browser.isMini ? "total, pager" : "total, sizes, prev, pager, next, jumper"
}
/>,
{
onSizeChange,
onCurrentChange,
total: total.value,
currentPage: currentPage.value,
pageSize: pageSize.value
}
);
};
}
});

23
packages/crud/src/components/refresh-btn/index.tsx

@ -0,0 +1,23 @@
import { defineComponent } from "vue";
import { useConfig, useCore } from "../../hooks";
export default defineComponent({
name: "cl-refresh-btn",
setup(_, { slots }) {
const { crud } = useCore();
const { style } = useConfig();
return () => {
return (
<el-button
size={style.size}
onClick={() => {
crud.refresh();
}}>
{slots.default?.() || crud.dict.label.refresh}
</el-button>
);
};
}
});

11
packages/crud/src/components/row/index.tsx

@ -0,0 +1,11 @@
import { defineComponent } from "vue";
export default defineComponent({
name: "cl-row",
setup(_, { slots }) {
return () => {
return <el-row class="cl-row">{slots.default && slots.default()}</el-row>;
};
}
});

178
packages/crud/src/components/search-key/index.tsx

@ -0,0 +1,178 @@
import { defineComponent, ref, computed, type PropType, useModel } from "vue";
import { useConfig, useCore } from "../../hooks";
import { parsePx } from "../../utils";
import { debounce } from "lodash-es";
export default defineComponent({
name: "cl-search-key",
props: {
// 绑定值
modelValue: String,
// 选中字段
field: {
type: String,
default: "keyWord"
},
// 字段列表
fieldList: {
type: Array as PropType<Array<{ label: string; value: string }>>,
default: () => []
},
// 搜索时的钩子
onSearch: Function,
// 输入框占位内容
placeholder: String,
// 宽度
width: {
type: [String, Number],
default: 280
},
// 是否实时刷新
refreshOnInput: Boolean
},
emits: ["update:modelValue", "change", "field-change"],
setup(props, { emit, expose }) {
const { crud } = useCore();
const { style } = useConfig();
// 选中字段
const selectField = ref(props.field);
// 加载状态
const loading = ref(false);
// 文字提示
const placeholder = computed(() => {
if (props.placeholder) {
return props.placeholder;
} else {
const item = props.fieldList.find((e) => e.value == selectField.value);
if (item) {
return crud.dict.label.placeholder + item.label;
} else {
return crud.dict.label.searchKey;
}
}
});
// 搜索内容
const value = useModel(props, "modelValue");
// 锁
let lock = false;
// 搜索
function search() {
if (!lock) {
const params: obj = {};
props.fieldList.forEach((e) => {
params[e.value] = undefined;
});
async function next(newParams?: obj) {
loading.value = true;
await crud.refresh({
page: 1,
...params,
[selectField.value]: value.value || undefined,
...newParams
})
.catch(err => {
console.error(err);
})
loading.value = false;
}
if (props.onSearch) {
props.onSearch(params, { next });
} else {
next();
}
}
}
// 回车搜索
function onKeydown({ key }: KeyboardEvent) {
if (key === "Enter") {
search();
}
}
// 监听变化
function onChange(val: string) {
if (!props.refreshOnInput) {
search();
lock = true;
setTimeout(() => {
lock = false;
}, 300);
emit("change", val);
}
}
// 监听输入
const onInput = debounce((val: string) => {
emit("change", val);
if (props.refreshOnInput) {
search();
}
}, 300);
// 监听字段选择
function onFieldChange() {
emit("field-change", selectField.value);
value.value = undefined;
}
expose({
search
});
return () => {
return (
<div class="cl-search-key">
<el-select
class="cl-search-key__select"
size={style.size}
v-model={selectField.value}
v-show={props.fieldList.length > 0}
onChange={onFieldChange}>
{props.fieldList.map((e, i) => (
<el-option key={i} label={e.label} value={e.value} />
))}
</el-select>
<div class="cl-search-key__wrap" style={{ width: parsePx(props.width) }}>
<el-input
v-model={value.value}
size={style.size}
placeholder={placeholder.value}
onKeydown={onKeydown}
onChange={onChange}
onInput={onInput}
clearable
/>
<el-button
size={style.size}
type="primary"
loading={loading.value}
onClick={search}>
{crud.dict.label.search}
</el-button>
</div>
</div>
);
};
}
});

21
packages/crud/src/components/search/helper/plugins.ts

@ -0,0 +1,21 @@
import { getCurrentInstance } from "vue";
import { useConfig } from "../../../hooks";
import { uniqueFns } from "../../../utils";
export function usePlugins() {
const that: any = getCurrentInstance();
const { style } = useConfig();
// 插件创建
function create(plugins: ClSearch.Plugin[] = []) {
uniqueFns([...(style.search?.plugins || []), ...plugins]).forEach((p) => {
p({
exposed: that.exposed
});
});
}
return {
create
};
}

298
packages/crud/src/components/search/index.tsx

@ -0,0 +1,298 @@
import { useConfig, useCore, useForm, useProxy, useRefs } from "../../hooks";
import {
onMounted,
PropType,
defineComponent,
ref,
h,
reactive,
inject,
mergeProps,
nextTick,
onUnmounted
} from "vue";
import { useApi } from "../form/helper";
import { Search, Refresh, Bottom, Top } from "@element-plus/icons-vue";
import { mitt } from "../../utils/mitt";
import { isArray, isEmpty } from "lodash-es";
import { usePlugins } from "./helper/plugins";
export default defineComponent({
name: "cl-search",
props: {
// 是否行内
inline: {
type: Boolean,
default: true
},
// cl-form 表单配置
props: {
type: Object,
default: () => ({})
},
// 表单值
data: {
type: Object,
default: () => ({})
},
// 列
items: {
type: Array as PropType<ClForm.Item[]>,
default: () => []
},
// 是否需要重置按钮
resetBtn: {
type: Boolean,
default: false
},
// 是否需要折叠
collapse: {
type: Boolean,
default: false
},
// 初始化
onLoad: Function,
// 搜索时钩子
onSearch: Function
},
emits: ["reset"],
setup(props, { slots, expose, emit }) {
const { crud } = useCore();
const { refs, setRefs } = useRefs()
const { style } = useConfig();
const plugin = usePlugins()
// 配置
const config = reactive<ClSearch.Config>(
mergeProps(props, inject("useSearch__options") || {})
);
// cl-form
const Form = useForm();
// 加载中
const loading = ref(false);
// 展开
const isExpand = ref(!config.collapse);
// 显示展开、收起按钮
const showExpandBtn = ref(false);
// 搜索
function search(params?: any) {
const form = Form.value?.getForm();
async function next(data?: any) {
loading.value = true;
const d = {
page: 1,
...form,
...data,
...params
};
for (const i in d) {
if (d[i] === "") {
d[i] = undefined;
}
}
const res = await crud.refresh(d);
loading.value = false;
return res;
}
if (config.onSearch) {
config.onSearch(form, { next });
} else {
next();
}
}
// 重置
function reset() {
const d: any = {};
config.items?.map((e) => {
if (typeof e.hook != 'string' && e.hook?.reset) {
const props = e.hook.reset(e.prop!)
if (isArray(props)) {
props.forEach((prop) => {
d[prop] = undefined;
})
}
}
d[e.prop!] = undefined;
});
// 重置表单
Form.value?.reset();
// 列表刷新
search(d);
// 重置事件
emit("reset", d);
}
// 收起、展开
function expand() {
isExpand.value = !isExpand.value;
nextTick(() => {
crud?.["cl-table"].calcMaxHeight()
})
}
// 判断展开状态
function onExpand() {
if (config.collapse) {
const el = refs.form?.querySelector(".cl-form__items");
if (el) {
showExpandBtn.value = el.clientHeight > 84;
}
}
}
function onResize() {
onExpand();
}
const ctx = {
search,
reset,
Form,
config,
...useApi({ Form })
};
useProxy(ctx);
expose(ctx);
plugin.create(config.plugins);
onMounted(() => {
Form.value?.open({
op: {
hidden: true
},
props: {
labelPosition: 'right',
...config.props
},
items: config.items?.map(e => {
return {
col: {
sm: 12,
md: 8,
xs: 24,
lg: 6,
},
...e,
}
}),
form: config.data,
on: {
open(data) {
config.onLoad?.(data);
onExpand();
},
change(data, prop) {
config.onChange?.(data, prop);
}
}
});
mitt.on("resize", onResize);
});
onUnmounted(() => {
mitt.off("resize", onResize);
})
return () => {
const btnEl = (
<el-form-item label=" " class="cl-search__btns">
{/* 重置按钮 */}
{config.resetBtn && (
<el-button size={style.size} icon={Refresh} onClick={reset}>
{crud.dict.label.reset}
</el-button>
)}
{/* 搜索按钮 */}
<el-button
type="primary"
loading={loading.value}
size={style.size}
icon={Search}
onClick={() => {
search();
}}>
{crud.dict.label.search}
</el-button>
{/* 自定义按钮 */}
{slots?.buttons?.(Form.value?.form)}
</el-form-item>
);
return (
<div class={["cl-search", isExpand.value ? "is-expand" : "is-fold", {
'is-inline': config.inline,
'is-collapse': config.collapse,
}]}>
<div class="cl-search__form" ref={setRefs("form")}>
{h(
<cl-form
ref={Form}
inner
inline={config.inline}
enable-plugin={false}
name='search'
/>,
{},
{
append() {
return config.collapse ? null : (isEmpty(config.items) || btnEl);
},
...slots
}
)}
</div>
{config.collapse && (
<div class="cl-search__more">
{showExpandBtn.value && (
<el-button onClick={expand}>
<span>{isExpand.value ? crud.dict.label.collapse : crud.dict.label.expand}</span>
<el-icon>{isExpand.value ? <Top /> : <Bottom />}</el-icon>
</el-button>
)}
<cl-flex1 />
{btnEl}
</div>
)}
</div>
);
};
}
});

35
packages/crud/src/components/table/helper/data.ts

@ -0,0 +1,35 @@
import { nextTick, ref } from "vue";
import { useCore } from "../../../hooks";
export function useData({ config, Table }: { config: ClTable.Config; Table: Vue.Ref<any> }) {
const { mitt, crud } = useCore();
// 列表数据
const data = ref<obj[]>([]);
// 设置数据
function setData(list: obj[]) {
data.value = list;
}
// 监听刷新
mitt.on("crud.refresh", ({ list }: ClCrud.Response["page"]) => {
data.value = list;
// 显示选中行
nextTick(() => {
crud.selection.forEach((e) => {
const d = list.find((a) => a[config.rowKey] == e[config.rowKey]);
if (d) {
Table.value.toggleRowSelection(d, true);
}
});
});
});
return {
data,
setData
};
}

91
packages/crud/src/components/table/helper/header.tsx

@ -0,0 +1,91 @@
import { CloseBold, Search } from "@element-plus/icons-vue";
import { h } from "vue";
import { useCrud } from "../../../hooks";
import { renderNode } from "../../../utils/vnode";
export function renderHeader(item: ClTable.Column, { scope, slots }: any) {
const crud = useCrud();
const slot = slots[`header-${item.prop}`];
if (slot) {
return slot({
scope
});
}
if (!item.search || !item.search.component) {
return item.label;
}
// 显示输入框
function show(e: MouseEvent) {
item.search.isInput = true;
e.stopPropagation();
}
// 隐藏输入框
function hide() {
if (item.search.value !== undefined) {
item.search.value = undefined;
refresh();
}
item.search.isInput = false;
}
// 刷新
function refresh(params?: any) {
const { value } = item.search;
crud.value?.refresh({
page: 1,
[item.prop]: value === "" ? undefined : value,
...params
});
}
// 文字
const text = (
<div class="cl-table-header__search-label" onClick={show}>
<el-icon size={14}>{item.search.icon?.() ?? <Search />}</el-icon>
{item.renderLabel ? item.renderLabel(scope) : item.label}
</div>
);
// 输入框
const input = h(renderNode(item.search.component, { prop: item.prop }), {
clearable: true,
modelValue: item.search.value,
onVnodeMounted(vn) {
// 默认聚焦
vn.component?.exposed?.focus?.();
},
onInput(val: any) {
item.search.value = val;
},
onChange(val: any) {
item.search.value = val;
// 更改时刷新列表
if (item.search.refreshOnChange) {
refresh();
}
}
});
return (
<div class={["cl-table-header__search", { "is-input": item.search.isInput }]}>
<div class="cl-table-header__search-inner">{item.search.isInput ? input : text}</div>
{item.search.isInput && (
<div class="cl-table-header__search-close" onClick={hide}>
<el-icon>
<CloseBold />
</el-icon>
</div>
)}
</div>
);
}

99
packages/crud/src/components/table/helper/height.ts

@ -0,0 +1,99 @@
import { debounce, last } from "lodash-es";
import { nextTick, onActivated, onMounted, ref } from "vue";
import { addClass, removeClass } from "../../../utils";
import { mitt } from "../../../utils/mitt";
// 表格高度
export function useHeight({ config, Table }: { Table: Vue.Ref<any>; config: ClTable.Config }) {
// 最大高度
const maxHeight = ref(0);
// 计算表格最大高度
const update = debounce(async () => {
await nextTick();
let vm = Table.value;
if (vm) {
while (!vm.$parent?.$el.className?.includes("cl-crud")) {
vm = vm.$parent;
}
if (vm) {
const p = vm.$parent.$el;
await nextTick();
// 高度
let h = 0;
// 表格下间距
if (vm.$el.className.includes("cl-row")) {
h += 10;
}
// 上高度
h += vm.$el.offsetTop;
// 获取下高度
let n = vm.$el.nextSibling;
// 集合
const arr = [vm.$el];
while (n) {
if (n.offsetHeight > 0) {
h += n.offsetHeight || 0;
arr.push(n);
if (n.className.includes("cl-row--last")) {
h += 10;
}
}
n = n.nextSibling;
}
// 移除 cl-row--last
arr.forEach((e) => {
removeClass(e, "cl-row--last");
});
// 最后一个可视元素
const z = last(arr);
// 去掉 cl-row 下间距高度
if (z?.className.includes("cl-row")) {
addClass(z, "cl-row--last");
h -= 10;
}
// 上间距
h += parseInt(window.getComputedStyle(p).paddingTop, 10);
// 设置最大高度
if (config.autoHeight) {
maxHeight.value = p.clientHeight - h;
}
}
}
}, 100);
// 窗口大小改变事件
mitt.on("resize", () => {
update();
});
onMounted(function () {
update();
});
onActivated(function () {
update();
});
return {
maxHeight,
calcMaxHeight: update
};
}

43
packages/crud/src/components/table/helper/index.ts

@ -0,0 +1,43 @@
import { inject, reactive, ref } from "vue";
import { useConfig } from "../../../hooks";
import { getValue, mergeConfig } from "../../../utils";
import type { TableInstance } from "element-plus";
export function useTable(props: any) {
const { style } = useConfig();
const Table = ref<TableInstance>();
// 配置
const config = reactive<ClTable.Config>(mergeConfig(props, inject("useTable__options") || {}));
// 列表项动态处理
config.columns = (config.columns || []).map((e) => getValue(e));
// 自动高度
config.autoHeight = config.autoHeight ?? style.table.autoHeight;
// 右键菜单
config.contextMenu = config.contextMenu ?? style.table.contextMenu;
// 事件
if (!config.on) {
config.on = {};
}
// 参数
if (!config.props) {
config.props = {};
}
return { Table, config };
}
export * from "./data";
export * from "./height";
export * from "./op";
export * from "./render";
export * from "./row";
export * from "./selection";
export * from "./sort";
export * from "./header";

69
packages/crud/src/components/table/helper/op.ts

@ -0,0 +1,69 @@
import { nextTick, ref } from "vue";
import { useCore } from "../../../hooks";
import { isArray, isBoolean } from "lodash-es";
export function useOp({ config }: { config: ClTable.Config }) {
const { mitt } = useCore();
// 是否可见,用于解决一些显示隐藏的副作用
const visible = ref(true);
// 重新构建
async function reBuild(cb?: fn) {
visible.value = false;
await nextTick();
if (cb) {
cb();
}
visible.value = true;
await nextTick();
mitt.emit("resize");
}
// 显示列
function showColumn(prop: string | string[], status?: boolean) {
const keys = isArray(prop) ? prop : [prop];
// 多级表头
function deep(list: ClTable.Column[]) {
list.forEach((e) => {
if (e.prop && keys.includes(e.prop)) {
e.hidden = isBoolean(status) ? !status : false;
}
if (e.children) {
deep(e.children);
}
});
}
deep(config.columns);
}
// 隐藏列
function hideColumn(prop: string | string[]) {
showColumn(prop, false);
}
// 设置列
function setColumns(list: ClTable.Column[]) {
if (list) {
reBuild(() => {
config.columns.splice(0, config.columns.length, ...list);
});
}
}
return {
visible,
reBuild,
showColumn,
hideColumn,
setColumns
};
}

22
packages/crud/src/components/table/helper/plugins.ts

@ -0,0 +1,22 @@
import { getCurrentInstance } from "vue";
import { useConfig } from "../../../hooks";
import { uniqueFns } from "../../../utils";
export function usePlugins() {
const that: any = getCurrentInstance();
const { style } = useConfig();
// 插件创建
function create(plugins: ClTable.Plugin[] = []) {
// 执行
uniqueFns([...(style.table.plugins || []), ...plugins]).forEach((p) => {
p({
exposed: that.exposed
});
});
}
return {
create
};
}

327
packages/crud/src/components/table/helper/render.tsx

@ -0,0 +1,327 @@
import { h, useSlots } from "vue";
import { useCore, useBrowser, useConfig } from "../../../hooks";
import { assign, cloneDeep, isArray, isEmpty, isObject, isString, orderBy } from "lodash-es";
import { deepFind, getValue } from "../../../utils";
import { renderNode } from "../../../utils/vnode";
import { renderHeader } from "./header";
// 渲染
export function useRender() {
const browser = useBrowser();
const slots = useSlots();
const { crud } = useCore();
const { style } = useConfig();
// 渲染列
function renderColumn(columns: ClTable.Column[]) {
const arr = columns.map((e) => {
const d = getValue(e);
if (!d.orderNum) {
d.orderNum = 0;
}
return d;
});
return orderBy(arr, "orderNum", "asc")
.map((item, index) => {
if (item.hidden) {
return null;
}
const ElTableColumn = (
<el-table-column
key={`cl-table-column__${index}`}
align={style.table.column.align}
header-align={style.table.column.headerAlign}
minWidth={style.table.column.minWidth}
/>
);
// 操作按钮
if (item.type === "op") {
const props = assign(
{
label: crud.dict.label.op,
width: style.table.column.opWidth,
fixed: browser.isMini ? null : "right"
},
item
);
return h(ElTableColumn, props, {
default: (scope: any) => {
return (
<div class="cl-table__op">
{renderOpButtons(item.buttons, { scope })}
</div>
);
}
});
}
// 多选,序号
else if (["selection", "index"].includes(item.type)) {
return h(ElTableColumn, item);
}
// 默认
else {
function deep(item: ClTable.Column) {
if (item.hidden) {
return null;
}
const props: obj = cloneDeep(item);
// Cannot set property children of #<Element>
delete props.children;
return h(ElTableColumn, props, {
header(scope: any) {
return renderHeader(item, { scope, slots });
},
default(scope: any) {
if (item.children) {
return item.children.map(deep);
}
// 使用插槽
const slot = slots[`column-${item.prop}`];
if (slot) {
return slot({
scope,
item
});
} else {
// 绑定值
let value = scope.row[item.prop];
// 格式化
if (item.formatter) {
value = item.formatter(
scope.row,
scope.column,
value,
scope.$index
);
if (isObject(value)) {
return value;
}
}
// 自定义渲染
if (item.render) {
return item.render(
scope.row,
scope.column,
value,
scope.$index
);
}
// 自定义渲染2
else if (item.component) {
return renderNode(item.component, {
prop: item.prop,
scope: scope.row,
_data: {
column: scope.column,
index: scope.$index,
row: scope.row
}
});
}
// 字典状态
else if (item.dict) {
return renderDict(value, item);
}
// 空数据
else if (isEmpty(value)) {
return scope.emptyText;
} else {
return value;
}
}
}
});
}
return deep(item);
}
})
.filter(Boolean);
}
// 渲染操作按钮
function renderOpButtons(buttons: any, { scope }: any) {
const list = getValue(buttons || ["edit", "delete"], { scope }) as ClTable.OpButton;
return list.map((vnode) => {
if (vnode === "info") {
return (
<el-button
plain
size={style.size}
v-show={crud.getPermission("info")}
onClick={(e: MouseEvent) => {
crud.rowInfo(scope.row);
e.stopPropagation();
}}>
{crud.dict.label?.info}
</el-button>
);
} else if (vnode === "edit") {
return (
<el-button
text
type="primary"
size={style.size}
v-show={crud.getPermission("update")}
onClick={(e: MouseEvent) => {
crud.rowEdit(scope.row);
e.stopPropagation();
}}>
{crud.dict.label?.update}
</el-button>
);
} else if (vnode === "delete") {
return (
<el-button
text
type="danger"
size={style.size}
v-show={crud.getPermission("delete")}
onClick={(e: MouseEvent) => {
crud.rowDelete(scope.row);
e.stopPropagation();
}}>
{crud.dict.label?.delete}
</el-button>
);
} else {
if (typeof vnode === "object") {
if (vnode.hidden) {
return null;
}
}
return renderNode(vnode, {
scope,
slots,
custom(vnode) {
return (
<el-button
text
type={vnode.type}
{...vnode?.props}
onClick={(e: MouseEvent) => {
vnode.onClick({ scope });
e.stopPropagation();
}}>
{vnode.label}
</el-button>
);
}
});
}
});
}
// 渲染字典
function renderDict(value: any, item: ClTable.Column) {
// 选项列表
const list = cloneDeep(item.dict || []) as DictOptions;
// 字符串分隔符
const separator = item.dictSeparator === undefined ? "," : item.dictSeparator;
// 设置颜色
if (item.dictColor) {
list.forEach((e, i) => {
if (!e.color) {
e.color = style.colors[i];
}
});
}
// 绑定值
let values: any[] = [];
// 格式化值
if (isArray(value)) {
values = value;
} else if (isString(value)) {
if (separator) {
values = value.split(separator);
} else {
values = [value];
}
} else {
values = [value];
}
// 返回值
const result = values
.filter((e) => e !== undefined && e !== null && e !== "")
.map((v) => {
const d = deepFind(v, list, { allLevels: item.dictAllLevels }) || {
label: v,
value: v
};
return {
...d,
children: []
};
});
// 格式化返回
if (item.dictFormatter) {
return item.dictFormatter(result);
} else {
// tag 返回
return result.map((e) => {
return h(
<el-tag disable-transitions style="margin: 2px; border: 0" />,
{
type: e.type,
closable: e.closable,
hit: e.hit,
color: e.color,
size: e.size,
effect: e.effect || "dark",
round: e.round
},
{
default: () => <span>{e.label}</span>
}
);
});
}
}
// 插槽 empty
function renderEmpty(emptyText: string) {
return (
<div class="cl-table__empty">
{slots.empty ? (
slots.empty()
) : (
<el-empty image-size={100} description={emptyText}></el-empty>
)}
</div>
);
}
// 插槽 append
function renderAppend() {
return <div class="cl-table__append">{slots.append && slots.append()}</div>;
}
return {
renderColumn,
renderEmpty,
renderAppend
};
}

130
packages/crud/src/components/table/helper/row.ts

@ -0,0 +1,130 @@
import { isEmpty, isFunction } from "lodash-es";
import { useCore } from "../../../hooks";
import { ContextMenu } from "../../context-menu";
// 单元行事件
export function useRow({
Table,
config,
Sort
}: {
Table: Vue.Ref<any>;
config: ClTable.Config;
Sort: {
defaultSort: {
prop?: string;
order?: string;
};
changeSort(prop: string, order: string): void;
};
}) {
const { crud } = useCore();
// 右键菜单
function onRowContextMenu(row: obj, column: obj, event: PointerEvent) {
// 菜单按钮
const buttons = config.contextMenu;
// 是否开启
const enable = !isEmpty(buttons);
if (enable) {
// 高亮
Table.value.setCurrentRow(row);
// 解析按钮
const list = buttons
.map((e) => {
switch (e) {
case "refresh":
return {
label: crud.dict.label.refresh,
callback(done: fn) {
crud.refresh();
done();
}
};
case "edit":
case "update":
return {
label: crud.dict.label.update,
hidden: !crud.getPermission("update"),
callback(done: fn) {
crud.rowEdit(row);
done();
}
};
case "delete":
return {
label: crud.dict.label.delete,
hidden: !crud.getPermission("delete"),
callback(done: fn) {
crud.rowDelete(row);
done();
}
};
case "info":
return {
label: crud.dict.label.info,
hidden: !crud.getPermission("info"),
callback(done: fn) {
crud.rowInfo(row);
done();
}
};
case "check":
return {
label: crud.selection.find((e) => e.id == row.id)
? crud.dict.label.deselect
: crud.dict.label.select,
hidden: !config.columns.find((e) => e.type === "selection"),
callback(done: fn) {
Table.value.toggleRowSelection(row);
done();
}
};
case "order-desc":
return {
label: `${column.label} - ${crud.dict.label.desc}`,
hidden: !column.sortable,
callback(done: fn) {
Sort.changeSort(column.property, "desc");
done();
}
};
case "order-asc":
return {
label: `${column.label} - ${crud.dict.label.asc}`,
hidden: !column.sortable,
callback(done: fn) {
Sort.changeSort(column.property, "asc");
done();
}
};
default:
if (isFunction(e)) {
return e(row, column, event);
} else {
return e;
}
}
})
.filter((e) => Boolean(e) && !e.hidden);
// 打开菜单
if (!isEmpty(list)) {
ContextMenu.open(event, {
list
});
}
}
// 回调
if (config.onRowContextmenu) {
config.onRowContextmenu(row, column, event);
}
}
return {
onRowContextMenu
};
}

16
packages/crud/src/components/table/helper/selection.ts

@ -0,0 +1,16 @@
import { useCore } from "../../../hooks";
export function useSelection({ emit }: { emit: Vue.Emit }) {
const { crud } = useCore();
// 选择项发生变化
function onSelectionChange(selection: any[]) {
crud.selection.splice(0, crud.selection.length, ...selection);
emit("selection-change", crud.selection);
}
return {
selection: crud.selection,
onSelectionChange
};
}

86
packages/crud/src/components/table/helper/sort.ts

@ -0,0 +1,86 @@
import { useCore } from "../../../hooks";
// 排序
export function useSort({
config,
Table,
emit
}: {
config: ClTable.Config;
Table: Vue.Ref<any>;
emit: Vue.Emit;
}) {
const { crud } = useCore();
// 设置默认排序Ï
const defaultSort = (function () {
let { prop, order } = config.defaultSort || {};
const item = config.columns.find((e) =>
["desc", "asc", "descending", "ascending"].find((a) => a == e.sortable)
);
if (item) {
prop = item.prop;
order = ["descending", "desc"].find((a) => a == item.sortable)
? "descending"
: "ascending";
}
if (order && prop) {
crud.params.order = ["descending", "desc"].includes(order) ? "desc" : "asc";
crud.params.prop = prop;
return {
prop,
order
};
}
return {};
})();
// 排序监听
function onSortChange({ prop, order }: { prop: string | undefined; order: string }) {
if (config.sortRefresh) {
if (order === "descending") {
order = "desc";
}
if (order === "ascending") {
order = "asc";
}
if (!order) {
prop = undefined;
}
crud.refresh({
prop,
order,
page: 1
});
}
emit("sort-change", { prop, order });
}
// 改变排序
function changeSort(prop: string, order: string) {
if (order === "desc") {
order = "descending";
}
if (order === "asc") {
order = "ascending";
}
Table.value?.sort(prop, order);
}
return {
defaultSort,
onSortChange,
changeSort
};
}

165
packages/crud/src/components/table/index.tsx

@ -0,0 +1,165 @@
import { defineComponent, h } from "vue";
import {
useRow,
useHeight,
useRender,
useSort,
useData,
useSelection,
useOp,
useTable
} from "./helper";
import { useCore, useProxy, useElApi, useConfig } from "../../hooks";
import { usePlugins } from "./helper/plugins";
export default defineComponent({
name: "cl-table",
props: {
// 列配置
columns: {
type: Array,
default: () => []
},
// 是否自动计算高度
autoHeight: {
type: Boolean,
default: null
},
// 固定高度
height: null,
// 右键菜单
contextMenu: {
type: [Array, Boolean],
default: null
},
// 默认排序
defaultSort: Object,
// 排序后是否刷新
sortRefresh: {
type: Boolean,
default: true
},
// 空数据显示文案
emptyText: String,
// 当前行的 key
rowKey: {
type: String,
default: "id"
}
},
emits: ["selection-change", "sort-change"],
setup(props, { emit, expose }) {
const { crud } = useCore();
const { style } = useConfig();
const { Table, config } = useTable(props);
const plugin = usePlugins();
// 排序
const Sort = useSort({ config, emit, Table });
// 行
const Row = useRow({
config,
Table,
Sort
});
// 高度
const Height = useHeight({ config, Table });
// 数据
const Data = useData({ config, Table });
// 多选
const Selection = useSelection({ emit });
// 操作
const Op = useOp({ config });
// 方法
const ElTableApi = useElApi(
[
"clearSelection",
"getSelectionRows",
"toggleRowSelection",
"toggleAllSelection",
"toggleRowExpansion",
"setCurrentRow",
"clearSort",
"clearFilter",
"doLayout",
"sort",
"scrollTo",
"setScrollTop",
"setScrollLeft",
"updateKeyChildren"
],
Table
);
const ctx = {
Table,
config,
columns: config.columns,
...Selection,
...Data,
...Sort,
...Row,
...Height,
...Op,
...ElTableApi
};
useProxy(ctx);
expose(ctx);
plugin.create(config.plugins);
return () => {
const { renderColumn, renderAppend, renderEmpty } = useRender();
return (
ctx.visible.value &&
h(
<el-table class="cl-table" ref={Table} v-loading={crud.loading} />,
{
...config.on,
...config.props,
// config
maxHeight: config.autoHeight ? ctx.maxHeight.value : null,
height: config.autoHeight ? config.height : null,
rowKey: config.rowKey,
// ctx
defaultSort: ctx.defaultSort,
data: ctx.data.value,
onRowContextmenu: ctx.onRowContextMenu,
onSelectionChange: ctx.onSelectionChange,
onSortChange: ctx.onSortChange,
// style
size: style.size,
border: style.table.border,
highlightCurrentRow: style.table.highlightCurrentRow,
resizable: style.table.resizable,
stripe: style.table.stripe,
},
{
default() {
return renderColumn(ctx.columns);
},
empty() {
return renderEmpty(config.emptyText || crud.dict.label.empty);
},
append() {
return renderAppend();
}
}
)
);
};
}
});

306
packages/crud/src/components/upsert/index.tsx

@ -0,0 +1,306 @@
import { defineComponent, h, inject, reactive, ref, toRefs } from "vue";
import { ElMessage } from "element-plus";
import { useCore, useProxy } from "../../hooks";
import { useApi } from "../form/helper";
import { mergeConfig } from "../../utils";
export default defineComponent({
name: "cl-upsert",
props: {
// 表单项
items: {
type: Array,
default: () => []
},
// <el-form /> 参数
props: Object,
// 编辑时是否同步打开
sync: Boolean,
// 操作按钮参数
op: Object,
// <cl-dialog /> 参数
dialog: Object,
// 打开表单钩子
onOpen: Function,
// 打开表单后钩子
onOpened: Function,
// 关闭表单钩子
onClose: Function,
// 关闭表单后钩子
onClosed: Function,
// 获取表单数据钩子
onInfo: Function,
// 表单提交钩子
onSubmit: Function
},
emits: ["opened", "closed"],
setup(props, { slots, expose }) {
const { crud } = useCore();
const config = reactive<ClUpsert.Config>(
mergeConfig(props, inject("useUpsert__options") || {})
);
// el-form
const Form = ref<ClForm.Ref>();
// 模式
const mode = ref<ClUpsert.Ref["mode"]>("info");
// 关闭表单
function close(action?: ClForm.CloseAction) {
Form.value?.close(action);
}
// 关闭后
function onClosed() {
Form.value?.hideLoading();
if (config.onClosed) {
config.onClosed();
}
}
// 关闭前
function beforeClose(action: ClForm.CloseAction, done: fn) {
function next() {
done();
onClosed();
}
if (config.onClose) {
config.onClose(action, next);
} else {
next();
}
}
// 提交
function submit(data: obj) {
const { service, dict, refresh } = crud;
function done() {
Form.value?.done();
}
function next(data: obj) {
return new Promise((resolve, reject) => {
// 发送请求
service[dict.api[mode.value]](data)
.then((res) => {
ElMessage.success(dict.label.saveSuccess);
done();
close("save");
refresh();
resolve(res);
})
.catch((err) => {
ElMessage.error(err.message);
done();
reject(err);
});
});
}
// 提交钩子
if (config.onSubmit) {
config.onSubmit(data, {
done,
next,
close() {
close("save");
}
});
} else {
next(data);
}
}
// 打开表单
function open() {
// 是否禁用
const isDisabled = mode.value == "info";
return new Promise((resolve) => {
if (!Form.value) {
return console.error("<cl-upsert /> is not found");
}
Form.value?.open(
{
title: crud.dict.label[mode.value],
props: {
...config.props,
disabled: isDisabled
},
op: {
...config.op,
hidden: isDisabled
},
dialog: config.dialog,
items: config.items || [],
on: {
open() {
if (config.onOpen) {
config.onOpen();
}
resolve(true);
},
submit,
close: beforeClose
},
form: {},
_data: {
isDisabled
}
},
config.plugins
);
});
}
// 打开后事件
function onOpened() {
const data = Form.value?.getForm();
if (config.onOpened) {
config.onOpened(data);
}
}
// 新增
async function add() {
mode.value = "add";
// 打开中
await open();
// 打开后
onOpened();
}
// 追加
async function append(data: any) {
mode.value = "add";
// 打开中
await open();
// 绑定值
if (data) {
Form.value?.bindForm(data);
}
// 打开后
onOpened();
}
// 编辑
function edit(data?: any) {
mode.value = "update";
getInfo(data);
}
// 详情
function info(data?: any) {
mode.value = "info";
getInfo(data);
}
// 信息
function getInfo(data: any) {
// 显示加载中
Form.value?.showLoading();
// 是否同步打开
if (!config.sync) {
open();
}
// 完成
async function done(data?: any) {
// 加载完成
Form.value?.hideLoading();
// 合并数据
if (data) {
Form.value?.bindForm(data);
}
// 同步打开表单
if (config.sync) {
await open();
}
onOpened();
}
// 获取详情
function next(data: any): Promise<any> {
return new Promise(async (resolve, reject) => {
// 发送请求
await crud.service[crud.dict.api.info]({
[crud.dict.primaryId]: data[crud.dict.primaryId]
})
.then((res) => {
done(res);
resolve(res);
})
.catch((err) => {
ElMessage.error(err.message);
reject(err);
});
// 隐藏加载框
Form.value?.hideLoading();
});
}
// 详情钩子
if (config.onInfo) {
config.onInfo(data, {
close,
next,
done
});
} else {
next(data);
}
}
// 完成
function done() {
Form.value?.hideLoading();
}
const ctx = {
config,
...toRefs(config),
...useApi({ Form }),
Form,
get form() {
return Form.value?.form || {};
},
mode,
add,
append,
edit,
info,
open,
close,
done,
submit
};
useProxy(ctx);
expose(ctx);
return () => {
return <div class="cl-upsert">{h(<cl-form ref={Form} />, {}, slots)}</div>;
};
}
});

27
packages/crud/src/emitter.ts

@ -0,0 +1,27 @@
export const crudList: ClCrud.Ref[] = [];
export const emitter: Emitter = {
list: [],
init(events) {
for (const i in events) {
this.on(i, events[i]);
}
},
emit(name, data) {
this.list.forEach((e: EmitterItem) => {
const [_name] = e.name.split("-");
if (name == _name) {
e.callback(data, {
crudList,
refresh(params) {
crudList.forEach((c) => c.refresh(params));
}
});
}
});
},
on(name, callback) {
this.list.push({ name, callback });
}
};

30
packages/crud/src/entry.ts

@ -0,0 +1,30 @@
import { type App } from "vue";
import { useComponent } from "./components";
import { useProvide } from "./provide";
import global from "./utils/global";
import "./static/index.scss";
const Crud = {
install(app: App, options?: any) {
global.set("__CrudApp__", app);
// 穿透值
useProvide(app, options);
// 设置组件
useComponent(app);
return {
name: "cl-crud"
};
}
};
export { Crud };
export * from "./emitter";
export * from "./hooks";
export * from "./locale";
export { registerFormHook } from "./utils/form-hook";
export { renderNode } from "./utils/vnode";
export { ContextMenu } from "./components/context-menu";

191
packages/crud/src/hooks/crud.ts

@ -0,0 +1,191 @@
import { assign } from "lodash-es";
import { TestService } from "../test/service";
import { watch, ref, nextTick, getCurrentInstance, type Ref, inject, provide } from "vue";
// 获取上级
function useParent(name: string, r: Ref) {
const d = getCurrentInstance();
if (d) {
let parent = d.proxy?.$.parent;
if (parent) {
while (parent && parent.type?.name != name && parent.type?.name != "cl-crud") {
parent = parent?.parent;
}
if (parent) {
if (parent.type.name == name) {
r.value = parent.exposed;
}
}
}
}
}
// 多事件
function useEvent(
names: string[],
{ r, options, clear, isChild }: { r: any; options: any; clear?: string; isChild?: boolean }
) {
if (!r.__ev) r.__ev = {};
const d: { [key: string]: (args: any[]) => void } = {};
const ev = r.__ev as { [key: string]: { fn: any; isChild?: boolean }[] };
names.forEach((k) => {
if (!ev[k]) ev[k] = [];
if (options[k]) {
ev[k].push({
fn: options[k],
isChild
});
}
d[k] = (...args: any[]) => {
ev[k].forEach((e) => {
if (e.fn) {
e.fn(...args);
}
});
if (clear == k) {
for (const i in ev) {
ev[i] = ev[i].filter((e) => !e.isChild);
}
}
};
});
return d;
}
// crud
export function useCrud(options?: ClCrud.Options, cb?: (app: ClCrud.Ref) => void) {
const Crud = ref<ClCrud.Ref>();
useParent("cl-crud", Crud);
if (options) {
// 测试模式
if (options.service == "test") {
options.service = new TestService();
}
provide("useCrud__options", options);
}
watch(Crud, (val) => {
if (val) {
if (cb) {
cb(val);
}
}
});
return Crud;
}
// 新增、编辑
export function useUpsert<T = any>(options?: ClUpsert.Options<T>) {
const Upsert = ref<ClUpsert.Ref>();
useParent("cl-upsert", Upsert);
const isChild = !!Upsert.value;
if (options) {
provide("useUpsert__options", options);
}
watch(
Upsert,
(val) => {
if (val) {
if (options) {
const event = useEvent(["onOpen", "onOpened", "onClosed"], {
r: val,
options,
clear: "onClosed",
isChild
});
assign(val.config, event);
}
}
},
{
immediate: true
}
);
return Upsert;
}
// 表格
export function useTable<T = any>(options?: ClTable.Options<T>, cb?: (table: ClTable.Ref) => void) {
const Table = ref<ClTable.Ref<T>>();
useParent("cl-table", Table);
if (options) {
provide("useTable__options", options);
}
watch(Table, (val) => {
if (val) {
if (cb) {
cb(val);
}
}
});
return Table;
}
// 表单
export function useForm<T = any>(cb?: (app: ClForm.Ref<T>) => void) {
const Form = ref<ClForm.Ref<T>>();
useParent("cl-form", Form);
nextTick(() => {
if (cb && Form.value) {
cb(Form.value);
}
});
return Form;
}
// 高级搜索
export function useAdvSearch<T = any>(options?: ClAdvSearch.Options<T>) {
const AdvSearch = ref<ClAdvSearch.Ref<T>>();
useParent("cl-adv-search", AdvSearch);
if (options) {
provide("useAdvSearch__options", options);
}
return AdvSearch;
}
// 搜索
export function useSearch<T = any>(options?: ClSearch.Options<T>) {
const Search = ref<ClSearch.Ref<T>>();
useParent("cl-search", Search);
provide("useSearch__options", options);
return Search;
}
// 对话框
export function useDialog(options?: { onFullscreen(visible: boolean): void }) {
const Dialog = inject("dialog") as ClDialog.Provide;
watch(
() => Dialog?.fullscreen.value,
(val) => {
options?.onFullscreen(val);
}
);
return Dialog;
}

74
packages/crud/src/hooks/index.ts

@ -0,0 +1,74 @@
import { Mitt } from "../utils/mitt";
import { isFunction } from "lodash-es";
import { getCurrentInstance, inject, reactive } from "vue";
export function useCore() {
const crud = inject("crud") as ClCrud.Ref;
const mitt = inject("mitt") as Mitt;
return {
crud,
mitt
};
}
export function useConfig() {
return inject("__config__") as Config;
}
export function useBrowser() {
return inject("__browser__") as Browser;
}
export function useRefs() {
const refs = reactive<{ [key: string]: obj }>({});
function setRefs(name: string) {
return (el: any) => {
refs[name] = el;
};
}
return { refs, setRefs };
}
export function useProxy(ctx: any) {
const { type }: any = getCurrentInstance();
const { mitt, crud } = useCore();
// 挂载
crud[type.name] = ctx;
// 事件
mitt.on("crud.proxy", ({ name, data = [], callback }: any) => {
if (ctx[name]) {
let d = null;
if (isFunction(ctx[name])) {
d = ctx[name](...data);
} else {
d = ctx[name];
}
if (callback) {
callback(d);
}
}
});
return ctx;
}
export function useElApi(keys: string[], el: any) {
const apis: obj = {};
keys.forEach((e) => {
apis[e] = (...args: any[]) => {
return el.value[e](...args);
};
});
return apis;
}
export * from "./crud";

1
packages/crud/src/index.ts

@ -0,0 +1 @@
export * from "./entry";

33
packages/crud/src/locale/en.ts

@ -0,0 +1,33 @@
export default {
op: "Operation",
add: "Add",
delete: "Delete",
multiDelete: "Delete",
update: "Edit",
refresh: "Refresh",
info: "Details",
search: "Search",
reset: "Reset",
clear: "Clear",
save: "Save",
close: "Cancel",
confirm: "Confirm",
advSearch: "Advanced Search",
searchKey: "Search Keyword",
placeholder: "Please enter",
tips: "Tips",
saveSuccess: "Save successful",
deleteSuccess: "Delete successful",
deleteConfirm:
"This operation will permanently delete the selected data. Do you want to continue?",
empty: "No data available",
desc: "Descending",
asc: "Ascending",
select: "Select",
deselect: "Deselect",
seeMore: "See more",
hideContent: "Hide content",
nonEmpty: "{label} cannot be empty",
collapse: "Collapse",
expand: "Expand"
};

11
packages/crud/src/locale/index.ts

@ -0,0 +1,11 @@
import en from "./en";
import ja from "./ja";
import zhCn from "./zh-cn";
import zhTw from "./zh-tw";
export const locale = {
en,
ja,
"zh-cn": zhCn,
"zh-tw": zhTw
};

32
packages/crud/src/locale/ja.ts

@ -0,0 +1,32 @@
export default {
op: "操作",
add: "追加",
delete: "削除",
multiDelete: "削除",
update: "編集",
refresh: "リフレッシュ",
info: "詳細",
search: "検索",
reset: "リセット",
clear: "クリア",
save: "保存",
close: "キャンセル",
confirm: "確認",
advSearch: "高度な検索",
searchKey: "検索キーワード",
placeholder: "入力してください",
tips: "ヒント",
saveSuccess: "保存が成功しました",
deleteSuccess: "削除が成功しました",
deleteConfirm: "この操作は選択したデータを永久に削除します。続行しますか?",
empty: "データがありません",
desc: "降順",
asc: "昇順",
select: "選択",
deselect: "選択解除",
seeMore: "詳細を表示",
hideContent: "コンテンツを非表示",
nonEmpty: "{label}は空にできません",
collapse: "折り畳む",
expand: "展開"
};

32
packages/crud/src/locale/zh-cn.ts

@ -0,0 +1,32 @@
export default {
op: "操作",
add: "新增",
delete: "删除",
multiDelete: "删除",
update: "编辑",
refresh: "刷新",
info: "详情",
search: "搜索",
reset: "重置",
clear: "清空",
save: "保存",
close: "取消",
confirm: "确定",
advSearch: "高级搜索",
searchKey: "搜索关键字",
placeholder: "请输入",
tips: "提示",
saveSuccess: "保存成功",
deleteSuccess: "删除成功",
deleteConfirm: "此操作将永久删除选中数据,是否继续?",
empty: "暂无数据",
desc: "降序",
asc: "升序",
select: "选择",
deselect: "取消选择",
seeMore: "查看更多",
hideContent: "隐藏内容",
nonEmpty: "{label}不能为空",
collapse: "收起",
expand: "展开更多"
};

32
packages/crud/src/locale/zh-tw.ts

@ -0,0 +1,32 @@
export default {
op: "操作",
add: "新增",
delete: "刪除",
multiDelete: "刪除",
update: "編輯",
refresh: "刷新",
info: "詳情",
search: "搜尋",
reset: "重置",
clear: "清空",
save: "保存",
close: "取消",
confirm: "確定",
advSearch: "高級搜索",
searchKey: "搜索關鍵字",
placeholder: "請輸入",
tips: "提示",
saveSuccess: "保存成功",
deleteSuccess: "刪除成功",
deleteConfirm: "此操作將永久刪除選中數據,是否繼續?",
empty: "暫無數據",
desc: "降序",
asc: "升序",
select: "選擇",
deselect: "取消選擇",
seeMore: "查看更多",
hideContent: "隱藏內容",
nonEmpty: "{label}不能為空",
collapse: "收起",
expand: "展開"
};

28
packages/crud/src/main.ts

@ -0,0 +1,28 @@
import { createApp } from "vue";
import App from "./App.vue";
import { Crud, locale } from "./entry";
// import Crud, { locale } from "../dist/index.umd";
// import "../dist/index.css";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
const app = createApp(App);
app.use(ElementPlus)
.use(Crud, {
dict: {
sort: {
prop: "order",
order: "sort"
},
label: locale["zh-cn"]
},
style: {
// size: "default"
},
render: {
autoHeight: true
}
})
.mount("#app");

129
packages/crud/src/provide.ts

@ -0,0 +1,129 @@
import { type App, reactive } from "vue";
import { mitt } from "./utils/mitt";
import { emitter } from "./emitter";
import { locale } from "./locale";
import { merge } from "./utils";
// 设置配置
function setConfig(app: App, options: Options = {}) {
const config = merge(
{
permission: {
update: true,
page: true,
info: true,
list: true,
add: true,
delete: true
},
dict: {
primaryId: "id",
api: {
list: "list",
add: "add",
update: "update",
delete: "delete",
info: "info",
page: "page"
},
pagination: {
page: "page",
size: "size"
},
search: {
keyWord: "keyWord",
query: "query"
},
sort: {
order: "order",
prop: "prop"
},
label: locale["zh-cn"]
},
style: {
colors: [
"#d42ca8",
"#1c109d",
"#6d17c3",
"#6dc9f1",
"#04c273",
"#06b31c",
"#f9f494",
"#aa7a24",
"#d57121",
"#e93f4d"
],
form: {
labelPostion: "right",
labelWidth: "100px",
span: 24
},
table: {
border: true,
highlightCurrentRow: true,
autoHeight: true,
contextMenu: ["refresh", "check", "edit", "delete", "order-asc", "order-desc"],
column: {
align: "center",
opWidth: 180
}
}
},
events: {}
} as Options,
options
);
// 初始化事件
if (config.events) {
emitter.init(config.events);
}
app.provide("__config__", config);
return config;
}
// 设置浏览器
function setBrowser(app: App) {
// 浏览器信息
const browser = reactive({
isMini: false,
screen: "full"
});
// 更新信息
function update() {
const w = document.body.clientWidth;
if (w < 768) {
browser.screen = "xs";
} else if (w < 992) {
browser.screen = "sm";
} else if (w < 1200) {
browser.screen = "md";
} else if (w < 1920) {
browser.screen = "xl";
} else {
browser.screen = "full";
}
browser.isMini = browser.screen === "xs";
}
// 监听浏览器窗口变化
window.addEventListener("resize", () => {
update();
// 事件
mitt.emit("resize");
});
update();
app.provide("__browser__", browser);
}
export function useProvide(app: App, options: Options = {}) {
setBrowser(app);
setConfig(app, options);
}

862
packages/crud/src/static/index.scss

@ -0,0 +1,862 @@
.cl-crud {
height: 100%;
overflow: hidden auto;
position: relative;
box-sizing: border-box;
background-color: #fff;
&.is-border {
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
}
& > .cl-row {
width: 100%;
&:not(.cl-row--last) > * {
margin: 0 10px 10px 0;
&:last-child {
margin-right: 0;
}
}
.cl-flex1 {
margin-right: 0;
}
}
}
.cl-flex1 {
flex: 1;
font-size: 12px;
}
.cl-search-key {
display: inline-flex;
&__select {
margin-right: 10px;
&.el-select {
width: 100px;
}
}
&__wrap {
display: inline-flex;
.el-input {
flex: 1;
}
.el-button {
margin-left: 10px;
}
}
}
.cl-table {
width: 100%;
.el-table {
&.el-loading-parent--relative {
box-sizing: border-box;
}
&__header {
.el-table__cell {
background-color: var(--el-fill-color-lighter) !important;
color: var(--el-text-color-primary);
.cell {
line-height: normal;
}
&.is-sortable {
.cl-table-header__search {
width: auto;
&.is-input {
width: calc(100% - 24px) !important;
}
}
}
}
}
&__empty-block {
height: auto !important;
}
}
.el-loading-mask {
.el-loading-spinner {
.el-icon-loading {
font-size: 25px;
color: #000;
}
.el-loading-text {
color: var(--el-text-color-secondary);
margin-top: 5px;
}
}
}
&__op {
margin-bottom: -5px;
.el-button {
margin-bottom: 5px;
outline-offset: -2px !important;
&.is-text {
border: 1px solid var(--el-button-border-color) !important;
&:hover {
background-color: var(--el-button-bg-color) !important;
}
}
}
}
.cl-table-header__search {
display: inline-flex;
align-items: center;
gap: 5px;
width: 100%;
cursor: pointer;
&-label {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
}
&:hover {
color: var(--el-color-primary);
}
&-inner {
flex: 1;
& > div {
width: 100%;
.el-date-editor {
margin-right: 0;
}
}
}
&-close {
font-size: 14px;
height: 30px;
width: 30px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
background-color: var(--el-fill-color-blank);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
color: var(--el-text-color-secondary);
&:hover {
border-color: var(--el-border-color-hover);
color: var(--el-text-color-primary);
}
}
}
.is-left {
.cl-table-header__search-label {
justify-content: flex-start;
}
}
.is-right {
.cl-table-header__search-label {
justify-content: flex-end;
}
}
}
.cl-pagination {
&.el-pagination {
--el-pagination-border-radius: var(--el-border-radius-base);
}
.btn-prev,
.btn-next,
.el-pager li {
border-radius: var(--el-border-radius-base);
}
}
.cl-filter {
display: flex;
align-items: center;
margin: 0 10px;
&__label {
font-size: 12px;
margin-right: 10px;
white-space: nowrap;
}
.el-select {
min-width: 120px;
}
}
.cl-search {
&__btns {
margin-left: 5px;
}
&__more {
display: flex;
align-items: center;
justify-content: space-between;
.el-form-item {
margin-bottom: 0;
}
}
.el-form:not(.el-form--label-top) {
.el-form-item__label {
&:empty {
display: none;
}
}
.cl-search__btns {
.el-form-item__label {
display: none;
}
}
}
.cl-search__btns {
.el-button + .el-button {
margin-left: 10px;
}
}
&.is-inline {
margin-bottom: 0 !important;
}
&.is-collapse {
background-color: var(--el-fill-color-lighter);
padding: 10px;
margin-bottom: 10px !important;
border-radius: 6px;
border: 1px solid var(--el-border-color-extra-light);
&.is-fold {
.cl-search__form {
max-height: 42px;
overflow: hidden;
&:has(.el-form--label-top) {
max-height: 68px;
}
}
}
}
}
.cl-adv-btn {
margin-left: 10px;
.el-icon {
margin-right: 5px;
}
}
.cl-adv-search {
&.el-drawer {
background-color: #fff;
}
.el-drawer__body {
padding: 0;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
padding: 0 15px 0 20px;
user-select: none;
.text {
font-size: 16px;
}
.el-icon {
cursor: pointer;
&:hover {
color: red;
}
}
}
&__container {
height: calc(100% - 110px);
overflow-y: auto;
padding: 10px 20px;
box-sizing: border-box;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
border-radius: 6px;
background-color: rgba(144, 147, 153, 0.3);
}
.el-form-item__content {
& > div {
width: 100%;
}
}
}
&__footer {
display: flex;
align-items: center;
justify-content: flex-end;
height: 60px;
border-top: 1px solid var(--el-border-color-extra-light);
padding: 0 10px;
box-sizing: border-box;
}
}
.cl-form {
[class*="el-col-"].is-guttered {
min-height: 0;
}
&-item {
display: flex;
&__component {
display: flex;
&.flex1 {
flex: 1;
width: 100%;
& > div {
width: 100%;
}
}
}
&__prepend {
margin-right: 10px;
}
&__append {
margin-left: 10px;
}
&__collapse {
width: 100%;
font-size: 12px;
cursor: pointer;
.el-divider {
margin: 16px 0;
&__text {
font-size: 12px;
}
}
i {
margin-left: 6px;
}
}
.el-table__header tr {
line-height: normal;
}
}
&__footer {
display: flex;
justify-content: flex-end;
}
.cl-crud {
line-height: normal;
}
.el-form {
.el-form-item {
.el-form-item {
margin-bottom: 18px;
}
.el-input-number {
&__decrease,
&__increase {
border: 0;
background-color: transparent;
}
}
&__label {
.el-tooltip {
i {
margin-left: 5px;
}
}
}
&__content {
min-width: 0px;
& > div {
width: 100%;
}
}
}
&:not(.el-form--label-top) {
.el-form-item {
&.no-label {
& > .el-form-item__label {
padding: 0;
display: none;
}
}
}
}
&.el-form--label-top {
.el-form-item {
margin-bottom: 22px;
}
.el-form-item__label {
margin: 0 0 4px 0;
min-height: 22px;
}
.el-form-item__error {
padding-top: 4px;
}
}
&.el-form--inline {
.cl-form__items {
display: flex;
flex-wrap: wrap;
}
.el-form-item {
margin: 0 10px 10px 0;
.el-date-editor {
box-sizing: border-box;
.el-range-input {
&:nth-child(2) {
margin-left: 5px;
}
}
}
.el-select {
width: 173px;
}
&:last-child {
margin-right: 0;
}
}
}
}
}
.cl-form-tabs {
border-bottom: 1px solid var(--el-border-color);
overflow: hidden;
width: calc(100% - 10px);
margin: 0 5px 20px 5px;
&__wrap {
height: 35px;
width: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
position: relative;
&::-webkit-scrollbar {
display: none;
}
}
ul {
display: inline-flex;
white-space: nowrap;
margin: 0;
padding: 0;
li {
display: inline-flex;
align-items: center;
list-style: none;
padding: 0 20px;
height: 35px;
cursor: pointer;
.el-icon {
margin-right: 5px;
font-size: 16px;
}
&.is-active {
color: var(--el-color-primary);
}
}
}
&__line {
height: 3px;
width: 100%;
position: absolute;
bottom: -1px;
left: 0;
transition:
transform 0.3s ease-in-out,
width 0.2s 0.1s cubic-bezier(0.645, 0.045, 0.355, 1);
background-color: var(--el-color-primary);
}
&--card {
.cl-form-tabs__line {
display: none;
}
ul {
border: 1px solid var(--el-border-color);
border-top-left-radius: 5px;
border-top-right-radius: 5px;
li {
border-left: 1px solid var(--el-border-color);
&:first-child {
border-left-width: 0;
}
}
}
}
}
.cl-form-card {
margin-top: 0;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
height: 32px;
line-height: normal;
font-weight: bold;
padding: 0 10px;
background-color: var(--el-fill-color-lighter);
border-radius: var(--el-border-radius-base);
cursor: pointer;
user-select: none;
&:hover {
background-color: var(--el-fill-color-light);
}
}
&__container {
transition: all 0.3s;
display: grid;
grid-template-rows: 0fr;
> .cl-form-item__children {
min-height: 0;
overflow: hidden;
.el-row {
margin-top: 10px;
}
}
}
&.is-expand {
> .cl-form-card__container {
grid-template-rows: 1fr;
margin-bottom: -18px;
}
}
.cl-form-card {
margin-left: 10px;
}
}
.cl-dialog {
display: flex;
flex-direction: column;
&.el-dialog {
padding: 0;
border-radius: 8px;
}
.el-dialog {
&__header {
padding: 0;
margin-right: 0;
&-slot {
&.is-drag {
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
-khtml-user-select: none;
user-select: none;
cursor: move;
}
}
}
&__body {
padding: 0;
box-sizing: border-box;
flex: 1;
overflow: hidden;
}
&__footer {
padding: 0;
}
}
&__header {
position: relative;
padding: 10px;
border-bottom: 1px solid var(--el-border-color-extra-light);
text-align: center;
user-select: none;
}
&__default {
box-sizing: border-box;
}
&__footer {
border-top: 1px solid var(--el-border-color-extra-light);
padding: 20px;
}
&__title {
display: block;
font-size: 15px;
letter-spacing: 0.5px;
}
&__controls {
display: flex;
justify-content: flex-end;
position: absolute;
right: 8px;
top: 8px;
z-index: 9;
width: 100%;
&-icon,
.minimize,
.maximize,
.close {
display: flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
background-color: transparent;
cursor: pointer;
outline: none;
border-radius: 6px;
margin-left: 5px;
i {
font-size: 18px;
}
&:hover {
background-color: var(--el-fill-color-darker);
}
}
}
&.hidden-header {
.el-dialog__header {
display: none;
}
}
&.is-fullscreen {
height: 100vh !important;
border-radius: 0;
overflow: hidden;
.cl-dialog__container {
height: 100% !important;
}
}
&.is-transparent {
background-color: transparent !important;
}
}
.cl-context-menu {
position: absolute;
z-index: 9999;
&__box {
width: 160px;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-extra-light);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
border-radius: 6px;
position: absolute;
top: 0;
padding: 5px;
&.is-append {
right: calc(-100% - 5px);
top: -5px;
}
& > div {
display: flex;
align-items: center;
font-size: 12px;
cursor: pointer;
padding: 5px 15px;
color: var(--el-text-color-primary);
position: relative;
border-radius: 4px;
span {
flex: 1;
user-select: none;
}
&:hover {
background-color: var(--el-fill-color-lighter);
}
i {
&:first-child {
margin-right: 5px;
}
&:last-child {
margin-left: 5px;
}
}
&.is-active {
background-color: #f7f7f7;
color: #000;
}
&.is-ellipsis {
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&.is-disabled {
span {
color: #ccc;
&:hover {
color: #ccc;
}
}
}
}
}
&__target {
position: relative;
&::after {
content: "";
display: block;
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
background-color: rgba(0, 0, 0, 0.05);
}
}
}
@media only screen and (max-width: 768px) {
.el-table {
&__body {
&-wrapper {
&::-webkit-scrollbar {
height: 6px;
width: 6px;
}
}
}
}
.cl-search-key {
width: 100%;
margin-right: 0 !important;
&__wrap {
width: 100% !important;
}
}
}
.cl-error-message {
display: flex;
align-items: center;
height: 32px;
padding: 0 10px;
font-size: 14px;
color: var(--el-color-danger);
border: 1px solid var(--el-color-danger);
border-radius: 4px;
box-sizing: border-box;
user-select: none;
}

231
packages/crud/src/test/service.ts

@ -0,0 +1,231 @@
import { assign, orderBy } from "lodash-es";
import { uuid } from "../utils";
const userList = [
{
id: 1,
name: "楚行云",
createTime: "1996-09-14",
wages: 73026,
status: 1,
account: "chuxingyun",
occupation: 4,
phone: 13797353874
},
{
id: 2,
name: "秦尘",
createTime: "1977-11-09",
wages: 74520,
status: 0,
account: "qincheng",
occupation: 3,
phone: 18593911044
},
{
id: 3,
name: "叶凡",
createTime: "1982-11-28",
wages: 81420,
status: 0,
account: "yefan",
occupation: 1,
phone: 16234136338
},
{
id: 4,
name: "白小纯",
createTime: "2012-12-17",
wages: 65197,
status: 1,
account: "baixiaochun",
occupation: 2,
phone: 16325661110
},
{
id: 5,
name: "韩立",
createTime: "1982-07-10",
wages: 99107,
status: 1,
account: "hanli",
occupation: 2,
phone: 18486594866
},
{
id: 6,
name: "唐三",
createTime: "2019-07-31",
wages: 80658,
status: 1,
account: "tangsan",
occupation: 5,
phone: 15565014642
},
{
id: 7,
name: "王林",
createTime: "2009-07-26",
wages: 57408,
status: 1,
account: "wanglin",
occupation: 1,
phone: 13852767084
},
{
id: 8,
name: "李强",
createTime: "2016-04-26",
wages: 71782,
status: 1,
account: "liqiang",
occupation: 3,
phone: 18365332834
},
{
id: 9,
name: "秦羽",
createTime: "1984-01-18",
wages: 87860,
status: 1,
account: "qinyu",
occupation: 0,
phone: 18149247129
}
];
class TestService {
// 分页列表
async page(params: any) {
const { keyWord, page, size, sort, order } = params || {};
// 关键字查询
const keyWordLikeFields = ["phone", "name"];
// 等值查询
const fieldEq = ["createTime", "occupation", "status"];
// 模糊查询
const likeFields = ["phone", "name"];
// 过滤后的列表
const list = orderBy(userList, order, sort).filter((e: any) => {
let f = true;
if (keyWord !== undefined) {
f = !!keyWordLikeFields.find((k) => String(e[k]).includes(String(params.keyWord)));
}
fieldEq.forEach((k) => {
if (f) {
if (params[k] !== undefined) {
f = e[k] == params[k];
}
}
});
likeFields.forEach((k) => {
if (f) {
if (params[k] !== undefined) {
f = String(e[k]).includes(String(params[k]));
}
}
});
return f;
});
return new Promise((resolve) => {
// 模拟延迟
setTimeout(() => {
resolve({
list: list.slice((page - 1) * size, page * size),
pagination: {
total: list.length,
page,
size
},
subData: {
wages: list.reduce((a, b) => {
return a + b.wages;
}, 0)
}
});
}, 500);
});
}
// 更新
async update(params: { id: any; [key: string]: any }) {
const item = userList.find((e) => e.id == params.id);
if (item) {
assign(item, params);
}
}
// 新增
async add(params: any) {
const id = uuid();
userList.push({
id,
...params
});
return id;
}
// 详情
async info(params: { id: any }) {
const { id } = params || {};
return userList.find((e) => e.id == id);
}
// 删除
async delete(params: { ids: any[] }) {
const { ids = [] } = params || {};
ids.forEach((id) => {
const index = userList.findIndex((e) => e.id == id);
userList.splice(index, 1);
});
}
// 全部列表
async list() {
return userList;
}
search = {
fieldEq: [
{
propertyName: "occupation",
comment: "工作",
source: "a.occupation"
}
],
fieldLike: [
{
propertyName: "status",
comment: "状态",
dict: ["关闭", "开启"],
source: "a.status"
}
],
keyWordLikeFields: [
{
propertyName: "name",
comment: "姓名",
source: "a.name"
},
{
propertyName: "phone",
comment: "手机号",
source: "a.phone"
}
]
};
}
export { TestService };

149
packages/crud/src/utils/form-hook.ts

@ -0,0 +1,149 @@
import { isArray, isEmpty, isFunction, isObject, isString } from "lodash-es";
export const format: { [key: string]: ClForm.Hook["Fn"] } = {
number(value) {
return value ? (isArray(value) ? value.map(Number) : Number(value)) : value;
},
string(value) {
return value ? (isArray(value) ? value.map(String) : String(value)) : value;
},
split(value) {
if (isString(value)) {
return value.split(",").filter(Boolean);
} else if (isArray(value)) {
return value;
} else {
return [];
}
},
join(value) {
return isArray(value) ? value.join(",") : value;
},
boolean(value) {
return Boolean(value);
},
booleanNumber(value) {
return value ? 1 : 0;
},
datetimeRange(value, { form, method, prop }) {
const key = prop.charAt(0).toUpperCase() + prop.slice(1);
const start = `start${key}`;
const end = `end${key}`;
if (method == "bind") {
return [form[start], form[end]];
} else {
const [startTime, endTime] = value || [];
form[start] = startTime;
form[end] = endTime;
return undefined;
}
},
splitJoin(value, { method }) {
if (method == "bind") {
return isString(value) ? value.split(",").filter(Boolean) : value;
} else {
return isArray(value) ? value.join(",") : value;
}
},
json(value, { method }) {
if (method == "bind") {
try {
return JSON.parse(value);
} catch (e) {
return {};
}
} else {
return JSON.stringify(value);
}
},
empty(value) {
if (isString(value)) {
return value === "" ? undefined : value;
}
if (isArray(value)) {
return isEmpty(value) ? undefined : value;
}
return value;
}
};
function init({ value, form, prop }: any) {
if (prop) {
const [a, b] = prop.split("-");
if (b) {
form[prop] = form[a] ? form[a][b] : form[a];
} else {
form[prop] = value;
}
}
}
function parse(method: "submit" | "bind", { value, hook: pipe, form, prop }: any) {
init({ value, method, form, prop });
if (!pipe) {
return false;
}
let pipes: any[] = [];
if (isString(pipe)) {
if (format[pipe]) {
pipes = [pipe];
} else {
console.error(`[hook] ${pipe} is not found`);
}
} else if (isArray(pipe)) {
pipes = pipe;
} else if (isObject(pipe)) {
pipes = isArray(pipe[method]) ? pipe[method] : [pipe[method]];
} else if (isFunction(pipe)) {
pipes = [pipe];
} else {
console.error(`[hook] ${pipe} format error`);
}
let v = value;
pipes.forEach((e) => {
let f: any = null;
if (isString(e)) {
f = format[e];
} else if (isFunction(e)) {
f = e;
}
if (f) {
v = f(v, {
method,
form,
prop
});
}
});
if (prop) {
form[prop] = v;
}
}
const formHook = {
bind(data: any) {
parse("bind", data);
},
submit(data: any) {
parse("submit", data);
}
};
export function registerFormHook(name: string, fn: ClForm.Hook["Fn"]) {
format[name] = fn;
}
export default formHook;

16
packages/crud/src/utils/global.ts

@ -0,0 +1,16 @@
// @ts-nocheck
import { type App } from "vue";
export default {
get vue(): App {
return window.__CrudApp__;
},
get(key: string) {
return window[key];
},
set(key: string, value: any) {
window[key] = value;
}
};

164
packages/crud/src/utils/index.ts

@ -0,0 +1,164 @@
import { isRef, mergeProps, toValue } from "vue";
import { assign, flatMap, isArray, isFunction, isNumber, mergeWith } from "lodash-es";
// 是否对象
export function isObject(val: any) {
return val !== null && typeof val === "object";
}
// 解析px
export function parsePx(val: string | number) {
return isNumber(val) ? `${val}px` : val;
}
// 数据设置
export function dataset(obj: any, key: string, value: any): any {
const isGet = value === undefined;
let d = obj;
const arr = flatMap(
key.split(".").map((e) => {
if (e.includes("[")) {
return e.split("[").map((e) => e.replace(/"/g, ""));
} else {
return e;
}
})
);
try {
for (let i = 0; i < arr.length; i++) {
const e: any = arr[i];
let n: any = null;
if (e.includes("]")) {
const [k, v] = e.replace("]", "").split(":");
if (v) {
n = d.findIndex((x: any) => x[k] == v);
} else {
n = Number(k);
}
} else {
n = e;
}
if (i != arr.length - 1) {
d = d[n];
} else {
if (isGet) {
return d[n];
} else {
if (isObject(value)) {
assign(d[n], value);
} else {
d[n] = value;
}
}
}
}
return obj;
} catch (e) {
console.error("[dataset] format error", `${key}`);
return {};
}
}
// 元素是否包含
export function contains(parent: any, node: any) {
return parent !== node && parent && parent.contains(node);
}
// 合并配置
export function mergeConfig(a: any, b?: any): any {
return b ? mergeProps(a, b) : a;
}
// 合并数据
export function merge(d1: any, d2: any) {
return mergeWith(d1, d2, (_, b) => {
if (isArray(b)) {
return b;
}
});
}
// 添加元素
export function addClass(el: Element, name: string) {
if (el?.classList) {
el.classList.add(name);
}
}
// 移除元素
export function removeClass(el: Element, name: string) {
if (el?.classList) {
el.classList.remove(name);
}
}
// 获取值
export function getValue<T = any>(value: T | Vue.Ref<T> | ((d: any) => T), data?: any): T {
if (isRef(value)) {
return toValue(value) as T;
} else {
if (isFunction(value)) {
return value(data);
} else {
return value as T;
}
}
}
// 深度查找
export function deepFind(value: any, list: any[], options?: { allLevels: boolean }) {
const { allLevels = true } = options || {};
function deep(arr: DictOptions, name: string[]): any | undefined {
for (const e of arr) {
if (e.value === value) {
if (allLevels) {
return {
...e,
label: [...name, e.label].join(" / ")
};
} else {
return e;
}
} else if (e.children) {
const d = deep(e.children, [...name!, e.label!]);
if (d !== undefined) {
return d;
}
}
}
return undefined;
}
return deep(list, []);
}
// uuid
export function uuid(separator = "-"): string {
const s: any[] = [];
const hexDigits = "0123456789abcdef";
for (let i = 0; i < 36; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1);
}
s[14] = "4";
s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1);
s[8] = s[13] = s[18] = s[23] = separator;
return s.join("");
}
// 移除相同的方法
export function uniqueFns(fns: any[]) {
const arr = new Map();
fns.forEach((fn) => {
arr.set(fn.name, fn);
});
return Array.from(arr.values());
}

30
packages/crud/src/utils/mitt.ts

@ -0,0 +1,30 @@
import _mitt from "mitt";
const mitt = _mitt();
class Mitt {
id: number;
constructor(id?: number) {
this.id = id || 0;
}
send(type: "emit" | "off" | "on", name: string, ...args: any[]) {
// @ts-expect-error
mitt[type](`${this.id}__${name}`, ...args);
}
emit(name: string, ...args: any[]) {
this.send("emit", name, ...args);
}
off(name: string, handler: (...args: any[]) => void) {
this.send("off", name, handler);
}
on(name: string, handler: (...args: any[]) => void) {
this.send("on", name, handler);
}
}
export { Mitt, mitt };

52
packages/crud/src/utils/parse.tsx

@ -0,0 +1,52 @@
import { isString } from "lodash-es";
import { getValue, isObject } from ".";
// 解析扩展组件
export function parseExtensionComponent(vnode: Render.Component) {
if (["el-select", "el-radio-group", "el-checkbox-group"].includes(vnode.name!)) {
const list = getValue(vnode.options || []);
const children = (
<div>
{list.map((e, i) => {
let label: any;
let value: any;
if (isString(e)) {
label = value = e;
} else if (isObject(e)) {
label = e.label;
value = e.value;
} else {
return <cl-error-message title={`Component options error`} />;
}
switch (vnode.name) {
case "el-select":
return <el-option key={i} label={label} value={value} {...e.props} />;
case "el-radio-group":
return (
<el-radio key={i} value={value} {...e.props}>
{label}
</el-radio>
);
case "el-checkbox-group":
return (
<el-checkbox key={i} value={value} {...e.props}>
{label}
</el-checkbox>
);
default:
return null;
}
})}
</div>
);
return {
children
};
} else {
return {};
}
}

182
packages/crud/src/utils/vnode.tsx

@ -0,0 +1,182 @@
import { h, resolveComponent, toRaw, type VNode } from "vue";
import { isObject } from "./index";
import { parseExtensionComponent } from "./parse";
import global from "./global";
import { useConfig } from "../hooks";
import { isFunction, isString } from "lodash-es";
// 配置
interface Options {
// 标识
prop?: string;
// 数据值
scope?: any;
// 当前行
item?: any;
// 插槽
slots?: any;
// 子集
children?: any[] & any;
// 自定义
custom?: (vnode: any) => any;
// 渲染方式
render?: "slot" | null;
// 其他
[key: string]: any;
}
// 临时注册组件列表
const regs: Map<string, any> = new Map();
// 解析节点
export function parseNode(vnode: any, options: Options): VNode {
const { scope, prop, slots, children, _data } = options || {};
// 渲染后组件
let comp: VNode | null = null;
// 插槽模式渲染
if (vnode.name?.includes("slot-")) {
const rn = slots[vnode.name];
if (rn) {
return rn({ scope, prop, ..._data });
} else {
return <cl-error-message title={`${vnode.name} is not found`} />;
}
}
// 实例模式下,先注册到全局,再分解组件渲染
if (vnode.vm && !regs.get(vnode.name)) {
global.vue.component(vnode.name, { ...vnode.vm });
regs.set(vnode.name, { ...vnode.vm });
}
// 处理 props
if (isFunction(vnode.props)) {
vnode.props = vnode.props({ scope, prop, ..._data });
}
// 组件参数
const props = {
...vnode.props,
..._data,
prop,
scope
};
// 是否禁用
props.disabled = _data?.isDisabled || props.disabled;
// 添加双向绑定
if (props && scope) {
if (prop) {
props.modelValue = scope[prop];
props["onUpdate:modelValue"] = function (val: any) {
scope[prop] = val;
};
}
}
// 组件实例渲染
if (vnode.vm) {
comp = h(regs.get(vnode.name), props);
} else {
const slots = {
...vnode.slots
};
if (children) {
slots.default = () => children;
}
// 渲染组件
comp = h(toRaw(resolveComponent(vnode.name)), props, slots);
}
// 挂载到 refs 中
const refBind = vnode.ref || options.ref;
if (isFunction(refBind)) {
setTimeout(() => {
refBind(comp?.component?.exposed);
}, 0);
}
return comp;
}
// 渲染节点
export function renderNode(vnode: any, options: Options) {
const config = useConfig();
const { item, scope, children, _data, render } = options || {};
if (!vnode) {
return null;
}
if (vnode.__v_isVNode) {
return vnode;
}
// 默认参数配置
if (item) {
if (item.component) {
if (!item.component.props) {
item.component.props = {};
}
// 占位符
let placeholder = "";
switch (item.component?.name) {
case "el-input":
placeholder = config.dict.label.placeholder;
break;
}
if (placeholder) {
if (!item.component.props.placeholder) {
item.component.props.placeholder = placeholder;
}
}
}
}
// 组件实例
if (vnode.vm) {
if (!vnode.name) {
vnode.name = vnode.vm?.name || vnode.vm?.__hmrId;
}
return parseNode(vnode, options);
}
// 组件名渲染
if (isString(vnode)) {
if (render == "slot") {
if (!vnode.includes("slot-")) {
return vnode;
}
}
return parseNode({ name: vnode }, options);
}
// 方法回调
if (isFunction(vnode)) {
return vnode({ scope, h, ..._data });
}
// jsx 模式
if (isObject(vnode)) {
if (vnode.name) {
return parseNode(vnode, { ...options, children, ...parseExtensionComponent(vnode) });
} else {
if (options.custom) {
return options.custom(vnode);
}
return <cl-error-message title="Error,component name is required" />;
}
}
}

34
packages/crud/tsconfig.json

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"strict": false,
"noImplicitAny": false,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["./index.d.ts"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"declaration": true,
"declarationDir": "types",
"emitDeclarationOnly": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["src/App.vue", "node_modules", "dist"]
}

2
packages/crud/types/components/add-btn/index.d.ts

@ -0,0 +1,2 @@
declare const _default: import('vue').DefineComponent<{}, () => any, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, true, {}, any>;
export default _default;

4
packages/crud/types/components/adv/btn.d.ts

@ -0,0 +1,4 @@
declare const _default: import('vue').DefineComponent<{}, () => any, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {
Search: import('vue').DefineComponent<{}, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').VNodeProps & import('vue').AllowedComponentProps & import('vue').ComponentCustomProps, Readonly<import('vue').ExtractPropTypes<{}>>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, true, {}, any>;
}, {}, string, import('vue').ComponentProvideOptions, true, {}, any>;
export default _default;

42
packages/crud/types/components/adv/search.d.ts

@ -0,0 +1,42 @@
import { PropType } from 'vue';
declare const _default: import('vue').DefineComponent<import('vue').ExtractPropTypes<{
items: {
type: PropType<ClForm.Item[]>;
default: () => any[];
};
title: StringConstructor;
size: {
type: (NumberConstructor | StringConstructor)[];
default: string;
};
op: {
type: ArrayConstructor;
default: () => string[];
};
onSearch: FunctionConstructor;
}>, () => any, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, ("clear" | "reset")[], "clear" | "reset", import('vue').PublicProps, Readonly<import('vue').ExtractPropTypes<{
items: {
type: PropType<ClForm.Item[]>;
default: () => any[];
};
title: StringConstructor;
size: {
type: (NumberConstructor | StringConstructor)[];
default: string;
};
op: {
type: ArrayConstructor;
default: () => string[];
};
onSearch: FunctionConstructor;
}>> & Readonly<{
onReset?: (...args: any[]) => any;
onClear?: (...args: any[]) => any;
}>, {
size: string | number;
items: ClForm.Item<any>[];
op: unknown[];
}, {}, {
Close: import('vue').DefineComponent<{}, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').VNodeProps & import('vue').AllowedComponentProps & import('vue').ComponentCustomProps, Readonly<import('vue').ExtractPropTypes<{}>>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, true, {}, any>;
}, {}, string, import('vue').ComponentProvideOptions, true, {}, any>;
export default _default;

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

Loading…
Cancel
Save