Compare commits
15 Commits
d2b8bc2f8e
...
9436f5feee
| Author | SHA1 | Date |
|---|---|---|
|
|
9436f5feee | |
|
|
21de506907 | |
|
|
3ae0869386 | |
|
|
9b15be521e | |
|
|
40eeb6899a | |
|
|
6bc11b3c95 | |
|
|
ff389d988d | |
|
|
984a03c339 | |
|
|
706aebe7c2 | |
|
|
943ec54aed | |
|
|
43487136fb | |
|
|
6a000652b1 | |
|
|
aac4047c9a | |
|
|
01df5849cf | |
|
|
9a15a88eb0 |
11
.env
11
.env
|
|
@ -2,12 +2,13 @@
|
|||
# 应用配置
|
||||
# =====================================================================================
|
||||
# 网站标题
|
||||
VITE_TITLE = Appnify
|
||||
VITE_TITLE = 绝弹管理中心
|
||||
# 网站副标题
|
||||
VITE_SUBTITLE = 快速开发web应用的模板工具
|
||||
VITE_SUBTITLE = 绝弹管理中心
|
||||
# 部署路径: 当为 ./ 时路由模式需为 hash
|
||||
VITE_BASE = /
|
||||
# 接口前缀:参见 axios 的 baseURL
|
||||
# VITE_API = http://127.0.0.1:3030/
|
||||
VITE_API = https://appnify.app.juetan.cn/
|
||||
# 首页路径
|
||||
VITE_HOME_PATH = /home
|
||||
|
|
@ -24,8 +25,10 @@ VITE_PORT = 3020
|
|||
# 代理前缀
|
||||
VITE_PROXY_PREFIX = /api,/upload
|
||||
# 代理地址
|
||||
VITE_PROXY = http://127.0.0.1:3030/
|
||||
VITE_PROXY = https://appnify.app.juetan.cn/
|
||||
# API文档 说明:需返回符合 OPENAPI 规范的json内容
|
||||
VITE_OPENAPI = http://127.0.0.1:3030/openapi.json
|
||||
# 文件后缀 说明:设为dev时会优先加载index.dev.vue文件,否则回退至index.vue文件
|
||||
VITE_EXTENSION = dev
|
||||
VITE_EXTENSION = dev
|
||||
# 是否以离线模式启动,跳过登录
|
||||
VITE_OFFLINE = true
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +1,4 @@
|
|||
FROM node:20-alpine as builder
|
||||
|
||||
# 指定工作目录方便下一阶段引用
|
||||
WORKDIR /app
|
||||
# 启用pnpm功能(v16+)
|
||||
|
|
@ -14,7 +13,6 @@ COPY . .
|
|||
RUN pnpm build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
# 复制产物
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
# 复制nginx配置
|
||||
|
|
|
|||
32
README.md
32
README.md
|
|
@ -18,16 +18,12 @@
|
|||
|
||||
## 快速开始
|
||||
|
||||
1. 确保本地安装有如下软件,推荐最新版本。
|
||||
1. 确保本地安装有如下软件(推荐最新版本)。提示:Pnpm 在 NodeJS v16+ 版本可通过 corepack enable 命令开启,低版本请通过 npm install pnpm 命令安装。
|
||||
|
||||
```bash
|
||||
# 官网:https://git-scm.com/
|
||||
git
|
||||
|
||||
# 官网:https://nodejs.org/en
|
||||
node + pnpm
|
||||
git # 地址:https://git-scm.com/
|
||||
node + pnpm # 地址:https://nodejs.org/en
|
||||
```
|
||||
备注:Pnpm 在 NodeJS v16+ 版本可通过 corepack enable 命令开启,低版本请通过 npm install pnpm 命令安装。
|
||||
|
||||
2. 拉取模板
|
||||
|
||||
|
|
@ -53,16 +49,20 @@ pnpm dev
|
|||
|
||||
### 路由菜单
|
||||
|
||||
基于 [vite-plugin-pages](https://github.com/hannoeru/vite-plugin-pages) 插件。本项目使用 src/pages 作为路由目录,最终生成的路由仅有 2 级,主要是出于 keepalive 缓存的需要,其中:
|
||||
基于 [vite-plugin-pages](https://github.com/hannoeru/vite-plugin-pages) 插件。根据 src/pages 目录生成路由数组,然后根据路由数组自动生成菜单数组,导航时根据菜单层级自动生成导航面包屑。
|
||||
|
||||
| 说明 |
|
||||
| ----------------------------------------------------------------- |
|
||||
| src/pages 目录下以 _ 开头的文件名/目录名为一级路由,如登陆页面。 |
|
||||
| src/pages 其他子目录或 .vue 文件为二级路由,如应用首页。 |
|
||||
根据 src/pages 目录生成路由数组,包含以下以下规则:
|
||||
|
||||
左侧菜单,将根据上面的二级路由自动生成,如需生成层级只需在对应目录下的 index.vue 文件中定义如下路由配置:
|
||||
- 以文件夹为路由,读取该文件夹下 index.vue 的信息作为路由信息,其他文件会跳过,可以包含子文件夹
|
||||
- 在 src/pages 的文件夹层级,作为菜单层级,路由层级最终只有 2 层(配合 keep-alive 缓存)
|
||||
- 在 src/pages 目录下,以 _ 开头的文件夹作为 1 级路由,其他作为 2 级路由,也就是应用路由
|
||||
- 子文件夹下,只有 index.vue 文件有效,其他文件会忽略,这些文件可以作为子组件使用
|
||||
- components 目录会被忽视
|
||||
- xxx.xx.xx 文件会被忽视,例如 index.my.vue
|
||||
|
||||
```
|
||||
对应目录下的 index.vue 文件中定义如下路由配置:
|
||||
|
||||
```jsonc
|
||||
<route lang="json">
|
||||
{
|
||||
"parentMeta": {
|
||||
|
|
@ -72,9 +72,9 @@ pnpm dev
|
|||
</route>
|
||||
```
|
||||
|
||||
### 文件后缀
|
||||
### 条件加载
|
||||
|
||||
在 scripts/vite/plugin.ts 文件中,内置有一个 VITE 插件,主要用于输出编译信息以及根据不同文件后缀进行打包。在项目根目录下的 .env 配置文件中,可指定以下属性:
|
||||
基于 [plugin](./scripts/vite/plugin.ts) 内置 VITE 插件,主要用于输出编译信息以及根据不同文件后缀进行打包。在项目根目录下的 .env 配置文件中,可指定以下属性:
|
||||
|
||||
```
|
||||
VITE_EXTENSION = my
|
||||
|
|
|
|||
31
index.html
31
index.html
|
|
@ -2,12 +2,24 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>%VITE_TITLE% - %VITE_SUBTITLE%</title>
|
||||
<meta name="description" content="%VITE_SUBTITLE%" />
|
||||
<link rel="icon" href="./favicon.ico" />
|
||||
<title>%VITE_TITLE%</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="dark:bg-slate-900 dark:text-slate-200">
|
||||
<div class="loading">
|
||||
<img
|
||||
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHN0eWxlPSJtYXJnaW46IGF1dG87IGJhY2tncm91bmQ6IHJnYigyNTUsIDI1NSwgMjU1KTsgZGlzcGxheTogYmxvY2s7IHNoYXBlLXJlbmRlcmluZzogYXV0bzsiIHdpZHRoPSIyMDBweCIgaGVpZ2h0PSIyMDBweCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaWRZTWlkIj48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1MCA1MCkiPjxnPjxhbmltYXRlVHJhbnNmb3JtIGF0dHJpYnV0ZU5hbWU9InRyYW5zZm9ybSIgdHlwZT0icm90YXRlIiB2YWx1ZXM9IjA7NDUiIGtleVRpbWVzPSIwOzEiIGR1cj0iMC4ycyIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiPjwvYW5pbWF0ZVRyYW5zZm9ybT48cGF0aCBkPSJNMjkuNDkxNTI0MjA2MTE3MjU1IC01LjUgTDM3LjQ5MTUyNDIwNjExNzI1NSAtNS41IEwzNy40OTE1MjQyMDYxMTcyNTUgNS41IEwyOS40OTE1MjQyMDYxMTcyNTUgNS41IEEzMCAzMCAwIDAgMSAyNC43NDI3NDQwNTAxOTg3MzggMTYuOTY0NTY5NDU3MTQ2NzEyIEwyNC43NDI3NDQwNTAxOTg3MzggMTYuOTY0NTY5NDU3MTQ2NzEyIEwzMC4zOTk1OTgyOTk2OTExMTcgMjIuNjIxNDIzNzA2NjM5MDkyIEwyMi42MjE0MjM3MDY2MzkwOTYgMzAuMzk5NTk4Mjk5NjkxMTE0IEwxNi45NjQ1Njk0NTcxNDY3MTYgMjQuNzQyNzQ0MDUwMTk4NzM0IEEzMCAzMCAwIDAgMSA1LjUgMjkuNDkxNTI0MjA2MTE3MjU1IEw1LjUgMjkuNDkxNTI0MjA2MTE3MjU1IEw1LjUgMzcuNDkxNTI0MjA2MTE3MjU1IEwtNS40OTk5OTk5OTk5OTk5OTcgMzcuNDkxNTI0MjA2MTE3MjU1IEwtNS40OTk5OTk5OTk5OTk5OTcgMjkuNDkxNTI0MjA2MTE3MjU1IEEzMCAzMCAwIDAgMSAtMTYuOTY0NTY5NDU3MTQ2NzA1IDI0Ljc0Mjc0NDA1MDE5ODczOCBMLTE2Ljk2NDU2OTQ1NzE0NjcwNSAyNC43NDI3NDQwNTAxOTg3MzggTC0yMi42MjE0MjM3MDY2MzkwODUgMzAuMzk5NTk4Mjk5NjkxMTE3IEwtMzAuMzk5NTk4Mjk5NjkxMTE3IDIyLjYyMTQyMzcwNjYzOTA5MiBMLTI0Ljc0Mjc0NDA1MDE5ODczOCAxNi45NjQ1Njk0NTcxNDY3MTIgQTMwIDMwIDAgMCAxIC0yOS40OTE1MjQyMDYxMTcyNTUgNS41MDAwMDAwMDAwMDAwMDkgTC0yOS40OTE1MjQyMDYxMTcyNTUgNS41MDAwMDAwMDAwMDAwMDkgTC0zNy40OTE1MjQyMDYxMTcyNTUgNS41MDAwMDAwMDAwMDAwMSBMLTM3LjQ5MTUyNDIwNjExNzI1NSAtNS41MDAwMDAwMDAwMDAwMDEgTC0yOS40OTE1MjQyMDYxMTcyNTUgLTUuNTAwMDAwMDAwMDAwMDAyIEEzMCAzMCAwIDAgMSAtMjQuNzQyNzQ0MDUwMTk4NzM4IC0xNi45NjQ1Njk0NTcxNDY3MDUgTC0yNC43NDI3NDQwNTAxOTg3MzggLTE2Ljk2NDU2OTQ1NzE0NjcwNSBMLTMwLjM5OTU5ODI5OTY5MTExNyAtMjIuNjIxNDIzNzA2NjM5MDg1IEwtMjIuNjIxNDIzNzA2NjM5MDkyIC0zMC4zOTk1OTgyOTk2OTExMTcgTC0xNi45NjQ1Njk0NTcxNDY3MTIgLTI0Ljc0Mjc0NDA1MDE5ODczOCBBMzAgMzAgMCAwIDEgLTUuNTAwMDAwMDAwMDAwMDExIC0yOS40OTE1MjQyMDYxMTcyNTUgTC01LjUwMDAwMDAwMDAwMDAxMSAtMjkuNDkxNTI0MjA2MTE3MjU1IEwtNS41MDAwMDAwMDAwMDAwMTIgLTM3LjQ5MTUyNDIwNjExNzI1NSBMNS40OTk5OTk5OTk5OTk5OTggLTM3LjQ5MTUyNDIwNjExNzI1NSBMNS41IC0yOS40OTE1MjQyMDYxMTcyNTUgQTMwIDMwIDAgMCAxIDE2Ljk2NDU2OTQ1NzE0NjcwMiAtMjQuNzQyNzQ0MDUwMTk4NzQgTDE2Ljk2NDU2OTQ1NzE0NjcwMiAtMjQuNzQyNzQ0MDUwMTk4NzQgTDIyLjYyMTQyMzcwNjYzOTA4IC0zMC4zOTk1OTgyOTk2OTExMiBMMzAuMzk5NTk4Mjk5NjkxMTE3IC0yMi42MjE0MjM3MDY2MzkxIEwyNC43NDI3NDQwNTAxOTg3MzggLTE2Ljk2NDU2OTQ1NzE0NjcxNiBBMzAgMzAgMCAwIDEgMjkuNDkxNTI0MjA2MTE3MjU1IC01LjUwMDAwMDAwMDAwMDAxMyBNMCAtMjBBMjAgMjAgMCAxIDAgMCAyMCBBMjAgMjAgMCAxIDAgMCAtMjAiIGZpbGw9IiMwOWYiPjwvcGF0aD48L2c+PC9nPjwvc3ZnPgo="
|
||||
alt="loading"
|
||||
class="loading-image"
|
||||
width="64"
|
||||
height="64"
|
||||
/>
|
||||
<h1 class="loading-title">欢迎访问%VITE_TITLE%</h1>
|
||||
<div class="loading-tip">资源加载中, 请稍等...</div>
|
||||
</div>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
|
|
@ -17,8 +29,8 @@
|
|||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-family: Inter, "-apple-system", BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "noto sans",
|
||||
"Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-family: Inter, '-apple-system', BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'noto sans',
|
||||
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
#app {
|
||||
width: 100%;
|
||||
|
|
@ -47,18 +59,9 @@
|
|||
.loading-tip {
|
||||
margin-top: 12px;
|
||||
line-height: 1;
|
||||
color: #888;
|
||||
color: #889;
|
||||
}
|
||||
</style>
|
||||
<div class="loading">
|
||||
<img
|
||||
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHN0eWxlPSJtYXJnaW46IGF1dG87IGJhY2tncm91bmQ6IHJnYigyNTUsIDI1NSwgMjU1KTsgZGlzcGxheTogYmxvY2s7IHNoYXBlLXJlbmRlcmluZzogYXV0bzsiIHdpZHRoPSIyMDBweCIgaGVpZ2h0PSIyMDBweCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaWRZTWlkIj48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1MCA1MCkiPjxnPjxhbmltYXRlVHJhbnNmb3JtIGF0dHJpYnV0ZU5hbWU9InRyYW5zZm9ybSIgdHlwZT0icm90YXRlIiB2YWx1ZXM9IjA7NDUiIGtleVRpbWVzPSIwOzEiIGR1cj0iMC4ycyIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiPjwvYW5pbWF0ZVRyYW5zZm9ybT48cGF0aCBkPSJNMjkuNDkxNTI0MjA2MTE3MjU1IC01LjUgTDM3LjQ5MTUyNDIwNjExNzI1NSAtNS41IEwzNy40OTE1MjQyMDYxMTcyNTUgNS41IEwyOS40OTE1MjQyMDYxMTcyNTUgNS41IEEzMCAzMCAwIDAgMSAyNC43NDI3NDQwNTAxOTg3MzggMTYuOTY0NTY5NDU3MTQ2NzEyIEwyNC43NDI3NDQwNTAxOTg3MzggMTYuOTY0NTY5NDU3MTQ2NzEyIEwzMC4zOTk1OTgyOTk2OTExMTcgMjIuNjIxNDIzNzA2NjM5MDkyIEwyMi42MjE0MjM3MDY2MzkwOTYgMzAuMzk5NTk4Mjk5NjkxMTE0IEwxNi45NjQ1Njk0NTcxNDY3MTYgMjQuNzQyNzQ0MDUwMTk4NzM0IEEzMCAzMCAwIDAgMSA1LjUgMjkuNDkxNTI0MjA2MTE3MjU1IEw1LjUgMjkuNDkxNTI0MjA2MTE3MjU1IEw1LjUgMzcuNDkxNTI0MjA2MTE3MjU1IEwtNS40OTk5OTk5OTk5OTk5OTcgMzcuNDkxNTI0MjA2MTE3MjU1IEwtNS40OTk5OTk5OTk5OTk5OTcgMjkuNDkxNTI0MjA2MTE3MjU1IEEzMCAzMCAwIDAgMSAtMTYuOTY0NTY5NDU3MTQ2NzA1IDI0Ljc0Mjc0NDA1MDE5ODczOCBMLTE2Ljk2NDU2OTQ1NzE0NjcwNSAyNC43NDI3NDQwNTAxOTg3MzggTC0yMi42MjE0MjM3MDY2MzkwODUgMzAuMzk5NTk4Mjk5NjkxMTE3IEwtMzAuMzk5NTk4Mjk5NjkxMTE3IDIyLjYyMTQyMzcwNjYzOTA5MiBMLTI0Ljc0Mjc0NDA1MDE5ODczOCAxNi45NjQ1Njk0NTcxNDY3MTIgQTMwIDMwIDAgMCAxIC0yOS40OTE1MjQyMDYxMTcyNTUgNS41MDAwMDAwMDAwMDAwMDkgTC0yOS40OTE1MjQyMDYxMTcyNTUgNS41MDAwMDAwMDAwMDAwMDkgTC0zNy40OTE1MjQyMDYxMTcyNTUgNS41MDAwMDAwMDAwMDAwMSBMLTM3LjQ5MTUyNDIwNjExNzI1NSAtNS41MDAwMDAwMDAwMDAwMDEgTC0yOS40OTE1MjQyMDYxMTcyNTUgLTUuNTAwMDAwMDAwMDAwMDAyIEEzMCAzMCAwIDAgMSAtMjQuNzQyNzQ0MDUwMTk4NzM4IC0xNi45NjQ1Njk0NTcxNDY3MDUgTC0yNC43NDI3NDQwNTAxOTg3MzggLTE2Ljk2NDU2OTQ1NzE0NjcwNSBMLTMwLjM5OTU5ODI5OTY5MTExNyAtMjIuNjIxNDIzNzA2NjM5MDg1IEwtMjIuNjIxNDIzNzA2NjM5MDkyIC0zMC4zOTk1OTgyOTk2OTExMTcgTC0xNi45NjQ1Njk0NTcxNDY3MTIgLTI0Ljc0Mjc0NDA1MDE5ODczOCBBMzAgMzAgMCAwIDEgLTUuNTAwMDAwMDAwMDAwMDExIC0yOS40OTE1MjQyMDYxMTcyNTUgTC01LjUwMDAwMDAwMDAwMDAxMSAtMjkuNDkxNTI0MjA2MTE3MjU1IEwtNS41MDAwMDAwMDAwMDAwMTIgLTM3LjQ5MTUyNDIwNjExNzI1NSBMNS40OTk5OTk5OTk5OTk5OTggLTM3LjQ5MTUyNDIwNjExNzI1NSBMNS41IC0yOS40OTE1MjQyMDYxMTcyNTUgQTMwIDMwIDAgMCAxIDE2Ljk2NDU2OTQ1NzE0NjcwMiAtMjQuNzQyNzQ0MDUwMTk4NzQgTDE2Ljk2NDU2OTQ1NzE0NjcwMiAtMjQuNzQyNzQ0MDUwMTk4NzQgTDIyLjYyMTQyMzcwNjYzOTA4IC0zMC4zOTk1OTgyOTk2OTExMiBMMzAuMzk5NTk4Mjk5NjkxMTE3IC0yMi42MjE0MjM3MDY2MzkxIEwyNC43NDI3NDQwNTAxOTg3MzggLTE2Ljk2NDU2OTQ1NzE0NjcxNiBBMzAgMzAgMCAwIDEgMjkuNDkxNTI0MjA2MTE3MjU1IC01LjUwMDAwMDAwMDAwMDAxMyBNMCAtMjBBMjAgMjAgMCAxIDAgMCAyMCBBMjAgMjAgMCAxIDAgMCAtMjAiIGZpbGw9IiMwOWYiPjwvcGF0aD48L2c+PC9nPjwvc3ZnPgo="
|
||||
alt="loading"
|
||||
class="loading-image"
|
||||
/>
|
||||
<h1 class="loading-title">欢迎访问%VITE_TITLE%</h1>
|
||||
<div class="loading-tip">资源加载中, 请稍等...</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
|
|
|||
58
package.json
58
package.json
|
|
@ -12,39 +12,41 @@
|
|||
"release": "release-it --config ./scripts/release/index.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arco-design/web-vue": "^2.51.1",
|
||||
"@iconify-json/icon-park-outline": "^1.1.12",
|
||||
"@release-it/conventional-changelog": "^5.1.1",
|
||||
"@types/ejs": "^3.1.2",
|
||||
"@types/lodash-es": "^4.17.9",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@vitejs/plugin-vue": "^4.3.4",
|
||||
"@vitejs/plugin-vue-jsx": "^3.0.2",
|
||||
"@vueuse/core": "^9.13.0",
|
||||
"axios": "^1.5.0",
|
||||
"dayjs": "^1.11.9",
|
||||
"@arco-design/web-vue": "^2.54.1",
|
||||
"@iconify-json/icon-park-outline": "^1.1.15",
|
||||
"@release-it/conventional-changelog": "^8.0.1",
|
||||
"@types/ejs": "^3.1.5",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||
"@vueuse/core": "^10.7.1",
|
||||
"axios": "^1.6.5",
|
||||
"dayjs": "^1.11.10",
|
||||
"dplayer": "^1.27.1",
|
||||
"ejs": "^3.1.9",
|
||||
"less": "^4.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"monaco-editor": "^0.44.0",
|
||||
"monaco-editor": "^0.45.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"numeral": "^2.0.6",
|
||||
"pinia": "^2.1.6",
|
||||
"pinia-plugin-persistedstate": "^3.2.0",
|
||||
"plop": "^3.1.2",
|
||||
"release-it": "^15.11.0",
|
||||
"rollup-plugin-visualizer": "^5.9.2",
|
||||
"swagger-typescript-api": "^12.0.4",
|
||||
"tsx": "^3.12.9",
|
||||
"typescript": "^4.9.5",
|
||||
"unocss": "^0.49.8",
|
||||
"unplugin-auto-import": "^0.13.0",
|
||||
"unplugin-vue-components": "^0.23.0",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-pages": "^0.28.0",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^3.2.1",
|
||||
"plop": "^4.0.1",
|
||||
"release-it": "^17.0.1",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"swagger-typescript-api": "^13.0.3",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3",
|
||||
"unocss": "^0.58.3",
|
||||
"unplugin-auto-import": "^0.17.3",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"unplugin-vue-router": "^0.7.0",
|
||||
"vite": "^5.0.11",
|
||||
"vite-plugin-pages": "^0.32.0",
|
||||
"vite-plugin-style-import": "^2.0.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4",
|
||||
"vue-tsc": "^1.8.11"
|
||||
"vue": "^3.4.8",
|
||||
"vue-router": "^4.2.5",
|
||||
"vue-tsc": "^1.8.27"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4021
pnpm-lock.yaml
4021
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,36 @@
|
|||
import fs from 'fs';
|
||||
import { Plugin, ResolvedConfig } from 'vite';
|
||||
|
||||
/**
|
||||
* 条件加载
|
||||
* @description
|
||||
* 先尝试 index.xx.vue 文件,
|
||||
* 回退至 index.vue 文件。
|
||||
*/
|
||||
export default function plugin(): Plugin {
|
||||
let config: ResolvedConfig;
|
||||
let extension: string;
|
||||
|
||||
return {
|
||||
name: 'vite:extension',
|
||||
enforce: 'pre',
|
||||
|
||||
configResolved(resolvedConfig) {
|
||||
config = resolvedConfig;
|
||||
extension = config.env.VITE_EXTENTION ?? config.isProduction ? 'prod' : 'dev';
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (!extension || !id.includes('src')) {
|
||||
return;
|
||||
}
|
||||
if (id.includes('?vue')) {
|
||||
return;
|
||||
}
|
||||
const targetPath = id.replace(/\.([^.]*?)$/, `.${extension}.$1`);
|
||||
if (targetPath && fs.existsSync(targetPath)) {
|
||||
return fs.readFileSync(targetPath, 'utf-8');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { spawn } from 'child_process';
|
||||
import { Plugin, ResolvedConfig } from 'vite';
|
||||
import pkg from '../../package.json';
|
||||
|
||||
/**
|
||||
* 项目 logo
|
||||
* @description 内容:APPTIFY
|
||||
*/
|
||||
const LOGO = ` _ _______ _______ ____ _____ _____ ________ ____ ____
|
||||
/ \\\\ |_ __ \\\\|_ __ \\\\|_ \\\\|_ _||_ _||_ __ ||_ _||_ _|
|
||||
/ _ \\\\ | |__) | | |__) | | \\\\ | | | | | |_ \\\\_| \\\\ \\\\ / /
|
||||
/ ___ \\\\ | ___/ | ___/ | |\\\\ \\\\| | | | | _| \\\\ \\\\/ /
|
||||
_/ / \\\\ \\\\_ _| |_ _| |_ _| |_\\\\ |_ _| |_ _| |_ _| |_
|
||||
|____| |____||_____| |_____| |_____|\\\\____||_____||_____| |______|
|
||||
`;
|
||||
|
||||
/**
|
||||
* 以 shell 形式执行命令,成功返回输出的字符串
|
||||
* @param cmd 命令
|
||||
* @returns Promise<string | void>
|
||||
*/
|
||||
const exec = (cmd: string) => {
|
||||
return new Promise<string | void>(resolve => {
|
||||
if (!cmd) {
|
||||
return resolve();
|
||||
}
|
||||
const child = spawn(cmd, [], { shell: true });
|
||||
child.stdout.once('data', data => {
|
||||
resolve(data.toString().replace(/"|\n/g, ''));
|
||||
});
|
||||
child.stderr.once('data', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取构建信息
|
||||
* @returns Promise<string>
|
||||
*/
|
||||
const getBuildInfo = async () => {
|
||||
const hash = await exec('git log --format=%h -n 1');
|
||||
const time = new Date().toLocaleString('zh-Hans-CN');
|
||||
const latestTag = await exec('git describe --tags --abbrev=0');
|
||||
const commits = await exec(`git rev-list --count ${latestTag}..HEAD`);
|
||||
const version = commits ? `${latestTag}.${commits}` : `v${pkg.version}`;
|
||||
const content = `欢迎访问!版本: ${version} 标识: ${hash} 构建: ${time}`;
|
||||
const style = `"color: #09f; font-weight: 900;", "font-size: 12px; color: #09f; font-family: ''"`;
|
||||
const script = `console.log(\`%c${LOGO} \n%c${content}\n\`, ${style});\n`;
|
||||
return script;
|
||||
};
|
||||
|
||||
/**
|
||||
* 项目构建插件
|
||||
* @returns Plugin
|
||||
*/
|
||||
export default function plugin(): Plugin {
|
||||
let config: ResolvedConfig;
|
||||
|
||||
return {
|
||||
name: 'vite:info',
|
||||
enforce: 'pre',
|
||||
|
||||
configResolved(resolvedConfig) {
|
||||
config = resolvedConfig;
|
||||
},
|
||||
|
||||
async transformIndexHtml() {
|
||||
const script = await getBuildInfo();
|
||||
return [
|
||||
{
|
||||
tag: 'script',
|
||||
injectTo: 'body',
|
||||
children: script,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import { spawn } from "child_process";
|
||||
import fs from "fs";
|
||||
import { Plugin, ResolvedConfig } from "vite";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
/**
|
||||
* 项目 logo
|
||||
* @description 内容:APPTIFY
|
||||
*/
|
||||
const LOGO = `
|
||||
________ ______ ______ _________ ________ ______ __ __
|
||||
/_______/\\\\ /_____/\\\\ /_____/\\\\ /________/\\\\/_______/\\\\/_____/\\\\ /_/\\\\/_/\\\\
|
||||
\\\\::: _ \\\\ \\\\\\\\:::_ \\\\ \\\\\\\\:::_ \\\\ \\\\\\\\__.::.__\\\\/\\\\__.::._\\\\/\\\\::::_\\\\/_\\\\ \\\\ \\\\ \\\\ \\\\
|
||||
\\\\::(_) \\\\ \\\\\\\\:(_) \\\\ \\\\\\\\:(_) \\\\ \\\\ \\\\::\\\\ \\\\ \\\\::\\\\ \\\\ \\\\:\\\\/___/\\\\\\\\:\\\\_\\\\ \\\\ \\\\
|
||||
\\\\:: __ \\\\ \\\\\\\\: ___\\\\/ \\\\: ___\\\\/ \\\\::\\\\ \\\\ _\\\\::\\\\ \\\\__\\\\:::._\\\\/ \\\\::::_\\\\/
|
||||
\\\\:.\\\\ \\\\ \\\\ \\\\\\\\ \\\\ \\\\ \\\\ \\\\ \\\\ \\\\::\\\\ \\\\ /__\\\\::\\\\__/\\\\\\\\:\\\\ \\\\ \\\\::\\\\ \\\\
|
||||
\\\\__\\\\/\\\\__\\\\/ \\\\_\\\\/ \\\\_\\\\/ \\\\__\\\\/ \\\\________\\\\/ \\\\_\\\\/ \\\\__\\\\/
|
||||
`;
|
||||
|
||||
/**
|
||||
* 以 shell 形式执行命令,成功返回输出的字符串
|
||||
* @param cmd 命令
|
||||
* @returns Promise<string | void>
|
||||
*/
|
||||
const exec = (cmd: string) => {
|
||||
return new Promise<string | void>((resolve) => {
|
||||
if (!cmd) {
|
||||
return resolve();
|
||||
}
|
||||
const child = spawn(cmd, [], { shell: true });
|
||||
child.stdout.once("data", (data) => {
|
||||
resolve(data.toString().replace(/"|\n/g, ""));
|
||||
});
|
||||
child.stderr.once("data", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取构建信息
|
||||
* @returns Promise<string>
|
||||
*/
|
||||
const getBuildInfo = async () => {
|
||||
const hash = await exec("git log --format=%h -n 1");
|
||||
const time = new Date().toLocaleString("zh-Hans-CN");
|
||||
const latestTag = await exec("git describe --tags --abbrev=0");
|
||||
const commits = await exec(`git rev-list --count ${latestTag}..HEAD`);
|
||||
const version = commits ? `${latestTag}.${commits}` : `v${pkg.version}`;
|
||||
const content = ` 欢迎访问!版本: ${version} 标识: ${hash} 构建时间: ${time}`;
|
||||
const style = `"color: #09f; font-weight: 900;", "font-size: 12px; color: #09f; font-family: ''"`;
|
||||
const script = `console.log(\`%c${LOGO} \n%c${content}\n\`, ${style});\n`;
|
||||
return script;
|
||||
};
|
||||
|
||||
/**
|
||||
* 项目构建插件
|
||||
* @returns Plugin
|
||||
*/
|
||||
export default function plugin(): Plugin {
|
||||
let config: ResolvedConfig;
|
||||
let extension: string;
|
||||
|
||||
return {
|
||||
name: "vite:customizer",
|
||||
enforce: "pre",
|
||||
|
||||
configResolved(resolvedConfig) {
|
||||
config = resolvedConfig;
|
||||
extension = config.env.VITE_EXTENTION ?? config.isProduction ? "prod" : "dev";
|
||||
},
|
||||
|
||||
async transformIndexHtml() {
|
||||
const script = await getBuildInfo();
|
||||
return [
|
||||
{
|
||||
tag: "script",
|
||||
injectTo: "body",
|
||||
children: script,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (!extension || !id.includes("src")) {
|
||||
return;
|
||||
}
|
||||
if (id.includes("?vue")) {
|
||||
return;
|
||||
}
|
||||
const targetPath = id.replace(/\.([^.]*?)$/, `.${extension}.$1`);
|
||||
if (targetPath && fs.existsSync(targetPath)) {
|
||||
return fs.readFileSync(targetPath, "utf-8");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
47
src/App.vue
47
src/App.vue
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<a-config-provider>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<keep-alive :include="menuStore.cacheTopNames">
|
||||
<component v-if="hasAuth(route)" :is="Component"></component>
|
||||
<keep-alive :include="menuStore.caches">
|
||||
<component v-if="hasAuth" :is="Component"></component>
|
||||
<AnForbidden v-else></AnForbidden>
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
|
|
@ -10,27 +10,36 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import { useUserStore } from './store';
|
||||
import { useMenuStore } from './store/menu';
|
||||
import { useMenuStore } from '@/store/menu';
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
const hasAuth = (route: RouteLocationNormalizedLoaded) => {
|
||||
const aAuth = route.meta.auth;
|
||||
const uAuth = userStore.auth;
|
||||
if (!aAuth?.length) {
|
||||
return true;
|
||||
}
|
||||
if (aAuth.some(i => i === '*')) {
|
||||
return true;
|
||||
}
|
||||
if (uAuth.some(i => aAuth.some(j => j === i))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const hasAuth = computed(() => {
|
||||
return route.matched.every(item => {
|
||||
console.log('i', item);
|
||||
const needAuth = item.meta.auth;
|
||||
const userAuth = userStore.auth;
|
||||
if (needAuth?.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
if (!userStore.accessToken && needAuth?.includes('unlogin')) {
|
||||
return true;
|
||||
}
|
||||
if (!userStore.accessToken) {
|
||||
return false;
|
||||
}
|
||||
if (!needAuth) {
|
||||
return true;
|
||||
}
|
||||
if (userAuth.some(i => needAuth.some(j => j === i))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { env } from '@/config/env';
|
|||
* @see src/api/instance/instance.ts
|
||||
*/
|
||||
export const api = new Service({
|
||||
timeout: 2000,
|
||||
baseURL: env.apiPrefix,
|
||||
});
|
||||
|
||||
|
|
@ -17,12 +18,12 @@ export const api = new Service({
|
|||
*/
|
||||
addToastInterceptor(api.instance);
|
||||
|
||||
/**
|
||||
* 添加异常处理拦截器
|
||||
*/
|
||||
addExceptionInterceptor(api.instance, () => api.expireHandler?.());
|
||||
/**
|
||||
* 添加登陆令牌拦截器
|
||||
*/
|
||||
addAuthInterceptor(api.instance);
|
||||
|
||||
/**
|
||||
* 添加异常处理拦截器
|
||||
*/
|
||||
addExceptionInterceptor(api.instance, () => api.expireHandler?.());
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { IToastOptions } from "@/components";
|
||||
import "axios";
|
||||
import { IToastOptions } from '@/components';
|
||||
import 'axios';
|
||||
|
||||
declare module "axios" {
|
||||
declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
/**
|
||||
* 请求弹窗配置
|
||||
|
|
@ -26,5 +26,9 @@ declare module "axios" {
|
|||
* 请求异常提示
|
||||
*/
|
||||
reqErrorTip?: boolean | string;
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
tip?: boolean | string | { requestErrorTip?: string; responseErrorTip?: string };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export function addAuthInterceptor(axios: AxiosInstance) {
|
|||
if (userStore.accessToken) {
|
||||
config.headers.Authorization = `Bearer ${userStore.accessToken}`;
|
||||
}
|
||||
// throw Error('dd');
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ const expiredCodes = [4050, 4051];
|
|||
const resMessageTip = `响应异常,请检查参数或稍后重试!`;
|
||||
const resGetMessage = `数据获取失败,请检查网络或稍后重试!`;
|
||||
const reqMessageTip = `请求失败,请检查网络或稍后重试!`;
|
||||
|
||||
let logoutTipShowing = false;
|
||||
|
||||
/**
|
||||
|
|
@ -15,6 +14,10 @@ let logoutTipShowing = false;
|
|||
* @param axios Axios实例
|
||||
*/
|
||||
export function addExceptionInterceptor(axios: AxiosInstance, exipreHandler?: (...args: any[]) => any) {
|
||||
/**
|
||||
* 虽说是请求异常拦截,但也仅仅是处理中间件异常的问题
|
||||
* 一旦发起请求就属于响应异常了(包括网络问题)。
|
||||
*/
|
||||
axios.interceptors.request.use(null, error => {
|
||||
const msg = error.response?.data?.message;
|
||||
Notification.error({
|
||||
|
|
@ -24,7 +27,13 @@ export function addExceptionInterceptor(axios: AxiosInstance, exipreHandler?: (.
|
|||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理响应异常,包括
|
||||
*/
|
||||
axios.interceptors.response.use(
|
||||
/**
|
||||
* 处理自定义状态码异常
|
||||
*/
|
||||
res => {
|
||||
const code = res.data?.code;
|
||||
if (code && !successCodes.includes(code)) {
|
||||
|
|
@ -33,25 +42,33 @@ export function addExceptionInterceptor(axios: AxiosInstance, exipreHandler?: (.
|
|||
return res;
|
||||
},
|
||||
error => {
|
||||
/**
|
||||
* 有结果返回
|
||||
*/
|
||||
if (error.response) {
|
||||
const code = error.response.data?.code;
|
||||
|
||||
if (expiredCodes.includes(code)) {
|
||||
if (!logoutTipShowing) {
|
||||
logoutTipShowing = true;
|
||||
Notification.warning({
|
||||
title: '登陆提示',
|
||||
content: '当前登陆已过期,请重新登陆!',
|
||||
content: '登陆已过期,请重新登陆!',
|
||||
onClose: () => (logoutTipShowing = false),
|
||||
});
|
||||
exipreHandler?.(error);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const resMsg = error.response?.data?.message;
|
||||
let message: string | null = resMsg ?? resMessageTip;
|
||||
|
||||
let message: string | null = resMessageTip;
|
||||
if (error.config?.method === 'get') {
|
||||
message = resGetMessage;
|
||||
}
|
||||
const resMsg = error.response?.data?.message;
|
||||
if (resMsg) {
|
||||
message = resMsg;
|
||||
}
|
||||
if (has(error.config, 'resErrorTip')) {
|
||||
const tip = error.config.resErrorTip;
|
||||
if (tip) {
|
||||
|
|
@ -67,7 +84,9 @@ export function addExceptionInterceptor(axios: AxiosInstance, exipreHandler?: (.
|
|||
});
|
||||
}
|
||||
return Promise.reject(error);
|
||||
} else if (error.request) {
|
||||
}
|
||||
|
||||
if (error.request) {
|
||||
const resMsg = error.response?.message;
|
||||
let message: string | null = resMsg ?? reqMessageTip;
|
||||
if (has(error.config, 'reqErrorTip')) {
|
||||
|
|
|
|||
|
|
@ -10,19 +10,12 @@ export function addToastInterceptor(axios: AxiosInstance) {
|
|||
axios.interceptors.request.use(
|
||||
config => {
|
||||
if (config.toast) {
|
||||
let options: AnToastOptions = {};
|
||||
if (typeof config.toast === 'string') {
|
||||
options = { message: config.toast };
|
||||
}
|
||||
if (typeof config.toast === 'object') {
|
||||
options = config.toast;
|
||||
}
|
||||
config.closeToast = toast(options);
|
||||
config.closeToast = toast(config.toast);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
error.config.closeToast?.();
|
||||
error.config?.closeToast?.();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
|
@ -37,7 +30,7 @@ export function addToastInterceptor(axios: AxiosInstance) {
|
|||
return response;
|
||||
},
|
||||
error => {
|
||||
error.config.closeToast?.();
|
||||
error.config?.closeToast?.();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<div class="h-full overflow-hidden grid grid-rows-[auto_1fr]">
|
||||
<div class="bg-white px-4 py-2 border-b border-gray-200">
|
||||
<div class="flex justify-between gap-4">
|
||||
<BreadCrumb></BreadCrumb>
|
||||
<div>
|
||||
<a-link>需要帮助?</a-link>
|
||||
<a-button size="mini" @click="router.push({ path: route.path, query: { s: Math.random() }, force: true })">
|
||||
<template #icon>
|
||||
<i class="icon-park-outline-refresh"></i>
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="content">
|
||||
<a-scrollbar outer-class="h-full overflow-hidden" class="h-full overflow-auto" type="track">
|
||||
<div class="m-4 bg-white rounded overflow-hidden" :class="{ 'p-4': contentPadding }">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</a-scrollbar>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BreadCrumb from './bread-crumb.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
defineProps({
|
||||
contentPadding: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -6,21 +6,21 @@
|
|||
</a-form-item>
|
||||
</slot>
|
||||
|
||||
<a-form-item label="颜色">
|
||||
<a-form-item label="字眼颜色">
|
||||
<input-color v-model="model.color"></input-color>
|
||||
</a-form-item>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<a-form-item label="字体">
|
||||
<a-form-item label="字体名称">
|
||||
<a-select v-model="model.family" :options="FontFamilyOptions" class="w-full overflow-hidden"> </a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="大小">
|
||||
<a-form-item label="字体大小">
|
||||
<a-input-number v-model="model.size" :min="12" :step="2"> </a-input-number>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<a-form-item label="样式">
|
||||
<a-form-item label="字体样式">
|
||||
<div class="h-8 flex items-center justify-between">
|
||||
<a-tag
|
||||
class="cursor-pointer !h-7"
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
</a-tag>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="方向">
|
||||
<a-form-item label="字体排列">
|
||||
<a-select v-model="model.align" :options="AlignOptions"></a-select>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { defineBlocker } from '../../core';
|
||||
import { Image } from './interface';
|
||||
import Option from './option.vue';
|
||||
import Render from './render.vue';
|
||||
|
||||
export default defineBlocker<Image>({
|
||||
type: 'image',
|
||||
icon: 'icon-park-outline-pic',
|
||||
title: '图片组件',
|
||||
description: '文字',
|
||||
render: Render,
|
||||
option: Option,
|
||||
initial: {
|
||||
id: '',
|
||||
type: 'image',
|
||||
title: '',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 300,
|
||||
h: 100,
|
||||
xFixed: false,
|
||||
yFixed: false,
|
||||
bgImage: '',
|
||||
bgColor: '',
|
||||
meta: {},
|
||||
actived: false,
|
||||
resizable: true,
|
||||
draggable: true,
|
||||
params: {
|
||||
fit: 'cover',
|
||||
switchTime: 500,
|
||||
images: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { Block } from '../../core';
|
||||
|
||||
export interface ImagePramsImage {
|
||||
id: any;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ImagePrams {
|
||||
fit: 'cover' | 'contain';
|
||||
switchTime: number;
|
||||
images: ImagePramsImage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片组件
|
||||
*/
|
||||
export type Image = Block<ImagePrams>;
|
||||
|
||||
export const fitOptions = [
|
||||
{
|
||||
label: '保持比例,适应容器',
|
||||
value: 'contain',
|
||||
},
|
||||
{
|
||||
label: '保持比例,占满容器',
|
||||
value: 'cover',
|
||||
},
|
||||
{
|
||||
label: '拉伸比例,占满容器',
|
||||
value: '100% 100%',
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<template>
|
||||
<div>
|
||||
<base-option v-model="model"></base-option>
|
||||
</div>
|
||||
<a-divider></a-divider>
|
||||
<a-form-item label="图片列表" :label-attrs="{ class: 'flex-1' }">
|
||||
<template #label>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span>图片列表</span>
|
||||
<span class="pr-3">
|
||||
<a-link @click="showImagePicker = true">选择</a-link>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<ul v-if="model.params.images.length" class="list-none p-0 m-0 py-1 bg-gray-100">
|
||||
<li
|
||||
v-for="(item, index) in model.params.images"
|
||||
:key="item.id"
|
||||
class="group flex items-center justify-between gap-2 px-3 h-8 bg-gray-100"
|
||||
>
|
||||
<span class="hover:text-brand-500 cursor-pointer" @click="onPreviewImage(index)">
|
||||
<i class="icon-park-outline-picture mr-1"></i>
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span class="text-red-400 cursor-pointer hover:text-red-700" @click="onRemoveImage(item, index)">
|
||||
<i class="hidden! group-hover:inline-block! icon-park-outline-delete"></i>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="text-gray-400 px-3 h-8 bg-gray-100 flex items-center">暂未选择任何图片</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="图片轮播">
|
||||
<a-input-number v-model="model.params.switchTime" :min="100" :step="100">
|
||||
<template #append>
|
||||
毫秒(ms)
|
||||
</template>
|
||||
</a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="图片比例">
|
||||
<a-radio-group
|
||||
v-model="model.params.fit"
|
||||
:options="fitOptions"
|
||||
direction="vertical"
|
||||
class="bg-gray-100 w-full px-1.5 py-1 rounded"
|
||||
>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<ImagePicker :multiple="true" v-model:visible="showImagePicker" v-model="model.params.images"></ImagePicker>
|
||||
<a-image-preview-group
|
||||
v-model:visible="showImagePreview"
|
||||
v-model:current="imageIndex"
|
||||
:src-list="imageList"
|
||||
></a-image-preview-group>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { delConfirm } from '@/utils';
|
||||
import BaseOption from '../../components/BaseOption.vue';
|
||||
import ImagePicker from '../../components/ImagePicker.vue';
|
||||
import { Image, fitOptions } from './interface';
|
||||
|
||||
const model = defineModel<Image>({ required: true });
|
||||
const showImagePicker = ref(false);
|
||||
const showImagePreview = ref(false);
|
||||
const imageList = computed(() => model.value.params.images.map(i => i.url));
|
||||
const imageIndex = ref(0);
|
||||
|
||||
const onPreviewImage = (index: number) => {
|
||||
imageIndex.value = index;
|
||||
showImagePreview.value = true;
|
||||
};
|
||||
|
||||
const onRemoveImage = async (item: any, index: number) => {
|
||||
await delConfirm({
|
||||
content: '确定删除该图片吗?',
|
||||
okText: '确定删除',
|
||||
});
|
||||
model.value.params.images.splice(index, 1);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.dir-radio {
|
||||
.arco-radio-button-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
../components/font ../font
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<div :style="style" class="w-full h-full bg-orange-500 flex items-center justify-center text-white text-lg">
|
||||
图片组件
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CSSProperties, PropType } from 'vue';
|
||||
import { Image } from './interface';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object as PropType<Image>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const style = computed(() => {
|
||||
return {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundImage: `url(${props.data.params.images[0]?.url})`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: props.data.params.fit
|
||||
} as CSSProperties;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
../components/font../font
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { InjectionKey } from 'vue';
|
||||
import { Block, Blocker, Container } from '../core';
|
||||
import { useTextBlock } from './text';
|
||||
|
||||
const blockers: Record<string, Blocker> = import.meta.glob(['./*/index.ts', '!./font/*'], {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
const BlockerMap: Record<string, Blocker> = {};
|
||||
|
||||
for (const blocker of Object.values(blockers)) {
|
||||
BlockerMap[blocker.type] = blocker;
|
||||
}
|
||||
|
||||
const getBlockerRender = (type: string) => {
|
||||
return BlockerMap[type].render;
|
||||
};
|
||||
|
||||
const getTypeName = (type: string) => {
|
||||
return BlockerMap[type].title;
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
return BlockerMap[type].icon;
|
||||
};
|
||||
|
||||
export { BlockerMap, getBlockerRender, getIcon, getTypeName };
|
||||
|
||||
export const BlockerManagerKey = Symbol('k') as InjectionKey<ReturnType<typeof useBlockerManage>>
|
||||
|
||||
export function useBlockerManage() {
|
||||
const blockers: Blocker[] = [useTextBlock()];
|
||||
const leftPanels: any[] = [];
|
||||
|
||||
for (const blocker of blockers) {
|
||||
const panel = blocker.addLeftTab?.();
|
||||
if (panel) {
|
||||
leftPanels.push(leftPanels);
|
||||
}
|
||||
}
|
||||
|
||||
const callInitHook = (container: Container) => {
|
||||
for (const blocker of blockers) {
|
||||
container = blocker.onLoadContainer?.(container) || container;
|
||||
}
|
||||
return container;
|
||||
};
|
||||
|
||||
const callLoadHook = (data: any): Blocker => {
|
||||
for (const blocker of blockers) {
|
||||
data = blocker.onLoadBlock?.(data) || data;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const callSaveHook = (block: Block) => {
|
||||
let data = block;
|
||||
for (const blocker of blockers) {
|
||||
data = blocker.onSaveBlock?.(data) || data;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
return {
|
||||
blockers,
|
||||
leftPanels,
|
||||
callInitHook,
|
||||
callLoadHook,
|
||||
callSaveHook,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { Block, Blocker, defineBlocker } from '../../core';
|
||||
import { font } from '../font';
|
||||
import { Text } from './interface';
|
||||
import Option from './option.vue';
|
||||
import Render from './render.vue';
|
||||
|
||||
export default defineBlocker<Text>({
|
||||
type: 'text',
|
||||
icon: 'icon-park-outline-text',
|
||||
title: '文本组件',
|
||||
description: '文字',
|
||||
render: Render,
|
||||
option: Option,
|
||||
initial: {
|
||||
id: '',
|
||||
type: 'text',
|
||||
title: '',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 300,
|
||||
h: 100,
|
||||
xFixed: false,
|
||||
yFixed: false,
|
||||
bgImage: '',
|
||||
bgColor: '',
|
||||
meta: {},
|
||||
actived: false,
|
||||
resizable: true,
|
||||
draggable: true,
|
||||
params: {
|
||||
marquee: false,
|
||||
speed: 100,
|
||||
direction: 'left',
|
||||
fontCh: {
|
||||
...font,
|
||||
content:
|
||||
'温馨提示:乘客您好,进站检票时,持票卡的乘客请在右侧闸机上方感应区内验票,扫码过闸的乘客请将乘车码对准闸机扫码口,扇门打开后依次进闸。乘车过程中请妥善保管好车票,以免丢失。',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function useTextBlock(): Blocker<Text> {
|
||||
const initialData: Text = {
|
||||
id: '',
|
||||
type: 'text',
|
||||
title: '',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 300,
|
||||
h: 100,
|
||||
xFixed: false,
|
||||
yFixed: false,
|
||||
bgImage: '',
|
||||
bgColor: '',
|
||||
meta: {},
|
||||
actived: false,
|
||||
resizable: true,
|
||||
draggable: true,
|
||||
params: {
|
||||
marquee: false,
|
||||
speed: 100,
|
||||
direction: 'left',
|
||||
fontCh: {
|
||||
...font,
|
||||
content:
|
||||
'温馨提示:乘客您好,进站检票时,持票卡的乘客请在右侧闸机上方感应区内验票,扫码过闸的乘客请将乘车码对准闸机扫码口,扇门打开后依次进闸。乘车过程中请妥善保管好车票,以免丢失。',
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
type: 'text',
|
||||
icon: 'icon-park-outline-text',
|
||||
title: '文本组件',
|
||||
description: '文字',
|
||||
render: Render,
|
||||
option: Option,
|
||||
initial: initialData,
|
||||
addLeftTab() {
|
||||
return {
|
||||
title: '文本测试',
|
||||
icon: 'icon-park-outline-user',
|
||||
component: () => h('div', null, 'TODO')
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { Block } from "../../core";
|
||||
import { Font } from "../font";
|
||||
|
||||
export interface OutputText {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface TextPrams {
|
||||
/**
|
||||
* 是否滚动
|
||||
|
|
@ -13,9 +13,12 @@
|
|||
<a-popover>
|
||||
<i class="icon-park-outline-info text-gray-400 cursor-pointer"></i>
|
||||
<template #content>
|
||||
<p>HH 两位数的小时, 01 到 24</p>
|
||||
<p>mm 两位数的分钟, 00 到 59</p>
|
||||
<p>ss 两位数的秒数, 00 到 59</p>
|
||||
<div class="w-48">
|
||||
<div class="mb-2">语法:</div>
|
||||
<div>HH: 01 ~ 24</div>
|
||||
<div>mm: 00 ~ 59</div>
|
||||
<div>ss: 00 ~ 59</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-popover>
|
||||
</template>
|
||||
|
|
@ -25,9 +28,9 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BaseOption from "../../components/BaseOption.vue";
|
||||
import { FontOption } from "../font";
|
||||
import { Time, FomatSuguestions } from "./interface";
|
||||
import BaseOption from '../../components/BaseOption.vue';
|
||||
import { FontOption } from '../font';
|
||||
import { Time, FomatSuguestions } from './interface';
|
||||
|
||||
const model = defineModel<Time>({ required: true });
|
||||
</script>
|
||||
|
|
@ -39,4 +42,4 @@ const model = defineModel<Time>({ required: true });
|
|||
}
|
||||
}
|
||||
</style>
|
||||
../font
|
||||
../font
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<font-render :data="props.data.params.fontCh">
|
||||
{{ updatedTime || time }}
|
||||
</font-render>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { dayjs } from '@/libs/dayjs';
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { FontRender } from '../font';
|
||||
import { Time } from './interface';
|
||||
import { PropType } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object as PropType<Time>,
|
||||
required: true,
|
||||
},
|
||||
update: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const format = computed(() => props.data.params.fontCh.content || 'HH:mm:ss');
|
||||
const time = computed(() => dayjs().format(format.value));
|
||||
const updatedTime = ref('')
|
||||
|
||||
if (props.update) {
|
||||
let timer: any = null;
|
||||
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
updatedTime.value = dayjs().format(format.value);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
../components/font ../font
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { defineBlocker } from '../../core';
|
||||
import { Video } from './interface';
|
||||
import Option from './option.vue';
|
||||
import Render from './render.vue';
|
||||
|
||||
export default defineBlocker<Video>({
|
||||
type: 'video',
|
||||
icon: 'icon-park-outline-video',
|
||||
title: '视频组件',
|
||||
description: '文字',
|
||||
render: Render,
|
||||
option: Option,
|
||||
initial: {
|
||||
id: '',
|
||||
type: 'video',
|
||||
title: '',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 300,
|
||||
h: 100,
|
||||
xFixed: false,
|
||||
yFixed: false,
|
||||
bgImage: '',
|
||||
bgColor: '',
|
||||
meta: {},
|
||||
actived: false,
|
||||
resizable: true,
|
||||
draggable: true,
|
||||
params: {
|
||||
type: 'file',
|
||||
url: 'https://example.com/live',
|
||||
videos: [],
|
||||
fit: 'cover',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Block } from "../../core";
|
||||
|
||||
export interface VideoPrams {
|
||||
type: 'live' | 'file',
|
||||
url: string;
|
||||
/**
|
||||
* 视频地址
|
||||
*/
|
||||
videos: any[];
|
||||
/**
|
||||
* 播放比例
|
||||
*/
|
||||
fit: 'cover' | 'contain';
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本组件
|
||||
*/
|
||||
export type Video = Block<VideoPrams>;
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<template>
|
||||
<div>
|
||||
<base-option v-model="model"></base-option>
|
||||
</div>
|
||||
<a-divider></a-divider>
|
||||
|
||||
<a-form-item label="视频来源">
|
||||
<a-radio-group v-model="model.params.type" direction="vertical" class="bg-gray-100 w-full px-2 py-1 rounded">
|
||||
<a-radio value="live"> 使用直播地址 </a-radio>
|
||||
<a-radio value="file"> 使用视频列表 </a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="model.params.type === 'live'" label="直播地址">
|
||||
<a-input v-model="model.params.url"></a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="model.params.type === 'file'" :label-attrs="{ class: 'flex-1' }">
|
||||
<template #label>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span>视频列表</span>
|
||||
<span class="pr-3">
|
||||
<a-link @click="showImagePicker = true">选择</a-link>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<ul v-if="model.params.videos.length" class="list-none p-0 m-0 space-y-1">
|
||||
<li
|
||||
v-for="(item, index) in model.params.videos"
|
||||
:key="item.id"
|
||||
class="group flex items-center justify-between gap-2 px-3 h-8 bg-gray-100"
|
||||
>
|
||||
<span class="hover:text-brand-500 cursor-pointer" @click="onPreviewImage(index)">
|
||||
<i class="icon-park-outline-picture mr-1"></i>
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span class="text-red-400 cursor-pointer hover:text-red-700" @click="onRemoveImage(item, index)">
|
||||
<i class="hidden! group-hover:inline-block! icon-park-outline-delete"></i>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="text-gray-400 px-3 h-8 bg-gray-100 flex items-center">暂未选择任何视频</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="视频比例">
|
||||
<a-radio-group
|
||||
v-model="model.params.fit"
|
||||
:options="fitOptions"
|
||||
direction="vertical"
|
||||
class="bg-gray-100 w-full px-2 py-1 rounded"
|
||||
>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<ImagePicker :multiple="true" v-model:visible="showImagePicker" v-model="model.params.videos"></ImagePicker>
|
||||
<a-image-preview-group
|
||||
v-model:visible="showImagePreview"
|
||||
v-model:current="imageIndex"
|
||||
:src-list="imageList"
|
||||
></a-image-preview-group>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { delConfirm } from '@/utils';
|
||||
import BaseOption from '../../components/BaseOption.vue';
|
||||
import ImagePicker from '../../components/ImagePicker.vue';
|
||||
import { Video } from './interface';
|
||||
import { fitOptions } from '../image/interface';
|
||||
|
||||
const model = defineModel<Video>({ required: true });
|
||||
const showImagePicker = ref(false);
|
||||
const showImagePreview = ref(false);
|
||||
const imageList = computed(() => model.value.params.videos.map(i => i.url));
|
||||
const imageIndex = ref(0);
|
||||
|
||||
const onPreviewImage = (index: number) => {
|
||||
imageIndex.value = index;
|
||||
showImagePreview.value = true;
|
||||
};
|
||||
|
||||
const onRemoveImage = async (item: any, index: number) => {
|
||||
await delConfirm({
|
||||
content: '确定删除该图片吗?',
|
||||
okText: '确定删除',
|
||||
});
|
||||
model.value.params.videos.splice(index, 1);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.dir-radio {
|
||||
.arco-radio-button-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
../components/font ../font
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<div :style="style" class="w-full h-full bg-blue-500 flex items-center justify-center text-white text-lg">
|
||||
视频组件
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CSSProperties, PropType } from 'vue';
|
||||
import { Video } from './interface';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object as PropType<Video>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const style = computed(() => {
|
||||
return {
|
||||
backgroundImage: `url()`,
|
||||
objectFit: props.data.params.fit,
|
||||
} as CSSProperties;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
../components/font../font
|
||||
|
|
@ -57,9 +57,9 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Block, EditorKey } from "../core";
|
||||
import InputColor from "./InputColor.vue";
|
||||
import InputImage from "./InputImage.vue";
|
||||
import { Block, EditorKey } from '../core';
|
||||
import InputColor from './InputColor.vue';
|
||||
import InputImage from './InputImage.vue';
|
||||
|
||||
const model = defineModel<Block>({ required: true });
|
||||
const { container } = inject(EditorKey)!;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<!-- 修改自: https://github.com/zuley/vue-color-picker -->
|
||||
|
||||
<script setup lang="ts">
|
||||
// @ts-nocheck
|
||||
import { onClickOutside } from "@vueuse/core";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<template>
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="me-contextmenu"
|
||||
:style="{
|
||||
display: show ? 'grid' : 'none',
|
||||
left: x + 'px',
|
||||
top: y + 'px',
|
||||
}"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<ContextMenuList v-bind="props" @done="emit('done')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, onMounted, ref, PropType } from 'vue';
|
||||
import ContextMenuList from './ContextMenuList.vue';
|
||||
import { onClickOutside, useVModel } from '@vueuse/core';
|
||||
|
||||
defineOptions({
|
||||
name: 'ContextMenu',
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
x: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
y: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
items: {
|
||||
type: Array as PropType<any[]>,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['done', 'update:visible']);
|
||||
const show = useVModel(props, 'visible', emit);
|
||||
const menuRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
onClickOutside(menuRef, () => {
|
||||
show.value = false;
|
||||
});
|
||||
|
||||
const onClick = (e: Event) => {
|
||||
if (menuRef.value?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
emit('done');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onClick);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.me-contextmenu {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
min-width: 256px;
|
||||
background: #fff;
|
||||
padding: 6px 0;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
<template>
|
||||
<div
|
||||
class="me-contextmenu"
|
||||
:style="{
|
||||
display: visible ? 'grid' : 'none',
|
||||
left: left + 'px',
|
||||
top: top + 'px',
|
||||
}"
|
||||
>
|
||||
<template v-for="item in items" :key="item.uid">
|
||||
<div v-if="item.type === 'divider'" class="me-contextmenu-divider"></div>
|
||||
<div
|
||||
v-else
|
||||
class="me-contextmenu-item"
|
||||
:class="item.class"
|
||||
@mouseover="() => (item.showChildren = true)"
|
||||
@mouseleave="() => (item.showChildren = false)"
|
||||
>
|
||||
<div @click="onItemClick(item.onClick)" class="me-contextmenu-inner">
|
||||
<div class="me-contextmenu-icon">
|
||||
<i v-if="(typeof item.icon === 'string')" :class="item.icon"></i>
|
||||
<IconCheck v-else-if="item.icon?.() === 'check'" />
|
||||
<component v-else :is="item.icon"></component>
|
||||
</div>
|
||||
<div class="me-contextmenu-action">
|
||||
<span class="me-contextmenu-name"> {{ item.name }} </span>
|
||||
<span class="me-contextmenu-tip"> {{ item.tip }} </span>
|
||||
</div>
|
||||
<div class="me-contextmenu-expand">
|
||||
<IconRight v-if="item.children" />
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenuList
|
||||
v-if="item.children"
|
||||
:show="item.showChildren"
|
||||
:items="item.children"
|
||||
:style="{ position: 'absolute', top: 0, left: '100%' }"
|
||||
@done="emit('done')"
|
||||
></ContextMenuList>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconCheck, IconRight } from '@arco-design/web-vue/es/icon';
|
||||
import { PropType } from 'vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ContextMenuList',
|
||||
errorCaptured(e) {
|
||||
console.log(e);
|
||||
},
|
||||
});
|
||||
|
||||
defineProps({
|
||||
left: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
top: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
right: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
bottom: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
items: {
|
||||
type: Array as PropType<any[]>,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['done']);
|
||||
|
||||
const onItemClick = async (click: Function) => {
|
||||
await click?.();
|
||||
emit('done');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.me-contextmenu {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
min-width: 256px;
|
||||
background: #fff;
|
||||
padding: 6px 0;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.me-contextmenu-item {
|
||||
position: relative;
|
||||
margin: 0 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.me-contextmenu-inner {
|
||||
display: grid;
|
||||
grid-template-columns: 16px 1fr 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 4px 0 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.me-contextmenu-inner:hover {
|
||||
background: var(--color-neutral-2);
|
||||
}
|
||||
.me-contextmenu-icon {
|
||||
width: 16px;
|
||||
}
|
||||
.me-contextmenu-name {
|
||||
flex: 1;
|
||||
}
|
||||
.me-contextmenu-tip {
|
||||
font-size: 12px;
|
||||
color: var(--color-neutral-6);
|
||||
}
|
||||
.me-contextmenu-divider {
|
||||
height: 1px;
|
||||
background: var(--color-neutral-3);
|
||||
margin: 0;
|
||||
}
|
||||
.me-contextmenu-expand {
|
||||
color: var(--color-neutral-5);
|
||||
}
|
||||
.me-contextmenu-action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
<template>
|
||||
<a-modal v-model:visible="show" :fullscreen="true" :footer="false" class="an-editor">
|
||||
<div class="w-full h-full bg-slate-100 grid grid-rows-[auto_1fr] select-none">
|
||||
<div class="h-13 bg-white border-b border-slate-200 z-10">
|
||||
<EditorHeader
|
||||
v-model:container="container"
|
||||
:saving="saving"
|
||||
@preview="showPreview = true"
|
||||
@config="showConfig = true"
|
||||
@exit="onExit()"
|
||||
@save="saveData()"
|
||||
></EditorHeader>
|
||||
</div>
|
||||
<div class="grid grid-cols-[auto_1fr_auto] overflow-hidden">
|
||||
<div class="h-full overflow-hidden bg-white shadow-[2px_0_6px_rgba(0,0,0,.05)] z-10">
|
||||
<EditorLeft @rm-block="rmBlock" @current-block="setCurrentBlock"></EditorLeft>
|
||||
</div>
|
||||
<div class="w-full h-full">
|
||||
<EditorMain
|
||||
v-model:rightPanelCollapsed="rightPanelCollapsed"
|
||||
@add-block="addBlock"
|
||||
@current-block="setCurrentBlock"
|
||||
@block-menu="onBlockContextMenu"
|
||||
></EditorMain>
|
||||
</div>
|
||||
<div class="h-full overflow-hidden bg-white shadow-[-2px_0_6px_rgba(0,0,0,.05)]">
|
||||
<EditorRight v-model:collapsed="rightPanelCollapsed" v-model:block="currentBlock"></EditorRight>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EditorPreview v-model:visible="showPreview" :container="container" :blocks="blocks"></EditorPreview>
|
||||
<EditorSetting v-model:visible="showConfig" v-model="container"></EditorSetting>
|
||||
<ContextMenu
|
||||
v-model:visible="blockMenu.show"
|
||||
:x="blockMenu.x"
|
||||
:y="blockMenu.y"
|
||||
:items="blockMenuItems"
|
||||
@done="blockMenu.show = false"
|
||||
></ContextMenu>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { delConfirm, sleep } from '@/utils';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Block, ContextMenuItem, EditorKey, useEditor } from '../core';
|
||||
import ContextMenu from './ContextMenu.vue';
|
||||
import EditorSetting from './EditorConfig.vue';
|
||||
import EditorHeader from './EditorHeader.vue';
|
||||
import EditorLeft from './EditorLeft.vue';
|
||||
import EditorMain from './EditorMain.vue';
|
||||
import EditorPreview from './EditorPreview.vue';
|
||||
import EditorRight from './EditorRight.vue';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible']);
|
||||
const show = useVModel(props, 'visible', emit);
|
||||
const rightPanelCollapsed = ref(false);
|
||||
const showPreview = ref(false);
|
||||
const showConfig = ref(false);
|
||||
const saving = ref(false);
|
||||
const editor = useEditor();
|
||||
const { container, blocks, currentBlock, addBlock, rmBlock, setCurrentBlock } = editor;
|
||||
|
||||
const blockMenu = reactive<{ show: boolean; x: number; y: number; block: Block | null }>({
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
block: null,
|
||||
});
|
||||
|
||||
const blockMenuItems: ContextMenuItem[] = [
|
||||
{
|
||||
name: '删除',
|
||||
icon: 'icon-park-outline-delete',
|
||||
class: 'text-red-500',
|
||||
async onClick() {
|
||||
await delConfirm({
|
||||
content: '确定删除该组件吗?',
|
||||
okText: '确定删除',
|
||||
});
|
||||
if (blockMenu.block) {
|
||||
rmBlock(blockMenu.block);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const onBlockContextMenu = (block: Block, e: MouseEvent) => {
|
||||
blockMenu.x = e.clientX;
|
||||
blockMenu.y = e.clientY;
|
||||
blockMenu.block = block;
|
||||
blockMenu.show = true;
|
||||
};
|
||||
|
||||
const saveData = async () => {
|
||||
const data = {
|
||||
container: container.value,
|
||||
children: blocks.value,
|
||||
};
|
||||
saving.value = true;
|
||||
await sleep(3000);
|
||||
saving.value = false;
|
||||
const str = JSON.stringify(data);
|
||||
localStorage.setItem('ANI_EDITOR_DATA', str);
|
||||
Message.success('提示:保存成功');
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const str = localStorage.getItem('ANI_EDITOR_DATA');
|
||||
if (!str) {
|
||||
return;
|
||||
}
|
||||
const data = JSON.parse(str);
|
||||
container.value = data.container;
|
||||
blocks.value = data.children;
|
||||
};
|
||||
|
||||
const onExit = async () => {
|
||||
await delConfirm({
|
||||
content: '可能有尚未保存的修改,是否确定退出?',
|
||||
okText: '确定退出',
|
||||
});
|
||||
|
||||
show.value = false;
|
||||
};
|
||||
|
||||
provide(EditorKey, editor);
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.an-editor {
|
||||
.muti-form-item .arco-form-item .arco-form-item-label {
|
||||
line-height: 1;
|
||||
}
|
||||
.arco-modal-fullscreen {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.arco-modal-header {
|
||||
display: none;
|
||||
}
|
||||
.arco-modal-body {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.arco-tabs-nav-vertical .arco-tabs-nav-ink {
|
||||
display: none;
|
||||
}
|
||||
.arco-tabs-nav-vertical.arco-tabs-nav-type-line .arco-tabs-tab {
|
||||
padding: 4px;
|
||||
}
|
||||
.arco-form-item-content-flex {
|
||||
display: block;
|
||||
}
|
||||
.arco-divider-text-left {
|
||||
left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
../core/editor
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<a-drawer v-model:visible="show" :footer="false" title="配置">
|
||||
<a-form :model="{}" layout="vertical">
|
||||
<a-form-item label="标题">
|
||||
<a-input v-model="model.title"></a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model="model.description"></a-textarea >
|
||||
</a-form-item>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<a-form-item label="宽度">
|
||||
<a-input-number v-model="model.width" :min="0"> </a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="高度">
|
||||
<a-input-number v-model="model.height" :min="0"> </a-input-number>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<a-form-item label="背景图片">
|
||||
<input-image v-model="model.bgImage"></input-image>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="背景颜色">
|
||||
<input-color v-model="model.bgColor"></input-color>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from 'vue';
|
||||
import { Container } from '../core';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import InputImage from './InputImage.vue';
|
||||
import InputColor from './InputColor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: Object as PropType<Container>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'update:modelValue']);
|
||||
|
||||
const show = useVModel(props, 'visible', emit);
|
||||
const model = useVModel(props, 'modelValue', emit);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<div class="h-full flex items-center justify-between pl-2 pr-4">
|
||||
<div class="text-base flex items-center group">
|
||||
<a-link @click="emit('exit')">
|
||||
<template #icon>
|
||||
<i class="icon-park-outline-back"></i>
|
||||
</template>
|
||||
返回
|
||||
</a-link>
|
||||
<a-divider :direction="'vertical'" :margin="8"></a-divider>
|
||||
<ani-texter v-model="container.title"></ani-texter>
|
||||
<!-- <a-tag :color="container.id ? 'blue' : 'green'" class="mr-2 ml-1">
|
||||
{{ container.id ? '修改' : '新增' }}
|
||||
</a-tag> -->
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a-button @click="emit('preview')">
|
||||
<template #icon>
|
||||
<i class="icon-park-outline-play"></i>
|
||||
</template>
|
||||
预览
|
||||
</a-button>
|
||||
<!-- <a-button @click="emit('config')">
|
||||
<template #icon>
|
||||
<i class="icon-park-outline-config"></i>
|
||||
</template>
|
||||
设置
|
||||
</a-button> -->
|
||||
<a-button type="primary" :loading="saving" @click="emit('save')">
|
||||
<template #icon>
|
||||
<i class="icon-park-outline-save"></i>
|
||||
</template>
|
||||
保存
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Container } from '../core';
|
||||
import AniTexter from './InputTexter.vue';
|
||||
|
||||
defineProps({
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['preview', 'config', 'exit', 'save']);
|
||||
|
||||
const container = defineModel<Container>('container', { required: true });
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
../core
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
<template>
|
||||
<div class="h-full grid grid-rows-[auto_1fr]">
|
||||
<div class="h-10">
|
||||
<ani-header :container="container" v-model:rightPanelCollapsed="rightPanelCollapsed"></ani-header>
|
||||
<EditorMainHeader
|
||||
:container="container"
|
||||
v-model:rightPanelCollapsed="rightPanelCollapsed"
|
||||
@preview="emit('preview')"
|
||||
></EditorMainHeader>
|
||||
</div>
|
||||
<div class="h-full w-full overflow-hidden p-4">
|
||||
<div
|
||||
|
|
@ -16,7 +20,13 @@
|
|||
@wheel="onMouseWheel"
|
||||
@mousedown="onMouseDown"
|
||||
>
|
||||
<ani-block v-for="block in blocks" :key="block.id" :data="block" :container="container"></ani-block>
|
||||
<EditorMainBlock
|
||||
v-for="block in blocks"
|
||||
:key="block.id"
|
||||
:data="block"
|
||||
:container="container"
|
||||
@contextmenu.prevent="emit('block-menu', block, $event)"
|
||||
></EditorMainBlock>
|
||||
<template v-if="active">
|
||||
<div
|
||||
v-for="line in xLines"
|
||||
|
|
@ -50,18 +60,20 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Block, EditorKey } from "../core";
|
||||
import AniBlock from "./PanelMainBlock.vue";
|
||||
import AniHeader from "./PanelMainHeader.vue";
|
||||
import { Block, EditorKey } from '../core';
|
||||
import EditorMainBlock from './EditorMainBlock.vue';
|
||||
import EditorMainHeader from './EditorMainHeader.vue';
|
||||
|
||||
const rightPanelCollapsed = defineModel<boolean>("rightPanelCollapsed");
|
||||
const rightPanelCollapsed = defineModel<boolean>('rightPanelCollapsed');
|
||||
const { blocks, container, refLine, formatContainerStyle, scene } = inject(EditorKey)!;
|
||||
const { onMouseDown, onMouseWheel } = scene;
|
||||
const { active, xLines, yLines } = refLine;
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "add-block", type: string, x?: number, y?: number): void;
|
||||
(event: "current-block", block: Block | null): void;
|
||||
(event: 'add-block', type: string, x?: number, y?: number): void;
|
||||
(event: 'current-block', block: Block | null): void;
|
||||
(event: 'preview'): void;
|
||||
(event: 'block-menu', block: Block, e: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
/**
|
||||
|
|
@ -69,7 +81,7 @@ const emit = defineEmits<{
|
|||
*/
|
||||
const onClick = (e: Event) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
emit("current-block", null);
|
||||
emit('current-block', null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -84,11 +96,11 @@ const containerStyle = computed(() => formatContainerStyle(container.value));
|
|||
const onDragDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const type = e.dataTransfer?.getData("type");
|
||||
const type = e.dataTransfer?.getData('type');
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
emit("add-block", type, e.offsetX, e.offsetY);
|
||||
emit('add-block', type, e.offsetX, e.offsetY);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
@ -116,7 +116,6 @@ const onItemMouseup = () => {
|
|||
&::before {
|
||||
outline-style: solid;
|
||||
outline-color: rgb(var(--primary-6));
|
||||
background-color: rgba(var(--primary-1), 0.5);
|
||||
}
|
||||
}
|
||||
:deep(.vdr-stick) {
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
<div class="flex-1">
|
||||
<div class="group">
|
||||
<span class="text-gray-400">描述: </span>
|
||||
<ani-texter v-model="container.description"></ani-texter>
|
||||
<InputTexter v-model="container.description"></InputTexter>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
|
|
@ -30,57 +30,53 @@
|
|||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="预览" position="bottom">
|
||||
<a-button type="text">
|
||||
<!-- <a-tooltip content="预览" position="bottom">
|
||||
<a-button type="text" @click="emit('preview')">
|
||||
<template #icon>
|
||||
<i class="icon-park-outline-play text-base !text-gray-600"></i>
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-popover position="br" trigger="click">
|
||||
<a-tooltip content="设置" position="bottom">
|
||||
<a-button type="text">
|
||||
<template #icon>
|
||||
<i class="icon-park-outline-config text-base !text-gray-600"></i>
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<template #content>
|
||||
<a-form :model="{}" layout="vertical">
|
||||
<div class="muti-form-item">
|
||||
<a-form-item label="背景图片">
|
||||
<input-image v-model="container.bgImage"></input-image>
|
||||
</a-form-item>
|
||||
<a-form-item label="背景颜色">
|
||||
<input-color v-model="container.bgColor"></input-color>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</template>
|
||||
</a-popover>
|
||||
<a-tooltip :content="rightPanelCollapsed ? '展开' : '折叠'" position="bottom">
|
||||
<a-button type="text" @click="rightPanelCollapsed = !rightPanelCollapsed">
|
||||
<a-tooltip content="设置" position="bottom">
|
||||
<a-button type="text" @click="visible = true">
|
||||
<template #icon>
|
||||
<i class="icon-park-outline-config text-base !text-gray-600"></i>
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip> -->
|
||||
<a-tooltip :content="collapsed ? '展开' : '折叠'" position="bottom">
|
||||
<a-button type="text" @click="collapsed = !collapsed">
|
||||
<template #icon>
|
||||
<i
|
||||
class="text-base !text-gray-600"
|
||||
:class="rightPanelCollapsed ? 'icon-park-outline-expand-right' : 'icon-park-outline-expand-left'"
|
||||
:class="collapsed ? 'icon-park-outline-expand-right' : 'icon-park-outline-expand-left'"
|
||||
></i>
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<!-- <EditorMainConfig v-model="container" v-model:visible="visible"></EditorMainConfig> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputColor from "./InputColor.vue";
|
||||
import InputImage from "./InputImage.vue";
|
||||
import AniTexter from "./InputTexter.vue";
|
||||
import { EditorKey } from "../core";
|
||||
import InputTexter from './InputTexter.vue';
|
||||
// import EditorMainConfig from './EditorMainConfig.vue';
|
||||
import { EditorKey } from '../core';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
rightPanelCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['preview', 'update:rightPanelCollapsed']);
|
||||
const collapsed = useVModel(props, 'rightPanelCollapsed', emit);
|
||||
const { container, blocks, setContainerOrigin } = inject(EditorKey)!;
|
||||
|
||||
const rightPanelCollapsed = defineModel<boolean>("rightPanelCollapsed");
|
||||
const visible = ref(false);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="h-0 overflow-hidden">
|
||||
<div ref="el" class="bg-white w-screen h-screen select-none">
|
||||
<div ref="el" class="an-screen bg-white w-screen h-screen select-none flex items-center justify-center">
|
||||
<div
|
||||
v-if="visible"
|
||||
:style="{
|
||||
|
|
@ -36,13 +36,29 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from "@arco-design/web-vue";
|
||||
import { useFullscreen } from "@vueuse/core";
|
||||
import { BlockerMap } from "../blocks";
|
||||
import { EditorKey } from "../core";
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { useFullscreen, useVModel } from '@vueuse/core';
|
||||
import { BlockerMap } from '../blocks';
|
||||
import { Block, Container } from '../core';
|
||||
import { PropType } from 'vue';
|
||||
|
||||
const { container, blocks } = inject(EditorKey)!;
|
||||
const visible = defineModel<boolean>("visible");
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
container: {
|
||||
type: Object as PropType<Container>,
|
||||
required: true,
|
||||
},
|
||||
blocks: {
|
||||
type: Array as PropType<Block[]>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible']);
|
||||
const show = useVModel(props, 'visible', emit);
|
||||
const el = ref<HTMLElement | null>(null);
|
||||
const { enter, isFullscreen, isSupported } = useFullscreen(el);
|
||||
|
||||
|
|
@ -50,19 +66,19 @@ watch(
|
|||
() => isFullscreen.value,
|
||||
() => {
|
||||
if (!isFullscreen.value) {
|
||||
visible.value = false;
|
||||
show.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
(value) => {
|
||||
() => show.value,
|
||||
value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (!isSupported.value) {
|
||||
Message.warning("抱歉,您的浏览器不支持全屏功能!");
|
||||
Message.warning('抱歉,您的浏览器不支持全屏功能!');
|
||||
return;
|
||||
}
|
||||
enter();
|
||||
|
|
@ -70,5 +86,20 @@ watch(
|
|||
);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.an-screen {
|
||||
--color: rgba(0, 0, 0, 0.2);
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
var(--color) 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
var(--color) 75%,
|
||||
var(--color) 100%
|
||||
),
|
||||
linear-gradient(45deg, var(--color) 25%, transparent 25%, transparent 75%, var(--color) 75%, var(--color) 100%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 10px 10px;
|
||||
}
|
||||
</style>
|
||||
../core/editor
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div class="h-full w-[248px] overflow-hidden" :style="`display: ${collapsed ? 'none' : 'block'}`">
|
||||
<div v-if="model" class="p-3 pr-0 grid grid-rows-[auto_1fr]">
|
||||
<a-tag class="text-sm! mb-2 mr-3" size="large" color="blue" :bordered="false">
|
||||
<template #icon>
|
||||
<i :class="BlockerMap[model.type].icon"></i>
|
||||
</template>
|
||||
{{ BlockerMap[model.type].title }}属性
|
||||
</a-tag>
|
||||
<a-scrollbar outer-class="h-full overflow-hidden" class="h-full overflow-auto">
|
||||
<a-form :model="{}" layout="vertical" class="pr-3">
|
||||
<div class="muti-form-item mt-1">
|
||||
<component :is="BlockerMap[model.type].option" v-model="model" />
|
||||
</div>
|
||||
</a-form>
|
||||
</a-scrollbar>
|
||||
</div>
|
||||
<div v-show="!model" class="w-full h-full">
|
||||
<EditorSetting v-model="container"></EditorSetting>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BlockerMap } from '../blocks';
|
||||
import { Block, EditorKey } from '../core';
|
||||
import EditorSetting from './EditorSetting.vue';
|
||||
|
||||
const collapsed = defineModel<boolean>('collapsed');
|
||||
const model = defineModel<Block | null>('block');
|
||||
const { container } = inject(EditorKey)!;
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
../core
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<template>
|
||||
<div class="p-3">
|
||||
<a-tag class="text-sm! mb-2 w-full" size="large" color="blue" :bordered="false">
|
||||
<template #icon>
|
||||
<i class="icon-park-outline-config" ></i>
|
||||
</template>
|
||||
画布属性
|
||||
</a-tag>
|
||||
<a-form :model="{}" layout="vertical">
|
||||
<a-form-item label="标题">
|
||||
<a-input v-model="model.title"></a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model="model.description"></a-textarea>
|
||||
</a-form-item>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<a-form-item label="宽度">
|
||||
<a-input-number v-model="model.width" :min="0"> </a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="高度">
|
||||
<a-input-number v-model="model.height" :min="0"> </a-input-number>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<a-form-item label="背景图片">
|
||||
<input-image v-model="model.bgImage"></input-image>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="背景颜色">
|
||||
<input-color v-model="model.bgColor"></input-color>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="语言列表">
|
||||
<a-checkbox-group v-model="model.langList" direction="vertical" class="bg-gray-100 w-full px-1.5 py-1 rounded">
|
||||
<a-checkbox value="ch">中文<span class="text-gray-400">(cn)</span></a-checkbox>
|
||||
<a-checkbox value="en">英语<span class="text-gray-400">(en)</span></a-checkbox>
|
||||
<a-checkbox value="ru">俄语<span class="text-gray-400">(ru)</span></a-checkbox>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="语言切换">
|
||||
<a-input-number v-model="model.langSwitch" :min="0">
|
||||
<template #append>
|
||||
秒(s)
|
||||
</template>
|
||||
</a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from 'vue';
|
||||
import { Container } from '../core';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object as PropType<Container>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const model = useVModel(props, 'modelValue', emit);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
<template>
|
||||
<a-modal
|
||||
v-model:visible="innerVisible"
|
||||
v-model:visible="show"
|
||||
title="选择素材"
|
||||
title-align="start"
|
||||
:width="1080"
|
||||
:closable="false"
|
||||
:mask-closable="false"
|
||||
:draggable="true"
|
||||
:ok-button-props="{ disabled: !seleted.length }"
|
||||
:ok-button-props="{ disabled: !selected.length }"
|
||||
>
|
||||
<div class="w-full flex items-center justify-between gap-4">
|
||||
<div>
|
||||
|
|
@ -59,10 +59,10 @@
|
|||
</a-spin>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>已选: {{ seleted.length }} 项</div>
|
||||
<div>已选: {{ selected.length }} 项</div>
|
||||
<div>
|
||||
<a-button class="mr-2" @click="onClose"> 取消 </a-button>
|
||||
<a-button type="primary" @click="onBeforeOk" :disabled="!seleted.length"> 确定 </a-button>
|
||||
<a-button type="primary" @click="onBeforeOk" :disabled="!selected.length"> 确定 </a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -70,12 +70,15 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mockLoad } from "../utils/mock";
|
||||
import { PropType } from 'vue';
|
||||
import { mockLoad } from '../utils/mock';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
type: [String, Array] as PropType<string | any[]>,
|
||||
default: '',
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
|
|
@ -91,12 +94,16 @@ const props = defineProps({
|
|||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:visible"]);
|
||||
const emit = defineEmits(['update:modelValue', 'update:visible']);
|
||||
|
||||
const innerVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit("update:visible", value),
|
||||
});
|
||||
const show = useVModel(props, 'visible', emit);
|
||||
const model = useVModel(props, 'modelValue', emit);
|
||||
const pagination = ref({ page: 1, size: 15, total: 0 });
|
||||
const search = ref({ name: '' });
|
||||
const loading = ref(false);
|
||||
const images = ref<any[]>([]);
|
||||
const selected = ref<any[]>([]);
|
||||
const selectedKeys = computed(() => (selected.value ?? []).map(item => item.id));
|
||||
|
||||
const loadData = async () => {
|
||||
const { page, size } = pagination.value;
|
||||
|
|
@ -111,45 +118,39 @@ const loadData = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const pagination = ref({ page: 1, size: 15, total: 0 });
|
||||
const search = ref({ name: "" });
|
||||
const loading = ref(false);
|
||||
const images = ref<any[]>([]);
|
||||
const seleted = ref<any[]>([]);
|
||||
const selectedKeys = computed(() => seleted.value.map((item) => item.id));
|
||||
|
||||
const onBeforeOk = () => {
|
||||
emit("update:modelValue", seleted.value[0]?.url);
|
||||
model.value = props.multiple ? selected.value : selected.value[0]?.url;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
seleted.value = [];
|
||||
selected.value = [];
|
||||
images.value = [];
|
||||
pagination.value.page = 1;
|
||||
pagination.value.total = 0;
|
||||
search.value.name = "";
|
||||
innerVisible.value = false;
|
||||
search.value.name = '';
|
||||
show.value = false;
|
||||
};
|
||||
|
||||
const onSelectedImage = (image: any) => {
|
||||
if (selectedKeys.value.includes(image.id)) {
|
||||
seleted.value = seleted.value.filter((item) => item.id !== image.id);
|
||||
selected.value = selected.value.filter(item => item.id !== image.id);
|
||||
} else {
|
||||
if (!props.multiple) {
|
||||
seleted.value = [image];
|
||||
return;
|
||||
if (props.multiple) {
|
||||
selected.value.push(image);
|
||||
} else {
|
||||
selected.value = [image];
|
||||
}
|
||||
seleted.value.push(image);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(value) => {
|
||||
value => {
|
||||
if (value) {
|
||||
loadData();
|
||||
}
|
||||
selected.value = cloneDeep(props.multiple ? model.value : [model.value]) as any[];
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
|
@ -162,7 +163,7 @@ watch(
|
|||
cursor: pointer;
|
||||
}
|
||||
.selected:after {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
|
|
@ -172,7 +173,7 @@ watch(
|
|||
border-left: 20px solid transparent;
|
||||
}
|
||||
.selected:before {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
right: 1px;
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<span v-if="!descEditing" class="inline-block leading-[32px] h-8 cursor-text" @click="onDescEdit">
|
||||
{{ modelValue }}
|
||||
</span>
|
||||
<span v-else class="inline-flex items-center" ref="inputRef">
|
||||
<a-input size="small" v-model="descContent" class="!w-96" v-bind="inputProps"></a-input>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@arco-design/web-vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { PropType } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inputProps: {
|
||||
type: Object as PropType<Partial<InstanceType<typeof Input>['$props']>>,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const descEditing = ref(false);
|
||||
const descContent = ref('');
|
||||
const inputRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const onDescEdited = () => {
|
||||
emit('update:modelValue', descContent.value);
|
||||
descEditing.value = false;
|
||||
};
|
||||
|
||||
onClickOutside(inputRef, () => {
|
||||
onDescEdited();
|
||||
});
|
||||
|
||||
const onDescEdit = () => {
|
||||
descContent.value = props.modelValue;
|
||||
descEditing.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { Component } from "vue";
|
||||
|
||||
/**
|
||||
* 组件参数
|
||||
*/
|
||||
|
|
@ -67,3 +69,26 @@ export interface Block<T = any> {
|
|||
*/
|
||||
params: T;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
type?: 'divider' | 'menu'
|
||||
showChildren?: boolean
|
||||
onClick?: (item: ContextMenuItem) => void;
|
||||
icon?: Component | string
|
||||
name: string
|
||||
tip?: string
|
||||
class?: string;
|
||||
children?: ContextMenuItem[]
|
||||
}
|
||||
|
||||
export const useBlockContextMenu = (blocks: Block[]) => {
|
||||
const items: ContextMenuItem[] = [
|
||||
{
|
||||
name: '删除',
|
||||
icon: () => h('i', { class: 'icon-park-outline-delete' }),
|
||||
onClick(item) {
|
||||
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { Component } from 'vue';
|
||||
import { Block } from './block';
|
||||
import { Container } from './container';
|
||||
|
||||
/**
|
||||
* 组件配置
|
||||
*/
|
||||
export interface Blocker<T = any> {
|
||||
/**
|
||||
* 组件名称
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* 组件描述
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* 组件类型
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* 组件图标
|
||||
*/
|
||||
icon: string;
|
||||
/**
|
||||
* 组件默认值
|
||||
*/
|
||||
initial: T;
|
||||
/**
|
||||
* 编辑时的渲染组件
|
||||
*/
|
||||
render: Component;
|
||||
/**
|
||||
* 配置时的渲染组件
|
||||
*/
|
||||
option: Component;
|
||||
/**
|
||||
* 预览时的渲染组件
|
||||
*/
|
||||
viewer?: Component;
|
||||
/**
|
||||
* 将实际格式转换为内部格式
|
||||
*/
|
||||
onLoadContainer?: (container: Container) => void;
|
||||
/**
|
||||
* 将内部格式转换为实际格式
|
||||
*/
|
||||
onSaveContainer?: (container: Container) => any;
|
||||
/**
|
||||
* 将实际格式转换为内部格式
|
||||
*/
|
||||
onLoadBlock?: (data: any) => Block;
|
||||
/**
|
||||
* 将内部格式转换为实际格式
|
||||
*/
|
||||
onSaveBlock?: (block: Block) => any;
|
||||
/**
|
||||
* 在左侧添加选项卡
|
||||
*/
|
||||
addLeftTab?: () => {
|
||||
title: string;
|
||||
icon: string | Component;
|
||||
component: Component;
|
||||
};
|
||||
addBlock?: () => {
|
||||
/**
|
||||
* 唯一标识符
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* 显示标题
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* 显示图标
|
||||
*/
|
||||
icon: string;
|
||||
/**
|
||||
* 显示描述
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* 默认初始值
|
||||
*/
|
||||
initial: Block;
|
||||
/**
|
||||
* 预览时的渲染组件
|
||||
*/
|
||||
render: Component;
|
||||
/**
|
||||
* 编辑参数时的渲染组件
|
||||
*/
|
||||
optionRender: Component<{ modelValue: Block }>;
|
||||
/**
|
||||
* 编辑时的渲染组件
|
||||
*/
|
||||
modifyRender: Component;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义组件
|
||||
* @param blocker 参数
|
||||
* @returns
|
||||
*/
|
||||
export const defineBlocker = <T>(blocker: Blocker<T>) => {
|
||||
return blocker;
|
||||
};
|
||||
|
|
@ -42,6 +42,8 @@ export interface Container {
|
|||
* 背景颜色
|
||||
*/
|
||||
bgColor: string;
|
||||
langList: string[];
|
||||
langSwitch: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,4 +60,6 @@ export const defaultContainer: Container = {
|
|||
height: 1080,
|
||||
bgImage: "",
|
||||
bgColor: "#ffffff",
|
||||
langList: ['ch', 'en'],
|
||||
langSwitch: 0
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Ref } from "vue";
|
||||
import { Block } from "./block";
|
||||
import { getClosestValInSortedArr } from "../utils/closest";
|
||||
import { Block } from "./block";
|
||||
|
||||
/**
|
||||
* 组件参考线
|
||||
|
|
@ -100,8 +100,8 @@ export const useReferenceLine = (blocks: Ref<Block[]>, current: Ref<Block | null
|
|||
* 6. 绘制参考线段
|
||||
*/
|
||||
function updateRefLine(rect: DragRect) {
|
||||
const allXLines = [];
|
||||
const allYLines = [];
|
||||
const allXLines: any[] = [];
|
||||
const allYLines: any[] = [];
|
||||
const box = getRectBox(rect);
|
||||
let offsetX: number | undefined;
|
||||
let offsetY: number | undefined;
|
||||
|
|
@ -138,7 +138,7 @@ export const useReferenceLine = (blocks: Ref<Block[]>, current: Ref<Block | null
|
|||
} else if (isEqualNum(closetDistX, distMaxX)) {
|
||||
offsetX = closetMaxX - box.maxX;
|
||||
} else {
|
||||
throw new Error("un");
|
||||
throw new Error('un');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +150,7 @@ export const useReferenceLine = (blocks: Ref<Block[]>, current: Ref<Block | null
|
|||
} else if (isEqualNum(closetDistY, distMaxY)) {
|
||||
offsetY = closetMaxY - box.maxY;
|
||||
} else {
|
||||
throw new Error("un");
|
||||
throw new Error('un');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5,8 +5,8 @@
|
|||
<img src="@/assets/403.svg" alt="forbiden" class="w-[320px]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-3xl m-0 font-bold">403</h2>
|
||||
<p class="mt-2">权限不足,如需访问请联系管理员!</p>
|
||||
<h2 class="text-3xl m-0 font-medium">403</h2>
|
||||
<p class="mt-3">权限不足,如需访问请联系管理员!</p>
|
||||
<div class="space-x-3 mt-6">
|
||||
<a-button type="primary" @click="router.back()">
|
||||
<template #icon>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,29 @@
|
|||
import { Form, FormInstance } from '@arco-design/web-vue';
|
||||
import { Form, FormInstance, Message } from '@arco-design/web-vue';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { PropType } from 'vue';
|
||||
import { FormContextKey } from './useFormContext';
|
||||
import { useFormItems } from './useFormItems';
|
||||
import { useFormModel } from './useFormModel';
|
||||
import { useFormRef } from './useFormRef';
|
||||
import { useFormSubmit } from './useFormSubmit';
|
||||
import { ComputedRef, InjectionKey, PropType, Ref } from 'vue';
|
||||
import { initFormItems } from '../utils/useFormItems';
|
||||
import { FormRef, useFormRef } from '../utils/useFormRef';
|
||||
import { AnFormItem, AnFormItemProps } from './FormItem';
|
||||
import { cloneDeep, isFunction, isObject, merge } from 'lodash-es';
|
||||
import { getModel } from '../utils/useFormModel';
|
||||
|
||||
const SUBMIT_ITEM = {
|
||||
field: 'id',
|
||||
setter: 'submit' as const,
|
||||
itemProps: {
|
||||
hideLabel: true,
|
||||
},
|
||||
};
|
||||
|
||||
export type FormContextInterface = FormRef & {
|
||||
model: Ref<Recordable>;
|
||||
items: ComputedRef<AnFormItemProps[]>;
|
||||
loading: Ref<boolean>;
|
||||
submitForm: any;
|
||||
resetForm: any;
|
||||
};
|
||||
|
||||
export const FormContextKey = Symbol('FormContextKey') as InjectionKey<FormContextInterface>;
|
||||
|
||||
/**
|
||||
* 表单组件
|
||||
|
|
@ -50,7 +67,7 @@ export const AnForm = defineComponent({
|
|||
* ```
|
||||
*/
|
||||
submit: {
|
||||
type: [String, Function, Object] as PropType<AnFormSubmit>,
|
||||
type: [Function, Object] as PropType<AnFormSubmit>,
|
||||
},
|
||||
/**
|
||||
* 传给Form组件的参数
|
||||
|
|
@ -69,25 +86,61 @@ export const AnForm = defineComponent({
|
|||
setup(props, { slots, emit }) {
|
||||
const model = useVModel(props, 'model', emit);
|
||||
const items = computed(() => props.items);
|
||||
const formRefes = useFormRef();
|
||||
const formModel = useFormModel(model, formRefes.clearValidate);
|
||||
const formItems = useFormItems(items, model);
|
||||
const formSubmit = useFormSubmit(props, formRefes.validate, formModel.getModel);
|
||||
const context = { slots, ...formModel, ...formItems, ...formRefes, ...formSubmit };
|
||||
const initModel = cloneDeep(model.value);
|
||||
const loading = ref(false);
|
||||
const { formRef, ...formMethods } = useFormRef();
|
||||
|
||||
const submitItem = () => {
|
||||
if (!props.submit) {
|
||||
return null;
|
||||
}
|
||||
if (isFunction(props.submit)) {
|
||||
return SUBMIT_ITEM;
|
||||
}
|
||||
if (isObject(props.submit)) {
|
||||
return merge({}, SUBMIT_ITEM, props.submit);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
model.value = cloneDeep(initModel);
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
if (await formRef.value?.validate()) {
|
||||
return;
|
||||
}
|
||||
const submit: any = typeof props.submit === 'object' ? props.submit.visible : props.submit;
|
||||
try {
|
||||
loading.value = true;
|
||||
const data = getModel(model.value);
|
||||
const res = await submit?.(data, props.items);
|
||||
const msg = res?.data?.message;
|
||||
msg && Message.success(`提示: ${msg}`);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const context = { slots, loading, resetForm, submitForm, submitItem, model, items, formRef, ...formMethods };
|
||||
provide(FormContextKey, context);
|
||||
|
||||
onMounted(() => {
|
||||
initFormItems(props.items, model.value);
|
||||
});
|
||||
|
||||
return context;
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
<Form layout="vertical" {...this.$attrs} {...this.formProps} class="an-form" ref="formRef" model={this.model}>
|
||||
<Form layout="vertical" {...this.formProps} class="an-form" ref="formRef" model={this.model}>
|
||||
{this.items.map(item => (
|
||||
<AnFormItem key={item.field} item={item} items={this.items} model={this.model}></AnFormItem>
|
||||
))}
|
||||
{this.$slots.submit?.(this.model, this.validate) ||
|
||||
(this.submit && this.submitItem && (
|
||||
<AnFormItem item={this.submitItem} items={this.items} model={this.model}></AnFormItem>
|
||||
))}
|
||||
{this.submitItem()}
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
|
|
@ -99,4 +152,4 @@ export type AnFormProps = Pick<AnFormInstance['$props'], 'model' | 'items' | 'su
|
|||
|
||||
export type AnFormSubmitFn = (model: Recordable, items: AnFormItemProps[]) => any;
|
||||
|
||||
export type AnFormSubmit = string | AnFormSubmitFn;
|
||||
export type AnFormSubmit = AnFormSubmitFn | AnFormItemProps;
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
import { useVisible } from '@/hooks/useVisible';
|
||||
import { Button, ButtonInstance, FormInstance, Modal } from '@arco-design/web-vue';
|
||||
import { InjectionKey, PropType, Ref } from 'vue';
|
||||
import { useModalSubmit } from './useModalSubmit';
|
||||
import { useModalTrigger } from './useModalTrigger';
|
||||
import { AnForm, AnFormInstance, AnFormProps, AnFormSubmit } from './Form';
|
||||
import { AnFormItemProps } from './FormItem';
|
||||
import { Button, ButtonInstance, FormInstance, Message, Modal } from '@arco-design/web-vue';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { InjectionKey, PropType, Ref } from 'vue';
|
||||
import { getModel, setModel } from '../utils/useFormModel';
|
||||
import { AnForm, AnFormInstance, AnFormSubmit } from './Form';
|
||||
import { AnFormItemProps } from './FormItem';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
export interface AnFormModalContext {
|
||||
visible: Ref<boolean>;
|
||||
loading: Ref<boolean>;
|
||||
formRef: Ref<AnFormInstance | null>;
|
||||
anFormRef: Ref<AnFormInstance | null>;
|
||||
submitForm: () => any | Promise<any>;
|
||||
open: (data: Recordable) => void;
|
||||
close: () => void;
|
||||
submitForm: () => any | Promise<any>;
|
||||
modalTitle: () => any;
|
||||
modalTrigger: () => any;
|
||||
onClose: () => void;
|
||||
|
|
@ -97,7 +96,7 @@ export const AnFormModal = defineComponent({
|
|||
* ```
|
||||
*/
|
||||
submit: {
|
||||
type: [String, Function] as PropType<AnFormSubmit>,
|
||||
type: [Object, Function] as PropType<AnFormSubmit>,
|
||||
},
|
||||
/**
|
||||
* 传给Form组件的参数
|
||||
|
|
@ -114,25 +113,11 @@ export const AnFormModal = defineComponent({
|
|||
},
|
||||
emits: ['update:model', 'submited'],
|
||||
setup(props, { emit }) {
|
||||
const formRef = ref<AnFormInstance | null>(null);
|
||||
const model = useVModel(props, 'model', emit);
|
||||
const originModel = cloneDeep(model.value);
|
||||
const anFormRef = ref<AnFormInstance | null>(null);
|
||||
const visible = ref(false);
|
||||
const show = () => (visible.value = true);
|
||||
const hide = () => (visible.value = false);
|
||||
const modalTrigger = useModalTrigger(props, show);
|
||||
const { loading, setLoading, submitForm } = useModalSubmit(props, formRef, visible, emit, model);
|
||||
|
||||
const open = (data: Recordable = {}) => {
|
||||
formRef.value?.setModel(data);
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setLoading(false);
|
||||
hide();
|
||||
};
|
||||
|
||||
const onClose = () => {};
|
||||
const loading = ref(false);
|
||||
|
||||
const modalTitle = () => {
|
||||
if (typeof props.title === 'string') {
|
||||
|
|
@ -141,10 +126,72 @@ export const AnFormModal = defineComponent({
|
|||
return <props.title model={props.model} items={props.items}></props.title>;
|
||||
};
|
||||
|
||||
const modalTrigger = () => {
|
||||
if (!props.trigger) {
|
||||
return null;
|
||||
}
|
||||
if (typeof props.trigger === 'function') {
|
||||
return <props.trigger model={props.model} items={props.items} open={open}></props.trigger>;
|
||||
}
|
||||
const internal = {
|
||||
text: '新增',
|
||||
buttonProps: {},
|
||||
buttonSlots: {},
|
||||
};
|
||||
if (typeof props.trigger === 'string') {
|
||||
internal.text = props.trigger;
|
||||
}
|
||||
if (typeof props.trigger === 'object') {
|
||||
Object.assign(internal, props.trigger);
|
||||
}
|
||||
return (
|
||||
<Button type="primary" {...internal.buttonProps} onClick={open}>
|
||||
{{
|
||||
...internal.buttonSlots,
|
||||
icon: () => <i class="icon-park-outline-add"></i>,
|
||||
default: () => internal.text,
|
||||
}}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
if (await anFormRef.value?.validate()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
loading.value = true;
|
||||
const data = getModel(model.value);
|
||||
const res = await (props as any).submit?.(data, props.items);
|
||||
const msg = res?.data?.message;
|
||||
msg && Message.success(msg);
|
||||
visible.value = false;
|
||||
emit('submited', res);
|
||||
} catch {
|
||||
// todo
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async (data: Recordable = {}) => {
|
||||
visible.value = true;
|
||||
await nextTick();
|
||||
model.value = cloneDeep(originModel)
|
||||
setModel(model.value, data);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
loading.value = false;
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
const onClose = () => {};
|
||||
|
||||
const context: AnFormModalContext = {
|
||||
visible,
|
||||
loading,
|
||||
formRef,
|
||||
anFormRef,
|
||||
open,
|
||||
close,
|
||||
onClose,
|
||||
|
|
@ -155,7 +202,9 @@ export const AnFormModal = defineComponent({
|
|||
|
||||
provide(AnFormModalContextKey, context);
|
||||
|
||||
return context;
|
||||
return {
|
||||
...context
|
||||
};
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
|
|
@ -164,11 +213,11 @@ export const AnFormModal = defineComponent({
|
|||
<Modal
|
||||
titleAlign="start"
|
||||
closable={false}
|
||||
{...this.$attrs}
|
||||
{...this.modalProps}
|
||||
v-model:visible={this.visible}
|
||||
class="an-form-modal"
|
||||
maskClosable={false}
|
||||
unmountOnClose={true}
|
||||
onClose={this.onClose}
|
||||
>
|
||||
{{
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import setterMap from '../setters';
|
||||
|
||||
/**
|
||||
* 键值对类型
|
||||
*/
|
||||
export type SetterMap = typeof setterMap;
|
||||
|
||||
/**
|
||||
* 组件名联合类型
|
||||
*/
|
||||
export type SetterType = keyof SetterMap;
|
||||
|
||||
/**
|
||||
* 重新映射
|
||||
*/
|
||||
export type SetterItemMap = {
|
||||
[key in SetterType]: {
|
||||
/**
|
||||
|
|
@ -33,6 +42,11 @@ export type SetterItemMap = {
|
|||
};
|
||||
};
|
||||
|
||||
export type SetterItem = SetterItemMap[SetterType] | { setter?: undefined; setterProps?: undefined; setterSlots?: undefined };
|
||||
/**
|
||||
* 控件类型
|
||||
*/
|
||||
export type SetterItem =
|
||||
| SetterItemMap[SetterType]
|
||||
| { setter?: undefined; setterProps?: undefined; setterSlots?: undefined };
|
||||
|
||||
export { setterMap };
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import { InjectionKey } from "vue";
|
||||
import { FormItems } from "./useFormItems";
|
||||
import { FormModel } from "./useFormModel";
|
||||
import { FormRef } from "./useFormRef";
|
||||
import { FormSubmit } from "./useFormSubmit";
|
||||
|
||||
export type FormContextInterface = FormModel &
|
||||
FormItems &
|
||||
FormRef &
|
||||
FormSubmit & {
|
||||
slots: Recordable;
|
||||
};
|
||||
|
||||
export const FormContextKey = Symbol("FormContextKey") as InjectionKey<FormContextInterface>;
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { Ref } from 'vue';
|
||||
import { AnFormItemProps } from './FormItem';
|
||||
import { setterMap } from './FormSetter';
|
||||
|
||||
export function useFormItems(items: Ref<AnFormItemProps[]>, model: Ref<Recordable>) {
|
||||
const getItem = (field: string) => {
|
||||
return items.value.find(i => i.field === field);
|
||||
};
|
||||
|
||||
const getItemOptions = (field: string) => {
|
||||
const item = getItem(field);
|
||||
if (item) {
|
||||
return (item.setterProps as any)?.options;
|
||||
}
|
||||
};
|
||||
|
||||
const initItemOptions = (field: string) => {
|
||||
const item = getItem(field);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const setter = setterMap[item.setter!];
|
||||
if (!setter) {
|
||||
return;
|
||||
}
|
||||
setter.onSetup?.({ item, items: items.value, model: model.value });
|
||||
};
|
||||
|
||||
const initItems = () => {
|
||||
for (const item of items.value) {
|
||||
const setter = setterMap[item?.setter!];
|
||||
setter.onSetup?.({ item, items: items.value, model: model.value });
|
||||
}
|
||||
};
|
||||
|
||||
const initItem = (field: string) => {
|
||||
const item = getItem(field);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const setter = setterMap[item?.setter!];
|
||||
setter.onSetup?.({ item, items: items.value, model: model.value });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initItems();
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
getItem,
|
||||
initItem,
|
||||
initItems,
|
||||
getItemOptions,
|
||||
initItemOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export type FormItems = ReturnType<typeof useFormItems>;
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { cloneDeep } from 'lodash-es';
|
||||
import { Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 表单数据管理
|
||||
* @param initial 初始值
|
||||
* @returns
|
||||
*/
|
||||
export function useFormModel(model: Ref<Recordable>, clearValidate: any) {
|
||||
const initial = cloneDeep(model.value);
|
||||
|
||||
const resetModel = () => {
|
||||
model.value = cloneDeep(initial);
|
||||
clearValidate();
|
||||
};
|
||||
|
||||
const getInitialModel = () => {
|
||||
return initial;
|
||||
};
|
||||
|
||||
const setModel = (data: Recordable) => {
|
||||
for (const key of Object.keys(model.value)) {
|
||||
model.value[key] = data[key];
|
||||
}
|
||||
};
|
||||
|
||||
const getModel = () => {
|
||||
return formatModel(model.value);
|
||||
};
|
||||
|
||||
return {
|
||||
model,
|
||||
getInitialModel,
|
||||
resetModel,
|
||||
setModel,
|
||||
getModel,
|
||||
};
|
||||
}
|
||||
|
||||
export type FormModel = ReturnType<typeof useFormModel>;
|
||||
|
||||
export function formatModel(model: Recordable) {
|
||||
const data: Recordable = {};
|
||||
|
||||
for (const [key, value] of Object.entries(model)) {
|
||||
if (value === '') {
|
||||
continue;
|
||||
}
|
||||
if (/^\[.+\]$/.test(key)) {
|
||||
formatModelArray(key, value, data);
|
||||
continue;
|
||||
}
|
||||
if (/^\{.+\}$/.test(key)) {
|
||||
formatModelObject(key, value, data);
|
||||
continue;
|
||||
}
|
||||
data[key] = value;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function formatModelArray(key: string, value: any, data: Recordable) {
|
||||
let field = key.replaceAll(/\s/g, '');
|
||||
field = field.match(/^\[(.+)\]$/)?.[1] ?? '';
|
||||
|
||||
if (!field) {
|
||||
data[key] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
field.split(',').forEach((key, index) => {
|
||||
data[key] = value?.[index];
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function formatModelObject(key: string, value: any, data: Recordable) {
|
||||
let field = key.replaceAll(/\s/g, '');
|
||||
field = field.match(/^\{(.+)\}$/)?.[1] ?? '';
|
||||
|
||||
if (!field) {
|
||||
data[key] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of field.split(',')) {
|
||||
data[key] = value?.[key];
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { Message } from '@arco-design/web-vue';
|
||||
import { AnFormProps } from './Form';
|
||||
import { AnFormItemProps } from './FormItem';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
const SUBMIT_ITEM = {
|
||||
field: 'id',
|
||||
setter: 'submit' as const,
|
||||
itemProps: {
|
||||
hideLabel: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function useFormSubmit(props: AnFormProps, validate: any, getModel: any) {
|
||||
const loading = ref(false);
|
||||
const submitItem = ref<AnFormItemProps | null>(null);
|
||||
|
||||
if (props.submit) {
|
||||
submitItem.value = cloneDeep(SUBMIT_ITEM);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置loading
|
||||
* @param value 值
|
||||
*/
|
||||
const setLoading = (value: boolean) => {
|
||||
loading.value = value;
|
||||
};
|
||||
|
||||
/**
|
||||
* 提交表单
|
||||
*/
|
||||
const submitForm = async () => {
|
||||
if (await validate()) {
|
||||
return;
|
||||
}
|
||||
const submit = typeof props.submit === 'string' ? () => null : props.submit;
|
||||
try {
|
||||
loading.value = true;
|
||||
const data = getModel();
|
||||
const res = await submit?.(data, props.items ?? []);
|
||||
const msg = res?.data?.message;
|
||||
msg && Message.success(`提示: ${msg}`);
|
||||
} catch {
|
||||
console.log();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消提交
|
||||
*/
|
||||
const cancelForm = () => {};
|
||||
|
||||
return {
|
||||
loading,
|
||||
submitItem,
|
||||
setLoading,
|
||||
submitForm,
|
||||
cancelForm,
|
||||
};
|
||||
}
|
||||
|
||||
export type FormSubmit = ReturnType<typeof useFormSubmit>;
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { sleep } from '@/utils';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { Ref } from 'vue';
|
||||
|
||||
export function useModalSubmit(props: any, formRef: any, visible: Ref<boolean>, emit?: any, model?: Ref<Recordable>) {
|
||||
const loading = ref(false);
|
||||
const origin = cloneDeep(props.model);
|
||||
|
||||
const submitForm = async () => {
|
||||
if (await formRef.value?.validate()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
loading.value = true;
|
||||
const data = formRef.value?.getModel() ?? {};
|
||||
const res = await props.submit?.(data, props.items);
|
||||
const msg = res?.data?.message;
|
||||
msg && Message.success(msg);
|
||||
emit('submited', res);
|
||||
visible.value = false;
|
||||
if (model) {
|
||||
model.value = cloneDeep(origin);
|
||||
}
|
||||
} catch {
|
||||
// todo
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setLoading = (value: boolean) => {
|
||||
loading.value = value;
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
setLoading,
|
||||
submitForm,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { Button } from '@arco-design/web-vue';
|
||||
|
||||
export function useModalTrigger(props: any, open: () => void) {
|
||||
const modalTrigger = () => {
|
||||
if (!props.trigger) {
|
||||
return null;
|
||||
}
|
||||
if (typeof props.trigger === 'function') {
|
||||
return <props.trigger model={props.model} items={props.items} open={open}></props.trigger>;
|
||||
}
|
||||
const internal = {
|
||||
text: '新增',
|
||||
buttonProps: {},
|
||||
buttonSlots: {},
|
||||
};
|
||||
if (typeof props.trigger === 'string') {
|
||||
internal.text = props.trigger;
|
||||
}
|
||||
if (typeof props.trigger === 'object') {
|
||||
Object.assign(internal, props.trigger);
|
||||
}
|
||||
return (
|
||||
<Button type="primary" {...internal.buttonProps} onClick={open}>
|
||||
{{
|
||||
...internal.buttonSlots,
|
||||
icon: () => <i class="icon-park-outline-add"></i>,
|
||||
default: () => internal.text,
|
||||
}}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
return modalTrigger;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { merge } from 'lodash-es';
|
||||
import { AnForm, AnFormInstance, AnFormProps } from '../components/Form';
|
||||
import { FormItem, useItems } from './useItems';
|
||||
import { FormItem, useFormItems } from './useFormItems';
|
||||
|
||||
export type FormUseOptions = Partial<Omit<AnFormProps, 'items'>> & {
|
||||
/**
|
||||
|
|
@ -20,7 +20,7 @@ export type FormUseOptions = Partial<Omit<AnFormProps, 'items'>> & {
|
|||
export function useFormProps(options: FormUseOptions): Required<AnFormProps> {
|
||||
const { model: _model = {}, items: _items = [], submit = () => null, formProps = {} } = options;
|
||||
const model = merge({ id: undefined }, _model);
|
||||
const items = useItems(_items ?? [], model);
|
||||
const items = useFormItems(_items ?? [], model);
|
||||
return {
|
||||
model,
|
||||
items,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { defaultsDeep, merge, omit } from 'lodash-es';
|
||||
import { defaultsDeep, has, merge, omit } from 'lodash-es';
|
||||
import { AnFormItemProps, AnFormItemPropsBase } from '../components/FormItem';
|
||||
import { SetterItem, setterMap } from '../components/FormSetter';
|
||||
import { Rule, useRules } from './useRules';
|
||||
import { Rule, useFormRules } from './useFormRules';
|
||||
|
||||
/**
|
||||
* 表单项数据
|
||||
|
|
@ -34,17 +34,26 @@ export type FormItem = Omit<AnFormItemPropsBase, 'rules'> &
|
|||
* ```
|
||||
*/
|
||||
rules?: Rule[];
|
||||
|
||||
/**
|
||||
* 参数 `setterProps.placeholder` 的快捷语法
|
||||
* @example
|
||||
* ```ts
|
||||
* '请输入用户名称'
|
||||
* ```
|
||||
*/
|
||||
placeholder?: string | string[];
|
||||
};
|
||||
|
||||
const ITEM: Partial<FormItem> = {
|
||||
setter: 'input',
|
||||
};
|
||||
|
||||
export function useItems(list: FormItem[], model: Recordable) {
|
||||
const items: AnFormItemProps[] = [];
|
||||
export function useFormItems(items: FormItem[], model: Recordable) {
|
||||
const data: AnFormItemProps[] = [];
|
||||
|
||||
for (const item of list) {
|
||||
let target: any = defaultsDeep({}, ITEM);
|
||||
for (const item of items) {
|
||||
let target: AnFormItemProps = defaultsDeep({}, ITEM);
|
||||
|
||||
if (!item.setter || typeof item.setter === 'string') {
|
||||
const setter = setterMap[item.setter ?? 'input'];
|
||||
|
|
@ -53,16 +62,23 @@ export function useItems(list: FormItem[], model: Recordable) {
|
|||
}
|
||||
}
|
||||
|
||||
target = merge(target, omit(item, ['required', 'rules', 'value']));
|
||||
target = merge(target, omit(item, ['required', 'rules', 'value', 'placeholder']));
|
||||
|
||||
const rules = useRules(item);
|
||||
if (rules) {
|
||||
if (item.required || item.rules) {
|
||||
const rules = useFormRules(item)!;
|
||||
target.rules = rules;
|
||||
}
|
||||
|
||||
model[item.field] = model[item.field] ?? item.value;
|
||||
items.push(target);
|
||||
if (target.setterProps && has(item, 'placeholder')) {
|
||||
(target.setterProps as Recordable).placholder = item.placeholder;
|
||||
}
|
||||
|
||||
if (!has(model, item.field)) {
|
||||
model[item.field] = item.value;
|
||||
}
|
||||
|
||||
data.push(target);
|
||||
}
|
||||
|
||||
return items;
|
||||
return data;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { merge } from 'lodash-es';
|
||||
import { AnFormModal, AnFormModalProps } from '../components/FormModal';
|
||||
import { useFormProps } from './useForm';
|
||||
import { FormItem } from './useItems';
|
||||
import { FormItem } from './useFormItems';
|
||||
|
||||
export type FormModalUseOptions = Partial<Omit<AnFormModalProps, 'items'>> & {
|
||||
/**
|
||||
|
|
@ -13,6 +13,14 @@ export type FormModalUseOptions = Partial<Omit<AnFormModalProps, 'items'>> & {
|
|||
* ```
|
||||
*/
|
||||
width?: number;
|
||||
/**
|
||||
* modal宽度
|
||||
* @example
|
||||
* ```ts
|
||||
* 1080
|
||||
* ```
|
||||
*/
|
||||
modalWidth?: number;
|
||||
/**
|
||||
* 表单类名
|
||||
* @description 参数 `formProps.class` 的便捷语法
|
||||
|
|
@ -58,7 +66,7 @@ export function useFormModalProps(options: FormModalUseOptions): AnFormModalProp
|
|||
|
||||
export function useFormModal(options: FormModalUseOptions) {
|
||||
const modalRef = ref<InstanceType<typeof AnFormModal> | null>(null);
|
||||
const formRef = computed(() => modalRef.value?.formRef);
|
||||
const formRef = computed(() => modalRef.value?.anFormRef);
|
||||
const open = (data: Recordable = {}) => modalRef.value?.open(data);
|
||||
const rawProps = useFormModalProps(options);
|
||||
const props = reactive(rawProps);
|
||||
|
|
@ -67,7 +75,7 @@ export function useFormModal(options: FormModalUseOptions) {
|
|||
<AnFormModal
|
||||
ref={(el: any) => (modalRef.value = el)}
|
||||
title={props.title}
|
||||
trigger={props.title}
|
||||
trigger={props.trigger}
|
||||
modalProps={props.modalProps as any}
|
||||
model={props.model}
|
||||
items={props.items}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ function defineRuleMap<T extends Record<string, FieldRule>>(ruleMap: T) {
|
|||
* @param item 表单项
|
||||
* @returns
|
||||
*/
|
||||
export const useRules = <T extends { required?: boolean; rules?: Rule[] }>(item: T) => {
|
||||
export const useFormRules = <T extends { required?: boolean; rules?: Rule[] }>(item: T) => {
|
||||
const data: AnFormItemRule[] = [];
|
||||
const { required, rules } = item;
|
||||
|
||||
|
|
@ -2,15 +2,11 @@ export * from './components/Form';
|
|||
export * from './components/FormItem';
|
||||
export * from './components/FormModal';
|
||||
export * from './components/FormSetter';
|
||||
export * from './components/useFormContext';
|
||||
export * from './components/useFormItems';
|
||||
export * from './components/useFormModel';
|
||||
export * from './components/useFormRef';
|
||||
export * from './components/useFormSubmit';
|
||||
export * from './components/useModalSubmit';
|
||||
export * from './components/useModalTrigger';
|
||||
export * from './utils/useFormItems';
|
||||
export * from './utils/useFormModel';
|
||||
export * from './utils/useFormRef';
|
||||
export * from './hooks/useForm';
|
||||
export * from './hooks/useFormModal';
|
||||
export * from './hooks/useItems';
|
||||
export * from './hooks/useRules';
|
||||
export * from './hooks/useFormItems';
|
||||
export * from './hooks/useFormRules';
|
||||
export * from './setters';
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
import { Button } from '@arco-design/web-vue';
|
||||
import { FormContextKey } from '../components/useFormContext';
|
||||
import { FormContextKey } from '../components/Form';
|
||||
import { defineSetter } from './util';
|
||||
|
||||
export default defineSetter<{}, 'none'>({
|
||||
setter() {
|
||||
const { loading, submitForm, resetModel } = inject(FormContextKey)!;
|
||||
const { submitForm, resetForm } = inject(FormContextKey)!;
|
||||
return (
|
||||
<>
|
||||
<Button type="primary" loading={loading.value} onClick={submitForm} class="mr-3">
|
||||
<Button type="primary" onClick={submitForm} class="mr-3">
|
||||
提交
|
||||
</Button>
|
||||
<Button disabled={loading.value} onClick={resetModel}>
|
||||
重置
|
||||
</Button>
|
||||
<Button onClick={resetForm}>重置</Button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { AnFormItemProps } from '../components/FormItem';
|
||||
import { setterMap } from '../components/FormSetter';
|
||||
|
||||
export const getFormItem = (items: AnFormItemProps[], field: string) => {
|
||||
return items.find(i => i.field === field);
|
||||
};
|
||||
|
||||
export const initFormItems = (items: AnFormItemProps[], model: Recordable) => {
|
||||
for (const item of items) {
|
||||
const setter = setterMap[item.setter!];
|
||||
setter.onSetup?.({ item, items, model });
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
export function getModel(model: Recordable) {
|
||||
const data: Recordable = {};
|
||||
|
||||
for (const [key, value] of Object.entries(model)) {
|
||||
if (value === '') {
|
||||
continue;
|
||||
}
|
||||
if (/^\[.+\]$/.test(key)) {
|
||||
getModelArray(key, value, data);
|
||||
continue;
|
||||
}
|
||||
if (/^\{.+\}$/.test(key)) {
|
||||
getModelObject(key, value, data);
|
||||
continue;
|
||||
}
|
||||
data[key] = value;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function setModel(model: Recordable, data: Recordable) {
|
||||
for (const [key, value] of Object.entries(model)) {
|
||||
if (/^\[.+\]$/.test(key)) {
|
||||
model[key] = setModelArray(data, key);
|
||||
continue;
|
||||
}
|
||||
if (/^\{.+\}$/.test(key)) {
|
||||
model[key] = setModelObject(data, key);
|
||||
continue;
|
||||
}
|
||||
model[key] = data[key];
|
||||
}
|
||||
console.log(model, data);
|
||||
return model;
|
||||
}
|
||||
|
||||
function rmString(str: string) {
|
||||
const field = str.replaceAll(/\s/g, '');
|
||||
return field.match(/^(\{|\[)(.+)(\}|\])$/)?.[1] ?? '';
|
||||
}
|
||||
|
||||
function setModelArray(data: Recordable, key: string) {
|
||||
const result: any[] = [];
|
||||
const field = rmString(key);
|
||||
for (const key of field.split(',')) {
|
||||
result.push(data[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setModelObject(data: Recordable, key: string) {
|
||||
const result: Recordable = {};
|
||||
const field = rmString(key);
|
||||
for (const key of field.split(',')) {
|
||||
result[key] = data[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getModelArray(key: string, value: any, data: Recordable) {
|
||||
let field = rmString(key);
|
||||
|
||||
if (!field) {
|
||||
data[key] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
field.split(',').forEach((key, index) => {
|
||||
data[key] = value?.[index];
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function getModelObject(key: string, value: any, data: Recordable) {
|
||||
const field = rmString(key);
|
||||
|
||||
if (!field) {
|
||||
data[key] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of field.split(',')) {
|
||||
data[key] = value?.[key];
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<div class="grid grid-rows-[auto_1fr]">
|
||||
<div class="h-10 bg-white flex items-center gap-2 px-4">
|
||||
<router-link
|
||||
v-for="menu in menus"
|
||||
:key="menu.path"
|
||||
:to="menu.path"
|
||||
:class="route.path === menu.path ? `bg-blue-500! text-white` : null"
|
||||
class="px-2 text-gray-500 leading-[24px] rounded-sm hover:bg-gray-100"
|
||||
>
|
||||
<i :class="`${menu.icon}`"></i>
|
||||
{{ menu.title }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="bg-white py-4 px-5">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
import { useMenuStore } from '@/store/menu';
|
||||
|
||||
const route = useRoute();
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
const menus = computed(() => {
|
||||
const parentPath = route.path.split('/').slice(0, -1).join('/');
|
||||
const item = menuStore.find(parentPath);
|
||||
return item?.children ?? [];
|
||||
});
|
||||
</script>
|
||||
|
|
@ -5,17 +5,25 @@ import {
|
|||
AnFormModalInstance,
|
||||
AnFormModalProps,
|
||||
AnFormProps,
|
||||
getModel,
|
||||
} from '@/components/AnForm';
|
||||
import AnEmpty from '@/components/AnEmpty/AnEmpty.vue';
|
||||
import { Button, PaginationProps, Table, TableColumnData, TableData, TableInstance } from '@arco-design/web-vue';
|
||||
import { isArray, isFunction, merge } from 'lodash-es';
|
||||
import { InjectionKey, PropType, Ref, defineComponent, ref } from 'vue';
|
||||
import { InjectionKey, PropType, Ref, VNodeChild, defineComponent, ref } from 'vue';
|
||||
import { PluginContainer } from '../hooks/useTablePlugin';
|
||||
|
||||
type DataFn = (filter: { page: number; size: number; [key: string]: any }) => any | Promise<any>;
|
||||
|
||||
export type ArcoTableProps = Omit<
|
||||
TableInstance['$props'],
|
||||
'ref' | 'pagination' | 'loading' | 'data' | 'onPageChange' | 'onPageSizeChange'
|
||||
>;
|
||||
|
||||
export const AnTableContextKey = Symbol('AnTableContextKey') as InjectionKey<AnTableContext>;
|
||||
|
||||
export type TableColumnRender = (data: { record: TableData; column: TableColumnData; rowIndex: number }) => VNodeChild;
|
||||
|
||||
/**
|
||||
* 表格组件
|
||||
*/
|
||||
|
|
@ -64,9 +72,7 @@ export const AnTable = defineComponent({
|
|||
* 传递给 Table 组件的属性
|
||||
*/
|
||||
tableProps: {
|
||||
type: Object as PropType<
|
||||
Omit<TableInstance['$props'], 'ref' | 'pagination' | 'loading' | 'data' | 'onPageChange' | 'onPageSizeChange'>
|
||||
>,
|
||||
type: Object as PropType<ArcoTableProps>,
|
||||
},
|
||||
/**
|
||||
* 插件列表
|
||||
|
|
@ -116,7 +122,7 @@ export const AnTable = defineComponent({
|
|||
}
|
||||
|
||||
const paging = getPaging();
|
||||
const search = searchRef.value?.getModel() ?? {};
|
||||
const search = getModel(props.search?.model ?? {});
|
||||
|
||||
if (isArray(props.source)) {
|
||||
// todo
|
||||
|
|
@ -126,13 +132,20 @@ export const AnTable = defineComponent({
|
|||
try {
|
||||
loading.value = true;
|
||||
let params = { ...search, ...paging };
|
||||
params = props.pluginer?.callBeforeSearchHook(params) ?? params;
|
||||
const resData = await props.source(params);
|
||||
const { data = [], total = 0 } = resData?.data || {};
|
||||
let resData = (await props.pluginer?.callLoadHook(props.source, params)) || (await props.source(params));
|
||||
let data: any[] = [];
|
||||
let total = 0;
|
||||
if (isArray(resData)) {
|
||||
data = resData;
|
||||
total = resData.length;
|
||||
} else {
|
||||
data = resData.data.data;
|
||||
total = resData.data.total;
|
||||
}
|
||||
renderData.value = data;
|
||||
setPaging({ total });
|
||||
} catch (e) {
|
||||
// todo
|
||||
console.log('AnTable load fail: ', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
|
@ -160,13 +173,11 @@ export const AnTable = defineComponent({
|
|||
});
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
props.pluginer?.callPageChangeHook(page);
|
||||
setPaging({ current: page });
|
||||
loadData();
|
||||
};
|
||||
|
||||
const onPageSizeChange = (size: number) => {
|
||||
props.pluginer?.callSizeChangeHook(size);
|
||||
setPaging({ current: 1, pageSize: size });
|
||||
loadData();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { FormItem, FormModalUseOptions, useFormModalProps, AnFormModalProps } from '@/components/AnForm';
|
||||
import { merge } from 'lodash-es';
|
||||
import { cloneDeep, merge } from 'lodash-es';
|
||||
import { ExtendFormItem } from './useSearchForm';
|
||||
import { TableUseOptions } from './useTable';
|
||||
|
||||
|
|
@ -23,14 +23,14 @@ export type ModifyForm = Omit<FormModalUseOptions, 'items' | 'trigger'> & {
|
|||
items?: ExtendFormItem[];
|
||||
};
|
||||
|
||||
export function useModifyForm(options: TableUseOptions): AnFormModalProps | undefined {
|
||||
export function useModifyForm(options: TableUseOptions, createModel: Recordable): AnFormModalProps | undefined {
|
||||
const { create, modify } = options;
|
||||
|
||||
if (!modify) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let result: FormModalUseOptions = { items: [] };
|
||||
let result: FormModalUseOptions = { items: [], model: cloneDeep(createModel) };
|
||||
if (modify.extend && create) {
|
||||
result = merge({}, create);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { defaultsDeep, isArray, merge } from 'lodash-es';
|
||||
import { AnFormProps, FormUseOptions, AnFormItemProps, FormItem, useItems } from '@/components/AnForm';
|
||||
import { AnFormProps, FormUseOptions, AnFormItemProps, FormItem, useFormItems } from '@/components/AnForm';
|
||||
|
||||
export type ExtendFormItem = Partial<
|
||||
FormItem & {
|
||||
|
|
@ -14,9 +14,10 @@ export type ExtendFormItem = Partial<
|
|||
}
|
||||
>;
|
||||
|
||||
type SearchFormItem = ExtendFormItem & {
|
||||
export type SearchFormItem = ExtendFormItem & {
|
||||
/**
|
||||
* 是否点击图标后进行搜索
|
||||
* @description 仅 setter: 'search' 类型可用
|
||||
* @default
|
||||
* ```ts
|
||||
* false
|
||||
|
|
@ -33,7 +34,7 @@ type SearchFormItem = ExtendFormItem & {
|
|||
enterable?: boolean;
|
||||
};
|
||||
|
||||
export type SearchFormObject = Omit<FormUseOptions, 'items' | 'submit'> & {
|
||||
export type SearchForm = Omit<FormUseOptions, 'items' | 'submit'> & {
|
||||
/**
|
||||
* 搜索表单项
|
||||
* @example
|
||||
|
|
@ -54,9 +55,10 @@ export type SearchFormObject = Omit<FormUseOptions, 'items' | 'submit'> & {
|
|||
hideSearch?: boolean;
|
||||
};
|
||||
|
||||
export type SearchForm = SearchFormObject | SearchFormItem[];
|
||||
|
||||
export function useSearchForm(search?: SearchForm, extendItems: AnFormItemProps[] = []): AnFormProps | undefined {
|
||||
export function useSearchForm(
|
||||
search?: SearchForm | SearchFormItem[],
|
||||
extendItems: AnFormItemProps[] = []
|
||||
): AnFormProps | undefined {
|
||||
if (!search) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -83,6 +85,7 @@ export function useSearchForm(search?: SearchForm, extendItems: AnFormItemProps[
|
|||
};
|
||||
|
||||
const items: AnFormItemProps[] = [];
|
||||
|
||||
for (const _item of _items) {
|
||||
const { searchable, enterable, field, extend, ...itemRest } = _item;
|
||||
if ((field || extend) === 'submit' && hideSearch) {
|
||||
|
|
@ -95,7 +98,7 @@ export function useSearchForm(search?: SearchForm, extendItems: AnFormItemProps[
|
|||
item = merge({}, extendItem, itemRest);
|
||||
}
|
||||
}
|
||||
if (searchable) {
|
||||
if (searchable && item.setter === 'search') {
|
||||
(item as any).setterProps.onSearch = () => null;
|
||||
}
|
||||
if (enterable) {
|
||||
|
|
@ -107,7 +110,7 @@ export function useSearchForm(search?: SearchForm, extendItems: AnFormItemProps[
|
|||
items.push(item);
|
||||
}
|
||||
|
||||
props.items = useItems(items, props.model);
|
||||
props.items = useFormItems(items, props.model);
|
||||
|
||||
return props;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useFormModalProps } from '@/components/AnForm';
|
||||
import { AnTable, AnTableInstance, AnTableProps } from '../components/Table';
|
||||
import { ModifyForm, useModifyForm } from './useModiyForm';
|
||||
import { SearchForm, useSearchForm } from './useSearchForm';
|
||||
import { SearchForm, SearchFormItem, useSearchForm } from './useSearchForm';
|
||||
import { TableColumn, useTableColumns } from './useTableColumn';
|
||||
import { AnTablePlugin, PluginContainer } from './useTablePlugin';
|
||||
import { UseCreateFormOptions } from './useCreateForm';
|
||||
|
|
@ -46,7 +46,7 @@ export interface TableUseOptions extends Pick<AnTableProps, 'source' | 'tablePro
|
|||
* }]
|
||||
* ```
|
||||
*/
|
||||
search?: SearchForm;
|
||||
search?: SearchForm | SearchFormItem[];
|
||||
/**
|
||||
* 新建弹窗
|
||||
* @example
|
||||
|
|
@ -80,7 +80,7 @@ export function useTableProps(options: TableUseOptions): AnTableProps {
|
|||
const paging = { hide: false, showTotal: true, showPageSize: true, ...(options.paging ?? {}) };
|
||||
const search = options.search && useSearchForm(options.search);
|
||||
const create = options.create && useFormModalProps(options.create);
|
||||
const modify = options.modify && useModifyForm(options);
|
||||
const modify = options.modify && useModifyForm(options, create?.model ?? {} );
|
||||
|
||||
return {
|
||||
tableProps,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ interface TableColumnButton {
|
|||
* @see ALink
|
||||
*/
|
||||
buttonProps?: Recordable;
|
||||
icon?: string;
|
||||
/**
|
||||
* 是否可见
|
||||
* @example
|
||||
|
|
@ -153,9 +154,12 @@ function useTableButtonColumn(column: TableButtonColumn & TableColumnData) {
|
|||
}
|
||||
return (
|
||||
<>
|
||||
{index !== 0 && <Divider direction="vertical" margin={2} />}
|
||||
{index !== 0 && <Divider direction="vertical" margin={4} />}
|
||||
<Link {...item.buttonProps} disabled={item.disable?.(props)} onClick={() => item.onClick?.(props)}>
|
||||
{item.text}
|
||||
{{
|
||||
default: () => item.text,
|
||||
// icon: () => item.icon ? <i class={item.icon}></i> : null
|
||||
}}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,17 +28,25 @@ export interface AnTablePlugin {
|
|||
provide?: Recordable;
|
||||
|
||||
/**
|
||||
* 组件钩子
|
||||
* @description 对应表格组件的 `setup` 钩子
|
||||
* 在表格组件的 `setup` 函数中调用
|
||||
*/
|
||||
onSetup?: (context: AnTableContext) => void;
|
||||
|
||||
/**
|
||||
* 钩子
|
||||
* @description 在处理前进行参数处理
|
||||
*/
|
||||
options?: (options: TableUseOptions) => TableUseOptions | null | undefined | void;
|
||||
|
||||
/**
|
||||
* 解析参数之前调用
|
||||
*/
|
||||
parse?: (options: TableUseOptions) => TableUseOptions | null | undefined | void;
|
||||
|
||||
/**
|
||||
* 解析参数之后调用
|
||||
*/
|
||||
parsed?: (options: any) => any;
|
||||
|
||||
/**
|
||||
* 表格列
|
||||
*/
|
||||
|
|
@ -62,27 +70,37 @@ export interface AnTablePlugin {
|
|||
*/
|
||||
action?: () => (props: any) => any | Component;
|
||||
|
||||
/**
|
||||
* 搜索前处理
|
||||
*
|
||||
*/
|
||||
onBeforeSearch?: (args: { page: number; size: number; [key: string]: any }) => Recordable | null | undefined | void;
|
||||
onPageChange?: (page: number) => void;
|
||||
onSizeChange?: (size: number) => void;
|
||||
onSearch?: (search: Recordable) => any[] | { data: any[]; total: number };
|
||||
|
||||
onLoad?: (search: Recordable) => void;
|
||||
onLoaded?: (res: any) => void;
|
||||
onLoadOk?: (res: any) => void;
|
||||
onLoadFail?: (e: any) => void;
|
||||
}
|
||||
|
||||
const callHookWithData = async (name: string, plugins: AnTablePlugin[], data?: any) => {
|
||||
for (const plugin of plugins) {
|
||||
data = (await (plugin as any)[name]?.(data)) ?? data;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const callHookFirst = async (name: string, plugins: AnTablePlugin[], ...args: any[]) => {
|
||||
for (const plugin of plugins) {
|
||||
const data = await (plugin as any)[name]?.(...args);
|
||||
if (data) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export class PluginContainer {
|
||||
actions: any[] = [];
|
||||
widgets: any[] = [];
|
||||
|
||||
constructor(private plugins: AnTablePlugin[]) {
|
||||
this.plugins.unshift(
|
||||
useTableRefresh(),
|
||||
useColumnConfig(),
|
||||
useRowFormat(),
|
||||
useRowDelete(),
|
||||
useRowModify()
|
||||
);
|
||||
this.plugins.unshift(useTableRefresh(), useRowFormat(), useRowDelete(), useRowModify());
|
||||
for (const plugin of plugins) {
|
||||
const action = plugin.action?.();
|
||||
if (action) {
|
||||
|
|
@ -122,22 +140,19 @@ export class PluginContainer {
|
|||
return options;
|
||||
}
|
||||
|
||||
callBeforeSearchHook(options: any) {
|
||||
for (const plugin of this.plugins) {
|
||||
options = plugin.onBeforeSearch?.(options) ?? options;
|
||||
}
|
||||
return options;
|
||||
callLoadHook(data: any[] | ((...args: any[]) => Promise<any> | any), params: Recordable) {
|
||||
return callHookFirst('onLoad', this.plugins, data, params);
|
||||
}
|
||||
|
||||
callPageChangeHook(page: number) {
|
||||
for (const plugin of this.plugins) {
|
||||
plugin.onPageChange?.(page);
|
||||
}
|
||||
callLoadedHook(res: any) {
|
||||
return callHookWithData('onLoaded', this.plugins, res);
|
||||
}
|
||||
|
||||
callSizeChangeHook(page: number) {
|
||||
for (const plugin of this.plugins) {
|
||||
plugin.onPageChange?.(page);
|
||||
}
|
||||
callLoadOkHook(res: any) {
|
||||
return callHookWithData('onLoadOk', this.plugins, res);
|
||||
}
|
||||
|
||||
callLoadFailHook(res: any) {
|
||||
return callHookWithData('onLoadFail', this.plugins, res);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue