16 Commits

Author SHA1 Message Date
鬼画符
be82815659 feat: 初步接入 translate.js 的多语言切换能力
目前只是在PC端的登录页面的右上角显示多语言切换入口
2025-10-30 03:09:35 +00:00
7ff0bfd846 docs: 更新 README 及 Bug Issue 模板 2025-09-23 22:12:00 +08:00
maqiang9527
d85ac20d7e fix: 补充UserInfo定义的新增字段
Co-authored-by: maqiang9527<maqiang_9527@163.com>



# message auto-generated for no-merge-commit merge:
merge fix/UserInfo into dev

fix: 补充UserInfo定义新增字段

Created-by: maqiang9527
Commit-by: maqiang9527
Merged-by: Charles_7c
Description: <!--
  非常感谢您的 PR!在提交之前,请务必确保您 PR 的代码经过了完整测试,并且通过了代码规范检查。
-->

<!-- 在 [] 中输入 x 来勾选) -->

## PR 类型

<!-- 您的 PR 引入了哪种类型的变更? -->
<!-- 只支持选择一种类型,如果有多种类型,可以在更新日志中增加 “类型” 列。 -->

- [ ] 新 feature
- [x] Bug 修复
- [ ] 功能增强
- [ ] 文档变更
- [ ] 代码样式变更
- [ ] 重构
- [ ] 性能改进
- [ ] 单元测试
- [ ] CI/CD
- [ ] 其他

## PR 目的

<!-- 描述一下您的 PR 解决了什么问题。如果可以,请链接到相关 issues。 -->

## 解决方案

<!-- 详细描述您是如何解决的问题 -->

## PR 测试

<!-- 如果可以,请为您的 PR 添加或更新单元测试。 -->
<!-- 请描述一下您是如何测试 PR 的。例如:创建/更新单元测试或添加相关的截图。 -->

## Changelog

| 模块  | Changelog | Related issues |
|-----|-----------| -------------- |
|     |           |                |

<!-- 如果有多种类型的变更,可以在变更日志表中增加 “类型” 列,该列的值与上方 “PR 类型” 相同。 -->
<!-- Related issues 格式为 Closes #<issue号>,或者 Fixes #<issue号>,或者 Resolves #<issue号>。 -->

## 其他信息

<!-- 请描述一下还有哪些注意事项。例如:如果引入了一个不向下兼容的变更,请描述其影响。 -->

## 提交前确认

- [x] PR 代码经过了完整测试,并且通过了代码规范检查
- [x] 已经完整填写 Changelog,并链接到了相关 issues
- [x] PR 代码将要提交到 dev 分支

See merge request: continew/continew-admin-ui!9
2025-09-23 12:51:11 +08:00
bbe36b4f9f chore: 优化 Issue 模板,更新部分链接 2025-09-09 23:06:44 +08:00
f0b24cc18a refactor(system/storage): 修改存储配置时,保持Secret Key为空将不更改 2025-08-28 21:55:38 +08:00
09775d69b9 ci: 修改部署脚本 2025-08-27 22:01:21 +08:00
4aeb795db0 refactor: v-model.trim => v-model(外国用户输入单词无法直接在首尾输入空格) 2025-08-15 23:19:53 +08:00
eddd7c5fc6 refactor: 统一表格操作按钮最大数量为 3,超过 2 个则显示更多 2025-08-15 23:17:17 +08:00
c57a0a2195 refactor: 简化命名,例如:UserAddDrawer => AddDrawer 2025-08-15 22:42:48 +08:00
b1805dc41b fix(profile): 修复个人中心角色信息展示错误 2025-08-14 22:39:41 +08:00
26ff948a73 build: 更新项目版本号至4.1.0-SNAPSHOT 2025-08-14 22:38:29 +08:00
KAI
a3ce4b508a feat(system/file): 新增分片文件上传 2025-08-12 13:11:59 +00:00
986c03e69f refactor: 优化 useDownload 函数,如果指定了文件名则直接使用 2025-08-10 12:18:51 +08:00
82cb66e112 docs: 更新 README 文档 2025-08-07 20:34:53 +08:00
莫愁
eea9a93ae6 style: 优化操作日志json预览效果 2025-08-07 15:03:34 +08:00
a83d710b82 docs: 更新 README 参与贡献部分内容 2025-08-03 18:33:20 +08:00
64 changed files with 2076 additions and 251 deletions

View File

@@ -15,13 +15,15 @@ body:
options:
- label: 重启项目和 IDE 后,仍然能够复现此问题
required: true
- label: 查阅过 [使用指南](https://continew.top/admin/frontend/structure.html) 和 [常见问题](https://continew.top/admin/faq.html) ,仍无解决方法
- label: 尝试了最新 dev 分支代码(演示环境),仍有相同问题
required: true
- label: 根据报错信息(自行翻译英文)百度或 Google 后,仍无法解决
required: true
- label: 尝试了最新 dev 分支代码(演示环境),仍有相同问题
- label: 查阅过 [使用指南](https://continew.top/docs/admin/frontend/structure.html) 和 [常见问题](https://continew.top/docs/admin/faq.html) ,仍无解决方法
required: true
- label: 搜索了项目 Issues,没有其他人提交过类似的 Bug如果对应 Bug 尚未解决,您可以先订阅关注该 Issue为了方便后来者查找问题解决方法请避免创建重复的 Issue
- label: 搜索了 [吐槽广场](https://continew.top/docs/admin/issue-hub.html),没有其他人提交过类似的 Bug如果对应 Bug 尚未解决,您可以先订阅关注该 Issue为了方便后来者查找问题解决方法请避免创建重复的 Issue
required: true
- label: 问过 [DeepWiki](https://deepwiki.com/continew-org/continew-admin-ui) 及知名 AI 大模型,仍无解决方法
required: true
- label: 确认不是 gi-demo 前端模板相关的组件问题例如GiTable、GiForm、基础布局和配置等如有此类组件相关的问题请提交至 [gi-demo](https://gitee.com/lin0716/gi-demo) 或对应组件仓库)
required: true

View File

@@ -15,11 +15,9 @@ body:
options:
- label: 尝试了最新 dev 分支代码(演示环境),仍没有该功能
required: true
- label: 查阅过 [使用指南](https://continew.top/admin/frontend/structure.html) 和 [常见问题](https://continew.top/admin/faq.html) ,仍然认为很有必要
- label: 查阅过 [使用指南](https://continew.top/docs/admin/frontend/structure.html) 和 [常见问题](https://continew.top/docs/admin/faq.html) ,仍然认为很有必要
required: true
- label: 查阅过 [需求墙](https://continew.top/admin/other/feature.html)没有该功能计划
required: true
- label: 搜索了项目 Issues没有其他人提交过类似的 Feature如果对应 Feature 尚未实现,您可以先订阅关注该 Issue为了方便后来者查找问题解决方法请避免创建重复的 Issue
- label: 搜索了 [吐槽广场](https://continew.top/docs/admin/issue-hub.html),没有其他人提交过类似的 Feature如果对应 Feature 尚未实现,您可以先订阅关注该 Issue为了方便后来者查找问题解决方法请避免创建重复的 Issue
required: true
- label: 确认不是基础组件类需求例如GiTable、GiForm、基础布局、纯前端组件锁屏、引导如有此类组件相关的需求请提交至 [gi-demo](https://gitee.com/lin0716/gi-demo) 或对应组件仓库)
required: true

View File

@@ -40,9 +40,9 @@ jobs:
username: ${{ secrets.SERVER_USERNAME }}
password: ${{ secrets.SERVER_PASSWORD }}
source: ./dist/*
target: /tmp/html
target: ${{ secrets.SERVER_TMP_PATH }}
strip_components: 1
# 7、重启 Nginx
# 7、更新部署文件
- name: Restart
uses: appleboy/ssh-action@master
with:
@@ -52,5 +52,5 @@ jobs:
password: ${{ secrets.SERVER_PASSWORD }}
script: |
rm -rf ${{ secrets.SERVER_PATH }}/*
mv /tmp/html/* ${{ secrets.SERVER_PATH }}
mv ${{ secrets.SERVER_TMP_PATH }}/* ${{ secrets.SERVER_PATH }}
chmod -R 777 ${{ secrets.SERVER_PATH }}

View File

@@ -1,7 +1,7 @@
# ContiNew Admin UI
<a href="https://github.com/continew-org/continew-admin-ui" title="Release" target="_blank">
<img src="https://img.shields.io/badge/RELEASE-v4.0.0-%23ff3f59.svg" alt="Release" />
<img src="https://img.shields.io/badge/SNAPSHOT-v4.1.0-%23ff3f59.svg" alt="Release" />
</a>
<a href="https://vuejs.org/" title="Vue" target="_blank">
<img src="https://img.shields.io/badge/Vue-3.5.4-%236CB52D.svg?logo=Vue.js" alt="Vue" />
@@ -35,7 +35,7 @@
<img src="https://gitcode.com/continew/continew-admin/star/badge.svg" alt="GitCode Stars" />
</a>
📚 [在线文档](https://continew.top) | 🚀 [演示地址](https://continew.top/admin/guide/demo.html)
📚 [在线文档](https://continew.top) | 🚀 [演示地址](https://continew.top/docs/admin/guide/demo.html) | 💬 [吐槽广场(你就是 Talk King!](https://continew.top/docs/admin/issue-hub.html) | [![问 DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/continew-org/continew-admin-ui)
## 简介
@@ -79,7 +79,7 @@ ContiNew AdminContinue New Admin页面现代美观且专注设计与
## 为什么选我们?
> [!TIP]
> 更为完整的图文描述请查阅[《在线文档》](https://continew.top/admin/guide/why-choose-us.html)。
> 更为完整的图文描述请查阅[《在线文档》](https://continew.top/docs/admin/guide/why-choose-us.html)。
**AI 编程纪元已经开启,基于 ContiNew 项目开发,让 AI 助手“学习”更优雅的代码规范,“写出”更优质的代码。**
@@ -133,8 +133,8 @@ public class DeptController extends BaseController<DeptService, DeptResp, DeptDe
## 系统功能
> [!TIP]
> 更多功能和优化正在赶来💦,最新项目计划、进展请进群或关注 [需求墙](https://continew.top/admin/other/feature.html) 和 [更新日志](https://continew.top/admin/changelog/)。
> 功能不会用?请查看 [功能手册](https://continew.top/admin/function/tenant/management.html)。
> 更多功能和优化正在赶来💦,最新项目计划、进展请进群或查看 [吐槽广场](https://continew.top/docs/admin/issue-hub.html) 和 [更新日志](https://continew.top/docs/admin/changelog/)。
> 功能不会用?请查看 [功能手册](https://continew.top/docs/admin/function/tenant/management.html)。
- 仪表盘:提供工作台、分析页,工作台提供功能快捷导航入口、最新公告、动态;分析页提供全面数据可视化能力
- 个人中心:支持基础信息修改、密码修改、邮箱绑定、手机号绑定(并提供行为验证码、短信限流等安全处理)、第三方账号绑定/解绑、头像裁剪上传
@@ -233,7 +233,7 @@ public class DeptController extends BaseController<DeptService, DeptResp, DeptDe
## 快速开始
> **Note**
> 更详细的流程,请查看在线文档[《快速开始》](https://continew.top/admin/guide/quick-start.html)。
> 更详细的流程,请查看在线文档[《快速开始》](https://continew.top/docs/admin/guide/quick-start.html)。
```
# 1.克隆本项目
@@ -332,8 +332,6 @@ continew-admin-ui
├─ vite.config.ts
├─ .gitignoreGit 忽略文件相关配置文件)
├─ .githubGitHub 相关配置目录,实际开发时直接删除)
├─ .idea
│ └─ icon.pngIDEA 项目图标,实际开发时直接删除)
├─ .image截图目录实际开发时直接删除
├─ .vscodeVSCode 配置目录)
├─ LICENSE开源协议文件
@@ -341,43 +339,48 @@ continew-admin-ui
└─ README.md项目 README 文件,实际开发时替换为真实内容)
```
## 贡献指南
## 参与贡献
ContiNew Admin 致力于提供开箱即用持续舒适的开发体验。作为一个开源项目Creator 的初是希望 ContiNew Admin 依托开源协作模式,提升技术透明度、放大集体智慧、共创优秀实践,源源不断地为企业级项目开发提供助力。
ContiNewContinue New系列项目致力于通过持续迭代为开发者提供舒适的开发体验。作为开源社区我们的初是希望通过开源协作模式,提升技术透明度、放大集体智慧、共创优秀实践,源源不断地为企业级项目开发提供助力。
我们非常欢迎广大社区用户为 ContiNew Admin **贡献(开发,测试、文档、答疑等)** 或优化代码,欢迎各位感兴趣的小伙伴儿,[添加微信](https://continew.top/discussion.html) 讨论或认领任务。
我们诚挚邀请广大社区用户为 ContiNew 项目贡献力量,包括但不限于 Issue 排查、测试验证、代码开发与重构等。每一份贡献,都是推动项目进步的重要力量(请查阅 [贡献指南](https://continew.top/about/contributing.html))。欢迎各位感兴趣的小伙伴儿,[添加微信](https://continew.top/discussion.html) 讨论或认领任务。
### 分支说明
ContiNew Admin 的分支目前分为下个大版本的开发分支和上个大版本的维护分支PR 前请注意对应分支是否处于维护状态,版本支持情况请查看 [更新日志/版本支持](https://continew.top/admin/changelog/)。
ContiNew 系列项目采用清晰的分支策略,确保开发与维护有序进行。提交 PR 前,请确认目标分支是否处于活跃维护状态,版本支持情况请查看 [更新日志#版本支持](https://continew.top/docs/admin/changelog/)。
| 分支 | 说明 |
| ----- | ------------------------------------------------------------ |
| dev | 开发分支,默认为下个大版本的 SNAPSHOT 版本,接受新功能或功能优化 PR |
| x.x.x | 维护分支,在 vx.x.x 版本维护期终止前(一般为下个大版本发布前),用于修复上个版本Bug,只接受已有功能修复,不接受新功能 PR |
| dev | 开发分支,用于下个大版本的 SNAPSHOT 开发,接受新功能或功能优化 PR |
| x.x.x | 维护分支,用于特定版本(如 vx.x.xbug 修复,仅接受已有功能修复 PR,不接受新功能 |
### 贡献代码
### 流程步骤
如果您想提交新功能或优化现有代码,可以按照以下步骤操作
若您希望提交新功能或优化现有代码,请遵循以下步骤:
1. 首先,在 Gitee 或 GitHub 上将项目 fork 到您自己的仓库
2. 然后,将 fork 过来的项目(即您的项目)克隆到本地
3. 切换到当前仍在维护的分支(请务必充分了解分支使用说明,可进群联系维护者确认
4. 开始修改代码修改完成后,将代码 commit 并 push 到您的远程仓库
5. Gitee 或 GitHub 上新建 pull requestpr选择好源和目标,按模板要求填写说明信息后提交即可(多多参考 [批准合并的 pr 记录](https://github.com/Charles7c/continew-admin-ui/pulls?q=is%3Apr+is%3Amerged),会大大增加批准合并率)
6. 最后,耐心等待维护者合并您的请求即可
请记住,如果您有任何疑问或需要帮助,我们将随时提供支持。
1. 在开源平台上将项目 fork 到您的个人仓库
2. 将 fork 的项目克隆到本地开发环境
3. 基于当前维护的分支(如 dev创建新分支如 feat/newFeature请勿直接修改源分支源分支仅做同步 ContiNew 最新代码用
4. 在新分支上进行代码修改完成后提交并 push 到您的远程仓库
5.开源平台上创建 pull request (PR),选择正确的源分支和目标分支,按模板填写说明信息参考 [已合并的 PR](https://github.com/continew-org/continew-admin/pulls?q=is%3Apr+is%3Amerged) 可提高合并率)
6. 提交 PR 后,系统会提示签署 CLA贡献者协议。请确保 commit 使用的邮箱与平台绑定邮箱一致(如果不一致,可以在本地通过 `git reset --soft HEAD~1` 回退,然后使用正确邮箱重新提交,最后 `git push -f` 即可,不需要重新创建 PR然后使用该邮箱签署即可
7. 耐心等待维护者审核并合并您的 PR建议通过交流群进行快捷沟通
8. PR 合并后,下次贡献前请先同步最新代码,再重复步骤 3 开始
> [!IMPORTANT]
> 欢迎大家为 ContiNew Admin 贡献代码,我们非常感谢您的支持!为了更好地管理项目,维护者有一些要求
> 为了确保项目质量和协作效率,请注意以下事项
>
> 1. 请确保代码配置文件的结构命名规范良好,完善的代码注释
> 2. 提交代码前,请按照 [Angular 提交规范](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular) 编写 commit message
> 1. 代码配置文件请参考已有风格,遵循清晰的结构命名规范,提供完善的注释
> 2. 提交,请按照 [Angular 提交规范](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular) 编写 commit message(参考已有风格)
## 反馈交流
欢迎各位小伙伴儿扫描下方二维码加入项目交流群,与项目维护团队及其他大佬用户实时交流讨
感谢您对 ContiNew 开源项目的关注与支持!我们非常重视每一位用户的反馈和建议,这是推动项目不断进步的动力。 欢迎扫描下方二维码加入我们的官方交流群,与项目维护团队及其他大佬用户实时交流讨。
- 与项目核心团队直接沟通,获取第一手项目动态
- 解决使用过程中遇到的问题,分享经验心得
- 参与功能讨论和需求收集,影响项目未来发展
- 结识志同道合的技术爱好者,扩展人脉圈
<div align="left">
<img src=".image/qrcode.jpg" alt="二维码" height="230px" />
@@ -402,4 +405,4 @@ ContiNew Admin 的分支目前分为下个大版本的开发分支和上个大
## License
- 遵循 <a href="https://github.com/Charles7c/continew-admin-ui/blob/dev/LICENSE" target="_blank">Apache-2.0</a> 开源许可协议
- Copyright © 2022-present <a href="https://blog.charles7c.top" target="_blank">Charles7c</a>
- Copyright © 2022-present <a href="https://charles7c.top" target="_blank">Charles7c</a>

View File

@@ -11,7 +11,7 @@ export default function appInfo(): Plugin {
// eslint-disable-next-line no-console
console.log(
boxen(
`${bold(green(`${bgGreen('ContiNew Admin v4.0.0')}`))}\n${cyan('在线文档:')}${underline('https://continew.top')}\n${cyan('常见问题:')}${underline('https://continew.top/admin/faq.html')}\n${cyan('更新日志:')}${underline('https://continew.top/admin/changelog/')}\n${cyan('持续迭代优化的,高质量多租户中后台管理系统框架')}`,
`${bold(green(`${bgGreen('ContiNew Admin v4.1.0-SNAPSHOT')}`))}\n${cyan('在线文档:')}${underline('https://continew.top')}\n${cyan('吐槽广场:')}${underline('https://continew.top/docs/admin/issue-hub.html')}\n${cyan('常见问题:')}${underline('https://continew.top/docs/admin/faq.html')}\n${cyan('更新日志:')}${underline('https://continew.top/docs/admin/changelog/')}\n${cyan('持续迭代优化的,高质量多租户中后台管理系统框架')}`,
{
padding: 1,
margin: 1,

View File

@@ -1,7 +1,7 @@
{
"name": "continew-admin-ui",
"type": "module",
"version": "4.0.0",
"version": "4.1.0-SNAPSHOT",
"private": "true",
"scripts": {
"bootstrap": "pnpm install --registry=https://registry.npmmirror.com",
@@ -14,6 +14,7 @@
"lint:fix": "eslint . --fix"
},
"dependencies": {
"i18n-jsautotranslate": "3.18.63",
"@amap/amap-jsapi-loader": "^1.0.1",
"@arco-design/color": "^0.4.0",
"@arco-themes/vue-gi-demo": "^0.0.51",
@@ -44,6 +45,7 @@
"pinia-plugin-persistedstate": "^3.1.0",
"qs": "^6.11.2",
"query-string": "^9.0.0",
"spark-md5": "^3.0.2",
"v-viewer": "^3.0.10",
"viewerjs": "^1.11.6",
"vite-plugin-vue-devtools": "^7.0.27",
@@ -54,7 +56,7 @@
"vue-demi": "^0.14.10",
"vue-draggable-plus": "^0.3.5",
"vue-echarts": "^6.5.5",
"vue-json-pretty": "^2.4.0",
"vue-json-viewer": "^3.0.4",
"vue-router": "^4.3.3",
"vue3-tree-org": "^4.2.2",
"xe-utils": "^3.5.7",
@@ -67,6 +69,7 @@
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.2.5",
"@types/query-string": "^6.3.0",
"@types/spark-md5": "^3.0.5",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/tsconfig": "^0.1.3",

74
pnpm-lock.yaml generated
View File

@@ -98,6 +98,9 @@ importers:
query-string:
specifier: ^9.0.0
version: 9.0.0
spark-md5:
specifier: ^3.0.2
version: 3.0.2
v-viewer:
specifier: ^3.0.10
version: 3.0.13(viewerjs@1.11.6)(vue@3.5.12(typescript@5.0.4))
@@ -128,9 +131,9 @@ importers:
vue-echarts:
specifier: ^6.5.5
version: 6.7.2(@vue/runtime-core@3.5.12)(echarts@5.5.0)(vue@3.5.12(typescript@5.0.4))
vue-json-pretty:
specifier: ^2.4.0
version: 2.4.0(vue@3.5.12(typescript@5.0.4))
vue-json-viewer:
specifier: ^3.0.4
version: 3.0.4(vue@3.5.12(typescript@5.0.4))
vue-router:
specifier: ^4.3.3
version: 4.3.3(vue@3.5.12(typescript@5.0.4))
@@ -162,6 +165,9 @@ importers:
'@types/query-string':
specifier: ^6.3.0
version: 6.3.0
'@types/spark-md5':
specifier: ^3.0.5
version: 3.0.5
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.1(vite@5.2.11(@types/node@20.12.12)(less@4.2.0)(sass@1.77.2)(terser@5.31.0))(vue@3.5.12(typescript@5.0.4))
@@ -697,6 +703,7 @@ packages:
'@humanwhocodes/config-array@0.13.0':
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'}
deprecated: Use @eslint/config-array instead
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
@@ -704,6 +711,7 @@ packages:
'@humanwhocodes/object-schema@2.0.3':
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
deprecated: Use @eslint/object-schema instead
'@humanwhocodes/retry@0.3.0':
resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==}
@@ -813,55 +821,46 @@ packages:
resolution: {integrity: sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.17.2':
resolution: {integrity: sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.17.2':
resolution: {integrity: sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.17.2':
resolution: {integrity: sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-powerpc64le-gnu@4.17.2':
resolution: {integrity: sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.17.2':
resolution: {integrity: sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.17.2':
resolution: {integrity: sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.17.2':
resolution: {integrity: sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.17.2':
resolution: {integrity: sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.17.2':
resolution: {integrity: sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==}
@@ -1192,6 +1191,9 @@ packages:
'@types/sortablejs@1.15.8':
resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==}
'@types/spark-md5@3.0.5':
resolution: {integrity: sha512-lWf05dnD42DLVKQJZrDHtWFidcLrHuip01CtnC2/S6AMhX4t9ZlEUj4iuRlAnts0PQk7KESOqKxeGE/b6sIPGg==}
'@types/svgo@2.6.4':
resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==}
@@ -1748,6 +1750,9 @@ packages:
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
engines: {node: '>=18'}
clipboard@2.0.11:
resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -1998,6 +2003,9 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
delegate@3.2.0:
resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -2757,6 +2765,9 @@ packages:
resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
engines: {node: '>=10'}
good-listener@1.2.2:
resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==}
gopd@1.0.1:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
@@ -3960,6 +3971,9 @@ packages:
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
select@1.1.2:
resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==}
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -4079,6 +4093,9 @@ packages:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
deprecated: Please use @jridgewell/sourcemap-codec instead
spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
spdx-correct@3.2.0:
resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==}
@@ -4249,6 +4266,9 @@ packages:
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
tiny-emitter@2.1.0:
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
tippy.js@6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
@@ -4614,11 +4634,10 @@ packages:
peerDependencies:
eslint: '>=6.0.0'
vue-json-pretty@2.4.0:
resolution: {integrity: sha512-e9bP41DYYIc2tWaB6KuwqFJq5odZ8/GkE6vHQuGcbPn37kGk4a3n1RNw3ZYeDrl66NWXgTlOfS+M6NKkowmkWw==}
engines: {node: '>= 10.0.0', npm: '>= 5.0.0'}
vue-json-viewer@3.0.4:
resolution: {integrity: sha512-pnC080rTub6YjccthVSNQod2z9Sl5IUUq46srXtn6rxwhW8QM4rlYn+CTSLFKXWfw+N3xv77Cioxw7B4XUKIbQ==}
peerDependencies:
vue: '>=3.0.0'
vue: ^3.2.2
vue-router@4.3.3:
resolution: {integrity: sha512-8Q+u+WP4N2SXY38FDcF2H1dUEbYVHVPtPCPZj/GTZx8RCbiB8AtJP9+YIxn4Vs0svMTNQcLIzka4GH7Utkx9xQ==}
@@ -5758,6 +5777,8 @@ snapshots:
'@types/sortablejs@1.15.8': {}
'@types/spark-md5@3.0.5': {}
'@types/svgo@2.6.4':
dependencies:
'@types/node': 20.12.12
@@ -6501,6 +6522,12 @@ snapshots:
slice-ansi: 5.0.0
string-width: 7.2.0
clipboard@2.0.11:
dependencies:
good-listener: 1.2.2
select: 1.1.2
tiny-emitter: 2.1.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -6744,6 +6771,8 @@ snapshots:
delayed-stream@1.0.0: {}
delegate@3.2.0: {}
dequal@2.0.3: {}
devlop@1.1.0:
@@ -7653,6 +7682,10 @@ snapshots:
merge2: 1.4.1
slash: 3.0.0
good-listener@1.2.2:
dependencies:
delegate: 3.2.0
gopd@1.0.1:
dependencies:
get-intrinsic: 1.2.4
@@ -8887,6 +8920,8 @@ snapshots:
scule@1.3.0: {}
select@1.1.2: {}
semver@5.7.2: {}
semver@6.3.1: {}
@@ -9022,6 +9057,8 @@ snapshots:
sourcemap-codec@1.4.8: {}
spark-md5@3.0.2: {}
spdx-correct@3.2.0:
dependencies:
spdx-expression-parse: 3.0.1
@@ -9203,6 +9240,8 @@ snapshots:
text-table@0.2.0: {}
tiny-emitter@2.1.0: {}
tippy.js@6.3.7:
dependencies:
'@popperjs/core': 2.11.8
@@ -9615,8 +9654,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-json-pretty@2.4.0(vue@3.5.12(typescript@5.0.4)):
vue-json-viewer@3.0.4(vue@3.5.12(typescript@5.0.4)):
dependencies:
clipboard: 2.0.11
vue: 3.5.12(typescript@5.0.4)
vue-router@4.3.3(vue@3.5.12(typescript@5.0.4)):

View File

@@ -12,6 +12,7 @@ export interface UserInfo {
registrationDate: string
deptName: string
roles: string[]
roleNames: string[]
permissions: string[]
}

View File

@@ -0,0 +1,32 @@
// 分片上传 API 封装
import type * as T from './type'
import http from '@/utils/http'
export type * from './type'
const BASE_URL = '/system/multipart-upload'
/** @desc 初始化分片上传 */
export function initMultipartUpload(data: T.MultiPartUploadInitReq) {
return http.post<T.MultiPartUploadInitResp>(`${BASE_URL}/init`, data)
}
/** @desc 上传分片 */
export function uploadPart(data: T.UploadPartReq, signal?: AbortSignal) {
const formData = new FormData()
formData.append('file', data.file)
formData.append('uploadId', data.uploadId)
formData.append('partNumber', String(data.partNumber))
formData.append('path', data.path)
return http.post<T.UploadPartResp>(`${BASE_URL}/part`, formData, { signal })
}
/** @desc 完成上传 */
export function completeMultipartUpload(params: T.CompleteMultipartUploadReq) {
return http.get<string>(`${BASE_URL}/complete/${params.uploadId}`)
}
/** @desc 取消上传 */
export function cancelUpload(params: T.CancelUploadParams) {
return http.get<void>(`${BASE_URL}/cancel/${params.uploadId}`)
}

View File

@@ -467,3 +467,56 @@ export interface MessageQuery {
export interface MessagePageQuery extends MessageQuery, PageQuery {
}
/** 分片上传 - 初始化参数 */
export interface MultiPartUploadInitReq {
fileName: string
fileSize: number
fileMd5: string
parentPath: string
metaData: Record<string, string>
}
/** 分片上传 - 初始化响应 */
export interface MultiPartUploadInitResp {
uploadId: string
partSize: number
path: string
uploadedPartNumbers: number[]
}
/** 分片上传 - 上传分片参数 */
export interface UploadPartReq {
uploadId: string
partNumber: number
file: Blob
path: string
}
/** 分片上传 - 上传分片响应 */
export interface UploadPartResp {
/** 分片编号 */
partNumber: number
/** 分片ETag */
partETag: string
/** 分片大小 */
partSize: number
/** 是否成功 */
success: boolean
/** 错误信息 */
errorMessage?: string
}
/** 分片上传 - 完成上传参数 */
export interface CompleteMultipartUploadReq {
uploadId: string
partETags: Array<{
partNumber: number
eTag: string
}>
}
/** 分片上传 - 取消上传参数 */
export interface CancelUploadParams {
uploadId: string
}

View File

@@ -199,9 +199,17 @@ const updateValue = (value: any, field: string) => {
emit('update:modelValue', Object.assign(props.modelValue, { [field]: value }))
}
/** 必填项 */
const isRequired = (item: ColumnItem) => {
if (typeof item.required === 'boolean') return item.required
if (typeof item.required === 'function') {
return item.required(props.modelValue)
}
}
/** 表单项校验规则 */
const getFormItemRules = (item: ColumnItem) => {
if (item.required) {
if (isRequired(item)) {
const defaultProps = getComponentBindProps(item)
return [{ required: true, message: defaultProps.placeholder || `请输入${item.label}` }, ...(Array.isArray(item.rules) ? item.rules : [])]
}

View File

@@ -203,8 +203,8 @@ export interface ColumnItem<F = any> {
props?: ColumnItemProps
gridItemProps?: A.GridItemProps
formItemProps?: Omit<A.FormItemInstance['$props'], 'label' | 'field'> // a-form-item的props
required?: boolean // 是否必填
rules?: A.FormItemInstance['$props']['rules'] // 表单校验规则
required?: ColumnItemHide<F> // 是否必填
hide?: ColumnItemHide<F> // 是否隐藏
show?: ColumnItemShow<F> // 是否显示优先级比hide高
disabled?: ColumnItemDisabled<F> // 是否禁用

View File

@@ -1,13 +1,13 @@
<template>
<div class="json_pretty_container">
<VueJsonPretty path="res" :data="JSONObject" :show-length="true" />
<JsonViewer expand-depth="5" :value="JSONObject" :theme="currentThemeClass" sort />
<icon-copy class="copy_icon" @click="onCopy(JSONObject)" />
</div>
</template>
<script setup lang="ts">
import VueJsonPretty from 'vue-json-pretty'
import 'vue-json-pretty/lib/styles.css'
import JsonViewer from 'vue-json-viewer'
import { useTheme } from '@arco-design/web-vue/es/watermark/hooks/use-theme'
import { copyText } from '@/utils'
defineOptions({ name: 'JsonPretty', inheritAttrs: false })
@@ -15,16 +15,19 @@ defineOptions({ name: 'JsonPretty', inheritAttrs: false })
const props = defineProps<{
json: string
}>()
const JSONObject = computed(() => JSON.parse(props?.json))
// 拷贝
const onCopy = (data: object) => {
copyText(JSON.stringify(data))
}
// 监听主题变化
const { theme } = useTheme()
const currentThemeClass = computed(() => (theme.value === 'dark' ? 'vscode-dark' : 'vscode-light'))
</script>
<style scoped lang="scss">
@use "./json-them.scss";
.json_pretty_container {
width: 100%;
height: 100%;

View File

@@ -0,0 +1,43 @@
.vscode-light {
background: var(--color-bg-3,#ffffff);
color: #1f2328;
font-size: 14px;
font-family: 'Fira Code', 'Consolas', monospace;
:deep(.jv-key) { color: #0550ae; font-weight: 600; }
:deep(.jv-string) { color: #0c5460; }
:deep(.jv-number) { color: #116329; }
:deep(.jv-boolean) { color: #c24038; font-weight: 600; }
:deep(.jv-null) { color: #6f42c1; font-style: italic; }
:deep(.jv-undefined) { color: #e36209; }
:deep(.jv-function) { color: #795548; }
:deep(.jv-button) { color: #0969da; }
:deep(.jv-ellipsis) {
color: #6a737d;
background-color: #f6f8fa;
border-radius: 4px;
padding: 0 4px;
}
}
.vscode-dark {
background: var(--color-bg-3,#17171A);
color: #d4d4d4;
font-size: 14px;
font-family: 'Fira Code', 'Consolas', monospace;
:deep(.jv-key) { color: #9cdcfe; font-weight: 600; }
:deep(.jv-string) { color: #ce9178; }
:deep(.jv-number) { color: #b5cea8; }
:deep(.jv-boolean) { color: #569cd6; font-weight: 600; }
:deep(.jv-null) { color: #c586c0; font-style: italic; }
:deep(.jv-undefined) { color: #e08331; }
:deep(.jv-function) { color: #dcdcaa; }
:deep(.jv-button) { color: #4fc1ff; }
:deep(.jv-ellipsis) {
color: #999;
background-color: #333;
border-radius: 4px;
padding: 0 4px;
}
}

View File

@@ -0,0 +1,432 @@
<template>
<a-row :gutter="16" class="multipart-uploader-responsive-row">
<a-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
<div
class="multipart-uploader-table-flex"
:class="{ dragover: isDragOver }"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<!-- 文件/文件夹选择和全局操作按钮 -->
<div class="upload-select-area-flex">
<div class="upload-btns-left">
<a-button @click="triggerFileInput">选择文件</a-button>
<a-button style="margin-left: 8px;" @click="triggerFolderInput">选择文件夹</a-button>
<input ref="fileInput" type="file" multiple style="display: none" @change="onFileChange" />
<input ref="folderInput" type="file" webkitdirectory directory style="display: none" @change="onFolderChange" />
</div>
<div class="upload-btns-right">
<a-button type="primary" @click="startAllUpload">开始上传</a-button>
<a-button style="margin-left: 8px;" status="danger" @click="clearAllTasks">清空</a-button>
</div>
</div>
<div style="margin-bottom: 8px; color: #888; font-size: 13px;">
支持拖拽文件到此区域上传文件夹请使用"选择文件夹"按钮
<br />
<small style="color: #999;">提示拖拽上传时所有文件将上传到根目录</small>
</div>
<!-- 表格区域 -->
<div class="gi-table-flex-body">
<div class="gi-table-flex-container">
<a-table
:data="fileTasks"
:columns="columns"
row-key="uid"
:pagination="pagination"
style="height: 100%; background: transparent;"
>
<template #progress="{ record }">
<template v-if="md5CalculatingTaskUid === record.uid">
<span style="color: #888;">正在计算MD5...</span>
</template>
<template v-else>
<a-progress :percent="record.progress" :animation="true" size="large" />
</template>
</template>
<template #status="{ record }">
<div>
<a-tag :color="statusColor(record.status)" size="small">{{ statusText(record.status) }}</a-tag>
<div v-if="record.status === 'failed' && record.errorMessage" style="margin-top: 4px; font-size: 12px; color: #f56c6c;">
{{ record.errorMessage }}
</div>
</div>
</template>
<template #actions="{ record }">
<a-space>
<a-tooltip v-if="record.status === 'waiting'" content="开始">
<a-button size="mini" type="text" @click="startTask(record)"><IconPlayArrow /></a-button>
</a-tooltip>
<a-tooltip v-if="record.status === 'uploading'" content="暂停">
<a-button size="mini" type="text" @click="pauseTask(record)"><IconPause /></a-button>
</a-tooltip>
<a-tooltip v-if="record.status === 'paused'" content="继续">
<a-button size="mini" type="text" @click="resumeTask(record)"><IconPlayArrow /></a-button>
</a-tooltip>
<a-tooltip v-if="record.status === 'failed'" content="重试">
<a-button size="mini" type="text" @click="retryTask(record)"><IconRefresh /></a-button>
</a-tooltip>
<a-tooltip content="取消">
<a-button v-if="record.status !== 'completed' && record.status !== 'cancelled'" size="mini" type="text" @click="cancelTask(record)"><IconClose /></a-button>
</a-tooltip>
<a-tooltip content="删除">
<a-button size="mini" type="text" status="danger" @click="removeTask(record)"><IconDelete /></a-button>
</a-tooltip>
</a-space>
</template>
</a-table>
</div>
</div>
</div>
</a-col>
</a-row>
</template>
<script lang="ts" setup>
import { h, ref, resolveComponent } from 'vue'
import { IconClose, IconDelete, IconPause, IconPlayArrow, IconRefresh } from '@arco-design/web-vue/es/icon'
import { useMultipartUploader } from '@/hooks/modules/useMultipartUploader'
import { getFilesFromDataTransferItems, isFileSystemAccessAPISupported } from '@/utils/drag-drop-file-util'
// 组件props定义
const props = defineProps<{
extraParams?: Record<string, any>
maxConcurrentFiles?: number
maxConcurrentChunks?: number
maxUploadWorkers?: number
rootPath?: string
}>()
// 文件/文件夹选择input引用
const fileInput = ref<HTMLInputElement | null>(null)
const folderInput = ref<HTMLInputElement | null>(null)
// 拖拽高亮状态
const isDragOver = ref(false)
const pagination = {
pageSize: 10,
showTotal: true,
showJumper: true,
position: ['bottomCenter'],
}
// 文件大小格式化工具
function formatFileSize(bytes: number) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
// 表格列定义
const columns = [
{
title: '名称',
dataIndex: 'fileName',
ellipsis: true,
render: ({ record }) => h(
resolveComponent('a-tooltip'),
{ content: record.fileName, placement: 'top' },
() => h('span', record.fileName),
),
},
{
title: '文件目录',
dataIndex: 'relativePath',
ellipsis: true,
render: ({ record }) => {
// 显示完整路径
const displayPath = record.parentPath
// 确保路径格式正确
if (record.relativePath && record.relativePath !== '/') {
// 对于文件夹上传relativePath格式为folderName/file.txt
// 我们只需要显示parentPath因为它已经包含了正确的路径
const pathParts = record.relativePath.split('/')
if (pathParts.length > 1) {
// 如果是文件夹内的文件只显示parentPath
// parentPath已经是/test/upload这样的格式
}
}
return h(
resolveComponent('a-tooltip'),
{ content: displayPath, placement: 'top' },
() => h('span', displayPath),
)
},
},
{
title: '文件类型',
dataIndex: 'fileType',
ellipsis: true,
render: ({ record }) => h(
resolveComponent('a-tooltip'),
{ content: record.fileType, placement: 'top' },
() => h('span', record.fileType),
),
},
{
title: '文件大小',
dataIndex: 'fileSize',
ellipsis: true,
render: ({ record }) => formatFileSize(record.fileSize),
width: 120,
},
{ title: '进度', slotName: 'progress', width: 140 },
{ title: '状态', slotName: 'status', width: 80 },
{ title: '操作', slotName: 'actions', width: 150 },
]
// 使用 useMultipartUploader composable
const {
fileTasks,
uploadingCount: _uploadingCount,
maxConcurrent: _maxConcurrent,
maxChunkConcurrent: _maxChunkConcurrent,
startAllUpload,
addFiles,
pauseTask,
resumeTask,
cancelTask,
startTask,
retryTask,
clearAllTasks,
removeTask,
formatFileSize: _formatFileSize,
md5CalculatingTaskUid,
} = useMultipartUploader({
maxConcurrentFiles: props.maxConcurrentFiles,
maxConcurrentChunks: props.maxConcurrentChunks,
maxUploadWorkers: props.maxUploadWorkers,
rootPath: props.rootPath,
})
// 触发文件选择
function triggerFileInput() {
fileInput.value?.click()
}
// 触发文件夹选择
function triggerFolderInput() {
folderInput.value?.click()
}
// 文件选择事件处理
function onFileChange(e: Event) {
const files = (e.target as HTMLInputElement).files
if (!files) return
// 移除 clearAllTasks(),改为追加模式
// 普通文件上传路径 = rootPath
addFiles(Array.from(files), props.rootPath || '', false)
// 不要自动 startAllUpload()
;(e.target as HTMLInputElement).value = ''
}
// 文件夹选择事件处理
function onFolderChange(e: Event) {
const files = (e.target as HTMLInputElement).files
if (!files) return
// 移除 clearAllTasks(),改为追加模式
// 带目录文件上传路径 = rootPath
// 文件夹上传时webkitRelativePath会自动包含文件夹路径
addFiles(Array.from(files), props.rootPath || '', true)
// 不要自动 startAllUpload()
;(e.target as HTMLInputElement).value = ''
}
// 拖拽进入区域
function onDragOver(_e: DragEvent) {
isDragOver.value = true
}
// 拖拽离开区域
function onDragLeave(_e: DragEvent) {
isDragOver.value = false
}
// 拖拽释放文件/文件夹
async function onDrop(e: DragEvent) {
isDragOver.value = false
e.preventDefault()
let files: File[]
if (isFileSystemAccessAPISupported()) {
files = await getFilesFromDataTransferItems(e.dataTransfer!.items)
addFiles(files, props.rootPath || '', true)
} else {
files = Array.from(e.dataTransfer?.files || [])
// 验证文件的有效性
const validFiles = files.filter((file) => {
return !(!file || file.size === 0)
})
if (validFiles.length === 0) {
return
}
// 检查是否有文件夹结构
const hasFolder = validFiles.some((f) => {
if ((f as any).webkitRelativePath) {
return true
}
return f.name.includes('/') || f.name.includes('\\')
})
addFiles(validFiles, props.rootPath || '', hasFolder)
}
}
// 状态文本映射
function statusText(status: string) {
switch (status) {
case 'waiting': return '等待中'
case 'uploading': return '上传中'
case 'paused': return '已暂停'
case 'completed': return '已完成'
case 'failed': return '失败'
case 'cancelled': return '已取消'
default: return status
}
}
// 状态颜色映射
function statusColor(status: string) {
switch (status) {
case 'waiting': return '#909399'
case 'uploading': return '#409EFF'
case 'paused': return '#E6A23C'
case 'completed': return '#67C23A'
case 'failed': return '#F56C6C'
case 'cancelled': return '#C0C4CC'
default: return '#909399'
}
}
</script>
<style lang="scss" scoped>
.multipart-uploader-table-flex {
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px #0000000d;
border: 2px dashed #e5e6eb;
transition: border-color 0.2s, background 0.2s;
min-width: 1000px;
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
height: 700px;
}
.multipart-uploader-table-flex.dragover {
border: 2px dashed #409eff;
}
.upload-select-area-flex {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.upload-btns-left {
display: flex;
align-items: center;
}
.upload-btns-right {
display: flex;
align-items: center;
margin-left: auto;
}
.upload-select-area {
margin-bottom: 16px;
}
.gi-table-flex-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 400px;
border-radius: 8px;
box-shadow: 0 1px 4px #0001;
padding: 8px 0 0 0;
}
.gi-table-flex-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 400px;
height: 100%;
background: transparent;
}
:deep(.arco-table) {
flex: 1;
min-height: 400px;
height: 100%;
background: transparent;
display: flex;
flex-direction: column;
}
:deep(.arco-table-th) {
min-width: 120px;
font-weight: 500;
}
:deep(.arco-table-td) {
max-width: 400px;
min-width: 120px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
:deep(.arco-table-td:nth-child(1)),
:deep(.arco-table-th:nth-child(1)) {
min-width: 200px;
max-width: 350px;
}
:deep(.arco-table-td:nth-child(2)),
:deep(.arco-table-th:nth-child(2)) {
min-width: 180px;
max-width: 300px;
}
:deep(.arco-table-td:last-child),
:deep(.arco-table-th:last-child) {
min-width: 160px;
max-width: 200px;
}
:deep(.arco-table-element):has(tbody .arco-table-tr-empty) {
height: 100%;
}
:deep(.arco-table-pagination) {
margin-top: auto !important;
padding-bottom: 8px;
}
.multipart-uploader-responsive-row {
width: 100%;
margin: 0;
}
@media (max-width: 1200px) {
.multipart-uploader-table-flex {
min-width: 100%;
max-width: 100%;
padding: 12px;
}
}
@media (max-width: 900px) {
.multipart-uploader-table-flex {
min-width: 100%;
max-width: 100%;
padding: 6px;
}
.gi-table-flex-body {
min-height: 200px;
height: 300px;
padding: 0;
}
}
@media (max-width: 600px) {
.multipart-uploader-table-flex {
min-width: 100vw;
max-width: 100vw;
border-radius: 0;
padding: 2px;
}
.gi-table-flex-body {
min-height: 120px;
height: 180px;
padding: 0;
}
.upload-select-area-flex {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.upload-btns-right {
margin-left: 0;
}
}
</style>

View File

@@ -7,3 +7,4 @@ export * from './modules/useDevice'
export * from './modules/useBreakpoint'
export * from './modules/useDownload'
export * from './modules/useResetReactive'
export * from './modules/useMultipartUploader'

View File

@@ -1,49 +1,58 @@
import { Message, Notification } from '@arco-design/web-vue'
/**
* @description 接收数据流生成 blob创建链接下载文件
* @param {Function} api 导出表格的api方法 (必传)
* @param {string} tempName 导出的文件名 (必传)
* @param {object} params 导出的参数 (默认{})
* @param {boolean} isNotify 是否有导出消息提示 (默认为 true)
* @param {string} fileName 导出的文件名 (可选,例如:导出数据.xlsx默认从响应头或时间戳生成)
* @param {string} fileType 导出的文件格式 (默认为.xlsx)
* @param {boolean} isNotify 是否显示导出提示消息 (默认为 false)
* @returns {Promise<void>} 无返回值
*/
interface NavigatorWithMsSaveOrOpenBlob extends Navigator {
msSaveOrOpenBlob: (blob: Blob, fileName: string) => void
}
export const useDownload = async (api: () => Promise<any>, isNotify = false, tempName = '', fileType = '.xlsx') => {
export const useDownload = async (api: () => Promise<any>, fileName = '', fileType = '.xlsx', isNotify = false) => {
try {
const res = await api()
if (res.headers['content-disposition']) {
tempName = decodeURI(res.headers['content-disposition'].split(';')[1].split('=')[1])
} else {
tempName = tempName || new Date().getTime() + fileType
if (!fileName) {
if (res.headers['content-disposition']) {
// 从响应头提取文件名
fileName = decodeURI(res.headers['content-disposition'].split(';')[1].split('=')[1])
} else {
// 时间戳生成
fileName = new Date().getTime() + fileType
}
}
if (isNotify && !res?.code) {
Notification.warning({
title: '温馨提示',
content: '如果数据庞大会导致下载缓慢哦,请您耐心等待!',
})
}
if (res.status !== 200 || res.data == null || !(res.data instanceof Blob)) {
Message.error('导出失败,请稍后再试!')
// 验证响应数据
if (res.status !== 200 || !res.data || !(res.data instanceof Blob)) {
Message.error('导出失败:无效的响应数据')
return
}
const blob = new Blob([res.data])
// 兼容 edge 不支持 createObjectURL 方法
if ('msSaveOrOpenBlob' in (navigator as unknown as NavigatorWithMsSaveOrOpenBlob)) {
;(window.navigator as unknown as NavigatorWithMsSaveOrOpenBlob).msSaveOrOpenBlob(blob, tempName + fileType)
// 兼容 IE/Edge 浏览器
if ('msSaveOrOpenBlob' in navigator) {
return (navigator as unknown as NavigatorWithMsSaveOrOpenBlob).msSaveOrOpenBlob(blob, fileName)
}
// 创建下载链接并触发下载
const blobUrl = window.URL.createObjectURL(blob)
const exportFile = document.createElement('a')
exportFile.style.display = 'none'
exportFile.download = tempName
exportFile.href = blobUrl
document.body.appendChild(exportFile)
exportFile.click()
// 去除下载对 url 的影响
document.body.removeChild(exportFile)
const link = document.createElement('a')
link.style.display = 'none'
link.download = fileName
link.href = blobUrl
document.body.appendChild(link)
link.click()
// 清理资源
document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl)
} catch (error) {
// console.log(error)
const errorMsg = error instanceof Error ? error.message : '未知错误'
Message.error(`下载失败: ${errorMsg}`)
}
}

View File

@@ -0,0 +1,791 @@
// 分片上传通用 hooks支持多文件/多分片并发、暂停、恢复、取消、重试等
import { computed, onUnmounted, ref } from 'vue'
import { throttle } from 'lodash-es'
import {
cancelUpload,
completeMultipartUpload,
initMultipartUpload,
uploadPart,
} from '@/apis/system/multipart-upload'
// 文件上传任务对象类型
export interface FileTask {
uid: string // 唯一标识
file: File // 文件对象
relativePath: string // 相对路径(支持文件夹结构)
parentPath: string // 文件夹根路径
status: 'waiting' | 'uploading' | 'paused' | 'completed' | 'failed' | 'cancelled' // 状态
progress: number // 上传进度0-1
uploadedChunks: number[] // 已上传分片编号
totalChunks: number // 总分片数
chunkSize: number // 分片大小(由后端返回)
fileName: string // 文件名
fileType: string // 文件类型
fileSize: number // 文件大小
fileMd5?: string // 文件MD5
uploadId?: string // 分片上传ID
path?: string // 文件路径(由后端返回)
partETags: Array<{ partNumber: number, eTag: string }> // 分片ETag列表
errorMessage?: string // 错误信息
abortController?: AbortController // 请求中断控制器
_uploading?: boolean // 标记是否正在上传(内部控制)
_pause?: () => void // 暂停方法
_resume?: () => void // 继续方法
_cancel?: () => void // 取消方法
_retryCount?: Map<number, number> // 分片重试次数记录
}
/**
* useMultipartUploader - 通用分片上传 hooks
* @param props.maxConcurrentFiles 最大同时上传文件数(全局并发)
* @param props.maxConcurrentChunks 每个文件分片上传最大并发数
* @param props.maxUploadWorkers 最大上传Worker数量基于CPU核心数
* @returns 上传相关响应式状态与操作方法
*/
export function useMultipartUploader(props: {
maxConcurrentFiles?: number
maxConcurrentChunks?: number
maxUploadWorkers?: number
rootPath?: string
}) {
// 获取CPU核心数用于控制Worker数量
const getCpuCores = () => {
if (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) {
return navigator.hardwareConcurrency
}
return 2 // 默认2个核心
}
// 所有上传任务列表
const fileTasks = ref<FileTask[]>([])
// 当前正在上传的文件数量
const uploadingCount = computed(() => fileTasks.value.filter((t) => t.status === 'uploading').length)
// 最大并发上传文件数
const maxConcurrent = computed(() => props.maxConcurrentFiles ?? getCpuCores())
// 每个文件分片上传最大并发数
const maxChunkConcurrent = computed(() => props.maxConcurrentChunks ?? getCpuCores())
// 最大上传Worker数量
const maxUploadWorkers = computed(() => props.maxUploadWorkers ?? getCpuCores() / 2)
// 本地队列管理
const uploadQueue = ref<Array<{ task: FileTask, chunkNumber: number }>>([])
const activeUploads = ref<Set<string>>(new Set()) // 正在上传的分片ID集合
const md5CalculatingTaskUid = ref<string | null>(null) // MD5计算中的任务ID
const performanceStats = ref<{
md5StartTime: number
md5EndTime: number
uploadStartTime: number
uploadEndTime: number
totalTime: number
} | null>(null)
// MD5 Worker实例
let md5Worker: Worker | null = null
/** 节流的进度更新函数 */
const updateTaskProgress = throttle((task: FileTask, totalChunks: number) => {
const currentFinishedChunks = task.uploadedChunks.length
if (totalChunks > 0) {
task.progress = Number(Math.min(currentFinishedChunks / totalChunks, 1).toFixed(2))
} else {
task.progress = 0
}
}, 150)
/**
* 初始化MD5 Worker
*/
function initMd5Worker() {
if (typeof Worker !== 'undefined' && !md5Worker) {
// eslint-disable-next-line no-console
console.log('[Hooks] 初始化MD5 Worker...')
md5Worker = new Worker('/src/utils/md5-worker.ts', { type: 'module' })
md5Worker.onmessage = function (e) {
const { type, taskId, md5, error } = e.data
if (type === 'complete' && md5) {
const task = fileTasks.value.find((t) => t.uid === taskId)
if (task) {
task.fileMd5 = md5
md5CalculatingTaskUid.value = null
// eslint-disable-next-line no-console
console.log(`[Hooks] MD5计算完成: ${task.fileName}, MD5: ${md5}`)
}
} else if (type === 'error') {
console.error('MD5计算失败:', error)
md5CalculatingTaskUid.value = null
}
}
}
}
/**
* 计算文件MD5使用Web Worker - 优化版本)
*/
function calcFileMd5(file: File, taskUid: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!md5Worker) {
initMd5Worker()
}
if (md5Worker) {
md5CalculatingTaskUid.value = taskUid
// 记录MD5计算开始时间
performanceStats.value = {
md5StartTime: Date.now(),
md5EndTime: 0,
uploadStartTime: 0,
uploadEndTime: 0,
totalTime: 0,
}
// 根据文件大小动态调整分块和分片大小
const blockSize = file.size > 200 * 1024 * 1024 ? 50 * 1024 * 1024 : 25 * 1024 * 1024 // 50MB或25MB块
const chunkSize = file.size > 100 * 1024 * 1024 ? 10 * 1024 * 1024 : 2 * 1024 * 1024 // 10MB或2MB分片
// eslint-disable-next-line no-console
console.log(`[Hooks] 发送文件到Worker: ${file.name}, 大小: ${(file.size / 1024 / 1024).toFixed(2)}MB, 块大小: ${(blockSize / 1024 / 1024).toFixed(2)}MB, 分片大小: ${(chunkSize / 1024 / 1024).toFixed(2)}MB`)
md5Worker.postMessage({
file,
taskId: taskUid,
blockSize,
chunkSize,
})
// 监听完成事件
const handleComplete = (e: MessageEvent) => {
const { type, taskId, md5 } = e.data
if (type === 'complete' && taskId === taskUid) {
md5Worker?.removeEventListener('message', handleComplete)
// 记录MD5计算结束时间
if (performanceStats.value) {
performanceStats.value.md5EndTime = Date.now()
const md5Time = performanceStats.value.md5EndTime - performanceStats.value.md5StartTime
// eslint-disable-next-line no-console
console.log(`MD5计算完成耗时: ${md5Time}ms文件大小: ${formatFileSize(file.size)}`)
}
resolve(md5)
}
}
md5Worker.addEventListener('message', handleComplete)
} else {
reject(new Error('Web Worker not supported'))
}
})
}
/**
* 添加分片到上传队列
*/
function addChunkToQueue(task: FileTask, chunkNumber: number) {
const chunkId = `${task.uid}-${chunkNumber}`
if (!activeUploads.value.has(chunkId)) {
uploadQueue.value.push({ task, chunkNumber })
// eslint-disable-next-line no-console
console.log(`添加分片到队列: ${task.fileName} - 分片${chunkNumber}`)
processUploadQueue()
}
}
/**
* 处理上传队列 - 优化版本
*/
function processUploadQueue() {
// eslint-disable-next-line no-console
console.log(`[Hooks] 处理上传队列,队列长度: ${uploadQueue.value.length}, 活跃上传数: ${activeUploads.value.size}`)
// 智能队列处理:优先处理小文件的分片,避免大文件阻塞
const sortedQueue = [...uploadQueue.value].sort((a, b) => {
// 优先处理已完成更多分片的文件
const aProgress = a.task.uploadedChunks.length / a.task.totalChunks
const bProgress = b.task.uploadedChunks.length / b.task.totalChunks
return bProgress - aProgress
})
while (sortedQueue.length > 0 && activeUploads.value.size < maxUploadWorkers.value) {
const { task, chunkNumber } = sortedQueue.shift()!
const chunkId = `${task.uid}-${chunkNumber}`
// eslint-disable-next-line no-console
console.log(`[Hooks] 检查分片: ${task.fileName} - 分片${chunkNumber}, 任务状态: ${task.status}`)
if (task.status === 'uploading' && !activeUploads.value.has(chunkId)) {
// eslint-disable-next-line no-console
console.log(`[Hooks] 开始上传分片: ${task.fileName} - 分片${chunkNumber}`)
activeUploads.value.add(chunkId)
uploadChunk(task, chunkNumber)
// 从原始队列中移除已处理的项目
const index = uploadQueue.value.findIndex((item) =>
item.task.uid === task.uid && item.chunkNumber === chunkNumber,
)
if (index > -1) {
uploadQueue.value.splice(index, 1)
}
} else {
// eslint-disable-next-line no-console
console.log(`[Hooks] 跳过分片: ${task.fileName} - 分片${chunkNumber}, 原因: 状态不是uploading或已在活跃上传中`)
}
}
}
/**
* 上传单个分片
*/
async function uploadChunk(task: FileTask, chunkNumber: number) {
const chunkId = `${task.uid}-${chunkNumber}`
try {
const start = (chunkNumber - 1) * task.chunkSize
const end = Math.min(start + task.chunkSize, task.fileSize)
const chunkBlob = task.file.slice(start, end)
// 创建 AbortController 用于中断请求
if (!task.abortController) {
task.abortController = new AbortController()
}
const res = await uploadPart({
uploadId: task.uploadId!,
partNumber: chunkNumber,
file: chunkBlob,
path: task.path!,
}, task.abortController.signal)
// 检查上传是否成功
if (res.data && res.data.success) {
// 保存ETag
task.partETags.push({
partNumber: chunkNumber,
eTag: res.data.partETag,
})
// 更新已上传分片列表
if (!task.uploadedChunks.includes(chunkNumber)) {
task.uploadedChunks.push(chunkNumber)
}
updateTaskProgress(task, task.totalChunks)
// 检查是否所有分片都上传完成
if (task.uploadedChunks.length >= task.totalChunks) {
await completeMultipartUpload({
uploadId: task.uploadId!,
partETags: task.partETags,
})
task.status = 'completed'
task.progress = 1
startNextTasks()
}
} else {
// 上传失败,抛出错误
const errorMessage = res.data?.errorMessage || '分片上传失败'
throw new Error(`分片${chunkNumber}上传失败: ${errorMessage}`)
}
} catch (error) {
// 检查是否是取消请求导致的错误
if (error instanceof Error && error.name === 'AbortError') {
// eslint-disable-next-line no-console
console.log(`分片上传被取消: ${task.fileName} - 分片${chunkNumber}`)
return
}
console.error(`分片上传失败: ${task.fileName} - 分片${chunkNumber}`, error)
// 检查任务是否已经被取消或暂停
if (task.status === 'cancelled' || task.status === 'paused') {
// eslint-disable-next-line no-console
console.log(`任务 ${task.fileName} 已被取消或暂停,跳过错误处理`)
return
}
// 检查是否是网络错误或服务器错误
const isNetworkError = error instanceof TypeError
|| (error as any)?.message?.includes('Network')
|| (error as any)?.message?.includes('fetch')
const isServerError = (error as any)?.response?.status >= 500
|| (error as any)?.response?.status === 429
if (isNetworkError || isServerError) {
// 初始化重试计数器
if (!task._retryCount) {
task._retryCount = new Map()
}
const currentRetryCount = task._retryCount.get(chunkNumber) || 0
const maxRetries = 3 // 最大重试3次
if (currentRetryCount < maxRetries) {
// 网络错误或服务器错误,将分片重新加入队列进行重试
// eslint-disable-next-line no-console
console.log(`分片 ${chunkNumber} 上传失败,第${currentRetryCount + 1}次重试: ${task.fileName}`)
// 更新重试次数
task._retryCount.set(chunkNumber, currentRetryCount + 1)
// 延迟重试,避免立即重试
setTimeout(() => {
if (task.status === 'uploading' && !activeUploads.value.has(chunkId)) {
addChunkToQueue(task, chunkNumber)
}
}, 2000 * (currentRetryCount + 1)) // 递增延迟2秒、4秒、6秒
} else {
// 超过最大重试次数,标记任务失败
// eslint-disable-next-line no-console
console.log(`分片 ${chunkNumber} 重试次数超过限制,标记任务失败: ${task.fileName}`)
task.status = 'failed'
task.errorMessage = `分片 ${chunkNumber} 重试次数超过限制`
// 清理队列中该任务的所有分片
uploadQueue.value = uploadQueue.value.filter((item) => item.task.uid !== task.uid)
// 清理正在上传的分片
const activeChunkIds = Array.from(activeUploads.value).filter((id) => id.startsWith(task.uid))
activeChunkIds.forEach((id) => activeUploads.value.delete(id))
// 启动下一个任务
startNextTasks()
}
} else {
// 其他错误(如认证错误、参数错误等),标记任务失败
// eslint-disable-next-line no-console
console.log(`任务 ${task.fileName} 遇到不可恢复的错误,标记为失败`)
task.status = 'failed'
task.errorMessage = (error as Error)?.message || '上传失败'
// 清理队列中该任务的所有分片
uploadQueue.value = uploadQueue.value.filter((item) => item.task.uid !== task.uid)
// 清理正在上传的分片
const activeChunkIds = Array.from(activeUploads.value).filter((id) => id.startsWith(task.uid))
activeChunkIds.forEach((id) => activeUploads.value.delete(id))
// 启动下一个任务
startNextTasks()
}
} finally {
activeUploads.value.delete(chunkId)
processUploadQueue() // 继续处理队列
}
}
/**
* 分片上传核心逻辑,处理单个文件的分片上传、并发、暂停、恢复、取消等
* @param task FileTask
*/
async function uploadFileTask(task: FileTask) {
try {
// eslint-disable-next-line no-console
console.log(`[Hooks] 开始上传任务: ${task.fileName}, 当前状态: ${task.status}`)
// 1. 初始化分片上传,获取 uploadId
if (!task.uploadId) {
// eslint-disable-next-line no-console
console.log(`[Hooks] 任务 ${task.fileName} 没有 uploadId准备调用 initMultipartUpload`)
// 若没有MD5先计算
if (!task.fileMd5) {
// eslint-disable-next-line no-console
console.log(`[Hooks] 任务 ${task.fileName} 没有 MD5开始计算...`)
task.fileMd5 = await calcFileMd5(task.file, task.uid)
}
// eslint-disable-next-line no-console
console.log(`[Hooks] 调用 initMultipartUpload: ${task.fileName}, MD5: ${task.fileMd5}, 路径: ${task.parentPath}`)
// 确保parentPath不是空字符串如果是则使用"/"
const parentPath = task.parentPath && task.parentPath !== '' ? task.parentPath : '/'
const res = await initMultipartUpload({
fileName: task.fileName,
fileSize: task.fileSize,
fileMd5: task.fileMd5,
parentPath,
metaData: {
contentType: task.fileType,
originalName: task.fileName,
},
})
if (res && res.data) {
// eslint-disable-next-line no-console
console.log(`[Hooks] initMultipartUpload 成功: ${task.fileName}, uploadId: ${res.data.uploadId}`)
task.uploadId = res.data.uploadId
task.chunkSize = res.data.partSize
task.path = res.data.path
// 处理断点续传:如果后端返回了已上传的分片编号
if (res.data.uploadedPartNumbers && res.data.uploadedPartNumbers.length > 0) {
// eslint-disable-next-line no-console
console.log(`[Hooks] 发现已上传分片: ${task.fileName}, 已上传分片: ${res.data.uploadedPartNumbers.join(',')}`)
// 将已上传的分片编号添加到任务中
task.uploadedChunks = [...res.data.uploadedPartNumbers]
// 计算当前进度
const totalChunks = Math.ceil(task.fileSize / task.chunkSize)
updateTaskProgress(task, totalChunks)
// eslint-disable-next-line no-console
console.log(`[Hooks] 断点续传进度: ${task.fileName}, 进度: ${(task.progress * 100).toFixed(1)}%`)
}
} else {
// eslint-disable-next-line no-console
console.log(`[Hooks] initMultipartUpload 失败: ${task.fileName}`)
task.status = 'failed'
return
}
}
// 2. 计算总分片数
const totalChunks = Math.ceil(task.fileSize / task.chunkSize)
task.totalChunks = totalChunks
// eslint-disable-next-line no-console
console.log(`[Hooks] 计算总分片数: ${task.fileName}, 总分片数: ${totalChunks}, 分片大小: ${task.chunkSize}`)
// 检查是否有断点续传的分片
const hasResumeData = task.uploadedChunks.length > 0
if (!hasResumeData) {
// 如果没有断点续传数据,重新初始化
task.uploadedChunks = []
task.partETags = []
task.progress = 0
} else {
// 有断点续传数据,计算当前进度
// eslint-disable-next-line no-console
console.log(`[Hooks] 发现断点续传数据: ${task.fileName}, 已上传分片: ${task.uploadedChunks.join(',')}`)
updateTaskProgress(task, task.totalChunks)
}
// 将所有未完成的分片添加到队列
// eslint-disable-next-line no-console
console.log(`[Hooks] 开始添加分片到队列: ${task.fileName}`)
for (let i = 1; i <= totalChunks; i++) {
// 只添加未上传的分片
if (!task.uploadedChunks.includes(i)) {
addChunkToQueue(task, i)
}
}
// eslint-disable-next-line no-console
console.log(`[Hooks] 分片添加完成: ${task.fileName}, 队列长度: ${uploadQueue.value.length}`)
// 挂载暂停/取消控制方法到 task
task._pause = () => {
task.status = 'paused'
// 暂停时清空队列中该任务的分片
uploadQueue.value = uploadQueue.value.filter((item) => item.task.uid !== task.uid)
// 清理正在上传的分片
const activeChunkIds = Array.from(activeUploads.value).filter((id) => id.startsWith(task.uid))
activeChunkIds.forEach((id) => activeUploads.value.delete(id))
}
task._resume = () => {
if (task.status === 'paused') {
task.status = 'uploading'
// 重新添加未完成的分片到队列
for (let i = 1; i <= task.totalChunks; i++) {
if (!task.uploadedChunks.includes(i)) {
addChunkToQueue(task, i)
}
}
}
}
task._cancel = () => {
task.status = 'cancelled'
// 中断所有正在进行的请求
if (task.abortController) {
task.abortController.abort()
}
// 清空队列中该任务的分片
uploadQueue.value = uploadQueue.value.filter((item) => item.task.uid !== task.uid)
// 清理正在上传的分片
const activeChunkIds = Array.from(activeUploads.value).filter((id) => id.startsWith(task.uid))
activeChunkIds.forEach((id) => activeUploads.value.delete(id))
if (task.uploadId) {
cancelUpload({ uploadId: task.uploadId })
}
}
} catch (e) {
task.status = 'failed'
startNextTasks()
}
}
/**
* 启动下一个可用的上传任务(受最大并发数限制)
*/
function startNextTasks() {
let available = maxConcurrent.value - uploadingCount.value
for (const task of fileTasks.value) {
if (available <= 0) break
if ((task.status === 'waiting' || task.status === 'uploading') && !task._uploading) {
task.status = 'uploading'
task._uploading = true
available--
uploadFileTask(task)
}
}
}
/**
* 全部开始上传(将所有 waiting 状态任务置为 uploading 并启动并发上传)
*/
function startAllUpload() {
// eslint-disable-next-line no-console
console.log('[Hooks] 开始上传按钮被点击,准备启动所有等待中的任务')
// eslint-disable-next-line no-console
console.log('[Hooks] 当前任务列表:', fileTasks.value.map((t) => ({ name: t.fileName, status: t.status })))
for (const task of fileTasks.value) {
if (task.status === 'waiting' || task.status === 'paused') {
task._uploading = false // 标记尚未调度
// 如果是暂停状态,需要重新激活
if (task.status === 'paused') {
task.status = 'uploading'
}
}
}
startNextTasks()
}
/**
* 添加文件/文件夹到上传队列
* @param files File[]
* @param parentPath 父目录
* @param isFolder 是否为文件夹
*/
function addFiles(files: File[], parentPath: string, isFolder = false) {
// 验证文件的有效性
const validFiles = files.filter((file) => {
if (!file) {
return false
}
if (file.size === 0) {
return false
}
if (!file.name || file.name.trim() === '') {
return false
}
return true
})
if (validFiles.length === 0) {
return
}
for (const file of validFiles) {
const relativePath = (file as any).webkitRelativePath || '/'
let parent = ''
// 调试:查看 webkitRelativePath 的实际内容
// eslint-disable-next-line no-console
console.log('文件路径调试:', {
fileName: file.name,
webkitRelativePath: relativePath,
isFolder,
})
if (isFolder) {
// 文件夹上传如果有webkitRelativePath则路径为 rootPath + webkitRelativePath
// 如果没有webkitRelativePath则路径为 rootPath
if (relativePath && relativePath !== '/') {
// 有webkitRelativePath的情况例如folder/file.txt
parent = props.rootPath || parentPath || '/'
// 确保路径格式正确,去除结尾的斜杠
if (parent.length > 1 && parent.endsWith('/')) {
parent = parent.slice(0, -1)
}
// 从 webkitRelativePath 中提取文件夹路径(去掉文件名)
const pathParts = relativePath.split('/')
// 去掉最后一个部分(文件名),只保留文件夹路径
pathParts.pop()
const folderPath = pathParts.join('/') // 重新组合文件夹路径
// 组合路径rootPath + 文件夹路径
if (folderPath) {
parent = `${parent}/${folderPath}`
}
} else {
// 没有webkitRelativePath的情况直接使用rootPath
parent = props.rootPath || parentPath || '/'
}
} else {
// 普通文件上传直接使用rootPath
parent = props.rootPath || parentPath || '/'
}
// 去除 parentPath 结尾的 /
if (parent.length > 1 && parent.endsWith('/')) {
parent = parent.slice(0, -1)
}
// 确保路径不以双斜杠开头
if (parent.startsWith('//')) {
parent = parent.substring(1)
}
// eslint-disable-next-line no-console
console.log('最终路径:', {
fileName: file.name,
parentPath: parent,
relativePath,
})
const task: FileTask = {
uid: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
file,
fileName: file.name,
fileType: file.type,
fileSize: file.size,
relativePath,
parentPath: parent,
status: 'waiting',
progress: 0,
uploadedChunks: [],
totalChunks: 0,
chunkSize: 0, // 初始化时设为0后续由后端返回
fileMd5: '',
path: '', // 初始化时设为空,后续由后端返回
partETags: [],
errorMessage: '', // 初始化错误信息
abortController: new AbortController(), // 初始化请求中断控制器
_retryCount: new Map(), // 初始化重试计数器
}
// 立即开始计算MD5但不自动开始上传
calcFileMd5(file, task.uid).then((md5) => {
task.fileMd5 = md5
}).catch((_error) => {
task.status = 'failed'
})
fileTasks.value.push(task)
}
}
// 暂停单个任务
function pauseTask(task: FileTask) {
// eslint-disable-next-line no-console
console.log(`暂停任务: ${task.fileName}`)
task._pause?.()
}
// 恢复单个任务
function resumeTask(task: FileTask) {
// eslint-disable-next-line no-console
console.log(`[Hooks] 继续任务: ${task.fileName}, 当前状态: ${task.status}`)
task._resume?.()
if (task.status === 'paused') {
task._uploading = false
startNextTasks()
}
}
// 取消单个任务
function cancelTask(task: FileTask) {
// eslint-disable-next-line no-console
console.log(`取消任务: ${task.fileName}`)
// 中断所有正在进行的请求
if (task.abortController) {
task.abortController.abort()
// eslint-disable-next-line no-console
console.log(`已中断任务 ${task.fileName} 的所有请求`)
}
task._cancel?.()
}
// 启动单个任务
function startTask(task: FileTask) {
if (task.status === 'waiting') {
task.status = 'uploading'
task._uploading = false
startNextTasks()
}
}
// 失败重试单个任务
function retryTask(task: FileTask) {
if (task.status === 'failed') {
task.status = 'uploading'
task.progress = 0
// 重试时保留已上传的分片信息,支持断点续传
// task.uploadedChunks = [] // 注释掉,保留断点续传数据
// task.partETags = [] // 注释掉,保留断点续传数据
task._uploading = false
task._retryCount = new Map() // 重置重试计数器
task.errorMessage = '' // 清除错误信息
task.abortController = new AbortController() // 重新创建请求中断控制器
startNextTasks()
}
}
// 清空所有上传任务
function clearAllTasks() {
fileTasks.value = []
}
// 删除单个任务
function removeTask(task: FileTask) {
// 先取消任务
if (task.status === 'uploading' || task.status === 'waiting' || task.status === 'paused') {
task._cancel?.()
}
// 清理队列中的分片
uploadQueue.value = uploadQueue.value.filter((item) => item.task.uid !== task.uid)
// 清理正在上传的分片
const activeChunkIds = Array.from(activeUploads.value).filter((id) => id.startsWith(task.uid))
activeChunkIds.forEach((id) => activeUploads.value.delete(id))
// 从任务列表中移除
fileTasks.value = fileTasks.value.filter((t) => t.uid !== task.uid)
}
// 文件大小格式化工具
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
// 组件销毁时终止所有上传任务和Worker
onUnmounted(() => {
fileTasks.value.forEach((task) => {
if (task.status === 'uploading' || task.status === 'waiting' || task.status === 'paused') {
pauseTask(task)
}
})
if (md5Worker) {
md5Worker.terminate()
md5Worker = null
}
})
return {
fileTasks,
uploadingCount,
maxConcurrent,
maxChunkConcurrent,
uploadFileTask,
startNextTasks,
startAllUpload,
addFiles,
pauseTask,
resumeTask,
cancelTask,
startTask,
retryTask,
clearAllTasks,
removeTask,
formatFileSize,
md5CalculatingTaskUid,
}
}

View File

@@ -27,6 +27,9 @@ import directives from './directives'
// 状态管理
import pinia from '@/stores'
// 多语言切换
import {translateJsVueUseModel} from './utils/translate' // 导入translate插件
// 对特定组件进行默认配置
Card.props.bordered = false
@@ -39,5 +42,6 @@ app.use(pinia)
app.use(ArcoVue)
app.use(ArcoVueIcon)
app.use(directives)
//app.use(translateJsVueUseModel) //去掉注释,即可启用多语言切换能力。 具体多语言切换的设置,位于 /src/utils/translate.ts
app.mount('#app')

View File

@@ -33,6 +33,7 @@ const storeSetup = () => {
registrationDate: '',
deptName: '',
roles: [],
roleNames: [],
permissions: [],
})
const nickname = computed(() => userInfo.nickname)

View File

@@ -52,6 +52,7 @@ declare module 'vue' {
JsonPretty: typeof import('./../components/JsonPretty/index.vue')['default']
MinuteForm: typeof import('./../components/GenCron/CronForm/component/minute-form.vue')['default']
MonthForm: typeof import('./../components/GenCron/CronForm/component/month-form.vue')['default']
MultipartUpload: typeof import('./../components/MultipartUpload/index.vue')['default']
ParentView: typeof import('./../components/ParentView/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

View File

@@ -0,0 +1,49 @@
/**
* 递归读取 DataTransferItemList 中的所有文件(支持文件夹结构)
* 自动为每个 File 对象添加 webkitRelativePath 属性
* 仅在支持 File System Access API 的浏览器下有效
*/
export async function getFilesFromDataTransferItems(items: DataTransferItemList): Promise<File[]> {
const files: File[] = []
async function traverse(handle: FileSystemHandle, path = ''): Promise<void> {
if (handle.kind === 'file') {
const file = await (handle as FileSystemFileHandle).getFile()
// 创建新的 File 对象,包含相对路径信息
const fileWithPath = new File([file], file.name, {
type: file.type,
lastModified: file.lastModified,
})
// 使用 Object.defineProperty 添加 webkitRelativePath
Object.defineProperty(fileWithPath, 'webkitRelativePath', {
value: path + file.name,
writable: false,
enumerable: true,
configurable: true,
})
files.push(fileWithPath)
} else if (handle.kind === 'directory') {
for await (const [name, childHandle] of (handle as any).entries()) {
await traverse(childHandle, `${path}${name}/`)
}
}
}
for (const item of Array.from(items)) {
if (item.kind === 'file' && 'getAsFileSystemHandle' in item) {
const handle = await (item as any).getAsFileSystemHandle()
if (handle) {
await traverse(handle, '')
}
}
}
return files
}
/**
* 检查当前浏览器是否支持 File System Access API 拖拽文件夹
*/
export function isFileSystemAccessAPISupported(): boolean {
return typeof window !== 'undefined' && 'getAsFileSystemHandle' in DataTransferItem.prototype
}

149
src/utils/md5-worker.ts Normal file
View File

@@ -0,0 +1,149 @@
import SparkMD5 from 'spark-md5'
// 确保在 Web Worker 环境中运行
if (typeof globalThis !== 'undefined') {
// 监听来自主线程的消息
globalThis.addEventListener('message', (event) => {
const { file, taskId, blockSize, chunkSize } = event.data
if (file && taskId && blockSize && chunkSize) {
calculateFileMd5Optimized(file, taskId, blockSize, chunkSize)
} else {
globalThis.postMessage({
type: 'error',
taskId: taskId || 'unknown',
error: 'Missing required parameters: file, taskId, blockSize, chunkSize',
})
}
})
}
function calculateFileMd5Optimized(file: File, taskId: string, blockSize: number, chunkSize: number) {
const totalSize = file.size
const blocks = Math.ceil(totalSize / blockSize)
const blockHashes: string[] = Array.from({ length: blocks })
let processedBytes = 0
let processedBlocks = 0
const maxConcurrency = Math.max(2, navigator.hardwareConcurrency || 2)
let activeWorkers = 0
let nextBlockIndex = 0
function processBlock(blockIndex: number): Promise<void> {
return new Promise((resolve, reject) => {
try {
const start = blockIndex * blockSize
const end = Math.min(start + blockSize, totalSize)
const block = file.slice(start, end)
const spark = new SparkMD5.ArrayBuffer()
const chunks = Math.ceil(block.size / chunkSize)
let currentChunk = 0
const reader = new FileReader()
reader.onload = function (e: ProgressEvent<FileReader>) {
try {
if (e.target?.result) {
spark.append(e.target.result as ArrayBuffer)
processedBytes += (e.target.result as ArrayBuffer).byteLength
globalThis.postMessage({
type: 'progress',
taskId,
progress: processedBytes / totalSize,
processedBytes,
totalSize,
})
currentChunk++
if (currentChunk < chunks) {
loadNextChunk()
} else {
blockHashes[blockIndex] = spark.end()
processedBlocks++
resolve()
}
} else {
reject(new Error('FileReader result is null'))
}
} catch (error) {
reject(error)
}
}
reader.onerror = function (e: ProgressEvent<FileReader>) {
console.error(`[Worker] 文件读取错误:`, e)
globalThis.postMessage({
type: 'error',
taskId,
error: e,
})
reject(new Error('FileReader error'))
}
function loadNextChunk() {
try {
const chunkStart = currentChunk * chunkSize
const chunkEnd = Math.min(chunkStart + chunkSize, block.size)
const blob = block.slice(chunkStart, chunkEnd)
reader.readAsArrayBuffer(blob)
} catch (error) {
reject(error)
}
}
loadNextChunk()
} catch (error) {
reject(error)
}
})
}
function scheduleBlocks() {
while (activeWorkers < maxConcurrency && nextBlockIndex < blocks) {
const currentIndex = nextBlockIndex++
activeWorkers++
processBlock(currentIndex).then(() => {
activeWorkers--
if (processedBlocks >= blocks) {
// 所有块完成,计算最终 MD5
try {
const finalSpark = new SparkMD5.ArrayBuffer()
blockHashes.forEach((hash) => {
const hashBuffer = new TextEncoder().encode(hash)
finalSpark.append(hashBuffer)
})
const finalMd5 = finalSpark.end()
globalThis.postMessage({
type: 'complete',
taskId,
md5: finalMd5,
})
} catch (error) {
console.error(`[Worker] 计算最终 MD5 时出错:`, error)
globalThis.postMessage({
type: 'error',
taskId,
error,
})
}
} else {
// 继续调度
scheduleBlocks()
}
}).catch((error) => {
activeWorkers--
console.error(`[Worker] 处理块时出错:`, error)
globalThis.postMessage({
type: 'error',
taskId,
error,
})
})
}
}
// 启动调度器
scheduleBlocks()
}

54
src/utils/translate.ts Normal file
View File

@@ -0,0 +1,54 @@
import {translateJsVueUseModel, translate} from './translateVue3TS' // 导入 translate 的 VUE3 的 ts 插件
/*
translate.js AI 多语言切换模块的自定义配置。
如果不想启用,你可以通过以下方式中的任何一种进行禁用
1. 直接将所有配置全部注释掉
2. 将 /src/main.ts 中的这一行 app.use(translateJsVueUseModel) 注释掉即可。
*/
//打印包含具体执行时间的debug日志
//translate.time.use = true;
//window.translate.time.printTime = 100;
// 针对翻译动作的性能监控 https://translate.zvo.cn/549733.html
//translate.time.execute.start();
// 设置当前切换所支持的语言 http://translate.zvo.cn/4056.html
window.translate.selectLanguageTag.languages = 'chinese_simplified,english,korean,latin,french,russian';
// 设置本地语种(当前网页的语种) ,如果你网页语种很多,比如国际化论坛,哪个国家发言的都有,那这里你可以不用设置,交给 translate.js 自动去识别当前网页语种 http://translate.zvo.cn/4066.html
window.translate.language.setLocal('chinese_simplified');
// 本地语种也进行强制翻译 http://translate.zvo.cn/289574.html
//translate.language.translateLocal = true;
// 翻译时忽略指定的文字不翻译 http://translate.zvo.cn/283381.html
translate.ignore.text.push('ContiNew Admin');
// 网页打开时自动隐藏文字,翻译完成后显示译文 http://translate.zvo.cn/549731.html
// 注意,如果不启用本多语言切换能力,这个要注释掉,不然你网页的文本是会被隐藏的
//window.translate.visual.webPageLoadTranslateBeforeHiddenText({inHeadTip: false});
// 启用翻译中的遮罩层 http://translate.zvo.cn/407105.html
window.translate.progress.api.startUITip();
// 设置采用开源免费的 client.edge 无服务端翻译服务通道,无需任何注册接入即可直接使用 http://translate.zvo.cn/4081.html
translate.service.use('client.edge');
// 网页ajax请求触发自动翻译 http://translate.zvo.cn/4086.html
translate.request.listener.start();
// 开启页面元素动态监控js改变的内容也会被翻译参考文档 http://translate.zvo.cn/4067.html
translate.listener.start();
// 元素的内容整体翻译能力配置 ,提高翻译的语义 http://translate.zvo.cn/4078.html
translate.whole.enableAll();
//触发翻译执行,有关这个的说明可参考 http://translate.zvo.cn/547814.html
translate.execute();
//导出其中translateJsVueUseModel为vue插件translate为js函数
export { translateJsVueUseModel, translate };

View File

@@ -0,0 +1,91 @@
import { nextTick } from 'vue';
import translate from 'i18n-jsautotranslate'
/*
因为这个文件没什么需要用户单独设置的只是整体对vue的适配所以这个文件后续调好了会放到 npm 上当前因为下面DOM渲染完毕触发的问题没有精准触发所以暂时先放到这里进行方便优化调试
*/
var originalTrasnalteLog = translate.log;
translate.log = function(obj){
if(typeof(obj) === 'string' && obj.indexOf('- translate.js -') !== -1){
//不显示 translate.js 的说明
}else{
originalTrasnalteLog(obj);
}
}
//vue3框架的一些单独设置
translate.vue3 = {
/*
是否有 translate.execute() 代码的触发
有则是true没有则是false
false则不会再dom渲染完后自动进行翻译,自然也不会显示 select 选择语言
*/
isExecute: false,
}
//如果网页上有 translate.execute() 代码的触发,那么就设置 isExecute 为 true
translate.lifecycle.execute.trigger.push(function(data){
if(data.executeTriggerNumber === 1){
translate.vue3.isExecute = true;
translate.time.log('打开页面后,第一次触发 translate.execute() - 设置 translate.vue3.isExecute = true;');
return false;
}
});
translate.time.log('设置vue3初始化配置 - translate.vue3');
//将 translate 参数挂载到 window 上,方便在全局调用
if(typeof(window.translate) === 'undefined'){
window.translate = translate;
}
translate.time.log('将 translate 参数挂载到 window 上,方便在全局调用');
const translateJsVueUseModel = {
install(app) {
// 直接监听应用挂载完成
const originalMount = app.mount;
app.mount = function(...args) {
const root = originalMount.apply(this, args);
// 应用挂载完成后执行
// 使用双重nextTick确保DOM完全稳定后再执行翻译
// 第一个nextTick确保初始DOM渲染完成
nextTick(() => {
// 第二个nextTick确保可能的异步更新也完成
nextTick(() => {
/*
这里有问题应该是vue的DOM渲染完毕后触发但是实际打断点测试DOM还没有渲染完就触发了这里还需要跟踪优化
*/
if(translate.vue3.isExecute){
translate.time.log('组件渲染完成,触发 translate.execute();');
//对vue3的某些第三方组件进行容错处理
translate.faultTolerance.documentCreateTextNode.use(); //对VUE的某些组件频繁渲染dom进行容错
translate.time.log('对vue3的某些第三方组件进行容错处理 - translate.faultTolerance.documentCreateTextNode.use();');
translate.execute();
setTimeout(() => {
translate.execute();
}, 100);
setTimeout(() => {
translate.execute();
}, 2000);
}else{
translate.time.log('组件渲染完成但未发现translate.execute();存在,不进行翻译');
}
});
});
return root;
};
}
};
//export default translateJsVueUseModel;
export { translateJsVueUseModel, translate };

View File

@@ -1,5 +1,5 @@
<template>
<GiIframe src="https://continew.top/admin/changelog/"></GiIframe>
<GiIframe src="https://continew.top/docs/admin/changelog/"></GiIframe>
</template>
<script lang='ts' setup>

View File

@@ -25,10 +25,10 @@
<script setup lang="ts">
const links = [
{ text: '项目简介', url: 'https://continew.top/admin/guide/introduction.html' },
{ text: '快速开始', url: 'https://continew.top/admin/guide/quick-start.html' },
{ text: '常见问题', url: 'https://continew.top/admin/faq.html' },
{ text: '更新日志', url: 'https://continew.top/admin/changelog/' },
{ text: '项目简介', url: 'https://continew.top/docs/admin/guide/introduction.html' },
{ text: '快速开始', url: 'https://continew.top/docs/admin/guide/quick-start.html' },
{ text: '常见问题', url: 'https://continew.top/docs/admin/faq.html' },
{ text: '更新日志', url: 'https://continew.top/docs/admin/changelog/' },
{ text: '贡献指南', url: 'https://continew.top/about/contributing.html' },
{ text: '赞助支持 💖', url: 'https://continew.top/sponsor/' },
]

View File

@@ -6,6 +6,9 @@
<span>{{ title }}</span>
</h3>
<!-- 多语言切换的 Select 下拉选择 -->
<LanguageSelect/>
<a-row align="stretch" class="login-box">
<a-col :xs="0" :sm="12" :md="13">
<div class="login-left">
@@ -100,6 +103,7 @@ import { useAppStore } from '@/stores'
import { useTenantStore } from '@/stores/modules/tenant'
import { useDevice } from '@/hooks'
import { getTenantIdByDomain, getTenantStatus } from '@/apis'
import LanguageSelect from 'i18n-jsautotranslate/ArcoDesign/Vue3/LanguageSelect.vue';
defineOptions({ name: 'Login' })
@@ -542,5 +546,13 @@ onMounted(() => {
}
}
}
//多语言切换
.LanguageSelect {
position: fixed;
top: 20px;
right: 80px;
z-index: 999;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<a-drawer v-model:visible="visible" title="日志详情" :width="720" :footer="false">
<a-drawer v-model:visible="visible" title="日志详情" :width="width >= 720 ? 720 : '100%'" :footer="false">
<a-descriptions title="基本信息" :column="2" size="large" class="general-description">
<a-descriptions-item label="日志 ID">{{ dataDetail?.id }}</a-descriptions-item>
<a-descriptions-item label="Trace ID"><a-typography-paragraph :copyable="!!dataDetail?.traceId">{{ dataDetail?.traceId }}</a-typography-paragraph></a-descriptions-item>
@@ -68,8 +68,11 @@
</template>
<script setup lang="ts">
import { useWindowSize } from '@vueuse/core/index'
import { type LogDetailResp, getLog as getDetail } from '@/apis/monitor'
const { width } = useWindowSize()
const dataId = ref('')
const dataDetail = ref<LogDetailResp>()
const visible = ref(false)
@@ -97,6 +100,5 @@ defineExpose({ onOpen })
:deep(.arco-tabs-content) {
padding-top: 5px;
padding-left: 15px;
}
</style>

View File

@@ -56,39 +56,40 @@
<a-space>
<a-link v-permission="['open:app:get']" title="详情" @click="onDetail(record)">详情</a-link>
<a-link v-permission="['open:app:update']" title="修改" @click="onUpdate(record)">修改</a-link>
<a-link
v-permission="['open:app:delete']"
status="danger"
:disabled="record.disabled"
:title="record.disabled ? '禁止删除' : '删除'"
@click="onDelete(record)"
>
删除
</a-link>
<a-dropdown>
<a-button v-if="has.hasPermOr(['open:app:resetSecret'])" type="text" size="mini" title="更多">
<a-button v-if="has.hasPermOr(['open:app:resetSecret', 'open:app:delete'])" type="text" size="mini" title="更多">
<template #icon>
<icon-more :size="16" />
</template>
</a-button>
<template #content>
<a-doption v-permission="['open:app:resetSecret']" title="重置密钥" @click="onResetSecret(record)">重置密钥</a-doption>
<a-doption v-permission="['open:app:delete']">
<a-link
status="danger"
:disabled="record.disabled"
:title="record.disabled ? '禁止删除' : '删除'"
@click="onDelete(record)"
>
删除
</a-link>
</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
</GiTable>
<AppAddModal ref="AppAddModalRef" @save-success="search" />
<AppDetailDrawer ref="AppDetailDrawerRef" />
<AddModal ref="AddModalRef" @save-success="search" />
<DetailDrawer ref="DetailDrawerRef" />
</GiPageLayout>
</template>
<script setup lang="ts">
import type { TableInstance } from '@arco-design/web-vue'
import { Message, Modal } from '@arco-design/web-vue'
import AppAddModal from './AppAddModal.vue'
import AppDetailDrawer from './AppDetailDrawer.vue'
import AddModal from './AddModal.vue'
import DetailDrawer from './DetailDrawer.vue'
import {
type AppQuery,
type AppResp,
@@ -137,14 +138,14 @@ const columns: TableInstance['columns'] = [
title: '操作',
dataIndex: 'action',
slotName: 'action',
width: 190,
width: 160,
align: 'center',
fixed: !isMobile() ? 'right' : undefined,
show: has.hasPermOr([
'open:app:get',
'open:app:update',
'open:app:delete',
'open:app:resetSecret',
'open:app:delete',
]),
},
]
@@ -200,21 +201,21 @@ const onResetSecret = async (record: AppResp) => {
})
}
const AppAddModalRef = ref<InstanceType<typeof AppAddModal>>()
const AddModalRef = ref<InstanceType<typeof AddModal>>()
// 新增
const onAdd = () => {
AppAddModalRef.value?.onAdd()
AddModalRef.value?.onAdd()
}
// 修改
const onUpdate = (record: AppResp) => {
AppAddModalRef.value?.onUpdate(record.id)
AddModalRef.value?.onUpdate(record.id)
}
const AppDetailDrawerRef = ref<InstanceType<typeof AppDetailDrawer>>()
const DetailDrawerRef = ref<InstanceType<typeof DetailDrawer>>()
// 详情
const onDetail = (record: AppResp) => {
AppDetailDrawerRef.value?.onOpen(record.id)
DetailDrawerRef.value?.onOpen(record.id)
}
</script>

View File

@@ -20,13 +20,13 @@
</a-col>
<a-col v-bind="colProps">
<a-form-item label="任务名称" field="jobName">
<a-input v-model.trim="form.jobName" placeholder="请输入任务名称" :max-length="64" show-word-limit />
<a-input v-model="form.jobName" placeholder="请输入任务名称" :max-length="64" show-word-limit />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="描述" field="description">
<a-textarea
v-model.trim="form.description"
v-model="form.description"
placeholder="请输入描述"
show-word-limit
:max-length="200"
@@ -99,14 +99,14 @@
</a-col>
<a-col v-bind="colProps">
<a-form-item label="执行器名称" field="executorInfo">
<a-input v-model.trim="form.executorInfo" placeholder="请输入执行器名称" :max-length="255" />
<a-input v-model="form.executorInfo" placeholder="请输入执行器名称" :max-length="255" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="任务参数" field="argsStr">
<a-textarea
v-if="form.taskType !== 3"
v-model.trim="form.argsStr"
v-model="form.argsStr"
placeholder="请输入任务参数"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
@@ -134,17 +134,17 @@
<a-row>
<a-col v-bind="colProps">
<a-form-item label="路由策略" field="routeKey">
<a-select v-model.trim="form.routeKey" placeholder="请选择路由策略" :options="job_route_strategy_enum" />
<a-select v-model="form.routeKey" placeholder="请选择路由策略" :options="job_route_strategy_enum" />
</a-form-item>
</a-col>
<a-col v-bind="colProps">
<a-form-item label="阻塞策略" field="blockStrategy">
<a-select v-model.trim="form.blockStrategy" placeholder="请选择阻塞策略" :options="job_block_strategy_enum" />
<a-select v-model="form.blockStrategy" placeholder="请选择阻塞策略" :options="job_block_strategy_enum" />
</a-form-item>
</a-col>
<a-col v-bind="colProps">
<a-form-item label="超时时间" field="executorTimeout">
<a-input-number v-model.trim="form.executorTimeout" placeholder="请输入超时时间" :min="1">
<a-input-number v-model="form.executorTimeout" placeholder="请输入超时时间" :min="1">
<template #suffix></template>
</a-input-number>
</a-form-item>
@@ -157,7 +157,7 @@
</a-col>
<a-col v-bind="colProps">
<a-form-item label="重试间隔" field="retryInterval">
<a-input-number v-model.trim="form.retryInterval" placeholder="请输入重试间隔" :min="1">
<a-input-number v-model="form.retryInterval" placeholder="请输入重试间隔" :min="1">
<template #suffix>
</template>

View File

@@ -62,18 +62,29 @@
</template>
<template #action="{ record }">
<a-space>
<a-link v-permission="['schedule:log:list']" title="日志" @click="onLog(record)">日志</a-link>
<a-popconfirm content="是否确定立即执行一次任务?" type="warning" @ok="onTrigger(record)">
<a-link v-permission="['schedule:job:trigger']" title="执行">执行</a-link>
</a-popconfirm>
<a-link v-permission="['schedule:job:update']" title="修改" @click="onUpdate(record)">修改</a-link>
<a-link v-permission="['schedule:job:delete']" status="danger" title="删除" @click="onDelete(record)">删除</a-link>
<a-dropdown>
<a-button v-if="has.hasPermOr(['schedule:log:list', 'schedule:job:delete'])" type="text" size="mini" title="更多">
<template #icon>
<icon-more :size="16" />
</template>
</a-button>
<template #content>
<a-doption v-permission="['schedule:log:list']" title="查看日志" @click="onLog(record)">查看日志</a-doption>
<a-doption v-permission="['schedule:job:delete']">
<a-link status="danger" title="删除" @click="onDelete(record)">删除</a-link>
</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
</GiTable>
<JobAddModal ref="JobAddModalRef" @save-success="reset" />
<JobDetailDrawer ref="JobDetailDrawerRef" />
<AddModal ref="AddModalRef" @save-success="reset" />
<DetailDrawer ref="DetailDrawerRef" />
</GiPageLayout>
</template>
@@ -81,8 +92,8 @@
import type { TableInstance } from '@arco-design/web-vue'
import { Message } from '@arco-design/web-vue'
import { useRouter } from 'vue-router'
import JobAddModal from './JobAddModal.vue'
import JobDetailDrawer from './JobDetailDrawer.vue'
import AddModal from './AddModal.vue'
import DetailDrawer from './DetailDrawer.vue'
import { type JobQuery, type JobResp, deleteJob, listGroup, listJob, triggerJob, updateJobStatus } from '@/apis/schedule'
import { useTable } from '@/hooks'
import { useDict } from '@/hooks/app'
@@ -121,13 +132,13 @@ const columns: TableInstance['columns'] = [
{
title: '操作',
slotName: 'action',
width: 200,
width: 160,
align: 'center',
fixed: !isMobile() ? 'right' : undefined,
show: has.hasPermOr([
'schedule:log:list',
'schedule:job:trigger',
'schedule:job:update',
'schedule:log:list',
'schedule:job:delete',
]),
},
@@ -178,21 +189,21 @@ const onTrigger = (record: JobResp) => {
})
}
const JobAddModalRef = ref<InstanceType<typeof JobAddModal>>()
const AddModalRef = ref<InstanceType<typeof AddModal>>()
// 新增
const onAdd = () => {
JobAddModalRef.value?.onAdd()
AddModalRef.value?.onAdd()
}
// 修改
const onUpdate = (record: JobResp) => {
JobAddModalRef.value?.onUpdate(record)
AddModalRef.value?.onUpdate(record)
}
const JobDetailDrawerRef = ref<InstanceType<typeof JobDetailDrawer>>()
const DetailDrawerRef = ref<InstanceType<typeof DetailDrawer>>()
// 详情
const onDetail = (record: JobResp) => {
JobDetailDrawerRef.value?.onOpen(record)
DetailDrawerRef.value?.onOpen(record)
}
const router = useRouter()

View File

@@ -53,16 +53,16 @@
</template>
</GiTable>
<ClientAddModal ref="ClientAddModalRef" @save-success="search" />
<ClientDetailDrawer ref="ClientDetailDrawerRef" />
<AddModal ref="AddModalRef" @save-success="search" />
<DetailDrawer ref="DetailDrawerRef" />
</GiPageLayout>
</template>
<script setup lang="tsx">
import type { LabelValue } from '@arco-design/web-vue/es/tree-select/interface'
import type { TableInstance } from '@arco-design/web-vue'
import ClientAddModal from './ClientAddModal.vue'
import ClientDetailDrawer from './ClientDetailDrawer.vue'
import AddModal from './AddModal.vue'
import DetailDrawer from './DetailDrawer.vue'
import { type ClientQuery, type ClientResp, deleteClient, listClient } from '@/apis/system/client'
import { DisEnableStatusList } from '@/constant/common'
import { useTable } from '@/hooks'
@@ -186,21 +186,21 @@ const onDelete = (record: ClientResp) => {
})
}
const ClientAddModalRef = ref<InstanceType<typeof ClientAddModal>>()
const AddModalRef = ref<InstanceType<typeof AddModal>>()
// 新增
const onAdd = () => {
ClientAddModalRef.value?.onAdd()
AddModalRef.value?.onAdd()
}
// 修改
const onUpdate = (record: ClientResp) => {
ClientAddModalRef.value?.onUpdate(record.id)
AddModalRef.value?.onUpdate(record.id)
}
const ClientDetailDrawerRef = ref<InstanceType<typeof ClientDetailDrawer>>()
const DetailDrawerRef = ref<InstanceType<typeof DetailDrawer>>()
// 详情
const onDetail = (record: ClientResp) => {
ClientDetailDrawerRef.value?.onOpen(record.id)
DetailDrawerRef.value?.onOpen(record.id)
}
</script>

View File

@@ -18,7 +18,7 @@
:help="mailConfig.MAIL_PROTOCOL.description"
hide-asterisk
>
<a-select v-model.trim="form.MAIL_PROTOCOL">
<a-select v-model="form.MAIL_PROTOCOL">
<a-option label="SMTP" value="smtp" />
</a-select>
</a-form-item>
@@ -28,7 +28,7 @@
:help="mailConfig.MAIL_HOST.description"
hide-asterisk
>
<a-input v-model.trim="form.MAIL_HOST" />
<a-input v-model="form.MAIL_HOST" />
</a-form-item>
<a-form-item
field="MAIL_PORT"
@@ -44,7 +44,7 @@
:help="mailConfig.MAIL_USERNAME.description"
hide-asterisk
>
<a-input v-model.trim="form.MAIL_USERNAME" />
<a-input v-model="form.MAIL_USERNAME" />
</a-form-item>
<a-form-item
field="MAIL_PASSWORD"
@@ -52,7 +52,7 @@
:help="mailConfig.MAIL_PASSWORD.description"
hide-asterisk
>
<a-input-password v-model.trim="form.MAIL_PASSWORD" />
<a-input-password v-model="form.MAIL_PASSWORD" />
</a-form-item>
<a-form-item
field="MAIL_SSL_ENABLED"

View File

@@ -74,20 +74,20 @@
</template>
</a-form-item>
<a-form-item class="input-item" field="SITE_TITLE" :label="siteConfig.SITE_TITLE.name" :help="siteConfig.SITE_TITLE.description">
<a-input v-model.trim="form.SITE_TITLE" placeholder="请输入系统名称" :max-length="18" show-word-limit />
<a-input v-model="form.SITE_TITLE" placeholder="请输入系统名称" :max-length="18" show-word-limit />
</a-form-item>
<a-form-item class="input-item" field="SITE_DESCRIPTION" :label="siteConfig.SITE_DESCRIPTION.name" :help="siteConfig.SITE_DESCRIPTION.description">
<a-textarea
v-model.trim="form.SITE_DESCRIPTION"
v-model="form.SITE_DESCRIPTION"
placeholder="请输入系统描述"
:auto-size="{ minRows: 1, maxRows: 3 }"
/>
</a-form-item>
<a-form-item class="input-item" field="SITE_COPYRIGHT" :label="siteConfig.SITE_COPYRIGHT.name" :help="siteConfig.SITE_COPYRIGHT.description">
<a-input v-model.trim="form.SITE_COPYRIGHT" placeholder="请输入版权声明" />
<a-input v-model="form.SITE_COPYRIGHT" placeholder="请输入版权声明" />
</a-form-item>
<a-form-item field="SITE_BEIAN" :label="siteConfig.SITE_BEIAN.name" :help="siteConfig.SITE_BEIAN.description">
<a-input v-model.trim="form.SITE_BEIAN" placeholder="请输入备案号" :max-length="30" show-word-limit />
<a-input v-model="form.SITE_BEIAN" placeholder="请输入备案号" :max-length="30" show-word-limit />
</a-form-item>
<a-space style="margin-top: 16px">
<a-button v-if="!isUpdate" v-permission="['system:siteConfig:update']" type="primary" @click="onUpdate">

View File

@@ -71,14 +71,14 @@
</template>
</GiTable>
<SmsConfigAddModal ref="SmsConfigAddModalRef" @save-success="search" />
<AddModal ref="AddModalRef" @save-success="search" />
</GiPageLayout>
</template>
<script setup lang="tsx">
import { Message, Modal } from '@arco-design/web-vue'
import type { TableInstance } from '@arco-design/web-vue'
import SmsConfigAddModal from './SmsConfigAddModal.vue'
import AddModal from './AddModal.vue'
import {
type SmsConfigQuery,
type SmsConfigResp,
@@ -208,15 +208,15 @@ const onSetDefault = (record: SmsConfigResp) => {
})
}
const SmsConfigAddModalRef = ref<InstanceType<typeof SmsConfigAddModal>>()
const AddModalRef = ref<InstanceType<typeof AddModal>>()
// 新增
const onAdd = () => {
SmsConfigAddModalRef.value?.onAdd()
AddModalRef.value?.onAdd()
}
// 修改
const onUpdate = (record: SmsConfigResp) => {
SmsConfigAddModalRef.value?.onUpdate(record.id)
AddModalRef.value?.onUpdate(record.id)
}
const router = useRouter()

View File

@@ -8,7 +8,14 @@
@before-ok="save"
@close="reset"
>
<GiForm ref="formRef" v-model="form" :columns="columns" />
<GiForm ref="formRef" v-model="form" :columns="columns">
<template #secretKey>
<a-input
v-model="form.secretKey"
:placeholder="isUpdate ? '保持 Secret Key 为空将不更改' : '请输入 Secret Key'"
/>
</template>
</GiForm>
</a-modal>
</template>
@@ -77,7 +84,7 @@ const columns: ColumnItem[] = reactive([
field: 'secretKey',
type: 'input',
span: 24,
required: true,
required: () => !isUpdate.value,
show: () => form.type === 2,
},
{
@@ -165,7 +172,7 @@ const save = async () => {
if (isUpdate.value) {
await updateStorage({
...form,
secretKey: form.type === 2 && !form.secretKey.includes('*') ? encryptByRsa(form.secretKey) || '' : null,
secretKey: form.type === 2 && form.secretKey ? encryptByRsa(form.secretKey) || '' : null,
}, dataId.value)
Message.success('修改成功')
} else {

View File

@@ -14,11 +14,11 @@
</div>
</a-card>
<StorageAddModal ref="StorageAddModalRef" @save-success="search" />
<AddModal ref="AddModalRef" @save-success="search" />
</template>
<script lang="ts" setup>
import StorageAddModal from '../StorageAddModal.vue'
import AddModal from '../AddModal.vue'
const props = defineProps({
type: {
@@ -35,10 +35,10 @@ const search = () => {
emit('save-success')
}
const StorageAddModalRef = ref<InstanceType<typeof StorageAddModal>>()
const AddModalRef = ref<InstanceType<typeof AddModal>>()
// 新增
const onAdd = () => {
StorageAddModalRef.value?.onAdd(props.type)
AddModalRef.value?.onAdd(props.type)
}
</script>

View File

@@ -72,12 +72,12 @@
</div>
</a-card>
<StorageAddModal ref="StorageAddModalRef" @save-success="search" />
<AddModal ref="AddModalRef" @save-success="search" />
</template>
<script lang="ts" setup>
import { Message, Modal } from '@arco-design/web-vue'
import StorageAddModal from '../StorageAddModal.vue'
import AddModal from '../AddModal.vue'
import has from '@/utils/has'
import { type StorageResp, deleteStorage, setDefaultStorage, updateStorageStatus } from '@/apis/system'
import { useDict } from '@/hooks/app'
@@ -182,11 +182,11 @@ const onDelete = (record: StorageResp) => {
})
}
const StorageAddModalRef = ref<InstanceType<typeof StorageAddModal>>()
const AddModalRef = ref<InstanceType<typeof AddModal>>()
// 修改
const onUpdate = (record: StorageResp) => {
StorageAddModalRef.value?.onUpdate(record.id)
AddModalRef.value?.onUpdate(record.id)
}
</script>

View File

@@ -87,7 +87,7 @@
</a-dropdown>
</a-card>
</div>
<DeptAddModal ref="DeptAddModalRef" :depts="dataList" @save-success="search" />
<AddModal ref="AddModalRef" :depts="dataList" @save-success="search" />
</GiPageLayout>
</template>
@@ -95,7 +95,7 @@
import 'vue3-tree-org/lib/vue3-tree-org.css'
import { Vue3TreeOrg } from 'vue3-tree-org'
import type { TableInstance } from '@arco-design/web-vue'
import DeptAddModal from './DeptAddModal.vue'
import AddModal from './AddModal.vue'
import { type DeptQuery, type DeptResp, deleteDept, exportDept, listDept } from '@/apis/system/dept'
import type GiTable from '@/components/GiTable/index.vue'
import { useDownload, useTable } from '@/hooks'
@@ -196,17 +196,17 @@ const onExport = () => {
useDownload(() => exportDept(queryForm))
}
const DeptAddModalRef = ref<InstanceType<typeof DeptAddModal>>()
const AddModalRef = ref<InstanceType<typeof AddModal>>()
// 新增
const onAdd = (parentId?: string) => {
DeptAddModalRef.value?.onAdd(parentId)
AddModalRef.value?.onAdd(parentId)
}
const handleAdd = (record: DeptResp) => {
onAdd(record.id)
}
// 修改
const onUpdate = (record: DeptResp) => {
DeptAddModalRef.value?.onUpdate(record.id)
AddModalRef.value?.onUpdate(record.id)
}
</script>

View File

@@ -12,16 +12,35 @@
<a-row justify="space-between" class="file-main__search">
<!-- 左侧区域 -->
<a-space wrap>
<a-upload v-permission="['system:file:upload']" :show-file-list="false" :custom-request="handleUpload">
<template #upload-button>
<a-button type="primary" shape="round">
<template #icon>
<icon-upload />
<!-- 上传文件按钮改为下拉菜单包含普通上传和分片上传 -->
<a-dropdown trigger="click">
<a-button type="primary" shape="round">
<icon-upload />
上传文件
</a-button>
<template #content>
<!-- 普通上传 -->
<a-upload v-permission="['system:file:upload']" :show-file-list="false" :custom-request="handleUpload" style="display: block;">
<template #upload-button>
<a-button type="text" style="width: 100%; text-align: left;">
普通上传
</a-button>
</template>
<template #default>上传</template>
</a-upload>
<!-- 分片上传 -->
<a-button type="text" style="width: 100%; text-align: left;" @click="visible = true">
分片上传
</a-button>
</template>
</a-upload>
</a-dropdown>
<a-modal v-model:visible="visible" title="分片上传" :width="width > 1350 ? 1350 : '100%'" :footer="false" @close="search">
<MultipartUpload
v-if="visible"
:root-path="queryForm.parentPath"
:chunk-size="5 * 1024 * 1024"
:max-concurrent-files="3"
/>
</a-modal>
<a-input-group>
<a-input v-model="queryForm.originalName" :placeholder="queryForm.type && queryForm.type !== '0' ? '请输入名称' : '在当前目录下搜索名称'" allow-clear style="width: 200px" />
@@ -101,6 +120,7 @@
<script setup lang="ts">
import { Message, Modal, type RequestOption } from '@arco-design/web-vue'
import { api as viewerApi } from 'v-viewer'
import { useWindowSize } from '@vueuse/core'
import {
openFileDetailModal,
openFileRenameModal,
@@ -122,7 +142,7 @@ const FilePreview = defineAsyncComponent(() => import('@/components/FilePreview/
const FileList = defineAsyncComponent(() => import('./FileList.vue'))
const route = useRoute()
const { mode, selectedFileIds, toggleMode, addSelectedFileItem } = useFileManage()
const { width } = useWindowSize()
const queryForm = reactive<FileQuery>({
originalName: undefined,
parentPath: (!route.query.type || route.query.type?.toString() === '0') ? '/' : undefined,
@@ -248,7 +268,6 @@ const handleMulDelete = () => {
},
})
}
// 上传
const handleUpload = (options: RequestOption) => {
const controller = new AbortController()
@@ -276,6 +295,8 @@ const handleUpload = (options: RequestOption) => {
}
}
const visible = ref(false)
onBeforeRouteUpdate((to) => {
if (!to.query.type) return
if (to.query.type === '0' || !to.query.type) {

View File

@@ -32,7 +32,7 @@
<a-row>
<a-col v-bind="colProps">
<a-form-item label="菜单标题" field="title">
<a-input v-model.trim="form.title" placeholder="请输入菜单标题" :max-length="30" show-word-limit allow-clear />
<a-input v-model="form.title" placeholder="请输入菜单标题" :max-length="30" show-word-limit allow-clear />
</a-form-item>
</a-col>
<a-col v-bind="colProps">
@@ -40,22 +40,22 @@
<GiIconSelector v-model="form.icon" />
</a-form-item>
<a-form-item v-else label="权限标识" field="permission">
<a-input v-model.trim="form.permission" placeholder="system:user:add" :max-length="100" show-word-limit allow-clear />
<a-input v-model="form.permission" placeholder="system:user:add" :max-length="100" show-word-limit allow-clear />
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-col v-bind="colProps">
<a-form-item v-if="[1, 2].includes(form.type)" label="路由地址" field="path">
<a-input v-model.trim="form.path" placeholder="请输入路由地址" allow-clear />
<a-input v-model="form.path" placeholder="请输入路由地址" allow-clear />
</a-form-item>
</a-col>
<a-col v-bind="colProps">
<a-form-item v-if="form.type === 1 || (form.type === 2 && !form.isExternal)" label="重定向" field="redirect">
<a-input v-model.trim="form.redirect" placeholder="请输入重定向地址" allow-clear />
<a-input v-model="form.redirect" placeholder="请输入重定向地址" allow-clear />
</a-form-item>
<a-form-item v-if="form.type === 2 && form.isExternal" label="组件路径" field="component">
<a-input v-model.trim="form.component" placeholder="请输入组件路径" allow-clear />
<a-input v-model="form.component" placeholder="请输入组件路径" allow-clear />
</a-form-item>
</a-col>
</a-row>
@@ -69,7 +69,7 @@
<a-row>
<a-col v-bind="colProps">
<a-form-item v-if="form.type === 1 || (form.type === 2 && !form.isExternal)" label="组件名称" field="name">
<a-input v-model.trim="form.name" placeholder="请输入组件名称" :max-length="50" show-word-limit allow-clear />
<a-input v-model="form.name" placeholder="请输入组件名称" :max-length="50" show-word-limit allow-clear />
<template #extra>
<div v-if="componentName">
<span>建议组件名称</span>
@@ -80,7 +80,7 @@
</a-col>
<a-col v-bind="colProps">
<a-form-item v-if="form.type === 2" label="权限标识" field="permission">
<a-input v-model.trim="form.permission" placeholder="system:user:add" :max-length="100" show-word-limit allow-clear />
<a-input v-model="form.permission" placeholder="system:user:add" :max-length="100" show-word-limit allow-clear />
</a-form-item>
</a-col>
</a-row>

View File

@@ -90,14 +90,14 @@
</template>
</GiTable>
<MenuAddModal ref="MenuAddModalRef" :menus="dataList" @save-success="search" />
<AddModal ref="AddModalRef" :menus="dataList" @save-success="search" />
</GiPageLayout>
</template>
<script setup lang="ts">
import type { TableInstance } from '@arco-design/web-vue'
import { Message, Modal } from '@arco-design/web-vue'
import MenuAddModal from './MenuAddModal.vue'
import AddModal from './AddModal.vue'
import { type MenuResp, clearMenuCache, deleteMenu, listMenu } from '@/apis/system/menu'
import type GiTable from '@/components/GiTable/index.vue'
import { useTable } from '@/hooks'
@@ -209,15 +209,15 @@ const onExpanded = () => {
tableRef.value?.tableRef?.expandAll(isExpanded.value)
}
const MenuAddModalRef = ref<InstanceType<typeof MenuAddModal>>()
const AddModalRef = ref<InstanceType<typeof AddModal>>()
// 新增
const onAdd = (parentId?: string) => {
MenuAddModalRef.value?.onAdd(parentId)
AddModalRef.value?.onAdd(parentId)
}
// 修改
const onUpdate = (record: MenuResp) => {
MenuAddModalRef.value?.onUpdate(record.id)
AddModalRef.value?.onUpdate(record.id)
}
</script>

View File

@@ -72,13 +72,13 @@
</template>
</GiTable>
<NoticeDetailDrawer ref="NoticeDetailDrawerRef" />
<DetailDrawer ref="DetailDrawerRef" />
</GiPageLayout>
</template>
<script setup lang="ts">
import type { TableInstance } from '@arco-design/web-vue'
import NoticeDetailDrawer from './NoticeDetailDrawer.vue'
import DetailDrawer from './DetailDrawer.vue'
import { type NoticeQuery, type NoticeResp, deleteNotice, listNotice } from '@/apis/system'
import { useTable } from '@/hooks'
import { useDict } from '@/hooks/app'
@@ -153,10 +153,10 @@ const onUpdate = (record: NoticeResp) => {
router.push({ path: '/system/notice/add', query: { id: record.id, type: 'update' } })
}
const NoticeDetailDrawerRef = ref<InstanceType<typeof NoticeDetailDrawer>>()
const DetailDrawerRef = ref<InstanceType<typeof DetailDrawer>>()
// 详情
const onDetail = (record: NoticeResp) => {
NoticeDetailDrawerRef.value?.onOpen(record.id)
DetailDrawerRef.value?.onOpen(record.id)
}
// 查看

View File

@@ -12,17 +12,17 @@
<fieldset>
<legend>基础信息</legend>
<a-form-item label="名称" field="name">
<a-input v-model.trim="form.name" placeholder="请输入名称" max-length="30" show-word-limit />
<a-input v-model="form.name" placeholder="请输入名称" max-length="30" show-word-limit />
</a-form-item>
<a-form-item label="编码" field="code">
<a-input v-model.trim="form.code" placeholder="请输入编码" max-length="30" show-word-limit :disabled="isUpdate" />
<a-input v-model="form.code" placeholder="请输入编码" max-length="30" show-word-limit :disabled="isUpdate" />
</a-form-item>
<a-form-item label="排序" field="sort">
<a-input-number v-model="form.sort" placeholder="请输入排序" :min="1" mode="button" />
</a-form-item>
<a-form-item label="描述" field="description">
<a-textarea
v-model.trim="form.description"
v-model="form.description"
placeholder="请输入描述"
show-word-limit
:max-length="200"
@@ -34,7 +34,7 @@
<legend>数据权限</legend>
<a-form-item hide-label field="dataScope">
<a-select
v-model.trim="form.dataScope"
v-model="form.dataScope"
:options="data_scope_enum"
placeholder="请选择数据权限"
:disabled="form.isSystem"

View File

@@ -57,13 +57,13 @@
</template>
</GiTable>
<RoleAssignModal ref="RoleAssignModalRef" @save-success="search" />
<AssignModal ref="AssignModalRef" @save-success="search" />
</template>
<script lang='tsx' setup>
import type { TableInstance } from '@arco-design/web-vue'
import { Message, Modal } from '@arco-design/web-vue'
import RoleAssignModal from '../RoleAssignModal.vue'
import AssignModal from '../AssignModal.vue'
import { useResetReactive, useTable } from '@/hooks'
import { type RoleUserQuery, type RoleUserResp, listRoleUser, unassignFromUsers } from '@/apis/system/role'
import { isMobile } from '@/utils'
@@ -158,10 +158,10 @@ const onDelete = (record: RoleUserResp) => {
})
}
const RoleAssignModalRef = ref<InstanceType<typeof RoleAssignModal>>()
const AssignModalRef = ref<InstanceType<typeof AssignModal>>()
// 分配
const onAssign = () => {
RoleAssignModalRef.value?.onOpen(props.roleId)
AssignModalRef.value?.onOpen(props.roleId)
}
// 监听 roleId 的变化

View File

@@ -40,7 +40,7 @@
</div>
</div>
<RoleAddDrawer ref="RoleAddDrawerRef" @save-success="getTreeData" />
<AddDrawer ref="AddDrawerRef" @save-success="getTreeData" />
</div>
</template>
@@ -48,7 +48,7 @@
import { Message, Modal } from '@arco-design/web-vue'
import type { TreeNodeData } from '@arco-design/web-vue'
import { mapTree } from 'xe-utils'
import RoleAddDrawer from '../RoleAddDrawer.vue'
import AddDrawer from '../AddDrawer.vue'
import RightMenu from './RightMenu.vue'
import { type RoleResp, deleteRole, listRole } from '@/apis/system/role'
import has from '@/utils/has'
@@ -112,16 +112,16 @@ const treeData = computed(() => {
return search(searchKey.value.toLowerCase())
})
const RoleAddDrawerRef = ref<InstanceType<typeof RoleAddDrawer>>()
const AddDrawerRef = ref<InstanceType<typeof AddDrawer>>()
// 新增
const onAdd = () => {
RoleAddDrawerRef.value?.onAdd()
AddDrawerRef.value?.onAdd()
}
// 点击菜单项
const onMenuItemClick = (mode: string, node: RoleResp) => {
if (mode === 'update') {
RoleAddDrawerRef.value?.onUpdate(node.id)
AddDrawerRef.value?.onUpdate(node.id)
} else if (mode === 'delete') {
Modal.warning({
title: '提示',

View File

@@ -53,17 +53,8 @@
<a-space>
<a-link v-permission="['system:user:get']" title="详情" @click="onDetail(record)">详情</a-link>
<a-link v-permission="['system:user:update']" title="修改" @click="onUpdate(record)">修改</a-link>
<a-link
v-permission="['system:user:delete']"
status="danger"
:disabled="record.isSystem"
:title="record.isSystem ? '系统内置数据不能删除' : '删除'"
@click="onDelete(record)"
>
删除
</a-link>
<a-dropdown>
<a-button v-if="has.hasPermOr(['system:user:resetPwd', 'system:user:updateRole'])" type="text" size="mini" title="更多">
<a-button v-if="has.hasPermOr(['system:user:resetPwd', 'system:user:updateRole', 'system:user:delete'])" type="text" size="mini" title="更多">
<template #icon>
<icon-more :size="16" />
</template>
@@ -71,28 +62,38 @@
<template #content>
<a-doption v-permission="['system:user:resetPwd']" title="重置密码" @click="onResetPwd(record)">重置密码</a-doption>
<a-doption v-permission="['system:user:updateRole']" :disabled="record.isSystem" title="分配角色" @click="onUpdateRole(record)">分配角色</a-doption>
<a-doption v-permission="['system:user:delete']">
<a-link
status="danger"
:disabled="record.isSystem"
:title="record.isSystem ? '系统内置数据不能删除' : '删除'"
@click="onDelete(record)"
>
删除
</a-link>
</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
</GiTable>
<UserAddDrawer ref="UserAddDrawerRef" @save-success="search" />
<UserImportDrawer ref="UserImportDrawerRef" @save-success="search" />
<UserDetailDrawer ref="UserDetailDrawerRef" />
<UserResetPwdModal ref="UserResetPwdModalRef" />
<UserUpdateRoleModal ref="UserUpdateRoleModalRef" @save-success="search" />
<AddDrawer ref="AddDrawerRef" @save-success="search" />
<ImportDrawer ref="ImportDrawerRef" @save-success="search" />
<DetailDrawer ref="DetailDrawerRef" />
<PwdResetModal ref="PwdResetModalRef" />
<RoleUpdateModal ref="RoleUpdateModalRef" @save-success="search" />
</GiPageLayout>
</template>
<script setup lang="ts">
import type { TableInstance } from '@arco-design/web-vue'
import DeptTree from './dept/index.vue'
import UserAddDrawer from './UserAddDrawer.vue'
import UserImportDrawer from './UserImportDrawer.vue'
import UserDetailDrawer from './UserDetailDrawer.vue'
import UserResetPwdModal from './UserResetPwdModal.vue'
import UserUpdateRoleModal from './UserUpdateRoleModal.vue'
import AddDrawer from './AddDrawer.vue'
import ImportDrawer from './ImportDrawer.vue'
import DetailDrawer from './DetailDrawer.vue'
import PwdResetModal from './PwdResetModal.vue'
import RoleUpdateModal from './RoleUpdateModal.vue'
import { type UserResp, deleteUser, exportUser, listUser } from '@/apis/system/user'
import { DisEnableStatusList } from '@/constant/common'
import { useDownload, useResetReactive, useTable } from '@/hooks'
@@ -174,15 +175,15 @@ const columns: TableInstance['columns'] = [
title: '操作',
dataIndex: 'action',
slotName: 'action',
width: 190,
width: 160,
align: 'center',
fixed: !isMobile() ? 'right' : undefined,
show: has.hasPermOr([
'system:user:get',
'system:user:update',
'system:user:delete',
'system:user:resetPwd',
'system:user:updateRole',
'system:user:delete',
]),
},
]
@@ -212,39 +213,39 @@ const handleSelectDept = (keys: Array<any>) => {
search()
}
const UserImportDrawerRef = ref<InstanceType<typeof UserImportDrawer>>()
const ImportDrawerRef = ref<InstanceType<typeof ImportDrawer>>()
// 导入
const onImport = () => {
UserImportDrawerRef.value?.onOpen()
ImportDrawerRef.value?.onOpen()
}
const UserAddDrawerRef = ref<InstanceType<typeof UserAddDrawer>>()
const AddDrawerRef = ref<InstanceType<typeof AddDrawer>>()
// 新增
const onAdd = () => {
UserAddDrawerRef.value?.onAdd()
AddDrawerRef.value?.onAdd()
}
// 修改
const onUpdate = (record: UserResp) => {
UserAddDrawerRef.value?.onUpdate(record.id)
AddDrawerRef.value?.onUpdate(record.id)
}
const UserDetailDrawerRef = ref<InstanceType<typeof UserDetailDrawer>>()
const DetailDrawerRef = ref<InstanceType<typeof DetailDrawer>>()
// 详情
const onDetail = (record: UserResp) => {
UserDetailDrawerRef.value?.onOpen(record.id)
DetailDrawerRef.value?.onOpen(record.id)
}
const UserResetPwdModalRef = ref<InstanceType<typeof UserResetPwdModal>>()
const PwdResetModalRef = ref<InstanceType<typeof PwdResetModal>>()
// 重置密码
const onResetPwd = (record: UserResp) => {
UserResetPwdModalRef.value?.onOpen(record.id)
PwdResetModalRef.value?.onOpen(record.id)
}
const UserUpdateRoleModalRef = ref<InstanceType<typeof UserUpdateRoleModal>>()
const RoleUpdateModalRef = ref<InstanceType<typeof RoleUpdateModal>>()
// 分配角色
const onUpdateRole = (record: UserResp) => {
UserUpdateRoleModalRef.value?.onOpen(record.id)
RoleUpdateModalRef.value?.onOpen(record.id)
}
</script>

View File

@@ -11,14 +11,14 @@
>
<a-form ref="formRef" :model="form" :rules="rules" auto-label-width size="large">
<a-form-item field="name" label="名称">
<a-input v-model.trim="form.name" placeholder="请输入名称" />
<a-input v-model="form.name" placeholder="请输入名称" />
</a-form-item>
<a-form-item label="排序" field="sort">
<a-input-number v-model="form.sort" placeholder="请输入排序" :min="1" mode="button" />
</a-form-item>
<a-form-item label="描述" field="description">
<a-textarea
v-model.trim="form.description"
v-model="form.description"
placeholder="请输入描述"
show-word-limit
:max-length="200"

View File

@@ -17,7 +17,11 @@
</template>
</a-upload>
<div class="name">
<span style="margin-right: 10px">{{ userInfo.nickname }}</span>
<span style="margin-right: 10px">
{{ userInfo.nickname }}
<icon-man v-if="userInfo.gender === 1" style="color: #19bbf1" />
<icon-woman v-else-if="userInfo.gender === 2" style="color: #fa7fa9" />
</span>
<icon-edit :size="16" class="btn" @click="onUpdate" />
</div>
<div class="id">
@@ -30,8 +34,6 @@
<a-descriptions-item :span="4">
<template #label> <icon-user /><span style="margin-left: 5px">用户名</span></template>
{{ userInfo.username }}
<icon-man v-if="userInfo.gender === 1" style="color: #19bbf1" />
<icon-woman v-else-if="userInfo.gender === 2" style="color: #fa7fa9" />
</a-descriptions-item>
<a-descriptions-item :span="4">
<template #label> <icon-phone /><span style="margin-left: 5px">手机</span></template>
@@ -47,7 +49,7 @@
</a-descriptions-item>
<a-descriptions-item :span="4">
<template #label> <icon-user-group /><span style="margin-left: 5px">角色</span></template>
{{ userInfo.roles.join('') }}
{{ userInfo.roleNames.join(' · ') }}
</a-descriptions-item>
</a-descriptions>
</footer>