Compare commits

...

34 Commits

Author SHA1 Message Date
绝弹 2a27f67b85 feat: 优化布局
自动部署 / build (push) Successful in 3m13s Details
2024-02-27 23:40:10 +08:00
luoer 53ddf5fb20 feat: 临时 2024-02-23 17:37:59 +08:00
luoer 4aef16583d feat: 优化编辑器逻辑 2024-02-23 16:14:31 +08:00
luoer 7f9cbe8466 feat: 第三方库作为VUE插件进行初始化 2024-01-30 10:02:28 +08:00
绝弹 95021c503e
feat: 添加企业微信通知 2024-01-16 14:17:16 +08:00
绝弹 8120ba3cd7
feat: 添加企业微信通知 2024-01-16 14:15:56 +08:00
绝弹 652703f371
feat: 添加企业微信通知 2024-01-16 09:31:24 +08:00
绝弹 877389828a
feat: 添加企业微信通知 2024-01-16 09:28:53 +08:00
绝弹 66dd00b110
feat: 添加企业微信通知 2024-01-16 09:26:10 +08:00
绝弹 8f6b0159d7
Update deploy.yml 2024-01-15 22:30:26 +08:00
绝弹 2bc0a5a7bb
Update deploy.yml 2024-01-15 22:28:27 +08:00
绝弹 b9c179a95b
Update deploy.yml 2024-01-15 22:26:31 +08:00
绝弹 40bb7edbd0
Update deploy.yml 2024-01-15 22:23:34 +08:00
绝弹 3dd78b1b24
Update deploy.yml 2024-01-15 22:18:00 +08:00
绝弹 f4f5529f4c
Update deploy.yml 2024-01-15 22:16:55 +08:00
绝弹 f768a8eead
Update deploy.yml 2024-01-15 22:10:42 +08:00
绝弹 e87b3b2cf3
Update deploy.yml 2024-01-15 22:09:48 +08:00
绝弹 9e7a635e1b
Update deploy.yml 2024-01-15 22:08:40 +08:00
绝弹 85dfe6c43f
Update deploy.yml 2024-01-15 22:06:44 +08:00
绝弹 63746a8f5e
Update deploy.yml 2024-01-15 21:59:45 +08:00
绝弹 48ef4bf597
Update deploy.yml 2024-01-15 21:54:38 +08:00
绝弹 6cbd596f9f
Update deploy.yml 2024-01-15 21:49:21 +08:00
绝弹 61f5bc6146
Update deploy.yml 2024-01-15 21:45:32 +08:00
绝弹 5ffb8737d3
Update deploy.yml 2024-01-15 21:40:31 +08:00
绝弹 511982621f
Update deploy.yml 2024-01-15 20:58:21 +08:00
绝弹 ae304c112b
Update deploy.yml 2024-01-15 20:51:13 +08:00
绝弹 6d3accc520
Update deploy.yml 2024-01-15 20:34:31 +08:00
绝弹 0281782612
feat: Update deploy.yml 2024-01-15 20:32:44 +08:00
绝弹 e559091a3d feat: 测试推送 2024-01-15 20:03:05 +08:00
绝弹 b3f9c11f26 feat: 优化菜单图标大小 2024-01-15 19:41:56 +08:00
luoer 13cabad76a feat: 优化表单样式 2024-01-12 14:56:41 +08:00
luoer d8230ad3b9 feat: 优化路由加载机制 2024-01-12 11:29:00 +08:00
绝弹 46c6c9a3a7 feat: 更新unocss include配置 2024-01-11 22:33:19 +08:00
绝弹 cdf9e3643a feat: 优化菜单折叠按钮 2024-01-11 22:28:13 +08:00
79 changed files with 1241 additions and 2556 deletions

4
.env.production Normal file
View File

@ -0,0 +1,4 @@
# 参见 .env
VITE_BASE = ./
VITE_HISTORY = hash

File diff suppressed because one or more lines are too long

View File

@ -28,4 +28,28 @@ jobs:
git init git init
git add -A git add -A
git commit -m "Build through github action" git commit -m "Build through github action"
git push -f "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" master:gh-pages git push -f "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" master:gh-pages
send:
runs-on: ubuntu-latest
steps:
- name: 克隆代码
uses: actions/checkout@v2
- name: 构建消息
id: exec_cmd
run: |
MSG=$(git log --format=%B -n 1 ${{ github.sha }})
echo "msg=$MSG" >> "$GITHUB_OUTPUT"
HASH=$(git log -1 --pretty=format:%h)
echo "hash1=$HASH" >> "$GITHUB_ENV"
shell: bash
- name: 推送到企微
uses: appnify/action-wechat-work@master
env:
WECHAT_WORK_BOT_WEBHOOK: ${{secrets.WECHAT_WORK_BOT_WEBHOOK}}
with:
msgtype: text
content: "推送通知\n\n仓库名称${{ github.repository }}\n提交用户${{ github.actor }}\n提交消息${{ steps.exec_cmd.outputs.msg }}\n提交哈希${{ env.hash1 }}\n\n提醒已有提交推送到仓库请留意构建结果。"

View File

@ -1,6 +1,6 @@
{ {
"tabWidth": 2, "tabWidth": 2,
"printWidth": 120, "printWidth": 180,
"bracketSpacing": true, "bracketSpacing": true,
"singleQuote": true, "singleQuote": true,
"arrowParens": "avoid" "arrowParens": "avoid"

View File

@ -18,5 +18,7 @@ COPY --from=builder /app/dist /usr/share/nginx/html
# 复制nginx配置 # 复制nginx配置
COPY --from=builder /app/.github/nginx.conf /etc/nginx/conf.d/default.conf COPY --from=builder /app/.github/nginx.conf /etc/nginx/conf.d/default.conf
# 显式暴露端口
EXPOSE 80 EXPOSE 80
# 启动,关闭后台运行启动前台运行,不然 docker 会结束运行
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

144
README.md
View File

@ -15,6 +15,7 @@
- 遵循 Conventional Changelog 规范, 自动生成版本记录文档 - 遵循 Conventional Changelog 规范, 自动生成版本记录文档
- 内置常用 VsCode 代码片段和推荐扩展,提升开发效率 - 内置常用 VsCode 代码片段和推荐扩展,提升开发效率
- 支持路由动态打包、路由权限、路由缓存和动态首页 - 支持路由动态打包、路由权限、路由缓存和动态首页
- 支持 Docker 部署,包含优化过的 Dockerfile 配置
## 快速开始 ## 快速开始
@ -53,20 +54,153 @@ pnpm dev
根据 src/pages 目录生成路由数组,包含以下以下规则: 根据 src/pages 目录生成路由数组,包含以下以下规则:
- 以文件夹为路由,读取该文件夹下 index.vue 的信息作为路由信息,其他文件会跳过,可以包含子文件夹 - 以文件夹为路由,读取该文件夹下 index.vue 的信息作为路由信息,其他文件会跳过,可以包含子文件夹作为嵌套路由
- 在 src/pages 的文件夹层级,作为菜单层级,路由层级最终只有 2 层(配合 keep-alive 缓存) - 在 src/pages 的文件夹层级,作为菜单层级,路由层级最终只有 2 层(配合 keep-alive 缓存)
- 在 src/pages 目录下,以 _ 开头的文件夹作为 1 级路由,其他作为 2 级路由,也就是应用路由 - 在 src/pages 目录下,以 _ 开头的文件夹作为 1 级路由,其他作为 2 级路由,也就是应用路由
- 子文件夹下,只有 index.vue 文件有效,其他文件会忽略,这些文件可以作为子组件使用 - 子文件夹下,只有 index.vue 文件有效,其他文件会忽略,这些文件可以作为子组件使用
- components 目录会被忽视 - components 目录会被忽视
- xxx.xx.xx 文件会被忽视,例如 index.my.vue - xxx.xx.xx 文件会被忽视,例如 index.my.vue 文件。
对应目录下的 index.vue 文件中定义如下路由配置: 对应目录下的 index.vue 文件中定义如下路由配置:
```jsonc ```jsonc
<route lang="json"> <route lang="json">
{ {
"parentMeta": { // 其他 Route 参数
// 具体属性查阅 src/types/vue-router.d.ts "meta": {
// 请看下面
}
}
</route>
```
目前支持的参数,如下:
```ts
interface RouteMeta {
/**
* 页面标题
* @description
* 菜单和导航面包屑等地方会用到
*/
title?: string;
/**
* 页面图标
* @description
* 使用 icon-park-outline 图标集的图标类名
*/
icon?: string;
/**
* 显示顺序
* @description
* 在菜单中的显示顺序,越小越靠前
*/
sort?: number;
/**
* 是否隐藏
* @description
* - false // 不隐藏(默认)
* - true // 在路由和菜单中隐藏,即忽略且不打包
* - 'menu' // 在菜单中隐藏,通过其他方式访问
* - 'prod' // 在生产环境下隐藏
*/
hide?: boolean | 'menu' | 'prod';
/**
* 所需权限
* @example
* ```js
* ['system:user']
* ```
*/
auth?: string[];
/**
* 是否缓存
* @description
* 是否使用 keep-alive 缓存
*/
cache?: boolean;
/**
* 组件名字
* @description
* 组件名字,当 cache为true 时必须
*/
name?: string;
/**
* 是否显示loading
* @description
* 可以自定义 loading 文本
*/
loading?: boolean | string;
/**
* 链接
* @description
* ```js
* 'https://juetan.cn'
* ```
*/
link?: string;
}
```
### 嵌套布局
默认情况下,嵌套路由会使用父级 index.vue 作为布局文件,如果不需要布局,只需在父级路由指定 component 为 null 即可,如下:
```jsonc
{
"component": null,
"meta": {}
}
```
这样,其层级仅作为菜单层级,在路由上表现为扁平。
### 路由权限
在每个路由的 index.vue 文件中,通过 meta.auth 字段指定访问该路由所需的权限,示例如下:
```jsonc
{
"meta": {
"auth": ["system:user", "system:menu"]
}
}
```
默认全部需要登陆才可访问,其中有 2 个比较特殊的权限:
- `*` 表示无需登陆即可访问,适合挂一些比较通用的页面。
- `unlogin` 表示未登录才可以访问。例如登录页,登陆后访问该页面会被拒绝。
用户登陆后获取的权限,应存储在 userStore.auth 字段中,在路由的 beforeEach 守卫中,会比较两个是否匹配,匹配上则继续,否则会显示如下 403 页面:
![Alt text](image.png)
### 动态路由
相比于比较流行的加法挂载,我更倾向于减法挂载。即默认加载完所有路由,在 beforeEach 钩子根据权限移除不必要的路由。
### 动态首页
在作为首页路由的 index.vue 文件中,指定 alias 为 '/' 即可,默认是 home/index.vue 文件。如需动态更新首页,在 beforeEach 获取完菜单信息,通过 removeRoute 移除旧的首页路由,通过 addRoute 添加新的首页路由即可。
### 路由缓存
在路由的 index.vue 文件,首先指定好组件的名字,再通过 cache 字段开启缓存,示例如下:
```html
<script>
defineOptions({
name: "MyPage"
})
</script>
<route>
{
"meta": {
// 组件名字
"name": "MyPage",
// 开启缓存
"cache": true
} }
} }
</route> </route>

BIN
image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -9,16 +9,13 @@
</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"> <div class="cube">
<img <div></div>
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHN0eWxlPSJtYXJnaW46IGF1dG87IGJhY2tncm91bmQ6IHJnYigyNTUsIDI1NSwgMjU1KTsgZGlzcGxheTogYmxvY2s7IHNoYXBlLXJlbmRlcmluZzogYXV0bzsiIHdpZHRoPSIyMDBweCIgaGVpZ2h0PSIyMDBweCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaWRZTWlkIj48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1MCA1MCkiPjxnPjxhbmltYXRlVHJhbnNmb3JtIGF0dHJpYnV0ZU5hbWU9InRyYW5zZm9ybSIgdHlwZT0icm90YXRlIiB2YWx1ZXM9IjA7NDUiIGtleVRpbWVzPSIwOzEiIGR1cj0iMC4ycyIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiPjwvYW5pbWF0ZVRyYW5zZm9ybT48cGF0aCBkPSJNMjkuNDkxNTI0MjA2MTE3MjU1IC01LjUgTDM3LjQ5MTUyNDIwNjExNzI1NSAtNS41IEwzNy40OTE1MjQyMDYxMTcyNTUgNS41IEwyOS40OTE1MjQyMDYxMTcyNTUgNS41IEEzMCAzMCAwIDAgMSAyNC43NDI3NDQwNTAxOTg3MzggMTYuOTY0NTY5NDU3MTQ2NzEyIEwyNC43NDI3NDQwNTAxOTg3MzggMTYuOTY0NTY5NDU3MTQ2NzEyIEwzMC4zOTk1OTgyOTk2OTExMTcgMjIuNjIxNDIzNzA2NjM5MDkyIEwyMi42MjE0MjM3MDY2MzkwOTYgMzAuMzk5NTk4Mjk5NjkxMTE0IEwxNi45NjQ1Njk0NTcxNDY3MTYgMjQuNzQyNzQ0MDUwMTk4NzM0IEEzMCAzMCAwIDAgMSA1LjUgMjkuNDkxNTI0MjA2MTE3MjU1IEw1LjUgMjkuNDkxNTI0MjA2MTE3MjU1IEw1LjUgMzcuNDkxNTI0MjA2MTE3MjU1IEwtNS40OTk5OTk5OTk5OTk5OTcgMzcuNDkxNTI0MjA2MTE3MjU1IEwtNS40OTk5OTk5OTk5OTk5OTcgMjkuNDkxNTI0MjA2MTE3MjU1IEEzMCAzMCAwIDAgMSAtMTYuOTY0NTY5NDU3MTQ2NzA1IDI0Ljc0Mjc0NDA1MDE5ODczOCBMLTE2Ljk2NDU2OTQ1NzE0NjcwNSAyNC43NDI3NDQwNTAxOTg3MzggTC0yMi42MjE0MjM3MDY2MzkwODUgMzAuMzk5NTk4Mjk5NjkxMTE3IEwtMzAuMzk5NTk4Mjk5NjkxMTE3IDIyLjYyMTQyMzcwNjYzOTA5MiBMLTI0Ljc0Mjc0NDA1MDE5ODczOCAxNi45NjQ1Njk0NTcxNDY3MTIgQTMwIDMwIDAgMCAxIC0yOS40OTE1MjQyMDYxMTcyNTUgNS41MDAwMDAwMDAwMDAwMDkgTC0yOS40OTE1MjQyMDYxMTcyNTUgNS41MDAwMDAwMDAwMDAwMDkgTC0zNy40OTE1MjQyMDYxMTcyNTUgNS41MDAwMDAwMDAwMDAwMSBMLTM3LjQ5MTUyNDIwNjExNzI1NSAtNS41MDAwMDAwMDAwMDAwMDEgTC0yOS40OTE1MjQyMDYxMTcyNTUgLTUuNTAwMDAwMDAwMDAwMDAyIEEzMCAzMCAwIDAgMSAtMjQuNzQyNzQ0MDUwMTk4NzM4IC0xNi45NjQ1Njk0NTcxNDY3MDUgTC0yNC43NDI3NDQwNTAxOTg3MzggLTE2Ljk2NDU2OTQ1NzE0NjcwNSBMLTMwLjM5OTU5ODI5OTY5MTExNyAtMjIuNjIxNDIzNzA2NjM5MDg1IEwtMjIuNjIxNDIzNzA2NjM5MDkyIC0zMC4zOTk1OTgyOTk2OTExMTcgTC0xNi45NjQ1Njk0NTcxNDY3MTIgLTI0Ljc0Mjc0NDA1MDE5ODczOCBBMzAgMzAgMCAwIDEgLTUuNTAwMDAwMDAwMDAwMDExIC0yOS40OTE1MjQyMDYxMTcyNTUgTC01LjUwMDAwMDAwMDAwMDAxMSAtMjkuNDkxNTI0MjA2MTE3MjU1IEwtNS41MDAwMDAwMDAwMDAwMTIgLTM3LjQ5MTUyNDIwNjExNzI1NSBMNS40OTk5OTk5OTk5OTk5OTggLTM3LjQ5MTUyNDIwNjExNzI1NSBMNS41IC0yOS40OTE1MjQyMDYxMTcyNTUgQTMwIDMwIDAgMCAxIDE2Ljk2NDU2OTQ1NzE0NjcwMiAtMjQuNzQyNzQ0MDUwMTk4NzQgTDE2Ljk2NDU2OTQ1NzE0NjcwMiAtMjQuNzQyNzQ0MDUwMTk4NzQgTDIyLjYyMTQyMzcwNjYzOTA4IC0zMC4zOTk1OTgyOTk2OTExMiBMMzAuMzk5NTk4Mjk5NjkxMTE3IC0yMi42MjE0MjM3MDY2MzkxIEwyNC43NDI3NDQwNTAxOTg3MzggLTE2Ljk2NDU2OTQ1NzE0NjcxNiBBMzAgMzAgMCAwIDEgMjkuNDkxNTI0MjA2MTE3MjU1IC01LjUwMDAwMDAwMDAwMDAxMyBNMCAtMjBBMjAgMjAgMCAxIDAgMCAyMCBBMjAgMjAgMCAxIDAgMCAtMjAiIGZpbGw9IiMwOWYiPjwvcGF0aD48L2c+PC9nPjwvc3ZnPgo=" <div></div>
alt="loading" <div></div>
class="loading-image" <div></div>
width="64" <div></div>
height="64" <div></div>
/>
<h1 class="loading-title">欢迎访问%VITE_TITLE%</h1>
<div class="loading-tip">资源加载中, 请稍等...</div>
</div> </div>
<style> <style>
html, html,
@ -29,8 +26,7 @@
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%;
@ -38,28 +34,53 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
user-select: none;
} }
.loading { @keyframes cube {
display: flex; 0% {
flex-direction: column; transform: rotate(45deg) rotateX(-25deg) rotateY(25deg);
align-items: center; }
justify-content: center; 50% {
transform: rotate(45deg) rotateX(-385deg) rotateY(25deg);
}
100% {
transform: rotate(45deg) rotateX(-385deg) rotateY(385deg);
}
} }
.loading-image { .cube {
width: 64px; animation: cube 2s infinite ease;
height: 64px; height: 40px;
transform-style: preserve-3d;
width: 40px;
} }
.loading-title { .cube div {
margin: 0; background-color: rgba(255, 255, 255, 0.25);
margin-top: 20px; height: 100%;
font-size: 22px; position: absolute;
font-weight: 400; width: 100%;
line-height: 1; border: 2px solid #000;
} }
.loading-tip { .cube div:nth-of-type(1) {
margin-top: 12px; transform: translateZ(-20px) rotateY(180deg);
line-height: 1; }
color: #889; .cube div:nth-of-type(2) {
transform: rotateY(-270deg) translateX(50%);
transform-origin: top right;
}
.cube div:nth-of-type(3) {
transform: rotateY(270deg) translateX(-50%);
transform-origin: center left;
}
.cube div:nth-of-type(4) {
transform: rotateX(90deg) translateY(-50%);
transform-origin: top center;
}
.cube div:nth-of-type(5) {
transform: rotateX(-90deg) translateY(50%);
transform-origin: bottom center;
}
.cube div:nth-of-type(6) {
transform: translateZ(20px);
} }
</style> </style>
</div> </div>

View File

@ -1,7 +1,7 @@
{ {
"name": "starter-vue", "name": "starter-vue",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -1 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: rgb(255, 255, 255); display: block; shape-rendering: auto;" width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid"><g transform="translate(50 50)"><g><animateTransform attributeName="transform" type="rotate" values="0;45" keyTimes="0;1" dur="0.2s" repeatCount="indefinite"></animateTransform><path d="M29.491524206117255 -5.5 L37.491524206117255 -5.5 L37.491524206117255 5.5 L29.491524206117255 5.5 A30 30 0 0 1 24.742744050198738 16.964569457146712 L24.742744050198738 16.964569457146712 L30.399598299691117 22.621423706639092 L22.621423706639096 30.399598299691114 L16.964569457146716 24.742744050198734 A30 30 0 0 1 5.5 29.491524206117255 L5.5 29.491524206117255 L5.5 37.491524206117255 L-5.499999999999997 37.491524206117255 L-5.499999999999997 29.491524206117255 A30 30 0 0 1 -16.964569457146705 24.742744050198738 L-16.964569457146705 24.742744050198738 L-22.621423706639085 30.399598299691117 L-30.399598299691117 22.621423706639092 L-24.742744050198738 16.964569457146712 A30 30 0 0 1 -29.491524206117255 5.500000000000009 L-29.491524206117255 5.500000000000009 L-37.491524206117255 5.50000000000001 L-37.491524206117255 -5.500000000000001 L-29.491524206117255 -5.500000000000002 A30 30 0 0 1 -24.742744050198738 -16.964569457146705 L-24.742744050198738 -16.964569457146705 L-30.399598299691117 -22.621423706639085 L-22.621423706639092 -30.399598299691117 L-16.964569457146712 -24.742744050198738 A30 30 0 0 1 -5.500000000000011 -29.491524206117255 L-5.500000000000011 -29.491524206117255 L-5.500000000000012 -37.491524206117255 L5.499999999999998 -37.491524206117255 L5.5 -29.491524206117255 A30 30 0 0 1 16.964569457146702 -24.74274405019874 L16.964569457146702 -24.74274405019874 L22.62142370663908 -30.39959829969112 L30.399598299691117 -22.6214237066391 L24.742744050198738 -16.964569457146716 A30 30 0 0 1 29.491524206117255 -5.500000000000013 M0 -20A20 20 0 1 0 0 20 A20 20 0 1 0 0 -20" fill="#09f"></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -4,7 +4,7 @@ const arcoLevels = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
export const arcoToUnoColor = (arcoColorName: string): { [id: string]: string } => { export const arcoToUnoColor = (arcoColorName: string): { [id: string]: string } => {
const colors = {}; const colors = {};
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
colors[unoLevels[i]] = `rgb(var(--${arcoColorName}-${arcoLevels[i]}))`; colors[unoLevels[i]] = `rgba(var(--${arcoColorName}-${arcoLevels[i]}), 1)`;
} }
return colors; return colors;
}; };

View File

@ -1,5 +1,5 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { Plugin, ResolvedConfig } from 'vite'; import { Plugin } from 'vite';
import pkg from '../../package.json'; import pkg from '../../package.json';
/** /**
@ -46,8 +46,11 @@ const getBuildInfo = async () => {
const version = commits ? `${latestTag}.${commits}` : `v${pkg.version}`; const version = commits ? `${latestTag}.${commits}` : `v${pkg.version}`;
const content = `欢迎访问!版本: ${version} 标识: ${hash} 构建: ${time}`; const content = `欢迎访问!版本: ${version} 标识: ${hash} 构建: ${time}`;
const style = `"color: #09f; font-weight: 900;", "font-size: 12px; color: #09f; font-family: ''"`; 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`; const vString = `var __APP_VERSION__ = '${version}';\n`;
return script; const hString = `var __APP_HASH__ = '${hash}';\n`;
const dString = `var __APP_DATE__ = '${time}';\n`;
const lString = `console.log(\`%c${LOGO} \n%c${content}\n\`, ${style});\n`;
return vString + hString + dString + lString;
}; };
/** /**
@ -55,16 +58,9 @@ const getBuildInfo = async () => {
* @returns Plugin * @returns Plugin
*/ */
export default function plugin(): Plugin { export default function plugin(): Plugin {
let config: ResolvedConfig;
return { return {
name: 'vite:info', name: 'vite:info',
enforce: 'pre', enforce: 'pre',
configResolved(resolvedConfig) {
config = resolvedConfig;
},
async transformIndexHtml() { async transformIndexHtml() {
const script = await getBuildInfo(); const script = await getBuildInfo();
return [ return [

View File

@ -18,8 +18,10 @@ const userStore = useUserStore();
const menuStore = useMenuStore(); const menuStore = useMenuStore();
const hasAuth = computed(() => { const hasAuth = computed(() => {
if (!route.name.startsWith('_')) {
return true;
}
return route.matched.every(item => { return route.matched.every(item => {
console.log('i', item);
const needAuth = item.meta.auth; const needAuth = item.meta.auth;
const userAuth = userStore.auth; const userAuth = userStore.auth;
if (needAuth?.includes('*')) { if (needAuth?.includes('*')) {

View File

@ -1,8 +1,28 @@
import { Service } from './service';
import { addToastInterceptor } from '../interceptors/toast'; import { addToastInterceptor } from '../interceptors/toast';
import { addAuthInterceptor } from '../interceptors/auth'; import { addAuthInterceptor } from '../interceptors/auth';
import { addExceptionInterceptor } from '../interceptors/exception'; import { addExceptionInterceptor } from '../interceptors/exception';
import { env } from '@/config/env'; import { env } from '@/config/env';
import { App } from 'vue';
import { Api } from '../generated/Api';
/**
* API
*/
export class Service extends Api<unknown> {
/**
* VUE
* @param app
*/
install(app: App) {
app.config.globalProperties.$api = this;
}
/**
*
* @description
*/
expireHandler: () => void = () => {};
}
/** /**
* API * API
@ -22,8 +42,8 @@ addToastInterceptor(api.instance);
* *
*/ */
addExceptionInterceptor(api.instance, () => api.expireHandler?.()); addExceptionInterceptor(api.instance, () => api.expireHandler?.());
/** /**
* *
*/ */
addAuthInterceptor(api.instance); addAuthInterceptor(api.instance);

View File

@ -1,12 +0,0 @@
import { Api } from '../generated/Api';
/**
* API
*/
export class Service extends Api<unknown> {
/**
*
* @description
*/
expireHandler: () => void = () => {};
}

View File

@ -1,12 +1,13 @@
import { store, useUserStore } from "@/store"; import { store } from '@/store';
import { AxiosInstance } from "axios"; import { useUserStore } from '@/store/user';
import { AxiosInstance } from 'axios';
/** /**
* *
* @param axios Axios * @param axios Axios
*/ */
export function addAuthInterceptor(axios: AxiosInstance) { export function addAuthInterceptor(axios: AxiosInstance) {
axios.interceptors.request.use((config) => { axios.interceptors.request.use(config => {
const userStore = useUserStore(store); const userStore = useUserStore(store);
if (userStore.accessToken) { if (userStore.accessToken) {
config.headers.Authorization = `Bearer ${userStore.accessToken}`; config.headers.Authorization = `Bearer ${userStore.accessToken}`;

View File

@ -1,15 +1,10 @@
<template> <template>
<div class="h-full overflow-hidden grid grid-rows-[auto_1fr]"> <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="bg-white px-4 py-2">
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
<BreadCrumb></BreadCrumb> <BreadCrumb></BreadCrumb>
<div> <div>
<a-link>需要帮助</a-link> <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> </div>
</div> </div>

View File

@ -1,6 +1,5 @@
import { InjectionKey } from 'vue'; import { InjectionKey } from 'vue';
import { Block, Blocker, Container } from '../core'; import { Block, Blocker, Container } from '../core';
import { useTextBlock } from './text';
const blockers: Record<string, Blocker> = import.meta.glob(['./*/index.ts', '!./font/*'], { const blockers: Record<string, Blocker> = import.meta.glob(['./*/index.ts', '!./font/*'], {
eager: true, eager: true,
@ -25,47 +24,3 @@ const getIcon = (type: string) => {
}; };
export { BlockerMap, getBlockerRender, getIcon, getTypeName }; 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

@ -0,0 +1,145 @@
import { merge } from 'lodash-es';
import { Block, Blocker, defineBlocker } from '../../core';
import { BlockItem, Plugin } from '../../core/plugin';
import { font } from '../font';
import { Text } from './interface';
import Option from './option.vue';
import Render from './render.vue';
import { Button } from '@arco-design/web-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:
'温馨提示:乘客您好,进站检票时,持票卡的乘客请在右侧闸机上方感应区内验票,扫码过闸的乘客请将乘车码对准闸机扫码口,扇门打开后依次进闸。乘车过程中请妥善保管好车票,以免丢失。',
},
},
},
});
const defaults: 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: '温馨提示:乘客您好',
},
},
};
export const item: BlockItem = {
type: 'text',
icon: 'icon-park-outline-text',
title: '文本组件',
description: '文字',
editRender: Option,
viewRender: Render,
onInit: () => {
return merge({}, defaults);
},
};
export function TextBlock(): Plugin {
const defaults = {
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 {
name: 'TextBlockPlugin',
hrRender: {
name: 'TextDelete',
render() {
return (
<Button>
{{
icon: <i class="icon-park-outline-delete"></i>,
default: '测试',
}}
</Button>
);
},
},
hlRender: {
name: 'tip',
render() {
return <span class="text-gray-400 text-xs ml-2"></span>;
},
},
addBlockItem() {
return {
type: 'text',
icon: 'icon-park-outline-text',
title: '文本组件',
description: '文字',
onInit: () => merge({}, defaults),
editRender: Option,
viewRender: Render,
};
},
};
}

View File

@ -2,41 +2,23 @@
<a-modal v-model:visible="show" :fullscreen="true" :footer="false" class="an-editor"> <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="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"> <div class="h-13 bg-white border-b border-slate-200 z-10">
<EditorHeader <EditorHeader v-model:container="container" :saving="saving" @preview="showPreview = true" @config="showConfig = true" @exit="onExit()" @save="saveData()"></EditorHeader>
v-model:container="container"
:saving="saving"
@preview="showPreview = true"
@config="showConfig = true"
@exit="onExit()"
@save="saveData()"
></EditorHeader>
</div> </div>
<div class="grid grid-cols-[auto_1fr_auto] overflow-hidden"> <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"> <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> <EditorLeft @rm-block="rmBlock" @current-block="setCurrentBlock"></EditorLeft>
</div> </div>
<div class="w-full h-full"> <div class="w-full h-full">
<EditorMain <EditorMain v-model:rightPanelCollapsed="rightPanelCollapsed" @add-block="addBlock" @current-block="setCurrentBlock" @block-menu="onBlockContextMenu"></EditorMain>
v-model:rightPanelCollapsed="rightPanelCollapsed"
@add-block="addBlock"
@current-block="setCurrentBlock"
@block-menu="onBlockContextMenu"
></EditorMain>
</div> </div>
<div class="h-full overflow-hidden bg-white shadow-[-2px_0_6px_rgba(0,0,0,.05)]"> <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> <EditorRight v-model:collapsed="rightPanelCollapsed" v-model:block="container.current"></EditorRight>
</div> </div>
</div> </div>
</div> </div>
<EditorPreview v-model:visible="showPreview" :container="container" :blocks="blocks"></EditorPreview> <EditorPreview v-model:visible="showPreview" :container="container" :blocks="container.children"></EditorPreview>
<EditorSetting v-model:visible="showConfig" v-model="container"></EditorSetting> <EditorSetting v-model:visible="showConfig" v-model="container"></EditorSetting>
<ContextMenu <ContextMenu v-model:visible="blockMenu.show" :x="blockMenu.x" :y="blockMenu.y" :items="blockMenuItems" @done="blockMenu.show = false"></ContextMenu>
v-model:visible="blockMenu.show"
:x="blockMenu.x"
:y="blockMenu.y"
:items="blockMenuItems"
@done="blockMenu.show = false"
></ContextMenu>
</a-modal> </a-modal>
</template> </template>
@ -44,7 +26,7 @@
import { delConfirm, sleep } from '@/utils'; import { delConfirm, sleep } from '@/utils';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
import { useVModel } from '@vueuse/core'; import { useVModel } from '@vueuse/core';
import { Block, ContextMenuItem, EditorKey, useEditor } from '../core'; import { Block, EditorKey, useEditor } from '../core';
import ContextMenu from './ContextMenu.vue'; import ContextMenu from './ContextMenu.vue';
import EditorSetting from './EditorConfig.vue'; import EditorSetting from './EditorConfig.vue';
import EditorHeader from './EditorHeader.vue'; import EditorHeader from './EditorHeader.vue';
@ -52,6 +34,8 @@ import EditorLeft from './EditorLeft.vue';
import EditorMain from './EditorMain.vue'; import EditorMain from './EditorMain.vue';
import EditorPreview from './EditorPreview.vue'; import EditorPreview from './EditorPreview.vue';
import EditorRight from './EditorRight.vue'; import EditorRight from './EditorRight.vue';
import { ContextKey, usePluginContext } from '../core/plugin';
import { TextBlock } from '../blocks/text';
const props = defineProps({ const props = defineProps({
visible: { visible: {
@ -67,7 +51,10 @@ const showPreview = ref(false);
const showConfig = ref(false); const showConfig = ref(false);
const saving = ref(false); const saving = ref(false);
const editor = useEditor(); const editor = useEditor();
const { container, blocks, currentBlock, addBlock, rmBlock, setCurrentBlock } = editor; const context = usePluginContext([TextBlock()]);
const { container, addBlock, rmBlock, setCurrentBlock } = context;
console.log({context});
const blockMenu = reactive<{ show: boolean; x: number; y: number; block: Block | null }>({ const blockMenu = reactive<{ show: boolean; x: number; y: number; block: Block | null }>({
show: false, show: false,
@ -76,7 +63,7 @@ const blockMenu = reactive<{ show: boolean; x: number; y: number; block: Block |
block: null, block: null,
}); });
const blockMenuItems: ContextMenuItem[] = [ const blockMenuItems: any[] = [
{ {
name: '删除', name: '删除',
icon: 'icon-park-outline-delete', icon: 'icon-park-outline-delete',
@ -103,7 +90,7 @@ const onBlockContextMenu = (block: Block, e: MouseEvent) => {
const saveData = async () => { const saveData = async () => {
const data = { const data = {
container: container.value, container: container.value,
children: blocks.value, children: container.value.children,
}; };
saving.value = true; saving.value = true;
await sleep(3000); await sleep(3000);
@ -120,7 +107,7 @@ const loadData = async () => {
} }
const data = JSON.parse(str); const data = JSON.parse(str);
container.value = data.container; container.value = data.container;
blocks.value = data.children; container.value.children = data.children;
}; };
const onExit = async () => { const onExit = async () => {
@ -133,6 +120,7 @@ const onExit = async () => {
}; };
provide(EditorKey, editor); provide(EditorKey, editor);
provide(ContextKey, context);
onMounted(loadData); onMounted(loadData);
</script> </script>

View File

@ -46,9 +46,7 @@ const props = defineProps({
required: true, required: true,
}, },
}); });
const emit = defineEmits(['update:visible', 'update:modelValue']); const emit = defineEmits(['update:visible', 'update:modelValue']);
const show = useVModel(props, 'visible', emit); const show = useVModel(props, 'visible', emit);
const model = useVModel(props, 'modelValue', emit); const model = useVModel(props, 'modelValue', emit);
</script> </script>

View File

@ -9,11 +9,13 @@
</a-link> </a-link>
<a-divider :direction="'vertical'" :margin="8"></a-divider> <a-divider :direction="'vertical'" :margin="8"></a-divider>
<ani-texter v-model="container.title"></ani-texter> <ani-texter v-model="container.title"></ani-texter>
<component v-for="item in HL" :is="item" />
<!-- <a-tag :color="container.id ? 'blue' : 'green'" class="mr-2 ml-1"> <!-- <a-tag :color="container.id ? 'blue' : 'green'" class="mr-2 ml-1">
{{ container.id ? '修改' : '新增' }} {{ container.id ? '修改' : '新增' }}
</a-tag> --> </a-tag> -->
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<component v-for="item in HR" :key="item.name" :is="item" />
<a-button @click="emit('preview')"> <a-button @click="emit('preview')">
<template #icon> <template #icon>
<i class="icon-park-outline-play"></i> <i class="icon-park-outline-play"></i>
@ -38,6 +40,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Container } from '../core'; import { Container } from '../core';
import { ContextKey } from '../core/plugin';
import AniTexter from './InputTexter.vue'; import AniTexter from './InputTexter.vue';
defineProps({ defineProps({
@ -48,8 +51,9 @@ defineProps({
}) })
const emit = defineEmits(['preview', 'config', 'exit', 'save']); const emit = defineEmits(['preview', 'config', 'exit', 'save']);
const container = defineModel<Container>('container', { required: true }); const container = defineModel<Container>('container', { required: true });
const { HR, HL } = inject(ContextKey)!
</script> </script>
<style scoped></style> <style scoped></style>

View File

@ -1,12 +1,7 @@
<template> <template>
<div class="h-full grid grid-cols-[auto_1fr]" :style="{ width: !collapsed ? '248px' : undefined }"> <div class="h-full grid grid-cols-[auto_1fr]" :style="{ width: !collapsed ? '248px' : undefined }">
<div class="h-full grid grid-rows-[1fr_auto] border-r border-slate-200"> <div class="h-full grid grid-rows-[1fr_auto] border-r border-slate-200">
<a-menu <a-menu :collapsed="true" :default-selected-keys="['0_0']" :selected-keys="[key]" @menu-item-click="k => (key = k)">
:collapsed="true"
:default-selected-keys="['0_0']"
:selected-keys="[key]"
@menu-item-click="(k) => (key = k)"
>
<a-menu-item key="list"> <a-menu-item key="list">
<template #icon> <template #icon>
<i class="icon-park-outline-all-application"></i> <i class="icon-park-outline-all-application"></i>
@ -31,10 +26,7 @@
<a-tooltip :content="collapsed ? '展开' : '折叠'" position="right"> <a-tooltip :content="collapsed ? '展开' : '折叠'" position="right">
<a-button type="text" @click="collapsed = !collapsed"> <a-button type="text" @click="collapsed = !collapsed">
<template #icon> <template #icon>
<i <i class="text-lg text-gray-400 hover:text-gray-700" :class="collapsed ? 'icon-park-outline-expand-left' : 'icon-park-outline-expand-right'"></i>
class="text-lg text-gray-400 hover:text-gray-700"
:class="collapsed ? 'icon-park-outline-expand-left' : 'icon-park-outline-expand-right'"
></i>
</template> </template>
</a-button> </a-button>
</a-tooltip> </a-tooltip>
@ -61,13 +53,13 @@
</ul> </ul>
<ul v-show="key === 'data'" class="list-none px-2 grid gap-2"> <ul v-show="key === 'data'" class="list-none px-2 grid gap-2">
<li <li
v-for="item in blocks" v-for="item in container.children"
:key="item.id" :key="item.id"
class="group h-8 w-full overflow-hidden grid grid-cols-[auto_1fr_auto] items-center gap-2 bg-gray-100 text-gray-500 px-2 py-1 rounded border border-transparent" class="group h-8 w-full overflow-hidden grid grid-cols-[auto_1fr_auto] items-center gap-2 bg-gray-100 text-gray-500 px-2 py-1 rounded border border-transparent"
:class="{ :class="{
'!bg-brand-50': currentBlock === item, '!bg-brand-50': container.current === item,
'!text-brand-500': currentBlock === item, '!text-brand-500': container.current === item,
'!border-brand-300': currentBlock === item, '!border-brand-300': container.current === item,
}" }"
@click="emit('current-block', item)" @click="emit('current-block', item)"
> >
@ -78,10 +70,7 @@
{{ item.title }} {{ item.title }}
</div> </div>
<div class="w-4"> <div class="w-4">
<i <i class="!hidden !group-hover:inline-block text-gray-400 hover:text-gray-700 icon-park-outline-delete !text-xs" @click.prevent="emit('rm-block', item)"></i>
class="!hidden !group-hover:inline-block text-gray-400 hover:text-gray-700 icon-park-outline-delete !text-xs"
@click.prevent="emit('rm-block', item)"
></i>
</div> </div>
</li> </li>
</ul> </ul>
@ -90,24 +79,24 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getIcon } from "../blocks"; import { getIcon } from '../blocks';
import { Block, EditorKey } from "../core"; import { Block, EditorKey } from '../core';
const { blocks, currentBlock, BlockerMap } = inject(EditorKey)!; const { container, BlockerMap } = inject(EditorKey)!;
const blockList = Object.values(BlockerMap); const blockList = Object.values(BlockerMap);
const collapsed = ref(false); const collapsed = ref(false);
const key = ref<"list" | "data">("list"); const key = ref<'list' | 'data'>('list');
const emit = defineEmits<{ const emit = defineEmits<{
(event: "rm-block", block: Block): void; (event: 'rm-block', block: Block): void;
(event: "current-block", block: Block | null): void; (event: 'current-block', block: Block | null): void;
}>(); }>();
/** /**
* 拖拽开始时设置数据 * 拖拽开始时设置数据
*/ */
const onDragStart = (e: DragEvent) => { const onDragStart = (e: DragEvent) => {
e.dataTransfer?.setData("type", (e.target as HTMLElement).dataset.type!); e.dataTransfer?.setData('type', (e.target as HTMLElement).dataset.type!);
}; };
</script> </script>

View File

@ -1,27 +1,13 @@
<template> <template>
<div class="h-full grid grid-rows-[auto_1fr]"> <div class="h-full grid grid-rows-[auto_1fr]">
<div class="h-10"> <div class="h-10">
<EditorMainHeader <EditorMainHeader :container="container" v-model:rightPanelCollapsed="rightPanelCollapsed" @preview="emit('preview')"></EditorMainHeader>
:container="container"
v-model:rightPanelCollapsed="rightPanelCollapsed"
@preview="emit('preview')"
></EditorMainHeader>
</div> </div>
<div class="h-full w-full overflow-hidden p-4"> <div class="h-full w-full overflow-hidden p-4">
<div <div class="juetan-editor-container w-full h-full flex items-center justify-center overflow-hidden relative bg-slate-50">
class="juetan-editor-container w-full h-full flex items-center justify-center overflow-hidden relative bg-slate-50" <div class="relative" :style="containerStyle" @dragover.prevent @click="onClick" @drop="onDragDrop" @wheel="onMouseWheel" @mousedown="onMouseDown">
>
<div
class="relative"
:style="containerStyle"
@dragover.prevent
@click="onClick"
@drop="onDragDrop"
@wheel="onMouseWheel"
@mousedown="onMouseDown"
>
<EditorMainBlock <EditorMainBlock
v-for="block in blocks" v-for="block in container.children"
:key="block.id" :key="block.id"
:data="block" :data="block"
:container="container" :container="container"
@ -60,12 +46,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Block, EditorKey } from '../core'; import { Block, EditorKey, formatContainerStyle } from '../core';
import { ContextKey } from '../core/plugin';
import EditorMainBlock from './EditorMainBlock.vue'; import EditorMainBlock from './EditorMainBlock.vue';
import EditorMainHeader from './EditorMainHeader.vue'; import EditorMainHeader from './EditorMainHeader.vue';
const rightPanelCollapsed = defineModel<boolean>('rightPanelCollapsed'); const rightPanelCollapsed = defineModel<boolean>('rightPanelCollapsed');
const { blocks, container, refLine, formatContainerStyle, scene } = inject(EditorKey)!; const { container, refLine, scene } = inject(ContextKey)!;
const { onMouseDown, onMouseWheel } = scene; const { onMouseDown, onMouseWheel } = scene;
const { active, xLines, yLines } = refLine; const { active, xLines, yLines } = refLine;
@ -107,14 +94,7 @@ const onDragDrop = (e: DragEvent) => {
<style scoped> <style scoped>
.juetan-editor-container { .juetan-editor-container {
--color: rgba(0, 0, 0, 0.2); --color: rgba(0, 0, 0, 0.2);
background: linear-gradient( background: linear-gradient(45deg, var(--color) 25%, transparent 25%, transparent 75%, var(--color) 75%, var(--color) 100%),
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%); linear-gradient(45deg, var(--color) 25%, transparent 25%, transparent 75%, var(--color) 75%, var(--color) 100%);
background-size: 20px 20px; background-size: 20px 20px;
background-position: 0 0, 10px 10px; background-position: 0 0, 10px 10px;

View File

@ -28,7 +28,8 @@
import { PropType } from "vue"; import { PropType } from "vue";
import { BlockerMap } from "../blocks"; import { BlockerMap } from "../blocks";
import DragResizer from "./DragResizer.vue"; import DragResizer from "./DragResizer.vue";
import { Block, Container, EditorKey } from "../core"; import { Block, Container } from "../core";
import { ContextKey } from "../core/plugin";
const props = defineProps({ const props = defineProps({
data: { data: {
@ -41,7 +42,7 @@ const props = defineProps({
}, },
}); });
const { setCurrentBlock, refLine } = inject(EditorKey)!; const { setCurrentBlock, refLine } = inject(ContextKey)!;
const { active, recordBlocksXY, updateRefLine } = refLine; const { active, recordBlocksXY, updateRefLine } = refLine;
/** /**

View File

@ -21,7 +21,7 @@
</span> </span>
<span class="text-gray-400 text-xs mr-2"> <span class="text-gray-400 text-xs mr-2">
组件 组件
<span class="inline-block w-8 text-gray-700">{{ blocks.length }} </span> <span class="inline-block w-8 text-gray-700">{{ container.children.length }} </span>
</span> </span>
<a-tooltip content="自适应比例" position="bottom"> <a-tooltip content="自适应比例" position="bottom">
<a-button type="text" @click="setContainerOrigin"> <a-button type="text" @click="setContainerOrigin">
@ -62,8 +62,8 @@
<script setup lang="ts"> <script setup lang="ts">
import InputTexter from './InputTexter.vue'; import InputTexter from './InputTexter.vue';
// import EditorMainConfig from './EditorMainConfig.vue'; // import EditorMainConfig from './EditorMainConfig.vue';
import { EditorKey } from '../core';
import { useVModel } from '@vueuse/core'; import { useVModel } from '@vueuse/core';
import { ContextKey } from '../core/plugin';
const props = defineProps({ const props = defineProps({
rightPanelCollapsed: { rightPanelCollapsed: {
@ -74,7 +74,7 @@ const props = defineProps({
const emit = defineEmits(['preview', 'update:rightPanelCollapsed']); const emit = defineEmits(['preview', 'update:rightPanelCollapsed']);
const collapsed = useVModel(props, 'rightPanelCollapsed', emit); const collapsed = useVModel(props, 'rightPanelCollapsed', emit);
const { container, blocks, setContainerOrigin } = inject(EditorKey)!; const { container, setContainerOrigin } = inject(ContextKey)!;
const visible = ref(false); const visible = ref(false);
</script> </script>

View File

@ -5,7 +5,7 @@
<template #icon> <template #icon>
<i :class="BlockerMap[model.type].icon"></i> <i :class="BlockerMap[model.type].icon"></i>
</template> </template>
{{ BlockerMap[model.type].title }}属性 {{ BlockerMap[model.type].title }}
</a-tag> </a-tag>
<a-scrollbar outer-class="h-full overflow-hidden" class="h-full overflow-auto"> <a-scrollbar outer-class="h-full overflow-hidden" class="h-full overflow-auto">
<a-form :model="{}" layout="vertical" class="pr-3"> <a-form :model="{}" layout="vertical" class="pr-3">
@ -23,12 +23,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { BlockerMap } from '../blocks'; import { BlockerMap } from '../blocks';
import { Block, EditorKey } from '../core'; import { Block } from '../core';
import { ContextKey } from '../core/plugin';
import EditorSetting from './EditorSetting.vue'; import EditorSetting from './EditorSetting.vue';
const collapsed = defineModel<boolean>('collapsed'); const collapsed = defineModel<boolean>('collapsed');
const model = defineModel<Block | null>('block'); const model = defineModel<Block | null>('block');
const { container } = inject(EditorKey)!; const { container } = inject(ContextKey)!;
</script> </script>
<style scoped></style> <style scoped></style>

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="p-3"> <div class="p-3">
<a-tag class="text-sm! mb-2 w-full" size="large" color="blue" :bordered="false"> <a-tag class="text-sm! mb-2 w-full" size="large" color="red" :bordered="false">
<template #icon> <template #icon>
<i class="icon-park-outline-config" ></i> <i class="icon-park-outline-config" ></i>
</template> </template>

View File

@ -1,4 +1,4 @@
import { Component } from "vue"; import { Component } from 'vue';
/** /**
* *
@ -70,25 +70,11 @@ export interface Block<T = any> {
params: T; params: T;
} }
export interface ContextMenuItem { export function formatBlockStyle(block: Block) {
type?: 'divider' | 'menu' const { bgColor, bgImage } = block;
showChildren?: boolean return {
onClick?: (item: ContextMenuItem) => void; backgroundColor: bgColor,
icon?: Component | string backgroundImage: bgImage ? `url(${bgImage})` : null,
name: string backgroundSize: '100% 100%',
tip?: string };
class?: string;
children?: ContextMenuItem[]
} }
export const useBlockContextMenu = (blocks: Block[]) => {
const items: ContextMenuItem[] = [
{
name: '删除',
icon: () => h('i', { class: 'icon-park-outline-delete' }),
onClick(item) {
},
}
]
}

View File

@ -1,3 +1,6 @@
import { CSSProperties } from 'vue';
import { Block } from './block';
/** /**
* *
*/ */
@ -42,8 +45,22 @@ export interface Container {
* *
*/ */
bgColor: string; bgColor: string;
/**
* 使
*/
langList: string[]; langList: string[];
/**
*
*/
langSwitch: number; langSwitch: number;
/**
*
*/
children: Block[];
/**
*
*/
current: Block | null;
} }
/** /**
@ -51,15 +68,30 @@ export interface Container {
*/ */
export const defaultContainer: Container = { export const defaultContainer: Container = {
id: 11, id: 11,
title: "国庆节喜庆版式设计", title: '国庆节喜庆版式设计',
description: "适用于国庆节1日-7日间上午9:00-10:00播出的版式设计", description: '适用于国庆节1日-7日间上午9:00-10:00播出的版式设计',
x: 0, x: 0,
y: 0, y: 0,
zoom: 0.7, zoom: 0.7,
width: 1920, width: 1920,
height: 1080, height: 1080,
bgImage: "", bgImage: '',
bgColor: "#ffffff", bgColor: '#ffffff',
langList: ['ch', 'en'], langList: ['ch', 'en'],
langSwitch: 0 langSwitch: 0,
children: [],
current: null,
}; };
export function formatContainerStyle(container: Container) {
const { width, height, bgColor, bgImage, zoom, x, y } = container;
return {
position: 'absolute',
width: `${width}px`,
height: `${height}px`,
backgroundColor: bgColor,
backgroundImage: bgImage ? `url(${bgImage})` : null,
backgroundSize: '100% 100%',
transform: `translate3d(${x}px, ${y}px, 0) scale(${zoom})`,
} as CSSProperties;
}

View File

@ -1,20 +1,16 @@
import { Container, defaultContainer } from "./container"; import { Container, defaultContainer } from './container';
import { Block } from "./block"; import { Block } from './block';
import { useReferenceLine } from "./ref-line"; import { useReferenceLine } from './ref-line';
import { BlockerMap } from "../blocks"; import { BlockerMap } from '../blocks';
import { cloneDeep } from "lodash-es"; import { cloneDeep } from 'lodash-es';
import { CSSProperties, InjectionKey } from "vue"; import { CSSProperties, InjectionKey } from 'vue';
import { useScene } from "./scene"; import { useScene } from './scene';
export const useEditor = () => { export const useEditor = () => {
/** /**
* *
*/ */
const container = ref<Container>({ ...defaultContainer }); const container = ref<Container>({ ...defaultContainer });
/**
*
*/
const blocks = ref<Block[]>([]);
/** /**
* *
*/ */
@ -22,7 +18,7 @@ export const useEditor = () => {
/** /**
* 线 * 线
*/ */
const refLine = useReferenceLine(blocks, currentBlock); const refLine = useReferenceLine(container);
/** /**
* *
*/ */
@ -43,17 +39,11 @@ export const useEditor = () => {
if (!blocker) { if (!blocker) {
return; return;
} }
const ids = blocks.value.map((i) => Number(i.id)); const ids = container.value.children.map(i => Number(i.id));
const maxId = ids.length ? Math.max.apply(null, ids) : 0; const maxId = ids.length ? Math.max.apply(null, ids) : 0;
const id = (maxId + 1).toString(); const id = (maxId + 1).toString();
const title = `${blocker.title}${id}`; const title = `${blocker.title}${id}`;
blocks.value.push({ container.value.children.push({ ...cloneDeep(blocker.initial), id, x, y, title });
...cloneDeep(blocker.initial),
id,
x,
y,
title,
});
}; };
/** /**
@ -61,9 +51,9 @@ export const useEditor = () => {
* @param block * @param block
*/ */
const rmBlock = (block: Block) => { const rmBlock = (block: Block) => {
const index = blocks.value.indexOf(block); const index = container.value.children.indexOf(block);
if (index > -1) { if (index > -1) {
blocks.value.splice(index, 1); container.value.children.splice(index, 1);
} }
}; };
@ -77,7 +67,7 @@ export const useEditor = () => {
return { return {
backgroundColor: bgColor, backgroundColor: bgColor,
backgroundImage: bgImage ? `url(${bgImage})` : undefined, backgroundImage: bgImage ? `url(${bgImage})` : undefined,
backgroundSize: "100% 100%", backgroundSize: '100% 100%',
}; };
}; };
@ -89,12 +79,12 @@ export const useEditor = () => {
const formatContainerStyle = (container: Container) => { const formatContainerStyle = (container: Container) => {
const { width, height, bgColor, bgImage, zoom, x, y } = container; const { width, height, bgColor, bgImage, zoom, x, y } = container;
return { return {
position: "absolute", position: 'absolute',
width: `${width}px`, width: `${width}px`,
height: `${height}px`, height: `${height}px`,
backgroundColor: bgColor, backgroundColor: bgColor,
backgroundImage: bgImage ? `url(${bgImage})` : undefined, backgroundImage: bgImage ? `url(${bgImage})` : undefined,
backgroundSize: "100% 100%", backgroundSize: '100% 100%',
transform: `translate3d(${x}px, ${y}px, 0) scale(${zoom})`, transform: `translate3d(${x}px, ${y}px, 0) scale(${zoom})`,
} as CSSProperties; } as CSSProperties;
}; };
@ -104,7 +94,7 @@ export const useEditor = () => {
* @param block * @param block
*/ */
const setCurrentBlock = (block: Block | null) => { const setCurrentBlock = (block: Block | null) => {
for (const item of blocks.value) { for (const item of container.value.children) {
item.actived = false; item.actived = false;
} }
if (!block) { if (!block) {
@ -121,7 +111,7 @@ export const useEditor = () => {
const setContainerOrigin = () => { const setContainerOrigin = () => {
container.value.x = 0; container.value.x = 0;
container.value.y = 0; container.value.y = 0;
const el = document.querySelector(".juetan-editor-container"); const el = document.querySelector('.juetan-editor-container');
if (el) { if (el) {
const { width, height } = el.getBoundingClientRect(); const { width, height } = el.getBoundingClientRect();
const wZoom = width / container.value.width; const wZoom = width / container.value.width;
@ -133,7 +123,6 @@ export const useEditor = () => {
return { return {
container, container,
blocks,
currentBlock, currentBlock,
refLine, refLine,
scene, scene,
@ -147,4 +136,4 @@ export const useEditor = () => {
}; };
}; };
export const EditorKey = Symbol("EditorKey") as InjectionKey<ReturnType<typeof useEditor>>; export const EditorKey = Symbol('EditorKey') as InjectionKey<ReturnType<typeof useEditor>>;

View File

@ -0,0 +1,255 @@
import { CSSProperties, Component } from 'vue';
import { Block } from './block';
import { Container, defaultContainer } from './container';
import { cloneDeep } from 'lodash-es';
import { useReferenceLine } from './ref-line';
import { useScene } from './scene';
export interface BlockItem {
/**
*
*/
type: string;
/**
*
*/
icon: string;
/**
*
*/
title: string;
/**
*
*/
description: string;
/**
*
*/
pickRender?: any;
/**
*
*/
listRender?: any;
/**
*
*/
showRender?: any;
/**
*
*/
viewRender?: any;
/**
*
*/
editRender?: any;
/**
*
*/
onInit?: any;
/**
*
*/
onLoad?: any;
/**
*
*/
onSave?: any;
}
interface SortableRender {
name: string;
sort?: number;
render: Component;
}
export interface Plugin {
/**
*
*/
name: string;
/**
* name
*/
hlRender?: SortableRender;
/**
* name
*/
hrRender?: SortableRender;
/**
* name
*/
ltRender?: SortableRender;
/**
* name
*/
lbRender?: SortableRender;
/**
* name
*/
mlRender?: SortableRender;
/**
* name
*/
mrRender?: SortableRender;
/**
* name
*/
rtRender?: SortableRender;
rbRender?: () => BlockItem;
addBlockItem?: () => BlockItem;
blockItems?: BlockItem | BlockItem[];
onInit?: (context: any) => void;
onSave?: () => void;
onLoad?: (data: any) => void;
}
export const usePluginContext = (pluginlist: Plugin[]) => {
const container: Ref<Container> = ref(cloneDeep(defaultContainer)) as any;
const blocks = computed(() => container.value.children);
const blockerMap: Record<string, BlockItem> = {};
const refLine = useReferenceLine(container);
const scene = useScene(container);
/** 顶部栏左侧 */
const HL: Ref<SortableRender[]> = ref([]);
/** 顶部栏右侧 */
const HR: Ref<SortableRender[]> = ref([]);
/** 左侧栏顶部 */
const LC: Ref<SortableRender[]> = ref([]);
/** 左侧栏底部 */
const LB: Ref<SortableRender[]> = ref([]);
/** 中间栏左侧 */
const ML: Ref<SortableRender[]> = ref([]);
/** 中间栏右侧 */
const MR: Ref<SortableRender[]> = ref([]);
/** 右侧栏顶部 */
const RC: Ref<SortableRender[]> = ref([]);
function load(data: any) {
data.children = data.children.map(item => {
return blockerMap[item.type]?.onLoad?.(item) ?? item;
});
for (const plugin of pluginlist) {
data = plugin.onLoad?.(data) ?? data;
}
return data;
}
function save(container: Container) {}
function addBlock(type: string, x = 0, y = 0) {
const blocker = blockerMap[type];
if (!type) {
return;
}
if (!blocker) {
return;
}
const ids = blocks.value.map(i => Number(i.id));
const maxId = ids.length ? Math.max.apply(null, ids) : 0;
const id = (maxId + 1).toString();
const title = `${blocker.title}${id}`;
const block = { ...cloneDeep(blocker.onInit?.()), id, x, y, title };
blocks.value.push(block);
}
function rmBlock(block: Block) {
const index = blocks.value.indexOf(block);
if (index > -1) {
blocks.value.splice(index, 1);
}
}
function setCurrentBlock(block: Block | null) {
for (const item of container.value.children) {
item.actived = false;
}
if (!block) {
container.value.current = null;
} else {
block.actived = true;
container.value.current = block;
}
}
function setContainerOrigin() {
container.value.x = 0;
container.value.y = 0;
const el = document.querySelector('.juetan-editor-container');
if (el) {
const { width, height } = el.getBoundingClientRect();
const wZoom = width / container.value.width;
const hZoom = height / container.value.width;
const zoom = Math.floor((wZoom > hZoom ? wZoom : hZoom) * 10000) / 10000;
container.value.zoom = zoom;
}
}
const context = {
container,
blockerMap,
refLine,
scene,
HL,
HR,
LB,
LC,
ML,
MR,
RC,
setCurrentBlock,
setContainerOrigin,
addBlock,
rmBlock,
};
function addRender(list: any[], render?: SortableRender) {
if (!render) {
return;
}
if (list.some(i => i.name === render.name)) {
console.log('name has existed');
return;
}
list.push(render);
}
for (const plugin of pluginlist) {
plugin.onInit?.(context);
addRender(HL.value, plugin.hlRender);
addRender(HR.value, plugin.hrRender);
addRender(LC.value, plugin.ltRender);
addRender(LB.value, plugin.lbRender);
addRender(ML.value, plugin.mlRender);
addRender(MR.value, plugin.mrRender);
addRender(RC.value, plugin.rtRender);
const bi = plugin.addBlockItem?.();
if (bi) {
blockerMap[bi.type] = bi;
}
}
HL.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
HR.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
LC.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
LB.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
ML.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
MR.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
RC.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
return context;
};
export const ContextKey = Symbol('ContextKey') as InjectionKey<ReturnType<typeof usePluginContext>>;
function corePlugin(): Plugin {
return {
name: 'core',
rtRender: {
name: 'ss',
render() {
return () => 123;
},
},
};
}

View File

@ -1,6 +1,7 @@
import { Ref } from "vue"; import { Ref } from "vue";
import { getClosestValInSortedArr } from "../utils/closest"; import { getClosestValInSortedArr } from "../utils/closest";
import { Block } from "./block"; import { Block } from "./block";
import { Container } from "./container";
/** /**
* 线 * 线
@ -8,7 +9,7 @@ import { Block } from "./block";
* @param current * @param current
* @returns * @returns
*/ */
export const useReferenceLine = (blocks: Ref<Block[]>, current: Ref<Block | null>) => { export const useReferenceLine = (container: Ref<Container>) => {
let xYsMap = new Map<number, number[]>(); let xYsMap = new Map<number, number[]>();
let yXsMap = new Map<number, number[]>(); let yXsMap = new Map<number, number[]>();
let sortedXs: number[] = []; let sortedXs: number[] = [];
@ -22,8 +23,8 @@ export const useReferenceLine = (blocks: Ref<Block[]>, current: Ref<Block | null
*/ */
const recordBlocksXY = () => { const recordBlocksXY = () => {
clear(); clear();
for (const block of blocks.value) { for (const block of container.value.children) {
if (block === current.value) { if (block === container.value.current) {
continue; continue;
} }
const { minX, minY, midX, midY, maxX, maxY } = getBlockBox(block); const { minX, minY, midX, midY, maxX, maxY } = getBlockBox(block);

View File

@ -0,0 +1,6 @@
export function arraify<T>(data: T | T[]): T[] {
if (!data) {
return [];
}
return Array.isArray(data) ? data : [data];
}

View File

@ -1,12 +1,4 @@
import { import { AnForm, AnFormInstance, AnFormModal, AnFormModalInstance, AnFormModalProps, AnFormProps, getModel } from '@/components/AnForm';
AnForm,
AnFormInstance,
AnFormModal,
AnFormModalInstance,
AnFormModalProps,
AnFormProps,
getModel,
} 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';
@ -14,14 +6,8 @@ import { InjectionKey, PropType, Ref, VNodeChild, defineComponent, ref } from 'v
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 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; export type TableColumnRender = (data: { record: TableData; column: TableColumnData; rowIndex: number }) => VNodeChild;
/** /**
@ -207,9 +193,7 @@ export const AnTable = defineComponent({
<div class="an-table table w-full"> <div class="an-table table w-full">
<div class={`mb-3 flex gap-2 toolbar justify-between`}> <div class={`mb-3 flex gap-2 toolbar justify-between`}>
{this.create && <AnFormModal {...this.create} ref="createRef" onSubmited={this.reload}></AnFormModal>} {this.create && <AnFormModal {...this.create} ref="createRef" onSubmited={this.reload}></AnFormModal>}
{this.modify && ( {this.modify && <AnFormModal {...this.modify} trigger={false} ref="modifyRef" onSubmited={this.refresh}></AnFormModal>}
<AnFormModal {...this.modify} trigger={false} ref="modifyRef" onSubmited={this.refresh}></AnFormModal>
)}
{this.$slots.action?.(this.renderData)} {this.$slots.action?.(this.renderData)}
{this.pluginer?.actions && ( {this.pluginer?.actions && (
<div class={`flex-1 flex gap-2 items-center`}> <div class={`flex-1 flex gap-2 items-center`}>
@ -220,12 +204,7 @@ export const AnTable = defineComponent({
)} )}
{this.search && ( {this.search && (
<div> <div>
<AnForm <AnForm ref="searchRef" v-model:model={this.search.model} items={this.search.items} formProps={this.search.formProps}>
ref="searchRef"
v-model:model={this.search.model}
items={this.search.items}
formProps={this.search.formProps}
>
{{ {{
submit: () => ( submit: () => (
<Button type="primary" loading={this.loading} onClick={this.reload}> <Button type="primary" loading={this.loading} onClick={this.reload}>
@ -279,10 +258,7 @@ export type AnTableInstance = InstanceType<typeof AnTable>;
/** /**
* *
*/ */
export type AnTableProps = Pick< export type AnTableProps = Pick<AnTableInstance['$props'], 'source' | 'columns' | 'search' | 'paging' | 'create' | 'modify' | 'tableProps' | 'pluginer'>;
AnTableInstance['$props'],
'source' | 'columns' | 'search' | 'paging' | 'create' | 'modify' | 'tableProps' | 'pluginer'
>;
export interface AnTableContext { export interface AnTableContext {
/** /**

View File

@ -1,16 +1,19 @@
import { dayjs } from '@/libs/dayjs'; import { dayjs } from '@/libs/dayjs';
import { Avatar } from '@arco-design/web-vue';
import { TableColumn } from '../hooks/useTableColumn'; import { TableColumn } from '../hooks/useTableColumn';
export function useUpdateColumn(extra: TableColumn = {}): TableColumn { export function useUpdateColumn(extra: TableColumn = {}): TableColumn {
return { return {
title: '更新', title: '最近修改',
dataIndex: 'createdAt', dataIndex: 'createdAt',
width: 180, width: 180,
render: ({ record }) => ( render: ({ record }) => (
<div class="flex flex-col overflow-hidden"> <div class="flex items-center gap-2 overflow-hidden">
<span>{record.updatedBy ?? '无'}</span> <span>
<span class="text-gray-400 text-xs truncate" title={record.updatedAt}> <Avatar size={22}>{record.updatedBy?.substr(0,1) ?? '无'}</Avatar>
{dayjs(record.updatedAt).fromNow()} </span>
<span class="truncate" title={record.updatedAt}>
{dayjs(record.updatedAt).fromNow()}
</span> </span>
</div> </div>
), ),
@ -24,10 +27,12 @@ export function useCreateColumn(extra: TableColumn = {}): TableColumn {
dataIndex: 'createdAt', dataIndex: 'createdAt',
width: 180, width: 180,
render: ({ record }) => ( render: ({ record }) => (
<div class="flex flex-col overflow-hidden"> <div class="flex direction-col items-center gap-2 overflow-hidden">
<span>{record.createdBy ?? '无'}</span> <span>
{record.createdBy ?? '无'}
</span>
<span class="text-gray-400 text-xs truncate" title={record.createdAt}> <span class="text-gray-400 text-xs truncate" title={record.createdAt}>
{dayjs(record.createdAt).fromNow()} {dayjs(record.createdAt).fromNow()}
</span> </span>
</div> </div>
), ),

View File

@ -0,0 +1,16 @@
import { ButtonProps, TableData } from '@arco-design/web-vue';
export interface AnTableActionBase {
text: string;
icon: string | Component;
visible: () => boolean;
disable: () => boolean;
buttonProps: ButtonProps;
}
interface AnTableActionBatch {
type: 'batch';
onClick: (rows: TableData) => void;
}
export type AnTableAction = AnTableActionBase & AnTableActionBatch;

View File

@ -35,6 +35,17 @@ export interface TableUseOptions extends Pick<AnTableProps, 'source' | 'tablePro
* ``` * ```
*/ */
columns?: TableColumn[]; columns?: TableColumn[];
/**
*
* @example
* ```ts
* [{
* text: '按钮',
* onClick: () => null,
* }]
* ```
*/
actions?: any[];
/** /**
* *
* @example * @example

View File

@ -2,22 +2,17 @@ import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
import localData from 'dayjs/plugin/localeData'; import localData from 'dayjs/plugin/localeData';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import { App } from 'vue';
/** declare module 'dayjs' {
* export var DATETIME: 'YYYY-MM-DD HH:mm';
* export var DATE: 'YYYY-MM-DD';
*/ export var TIME: 'HH:mm:ss';
const DATETIME = 'YYYY-MM-DD HH:mm'; export var install: (app: App) => void;
interface Dayjs {
/** _format: Dayjs['format'];
* }
*/ }
const DATE = 'YYYY-MM-DD';
/**
*
*/
const TIME = 'HH:mm:ss';
/** /**
* *
@ -39,17 +34,17 @@ dayjs.extend(localData);
/** /**
* *
*/ */
dayjs.DATETIME = DATETIME; dayjs.DATETIME = 'YYYY-MM-DD HH:mm';
/** /**
* *
*/ */
dayjs.DATE = DATE; dayjs.DATE = 'YYYY-MM-DD';
/** /**
* *
*/ */
dayjs.TIME = TIME; dayjs.TIME = 'HH:mm:ss';
/** /**
* *
@ -59,11 +54,15 @@ dayjs.prototype._format = dayjs.prototype.format;
/** /**
* *
*/ */
dayjs.prototype.format = function (format?: string) { dayjs.prototype.format = function (format: string = dayjs.DATETIME) {
if (format) { return this._format(format);
return this._format(format);
}
return this._format(dayjs.DATETIME);
}; };
export { DATE, DATETIME, TIME, dayjs }; /**
* VUE
*/
dayjs.install = function dayjsPlugin(app: App) {
app.config.globalProperties.$dayjs = dayjs;
};
export { dayjs };

View File

@ -1,16 +0,0 @@
import 'dayjs';
declare module 'dayjs' {
/**
*
*/
export var DATETIME: 'YYYY-MM-DD HH:mm';
export var DATE: 'YYYY-MM-DD';
export var TIME: 'HH:mm:ss';
interface Dayjs {
_format: Dayjs['format'];
}
}

View File

@ -1,6 +1,7 @@
import NProgress from 'nprogress'; import NProgress from 'nprogress';
import 'nprogress/nprogress.css'; import 'nprogress/nprogress.css';
import './nprogress.css'; import './nprogress.css';
import { App } from 'vue';
NProgress.configure({ NProgress.configure({
showSpinner: false, showSpinner: false,
@ -8,5 +9,9 @@ NProgress.configure({
minimum: 0.3, minimum: 0.3,
}); });
export { NProgress }; /**
* VUE
*/
NProgress.install = function (app: App) {};
export { NProgress };

8
src/libs/nprogress/interface.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import 'nprogress';
import { App } from 'vue';
declare module 'nprogress' {
interface NProgress {
install: (app: App) => void;
}
}

View File

@ -1,12 +1,18 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import App from './App.vue'; import App from '@/App.vue';
import { router } from './router'; import { router } from '@/router';
import { store } from './store'; import { store } from '@/store';
import { style } from './styles'; import { style } from '@/styles';
import { dayjs } from '@/libs/dayjs';
import { NProgress } from '@/libs/nprogress';
import { api } from '@/api';
const run = async () => { const run = async () => {
const app = createApp(App); const app = createApp(App);
app.use(dayjs);
app.use(NProgress);
app.use(store); app.use(store);
app.use(api);
app.use(style); app.use(style);
app.use(router); app.use(router);
await router.isReady(); await router.isReady();

View File

@ -13,9 +13,11 @@ export default defineComponent({
watch( watch(
() => route.path, () => route.path,
() => { () => {
selectedKeys.value = route.matched.map(i => i.path); selectedKeys.value = route.matched.map(i => i.aliasOf?.path ?? i.path);
}, },
{ immediate: true } {
immediate: true,
}
); );
function goto(route: MenuItem) { function goto(route: MenuItem) {
@ -40,14 +42,26 @@ export default defineComponent({
} }
return ( return (
<> <>
<a-menu-item key={route.path} v-slots={{ icon }} onClick={() => goto(route)}> <a-menu-item key={route.path} v-slots={{ icon }}>
<div class="flex items-center justify-between gap-2"> {route.link ? (
<div>{route.title}</div> <div class="flex items-center justify-between gap-2" onClick={() => goto(route)}>
<div class="text-xs text-gray-400"> <div>{route.title}</div>
{/* <a-badge count={8}>8</a-badge> */} <div class="text-xs text-gray-400">
{route.hide === 'prod' ? <a-tag color="red">{'开发'}</a-tag> : null} {/* <a-badge count={8}>8</a-badge> */}
{route.hide === 'prod' ? <a-tag color="red">{'开发'}</a-tag> : null}
</div>
</div> </div>
</div> ) : (
<router-link to={route.path}>
<div class="flex items-center justify-between gap-2">
<div>{route.title}</div>
<div class="text-xs text-gray-400">
{/* <a-badge count={8}>8</a-badge> */}
{route.hide === 'prod' ? <a-tag color="red">{'开发'}</a-tag> : null}
</div>
</div>
</router-link>
)}
</a-menu-item> </a-menu-item>
</> </>
); );

View File

@ -16,14 +16,16 @@
<a-avatar :size="32"> <a-avatar :size="32">
<img :src="userStore.avatar || 'https://github.com/juetan.png'" :alt="userStore.nickname" /> <img :src="userStore.avatar || 'https://github.com/juetan.png'" :alt="userStore.nickname" />
</a-avatar> </a-avatar>
<div class="leading-4 my-2"> <div class="leading-4 text-base my-2">
{{ userStore.nickname }} {{ userStore.nickname }}
<span class="text-xs text-gray-400">({{ userStore.username }})</span> <a-tag color="red" size="small" >管理员</a-tag>
<div class="text-xs text-gray-400">管理员</div> <div class="text-xs text-gray-400">
<span class="text-gray-400">@{{ userStore.username }}</span>
</div>
</div> </div>
</div> </div>
</a-doption> </a-doption>
<a-divider :margin="4"></a-divider> <a-divider :margin="4" class="border-gray-100!"></a-divider>
<a-doption @click="open()"> <a-doption @click="open()">
<template #icon> <template #icon>
<i class="icon-park-outline-lock"></i> <i class="icon-park-outline-lock"></i>
@ -36,7 +38,7 @@
</template> </template>
账号信息 账号信息
</a-doption> </a-doption>
<a-divider :margin="4"></a-divider> <!-- <a-divider :margin="4" class="border-gray-100!"></a-divider> -->
<a-doption @click="router.push('/user')"> <a-doption @click="router.push('/user')">
<template #icon> <template #icon>
<i class="icon-park-outline-config"></i> <i class="icon-park-outline-config"></i>
@ -49,7 +51,7 @@
</template> </template>
关于 关于
</a-doption> </a-doption>
<a-divider :margin="4"></a-divider> <a-divider :margin="4" class="border-gray-100!"></a-divider>
<a-doption @click="logout"> <a-doption @click="logout">
<template #icon> <template #icon>
<i class="icon-park-outline-power"></i> <i class="icon-park-outline-power"></i>
@ -62,8 +64,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { useFormModal } from '@/components/AnForm'; import { useFormModal } from '@/components/AnForm';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store/user';
import { delConfirm } from '@/utils'; import { delConfirm, sleep } from '@/utils';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
const userStore = useUserStore(); const userStore = useUserStore();
@ -74,10 +76,13 @@ const logout = async () => {
await delConfirm({ await delConfirm({
content: '退出后将跳转到登录页面,确定退出吗?', content: '退出后将跳转到登录页面,确定退出吗?',
okText: '确定退出', okText: '确定退出',
async onBeforeOk() {
await sleep(2000);
userStore.clearUser();
Message.success('提示:已退出登陆!');
router.push({ path: '/login', query: { redirect: route.path } });
},
}); });
userStore.clearUser();
Message.success('提示:已退出登陆!');
router.push({ path: '/login', query: { redirect: route.path } });
}; };
const { component: PasswordModal, open } = useFormModal({ const { component: PasswordModal, open } = useFormModal({

View File

@ -1,8 +1,6 @@
<template> <template>
<a-layout class="layout"> <a-layout class="layout">
<a-layout-header <a-layout-header class="h-13 overflow-hidden flex justify-between items-center gap-4 px-2 pr-4 border-b border-slate-200 bg-white dark:bg-slate-800 dark:border-slate-700">
class="h-13 overflow-hidden flex justify-between items-center gap-4 px-2 pr-4 border-b border-slate-200 bg-white dark:bg-slate-800 dark:border-slate-700"
>
<div class="h-13 flex items-center"> <div class="h-13 flex items-center">
<!-- <a-button size="small" @click="isCollapsed = !isCollapsed"> <!-- <a-button size="small" @click="isCollapsed = !isCollapsed">
<template #icon> <template #icon>
@ -11,7 +9,7 @@
</a-button> --> </a-button> -->
<router-link to="/" class="px-2 flex items-center gap-2 text-slate-700"> <router-link to="/" class="px-2 flex items-center gap-2 text-slate-700">
<img src="/favicon.ico" alt="" width="24" height="24" class="" /> <img src="/favicon.ico" alt="" width="24" height="24" class="" />
<h1 class="relative text-[18px] leading-[22px] dark:text-white m-0 p-0 font-semibold"> <h1 class="relative text-[18px] leading-[22px] dark:text-white m-0 p-0 font-normal">
{{ appStore.title }} {{ appStore.title }}
<span class="absolute -right-10 -top-1 font-normal text-xs text-gray-400"> v0.0.1 </span> <span class="absolute -right-10 -top-1 font-normal text-xs text-gray-400"> v0.0.1 </span>
</h1> </h1>
@ -19,18 +17,6 @@
</router-link> </router-link>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div>
<a-input-search placeholder="搜索菜单/页面" :allow-clear="true"></a-input-search>
</div>
<a-tooltip content="上传文件">
<a-button @click="() => null" class="!bg-transparent !hover:bg-gray-100">
<template #icon>
<a-badge :count="1" :dot="true">
<i class="text-base icon-park-outline-upload-one"></i>
</a-badge>
</template>
</a-button>
</a-tooltip>
<a-tooltip v-for="btn in buttons" :key="btn.icon" :content="btn.tooltip"> <a-tooltip v-for="btn in buttons" :key="btn.icon" :content="btn.tooltip">
<a-button @click="btn.onClick" class="!bg-transparent !hover:bg-gray-100"> <a-button @click="btn.onClick" class="!bg-transparent !hover:bg-gray-100">
<template #icon> <template #icon>
@ -57,21 +43,26 @@
<Menu /> <Menu />
</a-scrollbar> </a-scrollbar>
<template #trigger="{ collapsed }"> <template #trigger="{ collapsed }">
<i <div class="w-full h-full py-1 px-1 flex justify-between items-center gap-2" @click.stop>
:class="collapsed ? `icon-park-outline-expand-left` : 'icon-park-outline-expand-right'" <div
class="text-gray-400 text-base hover:text-gray-700" class="inline-block w-10 h-10 h-full rounded flex items-center justify-center hover:bg-zinc-100 text-base text-gray-400"
></i> @click="() => (isCollapsed = !isCollapsed)"
>
<i :class="collapsed ? `icon-park-outline-expand-left` : 'icon-park-outline-expand-right'"></i>
</div>
</div>
</template> </template>
</a-layout-sider> </a-layout-sider>
<a-layout class="layout-content flex-1"> <a-layout class="layout-content flex-1">
<a-layout-content class="overflow-x-auto"> <a-layout-content class="overflow-x-auto">
<a-spin :loading="appStore.pageLoding" tip="页面加载中,请稍等..." class="block h-full w-full"> <a-spin :loading="appStore.pageLoding" class="block h-full w-full">
<template #icon> <template #icon>
<IconSync></IconSync> <div class="loader"></div>
</template> </template>
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<keep-alive :include="menuStore.caches"> <keep-alive :include="menuStore.caches">
<component :is="Component"></component> <component v-if="hasAuth" :is="Component"></component>
<AnForbidden v-else></AnForbidden>
</keep-alive> </keep-alive>
</router-view> </router-view>
</a-spin> </a-spin>
@ -82,32 +73,46 @@
</template> </template>
<script lang="tsx" setup> <script lang="tsx" setup>
import { useAppStore } from '@/store'; import { useAppStore } from '@/store/app';
import { useMenuStore } from '@/store/menu'; import { useMenuStore } from '@/store/menu';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
import { IconSync } from '@arco-design/web-vue/es/icon'; import { useFullscreen } from '@vueuse/core';
import Menu from './Menu.vue'; import Menu from './Menu.vue';
import userDropdown from './UserDropdown.vue'; import userDropdown from './UserDropdown.vue';
import { useUserStore } from '@/store/user';
defineOptions({ name: 'LayoutPage' }); defineOptions({ name: 'LayoutPage' });
const route = useRoute(); const route = useRoute();
const appStore = useAppStore(); const appStore = useAppStore();
const menuStore = useMenuStore(); const menuStore = useMenuStore();
const userStore = useUserStore();
const isCollapsed = ref(false); const isCollapsed = ref(false);
const themeConfig = ref({ visible: false }); const themeConfig = ref({ visible: false });
const { toggle, isSupported } = useFullscreen();
const ButtonWithTooltip = (props: { tooltip: string; icon: string; onClick: any }) => { const hasAuth = computed(() => {
return ( return route.matched.every(item => {
<a-tooltip content={props.tooltip}> const needAuth = item.meta.auth;
<a-button onClick={props.onClick} class="!bg-transparent !hover:bg-gray-100"> const userAuth = userStore.auth;
{{ if (needAuth?.includes('*')) {
icon: () => <i class={`${props.icon} text-base`}></i>, return true;
}} }
</a-button> if (!userStore.accessToken && needAuth?.includes('unlogin')) {
</a-tooltip> return true;
); }
}; if (!userStore.accessToken) {
return false;
}
if (!needAuth) {
return true;
}
if (userAuth.some(i => needAuth.some(j => j === i))) {
return true;
}
return false;
});
});
const buttons = [ const buttons = [
{ {
@ -118,10 +123,14 @@ const buttons = [
}, },
}, },
{ {
icon: 'icon-park-outline-config', icon: 'icon-park-outline-full-screen',
tooltip: '设置', tooltip: '全屏',
onClick: () => { onClick: () => {
themeConfig.value.visible = true; if (!isSupported) {
Message.info('您的浏览器不支持全屏');
return;
}
toggle();
}, },
}, },
{ {
@ -131,6 +140,13 @@ const buttons = [
window.open('https://github.com/appnify/starter-vue', '_blank'); window.open('https://github.com/appnify/starter-vue', '_blank');
}, },
}, },
{
icon: 'icon-park-outline-config',
tooltip: '设置',
onClick: () => {
themeConfig.value.visible = true;
},
},
]; ];
</script> </script>
@ -196,16 +212,40 @@ const buttons = [
background-color: #e4ebf1; background-color: #e4ebf1;
transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1); transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
} }
/* HTML: <div class="loader"></div> */
.loader {
width: 120px;
height: 16px;
border-radius: 20px;
color: #222;
border: 2px solid;
position: relative;
}
.loader::before {
content: '';
position: absolute;
margin: 2px;
inset: 0 100% 0 0;
border-radius: inherit;
background: currentColor;
animation: l6 2s infinite;
}
@keyframes l6 {
100% {
inset: 0;
}
}
</style> </style>
<route lang="json"> <route lang="json">
{ {
"redirect": "/",
"meta": { "meta": {
"name": "LayoutPage", "name": "LayoutPage",
"sort": 101, "sort": 101,
"title": "首页", "title": "首页",
"icon": "icon-park-outline-home", "icon": "icon-park-outline-home"
"keepAlive": true
} }
} }
</route> </route>

View File

@ -13,7 +13,7 @@
</div> </div>
<div class="flex items-center justify-center w-full overflow-hidden"> <div class="flex items-center justify-center w-full overflow-hidden">
<div <div
class="login-box w-[960px] h-[560px] relative mx-4 grid md:grid-cols-2 rounded overflow-hidden border border-blue-100" class="login-box w-[960px] h-[560px] relative mx-4 grid md:grid-cols-2 rounded-lg overflow-hidden border border-blue-100"
> >
<div <div
class="login-left relative hidden md:block w-full h-full overflow-hidden bg-[rgb(var(--primary-6))] px-4" class="login-left relative hidden md:block w-full h-full overflow-hidden bg-[rgb(var(--primary-6))] px-4"
@ -42,7 +42,7 @@
<a-link @click="onForgetPassword">?</a-link> <a-link @click="onForgetPassword">?</a-link>
</div> </div>
<a-button type="primary" html-type="submit" long class="mt-2" :loading="loading" @click="onSubmitForm"> <a-button type="primary" html-type="submit" long class="mt-2" :loading="loading" @click="onSubmitForm">
{{ loading ? "登陆中" : "立即登录" }} {{ loading ? '登陆中' : '立即登录' }}
</a-button> </a-button>
<p type="text" long class="text-gray-400 text-center m-0">暂不支持其他方式登录</p> <p type="text" long class="text-gray-400 text-center m-0">暂不支持其他方式登录</p>
</a-space> </a-space>
@ -55,18 +55,19 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { api } from "@/api"; import { api } from '@/api';
import { dayjs } from "@/libs/dayjs"; import { dayjs } from '@/libs/dayjs';
import { useAppStore, useUserStore } from "@/store"; import { useAppStore } from '@/store/app';
import { FieldRule, Form, Message, Modal, Notification } from "@arco-design/web-vue"; import { useUserStore } from '@/store/user';
import { reactive } from "vue"; import { FieldRule, Form, Message, Modal, Notification } from '@arco-design/web-vue';
import { reactive } from 'vue';
defineOptions({ name: "LoginPage" }); defineOptions({ name: 'LoginPage' });
const meridiem = dayjs.localeData().meridiem(dayjs().hour(), dayjs().minute()); const meridiem = dayjs.localeData().meridiem(dayjs().hour(), dayjs().minute());
const appStore = useAppStore(); const appStore = useAppStore();
const userStore = useUserStore(); const userStore = useUserStore();
const model = reactive({ username: "", password: "" }); const model = reactive({ username: '', password: '' });
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const loading = ref(false); const loading = ref(false);
@ -76,22 +77,22 @@ const formRules: Record<string, FieldRule[]> = {
username: [ username: [
{ {
required: true, required: true,
message: "请输入账号/手机号/邮箱", message: '请输入账号/手机号/邮箱',
}, },
], ],
password: [ password: [
{ {
required: true, required: true,
message: "请输入密码", message: '请输入密码',
}, },
], ],
}; };
const onForgetPassword = () => { const onForgetPassword = () => {
Modal.info({ Modal.info({
title: "忘记密码?", title: '忘记密码?',
content: "如已忘记密码,请联系管理员进行密码重置!", content: '如已忘记密码,请联系管理员进行密码重置!',
modalClass: "text-center", modalClass: 'text-center',
maskClosable: false, maskClosable: false,
}); });
}; };
@ -105,10 +106,10 @@ const onSubmitForm = async () => {
const res = await api.auth.login(model); const res = await api.auth.login(model);
userStore.setAccessToken(res.data.data); userStore.setAccessToken(res.data.data);
Notification.success({ Notification.success({
title: "提示", title: '提示',
content: `${meridiem}好,您已成功登陆本系统!`, content: `${meridiem}好,您已成功登陆本系统!`,
}); });
router.push({ path: (route.query.redirect as string) || "/" }); router.push({ path: (route.query.redirect as string) || '/' });
} catch (error: any) { } catch (error: any) {
const message = error?.response?.data?.message; const message = error?.response?.data?.message;
message && Message.warning(`提示:${message}`); message && Message.warning(`提示:${message}`);

View File

@ -18,7 +18,7 @@ const { component: CategoryTable } = useTable({
<div class="flex flex-col overflow-hidden"> <div class="flex flex-col overflow-hidden">
<span> <span>
{record.name} {record.name}
<span class="text-gray-400 text-xs truncate ml-2">@{record.code}</span> <span class="text-orange-500 truncate ml-2">@{record.code}</span>
</span> </span>
<div class="text-gray-400 text-xs truncate mt-0.5">{record.description}</div> <div class="text-gray-400 text-xs truncate mt-0.5">{record.description}</div>
</div> </div>

View File

@ -5,16 +5,8 @@
</template> </template>
上传 上传
</a-button> </a-button>
<a-modal <a-modal class="an-upload" title="上传文件" title-align="start" v-model:visible="visible" :width="960" :mask-closable="false" :on-before-cancel="onBeforeCancel" @close="onClose">
title="上传文件" <div class="flex items-center justify-between gap-4 py-0">
title-align="start"
v-model:visible="visible"
:width="960"
:mask-closable="false"
:on-before-cancel="onBeforeCancel"
@close="onClose"
>
<div class="flex items-center gap-4 py-0">
<a-upload <a-upload
ref="uploadRef" ref="uploadRef"
class="upload" class="upload"
@ -27,7 +19,7 @@
@error="onUploadError" @error="onUploadError"
> >
<template #upload-button> <template #upload-button>
<a-button type="primary"> <a-button type="outline">
<template #icon> <template #icon>
<i class="icon-park-outline-upload-one"></i> <i class="icon-park-outline-upload-one"></i>
</template> </template>
@ -35,8 +27,8 @@
</a-button> </a-button>
</template> </template>
</a-upload> </a-upload>
<div class="flex-1 flex items-center text-gray-400"> <div class="flex items-center text-gray-400">
归类为: 已选择 {{ fileList.length }} 归类为:
<span> <span>
<a-select v-model="group" :bordered="false" :options="groupOptions"></a-select> <a-select v-model="group" :bordered="false" :options="groupOptions"></a-select>
</span> </span>
@ -59,14 +51,12 @@
<div v-show="item.status !== 'done'"> <div v-show="item.status !== 'done'">
<a-link v-show="item.status === 'uploading'" @click="pauseItem(item)"> </a-link> <a-link v-show="item.status === 'uploading'" @click="pauseItem(item)"> </a-link>
<a-link v-show="item.status === 'error'" @click="retryItem(item)"> </a-link> <a-link v-show="item.status === 'error'" @click="retryItem(item)"> </a-link>
<a-link v-show="item.status === 'init' || item.status === 'error'" @click="removeItem(item)"> <a-link v-show="item.status === 'init' || item.status === 'error'" @click="removeItem(item)"> </a-link>
移除
</a-link>
</div> </div>
</div> </div>
<a-progress :percent="formatProgress(item, true)" :show-text="false" class="block!"></a-progress> <a-progress :percent="formatProgress(item, true)" :show-text="false" class="block! mt-0.5"></a-progress>
<div class="flex items-center justify-between gap-2 text-gray-400 mt-1.5 text-xs"> <div class="flex items-center justify-between gap-2 text-gray-400 mt-1.5 text-xs">
<span class="text-xs"> <!-- <span class="text-xs">
<span v-if="item.status === 'init'"> <span v-if="item.status === 'init'">
<i class="icon-park-outline-lightning"></i> <i class="icon-park-outline-lightning"></i>
等待上传 等待上传
@ -86,14 +76,10 @@
</span> </span>
<span> <span>
<span v-if="item.status === 'init'"> </span> <span v-if="item.status === 'init'"> </span>
<span v-else-if="item.status === 'uploading'"> <span v-else-if="item.status === 'uploading'"> 速度{{ formatSpeed(item.uid) }}/s, 进度{{ formatProgress(item) }} % </span>
速度{{ formatSpeed(item.uid) }}/s, 进度{{ formatProgress(item) }} % <span v-else-if="item.status === 'done'"> 耗时{{ fileMap.get(item.uid)?.cost || 0 }} , 平均{{ formatAspeed(item.uid) }}/s </span>
</span>
<span v-else-if="item.status === 'done'">
耗时{{ fileMap.get(item.uid)?.cost || 0 }} , 平均{{ formatAspeed(item.uid) }}/s
</span>
<span v-else="item.status === 'error'"> {{ fileMap.get(item.uid)?.error }} </span> <span v-else="item.status === 'error'"> {{ fileMap.get(item.uid)?.error }} </span>
</span> </span> -->
</div> </div>
</div> </div>
</li> </li>
@ -106,14 +92,10 @@
<template #footer> <template #footer>
<div class="flex justify-between gap-2 items-center"> <div class="flex justify-between gap-2 items-center">
<div class="text-gray-400">已上传 {{ stat.doneCount }}/{{ fileList.length }} </div> <div class="text-gray-400"></div>
<div class="space-x-2"> <div class="space-x-2">
<a-button type="text" :disabled="!fileList.length || Boolean(stat.uploadingCount)" @click="clearUploaded"> <a-button type="text" :disabled="!fileList.length || Boolean(stat.uploadingCount)" @click="clearUploaded"> </a-button>
清空 <a-button type="primary" :disabled="!fileList.length || !stat.initCount" @click="startUpload"> </a-button>
</a-button>
<a-button type="primary" :disabled="!fileList.length || !stat.initCount" @click="startUpload">
开始上传
</a-button>
</div> </div>
</div> </div>
</template> </template>
@ -306,4 +288,10 @@ const groupOptions = [
]; ];
</script> </script>
<style lang="less" scoped></style> <style lang="less">
.an-upload {
.arco-modal-body {
padding-top: 16px;
}
}
</style>

View File

@ -25,14 +25,14 @@
</template> </template>
<script setup lang="tsx"> <script setup lang="tsx">
import { FileCategory, api } from '@/api';
import { useTable, useTableDelete, useUpdateColumn } from '@/components/AnTable';
import { FileTypes } from '@/constants/file';
import { Message } from '@arco-design/web-vue';
import numeral from 'numeral'; import numeral from 'numeral';
import AnCategory from './AnCategory.vue'; import AnCategory from './AnCategory.vue';
import AnPreview from './AnPreview.vue'; import AnPreview from './AnPreview.vue';
import AnUpload from './AnUpload.vue'; import AnUpload from './AnUpload.vue';
import { FileCategory, api } from '@/api';
import { useCreateColumn, useTable, useTableDelete, useUpdateColumn } from '@/components/AnTable';
import { FileTypes } from '@/constants/file';
import { Message } from '@arco-design/web-vue';
import { getIcon } from './util'; import { getIcon } from './util';
const current = ref<FileCategory>(); const current = ref<FileCategory>();
@ -116,7 +116,7 @@ const {
width: 150, width: 150,
render: ({ record }) => numeral(record.size).format('0 b'), render: ({ record }) => numeral(record.size).format('0 b'),
}, },
useCreateColumn(), // useCreateColumn(),
useUpdateColumn(), useUpdateColumn(),
{ {
type: 'button', type: 'button',

File diff suppressed because it is too large Load Diff

View File

@ -1,59 +0,0 @@
<template>
<div ref="editorRef" class="w-full h-full border"></div>
</template>
<script setup lang="ts">
import * as monaco from "monaco-editor";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
self.MonacoEnvironment = {
getWorker(_, label) {
if (label === "json") {
return new jsonWorker();
}
if (label === "css" || label === "scss" || label === "less") {
return new cssWorker();
}
if (label === "html" || label === "handlebars" || label === "razor") {
return new htmlWorker();
}
if (label === "typescript" || label === "javascript") {
return new tsWorker();
}
return new editorWorker();
},
};
let editor: monaco.editor.IStandaloneCodeEditor | undefined;
const editorRef = ref<HTMLElement | null>(null);
onMounted(async () => {
await nextTick();
if (editorRef.value) {
editor = monaco.editor.create(editorRef.value, {
value: "",
language: "html",
});
}
});
const props = defineProps({
content: {
type: String,
default: "",
},
});
watch(
() => props.content,
(value) => {
editor?.setValue(value);
}
);
</script>
<style lang="less" scoped></style>

View File

@ -1,124 +0,0 @@
<template>
<bread-page>
<div class="h-full grid grid-cols-[1fr_auto_1fr]">
<div>
<a-tabs @change="onChange">
<a-tab-pane v-for="tag in tags" :key="tag.name" :title="tag.description">
<a-form :model="{}" layout="vertical">
<a-form-item label="新增接口">
<a-radio-group type="button" v-model="type.create">
<a-radio
v-for="route in routes.filter(i => i.tag === tag.name)"
:value="route.operationId"
:key="route.path"
>
{{ route.description }}
</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="修改接口">
<a-radio-group type="button" v-model="type.modify">
<a-radio
v-for="route in routes.filter(i => i.tag === tag.name)"
:value="route.operationId"
:key="route.path"
>
{{ route.description }}
</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="查询接口">
<a-radio-group type="button" v-model="type.select">
<a-radio
v-for="route in routes.filter(i => i.tag === tag.name)"
:value="route.operationId"
:key="route.path"
>
{{ route.description }}
</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="删除接口">
<a-radio-group type="button" v-model="type.delete">
<a-radio
v-for="route in routes.filter(i => i.tag === tag.name)"
:value="route.operationId"
:key="route.path"
>
{{ route.description }}
</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</div>
<a-divider direction="vertical"></a-divider>
<div class="h-full grid grid-rows-[auto_1fr] gap-2">
<div>
<a-button type="primary" @click="onOpen"></a-button>
</div>
<editor-modal class="bg-gray-100" :content="content"></editor-modal>
</div>
</div>
</bread-page>
</template>
<script setup lang="ts">
import ejs from 'ejs';
import doc from './data.json';
import editorModal from './editor.vue';
import template from './page.ejs?raw';
const content = ref('');
const { tags, routes } = doc;
const type = ref({
create: undefined,
select: undefined,
modify: undefined,
delete: undefined,
});
const onChange = (value: string | number) => {
console.log(value);
};
const onOpen = () => {
const data = {
tag: '',
operationId: '',
create: {},
select: {},
modify: {},
delete: {},
};
for (const route of doc.routes) {
if (route.operationId === type.value.create) {
data.create = route;
}
if (route.operationId === type.value.select) {
data.select = route;
}
if (route.operationId === type.value.modify) {
data.modify = route;
}
if (route.operationId === type.value.delete) {
data.delete = route;
}
}
content.value = ejs.render(template, data);
};
</script>
<style lang="less" scoped></style>
<route lang="json">
{
"meta": {
"sort": 20010,
"hide": "prod",
"title": "接口生成",
"icon": "icon-park-outline-code"
}
}
</route>

View File

@ -1,103 +0,0 @@
<template>
<BreadPage>
<ani-table> </ani-table>
</BreadPage>
</template>
<script setup lang="tsx">
import { api } from "@/api";
import { createColumn, updateColumn, useAniTable } from "@/components";
const [aniTable, aniCtx] = useAniTable({
data: async (model, paging) => {
return api.<%= select.tag %>.<%= operationId %>({ ...model, ...paging });
},
columns: [
{
title: "用户描述",
dataIndex: "description",
},
createColumn,
updateColumn,
{
title: "操作",
type: "button",
width: 180,
buttons: [
{
type: "modify",
text: "修改",
},
{
type: "delete",
text: "删除",
onClick: async ({ record }) => {
return api.<%= tag %>.<%= operationId %>(record.id);
},
},
],
},
],
search: {
items: [
{
extend: "nickname",
required: false,
type: 'search',
enableLoad: true,
itemProps: {
hideLabel: true,
},
nodeProps: {
placeholder: "用户昵称",
},
},
],
},
create: {
title: "新建用户",
modalProps: {
width: 732,
maskClosable: false,
},
formProps: {
layout: "vertical",
class: "!grid grid-cols-2 gap-x-6",
},
items: [
<%_ for(const item of create.bodyParams) { _%>
{
field: "<%= item.name %>",
label: "<%= item.description %>",
type: "<%= item.type %>",
required: <%= item.required %>,
},
<%_ } _%>
],
submit: ({ model }) => {
return api.<%= create.tag %>.<%= create.operationId %>(model);
},
},
modify: {
extend: true,
title: "修改用户",
submit: ({ model }) => {
return api.<%= modify.tag %>.<%= modify.operationId %>(model.id, model);
},
},
});
</script>
<style scoped></style>
<%_ if(false) { _%>
<route lang="json">
{
"meta": {
"sort": 10301,
"title": "用户管理",
"icon": "icon-park-outline-user"
}
}
</route>
<%_ } _%>

View File

@ -1,15 +1,13 @@
<template> <template>
<div></div> <iframe src="https://nav.juetan.cn" frameborder="0" class="w-full h-full overflow-hidden"></iframe>
</template> </template>
<route lang="json"> <route lang="json">
{ {
"component": null,
"meta": { "meta": {
"sort": 120012, "sort": 120012,
"hide": "prod", "hide": "prod",
"title": "前端导航", "title": "前端导航",
"link": "https://nav.juetan.cn",
"icon": "icon-park-outline-mail" "icon": "icon-park-outline-mail"
} }
} }

View File

@ -19,6 +19,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="bg-white px-5 py-4 rounded-sm mt-4"> <div class="bg-white px-5 py-4 rounded-sm mt-4">
<div>常用服务</div> <div>常用服务</div>
<div class="grid grid-cols-5 justify-between gap-4 mt-4"> <div class="grid grid-cols-5 justify-between gap-4 mt-4">
@ -58,7 +59,7 @@
<ul class="list-none w-full m-0 p-0"> <ul class="list-none w-full m-0 p-0">
<li v-for="i in 8" class="w-full h-6 items-center overflow-hidden justify-between flex gap-2 mb-2"> <li v-for="i in 8" class="w-full h-6 items-center overflow-hidden justify-between flex gap-2 mb-2">
<a-tag>{{ i }}</a-tag> <a-tag>{{ i }}</a-tag>
<span class="flex-1 truncate hover:underline underline-offset-2 cursor-pointer"> <span class="flex-1 truncate hover:underline underline-offset-2 hover:text-brand-500 cursor-pointer">
但是预测已加载的数据不足以 但是预测已加载的数据不足以
</span> </span>
<span class="text-gray-400">3天前</span> <span class="text-gray-400">3天前</span>
@ -71,7 +72,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useUserStore } from '@/store'; import { useUserStore } from '@/store/user';
const userStore = useUserStore(); const userStore = useUserStore();
@ -108,9 +109,10 @@ const stat = {
<route lang="json"> <route lang="json">
{ {
"alias": "/",
"meta": { "meta": {
"sort": 1000, "sort": 1000,
"title": "首页", "title": "概览",
"icon": "icon-park-outline-home" "icon": "icon-park-outline-home"
} }
} }

View File

@ -1,6 +1,10 @@
<template> <template>
<bread-page> <bread-page>
<a-form :model="{}" :label-col-props="{ span: 3 }" label-align="left" layout="vertical" class="space-y-6"> <div>
<h2 class="m-0 text-base">常规设置</h2>
<p class="text-gray-500 mt-1">首次为你的帐户添加密码时你需要前往密码重置页面以便我们验证你的身份</p>
</div>
<a-form :model="{}" label-align="left" class="space-y-6 mt-6 col-form divide-y">
<a-form-item label="站点LOGO"> <a-form-item label="站点LOGO">
<a-avatar :size="64"> <a-avatar :size="64">
<img :src="appStore.logo" alt="" /> <img :src="appStore.logo" alt="" />
@ -10,7 +14,7 @@
</a-avatar> </a-avatar>
<template #help>提示仅支持 5MB 以内大小, png jpg 格式的图片 </template> <template #help>提示仅支持 5MB 以内大小, png jpg 格式的图片 </template>
</a-form-item> </a-form-item>
<a-form-item label="站点名称"> <a-form-item label="站点名称" class="pt-6">
<a-input <a-input
v-model="appStore.title" v-model="appStore.title"
placeholder="请输入" placeholder="请输入"
@ -21,7 +25,7 @@
></a-input> ></a-input>
<template #help> 用作系统内显示的名称可在后台修改 </template> <template #help> 用作系统内显示的名称可在后台修改 </template>
</a-form-item> </a-form-item>
<a-form-item label="站点描述"> <a-form-item label="站点描述" class="pt-6">
<a-textarea <a-textarea
v-model="appStore.subtitle" v-model="appStore.subtitle"
placeholder="请输入" placeholder="请输入"
@ -31,11 +35,11 @@
></a-textarea> ></a-textarea>
<template #help> 启用后消息通知将在左上角进行提示. </template> <template #help> 启用后消息通知将在左上角进行提示. </template>
</a-form-item> </a-form-item>
<a-form-item label="站点URL"> <a-form-item label="站点URL" class="pt-6">
<a-input v-model="appStore.title" placeholder="请输入" class="!w-[432px]" allow-clear></a-input> <a-input v-model="appStore.title" placeholder="请输入" class="!w-[432px]" allow-clear></a-input>
<template #help> 示例https://www.juetan.cn </template> <template #help> 示例https://www.juetan.cn </template>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item class="pt-6">
<a-button type="primary">保存修改</a-button> <a-button type="primary">保存修改</a-button>
</a-form-item> </a-form-item>
</a-form> </a-form>

View File

@ -2,10 +2,6 @@
<AnPage></AnPage> <AnPage></AnPage>
</template> </template>
<script setup lang="tsx"></script>
<style lang="less"></style>
<route lang="json"> <route lang="json">
{ {
"redirect": "/setting/common", "redirect": "/setting/common",

View File

@ -1,67 +1,68 @@
<template> <template>
<bread-page> <bread-page>
<div class="flex"> <div class="flex">
<a-form <div class="w-full">
:model="{}" <div class="flex item-center justify-between gap-4">
:label-col-props="{ span: 3 }" <div>
:disabled="!mail.enable" <h2 class="m-0 text-lg font-normal flex items-center gap-2">
layout="vertical" 邮件设置
label-align="left" <a-tag :color="mail.enable ? 'green' : 'red'">
class="w-[580px]! space-y-6" <template #icon>
> <i class="icon-park-outline-check-one"></i>
<a-form-item label="是否启用" :disabled="false"> {{ mail.enable ? '已启用' : '已停用' }}
<a-radio-group v-model="mail.enable"> </template>
<a-radio :value="true">启用</a-radio> </a-tag>
<a-radio :value="false">禁用</a-radio> </h2>
</a-radio-group> <p class="text-gray-500 mt-1.5 p-0 m0 m-0">首次为你的帐户添加密码时你需要前往密码重置页面以便我们验证你的身份</p>
</a-form-item> </div>
<a-form-item label="服务器和端口"> <div class="flex items-center pr-6">
<a-input v-model="mail.smtpHost" allow-clear placeholder="请输入" class="!w-[314px]"></a-input>
<span class="inline-block px-2">:</span> </div>
<a-input-number v-model="mail.smtpPort" :min="0" :max="65535" class="w-24!"></a-input-number> </div>
<template #help> <a-form :model="{}" :disabled="!mail.enable" label-align="left" class="col-form divide-y divide-gray-100 mt-8 space-y-6">
示例: smtp.163.com:25国内常见有 <a-form-item label="是否启用" :disabled="false">
<a target="_blank" class="mr-2" href="https://mail.163.com">网易邮箱</a> <a-switch v-model="mail.enable"> </a-switch>
<a target="_blank" class="mr-2" href="http://mail.aliyun.com/">阿里邮箱</a> <template #help> 启用后其他服务可发送邮件通知 </template>
<a target="_blank" class="mr-2" href="https://mail.qq.com">QQ邮箱</a> </a-form-item>
</template> <a-form-item label="服务器地址和端口" class="pt-6">
</a-form-item> <a-input v-model="mail.smtpHost" allow-clear placeholder="请输入" class="!w-[314px]"></a-input>
<a-form-item label="发信人地址"> <span class="inline-block px-2">:</span>
<a-input v-model="mail.smtpFrom" placeholder="请输入" class="!w-[432px]"></a-input> <a-input-number v-model="mail.smtpPort" :min="0" :max="65535" class="w-24!"></a-input-number>
<template #help> 示例: example@mail.com仅作为发送邮件时的发送人标识与登陆无关</template> <template #help>
</a-form-item> 示例: smtp.163.com:25国内常见有
<a-form-item label="是否需要验证"> <a target="_blank" class="mr-2" href="https://mail.163.com">网易邮箱</a>
<a-radio-group v-model="mail.smtpAuth"> <a target="_blank" class="mr-2" href="http://mail.aliyun.com/">阿里邮箱</a>
<a-radio :value="true"></a-radio> <a target="_blank" class="mr-2" href="https://mail.qq.com">QQ邮箱</a>
<a-radio :value="false"></a-radio> </template>
</a-radio-group> </a-form-item>
</a-form-item> <a-form-item label="发信人地址" class="pt-6">
<a-form-item label="验证账号"> <a-input v-model="mail.smtpFrom" placeholder="请输入" class="!w-[432px]"></a-input>
<a-input <template #help> 示例: example@mail.com仅作为发送邮件时的发送人标识与登陆无关</template>
:disabled="!mail.enable || !mail.smtpAuth" </a-form-item>
v-model="mail.smtpUser" <a-form-item label="是否需要验证" class="pt-6">
placeholder="请输入" <a-switch v-model="mail.smtpAuth"> </a-switch>
class="!w-[432px]" <template #help> 可选 </template>
></a-input> </a-form-item>
<template #help> 示例: example@mail.com企业邮箱请使用企业域名后缀</template> <a-form-item label="验证账号" class="pt-6">
</a-form-item> <a-input :disabled="!mail.enable || !mail.smtpAuth" v-model="mail.smtpUser" placeholder="请输入" class="!w-[432px]"></a-input>
<a-form-item label="验证密码"> <template #help> 示例: example@mail.com企业邮箱请使用企业域名后缀</template>
<a-input </a-form-item>
:disabled="!mail.enable || !mail.smtpAuth" <a-form-item label="验证密码" class="pt-6">
v-model="mail.smtpPass" <a-input :disabled="!mail.enable || !mail.smtpAuth" v-model="mail.smtpPass" placeholder="请输入" class="!w-[432px]"></a-input>
placeholder="请输入" <template #help> 示例AATOLARFABJKYWUY具体请在对应邮箱设置面板进行生成 </template>
class="!w-[432px]" </a-form-item>
></a-input> <a-form-item :disabled="false" class="pt-6">
<template #help> 示例AATOLARFABJKYWUY具体请在对应邮箱设置面板进行生成 </template> <a-button type="primary"> 保存修改 </a-button>
</a-form-item> <a-button class="ml-4">
<a-form-item :disabled="false"> 测试
<a-button type="primary"> 保存修改 </a-button> </a-button>
</a-form-item> </a-form-item>
</a-form> </a-form>
<a-divider direction="vertical" :margin="32"></a-divider> </div>
<!-- <a-divider direction="vertical" :margin="32"></a-divider>
<div class="flex-1"> <div class="flex-1">
<div> <div>
<div class="text-base font-semibold">配置测试</div> <div class="text-lg font-normal">邮件测试</div>
<div class="text-gray-400 mt-1">发送一封测试邮件检测邮件设置是否能正常工作</div> <div class="text-gray-400 mt-1">发送一封测试邮件检测邮件设置是否能正常工作</div>
<div class="mt-6"> <div class="mt-6">
<a-input placeholder="接收人邮箱" class="w-[432px]!"></a-input> <a-input placeholder="接收人邮箱" class="w-[432px]!"></a-input>
@ -71,7 +72,7 @@
<a-button type="primary" :disabled="!mail.enable">发送邮件</a-button> <a-button type="primary" :disabled="!mail.enable">发送邮件</a-button>
</div> </div>
</div> </div>
</div> </div> -->
</div> </div>
</bread-page> </bread-page>
</template> </template>

View File

@ -8,7 +8,7 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { api } from '@/api'; import { api } from '@/api';
import { useFormModal } from '@/components/AnForm'; import { useFormModal } from '@/components/AnForm';
import { TableColumnRender, useCreateColumn, useTable, useUpdateColumn } from '@/components/AnTable'; import { TableColumnRender, useCreateColumn, useTable } from '@/components/AnTable';
defineOptions({ name: 'SystemDepartmentPage' }); defineOptions({ name: 'SystemDepartmentPage' });
@ -64,9 +64,6 @@ const { component: UserTable } = useTable({
{ {
...useCreateColumn(), ...useCreateColumn(),
}, },
{
...useUpdateColumn(),
},
{ {
title: '操作', title: '操作',
type: 'button', type: 'button',
@ -173,7 +170,6 @@ const { component: UserTable } = useTable({
{ {
"meta": { "meta": {
"name": "SystemDepartmentPage", "name": "SystemDepartmentPage",
"keepAlive": true,
"sort": 10301, "sort": 10301,
"title": "部门管理", "title": "部门管理",
"icon": "icon-park-outline-group" "icon": "icon-park-outline-group"

View File

@ -8,12 +8,16 @@
<an-group :current="current" @change="onTypeChange"></an-group> <an-group :current="current" @change="onTypeChange"></an-group>
</div> </div>
<div class="bg-white p-4"> <div class="bg-white p-4">
<div :show-icon="false" class="rounded mb-3 bg-gray-200 px-4 py-3"> <div :show-icon="false" class="rounded mb-3 bg-gray-100 px-4 py-3">
<span class="text-base"> <span class="text-base">
<i class="icon-park-outline-folder-close"></i> <i class="icon-park-outline-folder-close"></i>
{{ current?.name }} {{ current?.name }}
</span> </span>
<div class="mt-1.5 text-gray-500">描述{{ current?.description }}</div> <div class="mt-1.5 text-gray-500">描述{{ current?.description }}</div>
<div class="mt-2 flex gap-1">
<a-link>修改</a-link>
<a-link status="danger">删除</a-link>
</div>
</div> </div>
<dict-table></dict-table> <dict-table></dict-table>
</div> </div>

View File

@ -1,6 +1,6 @@
<template> <template>
<BreadPage> <BreadPage>
<role-table></role-table> <RoleTable></RoleTable>
</BreadPage> </BreadPage>
</template> </template>
@ -106,6 +106,7 @@ const { component: RoleTable } = useTable({
"name": "SystemRolePage", "name": "SystemRolePage",
"sort": 10302, "sort": 10302,
"title": "角色管理", "title": "角色管理",
"auth": ["role"],
"icon": "icon-park-outline-shield" "icon": "icon-park-outline-shield"
} }
} }

View File

@ -8,7 +8,7 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { api } from '@/api'; import { api } from '@/api';
import { useFormModal } from '@/components/AnForm'; import { useFormModal } from '@/components/AnForm';
import { TableColumnRender, useCreateColumn, useTable, useUpdateColumn } from '@/components/AnTable'; import { TableColumnRender, useTable } from '@/components/AnTable';
defineOptions({ name: 'SystemUserPage' }); defineOptions({ name: 'SystemUserPage' });
@ -32,24 +32,14 @@ const { component: PasswordModal, open } = useFormModal({
const usernameRender: TableColumnRender = ({ record }) => ( const usernameRender: TableColumnRender = ({ record }) => (
<div class="flex items-center gap-4 w-full overflow-hidden"> <div class="flex items-center gap-4 w-full overflow-hidden">
<a-avatar size={32}> <a-avatar size={32} class="bg-brand-500!">
{record.avatar?.startsWith('/') ? <img src={record.avatar} alt="" /> : record.nickname?.[0]} {record.avatar?.startsWith('/') ? <img src={record.avatar} alt="" /> : record.nickname?.[0]}
</a-avatar> </a-avatar>
<div class="w-full flex-1 overflow-hidden"> <div class="w-full flex-1 overflow-hidden">
<div> <div>
<span class="cursor-pointer hover:text-brand-500">{record.nickname}</span> <span class="cursor-pointer ">{record.nickname}</span>
<span class="text-gray-400 text-xs truncate ml-2">@{record.username}</span>
</div>
<div class="w-full text-gray-400 space-x-4 text-xs">
<span>
<i class="icon-park-outline-mail mr-1 align-[-4px]"></i>
contact@juetan.cn
</span>
<span>
<i class="icon-park-outline-phone-telephone mr-1"></i>
1591234568
</span>
</div> </div>
</div> </div>
</div> </div>
); );
@ -62,16 +52,19 @@ const { component: UserTable } = useTable({
render: usernameRender, render: usernameRender,
}, },
{ {
...useCreateColumn(), title: '创建',
}, render: () => '3 天前'
{
...useUpdateColumn(),
}, },
// {
// ...useCreateColumn(),
// },
// {
// ...useUpdateColumn(),
// },
{ {
title: '操作', title: '操作',
type: 'button', type: 'button',
width: 200, width: 200,
align: 'right',
buttons: [ buttons: [
{ {
text: '重置密码', text: '重置密码',
@ -176,7 +169,6 @@ const { component: UserTable } = useTable({
"cache": true, "cache": true,
"sort": 10301, "sort": 10301,
"title": "用户管理", "title": "用户管理",
"auth": ["*"],
"icon": "icon-park-outline-user" "icon": "icon-park-outline-user"
} }
} }

View File

@ -1,19 +1,18 @@
import { api } from '@/api'; import { api } from '@/api';
import { env } from '@/config/env'; import { env } from '@/config/env';
import { store, useUserStore } from '@/store'; import { useUserStore } from '@/store/user';
import { useMenuStore } from '@/store/menu'; import { useMenuStore } from '@/store/menu';
import { treeEach, treeFilter, treeFind } from '@/utils/listToTree'; import { store } from '@/store';
import { treeEach } from '@/utils/listToTree';
import { Notification } from '@arco-design/web-vue'; import { Notification } from '@arco-design/web-vue';
import { Router } from 'vue-router'; import { Router } from 'vue-router';
import { menus } from '../menus'; import { menus } from '../menus';
import { APP_HOME_NAME } from '../routes/base'; import { appRoutes } from '../routes/page';
import { APP_ROUTE_NAME, routes } from '../routes/page';
/** /**
* *
* @param to * @param to
* @description store pinia-plugin-peristedstate * @description store pinia-plugin-peristedstate
* @returns
*/ */
export function useAuthGuard(router: Router) { export function useAuthGuard(router: Router) {
api.expireHandler = () => { api.expireHandler = () => {
@ -39,17 +38,17 @@ export function useAuthGuard(router: Router) {
return true; return true;
} }
// 直接访问跳转回首页(非路由跳转)
if (!from.matched.length) {
return '/';
}
// 提示已登陆 // 提示已登陆
Notification.warning({ Notification.warning({
title: '跳转提示', title: '跳转提示',
content: `您已登陆,如需重新登陆请退出后再操作!`, content: `您已登陆,如需重新登陆请退出后再操作!`,
}); });
// 直接访问跳转回首页(不是从路由跳转)
if (!from.matched.length) {
return '/';
}
// 已登陆不允许 // 已登陆不允许
return false; return false;
} }
@ -64,37 +63,24 @@ export function useAuthGuard(router: Router) {
// 未获取权限进行获取 // 未获取权限进行获取
if (!menuStore.menus.length) { if (!menuStore.menus.length) {
// 菜单处理 menuStore.setMenus(menus);
const authMenus = treeFilter(menus, item => {
if (item.path === env.homePath) {
item.path = '/';
}
return true;
});
menuStore.setMenus(authMenus);
menuStore.setHome(env.homePath); menuStore.setHome(env.homePath);
// 路由处理 treeEach(appRoutes, item => {
for (const route of routes) {
router.addRoute(route);
}
// 缓存处理
treeEach(routes, (item, level) => {
const { cache, name } = item.meta ?? {}; const { cache, name } = item.meta ?? {};
if (cache && name) { if (cache && name) {
menuStore.caches.push(name); menuStore.caches.push(name);
} }
// if (item.path === menuStore.home) {
// item.alias = '/';
// }
// if (!router.hasRoute(item.name!)) {
// const route = { ...item, children: undefined } as any;
// router.addRoute(route.parentName!, route);
// }
}); });
// 首页处理 return to.fullPath;
const home = treeFind(routes, i => i.path === menuStore.home);
if (home) {
const route = { ...home, name: APP_HOME_NAME, alias: '/' };
router.removeRoute(home.name!);
router.addRoute(APP_ROUTE_NAME, route);
return router.replace(to.path);
}
} }
// 兜底处理 // 兜底处理

View File

@ -1,5 +1,5 @@
import { NProgress } from '@/libs/nprogress'; import NProgress from 'nprogress';
import { useAppStore } from '@/store'; import { useAppStore } from '@/store/app';
import { Router } from 'vue-router'; import { Router } from 'vue-router';
const routeMap = new Map<string, boolean>(); const routeMap = new Map<string, boolean>();

View File

@ -1,4 +1,5 @@
import { store, useAppStore } from '@/store'; import { store } from '@/store';
import { useAppStore } from '@/store/app';
import { Router } from 'vue-router'; import { Router } from 'vue-router';
export function useTitleGuard(router: Router) { export function useTitleGuard(router: Router) {

View File

@ -2,7 +2,6 @@ import { createRouter } from 'vue-router';
import { useAuthGuard } from '../guards/auth'; import { useAuthGuard } from '../guards/auth';
import { useProgressGard } from '../guards/progress'; import { useProgressGard } from '../guards/progress';
import { useTitleGuard } from '../guards/title'; import { useTitleGuard } from '../guards/title';
import { baseRoutes } from '../routes/base';
import { historyMode } from './util'; import { historyMode } from './util';
import { routes } from '../routes/page'; import { routes } from '../routes/page';
@ -11,7 +10,7 @@ import { routes } from '../routes/page';
*/ */
export const router = createRouter({ export const router = createRouter({
history: historyMode(), history: historyMode(),
routes: [...baseRoutes, ...routes], routes: routes,
}); });
/** /**

View File

@ -1,14 +0,0 @@
import { RouteRecordRaw } from 'vue-router';
export const APP_HOME_NAME = '__APP_HOME__';
/**
*
*/
export const baseRoutes: RouteRecordRaw[] = [
{
path: '/',
name: APP_HOME_NAME,
component: () => 'Home Page',
},
];

View File

@ -15,8 +15,8 @@ function treeRoutes(list: RouteRecordRaw[]) {
for (const item of list) { for (const item of list) {
const parentPath = item.path.split('/').slice(0, -1).join('/'); const parentPath = item.path.split('/').slice(0, -1).join('/');
const parent = map[parentPath]; const parent = map[parentPath];
item.parentName = (parent?.name as string) || APP_ROUTE_NAME;
if (parent) { if (parent) {
(item as any).parentPath = parentPath;
(parent.children || (parent.children = [])).push(item); (parent.children || (parent.children = [])).push(item);
} else { } else {
tree.push(item); tree.push(item);
@ -47,12 +47,14 @@ function sortRoutes(routes: RouteRecordRaw[]) {
const transformRoutes = (routes: RouteRecordRaw[]) => { const transformRoutes = (routes: RouteRecordRaw[]) => {
const topRoutes: RouteRecordRaw[] = []; const topRoutes: RouteRecordRaw[] = [];
const appRoutes: RouteRecordRaw[] = []; const appRoutes: RouteRecordRaw[] = [];
let app: RouteRecordRaw;
for (const route of routes) { for (const route of routes) {
if (route.name === APP_ROUTE_NAME) {
app = route;
route.children = appRoutes;
}
if ((route.name as string)?.startsWith(TOP_ROUTE_PREF)) { if ((route.name as string)?.startsWith(TOP_ROUTE_PREF)) {
if (route.name === APP_ROUTE_NAME) {
route.children = appRoutes;
}
route.path = route.path.replace(TOP_ROUTE_PREF, ''); route.path = route.path.replace(TOP_ROUTE_PREF, '');
topRoutes.push(route); topRoutes.push(route);
continue; continue;
@ -60,7 +62,8 @@ const transformRoutes = (routes: RouteRecordRaw[]) => {
appRoutes.push(route); appRoutes.push(route);
} }
return [topRoutes, sortRoutes(treeRoutes(appRoutes))]; app!.children = sortRoutes(treeRoutes(appRoutes));
return [topRoutes, app!.children];
}; };
export const [routes, appRoutes] = transformRoutes(generatedRoutes); export const [routes, appRoutes] = transformRoutes(generatedRoutes);

View File

@ -1,3 +1 @@
export * from './app';
export * from './store'; export * from './store';
export * from './user';

View File

@ -1,16 +1,16 @@
import { defineStore } from "pinia"; import { defineStore } from 'pinia';
export const useUserStore = defineStore({ export const useUserStore = defineStore({
id: "user", id: 'user',
state: (): UserStore => { state: (): UserStore => {
return { return {
id: 0, id: 0,
username: "juetan", username: 'juetan',
nickname: "绝弹", nickname: '绝弹',
avatar: "https://github.com/juetan.png", avatar: 'https://github.com/juetan.png',
accessToken: "", accessToken: '',
refreshToken: undefined, refreshToken: undefined,
auth: [] auth: [],
}; };
}, },
actions: { actions: {
@ -48,7 +48,10 @@ export const useUserStore = defineStore({
accessToken && (this.accessToken = accessToken); accessToken && (this.accessToken = accessToken);
}, },
}, },
persist: true, persist: {
key: '__APP_USER__',
paths: ['accessToken'],
},
}); });
export interface UserStore { export interface UserStore {
@ -65,11 +68,11 @@ export interface UserStore {
*/ */
nickname: string; nickname: string;
/** /**
* *
*/ */
avatar?: string; avatar?: string;
/** /**
* JWT * 访
*/ */
accessToken?: string; accessToken?: string;
/** /**

View File

@ -3,7 +3,7 @@
@arcoblue-6: #08f; @arcoblue-6: #08f;
body { body {
// --border-radius-small: 4px; --border-radius-small: 2px;
.arco-table .arco-table-element { .arco-table .arco-table-element {
table-layout: fixed; table-layout: fixed;
@ -62,20 +62,20 @@ body {
.arco-menu { .arco-menu {
&.arco-menu-vertical .arco-menu-item { &.arco-menu-vertical .arco-menu-item {
line-height: 36px; line-height: 36px;
margin-top: 2px; margin-top: 4px;
} }
&.arco-menu-vertical .arco-menu-group-title { &.arco-menu-vertical .arco-menu-group-title {
line-height: 28px; line-height: 28px;
margin-top: 8px; margin-top: 8px;
} }
[class^="icon-"] { [class^="icon-"] {
font-size: 14px; font-size: 18px;
vertical-align: -2px; vertical-align: -2px;
} }
.arco-menu-item { .arco-menu-item {
margin: 0 4px; margin: 0 4px;
&:hover { &:hover {
background-color: var(--color-neutral-1); background-color: var(--color-neutral-2);
} }
&.arco-menu-selected { &.arco-menu-selected {
// color: @arcoblue-6; // color: @arcoblue-6;
@ -135,6 +135,10 @@ body {
.an-form-modal .arco-modal-body { .an-form-modal .arco-modal-body {
padding-bottom: 8px; padding-bottom: 8px;
} }
.arco-form-item-message {
margin-top: 8px;
}
} }
.dark { .dark {
.arco-menu-item.arco-menu-selected { .arco-menu-item.arco-menu-selected {
@ -155,3 +159,16 @@ body {
border-color: var(--color-neutral-2); border-color: var(--color-neutral-2);
} }
} }
.col-form {
.arco-form-item-wrapper-col {
// flex-direction: row;
}
.arco-form-item-content-wrapper {
width: 450px;
}
.arco-form-item-message {
font-size: 14px;
}
}

View File

@ -9,7 +9,6 @@ declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AAutoComplete: typeof import('@arco-design/web-vue')['AutoComplete'] AAutoComplete: typeof import('@arco-design/web-vue')['AutoComplete']
AAvatar: typeof import('@arco-design/web-vue')['Avatar'] AAvatar: typeof import('@arco-design/web-vue')['Avatar']
ABadge: typeof import('@arco-design/web-vue')['Badge']
ABreadcrumb: typeof import('@arco-design/web-vue')['Breadcrumb'] ABreadcrumb: typeof import('@arco-design/web-vue')['Breadcrumb']
ABreadcrumbItem: typeof import('@arco-design/web-vue')['BreadcrumbItem'] ABreadcrumbItem: typeof import('@arco-design/web-vue')['BreadcrumbItem']
AButton: typeof import('@arco-design/web-vue')['Button'] AButton: typeof import('@arco-design/web-vue')['Button']

View File

@ -50,12 +50,11 @@ declare module 'vue-router/auto/routes' {
'/content/material-category/': RouteRecordInfo<'/content/material-category/', '/content/material-category', Record<never, never>, Record<never, never>>, '/content/material-category/': RouteRecordInfo<'/content/material-category/', '/content/material-category', Record<never, never>, Record<never, never>>,
'/content/post/': RouteRecordInfo<'/content/post/', '/content/post', Record<never, never>, Record<never, never>>, '/content/post/': RouteRecordInfo<'/content/post/', '/content/post', Record<never, never>, Record<never, never>>,
'/dev/': RouteRecordInfo<'/dev/', '/dev', Record<never, never>, Record<never, never>>, '/dev/': RouteRecordInfo<'/dev/', '/dev', Record<never, never>, Record<never, never>>,
'/dev/editor/': RouteRecordInfo<'/dev/editor/', '/dev/editor', Record<never, never>, Record<never, never>>,
'/dev/nav/': RouteRecordInfo<'/dev/nav/', '/dev/nav', Record<never, never>, Record<never, never>>, '/dev/nav/': RouteRecordInfo<'/dev/nav/', '/dev/nav', Record<never, never>, Record<never, never>>,
'/dev/openapi/': RouteRecordInfo<'/dev/openapi/', '/dev/openapi', Record<never, never>, Record<never, never>>, '/dev/openapi/': RouteRecordInfo<'/dev/openapi/', '/dev/openapi', Record<never, never>, Record<never, never>>,
'/home/': RouteRecordInfo<'/home/', '/home', Record<never, never>, Record<never, never>>, '/home/': RouteRecordInfo<'/home/', '/home', Record<never, never>, Record<never, never>>,
'/log/': RouteRecordInfo<'/log/', '/log', Record<never, never>, Record<never, never>>, '/log/': RouteRecordInfo<'/log/', '/log', Record<never, never>, Record<never, never>>,
'/log/login/': RouteRecordInfo<'/log/login/', '/log/login', Record<never, never>, Record<never, never>>, '/log/auth/': RouteRecordInfo<'/log/auth/', '/log/auth', Record<never, never>, Record<never, never>>,
'/log/operation/': RouteRecordInfo<'/log/operation/', '/log/operation', Record<never, never>, Record<never, never>>, '/log/operation/': RouteRecordInfo<'/log/operation/', '/log/operation', Record<never, never>, Record<never, never>>,
'/setting/': RouteRecordInfo<'/setting/', '/setting', Record<never, never>, Record<never, never>>, '/setting/': RouteRecordInfo<'/setting/', '/setting', Record<never, never>, Record<never, never>>,
'/setting/common/': RouteRecordInfo<'/setting/common/', '/setting/common', Record<never, never>, Record<never, never>>, '/setting/common/': RouteRecordInfo<'/setting/common/', '/setting/common', Record<never, never>, Record<never, never>>,

View File

@ -1,6 +1,30 @@
import "vue-router"; import 'vue-router';
declare module 'vue-router' {
interface RouteRecordRaw {
parentName?: string;
}
interface RouteRecordSingleViewWithChildren {
parentName?: string;
}
interface RouteRecordSingleView {
parentName?: string;
}
interface RouteRecordMultipleViews {
parentName?: string;
}
interface RouteRecordMultipleViewsWithChildren {
parentName?: string;
}
interface RouteRecordRedirect {
parentName?: string;
}
declare module "vue-router" {
interface RouteMeta { interface RouteMeta {
/** /**
* *

5
src/types/vue.d.ts vendored
View File

@ -1,8 +1,9 @@
import { Router, RouteLocationNormalizedLoaded } from "vue-router"; import { Router, RouteLocationNormalizedLoaded } from 'vue-router';
declare module "vue" { declare module 'vue' {
interface ComponentCustomProperties { interface ComponentCustomProperties {
$router: Router; $router: Router;
$route: RouteLocationNormalizedLoaded; $route: RouteLocationNormalizedLoaded;
$dayjs: Dayjs;
} }
} }

View File

@ -111,7 +111,6 @@ export default defineConfig(({ mode }) => {
brand: arcoToUnoColor('primary'), brand: arcoToUnoColor('primary'),
}, },
}, },
include: ['src/**/*.{vue,ts,tsx,css,scss,sass,less,styl}'],
presets: [ presets: [
presetUno(), presetUno(),
presetIcons({ presetIcons({
@ -122,6 +121,11 @@ export default defineConfig(({ mode }) => {
}, },
}), }),
], ],
content: {
pipeline: {
include: ['src/**/*.{vue,ts,tsx,css,scss,sass,less,styl}'],
},
},
}), }),
/** /**