Compare commits

..

15 Commits

Author SHA1 Message Date
绝弹 9436f5feee feat: 优化路由权限逻辑
自动部署 / build (push) Failing after 12s Details
2024-01-11 21:18:49 +08:00
luoer 21de506907 feat: 调整路由以文件夹为单位 2024-01-11 17:36:46 +08:00
luoer 3ae0869386 fix: 临时提交 2024-01-10 17:39:52 +08:00
绝弹 9b15be521e feat: 优化素材页面 2024-01-09 22:34:36 +08:00
luoer 40eeb6899a feat: 添加首页统计demo 2024-01-08 17:33:26 +08:00
绝弹 6bc11b3c95 feat: 添加预览组件 2024-01-08 08:10:03 +08:00
luoer ff389d988d fix: 修复表单弹窗初始化问题 2023-12-26 17:11:02 +08:00
绝弹 984a03c339 feat: 移动素材分类页面 2023-12-21 12:22:23 +08:00
luoer 706aebe7c2 feat: 优化素材页面显示布局 2023-12-20 17:38:27 +08:00
绝弹 943ec54aed feat: 优化路由加载 2023-12-17 23:16:59 +08:00
luoer 43487136fb feat: 临时提交 2023-12-15 17:36:45 +08:00
luoer 6a000652b1 feat: 优化表格逻辑 2023-12-15 17:30:18 +08:00
luoer aac4047c9a feat: 添加图片和视频组件 2023-12-04 17:41:09 +08:00
luoer 01df5849cf feat: 优化其他 2023-11-27 17:23:53 +08:00
luoer 9a15a88eb0 feat: 移除旧的表格表单组件 2023-11-24 17:14:55 +08:00
197 changed files with 6883 additions and 7157 deletions

11
.env
View File

@ -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

View File

@ -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配置

View File

@ -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

View File

@ -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>

View File

@ -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"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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');
}
},
};
}

View File

@ -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,
},
];
},
};
}

View File

@ -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");
}
},
};
}

View File

@ -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>

View File

@ -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?.());

View File

@ -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 };
}
}

View File

@ -11,6 +11,7 @@ export function addAuthInterceptor(axios: AxiosInstance) {
if (userStore.accessToken) {
config.headers.Authorization = `Bearer ${userStore.accessToken}`;
}
// throw Error('dd');
return config;
});
}

View File

@ -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')) {

View File

@ -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);
}
);

View File

@ -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>

View File

@ -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>

View File

@ -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: [],
},
},
});

View File

@ -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%',
},
];

View File

@ -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

View File

@ -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

View File

@ -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,
};
}

View File

@ -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')
}
},
};
}

View File

@ -1,6 +1,10 @@
import { Block } from "../../core";
import { Font } from "../font";
export interface OutputText {
id: string;
}
export interface TextPrams {
/**
*

View File

@ -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

View File

@ -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

View File

@ -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',
},
},
});

View File

@ -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>;

View File

@ -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

View File

@ -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

View File

@ -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)!;

View File

@ -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";

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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) {

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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) {
},
}
]
}

View File

@ -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;
};

View File

@ -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
};

View File

@ -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');
}
}

View File

@ -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>

View File

@ -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;

View File

@ -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}
>
{{

View File

@ -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 };

View File

@ -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>;

View File

@ -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>;

View File

@ -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;
}

View File

@ -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>;

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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,

View File

@ -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;
}

View File

@ -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}

View File

@ -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;

View File

@ -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';

View File

@ -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>
</>
);
},

View File

View File

@ -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 });
}
};

View File

@ -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;
}

View File

@ -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>

View File

@ -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();
};

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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,

View File

@ -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>
</>
);

View File

@ -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