Compare commits

..

No commits in common. "9436f5feee7772bdb1e7026c4c2c4701dc145654" and "d2b8bc2f8e56fec0213fe7ce5c0c3a8d70bc896c" have entirely different histories.

197 changed files with 7163 additions and 6889 deletions

11
.env
View File

@ -2,13 +2,12 @@
# 应用配置 # 应用配置
# ===================================================================================== # =====================================================================================
# 网站标题 # 网站标题
VITE_TITLE = 绝弹管理中心 VITE_TITLE = Appnify
# 网站副标题 # 网站副标题
VITE_SUBTITLE = 绝弹管理中心 VITE_SUBTITLE = 快速开发web应用的模板工具
# 部署路径: 当为 ./ 时路由模式需为 hash # 部署路径: 当为 ./ 时路由模式需为 hash
VITE_BASE = / VITE_BASE = /
# 接口前缀:参见 axios 的 baseURL # 接口前缀:参见 axios 的 baseURL
# VITE_API = http://127.0.0.1:3030/
VITE_API = https://appnify.app.juetan.cn/ VITE_API = https://appnify.app.juetan.cn/
# 首页路径 # 首页路径
VITE_HOME_PATH = /home VITE_HOME_PATH = /home
@ -25,10 +24,8 @@ VITE_PORT = 3020
# 代理前缀 # 代理前缀
VITE_PROXY_PREFIX = /api,/upload VITE_PROXY_PREFIX = /api,/upload
# 代理地址 # 代理地址
VITE_PROXY = https://appnify.app.juetan.cn/ VITE_PROXY = http://127.0.0.1:3030/
# API文档 说明:需返回符合 OPENAPI 规范的json内容 # API文档 说明:需返回符合 OPENAPI 规范的json内容
VITE_OPENAPI = http://127.0.0.1:3030/openapi.json VITE_OPENAPI = http://127.0.0.1:3030/openapi.json
# 文件后缀 说明设为dev时会优先加载index.dev.vue文件否则回退至index.vue文件 # 文件后缀 说明设为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,4 +1,5 @@
FROM node:20-alpine as builder FROM node:20-alpine as builder
# 指定工作目录方便下一阶段引用 # 指定工作目录方便下一阶段引用
WORKDIR /app WORKDIR /app
# 启用pnpm功能(v16+) # 启用pnpm功能(v16+)
@ -13,6 +14,7 @@ COPY . .
RUN pnpm build RUN pnpm build
FROM nginx:alpine FROM nginx:alpine
# 复制产物 # 复制产物
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# 复制nginx配置 # 复制nginx配置

View File

@ -18,12 +18,16 @@
## 快速开始 ## 快速开始
1. 确保本地安装有如下软件(推荐最新版本)。提示Pnpm 在 NodeJS v16+ 版本可通过 corepack enable 命令开启,低版本请通过 npm install pnpm 命令安装 1. 确保本地安装有如下软件,推荐最新版本
```bash ```bash
git # 地址https://git-scm.com/ # 官网https://git-scm.com/
node + pnpm # 地址https://nodejs.org/en git
# 官网https://nodejs.org/en
node + pnpm
``` ```
备注Pnpm 在 NodeJS v16+ 版本可通过 corepack enable 命令开启,低版本请通过 npm install pnpm 命令安装。
2. 拉取模板 2. 拉取模板
@ -49,20 +53,16 @@ pnpm dev
### 路由菜单 ### 路由菜单
基于 [vite-plugin-pages](https://github.com/hannoeru/vite-plugin-pages) 插件。根据 src/pages 目录生成路由数组,然后根据路由数组自动生成菜单数组,导航时根据菜单层级自动生成导航面包屑。 基于 [vite-plugin-pages](https://github.com/hannoeru/vite-plugin-pages) 插件。本项目使用 src/pages 作为路由目录,最终生成的路由仅有 2 级,主要是出于 keepalive 缓存的需要,其中:
根据 src/pages 目录生成路由数组,包含以下以下规则: | 说明 |
| ----------------------------------------------------------------- |
| src/pages 目录下以 _ 开头的文件名/目录名为一级路由,如登陆页面。 |
| src/pages 其他子目录或 .vue 文件为二级路由,如应用首页。 |
- 以文件夹为路由,读取该文件夹下 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"> <route lang="json">
{ {
"parentMeta": { "parentMeta": {
@ -72,9 +72,9 @@ pnpm dev
</route> </route>
``` ```
### 条件加载 ### 文件后缀
基于 [plugin](./scripts/vite/plugin.ts) 内置 VITE 插件,主要用于输出编译信息以及根据不同文件后缀进行打包。在项目根目录下的 .env 配置文件中,可指定以下属性: 在 scripts/vite/plugin.ts 文件中,内置有一个 VITE 插件,主要用于输出编译信息以及根据不同文件后缀进行打包。在项目根目录下的 .env 配置文件中,可指定以下属性:
``` ```
VITE_EXTENSION = my VITE_EXTENSION = my

View File

@ -2,24 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="%VITE_SUBTITLE%" /> <title>%VITE_TITLE% - %VITE_SUBTITLE%</title>
<link rel="icon" href="./favicon.ico" />
<title>%VITE_TITLE%</title>
</head> </head>
<body> <body>
<div id="app" class="dark:bg-slate-900 dark:text-slate-200"> <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> <style>
html, html,
body { body {
@ -29,8 +17,8 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
font-size: 14px; font-size: 14px;
font-family: Inter, '-apple-system', BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'noto sans', font-family: Inter, "-apple-system", BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "noto sans",
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
} }
#app { #app {
width: 100%; width: 100%;
@ -59,9 +47,18 @@
.loading-tip { .loading-tip {
margin-top: 12px; margin-top: 12px;
line-height: 1; line-height: 1;
color: #889; color: #888;
} }
</style> </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> </div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>

View File

@ -12,41 +12,39 @@
"release": "release-it --config ./scripts/release/index.cjs" "release": "release-it --config ./scripts/release/index.cjs"
}, },
"devDependencies": { "devDependencies": {
"@arco-design/web-vue": "^2.54.1", "@arco-design/web-vue": "^2.51.1",
"@iconify-json/icon-park-outline": "^1.1.15", "@iconify-json/icon-park-outline": "^1.1.12",
"@release-it/conventional-changelog": "^8.0.1", "@release-it/conventional-changelog": "^5.1.1",
"@types/ejs": "^3.1.5", "@types/ejs": "^3.1.2",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.9",
"@types/nprogress": "^0.2.3", "@types/nprogress": "^0.2.0",
"@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue": "^4.3.4",
"@vitejs/plugin-vue-jsx": "^3.1.0", "@vitejs/plugin-vue-jsx": "^3.0.2",
"@vueuse/core": "^10.7.1", "@vueuse/core": "^9.13.0",
"axios": "^1.6.5", "axios": "^1.5.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.9",
"dplayer": "^1.27.1",
"ejs": "^3.1.9", "ejs": "^3.1.9",
"less": "^4.2.0", "less": "^4.2.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"monaco-editor": "^0.45.0", "monaco-editor": "^0.44.0",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"pinia": "^2.1.7", "pinia": "^2.1.6",
"pinia-plugin-persistedstate": "^3.2.1", "pinia-plugin-persistedstate": "^3.2.0",
"plop": "^4.0.1", "plop": "^3.1.2",
"release-it": "^17.0.1", "release-it": "^15.11.0",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.9.2",
"swagger-typescript-api": "^13.0.3", "swagger-typescript-api": "^12.0.4",
"tsx": "^4.7.0", "tsx": "^3.12.9",
"typescript": "^5.3.3", "typescript": "^4.9.5",
"unocss": "^0.58.3", "unocss": "^0.49.8",
"unplugin-auto-import": "^0.17.3", "unplugin-auto-import": "^0.13.0",
"unplugin-vue-components": "^0.26.0", "unplugin-vue-components": "^0.23.0",
"unplugin-vue-router": "^0.7.0", "vite": "^4.4.9",
"vite": "^5.0.11", "vite-plugin-pages": "^0.28.0",
"vite-plugin-pages": "^0.32.0",
"vite-plugin-style-import": "^2.0.0", "vite-plugin-style-import": "^2.0.0",
"vue": "^3.4.8", "vue": "^3.3.4",
"vue-router": "^4.2.5", "vue-router": "^4.2.4",
"vue-tsc": "^1.8.27" "vue-tsc": "^1.8.11"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,36 +0,0 @@
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

@ -1,79 +0,0 @@
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,
},
];
},
};
}

97
scripts/vite/plugin.ts Normal file
View File

@ -0,0 +1,97 @@
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> <template>
<a-config-provider> <a-config-provider>
<router-view v-slot="{ Component, route }"> <router-view v-slot="{ Component, route }">
<keep-alive :include="menuStore.caches"> <keep-alive :include="menuStore.cacheTopNames">
<component v-if="hasAuth" :is="Component"></component> <component v-if="hasAuth(route)" :is="Component"></component>
<AnForbidden v-else></AnForbidden> <AnForbidden v-else></AnForbidden>
</keep-alive> </keep-alive>
</router-view> </router-view>
@ -10,36 +10,27 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMenuStore } from '@/store/menu'; import { RouteLocationNormalizedLoaded } from 'vue-router';
import { useUserStore } from '@/store/user'; import { useUserStore } from './store';
import { useMenuStore } from './store/menu';
const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
const menuStore = useMenuStore(); const menuStore = useMenuStore();
const hasAuth = computed(() => { const hasAuth = (route: RouteLocationNormalizedLoaded) => {
return route.matched.every(item => { const aAuth = route.meta.auth;
console.log('i', item); const uAuth = userStore.auth;
const needAuth = item.meta.auth; if (!aAuth?.length) {
const userAuth = userStore.auth; return true;
if (needAuth?.includes('*')) { }
return true; if (aAuth.some(i => i === '*')) {
} return true;
if (!userStore.accessToken && needAuth?.includes('unlogin')) { }
return true; if (uAuth.some(i => aAuth.some(j => j === i))) {
} return true;
if (!userStore.accessToken) { }
return false; return false;
} };
if (!needAuth) {
return true;
}
if (userAuth.some(i => needAuth.some(j => j === i))) {
return true;
}
return false;
});
});
</script> </script>
<style scoped></style> <style scoped></style>

View File

@ -9,7 +9,6 @@ import { env } from '@/config/env';
* @see src/api/instance/instance.ts * @see src/api/instance/instance.ts
*/ */
export const api = new Service({ export const api = new Service({
timeout: 2000,
baseURL: env.apiPrefix, baseURL: env.apiPrefix,
}); });
@ -18,12 +17,12 @@ export const api = new Service({
*/ */
addToastInterceptor(api.instance); addToastInterceptor(api.instance);
/**
*
*/
addExceptionInterceptor(api.instance, () => api.expireHandler?.());
/** /**
* *
*/ */
addAuthInterceptor(api.instance); addAuthInterceptor(api.instance);
/**
*
*/
addExceptionInterceptor(api.instance, () => api.expireHandler?.());

View File

@ -1,7 +1,7 @@
import { IToastOptions } from '@/components'; import { IToastOptions } from "@/components";
import 'axios'; import "axios";
declare module 'axios' { declare module "axios" {
interface AxiosRequestConfig { interface AxiosRequestConfig {
/** /**
* *
@ -26,9 +26,5 @@ declare module 'axios' {
* *
*/ */
reqErrorTip?: boolean | string; reqErrorTip?: boolean | string;
/**
* TODO
*/
tip?: boolean | string | { requestErrorTip?: string; responseErrorTip?: string };
} }
} }

View File

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

View File

@ -7,6 +7,7 @@ const expiredCodes = [4050, 4051];
const resMessageTip = `响应异常,请检查参数或稍后重试!`; const resMessageTip = `响应异常,请检查参数或稍后重试!`;
const resGetMessage = `数据获取失败,请检查网络或稍后重试!`; const resGetMessage = `数据获取失败,请检查网络或稍后重试!`;
const reqMessageTip = `请求失败,请检查网络或稍后重试!`; const reqMessageTip = `请求失败,请检查网络或稍后重试!`;
let logoutTipShowing = false; let logoutTipShowing = false;
/** /**
@ -14,10 +15,6 @@ let logoutTipShowing = false;
* @param axios Axios * @param axios Axios
*/ */
export function addExceptionInterceptor(axios: AxiosInstance, exipreHandler?: (...args: any[]) => any) { export function addExceptionInterceptor(axios: AxiosInstance, exipreHandler?: (...args: any[]) => any) {
/**
*
* ()
*/
axios.interceptors.request.use(null, error => { axios.interceptors.request.use(null, error => {
const msg = error.response?.data?.message; const msg = error.response?.data?.message;
Notification.error({ Notification.error({
@ -27,13 +24,7 @@ export function addExceptionInterceptor(axios: AxiosInstance, exipreHandler?: (.
return Promise.reject(error); return Promise.reject(error);
}); });
/**
*
*/
axios.interceptors.response.use( axios.interceptors.response.use(
/**
*
*/
res => { res => {
const code = res.data?.code; const code = res.data?.code;
if (code && !successCodes.includes(code)) { if (code && !successCodes.includes(code)) {
@ -42,33 +33,25 @@ export function addExceptionInterceptor(axios: AxiosInstance, exipreHandler?: (.
return res; return res;
}, },
error => { error => {
/**
*
*/
if (error.response) { if (error.response) {
const code = error.response.data?.code; const code = error.response.data?.code;
if (expiredCodes.includes(code)) { if (expiredCodes.includes(code)) {
if (!logoutTipShowing) { if (!logoutTipShowing) {
logoutTipShowing = true; logoutTipShowing = true;
Notification.warning({ Notification.warning({
title: '登陆提示', title: '登陆提示',
content: '登陆已过期,请重新登陆!', content: '当前登陆已过期,请重新登陆!',
onClose: () => (logoutTipShowing = false), onClose: () => (logoutTipShowing = false),
}); });
exipreHandler?.(error); exipreHandler?.(error);
} }
return Promise.reject(error); return Promise.reject(error);
} }
const resMsg = error.response?.data?.message;
let message: string | null = resMessageTip; let message: string | null = resMsg ?? resMessageTip;
if (error.config?.method === 'get') { if (error.config?.method === 'get') {
message = resGetMessage; message = resGetMessage;
} }
const resMsg = error.response?.data?.message;
if (resMsg) {
message = resMsg;
}
if (has(error.config, 'resErrorTip')) { if (has(error.config, 'resErrorTip')) {
const tip = error.config.resErrorTip; const tip = error.config.resErrorTip;
if (tip) { if (tip) {
@ -84,9 +67,7 @@ export function addExceptionInterceptor(axios: AxiosInstance, exipreHandler?: (.
}); });
} }
return Promise.reject(error); return Promise.reject(error);
} } else if (error.request) {
if (error.request) {
const resMsg = error.response?.message; const resMsg = error.response?.message;
let message: string | null = resMsg ?? reqMessageTip; let message: string | null = resMsg ?? reqMessageTip;
if (has(error.config, 'reqErrorTip')) { if (has(error.config, 'reqErrorTip')) {

View File

@ -10,12 +10,19 @@ export function addToastInterceptor(axios: AxiosInstance) {
axios.interceptors.request.use( axios.interceptors.request.use(
config => { config => {
if (config.toast) { if (config.toast) {
config.closeToast = toast(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);
} }
return config; return config;
}, },
error => { error => {
error.config?.closeToast?.(); error.config.closeToast?.();
return Promise.reject(error); return Promise.reject(error);
} }
); );
@ -30,7 +37,7 @@ export function addToastInterceptor(axios: AxiosInstance) {
return response; return response;
}, },
error => { error => {
error.config?.closeToast?.(); error.config.closeToast?.();
return Promise.reject(error); return Promise.reject(error);
} }
); );

View File

@ -1,39 +0,0 @@
<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

@ -1,35 +0,0 @@
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

@ -1,33 +0,0 @@
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

@ -1,89 +0,0 @@
<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

@ -1,30 +0,0 @@
<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

@ -1,71 +0,0 @@
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

@ -1,87 +0,0 @@
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,45 +0,0 @@
<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

@ -1,36 +0,0 @@
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

@ -1,19 +0,0 @@
import { Block } from "../../core";
export interface VideoPrams {
type: 'live' | 'file',
url: string;
/**
*
*/
videos: any[];
/**
*
*/
fit: 'cover' | 'contain';
}
/**
*
*/
export type Video = Block<VideoPrams>;

View File

@ -1,97 +0,0 @@
<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

@ -1,27 +0,0 @@
<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

@ -1,81 +0,0 @@
<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

@ -1,147 +0,0 @@
<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

@ -1,172 +0,0 @@
<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

@ -1,56 +0,0 @@
<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

@ -1,56 +0,0 @@
<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,35 +0,0 @@
<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

@ -1,71 +0,0 @@
<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,46 +0,0 @@
<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,108 +0,0 @@
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

@ -5,8 +5,8 @@
<img src="@/assets/403.svg" alt="forbiden" class="w-[320px]" /> <img src="@/assets/403.svg" alt="forbiden" class="w-[320px]" />
</div> </div>
<div> <div>
<h2 class="text-3xl m-0 font-medium">403</h2> <h2 class="text-3xl m-0 font-bold">403</h2>
<p class="mt-3">权限不足如需访问请联系管理员!</p> <p class="mt-2">权限不足如需访问请联系管理员!</p>
<div class="space-x-3 mt-6"> <div class="space-x-3 mt-6">
<a-button type="primary" @click="router.back()"> <a-button type="primary" @click="router.back()">
<template #icon> <template #icon>

View File

@ -1,29 +1,12 @@
import { Form, FormInstance, Message } from '@arco-design/web-vue'; import { Form, FormInstance } from '@arco-design/web-vue';
import { useVModel } from '@vueuse/core'; import { useVModel } from '@vueuse/core';
import { ComputedRef, InjectionKey, PropType, Ref } from 'vue'; import { PropType } from 'vue';
import { initFormItems } from '../utils/useFormItems'; import { FormContextKey } from './useFormContext';
import { FormRef, useFormRef } from '../utils/useFormRef'; import { useFormItems } from './useFormItems';
import { useFormModel } from './useFormModel';
import { useFormRef } from './useFormRef';
import { useFormSubmit } from './useFormSubmit';
import { AnFormItem, AnFormItemProps } from './FormItem'; 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>;
/** /**
* *
@ -67,7 +50,7 @@ export const AnForm = defineComponent({
* ``` * ```
*/ */
submit: { submit: {
type: [Function, Object] as PropType<AnFormSubmit>, type: [String, Function, Object] as PropType<AnFormSubmit>,
}, },
/** /**
* Form * Form
@ -86,61 +69,25 @@ export const AnForm = defineComponent({
setup(props, { slots, emit }) { setup(props, { slots, emit }) {
const model = useVModel(props, 'model', emit); const model = useVModel(props, 'model', emit);
const items = computed(() => props.items); const items = computed(() => props.items);
const initModel = cloneDeep(model.value); const formRefes = useFormRef();
const loading = ref(false); const formModel = useFormModel(model, formRefes.clearValidate);
const { formRef, ...formMethods } = useFormRef(); const formItems = useFormItems(items, model);
const formSubmit = useFormSubmit(props, formRefes.validate, formModel.getModel);
const context = { slots, ...formModel, ...formItems, ...formRefes, ...formSubmit };
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); provide(FormContextKey, context);
onMounted(() => {
initFormItems(props.items, model.value);
});
return context; return context;
}, },
render() { render() {
return ( return (
<Form layout="vertical" {...this.formProps} class="an-form" ref="formRef" model={this.model}> <Form layout="vertical" {...this.$attrs} {...this.formProps} class="an-form" ref="formRef" model={this.model}>
{this.items.map(item => ( {this.items.map(item => (
<AnFormItem key={item.field} item={item} items={this.items} model={this.model}></AnFormItem> <AnFormItem key={item.field} item={item} items={this.items} model={this.model}></AnFormItem>
))} ))}
{this.submitItem()} {this.$slots.submit?.(this.model, this.validate) ||
(this.submit && this.submitItem && (
<AnFormItem item={this.submitItem} items={this.items} model={this.model}></AnFormItem>
))}
</Form> </Form>
); );
}, },
@ -152,4 +99,4 @@ export type AnFormProps = Pick<AnFormInstance['$props'], 'model' | 'items' | 'su
export type AnFormSubmitFn = (model: Recordable, items: AnFormItemProps[]) => any; export type AnFormSubmitFn = (model: Recordable, items: AnFormItemProps[]) => any;
export type AnFormSubmit = AnFormSubmitFn | AnFormItemProps; export type AnFormSubmit = string | AnFormSubmitFn;

View File

@ -1,18 +1,19 @@
import { Button, ButtonInstance, FormInstance, Message, Modal } from '@arco-design/web-vue'; import { useVisible } from '@/hooks/useVisible';
import { useVModel } from '@vueuse/core'; import { Button, ButtonInstance, FormInstance, Modal } from '@arco-design/web-vue';
import { InjectionKey, PropType, Ref } from 'vue'; import { InjectionKey, PropType, Ref } from 'vue';
import { getModel, setModel } from '../utils/useFormModel'; import { useModalSubmit } from './useModalSubmit';
import { AnForm, AnFormInstance, AnFormSubmit } from './Form'; import { useModalTrigger } from './useModalTrigger';
import { AnForm, AnFormInstance, AnFormProps, AnFormSubmit } from './Form';
import { AnFormItemProps } from './FormItem'; import { AnFormItemProps } from './FormItem';
import { cloneDeep } from 'lodash-es'; import { useVModel } from '@vueuse/core';
export interface AnFormModalContext { export interface AnFormModalContext {
visible: Ref<boolean>; visible: Ref<boolean>;
loading: Ref<boolean>; loading: Ref<boolean>;
anFormRef: Ref<AnFormInstance | null>; formRef: Ref<AnFormInstance | null>;
submitForm: () => any | Promise<any>;
open: (data: Recordable) => void; open: (data: Recordable) => void;
close: () => void; close: () => void;
submitForm: () => any | Promise<any>;
modalTitle: () => any; modalTitle: () => any;
modalTrigger: () => any; modalTrigger: () => any;
onClose: () => void; onClose: () => void;
@ -96,7 +97,7 @@ export const AnFormModal = defineComponent({
* ``` * ```
*/ */
submit: { submit: {
type: [Object, Function] as PropType<AnFormSubmit>, type: [String, Function] as PropType<AnFormSubmit>,
}, },
/** /**
* Form * Form
@ -113,11 +114,25 @@ export const AnFormModal = defineComponent({
}, },
emits: ['update:model', 'submited'], emits: ['update:model', 'submited'],
setup(props, { emit }) { setup(props, { emit }) {
const formRef = ref<AnFormInstance | null>(null);
const model = useVModel(props, 'model', emit); const model = useVModel(props, 'model', emit);
const originModel = cloneDeep(model.value);
const anFormRef = ref<AnFormInstance | null>(null);
const visible = ref(false); const visible = ref(false);
const loading = 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 modalTitle = () => { const modalTitle = () => {
if (typeof props.title === 'string') { if (typeof props.title === 'string') {
@ -126,72 +141,10 @@ export const AnFormModal = defineComponent({
return <props.title model={props.model} items={props.items}></props.title>; 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 = { const context: AnFormModalContext = {
visible, visible,
loading, loading,
anFormRef, formRef,
open, open,
close, close,
onClose, onClose,
@ -202,9 +155,7 @@ export const AnFormModal = defineComponent({
provide(AnFormModalContextKey, context); provide(AnFormModalContextKey, context);
return { return context;
...context
};
}, },
render() { render() {
return ( return (
@ -213,11 +164,11 @@ export const AnFormModal = defineComponent({
<Modal <Modal
titleAlign="start" titleAlign="start"
closable={false} closable={false}
{...this.$attrs}
{...this.modalProps} {...this.modalProps}
v-model:visible={this.visible} v-model:visible={this.visible}
class="an-form-modal" class="an-form-modal"
maskClosable={false} maskClosable={false}
unmountOnClose={true}
onClose={this.onClose} onClose={this.onClose}
> >
{{ {{

View File

@ -1,18 +1,9 @@
import setterMap from '../setters'; import setterMap from '../setters';
/**
*
*/
export type SetterMap = typeof setterMap; export type SetterMap = typeof setterMap;
/**
*
*/
export type SetterType = keyof SetterMap; export type SetterType = keyof SetterMap;
/**
*
*/
export type SetterItemMap = { export type SetterItemMap = {
[key in SetterType]: { [key in SetterType]: {
/** /**
@ -42,11 +33,6 @@ 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 }; export { setterMap };

View File

@ -0,0 +1,14 @@
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

@ -0,0 +1,59 @@
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

@ -0,0 +1,93 @@
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

@ -0,0 +1,65 @@
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

@ -0,0 +1,41 @@
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

@ -0,0 +1,33 @@
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 { merge } from 'lodash-es';
import { AnForm, AnFormInstance, AnFormProps } from '../components/Form'; import { AnForm, AnFormInstance, AnFormProps } from '../components/Form';
import { FormItem, useFormItems } from './useFormItems'; import { FormItem, useItems } from './useItems';
export type FormUseOptions = Partial<Omit<AnFormProps, 'items'>> & { 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> { export function useFormProps(options: FormUseOptions): Required<AnFormProps> {
const { model: _model = {}, items: _items = [], submit = () => null, formProps = {} } = options; const { model: _model = {}, items: _items = [], submit = () => null, formProps = {} } = options;
const model = merge({ id: undefined }, _model); const model = merge({ id: undefined }, _model);
const items = useFormItems(_items ?? [], model); const items = useItems(_items ?? [], model);
return { return {
model, model,
items, items,

View File

@ -1,7 +1,7 @@
import { merge } from 'lodash-es'; import { merge } from 'lodash-es';
import { AnFormModal, AnFormModalProps } from '../components/FormModal'; import { AnFormModal, AnFormModalProps } from '../components/FormModal';
import { useFormProps } from './useForm'; import { useFormProps } from './useForm';
import { FormItem } from './useFormItems'; import { FormItem } from './useItems';
export type FormModalUseOptions = Partial<Omit<AnFormModalProps, 'items'>> & { export type FormModalUseOptions = Partial<Omit<AnFormModalProps, 'items'>> & {
/** /**
@ -13,14 +13,6 @@ export type FormModalUseOptions = Partial<Omit<AnFormModalProps, 'items'>> & {
* ``` * ```
*/ */
width?: number; width?: number;
/**
* modal
* @example
* ```ts
* 1080
* ```
*/
modalWidth?: number;
/** /**
* *
* @description `formProps.class` 便 * @description `formProps.class` 便
@ -66,7 +58,7 @@ export function useFormModalProps(options: FormModalUseOptions): AnFormModalProp
export function useFormModal(options: FormModalUseOptions) { export function useFormModal(options: FormModalUseOptions) {
const modalRef = ref<InstanceType<typeof AnFormModal> | null>(null); const modalRef = ref<InstanceType<typeof AnFormModal> | null>(null);
const formRef = computed(() => modalRef.value?.anFormRef); const formRef = computed(() => modalRef.value?.formRef);
const open = (data: Recordable = {}) => modalRef.value?.open(data); const open = (data: Recordable = {}) => modalRef.value?.open(data);
const rawProps = useFormModalProps(options); const rawProps = useFormModalProps(options);
const props = reactive(rawProps); const props = reactive(rawProps);
@ -75,7 +67,7 @@ export function useFormModal(options: FormModalUseOptions) {
<AnFormModal <AnFormModal
ref={(el: any) => (modalRef.value = el)} ref={(el: any) => (modalRef.value = el)}
title={props.title} title={props.title}
trigger={props.trigger} trigger={props.title}
modalProps={props.modalProps as any} modalProps={props.modalProps as any}
model={props.model} model={props.model}
items={props.items} items={props.items}

View File

@ -1,7 +1,7 @@
import { defaultsDeep, has, merge, omit } from 'lodash-es'; import { defaultsDeep, merge, omit } from 'lodash-es';
import { AnFormItemProps, AnFormItemPropsBase } from '../components/FormItem'; import { AnFormItemProps, AnFormItemPropsBase } from '../components/FormItem';
import { SetterItem, setterMap } from '../components/FormSetter'; import { SetterItem, setterMap } from '../components/FormSetter';
import { Rule, useFormRules } from './useFormRules'; import { Rule, useRules } from './useRules';
/** /**
* *
@ -34,26 +34,17 @@ export type FormItem = Omit<AnFormItemPropsBase, 'rules'> &
* ``` * ```
*/ */
rules?: Rule[]; rules?: Rule[];
/**
* `setterProps.placeholder`
* @example
* ```ts
* '请输入用户名称'
* ```
*/
placeholder?: string | string[];
}; };
const ITEM: Partial<FormItem> = { const ITEM: Partial<FormItem> = {
setter: 'input', setter: 'input',
}; };
export function useFormItems(items: FormItem[], model: Recordable) { export function useItems(list: FormItem[], model: Recordable) {
const data: AnFormItemProps[] = []; const items: AnFormItemProps[] = [];
for (const item of items) { for (const item of list) {
let target: AnFormItemProps = defaultsDeep({}, ITEM); let target: any = defaultsDeep({}, ITEM);
if (!item.setter || typeof item.setter === 'string') { if (!item.setter || typeof item.setter === 'string') {
const setter = setterMap[item.setter ?? 'input']; const setter = setterMap[item.setter ?? 'input'];
@ -62,23 +53,16 @@ export function useFormItems(items: FormItem[], model: Recordable) {
} }
} }
target = merge(target, omit(item, ['required', 'rules', 'value', 'placeholder'])); target = merge(target, omit(item, ['required', 'rules', 'value']));
if (item.required || item.rules) { const rules = useRules(item);
const rules = useFormRules(item)!; if (rules) {
target.rules = rules; target.rules = rules;
} }
if (target.setterProps && has(item, 'placeholder')) { model[item.field] = model[item.field] ?? item.value;
(target.setterProps as Recordable).placholder = item.placeholder; items.push(target);
}
if (!has(model, item.field)) {
model[item.field] = item.value;
}
data.push(target);
} }
return data; return items;
} }

View File

@ -70,7 +70,7 @@ function defineRuleMap<T extends Record<string, FieldRule>>(ruleMap: T) {
* @param item * @param item
* @returns * @returns
*/ */
export const useFormRules = <T extends { required?: boolean; rules?: Rule[] }>(item: T) => { export const useRules = <T extends { required?: boolean; rules?: Rule[] }>(item: T) => {
const data: AnFormItemRule[] = []; const data: AnFormItemRule[] = [];
const { required, rules } = item; const { required, rules } = item;

View File

@ -2,11 +2,15 @@ export * from './components/Form';
export * from './components/FormItem'; export * from './components/FormItem';
export * from './components/FormModal'; export * from './components/FormModal';
export * from './components/FormSetter'; export * from './components/FormSetter';
export * from './utils/useFormItems'; export * from './components/useFormContext';
export * from './utils/useFormModel'; export * from './components/useFormItems';
export * from './utils/useFormRef'; export * from './components/useFormModel';
export * from './components/useFormRef';
export * from './components/useFormSubmit';
export * from './components/useModalSubmit';
export * from './components/useModalTrigger';
export * from './hooks/useForm'; export * from './hooks/useForm';
export * from './hooks/useFormModal'; export * from './hooks/useFormModal';
export * from './hooks/useFormItems'; export * from './hooks/useItems';
export * from './hooks/useFormRules'; export * from './hooks/useRules';
export * from './setters'; export * from './setters';

View File

@ -1,16 +1,18 @@
import { Button } from '@arco-design/web-vue'; import { Button } from '@arco-design/web-vue';
import { FormContextKey } from '../components/Form'; import { FormContextKey } from '../components/useFormContext';
import { defineSetter } from './util'; import { defineSetter } from './util';
export default defineSetter<{}, 'none'>({ export default defineSetter<{}, 'none'>({
setter() { setter() {
const { submitForm, resetForm } = inject(FormContextKey)!; const { loading, submitForm, resetModel } = inject(FormContextKey)!;
return ( return (
<> <>
<Button type="primary" onClick={submitForm} class="mr-3"> <Button type="primary" loading={loading.value} onClick={submitForm} class="mr-3">
</Button> </Button>
<Button onClick={resetForm}></Button> <Button disabled={loading.value} onClick={resetModel}>
</Button>
</> </>
); );
}, },

View File

@ -1,13 +0,0 @@
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

@ -1,89 +0,0 @@
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

@ -1,34 +0,0 @@
<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,25 +5,17 @@ import {
AnFormModalInstance, AnFormModalInstance,
AnFormModalProps, AnFormModalProps,
AnFormProps, AnFormProps,
getModel,
} from '@/components/AnForm'; } from '@/components/AnForm';
import AnEmpty from '@/components/AnEmpty/AnEmpty.vue'; import AnEmpty from '@/components/AnEmpty/AnEmpty.vue';
import { Button, PaginationProps, Table, TableColumnData, TableData, TableInstance } from '@arco-design/web-vue'; import { Button, PaginationProps, Table, TableColumnData, TableData, TableInstance } from '@arco-design/web-vue';
import { isArray, isFunction, merge } from 'lodash-es'; import { isArray, isFunction, merge } from 'lodash-es';
import { InjectionKey, PropType, Ref, VNodeChild, defineComponent, ref } from 'vue'; import { InjectionKey, PropType, Ref, defineComponent, ref } from 'vue';
import { PluginContainer } from '../hooks/useTablePlugin'; import { PluginContainer } from '../hooks/useTablePlugin';
type DataFn = (filter: { page: number; size: number; [key: string]: any }) => any | Promise<any>; 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 const AnTableContextKey = Symbol('AnTableContextKey') as InjectionKey<AnTableContext>;
export type TableColumnRender = (data: { record: TableData; column: TableColumnData; rowIndex: number }) => VNodeChild;
/** /**
* *
*/ */
@ -72,7 +64,9 @@ export const AnTable = defineComponent({
* Table * Table
*/ */
tableProps: { tableProps: {
type: Object as PropType<ArcoTableProps>, type: Object as PropType<
Omit<TableInstance['$props'], 'ref' | 'pagination' | 'loading' | 'data' | 'onPageChange' | 'onPageSizeChange'>
>,
}, },
/** /**
* *
@ -122,7 +116,7 @@ export const AnTable = defineComponent({
} }
const paging = getPaging(); const paging = getPaging();
const search = getModel(props.search?.model ?? {}); const search = searchRef.value?.getModel() ?? {};
if (isArray(props.source)) { if (isArray(props.source)) {
// todo // todo
@ -132,20 +126,13 @@ export const AnTable = defineComponent({
try { try {
loading.value = true; loading.value = true;
let params = { ...search, ...paging }; let params = { ...search, ...paging };
let resData = (await props.pluginer?.callLoadHook(props.source, params)) || (await props.source(params)); params = props.pluginer?.callBeforeSearchHook(params) ?? params;
let data: any[] = []; const resData = await props.source(params);
let total = 0; const { data = [], total = 0 } = resData?.data || {};
if (isArray(resData)) {
data = resData;
total = resData.length;
} else {
data = resData.data.data;
total = resData.data.total;
}
renderData.value = data; renderData.value = data;
setPaging({ total }); setPaging({ total });
} catch (e) { } catch (e) {
console.log('AnTable load fail: ', e); // todo
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -173,11 +160,13 @@ export const AnTable = defineComponent({
}); });
const onPageChange = (page: number) => { const onPageChange = (page: number) => {
props.pluginer?.callPageChangeHook(page);
setPaging({ current: page }); setPaging({ current: page });
loadData(); loadData();
}; };
const onPageSizeChange = (size: number) => { const onPageSizeChange = (size: number) => {
props.pluginer?.callSizeChangeHook(size);
setPaging({ current: 1, pageSize: size }); setPaging({ current: 1, pageSize: size });
loadData(); loadData();
}; };

View File

@ -1,5 +1,5 @@
import { FormItem, FormModalUseOptions, useFormModalProps, AnFormModalProps } from '@/components/AnForm'; import { FormItem, FormModalUseOptions, useFormModalProps, AnFormModalProps } from '@/components/AnForm';
import { cloneDeep, merge } from 'lodash-es'; import { merge } from 'lodash-es';
import { ExtendFormItem } from './useSearchForm'; import { ExtendFormItem } from './useSearchForm';
import { TableUseOptions } from './useTable'; import { TableUseOptions } from './useTable';
@ -23,14 +23,14 @@ export type ModifyForm = Omit<FormModalUseOptions, 'items' | 'trigger'> & {
items?: ExtendFormItem[]; items?: ExtendFormItem[];
}; };
export function useModifyForm(options: TableUseOptions, createModel: Recordable): AnFormModalProps | undefined { export function useModifyForm(options: TableUseOptions): AnFormModalProps | undefined {
const { create, modify } = options; const { create, modify } = options;
if (!modify) { if (!modify) {
return undefined; return undefined;
} }
let result: FormModalUseOptions = { items: [], model: cloneDeep(createModel) }; let result: FormModalUseOptions = { items: [] };
if (modify.extend && create) { if (modify.extend && create) {
result = merge({}, create); result = merge({}, create);
} }

View File

@ -1,5 +1,5 @@
import { defaultsDeep, isArray, merge } from 'lodash-es'; import { defaultsDeep, isArray, merge } from 'lodash-es';
import { AnFormProps, FormUseOptions, AnFormItemProps, FormItem, useFormItems } from '@/components/AnForm'; import { AnFormProps, FormUseOptions, AnFormItemProps, FormItem, useItems } from '@/components/AnForm';
export type ExtendFormItem = Partial< export type ExtendFormItem = Partial<
FormItem & { FormItem & {
@ -14,10 +14,9 @@ export type ExtendFormItem = Partial<
} }
>; >;
export type SearchFormItem = ExtendFormItem & { type SearchFormItem = ExtendFormItem & {
/** /**
* *
* @description setter: 'search'
* @default * @default
* ```ts * ```ts
* false * false
@ -34,7 +33,7 @@ export type SearchFormItem = ExtendFormItem & {
enterable?: boolean; enterable?: boolean;
}; };
export type SearchForm = Omit<FormUseOptions, 'items' | 'submit'> & { export type SearchFormObject = Omit<FormUseOptions, 'items' | 'submit'> & {
/** /**
* *
* @example * @example
@ -55,10 +54,9 @@ export type SearchForm = Omit<FormUseOptions, 'items' | 'submit'> & {
hideSearch?: boolean; hideSearch?: boolean;
}; };
export function useSearchForm( export type SearchForm = SearchFormObject | SearchFormItem[];
search?: SearchForm | SearchFormItem[],
extendItems: AnFormItemProps[] = [] export function useSearchForm(search?: SearchForm, extendItems: AnFormItemProps[] = []): AnFormProps | undefined {
): AnFormProps | undefined {
if (!search) { if (!search) {
return undefined; return undefined;
} }
@ -85,7 +83,6 @@ export function useSearchForm(
}; };
const items: AnFormItemProps[] = []; const items: AnFormItemProps[] = [];
for (const _item of _items) { for (const _item of _items) {
const { searchable, enterable, field, extend, ...itemRest } = _item; const { searchable, enterable, field, extend, ...itemRest } = _item;
if ((field || extend) === 'submit' && hideSearch) { if ((field || extend) === 'submit' && hideSearch) {
@ -98,7 +95,7 @@ export function useSearchForm(
item = merge({}, extendItem, itemRest); item = merge({}, extendItem, itemRest);
} }
} }
if (searchable && item.setter === 'search') { if (searchable) {
(item as any).setterProps.onSearch = () => null; (item as any).setterProps.onSearch = () => null;
} }
if (enterable) { if (enterable) {
@ -110,7 +107,7 @@ export function useSearchForm(
items.push(item); items.push(item);
} }
props.items = useFormItems(items, props.model); props.items = useItems(items, props.model);
return props; return props;
} }

View File

@ -1,7 +1,7 @@
import { useFormModalProps } from '@/components/AnForm'; import { useFormModalProps } from '@/components/AnForm';
import { AnTable, AnTableInstance, AnTableProps } from '../components/Table'; import { AnTable, AnTableInstance, AnTableProps } from '../components/Table';
import { ModifyForm, useModifyForm } from './useModiyForm'; import { ModifyForm, useModifyForm } from './useModiyForm';
import { SearchForm, SearchFormItem, useSearchForm } from './useSearchForm'; import { SearchForm, useSearchForm } from './useSearchForm';
import { TableColumn, useTableColumns } from './useTableColumn'; import { TableColumn, useTableColumns } from './useTableColumn';
import { AnTablePlugin, PluginContainer } from './useTablePlugin'; import { AnTablePlugin, PluginContainer } from './useTablePlugin';
import { UseCreateFormOptions } from './useCreateForm'; import { UseCreateFormOptions } from './useCreateForm';
@ -46,7 +46,7 @@ export interface TableUseOptions extends Pick<AnTableProps, 'source' | 'tablePro
* }] * }]
* ``` * ```
*/ */
search?: SearchForm | SearchFormItem[]; search?: SearchForm;
/** /**
* *
* @example * @example
@ -80,7 +80,7 @@ export function useTableProps(options: TableUseOptions): AnTableProps {
const paging = { hide: false, showTotal: true, showPageSize: true, ...(options.paging ?? {}) }; const paging = { hide: false, showTotal: true, showPageSize: true, ...(options.paging ?? {}) };
const search = options.search && useSearchForm(options.search); const search = options.search && useSearchForm(options.search);
const create = options.create && useFormModalProps(options.create); const create = options.create && useFormModalProps(options.create);
const modify = options.modify && useModifyForm(options, create?.model ?? {} ); const modify = options.modify && useModifyForm(options);
return { return {
tableProps, tableProps,

View File

@ -48,7 +48,6 @@ interface TableColumnButton {
* @see ALink * @see ALink
*/ */
buttonProps?: Recordable; buttonProps?: Recordable;
icon?: string;
/** /**
* *
* @example * @example
@ -154,12 +153,9 @@ function useTableButtonColumn(column: TableButtonColumn & TableColumnData) {
} }
return ( return (
<> <>
{index !== 0 && <Divider direction="vertical" margin={4} />} {index !== 0 && <Divider direction="vertical" margin={2} />}
<Link {...item.buttonProps} disabled={item.disable?.(props)} onClick={() => item.onClick?.(props)}> <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> </Link>
</> </>
); );

View File

@ -28,25 +28,17 @@ export interface AnTablePlugin {
provide?: Recordable; provide?: Recordable;
/** /**
* `setup` *
* @description `setup`
*/ */
onSetup?: (context: AnTableContext) => void; onSetup?: (context: AnTableContext) => void;
/** /**
* *
* @description
*/ */
options?: (options: TableUseOptions) => TableUseOptions | null | undefined | void; options?: (options: TableUseOptions) => TableUseOptions | null | undefined | void;
/**
*
*/
parse?: (options: TableUseOptions) => TableUseOptions | null | undefined | void;
/**
*
*/
parsed?: (options: any) => any;
/** /**
* *
*/ */
@ -70,37 +62,27 @@ export interface AnTablePlugin {
*/ */
action?: () => (props: any) => any | Component; action?: () => (props: any) => any | Component;
onSearch?: (search: Recordable) => any[] | { data: any[]; total: number }; /**
*
onLoad?: (search: Recordable) => void; *
onLoaded?: (res: any) => void; */
onLoadOk?: (res: any) => void; onBeforeSearch?: (args: { page: number; size: number; [key: string]: any }) => Recordable | null | undefined | void;
onLoadFail?: (e: any) => void; onPageChange?: (page: number) => void;
onSizeChange?: (size: number) => 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 { export class PluginContainer {
actions: any[] = []; actions: any[] = [];
widgets: any[] = []; widgets: any[] = [];
constructor(private plugins: AnTablePlugin[]) { constructor(private plugins: AnTablePlugin[]) {
this.plugins.unshift(useTableRefresh(), useRowFormat(), useRowDelete(), useRowModify()); this.plugins.unshift(
useTableRefresh(),
useColumnConfig(),
useRowFormat(),
useRowDelete(),
useRowModify()
);
for (const plugin of plugins) { for (const plugin of plugins) {
const action = plugin.action?.(); const action = plugin.action?.();
if (action) { if (action) {
@ -140,19 +122,22 @@ export class PluginContainer {
return options; return options;
} }
callLoadHook(data: any[] | ((...args: any[]) => Promise<any> | any), params: Recordable) { callBeforeSearchHook(options: any) {
return callHookFirst('onLoad', this.plugins, data, params); for (const plugin of this.plugins) {
options = plugin.onBeforeSearch?.(options) ?? options;
}
return options;
} }
callLoadedHook(res: any) { callPageChangeHook(page: number) {
return callHookWithData('onLoaded', this.plugins, res); for (const plugin of this.plugins) {
plugin.onPageChange?.(page);
}
} }
callLoadOkHook(res: any) { callSizeChangeHook(page: number) {
return callHookWithData('onLoadOk', this.plugins, res); for (const plugin of this.plugins) {
} plugin.onPageChange?.(page);
}
callLoadFailHook(res: any) {
return callHookWithData('onLoadFail', this.plugins, res);
} }
} }

View File

@ -19,8 +19,8 @@ export function useRowModify(): AnTablePlugin {
} }
const onClick = btn.onClick; const onClick = btn.onClick;
btn.onClick = async props => { btn.onClick = async props => {
const data = (await onClick?.(props)) ?? props.record; const { modifyRef } = ctx ?? {};
ctx.modifyRef.value?.open(data); modifyRef?.value?.open(props.record);
}; };
} }
}, },

View File

@ -1,27 +1,16 @@
import { Ref } from 'vue'; import { Ref } from 'vue';
import { AnTableContext, ArcoTableProps } from '../components/Table'; import { AnTableContext } from '../components/Table';
import { AnTablePlugin } from '../hooks/useTablePlugin'; import { AnTablePlugin } from '../hooks/useTablePlugin';
import { useTableSelect } from './useTableSelect'; import { useTableSelect } from './useTableSelect';
import { delConfirm, delOptions, sleep } from '@/utils'; import { delConfirm, delOptions, sleep } from '@/utils';
import { Button, Message, TableInstance } from '@arco-design/web-vue'; import { Button, Message } from '@arco-design/web-vue';
interface UseTableDeleteOptions { export function useTableDelete(): AnTablePlugin {
confirm?: string;
onDelete?: (keys: (string | number)[]) => any | Promise<any>;
}
export function useTableDelete(options: UseTableDeleteOptions = {}): AnTablePlugin {
let selected: Ref<any[]>; let selected: Ref<any[]>;
let context: AnTableContext; let context: AnTableContext;
let tableProps: ArcoTableProps;
const { confirm, onDelete } = options;
return { return {
id: 'deletemany', id: 'deletemany',
onSetup(ctx) {
context = ctx;
tableProps = ctx.props.tableProps!;
},
options(options) { options(options) {
let selectPlugin = options.plugins?.find(i => i.id === 'selection'); let selectPlugin = options.plugins?.find(i => i.id === 'selection');
if (!selectPlugin) { if (!selectPlugin) {
@ -31,31 +20,18 @@ export function useTableDelete(options: UseTableDeleteOptions = {}): AnTablePlug
selected = selectPlugin.provide!.selectedKeys; selected = selectPlugin.provide!.selectedKeys;
return options; return options;
}, },
onLoaded() {
console.log('loaded');
selected.value = [];
},
action() { action() {
const onClick = async (props: any) => { const onClick = async (props: any) => {
delConfirm({ delConfirm({
...delOptions, ...delOptions,
content: confirm ?? '危险操作,确定删除所选数据吗?', content: '危险操作,确定删除所选数据吗?',
async onBeforeOk() { async onBeforeOk() {
await sleep(3000); await sleep(3000);
try { const res: any = await onClick?.(props);
const res: any = await onDelete?.(props); const msg = res?.data?.message;
const msg = res?.data?.message; msg && Message.success(`提示: ${msg}`);
msg && Message.success(`提示: ${msg}`); selected.value = [];
if (tableProps) { context.refresh();
(tableProps as any).selectedKeys = [];
}
selected.value = [];
context.refresh();
return true;
} catch (e) {
console.log('删除失败:', e);
}
return false;
}, },
}); });
}; };

View File

@ -18,7 +18,7 @@ export function useTableRefresh(): AnTablePlugin {
return () => { return () => {
const { loading, refresh } = context; const { loading, refresh } = context;
return ( return (
<Button loading={loading.value} onClick={refresh}> <Button disabled={loading.value} onClick={refresh}>
{{ {{
icon: () => <span class="icon-park-outline-redo"></span>, icon: () => <span class="icon-park-outline-redo"></span>,
}} }}

View File

@ -1,7 +1,12 @@
import { cloneDeep, defaultsDeep, merge } from 'lodash-es'; import { cloneDeep, defaultsDeep, merge } from 'lodash-es';
import { TableUseOptions } from '../hooks/useTable'; import { TableUseOptions } from '../hooks/useTable';
import { AnTablePlugin } from '../hooks/useTablePlugin'; import { AnTablePlugin } from '../hooks/useTablePlugin';
import { AnTableContext, ArcoTableProps } from '../components/Table';
// declare module '@/components/AnTable/hooks/useTable' {
// interface TableUseOptions {
// todo?: string;
// }
// }
const defaults: TableUseOptions = { const defaults: TableUseOptions = {
tableProps: { tableProps: {
@ -21,15 +26,8 @@ interface UseTableSelectOptions {
* @description 使 * @description 使
*/ */
export function useTableSelect({ key = 'id', mode = 'key' }: UseTableSelectOptions = {}): AnTablePlugin { export function useTableSelect({ key = 'id', mode = 'key' }: UseTableSelectOptions = {}): AnTablePlugin {
let context: AnTableContext;
const selectedKeys = ref<(string | number)[]>([]); const selectedKeys = ref<(string | number)[]>([]);
const selectedRows = ref<any[]>([]); const selectedRows = ref<any[]>([]);
const setKeys = (keys: any[]) => {
const tableProps = context.props.tableProps;
if (tableProps) {
(tableProps as any).selectedKeys = keys;
}
};
return { return {
id: 'selection', id: 'selection',
@ -37,9 +35,6 @@ export function useTableSelect({ key = 'id', mode = 'key' }: UseTableSelectOptio
selectedKeys, selectedKeys,
selectedRows, selectedRows,
}, },
onSetup(ctx) {
context = ctx;
},
options(options) { options(options) {
const opts: TableUseOptions = defaultsDeep({}, defaults); const opts: TableUseOptions = defaultsDeep({}, defaults);
@ -50,8 +45,6 @@ export function useTableSelect({ key = 'id', mode = 'key' }: UseTableSelectOptio
if (mode === 'both' || mode === 'key') { if (mode === 'both' || mode === 'key') {
opts.tableProps!.onSelectionChange = rowkeys => { opts.tableProps!.onSelectionChange = rowkeys => {
selectedKeys.value = rowkeys; selectedKeys.value = rowkeys;
setKeys(rowkeys);
console.log(rowkeys);
}; };
} }
@ -61,7 +54,6 @@ export function useTableSelect({ key = 'id', mode = 'key' }: UseTableSelectOptio
if (index > -1) { if (index > -1) {
selectedRows.value.splice(index, 1); selectedRows.value.splice(index, 1);
} }
setKeys(selectedRows.value.map(i => i.id));
}; };
opts.tableProps!.onSelectAll = checked => { opts.tableProps!.onSelectAll = checked => {
if (checked) { if (checked) {
@ -69,14 +61,10 @@ export function useTableSelect({ key = 'id', mode = 'key' }: UseTableSelectOptio
} else { } else {
selectedRows.value = []; selectedRows.value = [];
} }
setKeys(selectedRows.value.map(i => i.id));
}; };
} }
return merge(options, opts); return merge(options, opts);
}, },
onLoaded() {
setKeys([]);
},
}; };
} }

View File

@ -1,61 +0,0 @@
<template>
<div class="audio-player flex items-center gap-4 bg-[rgba(255,255,255,.1)] text-white px-4 py-4">
<div
@click="playing = !playing"
class="hover:bg-[rgba(255,255,255,.1)] h-8 px-1.5 flex items-center justify-center rounded"
>
<i v-if="playing" class="text-xl icon-park-outline-pause-one"></i>
<i v-else class="text-xl icon-park-outline-play"></i>
</div>
<div>
{{ currentFormated }}
</div>
<div class="w-96">
<audio ref="audioRef" src="" class="hidden"></audio>
<a-slider class="block!"></a-slider>
</div>
<div>
{{ durationFormated }}
</div>
<div class="dd">
<a-popover>
<div class="text-xl hover:bg-[rgba(255,255,255,.1)] h-8 px-1.5 flex items-center justify-center rounded">
<i :class="volumeIcon"></i>
</div>
<template #content>
<div class="flex flex-col items-center">
<div class="w-6 text-center">
{{ volume }}
</div>
<a-slider class="min-w-auto!" v-model="volume" direction="vertical"></a-slider>
</div>
</template>
</a-popover>
</div>
</div>
</template>
<script setup lang="ts">
import numeral from 'numeral';
const playing = ref(true);
const volume = ref(50);
const volumeLast = ref(50);
const current = ref(18);
const duration = ref(120);
const currentFormated = computed(() => numeral(current.value).format('0:00'));
const durationFormated = computed(() => numeral(duration.value).format('0:00'));
const volumeIcon = computed(() => {
if (volume.value <= 0) {
return 'icon-park-outline-volume-mute';
} else if (volume.value <= 50) {
return 'icon-park-outline-volume-small';
} else {
return 'icon-park-outline-volume-notice';
}
});
</script>
<style scoped></style>

View File

@ -1,235 +0,0 @@
<template>
<teleport to="body">
<div
v-if="show"
ref="viewRef"
class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-[rgba(0,0,0,.6)]"
>
<div
ref="headerRef"
class="absolute top-0 left-0 w-full flex items-center justify-between gap-4 bg-[rgba(0,0,0,.3)] h-14 px-6 text-white"
>
<div>
<button
@click="onBack"
class="select-none bg-transparent text-white h-8 px-2 rounded hover:bg-[rgba(255,255,255,.1)] cursor-pointer"
>
<i class="icon-park-outline-back mr-1"></i>
返回
</button>
<span class="mx-2">|</span>
<span class="ml-2 text-base">
<slot name="name">
<i :class="typeIcon"></i>
<span class="ml-2">{{ name }}</span>
</slot>
</span>
</div>
<div class="flex items-center gap-2">
<button v-if="download" class="bg-transparent text-white h-8 px-3 rounded hover:bg-[rgba(255,255,255,.1)]">
<i class="icon-park-outline-download mr-1"></i>
<span>下载</span>
</button>
<button class="bg-transparent text-white text-xl w-8 h-8 rounded hover:bg-[rgba(255,255,255,.1)]">
<i class="icon-park-outline-more-one"></i>
</button>
</div>
</div>
<div>
<div v-if="type === ViewType.VIDEO" ref="videoRef" class="w-[1280px]"></div>
<div v-else-if="type === ViewType.TEXT" class="w-[1280px] h-[600px] bg-[rgba(0,0,0,.1)] text-white leading-8">
<a-scrollbar outer-class="h-full overflow-hidden" class="h-full overflow-auto">
<div class="py-4 px-5">
{{ url }}
</div>
</a-scrollbar>
</div>
<div v-else-if="type === ViewType.IMAGE">
<img :src="url" :alt="name" />
</div>
<div v-else-if="type === ViewType.AUDIO">
<div class="audio-player flex items-center gap-4 bg-[rgba(255,255,255,.1)] text-white px-4 py-4">
<div
@click="playing = !playing"
class="hover:bg-[rgba(255,255,255,.1)] h-8 px-1.5 flex items-center justify-center rounded"
>
<i v-if="playing" class="text-xl icon-park-outline-pause-one"></i>
<i v-else class="text-xl icon-park-outline-play"></i>
</div>
<div>
{{ currentFormated }}
</div>
<div class="w-96">
<audio ref="audioRef" :src="url" class="hidden" @timeupdate="onTimeUpdate"></audio>
<a-slider class="block!"></a-slider>
</div>
<div>
{{ durationFormated }}
</div>
<div class="dd">
<a-popover>
<div
@click="onMuteToggle"
class="text-xl hover:bg-[rgba(255,255,255,.1)] h-8 px-1.5 flex items-center justify-center rounded"
>
<i :class="volumeIcon"></i>
</div>
<template #content>
<div class="flex flex-col items-center">
<div class="w-6 text-center">
{{ volume }}
</div>
<a-slider class="min-w-auto!" v-model="volume" direction="vertical"></a-slider>
</div>
</template>
</a-popover>
</div>
</div>
</div>
<div v-else>
<div class="w-96 text-center bg-[rgba(0,0,0,.1)] text-white px-4 py-8">
<div class="">暂不支持该文件类型的预览</div>
<div v-if="download" class="mt-4">
<button class="text-white h-8 px-3 rounded bg-[rgba(255,255,255,.1)] hover:bg-[rgba(255,255,255,.2)]">
<i class="icon-park-outline-download mr-1"></i>
<span>下载文件</span>
</button>
</div>
</div>
</div>
</div>
<div class="absolute bottom-20"></div>
</div>
</teleport>
</template>
<script setup lang="tsx">
import { getIcon } from '@/pages/content/material/util';
import { useVModel } from '@vueuse/core';
import DPlayer from 'dplayer';
import numeral from 'numeral';
import { PropType } from 'vue';
enum ViewType {
TEXT = 'text',
IMAGE = 'image',
AUDIO = 'audio',
VIDEO = 'video',
}
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
type: {
type: String as PropType<'text' | 'image' | 'audio' | 'video'>,
},
url: {
type: String,
},
download: {
type: String,
},
name: {
type: String,
default: '失落的海峡.mp4',
},
});
let dplayer: any;
const emit = defineEmits(['update:visible']);
const show = useVModel(props, 'visible', emit);
const headerRef = ref<HTMLElement | null>(null);
const viewRef = ref<HTMLElement | null>(null);
const videoRef = ref<HTMLVideoElement | null>(null);
const audioRef = ref<HTMLAudioElement | null>(null);
const onBack = () => (show.value = false);
const playing = ref(true);
const volume = ref(50);
const volumeLast = ref(50);
const current = ref(18);
const duration = ref(120);
const currentFormated = computed(() => numeral(current.value).format('0:00'));
const durationFormated = computed(() => numeral(duration.value).format('0:00'));
const volumeIcon = computed(() => {
if (volume.value <= 0) {
return 'icon-park-outline-volume-mute';
} else if (volume.value <= 50) {
return 'icon-park-outline-volume-small';
} else {
return 'icon-park-outline-volume-notice';
}
});
const onTimeUpdate = (e: Event) => {};
const playAudio = async () => {
await nextTick();
duration.value = audioRef.value?.duration ?? 0;
};
const typeIcon = computed(() => {
return getIcon(props.type ?? 'text');
});
const onMuteToggle = () => {
if (volume.value === 0) {
volume.value = volumeLast.value;
} else {
volumeLast.value = volume.value;
volume.value = 0;
}
};
const initClickOutside = async () => {
await nextTick();
viewRef.value?.addEventListener('click', e => {
const target = e.target as HTMLElement;
if (target?.contains(headerRef.value)) {
return;
}
show.value = false;
});
};
const loadVideo = async () => {
if (dplayer) {
dplayer.destroy();
}
await nextTick();
if (!videoRef.value) {
return;
}
dplayer = new DPlayer({
container: videoRef.value,
video: {
url: props.url,
},
context: [],
});
};
watch(
() => props.visible,
val => {
if (!val) {
dplayer?.destroy();
return;
}
if (props.type === ViewType.VIDEO) {
loadVideo();
} else if (props.type === ViewType.AUDIO) {
playAudio();
}
},
{
immediate: true,
}
);
</script>
<style scoped></style>
@/pages/content/material/util

View File

@ -0,0 +1,25 @@
<template>
<div class="h-full overflow-hidden grid grid-rows-[auto_1fr]">
<div class="bg-white px-4 py-2">
<div class="flex justify-between gap-4">
<BreadCrumb></BreadCrumb>
<div>
<a-link>需要帮助</a-link>
</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 p-4 bg-white rounded overflow-hidden">
<slot></slot>
</div>
</a-scrollbar>
</slot>
</div>
</template>
<script setup lang="ts">
import BreadCrumb from "./bread-crumb.vue";
</script>
<style scoped></style>

View File

@ -6,21 +6,21 @@
</a-form-item> </a-form-item>
</slot> </slot>
<a-form-item label="字眼颜色"> <a-form-item label="颜色">
<input-color v-model="model.color"></input-color> <input-color v-model="model.color"></input-color>
</a-form-item> </a-form-item>
<div class="flex gap-4"> <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-select v-model="model.family" :options="FontFamilyOptions" class="w-full overflow-hidden"> </a-select>
</a-form-item> </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-input-number v-model="model.size" :min="12" :step="2"> </a-input-number>
</a-form-item> </a-form-item>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <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"> <div class="h-8 flex items-center justify-between">
<a-tag <a-tag
class="cursor-pointer !h-7" class="cursor-pointer !h-7"
@ -48,7 +48,7 @@
</a-tag> </a-tag>
</div> </div>
</a-form-item> </a-form-item>
<a-form-item label="字体排列"> <a-form-item label="方向">
<a-select v-model="model.align" :options="AlignOptions"></a-select> <a-select v-model="model.align" :options="AlignOptions"></a-select>
</a-form-item> </a-form-item>
</div> </div>

View File

@ -0,0 +1,25 @@
import { Blocker } from "../core";
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 };

View File

@ -0,0 +1,41 @@
import { 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:
"温馨提示:乘客您好,进站检票时,持票卡的乘客请在右侧闸机上方感应区内验票,扫码过闸的乘客请将乘车码对准闸机扫码口,扇门打开后依次进闸。乘车过程中请妥善保管好车票,以免丢失。",
},
},
},
});

View File

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

View File

@ -13,12 +13,9 @@
<a-popover> <a-popover>
<i class="icon-park-outline-info text-gray-400 cursor-pointer"></i> <i class="icon-park-outline-info text-gray-400 cursor-pointer"></i>
<template #content> <template #content>
<div class="w-48"> <p>HH 两位数的小时, 01 24</p>
<div class="mb-2">语法</div> <p>mm 两位数的分钟, 00 59</p>
<div>HH: 01 ~ 24</div> <p>ss 两位数的秒数, 00 59</p>
<div>mm: 00 ~ 59</div>
<div>ss: 00 ~ 59</div>
</div>
</template> </template>
</a-popover> </a-popover>
</template> </template>
@ -28,9 +25,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import BaseOption from '../../components/BaseOption.vue'; import BaseOption from "../../components/BaseOption.vue";
import { FontOption } from '../font'; import { FontOption } from "../font";
import { Time, FomatSuguestions } from './interface'; import { Time, FomatSuguestions } from "./interface";
const model = defineModel<Time>({ required: true }); const model = defineModel<Time>({ required: true });
</script> </script>
@ -42,4 +39,4 @@ const model = defineModel<Time>({ required: true });
} }
} }
</style> </style>
../font ../font

View File

@ -0,0 +1,38 @@
<template>
<font-render :data="props.data.params.fontCh">
{{ 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,
},
});
const format = computed(() => props.data.params.fontCh.content || "HH:mm:ss");
const time = ref(dayjs().format(format.value));
let timer: any = null;
onMounted(() => {
timer = setInterval(() => {
time.value = dayjs().format(format.value);
}, 1000);
});
onUnmounted(() => {
clearInterval(timer);
});
</script>
<style scoped></style>
../components/font
../font

View File

@ -57,9 +57,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Block, EditorKey } from '../core'; import { Block, EditorKey } from "../core";
import InputColor from './InputColor.vue'; import InputColor from "./InputColor.vue";
import InputImage from './InputImage.vue'; import InputImage from "./InputImage.vue";
const model = defineModel<Block>({ required: true }); const model = defineModel<Block>({ required: true });
const { container } = inject(EditorKey)!; const { container } = inject(EditorKey)!;

View File

@ -1,7 +1,6 @@
<!-- 修改自: https://github.com/zuley/vue-color-picker --> <!-- 修改自: https://github.com/zuley/vue-color-picker -->
<script setup lang="ts"> <script setup lang="ts">
// @ts-nocheck
import { onClickOutside } from "@vueuse/core"; import { onClickOutside } from "@vueuse/core";
import { computed, ref } from "vue"; import { computed, ref } from "vue";

View File

@ -0,0 +1,98 @@
<template>
<a-modal :visible="visible" :fullscreen="true" :footer="false" class="ani-modal">
<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">
<panel-header v-model:container="container"></panel-header>
</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">
<panel-left @rm-block="rmBlock" @current-block="setCurrentBlock"></panel-left>
</div>
<div class="w-full h-full">
<panel-main
v-model:rightPanelCollapsed="rightPanelCollapsed"
@add-block="addBlock"
@current-block="setCurrentBlock"
></panel-main>
</div>
<div class="h-full overflow-hidden bg-white shadow-[-2px_0_6px_rgba(0,0,0,.05)]">
<panel-right v-model:collapsed="rightPanelCollapsed" v-model:block="currentBlock"></panel-right>
</div>
</div>
</div>
<appnify-preview v-model:visible="preview"></appnify-preview>
</a-modal>
</template>
<script setup lang="ts">
import { EditorKey, useEditor } from "../core";
import PanelHeader from "./PanelHeader.vue";
import PanelLeft from "./PanelLeft.vue";
import PanelMain from "./PanelMain.vue";
import PanelRight from "./PanelRight.vue";
import AppnifyPreview from "./EditorPreview.vue";
const visible = defineModel("visible", { default: false });
const rightPanelCollapsed = ref(false);
const leftPanelCollapsed = ref(false);
const preview = ref(false);
const editor = useEditor();
const { container, blocks, currentBlock, addBlock, rmBlock, setCurrentBlock } = editor;
const saveData = () => {
const data = {
container: container.value,
children: blocks.value,
};
const str = JSON.stringify(data);
localStorage.setItem("ANI_EDITOR_DATA", str);
};
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;
};
provide(EditorKey, editor);
onMounted(loadData);
</script>
<style lang="less">
.ani-modal {
.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

@ -1,6 +1,6 @@
<template> <template>
<div class="h-0 overflow-hidden"> <div class="h-0 overflow-hidden">
<div ref="el" class="an-screen bg-white w-screen h-screen select-none flex items-center justify-center"> <div ref="el" class="bg-white w-screen h-screen select-none">
<div <div
v-if="visible" v-if="visible"
:style="{ :style="{
@ -36,29 +36,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Message } from '@arco-design/web-vue'; import { Message } from "@arco-design/web-vue";
import { useFullscreen, useVModel } from '@vueuse/core'; import { useFullscreen } from "@vueuse/core";
import { BlockerMap } from '../blocks'; import { BlockerMap } from "../blocks";
import { Block, Container } from '../core'; import { EditorKey } from "../core";
import { PropType } from 'vue';
const props = defineProps({ const { container, blocks } = inject(EditorKey)!;
visible: { const visible = defineModel<boolean>("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 el = ref<HTMLElement | null>(null);
const { enter, isFullscreen, isSupported } = useFullscreen(el); const { enter, isFullscreen, isSupported } = useFullscreen(el);
@ -66,19 +50,19 @@ watch(
() => isFullscreen.value, () => isFullscreen.value,
() => { () => {
if (!isFullscreen.value) { if (!isFullscreen.value) {
show.value = false; visible.value = false;
} }
} }
); );
watch( watch(
() => show.value, () => visible.value,
value => { (value) => {
if (!value) { if (!value) {
return; return;
} }
if (!isSupported.value) { if (!isSupported.value) {
Message.warning('抱歉,您的浏览器不支持全屏功能!'); Message.warning("抱歉,您的浏览器不支持全屏功能!");
return; return;
} }
enter(); enter();
@ -86,20 +70,5 @@ watch(
); );
</script> </script>
<style scoped> <style scoped></style>
.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 ../core/editor

View File

@ -1,13 +1,13 @@
<template> <template>
<a-modal <a-modal
v-model:visible="show" v-model:visible="innerVisible"
title="选择素材" title="选择素材"
title-align="start" title-align="start"
:width="1080" :width="1080"
:closable="false" :closable="false"
:mask-closable="false" :mask-closable="false"
:draggable="true" :draggable="true"
:ok-button-props="{ disabled: !selected.length }" :ok-button-props="{ disabled: !seleted.length }"
> >
<div class="w-full flex items-center justify-between gap-4"> <div class="w-full flex items-center justify-between gap-4">
<div> <div>
@ -59,10 +59,10 @@
</a-spin> </a-spin>
<template #footer> <template #footer>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div>已选: {{ selected.length }} </div> <div>已选: {{ seleted.length }} </div>
<div> <div>
<a-button class="mr-2" @click="onClose"> </a-button> <a-button class="mr-2" @click="onClose"> </a-button>
<a-button type="primary" @click="onBeforeOk" :disabled="!selected.length"> 确定 </a-button> <a-button type="primary" @click="onBeforeOk" :disabled="!seleted.length"> 确定 </a-button>
</div> </div>
</div> </div>
</template> </template>
@ -70,15 +70,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { PropType } from 'vue'; import { mockLoad } from "../utils/mock";
import { mockLoad } from '../utils/mock';
import { useVModel } from '@vueuse/core';
import { cloneDeep } from 'lodash-es';
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: [String, Array] as PropType<string | any[]>, type: String,
default: '', default: "",
}, },
visible: { visible: {
type: Boolean, type: Boolean,
@ -94,16 +91,12 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['update:modelValue', 'update:visible']); const emit = defineEmits(["update:modelValue", "update:visible"]);
const show = useVModel(props, 'visible', emit); const innerVisible = computed({
const model = useVModel(props, 'modelValue', emit); get: () => props.visible,
const pagination = ref({ page: 1, size: 15, total: 0 }); set: (value) => emit("update:visible", value),
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 loadData = async () => {
const { page, size } = pagination.value; const { page, size } = pagination.value;
@ -118,39 +111,45 @@ 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 = () => { const onBeforeOk = () => {
model.value = props.multiple ? selected.value : selected.value[0]?.url; emit("update:modelValue", seleted.value[0]?.url);
onClose(); onClose();
}; };
const onClose = () => { const onClose = () => {
selected.value = []; seleted.value = [];
images.value = []; images.value = [];
pagination.value.page = 1; pagination.value.page = 1;
pagination.value.total = 0; pagination.value.total = 0;
search.value.name = ''; search.value.name = "";
show.value = false; innerVisible.value = false;
}; };
const onSelectedImage = (image: any) => { const onSelectedImage = (image: any) => {
if (selectedKeys.value.includes(image.id)) { if (selectedKeys.value.includes(image.id)) {
selected.value = selected.value.filter(item => item.id !== image.id); seleted.value = seleted.value.filter((item) => item.id !== image.id);
} else { } else {
if (props.multiple) { if (!props.multiple) {
selected.value.push(image); seleted.value = [image];
} else { return;
selected.value = [image];
} }
seleted.value.push(image);
} }
}; };
watch( watch(
() => props.visible, () => props.visible,
value => { (value) => {
if (value) { if (value) {
loadData(); loadData();
} }
selected.value = cloneDeep(props.multiple ? model.value : [model.value]) as any[];
} }
); );
</script> </script>
@ -163,7 +162,7 @@ watch(
cursor: pointer; cursor: pointer;
} }
.selected:after { .selected:after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 0px; bottom: 0px;
right: 0px; right: 0px;
@ -173,7 +172,7 @@ watch(
border-left: 20px solid transparent; border-left: 20px solid transparent;
} }
.selected:before { .selected:before {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 5px; bottom: 5px;
right: 1px; right: 1px;

View File

@ -0,0 +1,54 @@
<template>
<span v-if="!descEditing">
{{ modelValue }}
<i
class="!hidden !group-hover:inline-block icon-park-outline-edit text-gray-400 hover:text-gray-700 ml-1 cursor-pointer"
@click="onDescEdit"
></i>
</span>
<span v-else class="inline-flex items-center">
<a-input size="small" v-model="descContent" class="!w-96" v-bind="inputProps"></a-input>
<a-button type="text" size="small" @click="onDescEdited" class="ml-2">
<template #icon>
<i class="icon-park-outline-check"></i>
</template>
</a-button>
<a-button type="text" size="small" class="!text-gray-500" @click="descEditing = false">
<template #icon>
<i class="icon-park-outline-close"></i>
</template>
</a-button>
</span>
</template>
<script setup lang="ts">
import { PropType } from "vue";
import { Input } from "@arco-design/web-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 onDescEdited = () => {
emit("update:modelValue", descContent.value);
descEditing.value = false;
};
const onDescEdit = () => {
descContent.value = props.modelValue;
descEditing.value = true;
};
</script>
<style scoped></style>

View File

@ -0,0 +1,37 @@
<template>
<div class="h-full flex items-center justify-between px-4">
<div class="text-base group">
<a-tag :color="container.id ? 'blue' : 'green'" bordered class="mr-2">
{{ container.id ? "修改" : "新增" }}
</a-tag>
<ani-texter v-model="container.title"></ani-texter>
</div>
<div class="flex gap-2">
<a-button> 导出 </a-button>
<a-button> 设置 </a-button>
<a-dropdown-button type="primary" @click="onSaveData">
保存
<template #content>
<a-doption>保存为JSON</a-doption>
<a-doption>保存为图片</a-doption>
</template>
</a-dropdown-button>
<a-button type="outline" status="danger">退出</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { Message } from "@arco-design/web-vue";
import { Container } from "../core";
import AniTexter from "./InputTexter.vue";
const onSaveData = () => {
Message.success("保存成功");
};
const container = defineModel<Container>("container", { required: true });
</script>
<style scoped></style>
../core

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