This commit is contained in:
zwt13703 2026-02-20 13:20:28 +08:00
commit a11329c502
617 changed files with 66376 additions and 0 deletions

73
.drone.yml Normal file
View File

@ -0,0 +1,73 @@
kind: pipeline
type: docker
name: Build and Deploy
clone:
depth: 10
volumes:
- name: go_cache
host:
path: /data/drone_cache/go_cache
steps:
- name: restore-cache
image: drillster/drone-volume-cache
volumes:
- name: go_cache
path: /cache
settings:
restore: true
mount:
- ./.npm-cache
- ./node_modules
- name: build
image: node:alpine
pull: if-not-exists
commands:
- export NODE_OPTIONS=--max_old_space_size=6144
- echo ${DRONE_BRANCH}
- echo ${DRONE_TAG}
- echo ${DRONE_COMMIT}
- echo ${DRONE_COMMIT:0-7}
- npm config set registry https://registry.npmmirror.com
- npm install -g pnpm
- pnpm config set registry https://registry.npmmirror.com
- pnpm i
- pnpm build
- name: rebuild-cache
image: drillster/drone-volume-cache
volumes:
- name: go_cache
path: /cache
settings:
rebuild: true
mount:
- ./.npm-cache
- ./node_modules
- name: scp files
image: appleboy/drone-scp
pull: if-not-exists
settings:
host:
from_secret: HOST
username:
from_secret: USERNAME
password:
from_secret: PASSWORD
port:
from_secret: PORT
target:
from_secret: TARGET_PATH
source: dist/*
overwrite: true
rm: true
trigger:
branch:
- master
event:
- push

11
.editorconfig Normal file
View File

@ -0,0 +1,11 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true

63
.env Normal file
View File

@ -0,0 +1,63 @@
# the base url of the application, the default is "/"
# if use a sub directory, it must be end with "/", like "/admin/" but not "/admin"
VITE_BASE_URL=/
VITE_APP_TITLE=RuoYi Plus Soybean
VITE_APP_DESC=RuoYi Plus Soybean 后台管理系统
# the prefix of the icon name
VITE_ICON_PREFIX=icon
# the prefix of the local svg icon component, must include VITE_ICON_PREFIX
# format {VITE_ICON_PREFIX}-{local icon name}
VITE_ICON_LOCAL_PREFIX=icon-local
# auth route mode: static dynamic
VITE_AUTH_ROUTE_MODE=dynamic
# static auth route home
VITE_ROUTE_HOME=home
# default menu icon
VITE_MENU_ICON=mdi:menu
# whether to enable http proxy when is dev mode
VITE_HTTP_PROXY=Y
# vue-router mode: hash | history | memory
VITE_ROUTER_HISTORY_MODE=history
# success code of backend service, when the code is received, the request is successful
VITE_SERVICE_SUCCESS_CODE=200
# logout codes of backend service, when the code is received, the user will be logged out and redirected to login page
VITE_SERVICE_LOGOUT_CODES=401
# modal logout codes of backend service, when the code is received, the user will be logged out by displaying a modal
VITE_SERVICE_MODAL_LOGOUT_CODES=401
# token expired codes of backend service, when the code is received, it will refresh the token and resend the request
VITE_SERVICE_EXPIRED_TOKEN_CODES=9999,9998,3333
# when the route mode is static, the defined super role
VITE_STATIC_SUPER_ROLE=R_SUPER
# sourcemap
VITE_SOURCE_MAP=N
# Used to differentiate storage across different domains
VITE_STORAGE_PREFIX=RY_
# used to control whether the program automatically detects updates
VITE_AUTOMATICALLY_DETECT_UPDATE=Y
# watermark
VITE_WATERMARK=N
# show proxy url log in terminal
VITE_PROXY_LOG=Y
# used to control whether to launch editor
# by the way, this plugin is only available in dev mode, not in build mode
VITE_DEVTOOLS_LAUNCH_EDITOR=code

26
.env.dev Normal file
View File

@ -0,0 +1,26 @@
# backend service base url, test environment
VITE_SERVICE_BASE_URL=http://localhost:8080
VITE_APP_BASE_API=/dev-api
# watermark
VITE_WATERMARK=N
# 是否开启 SSE 功能
VITE_APP_SSE=Y
# 是否开启 websocket 功能
VITE_APP_WEBSOCKET=N
# app client id
VITE_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e
# 记住密码 AES 加密密钥
VITE_REMEMBER_ME_AES_KEY=pC4aO6cD2uU7hA0bK6iD4vE1mV8sU8xG
# 接口加密功能开关(如需关闭 后端也必须对应关闭)
VITE_APP_ENCRYPT=Y
# AES 加密头标识
VITE_HEADER_FLAG=encrypt-key
# 接口加密传输 RSA 公钥与后端解密私钥对应 如更换需前后端一同更换
VITE_APP_RSA_PUBLIC_KEY='MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ=='
# 接口响应解密 RSA 私钥与后端加密公钥对应 如更换需前后端一同更换
VITE_APP_RSA_PRIVATE_KEY='MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE='

23
.env.prod Normal file
View File

@ -0,0 +1,23 @@
VITE_APP_BASE_API=/prod-api
# watermark
VITE_WATERMARK=Y
# 是否开启 SSE 功能
VITE_APP_SSE=Y
# 是否开启 websocket 功能
VITE_APP_WEBSOCKET=N
# app client id
VITE_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e
# 记住密码 AES 加密密钥
VITE_REMEMBER_ME_AES_KEY=pC4aO6cD2uU7hA0bK6iD4vE1mV8sU8xG
# 接口加密功能开关(如需关闭 后端也必须对应关闭)
VITE_APP_ENCRYPT=Y
# AES 加密头标识
VITE_HEADER_FLAG=encrypt-key
# 接口加密传输 RSA 公钥与后端解密私钥对应 如更换需前后端一同更换
VITE_APP_RSA_PUBLIC_KEY='MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ=='
# 接口响应解密 RSA 私钥与后端加密公钥对应 如更换需前后端一同更换
VITE_APP_RSA_PRIVATE_KEY='MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE='

23
.env.test Normal file
View File

@ -0,0 +1,23 @@
VITE_APP_BASE_API=/test-api
# watermark
VITE_WATERMARK=Y
# 是否开启 SSE 功能
VITE_APP_SSE=Y
# 是否开启 websocket 功能
VITE_APP_WEBSOCKET=N
# app client id
VITE_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e
# 记住密码 AES 加密密钥
VITE_REMEMBER_ME_AES_KEY=pC4aO6cD2uU7hA0bK6iD4vE1mV8sU8xG
# 接口加密功能开关(如需关闭 后端也必须对应关闭)
VITE_APP_ENCRYPT=Y
# AES 加密头标识
VITE_HEADER_FLAG=encrypt-key
# 接口加密传输 RSA 公钥与后端解密私钥对应 如更换需前后端一同更换
VITE_APP_RSA_PUBLIC_KEY='MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ=='
# 接口响应解密 RSA 私钥与后端加密公钥对应 如更换需前后端一同更换
VITE_APP_RSA_PRIVATE_KEY='MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE='

13
.gitattributes vendored Normal file
View File

@ -0,0 +1,13 @@
"*.vue" eol=lf
"*.js" eol=lf
"*.ts" eol=lf
"*.jsx" eol=lf
"*.tsx" eol=lf
"*.mjs" eol=lf
"*.json" eol=lf
"*.html" eol=lf
"*.css" eol=lf
"*.scss" eol=lf
"*.md" eol=lf
"*.yaml" eol=lf
"*.yml" eol=lf

35
.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/launch.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
package-lock.json
yarn.lock
.VSCodeCounter

4
.npmrc Normal file
View File

@ -0,0 +1,4 @@
registry=https://registry.npmmirror.com/
shamefully-hoist=true
ignore-workspace-root-check=true
link-workspace-packages=true

19
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,19 @@
{
"recommendations": [
"afzalsayed96.icones",
"antfu.iconify",
"antfu.unocss",
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig",
"lokalise.i18n-ally",
"mhutchie.git-graph",
"mikestead.dotenv",
"naumovs.color-highlight",
"pkief.material-icon-theme",
"sdras.vue-vscode-snippets",
"vue.volar",
"whtouche.vscode-js-console-utils",
"zhuangtongfa.material-theme",
"tu6ge.naive-ui-intelligence"
]
}

22
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,22 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Vue Debugger",
"url": "http://localhost:9527",
"webRoot": "${workspaceFolder}"
},
{
"type": "node",
"request": "launch",
"name": "TS Debugger",
"runtimeExecutable": "tsx",
"skipFiles": ["<node_internals>/**", "${workspaceFolder}/node_modules/**"],
"program": "${file}",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}

40
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,40 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
"editor.formatOnSave": false,
"eslint.validate": [
"html",
"css",
"scss",
"json",
"jsonc",
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue"
],
"i18n-ally.displayLanguage": "zh-cn",
"i18n-ally.enabledParsers": ["ts"],
"i18n-ally.enabledFrameworks": ["vue"],
"i18n-ally.editor.preferEditor": true,
"i18n-ally.keystyle": "nested",
"i18n-ally.localesPaths": ["src/locales/langs"],
"i18n-ally.parsers.typescript.compilerOptions": {
"moduleResolution": "node"
},
"prettier.enable": false,
"typescript.tsdk": "node_modules/typescript/lib",
"unocss.root": ["./"],
"vue.server.hybridMode": true,
"files.exclude": { "/docs": true },
"search.exclude": {
"/docs": true,
"**/dist/**": true,
"**/node_modules": true,
"node_modules/**": true,
},
"cSpell.words": ["Axios", "tinymce"]
}

501
CHANGELOG.md Normal file
View File

@ -0,0 +1,501 @@
# 更新日志
## [v2.0.0](https://gitee.com/xlsea/ruoyi-plus-soybean/compare/v1.2.1...v2.0.0) (2025-12-25)
### &nbsp;&nbsp;&nbsp;🚀 新功能
- **components**:
- 列设置新增滚动条处理 &nbsp;-&nbsp; by @m-xlsea [<samp>(6696d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/6696da52)
- 优化文件上传组件提示内容 &nbsp;-&nbsp; by @m-xlsea [<samp>(7bd11)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7bd115bf)
- 新增预设主题支持 &nbsp;-&nbsp; by @m-xlsea [<samp>(c1063)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/c1063e3e)
- **docs**:
- 新增 GitCode star 徽章 &nbsp;-&nbsp; by @m-xlsea [<samp>(5310d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/5310d352)
- **hooks**:
- 优化表格响应数据处理 &nbsp;-&nbsp; by @m-xlsea [<samp>(7d7f2)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7d7f28c4)
- 完成表格 Hooks 改造 &nbsp;-&nbsp; by @m-xlsea [<samp>(46996)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4699654f)
- 优化树形表格 hooks 封装 &nbsp;-&nbsp; by @m-xlsea [<samp>(ccbb7)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ccbb72c0)
- **project**:
- 优化业务代码语法格式 &nbsp;-&nbsp; by @m-xlsea [<samp>(7f04b)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7f04b119)
- **projects**:
- 项目适配 Soybean 2.0 &nbsp;-&nbsp; by @m-xlsea
- 客户端管理新增状态修改开关 &nbsp;-&nbsp; by @m-xlsea [<samp>(ea6a9)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ea6a92cd)
- Iframe 类型菜单传参更改 &nbsp;-&nbsp; by @m-xlsea [<samp>(bf3d5)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/bf3d5cb3)
- 新增同步租户参数配置功能 &nbsp;-&nbsp; by @m-xlsea [<samp>(901a6)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/901a65ad)
- 新增关于页面 &nbsp;-&nbsp; by @m-xlsea [<samp>(7d851)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7d85127c)
- 菜单新增布局选择支持 &nbsp;-&nbsp; by @m-xlsea [<samp>(13de6)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/13de6fbb)
- 优化控制台输出和 sql 导入文件 &nbsp;-&nbsp; by @m-xlsea [<samp>(bfb71)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/bfb7169e)
- 登录记住密码加密保存 &nbsp;-&nbsp; by @m-xlsea [<samp>(90c52)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/90c52d97)
- 使用 highlight.js 替换 monaco-editor &nbsp;-&nbsp; by @m-xlsea [<samp>(7dd7a)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7dd7a936)
- 演示页面新增字段排序 demo &nbsp;-&nbsp; by @m-xlsea [<samp>(41c25)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/41c25dcd)
- support pinning and unpinning of tabs &nbsp;-&nbsp; by @PChening [<samp>(b8a76)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/b8a767d7)
- hybrid layout mode auto select first deepest child menu &nbsp;-&nbsp; by @paynezhuang [<samp>(94019)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/9401925f)
### &nbsp;&nbsp;&nbsp;🐞 Bug 修复
- **hooks**:
- 修复 useTable 获取字段列表问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(0f83c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/0f83cf5f)
- update pagination pageSize after data fetch. &nbsp;-&nbsp; by **Azir-11** [<samp>(64226)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/64226d9b)
- **projects**:
- 修复表单校验问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(62fb9)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/62fb9d90)
- 修复登录页面 logo 颜色问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(27cae)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/27cae756)
- 修复路由 name 与 path 不一致激活菜单异常问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(789a6)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/789a6bb9)
- fix the incorrect judgment of home by pin tab. &nbsp;-&nbsp; by **Azir-11** [<samp>(62a43)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/62a43c39)
- **project**:
- 修复导出时查询参数错误问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(52ad9)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/52ad93b2)
- **table**:
- 修复分页数据处理逻辑 &nbsp;-&nbsp; by @imtzc [<samp>(a59fd)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a59fdc58)
- **template**:
- 调整搜索模块的属性定义位置 &nbsp;-&nbsp; by @imtzc [<samp>(bb039)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/bb039eff)
### &nbsp;&nbsp;&nbsp;🛠 优化
- **projects**:
- 修复菜单代码质量问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(6f349)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/6f34956e)
- 优化注释规范 &nbsp;-&nbsp; by @m-xlsea [<samp>(4139a)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4139a729)
- **styles**:
- 优化属性表格展开列样式 &nbsp;-&nbsp; by @m-xlsea [<samp>(e40c3)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e40c37a0)
### &nbsp;&nbsp;&nbsp;📖 文档
- **other**:
- 优化 sql 插入语句 &nbsp;-&nbsp; by @m-xlsea [<samp>(f7d8d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f7d8d189)
- 优化工作流相关菜单 &nbsp;-&nbsp; by @m-xlsea [<samp>(33155)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/3315552d)
- 移除 cursor 文件夹 &nbsp;-&nbsp; by @m-xlsea [<samp>(5f950)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/5f950b46)
- 修复模板处理工具类内容错误 &nbsp;-&nbsp; by @m-xlsea [<samp>(f6dcd)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f6dcded8)
- **projects**:
- 更新 cursor 规则 &nbsp;-&nbsp; by @m-xlsea [<samp>(e63fe)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e63fee59)
### &nbsp;&nbsp;&nbsp;🏡 杂项
- **deps**:
- update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(ec9f9)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ec9f9af9)
- update umo-editor deps &nbsp;-&nbsp; by @m-xlsea [<samp>(39f8d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/39f8d13b)
- **styles**:
- format code &nbsp;-&nbsp; by @soybeanjs [<samp>(098cd)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/098cd50e)
### &nbsp;&nbsp;&nbsp;❤️ 贡献者
[![m-xlsea](https://github.com/m-xlsea.png?size=48)](https://github.com/m-xlsea)&nbsp;&nbsp;[![soybeanjs](https://github.com/soybeanjs.png?size=48)](https://github.com/soybeanjs)&nbsp;&nbsp;[![imtzc](https://github.com/imtzc.png?size=48)](https://github.com/imtzc)&nbsp;&nbsp;[![paynezhuang](https://github.com/paynezhuang.png?size=48)](https://github.com/paynezhuang)&nbsp;&nbsp;[![PChening](https://github.com/PChening.png?size=48)](https://github.com/PChening)&nbsp;&nbsp;[![Azir-11](https://github.com/Azir-11.png?size=48)](https://github.com/Azir-11)&nbsp;&nbsp;[![wenyuanw](https://github.com/wenyuanw.png?size=48)](https://github.com/wenyuanw)&nbsp;&nbsp;[![CyberShen](https://github.com/CyberShen.png?size=48)](https://github.com/CyberShen)&nbsp;&nbsp;[![Lruihao](https://github.com/Lruihao.png?size=48)](https://github.com/Lruihao)&nbsp;&nbsp;
[刘璐](mailto:hi.alue@qq.com),&nbsp;[CyberShen123](mailto:s.lijun@qq.com),&nbsp;[whyang](mailto:whyang9701@gmail.com),&nbsp;[HongxuanG](mailto:1359774872@qq.com),&nbsp;[NicholasLD](mailto:878639947@qq.com),&nbsp;
## [v2.0.0-beta.2](https://gitee.com/xlsea/ruoyi-plus-soybean/compare/v2.0.0-beta.1...v2.0.0-beta.2) (2025-12-17)
### &nbsp;&nbsp;&nbsp;🚀 新功能
- **components**:
- 列设置新增滚动条处理 &nbsp;-&nbsp; by @m-xlsea [<samp>(6696d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/6696da52)
- **docs**:
- 新增 GitCode star 徽章 &nbsp;-&nbsp; by @m-xlsea [<samp>(5310d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/5310d352)
- **hooks**:
- 优化表格响应数据处理 &nbsp;-&nbsp; by @m-xlsea [<samp>(7d7f2)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7d7f28c4)
- **project**:
- 优化业务代码语法格式 &nbsp;-&nbsp; by @m-xlsea [<samp>(7f04b)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7f04b119)
- **projects**:
- 客户端管理新增状态修改开关 &nbsp;-&nbsp; by @m-xlsea [<samp>(ea6a9)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ea6a92cd)
- Iframe 类型菜单传参更改 &nbsp;-&nbsp; by @m-xlsea [<samp>(bf3d5)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/bf3d5cb3)
- 新增同步租户参数配置功能 &nbsp;-&nbsp; by @m-xlsea [<samp>(901a6)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/901a65ad)
- support pinning and unpinning of tabs &nbsp;-&nbsp; by @PChening [<samp>(b8a76)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/b8a767d7)
- hybrid layout mode auto select first deepest child menu &nbsp;-&nbsp; by @paynezhuang [<samp>(94019)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/9401925f)
- 新增关于页面 &nbsp;-&nbsp; by @m-xlsea [<samp>(7d851)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7d85127c)
- 菜单新增布局选择支持 &nbsp;-&nbsp; by @m-xlsea [<samp>(13de6)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/13de6fbb)
- 优化控制台输出和 sql 导入文件 &nbsp;-&nbsp; by @m-xlsea [<samp>(bfb71)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/bfb7169e)
- 登录记住密码加密保存 &nbsp;-&nbsp; by @m-xlsea [<samp>(90c52)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/90c52d97)
- 使用 highlight.js 替换 monaco-editor &nbsp;-&nbsp; by @m-xlsea [<samp>(7dd7a)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7dd7a936)
- 演示页面新增字段排序 demo &nbsp;-&nbsp; by @m-xlsea [<samp>(41c25)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/41c25dcd)
### &nbsp;&nbsp;&nbsp;🐞 Bug 修复
- **hooks**:
- update pagination pageSize after data fetch. &nbsp;-&nbsp; by **Azir-11** [<samp>(64226)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/64226d9b)
- **project**:
- 修复导出时查询参数错误问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(52ad9)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/52ad93b2)
- **projects**:
- fix the incorrect judgment of home by pin tab. &nbsp;-&nbsp; by **Azir-11** [<samp>(62a43)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/62a43c39)
- **table**:
- 修复分页数据处理逻辑 &nbsp;-&nbsp; by @imtzc [<samp>(a59fd)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a59fdc58)
- **template**:
- 调整搜索模块的属性定义位置 &nbsp;-&nbsp; by @imtzc [<samp>(bb039)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/bb039eff)
### &nbsp;&nbsp;&nbsp;🛠 优化
- **projects**:
- 修复菜单代码质量问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(6f349)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/6f34956e)
- 优化注释规范 &nbsp;-&nbsp; by @m-xlsea [<samp>(4139a)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4139a729)
### &nbsp;&nbsp;&nbsp;📖 文档
- **other**:
- 更新 cursor 规则 &nbsp;-&nbsp; by @m-xlsea [<samp>(1d6af)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/1d6af984)
- 优化 sql 插入语句 &nbsp;-&nbsp; by @m-xlsea [<samp>(f7d8d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f7d8d189)
- 优化工作流相关菜单 &nbsp;-&nbsp; by @m-xlsea [<samp>(33155)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/3315552d)
- 移除 cursor 文件夹 &nbsp;-&nbsp; by @m-xlsea [<samp>(5f950)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/5f950b46)
- 修复模板处理工具类内容错误 &nbsp;-&nbsp; by @m-xlsea [<samp>(f6dcd)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f6dcded8)
### &nbsp;&nbsp;&nbsp;🏡 重构
- **deps**:
- update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(7cf40)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7cf4083b)
- update umo-editor deps &nbsp;-&nbsp; by @m-xlsea [<samp>(39f8d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/39f8d13b)
- **styles**:
- format code &nbsp;-&nbsp; by @soybeanjs [<samp>(098cd)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/098cd50e)
### &nbsp;&nbsp;&nbsp;❤️ 贡献者
[![m-xlsea](https://github.com/m-xlsea.png?size=48)](https://github.com/m-xlsea)&nbsp;&nbsp;[![imtzc](https://github.com/imtzc.png?size=48)](https://github.com/imtzc)&nbsp;&nbsp;[![soybeanjs](https://github.com/soybeanjs.png?size=48)](https://github.com/soybeanjs)&nbsp;&nbsp;[![paynezhuang](https://github.com/paynezhuang.png?size=48)](https://github.com/paynezhuang)&nbsp;&nbsp;[![PChening](https://github.com/PChening.png?size=48)](https://github.com/PChening)&nbsp;&nbsp;[![Azir-11](https://github.com/Azir-11.png?size=48)](https://github.com/Azir-11)&nbsp;&nbsp;
## [v2.0.0-beta.1](https://gitee.com/xlsea/ruoyi-plus-soybean/compare/v1.2.1...v2.0.0-beta.1) (2025-12-04)
### &nbsp;&nbsp;&nbsp;🚀 新功能
- **projects**:
- 项目适配 Soybean 2.0 &nbsp;-&nbsp; by @m-xlsea
- **components**:
- 新增预设主题支持 &nbsp;-&nbsp; by @m-xlsea [<samp>(c1063)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/c1063e3e)
- **hooks**:
- 完成表格 Hooks 改造 &nbsp;-&nbsp; by @m-xlsea [<samp>(46996)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4699654f)
- 优化树形表格 hooks 封装 &nbsp;-&nbsp; by @m-xlsea [<samp>(ccbb7)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ccbb72c0)
### &nbsp;&nbsp;&nbsp;🐞 Bug 修复
- **projects**:
- 修复登录页面 logo 颜色问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(27cae)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/27cae756)
- 修复路由 name 与 path 不一致激活菜单异常问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(789a6)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/789a6bb9)
### &nbsp;&nbsp;&nbsp;❤️ 贡献者
[![m-xlsea](https://github.com/m-xlsea.png?size=48)](https://github.com/m-xlsea)&nbsp;&nbsp;[![soybeanjs](https://github.com/soybeanjs.png?size=48)](https://github.com/soybeanjs)&nbsp;&nbsp;[![Azir-11](https://github.com/Azir-11.png?size=48)](https://github.com/Azir-11)&nbsp;&nbsp;[![wenyuanw](https://github.com/wenyuanw.png?size=48)](https://github.com/wenyuanw)&nbsp;&nbsp;[![CyberShen](https://github.com/CyberShen.png?size=48)](https://github.com/CyberShen)&nbsp;&nbsp;[![Lruihao](https://github.com/Lruihao.png?size=48)](https://github.com/Lruihao)&nbsp;&nbsp;
[刘璐](mailto:hi.alue@qq.com),&nbsp;[CyberShen123](mailto:s.lijun@qq.com),&nbsp;[whyang](mailto:whyang9701@gmail.com),&nbsp;[HongxuanG](mailto:1359774872@qq.com),&nbsp;[NicholasLD](mailto:878639947@qq.com),&nbsp;
## [v1.2.1](https://gitee.com/xlsea/ruoyi-plus-soybean/compare/v1.2.0...v1.2.1) (2025-10-29)
### &nbsp;&nbsp;&nbsp;🚀 新功能
- **components**:
- 菜单树选择组件新增隐藏禁用标识 &nbsp;-&nbsp; by @m-xlsea [<samp>(08cfa)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/08cfa167)
- 列设置新增滚动条处理 &nbsp;-&nbsp; by @m-xlsea [<samp>(6696d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/6696da52)
- **docs**:
- 新增 GitCode star 徽章 &nbsp;-&nbsp; by @m-xlsea [<samp>(5310d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/5310d352)
- **projects**:
- 优化字典操作 &nbsp;-&nbsp; by @m-xlsea [<samp>(2400b)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/2400bf8c)
- 客户端管理新增状态修改开关 &nbsp;-&nbsp; by @m-xlsea [<samp>(ea6a9)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ea6a92cd)
- Iframe 类型菜单传参更改 &nbsp;-&nbsp; by @m-xlsea [<samp>(bf3d5)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/bf3d5cb3)
- 新增同步租户参数配置功能 &nbsp;-&nbsp; by @m-xlsea [<samp>(901a6)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/901a65ad)
### &nbsp;&nbsp;&nbsp;🐞 Bug 修复
- **projects**: 修复代码生成树模板问题 &nbsp;-&nbsp; by **AN** [<samp>(fa7bc)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/fa7bc434)
### &nbsp;&nbsp;&nbsp;🛠 优化
- **projects**:
- 优化代码内容 &nbsp;-&nbsp; by @m-xlsea [<samp>(9edbd)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/9edbd8e6)
- 修复菜单代码质量问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(6f349)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/6f34956e)
### &nbsp;&nbsp;&nbsp;📖 文档
- **other**: 更新 cursor 规则 &nbsp;-&nbsp; by @m-xlsea [<samp>(1d6af)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/1d6af984)
- **projects**: 更新 cursor 规则 &nbsp;-&nbsp; by @m-xlsea [<samp>(e63fe)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e63fee59)
### &nbsp;&nbsp;&nbsp;🎨 样式
- **projects**: 优化注释规范 &nbsp;-&nbsp; by @m-xlsea [<samp>(4139a)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4139a729)
### &nbsp;&nbsp;&nbsp;❤️ 贡献值
[![m-xlsea](https://github.com/m-xlsea.png?size=48)](https://gitee.com/xlsea)&nbsp;&nbsp;[![Elio-An](https://github.com/Elio-An.png?size=48)](https://gitee.com/elio-an)
## [v1.2.0](https://gitee.com/xlsea/ruoyi-plus-soybean/compare/v1.1.3...v1.2.0) (2025-09-26)
### &nbsp;&nbsp;&nbsp;🚀 新功能
- **components**:
- 新增 umodoc 编辑器集成 &nbsp;-&nbsp; by @m-xlsea [<samp>(f182d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f182def5)
- **projects**:
- 重构登录页面样式 &nbsp;-&nbsp; by @m-xlsea [<samp>(8412a)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/8412a8db)
- 路由兼容 activeMenu 选项 &nbsp;-&nbsp; by @m-xlsea [<samp>(25ee3)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/25ee3207)
- 用户列表新增头像展示 &nbsp;-&nbsp; by @m-xlsea [<samp>(3146c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/3146c039)
- 新增岗位部门树接口 &nbsp;-&nbsp; by **AN** [<samp>(28101)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/28101cb2)
- **styles**:
- 优化左侧树形结构样式 &nbsp;-&nbsp; by @m-xlsea [<samp>(513dc)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/513dc31e)
- **utils**:
- 新增本地 Excel 导出工具类 &nbsp;-&nbsp; by @m-xlsea [<samp>(7f2f3)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7f2f3bd0)
### &nbsp;&nbsp;&nbsp;🐞 Bug 修复
- **components**:
- 修复字典标签会修改字典数据值问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(90a14)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/90a14e33)
- **hooks**:
- 修复下载 hooks 错误未处理 &nbsp;-&nbsp; by @m-xlsea [<samp>(5ef1c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/5ef1c5de)
- **packages**:
- axios: fix json response. fixed #815 &nbsp;-&nbsp; by @soybeanjs in https://gitee.com/xlsea/ruoyi-plus-soybean/issues/815 [<samp>(fd087)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/fd087f59)
- 修复tinymce层级问题 &nbsp;-&nbsp; by **AN** [<samp>(2c248)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/2c248d82)
- **projects**:
- 修改代码生成功能模块名为驼峰时,路由错误问题 &nbsp;-&nbsp; by **AN** [<samp>(2f794)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/2f794c4b)
- 修复新增部门时不显示上级部门问题 &nbsp;-&nbsp; by **AN** [<samp>(d5bbc)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/d5bbc37d)
- 修复菜单弹窗打开未清空默认值问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(ad207)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ad207255)
- 修复退出登录未清空消息列表问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(dc2fb)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/dc2fbbd5)
- 修复菜单默认图标问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(34ab7)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/34ab7d5d)
- 修复消息通知字典值未处理问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(3f148)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/3f148a4e)
- 修复登录页面跳转问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(8aeb7)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/8aeb7362)
- 修复登录页面样式问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(4e27f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4e27f3b5)
- **types**:
- fix proxy types &nbsp;-&nbsp; by @soybeanjs [<samp>(12b25)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/12b25e0d)
- **utils**:
- 修复请求工具响应解密问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(9ef0b)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/9ef0bd41)
### &nbsp;&nbsp;&nbsp;🛠 优化
- **components**: 补充国际化 &nbsp;-&nbsp; by **AN** [<samp>(ecad1)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ecad1c3e)
- **projects**: 字典状态使用枚举值 &nbsp;-&nbsp; by @m-xlsea [<samp>(56fd5)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/56fd5434)
### &nbsp;&nbsp;&nbsp;📖 文档
- **other**: 更新 cursor 规则 &nbsp;-&nbsp; by @m-xlsea [<samp>(e623b)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e623b560)
### &nbsp;&nbsp;&nbsp;🏡 重构
- **deps**:
- update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(e33f9)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e33f944a)
- update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(9fa95)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/9fa951aa)
### &nbsp;&nbsp;&nbsp;🎨 样式
- **components**: 修改json预览组件样式问题 &nbsp;-&nbsp; by **AN** [<samp>(378aa)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/378aa869)
- **styles**: 修复字体样式导致下划线不可见问题 &nbsp;-&nbsp; by **AN** [<samp>(4a424)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4a4244b5)
### &nbsp;&nbsp;&nbsp;❤️ 贡献者
[![m-xlsea](https://github.com/m-xlsea.png?size=48)](https://github.com/m-xlsea)&nbsp;&nbsp;[![soybeanjs](https://github.com/soybeanjs.png?size=48)](https://github.com/soybeanjs)&nbsp;&nbsp;[![Elio-An](https://github.com/Elio-An.png?size=48)](https://gitee.com/elio-an)
## [v1.1.3](https://gitee.com/xlsea/ruoyi-plus-soybean/compare/v1.1.2...v1.1.3) (2025-08-16)
### &nbsp;&nbsp;&nbsp;🐞 Bug 修复
- **hooks**:
- 非安全环境下不使用流式下载 &nbsp;-&nbsp; by @m-xlsea [<samp>(f8983)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f8983557)
- 修复oss下载时未转码问题 &nbsp;-&nbsp; by **AN** [<samp>(2d31d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/2d31d7dc)
- **project**:
- 关闭多租户功能后仍然遍历租户列表导致控制台报错的问题 &nbsp;-&nbsp; by **wang_rui** [<samp>(b96c4)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/b96c46ba)
- 关闭多租户功能后仍然遍历租户列表导致控制台报错的问题 Merge pull request !25 from littleghost2016/dev &nbsp;-&nbsp; by **不寻俗** [<samp>(90276)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/9027632b)
- **projects**:
- 修复一级菜单隐藏失效问题 &nbsp;-&nbsp; by **AN** [<samp>(8fcc7)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/8fcc70d7)
- 修复日期搜索条件清除问题 &nbsp;-&nbsp; by **AN** [<samp>(52318)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/52318c10)
- 修复登录过期事件监听未被重置 &nbsp;-&nbsp; by @m-xlsea [<samp>(71037)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/71037439)
- 修复用户新增时角色下拉包含超级管理员问题 &nbsp;-&nbsp; by **AN** [<samp>(a15b6)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a15b683b)
- 修复用户导入功能无法更新问题 &nbsp;-&nbsp; by **AN** [<samp>(4e983)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4e9839bd)
- Fix the icon size in the image preview toolbar &nbsp;-&nbsp; by @m-xlsea [<samp>(4539f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4539fe01)
- 修复新增用户未查询角色列表问题 &nbsp;-&nbsp; by **AN** [<samp>(d6ae8)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/d6ae85d2)
- **readme**:
- update GitHub stars and forks links for gitee &nbsp;-&nbsp; by @soybeanjs [<samp>(923eb)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/923eb98a)
### &nbsp;&nbsp;&nbsp;💅 重构
- **menu**:
- 菜单管理中隐藏的菜单显示灰色 &nbsp;-&nbsp; by **NicholasLD** [<samp>(adca2)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/adca2e26)
- 菜单管理中隐藏的菜单显示灰色 Merge pull request !24 from NicholasLD/N/A &nbsp;-&nbsp; by **不寻俗** [<samp>(4eb77)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4eb77eac)
- **projects**:
- 菜单列表新增禁用菜单样式 &nbsp;-&nbsp; by @m-xlsea [<samp>(e5383)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e538355f)
### &nbsp;&nbsp;&nbsp;🏡 杂项
- **other**: update the ESLint validation configuration to support more file types. &nbsp;-&nbsp; by **Azir-11** [<samp>(8d7f9)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/8d7f91dc)
- **readme**: remove DartNode sponsorship badge from README files &nbsp;-&nbsp; by @soybeanjs [<samp>(33ade)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/33ade539)
### &nbsp;&nbsp;&nbsp;❤️ 贡献者
[![soybeanjs](https://github.com/soybeanjs.png?size=48)](https://github.com/soybeanjs)&nbsp;&nbsp;[![m-xlsea](https://github.com/m-xlsea.png?size=48)](https://github.com/m-xlsea)&nbsp;&nbsp;[![Elio-An](https://github.com/Elio-An.png?size=48)](https://gitee.com/elio-an)&nbsp;&nbsp;[![Azir-11](https://github.com/Azir-11.png?size=48)](https://github.com/Azir-11)&nbsp;&nbsp;[![Azir-11](https://github.com/NicholasLD.png?size=48)](https://github.com/NicholasLD)&nbsp;&nbsp;
[wang_rui](mailto:wrr1996@163.com)
## [v1.1.2](https://gitee.com/xlsea/ruoyi-plus-soybean/compare/v1.1.1...v1.1.2) (2025-07-24)
### &nbsp;&nbsp;&nbsp;🐞 Bug 修复
- 修复 api.d.ts.vm 代码生成模板bug &nbsp;-&nbsp; by **zygalaxy** [<samp>(4e8c8)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4e8c8715)
- **projects**:
- 修复刷新时跳转至登录页问题 &nbsp;-&nbsp; by **AN** [<samp>(2587f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/2587f8cb)
- 修复登录过期不弹窗问题 &nbsp;-&nbsp; by **AN** [<samp>(e485f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e485f680)
- 修复菜单结构变动后路由无法进入问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(f4038)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f4038a2d)
### &nbsp;&nbsp;&nbsp;🛠 优化
- **projects**: 优化搜索框FormItem &nbsp;-&nbsp; by **AN** [<samp>(a1336)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a1336d15)
### &nbsp;&nbsp;&nbsp;🏡 杂项
- **deps**: update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(e89b8)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e89b86ce)
### &nbsp;&nbsp;&nbsp;🎨 样式
- **projects**: 搜索FormItem占比调整 &nbsp;-&nbsp; by **AN** [<samp>(cc29e)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/cc29ea85)
### &nbsp;&nbsp;&nbsp;❤️ 贡献者
[![m-xlsea](https://github.com/m-xlsea.png?size=48)](https://github.com/m-xlsea)&nbsp;&nbsp;[![Elio-An](https://github.com/Elio-An.png?size=48)](https://gitee.com/elio-an)&nbsp;&nbsp;[![soybeanjs](https://github.com/soybeanjs.png?size=48)](https://github.com/soybeanjs)&nbsp;&nbsp;
[zygalaxy](mailto:zygalaxy@qq.com)
## [v1.1.1](https://gitee.com/xlsea/ruoyi-plus-soybean/compare/v1.1.0...v1.1.1) (2025-07-11)
### &nbsp;&nbsp;&nbsp;🚀 新功能
- **hooks**:
- 重构下载方法,支持流式下载 &nbsp;-&nbsp; by @m-xlsea [<samp>(65067)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/650673e2)
- **projects**:
- 角色分配用户新增部门与时间查询条件 &nbsp;-&nbsp; by @m-xlsea [<samp>(ad48d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ad48d8e8)
- 修改操作后列表查询方式 &nbsp;-&nbsp; by @m-xlsea [<samp>(d8542)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/d85424ee)
### &nbsp;&nbsp;&nbsp;🐞 Bug 修复
- **hooks**:
- 解决 streamsaver 访问不到 Github 资源问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(566b2)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/566b2c2d)
- **other**:
- 修复代码生成类型定义文件重复问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(f7c7f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f7c7fc41)
- **packages**:
- 修复 cleanup 会删除富文本编辑器资源问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(9ca7c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/9ca7ca8f)
- **projects**:
- 修复字典数据重复获取问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(3628c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/3628c249)
- 修改强退在线设备接口 &nbsp;-&nbsp; by **AN** [<samp>(dbcf8)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/dbcf8d42)
- 修复代码生成逻辑判断问题 &nbsp;-&nbsp; by **AN** [<samp>(6fc7b)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/6fc7b11b)
- 修复部门字典 sys_normal_disable 重复获取 Merge pull request !11 from 素还真/N/A &nbsp;-&nbsp; by @m-xlsea [<samp>(ad938)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ad9386eb)
- 修复未清空文件列表,上传回显问题 &nbsp;-&nbsp; by **AN** [<samp>(229e0)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/229e0044)
- Fix i18n-ally not working when setting moduleResolution to bundler. fixed #780 &nbsp;-&nbsp; by @xiaobao0505 in https://gitee.com/xlsea/ruoyi-plus-soybean/issues/780 [<samp>(41191)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/41191d54)
- 修复角色列表操作栏展示不全问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(62f2c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/62f2c6d5)
- 修复用户导入结果信息未渲染标签问题 &nbsp;-&nbsp; by **AN** [<samp>(efc95)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/efc953c0)
- 修复角色用户分配未调用接口问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(ff874)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ff87415d)
- **styles**:
- 修复登录页平板界面滚动问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(90145)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/90145fa5)
- **utils**:
- 修复isNull和IsNotNull判断方法潜在问题 &nbsp;-&nbsp; by **AN** [<samp>(90d32)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/90d32ee2)
### &nbsp;&nbsp;&nbsp;💅 重构
- **projects**: 调整租户套餐菜单接口 &nbsp;-&nbsp; by **AN** [<samp>(b9999)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/b9999935)
### &nbsp;&nbsp;&nbsp;📖 文档
- **other**: 修改文档内容 &nbsp;-&nbsp; by @m-xlsea [<samp>(3ae99)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/3ae9922d)
- **projects**: 优化 cursor 规则及 mcp &nbsp;-&nbsp; by @m-xlsea [<samp>(a3199)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a31994dc)
- **readme**: 更新 README.md 文件 &nbsp;-&nbsp; by @m-xlsea [<samp>(99675)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/99675cbc)
### &nbsp;&nbsp;&nbsp;🏡 杂项
- **deps**:
- update NodeJS and pnpm version requirements in package.json and documentation &nbsp;-&nbsp; by **Junior25306** [<samp>(a5c4b)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a5c4b4e3)
- update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(5cb1c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/5cb1cebd)
- update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(aeb63)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/aeb63690)
- update deps &nbsp;-&nbsp; by @m-xlsea [<samp>(89c71)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/89c716e1)
- **packages**:
- update Vite version to 7 in package.json and documentation. &nbsp;-&nbsp; by **Azir** [<samp>(03dd6)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/03dd64c5)
- **projects**:
- update pnpm-lock.yaml &nbsp;-&nbsp; by @m-xlsea [<samp>(7c6ca)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7c6ca91e)
- **vscode**:
- remove unused vue.server.hybridMode setting from .vscode/settings.json &nbsp;-&nbsp; by @soybeanjs [<samp>(13319)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/133196f3)
### &nbsp;&nbsp;&nbsp;❤️ 贡献值
[![m-xlsea](https://github.com/m-xlsea.png?size=48)](https://github.com/m-xlsea)&nbsp;&nbsp;[![soybeanjs](https://github.com/soybeanjs.png?size=48)](https://github.com/soybeanjs)&nbsp;&nbsp;[![xiaobao0505](https://github.com/xiaobao0505.png?size=48)](https://github.com/xiaobao0505)&nbsp;&nbsp;[![Elio-An](https://github.com/Elio-An.png?size=48)](https://gitee.com/elio-an)&nbsp;&nbsp;[![Azir-11](https://github.com/Azir-11.png?size=48)](https://github.com/Azir-11)&nbsp;&nbsp;[Junior25306](mailto:dayu429@qq.com)
## [v1.1.0](https://gitee.com/xlsea/ruoyi-plus-soybean/compare/v1.0.0...v1.1.0) (2025-07-01)
### &nbsp;&nbsp;&nbsp;🚀 新功能
- **components**:
- 新增表单上传组件 &nbsp;-&nbsp; by @m-xlsea [<samp>(03c8a)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/03c8a7f5)
- **other**:
- 新增菜单字典多语言适配 SQL &nbsp;-&nbsp; by @m-xlsea [<samp>(0f33f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/0f33f4a3)
- **projects**:
- add configurable user name watermark option &nbsp;-&nbsp; by @wenyuanw [<samp>(7c3da)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7c3dac42)
- 菜单字典适配 i18n &nbsp;-&nbsp; by @m-xlsea [<samp>(39dd9)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/39dd9acc)
- 新增字典多语言适配 &nbsp;-&nbsp; by @m-xlsea [<samp>(8c840)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/8c84063a)
- **styles**:
- 修复登录页移动端显示问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(742e3)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/742e3858)
### &nbsp;&nbsp;&nbsp;🐞 Bug 修复
- **app**:
- replace console.error with window.console.error for consistency &nbsp;-&nbsp; by @soybeanjs [<samp>(7d840)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7d84062e)
- **auth**:
- remove redundant authStore declaration in resetStore function &nbsp;-&nbsp; by @soybeanjs [<samp>(c57f8)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/c57f88aa)
- **components**:
- 修复菜单树选择组件 &nbsp;-&nbsp; by @m-xlsea [<samp>(bbda8)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/bbda803e)
- 修复树选择组件再次勾选父子联动导致全选问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(aeb73)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/aeb736eb)
- 修复部门选择组件非树结构,默认展开失败问题 &nbsp;-&nbsp; by **AN** [<samp>(da1c1)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/da1c16e0)
- 修复上传组件回显问题修改accept参数逻辑 &nbsp;-&nbsp; by **AN** [<samp>(e16a0)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e16a0fa6)
- 修复菜单选择标签渲染问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(6e6cc)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/6e6cc4d9)
- **other**:
- 修复代码生成问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(1ec10)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/1ec10991)
- 代码生成模板 dateRangeTime 错误 &nbsp;-&nbsp; by @m-xlsea [<samp>(f0810)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f0810bce)
- 修复代码生成字典相关问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(94d18)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/94d1863e)
- 修复代码生成类型定义文件重复问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(f7c7fc41)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f7c7fc41)
- **projects**:
- 修复自定义数据权限没有保存角色部门bug &nbsp;-&nbsp; by **AN** [<samp>(a0f33)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a0f33664)
- 修复登录过期后,重复弹窗问题 &nbsp;-&nbsp; by **AN** [<samp>(cafee)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/cafee1db)
- 修复首页未从环境变量获取问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(031b7)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/031b7f69)
- 修复导出查询参数问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(ffa47)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ffa47c37)
- 修复权限字符显示逻辑错误问题 &nbsp;-&nbsp; by **AN** [<samp>(0ac0a)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/0ac0a093)
- 目录类型禁用iframe选项 &nbsp;-&nbsp; by **AN** [<samp>(72b8f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/72b8f56e)
- 修复切换用户或登录过期部分问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(27f06)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/27f06195)
- 修复接口请求异常拦截问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(031d0)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/031d071a)
- 修复个人信息-修改密码未加密且参数错误问题 &nbsp;-&nbsp; by **AN** [<samp>(8b315)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/8b3151b8)
- 调整属性名 &nbsp;-&nbsp; by **AN** [<samp>(62e6c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/62e6c776)
- ensure proper text color when themes are inverted &nbsp;-&nbsp; by @wenyuanw [<samp>(afd60)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/afd60421)
- **styles**:
- 添加滚动条,去除页码 &nbsp;-&nbsp; by **AN** [<samp>(d37ad)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/d37adc36)
- **types**:
- The environment variable VITE_ICON_LOCAL_PREFIX has the wrong type. &nbsp;-&nbsp; by **chenziwen** [<samp>(da149)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/da149e5b)
- **utils**:
- 修复 删除当前tab为最后一个时tab切换错误bug. &nbsp;-&nbsp; by **AN** [<samp>(64bd1)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/64bd119c)
### &nbsp;&nbsp;&nbsp;🛠 优化
- **components**:
- optimize spacing for lang-switch dropdown options &nbsp;-&nbsp; by @wenyuanw [<samp>(fcb89)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/fcb89883)
- **projects**:
- optimize tab deletion logic. closed #755 &nbsp;-&nbsp; by @wenyuanw in https://gitee.com/xlsea/ruoyi-plus-soybean/issues/755 [<samp>(e6044)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e6044d0f)
- optimize tab deletion logic &nbsp;-&nbsp; by **AN** [<samp>(858c3)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/858c3180)
- 优化接口请求异常拦截代码 &nbsp;-&nbsp; by @m-xlsea [<samp>(47191)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/471912e1)
### &nbsp;&nbsp;&nbsp;💅 重构
- **iframe-page**: remove unused lifecycle hooks and clean up script setup &nbsp;-&nbsp; by @soybeanjs [<samp>(276d8)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/276d836c)
- **projects**: 补充formTip信息 &nbsp;-&nbsp; by **AN** [<samp>(f36ac)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f36ac9ab)
### &nbsp;&nbsp;&nbsp;📖 文档
- **readme**:
- 更新 README.md 文件 &nbsp;-&nbsp; by @m-xlsea [<samp>(99675cb)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/99675cb)
### &nbsp;&nbsp;&nbsp;🏡 杂项
- **deps**:
- update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(3e4e1)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/3e4e17ab)
- update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(dc674)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/dc674ce8)
- update deps &nbsp;-&nbsp; by @m-xlsea [<samp>(fec05)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/fec0563e)
- **projects**:
- 移除未使用代码 &nbsp;-&nbsp; by **AN** [<samp>(d141e)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/d141ed5b)
- update deps & fix `moduleResolution` &nbsp;-&nbsp; by @soybeanjs [<samp>(dbd99)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/dbd995c1)
### &nbsp;&nbsp;&nbsp;🎨 样式
- **projects**:
- 更换 logo 与加载样式 &nbsp;-&nbsp; by @m-xlsea [<samp>(7e4ec)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7e4ecae6)
- 重构登录页样式 &nbsp;-&nbsp; by @m-xlsea [<samp>(40680)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/406800de)
- 修改按钮文本颜色 &nbsp;-&nbsp; by @m-xlsea [<samp>(907f0)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/907f0439)
- 优化移动端字体大小 &nbsp;-&nbsp; by @m-xlsea [<samp>(8b4e4)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/8b4e41ce)
### &nbsp;&nbsp;&nbsp;❤️ 贡献者
[![xlsea](https://github.com/m-xlsea.png?size=48)](https://gitee.com/xlsea)&nbsp;&nbsp;[![soybeanjs](https://github.com/soybeanjs.png?size=48)](https://github.com/soybeanjs)&nbsp;&nbsp;[![wenyuanw](https://github.com/wenyuanw.png?size=48)](https://github.com/wenyuanw)&nbsp;&nbsp;[![Elio-An](https://github.com/Elio-An.png?size=48)](https://gitee.com/elio-an)&nbsp;&nbsp;[![chen-ziwen](https://github.com/chen-ziwen.png?size=48)](https://github.com/chen-ziwen)&nbsp;&nbsp;
[![wangzhongqi0917](https://gitee.com/wangzhongqi0917.png?width=48)](https://gitee.com/wangzhongqi0917)&nbsp;&nbsp;[![qq1822213252](https://gitee.com/qq1822213252.png?width=48)](https://gitee.com/qq1822213252)&nbsp;&nbsp;[![tangzc](https://gitee.com/tangzc.png?width=48)](https://gitee.com/tangzc),&nbsp;[metabytes](https://gitee.com/metabytes)
## [v1.0.0](https://gitee.com/xlsea/ruoyi-plus-soybean/releases/tag/v1.0.0) (2025-06-05)
### &nbsp;&nbsp;&nbsp;🚀 新功能
1.0.0 版本正式发布,此版本不包含工作流与多语言,请期待后续版本发布。
### &nbsp;&nbsp;&nbsp;❤️ 贡献者
首次发版不展示过多贡献者,敬请谅解
[![soybeanjs](https://github.com/honghuangdc.png?size=48)](https://github.com/honghuangdc)&nbsp;&nbsp;[![xlsea](https://github.com/m-xlsea.png?size=48)](https://gitee.com/xlsea)&nbsp;&nbsp;[![Elio-An](https://github.com/Elio-An.png?size=48)](https://gitee.com/elio-an)&nbsp;&nbsp;[![wangqiqi95](https://github.com/wangqiqi95.png?size=48)](https://github.com/wangqiqi95)&nbsp;

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 xlsea
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

391
README.md Normal file
View File

@ -0,0 +1,391 @@
<div align="center">
<img src="https://docs.ruoyi.xlsea.cn/logo.svg" width="160">
<h1>RuoYi-Plus-Soybean</h1>
</div>
<div style="height: 10px; clear: both;"></div>
<div align="center">
<p>一个基于 <a href="https://gitee.com/dromara/RuoYi-Vue-Plus" target="_blank">RuoYi-Vue-Plus</a> 的后端能力和 <a href="https://github.com/soybeanjs/soybean-admin" target="_blank">Soybean Admin</a> 前端特性的现代化多租户管理系统</p>
<p>
<a href="https://gitcode.com/xlsea/ruoyi-plus-soybean" target="_blank"><img src="https://gitcode.com/xlsea/ruoyi-plus-soybean/star/badge.svg" alt="GitCode"></a>
<a href="https://github.com/m-xlsea/ruoyi-plus-soybean" target="_blank"><img src="https://img.shields.io/github/stars/m-xlsea/ruoyi-plus-soybean" alt="Github"></a>
<a href="https://gitee.com/xlsea/ruoyi-plus-soybean" target="_blank"><img src="https://gitee.com/xlsea/ruoyi-plus-soybean/badge/star.svg" alt="Gitee"></a>
<a href="https://vuejs.org" target="_blank"><img src="https://img.shields.io/badge/Vue-3.5-brightgreen" alt="vue"></a>
<a href="https://www.typescriptlang.org" target="_blank"><img src="https://img.shields.io/badge/TypeScript-5.8-blue" alt="typescript"></a>
<a href="https://vite.dev" target="_blank"><img src="https://img.shields.io/badge/Vite-6.2-orange" alt="vite"></a>
<a href="https://www.naiveui.com" target="_blank"><img src="https://img.shields.io/badge/NaiveUI-2.41-purple" alt="naive-ui"></a>
<a href="./LICENSE" target="_blank"><img src="https://img.shields.io/badge/License-MIT-yellow" alt="license"></a>
</p>
</div>
# 📢 重要通知
2.0.0 版本已经正式发布(工作流版本请切换 [flow](https://gitee.com/xlsea/ruoyi-plus-soybean/tree/flow/) 分支查看),但仍然建议:
- 在生产环境使用前进行充分测试
- 关注项目更新,及时获取最新版本
- 积极反馈问题,帮助我们快速迭代
**后续规划**
- 多语言国际化完善
- 性能优化和稳定性提升
> 如果对该项目感兴趣,可以给一个 Star 支持一下,谢谢!
> 请大家踊跃提交 PR 和 Issue一起完善这个项目
# ❗开发前必看
<p style="font-weight: bold; font-size: 24px;">本项目强制使用 pnpm 构建,详细请看 <a href="#安装步骤及说明">安装步骤及说明</a></p>
<p style="font-weight: bold; font-size: 24px;">后端需要替换代码生成模板与菜单 SQL详细请看 <a href="#代码生成与菜单更新">代码生成与菜单更新</a></p>
# 💎 友情链接
- [Snail Job Pro](https://pro.snailjob.opensnail.com/home) - 灵活,可靠和快速的分布式任务重试和分布式任务调度平台
- [AiZuDa - 爱组搭(飞龙工作流企业版)](https://naiveui.aizuda.com) - 像搭积木一样进行低代码甚至零代码快速构建应用
## 📋 项目概述
RuoYi-Plus-Soybean 是一个现代化的企业级多租户管理系统,它结合了 RuoYi-Vue-Plus 的强大后端功能和 Soybean Admin 的现代化前端特性,为开发者提供了完整的企业管理解决方案。
### 🌟 项目特点
- **多租户架构**完整支持SaaS多租户模式灵活的租户管理能力
- **现代前端技术栈**基于Vue 3、TypeScript、Vite和Naive UI构建
- **Monorepo工程管理**使用pnpm workspaces管理多包结构
- **丰富的组件库**:内置大量业务组件和布局选项
- **主题定制**:支持多种布局模式和主题配色
- **国际化**:内置多语言支持
- **权限管理**:精细的基于角色的权限控制
## 🛠️ 技术栈
### 前端
- **核心框架**Vue 3.5.x
- **开发语言**TypeScript 5.8.x
- **构建工具**Vite 6.2.x
- **UI组件库**Naive UI 2.41.x
- **状态管理**Pinia 3.0.x
- **路由**Vue Router 4.5.x
- **HTTP客户端**Axios/Alova
- **CSS**UnoCSS
- **包管理器**pnpm 8.x+
### 后端与RuoYi-Vue-Plus兼容
- **核心框架**Spring Boot
- **安全框架**Spring Security
- **权限认证**Sa-Token
- **数据操作**MyBatis-Plus
- **数据库**MySQL
## 🏗️ 项目结构
```
root
├── build # 构建配置和插件
│ ├── config # 构建配置文件
│ └── plugins # Vite 插件
├── docs # 文档和模板
│ ├── java # 代码生成工具类
│ └── template # 代码生成模板
├── packages # Monorepo包
│ ├── alova # 使用Alova的HTTP客户端实现
│ ├── axios # 使用Axios的HTTP客户端实现
│ ├── color # 颜色管理工具
│ ├── hooks # 可复用的Vue组合函数
│ ├── materials # UI组件和材料
│ ├── ofetch # 使用ofetch的HTTP客户端实现
│ ├── scripts # 构建和开发脚本
│ ├── uno-preset # UnoCSS预设配置
│ └── utils # 通用工具函数
├── public # 静态资源
├── src # 主应用源代码
│ ├── assets # 静态资源(图片、图标)
│ ├── components # 可复用的 Vue 组件
│ ├── constants # 应用常量
│ ├── enum # TypeScript 枚举
│ ├── hooks # Vue 组合函数
│ ├── layouts # 页面布局
│ ├── locales # 国际化
│ ├── plugins # Vue 插件
│ ├── router # Vue Router 配置
│ ├── service # API 服务
│ ├── store # Pinia 存储模块
│ ├── styles # 全局样式
│ ├── theme # 主题配置
│ ├── typings # TypeScript 类型定义
│ ├── utils # 工具函数
│ └── views # 页面组件
└── vite.config.ts # Vite 配置
```
## 🚀 环境要求与安装
### 环境要求
- Node.js >= 20.19.0
- pnpm >= 10.5.0
- Git
### 安装步骤及说明
1. 克隆仓库
```bash
git clone https://gitee.com/xlsea/ruoyi-plus-soybean.git
cd ruoyi-plus-soybean
```
2. 安装 pnpm (如果未安装)
```bash
npm install pnpm -g
```
设置淘宝镜像
```bash
pnpm config set registry https://registry.npmmirror.com
```
3. 安装依赖
```bash
pnpm install
```
4. 运行开发服务器
```bash
pnpm dev
```
5. 构建生产版本
```bash
pnpm build
```
### 代码生成与菜单更新
项目提供了代码生成工具和菜单SQL更新文件<a href="https://gitee.com/xlsea/ruoyi-plus-soybean/tree/master/docs" target="_blank">docs</a> 目录下:
- **代码生成工具**
- 代码生成工具类位于 `docs/java` 目录如果没有修改过VelocityUtils.java文件直接替换即可
- 代码生成模板位于 `docs/template` 目录请在ruoyi-generator模块的`resource/vm`下新建 `soy`文件夹,并将所有模板拷贝至`soy`文件夹中
- **菜单SQL更新**
- 菜单数据更新SQL文件位于 `docs/sql` 目录
- 在系统初始化或更新时需要执行相应的SQL文件来更新菜单数据
## 📝 开发指南
### 可用的脚本命令
```bash
# 开发环境
pnpm dev
# 测试环境
pnpm dev:test
# 生产环境
pnpm dev:prod
# 构建生产版本
pnpm build
# 构建开发版本
pnpm build:dev
# 构建测试版本
pnpm build:test
# 预览构建
pnpm preview
# 类型检查
pnpm typecheck
# 代码规范检查并修复
pnpm lint
# 路由生成
pnpm gen-route
# 提交代码
pnpm commit
# 中文提交信息
pnpm commit:zh
# 依赖包更新
pnpm update-pkg
# 清理项目
pnpm cleanup
# 发布新版本
pnpm release
```
### 代码规范与风格
项目使用ESLint进行代码检查遵循以下规范
- **命名规范**
- Vue组件: PascalCase (如 UserProfile.vue)
- TypeScript文件: camelCase (如 userService.ts)
- CSS/SCSS: kebab-case (如 user-profile.scss)
- **代码风格**
- 使用Vue 3 Composition API
- 使用TypeScript类型系统
- 遵循单一职责原则
### 核心开发模式
#### 状态管理
使用Pinia进行状态管理模块位于`src/store/modules`目录:
- **app**: 应用全局状态
- **theme**: 主题配置
- **route**: 路由信息
- **tab**: 标签页管理
- **auth**: 认证信息
- **dict**: 字典管理
- **notice**: 通知管理
#### API交互
项目支持多种HTTP客户端实现
- **Axios**:
```typescript
import { useRequest } from '@/hooks/common/request';
const { data, loading, error } = useRequest(() => api.getData(params));
```
- **Hooks使用**:
```typescript
// 布尔值管理
import { useBoolean } from '@sa/hooks';
const { bool, setTrue, setFalse } = useBoolean();
// 加载状态管理
import { useLoading } from '@sa/hooks';
const { loading, startLoading, endLoading } = useLoading();
// 表格管理
import { useTable } from '@/hooks/common/table';
const { tableData, loading, getPaginationData } = useTable(fetchTableData);
```
#### 组件使用
项目包含多种业务组件:
- **表格组件**:支持列设置、搜索区域和高级操作
- **表单组件**:集成验证和表单布局
- **字典组件**:字典选择、标签和单选
- **布局组件**:支持多种布局模式和主题
### UnoCSS使用指南
项目优先使用 UnoCSS 来实现样式:
```html
<div class="flex flex-col items-center justify-center p-4 m-2 bg-blue-100 dark:bg-blue-800 rounded-md">
<span class="text-lg font-bold text-center">内容</span>
</div>
```
### 国际化
项目使用vue-i18n实现国际化支持
```typescript
// 在组件中使用
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
console.log(t('common.confirm'));
```
## 💎 特性与功能
### 前端特性
- **多种布局模式**:支持垂直、水平、混合等多种布局
- **可配置的主题**:明暗模式、主题色定制
- **标签页管理**:多种标签风格、右键菜单
- **组件封装**:进度条、图标、加载动画等
- **路由生成**:基于目录结构的路由生成
- **权限管理**:菜单和按钮级别的权限控制
### 业务功能
- **用户管理**:用户信息维护、角色分配
- **角色管理**:角色权限配置
- **菜单管理**:系统功能配置
- **部门管理**:组织架构维护
- **字典管理**:数据字典配置
- **租户管理**:多租户配置
- **系统监控**:登录日志、操作日志、在线用户、缓存监控
- **代码生成**:生成前后端代码,提升开发效率
## 🤝 贡献指南
### 开发流程
1. Fork项目
2. 创建功能分支 (`git checkout -b feature/amazing-feature`)
3. 提交更改 (`git commit -m 'feat: add amazing feature'`)
4. 推送到分支 (`git push origin feature/amazing-feature`)
5. 提交Pull Request
### 提交规范
项目使用约定式提交规范:
- `feat`: 新功能
- `fix`: 修复Bug
- `docs`: 文档更新
- `style`: 代码风格调整
- `refactor`: 代码重构
- `perf`: 性能优化
- `test`: 测试代码
- `chore`: 构建或工具变动
## 📄 许可证
[MIT License](./LICENSE)
## 🔗 相关链接
- [RuoYi-Vue-Plus](https://gitee.com/dromara/RuoYi-Vue-Plus) - 后端基础框架
- [Soybean Admin](https://github.com/soybeanjs/soybean-admin) - 前端基础框架
- [RuoYi-Plus-Soybean](https://ruoyi.xlsea.cn) - 官方演示站点
- [RuoYi-Plus-Soybean-Docs](https://docs.ruoyi.xlsea.cn) - 项目文档
- [Open Hives](https://openhives.com/questions) - OpenHives 问答社区
## 📮 联系方式
- **作者**: xlsea
- **邮箱**: m@xlsea.cn
- **作者主页**: https://gitee.com/xlsea
更多周边生态请翻阅 [周边生态](https://docs.soybeanjs.cn/zh/awesome) 文档。
- **作者**: Elio
- **邮箱**: 1983933789@qq.com
- **作者主页**: https://gitee.com/ahcode
## 💬 交流群
**加群前请先阅读一下内容:**
- 禁止内容:黄腔、暴力言论、政治话题,违者直接飞机票(踢出群)
- 遇到问题请先阅读 [项目文档](https://docs.ruoyi.xlsea.cn) 和 [Soybean 文档](https://docs.soybeanjs.cn/),某些简单问题不予理睬
- 蜡笔小新头像为机器人助手,私聊不保证回复,问题请在群内讨论
<img src="https://foruda.gitee.com/images/1749174520085305975/ad1b54fe_5601833.png" width="300px" />
添加作者微信备注:加群
## 🧧 捐献作者
作者为兼职做开源,平时还需要工作,如果帮到了您可以请作者吃个盒饭
<img src="https://foruda.gitee.com/images/1746840166037207866/f8c6f06b_5601833.png" width="300px" height="300px" />
## 🫡 捐赠列表
**捐赠列表已移至 [捐赠列表](https://docs.ruoyi.xlsea.cn/other/donate.html)**

11
School_Task1.md Normal file
View File

@ -0,0 +1,11 @@
【开发需求】
1. 功能扩展:在前端页面 `@src/views/art/school/index.vue` 的院校列表操作列中,新增子表操作按钮(院校名称管理、校区管理、专业管理等),按钮需绑定弹窗触发事件。
2. 组件适配:基于已完成的子模块基础代码(路径:`src/views/art/school/modules/` 下 school-campus、school-college 等所有子组件统一调整组件渲染形态为弹窗样式需符合现有项目的弹窗UI规范。
3. 数据关联实现列表按钮点击时将当前行的院校ID作为入参传递至对应子模块弹窗确保子模块接口调用时携带该关联ID完成子表数据与主表院校的关联管理。
4. 接口对接子模块需对接指定的TS接口文件路径`src/service/api/art/` 下 school-campus.ts、school-college.ts 等并引用对应的TS类型定义文件路径`src/typings/api/` 下 art.school-campus.api.d.ts、art.school-college.api.d.ts 等),保证类型校验与接口调用的规范性。
【文件路径清单】
- 主列表页面:@src/views/art/school/index.vue
- 子模块组件src/views/art/school/modules/school-campus/college/detail/dorm/enroll-plan/major/major-tag/media/name/tag
- 接口层文件src/service/api/art/school-campus.ts/college.ts/detail.ts/dorm.ts/enroll-plan.ts/major-tag.ts/major.ts/media.ts/name.ts/tag.ts
- 类型定义文件src/typings/api/art.school-campus.api.d.ts/college.api.d.ts/detail.api.d.ts/dorm.api.d.ts/enroll-plan.api.d.ts/major-tag.api.d.ts/major.api.d.ts/media.api.d.ts/name.api.d.ts/tag.api.d.ts/art.school.api.d.ts

2
build/config/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './proxy';
export * from './time';

56
build/config/proxy.ts Normal file
View File

@ -0,0 +1,56 @@
import type { ProxyOptions } from 'vite';
import { bgRed, bgYellow, green, lightBlue } from 'kolorist';
import { consola } from 'consola';
import { createServiceConfig } from '../../src/utils/service';
/**
* Set http proxy
*
* @param env - The current env
* @param enable - If enable http proxy
*/
export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
const isEnableHttpProxy = enable && env.VITE_HTTP_PROXY === 'Y';
if (!isEnableHttpProxy) return undefined;
const isEnableProxyLog = env.VITE_PROXY_LOG === 'Y';
const { baseURL, proxyPattern, ws, other } = createServiceConfig(env);
const proxy: Record<string, ProxyOptions> = createProxyItem({ baseURL, ws, proxyPattern }, isEnableProxyLog);
other.forEach(item => {
Object.assign(proxy, createProxyItem(item, isEnableProxyLog));
});
return proxy;
}
function createProxyItem(item: App.Service.ServiceConfigItem, enableLog: boolean) {
const proxy: Record<string, ProxyOptions> = {};
proxy[item.proxyPattern] = {
target: item.baseURL,
changeOrigin: true,
ws: item.ws,
configure: (_proxy, options) => {
_proxy.on('proxyReq', (_proxyReq, req, _res) => {
if (!enableLog) return;
const requestUrl = `${lightBlue('[proxy url]')}: ${bgYellow(` ${req.method} `)} ${green(`${item.proxyPattern}${req.url}`)}`;
const proxyUrl = `${lightBlue('[real request url]')}: ${green(`${options.target}${req.url}`)}`;
consola.log(`\n${requestUrl}\n${proxyUrl}`);
});
_proxy.on('error', (_err, req, _res) => {
if (!enableLog) return;
consola.log(bgRed(`Error: ${req.method} `), green(`${options.target}${req.url}`));
});
},
rewrite: path => path.replace(new RegExp(`^${item.proxyPattern}`), '')
};
return proxy;
}

12
build/config/time.ts Normal file
View File

@ -0,0 +1,12 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
export function getBuildTime() {
dayjs.extend(utc);
dayjs.extend(timezone);
const buildTime = dayjs.tz(Date.now(), 'Asia/Shanghai').format('YYYY-MM-DD HH:mm:ss');
return buildTime;
}

View File

@ -0,0 +1,9 @@
import VueDevtools from 'vite-plugin-vue-devtools';
export function setupDevtoolsPlugin(viteEnv: Env.ImportMeta) {
const { VITE_DEVTOOLS_LAUNCH_EDITOR } = viteEnv;
return VueDevtools({
launchEditor: VITE_DEVTOOLS_LAUNCH_EDITOR
});
}

13
build/plugins/html.ts Normal file
View File

@ -0,0 +1,13 @@
import type { Plugin } from 'vite';
export function setupHtmlPlugin(buildTime: string) {
const plugin: Plugin = {
name: 'html-plugin',
apply: 'build',
transformIndexHtml(html) {
return html.replace('<head>', `<head>\n <meta name="buildTime" content="${buildTime}">`);
}
};
return plugin;
}

24
build/plugins/index.ts Normal file
View File

@ -0,0 +1,24 @@
import type { PluginOption } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import progress from 'vite-plugin-progress';
import { setupElegantRouter } from './router';
import { setupUnocss } from './unocss';
import { setupUnplugin } from './unplugin';
import { setupHtmlPlugin } from './html';
import { setupDevtoolsPlugin } from './devtools';
export function setupVitePlugins(viteEnv: Env.ImportMeta, buildTime: string) {
const plugins: PluginOption = [
vue(),
vueJsx(),
setupDevtoolsPlugin(viteEnv),
setupElegantRouter(),
setupUnocss(viteEnv),
...setupUnplugin(viteEnv),
progress(),
setupHtmlPlugin(buildTime)
];
return plugins;
}

44
build/plugins/router.ts Normal file
View File

@ -0,0 +1,44 @@
import type { RouteMeta } from 'vue-router';
import ElegantVueRouter from '@elegant-router/vue/vite';
import type { RouteKey } from '@elegant-router/types';
export function setupElegantRouter() {
return ElegantVueRouter({
layouts: {
base: 'src/layouts/base-layout/index.vue',
blank: 'src/layouts/blank-layout/index.vue'
},
customRoutes: {
names: ['exception_403', 'exception_404', 'exception_500']
},
routePathTransformer(routeName, routePath) {
const key = routeName as RouteKey;
if (key === 'login') {
const modules: UnionKey.LoginModule[] = ['pwd-login', 'code-login', 'register', 'reset-pwd', 'bind-wechat'];
const moduleReg = modules.join('|');
return `/login/:module(${moduleReg})?`;
}
return routePath;
},
onRouteMetaGen(routeName) {
const key = routeName as RouteKey;
const constantRoutes: RouteKey[] = ['login', '403', '404', '500'];
const meta: Partial<RouteMeta> = {
title: key,
i18nKey: `route.${key}` as App.I18n.I18nKey
};
if (constantRoutes.includes(key)) {
meta.constant = true;
}
return meta;
}
});
}

32
build/plugins/unocss.ts Normal file
View File

@ -0,0 +1,32 @@
import process from 'node:process';
import path from 'node:path';
import unocss from '@unocss/vite';
import presetIcons from '@unocss/preset-icons';
import { FileSystemIconLoader } from '@iconify/utils/lib/loader/node-loaders';
export function setupUnocss(viteEnv: Env.ImportMeta) {
const { VITE_ICON_PREFIX, VITE_ICON_LOCAL_PREFIX } = viteEnv;
const localIconPath = path.join(process.cwd(), 'src/assets/svg-icon');
/** The name of the local icon collection */
const collectionName = VITE_ICON_LOCAL_PREFIX.replace(`${VITE_ICON_PREFIX}-`, '');
return unocss({
presets: [
presetIcons({
prefix: `${VITE_ICON_PREFIX}-`,
scale: 1,
extraProperties: {
display: 'inline-block'
},
collections: {
[collectionName]: FileSystemIconLoader(localIconPath, svg =>
svg.replace(/^<svg\s/, '<svg width="1em" height="1em" ')
)
},
warn: true
})
]
});
}

47
build/plugins/unplugin.ts Normal file
View File

@ -0,0 +1,47 @@
import process from 'node:process';
import path from 'node:path';
import type { PluginOption } from 'vite';
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
import Icons from 'unplugin-icons/vite';
import IconsResolver from 'unplugin-icons/resolver';
import Components from 'unplugin-vue-components/vite';
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
import { FileSystemIconLoader } from 'unplugin-icons/loaders';
export function setupUnplugin(viteEnv: Env.ImportMeta) {
const { VITE_ICON_PREFIX, VITE_ICON_LOCAL_PREFIX } = viteEnv;
const localIconPath = path.join(process.cwd(), 'src/assets/svg-icon');
/** The name of the local icon collection */
const collectionName = VITE_ICON_LOCAL_PREFIX.replace(`${VITE_ICON_PREFIX}-`, '');
const plugins: PluginOption[] = [
Icons({
compiler: 'vue3',
customCollections: {
[collectionName]: FileSystemIconLoader(localIconPath, svg =>
svg.replace(/^<svg\s/, '<svg width="1em" height="1em" ')
)
},
scale: 1,
defaultClass: 'inline-block'
}),
Components({
dts: 'src/typings/components.d.ts',
types: [{ from: 'vue-router', names: ['RouterLink', 'RouterView'] }],
resolvers: [
NaiveUiResolver(),
IconsResolver({ customCollections: [collectionName], componentPrefix: VITE_ICON_PREFIX })
]
}),
createSvgIconsPlugin({
iconDirs: [localIconPath],
symbolId: `${VITE_ICON_LOCAL_PREFIX}-[dir]-[name]`,
inject: 'body-last',
customDomId: '__SVG_ICON_LOCAL__'
})
];
return plugins;
}

11
docs/README.md Normal file
View File

@ -0,0 +1,11 @@
## java
后端代码生成工具类替换文件
## template
代码生成模板文件
## sql
菜单数据替换 SQL

View File

@ -0,0 +1,188 @@
# 院校主子表导入导出接口文档art/school
## 1. 目标
面向前端提供“院校主表 + 子表(详情/校区/学院/专业/招生计划)”的一体化导出与导入能力,支持:
- 多 Sheet 标准模板导出
- 导入前冲突预检
- 按院校编码选择性替换(原子事务)
- 导入明细反馈(成功/失败/跳过与原因)
---
## 2. 接口清单
### 2.1 导出模板数据(含业务数据)
- **URL**: `POST /art/school/export`
- **权限**: `art:school:export`
- **Content-Type**: `application/x-www-form-urlencoded`
- **响应**: Excel 文件下载(`.xlsx`
#### 查询参数(同院校列表)
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| mainCode | string | 否 | 院校编码 |
| mainName | string | 否 | 院校名称(模糊) |
| shortName | string | 否 | 院校简称(模糊) |
| province/city/district | string | 否 | 行政区 |
| universityType | string | 否 | 大学类型 |
| educationLevel | string | 否 | 学历层次 |
| schoolNature | string | 否 | 办学性质 |
#### 导出 Sheet 结构
1. `院校基本信息`
2. `院校详情信息`
3. `校区信息`
4. `学院信息`
5. `专业信息`
6. `招生计划信息`
7. `模板说明`
> 子表均包含 `院校编码*`、`院校名称*` 用于与主表关联。
---
### 2.2 导出空模板
- **URL**: `POST /art/school/importTemplate`
- **权限**: `art:school:export`
- **响应**: 空模板 Excel仅表头 + 模板说明)
---
### 2.3 导入预检
- **URL**: `POST /art/school/importPreview`
- **权限**: `art:school:import`
- **Content-Type**: `multipart/form-data`
- **入参**:
- `file`Excel 文件,必填)
#### 返回示例
```json
{
"code": 200,
"msg": "操作成功",
"data": {
"totalSchoolCount": 10,
"conflictCount": 3,
"invalidCount": 1,
"details": [
{
"mainCode": "1001",
"mainName": "测试院校01",
"status": "CONFLICT",
"message": "系统已存在该院校数据,导入时需确认是否替换"
},
{
"mainCode": "",
"mainName": "",
"status": "INVALID",
"message": "必填字段缺失:院校编码/院校名称不能为空"
}
]
}
}
```
#### 状态说明
- `CONFLICT`: 系统已存在(按院校编码/名称命中)
- `INVALID`: 模板数据无效(缺失、重复、子表关联不到主表)
---
### 2.4 导入执行
- **URL**: `POST /art/school/importData`
- **权限**: `art:school:import`
- **Content-Type**: `multipart/form-data`
- **入参**:
- `file`Excel 文件,必填)
- `replaceAll`boolean选填默认 `false`
- `replaceMainCodes`string[],选填,可多值传参)
#### 前端推荐调用逻辑
1. 先调 `importPreview`
2. 对 `CONFLICT` 院校弹窗询问“是否替换”。
3. 将用户同意替换的院校编码组装为 `replaceMainCodes`,再调 `importData`
4. 用户全量替换时可直接传 `replaceAll=true`
#### 返回示例
```json
{
"code": 200,
"msg": "操作成功",
"data": {
"totalSchoolCount": 10,
"successCount": 8,
"failCount": 1,
"skippedCount": 1,
"details": [
{
"mainCode": "1001",
"mainName": "测试院校01",
"status": "SUCCESS",
"message": "替换成功"
},
{
"mainCode": "1002",
"mainName": "测试院校02",
"status": "SKIPPED",
"message": "院校名称重复且用户取消替换"
},
{
"mainCode": "1003",
"mainName": "测试院校03",
"status": "FAILED",
"message": "学院保存失败"
}
]
}
}
```
#### 状态说明
- `SUCCESS`: 导入/替换成功
- `SKIPPED`: 冲突且用户未选择替换
- `FAILED`: 导入失败(事务回滚)
- `INVALID`: 文件预校验失败
---
## 3. 原子性与一致性
- 导入按“单院校”为最小事务单元:
- 该院校主表 + 子表(详情/校区/学院/专业/招生计划)要么全部成功,要么全部回滚。
- 替换逻辑:
1. 删除该院校历史主表及上述子表数据
2. 插入新主表数据
3. 插入新子表数据
---
## 4. 模板必填规则(简表)
- `院校基本信息`: `院校编码*`、`院校名称*`
- `院校详情信息`: `院校编码*`、`院校名称*`
- `校区信息`: `院校编码*`、`院校名称*`、`校区名称*`
- `学院信息`: `院校编码*`、`院校名称*`、`学院名称*`
- `专业信息`: `院校编码*`、`院校名称*`、`学院ID*`、`专业名称*`
- `招生计划信息`: `院校编码*`、`院校名称*`、`年份*`、`招生省份*`、`专业名称*`、`计划数*`
> `*` 表示模板必填字段。
---
## 5. 兼容说明
- 现有 `POST /art/school``PUT /art/school` 仍用于单院校编辑(`school + detail` 统一结构)。
- 本文档新增导入导出接口可与现有列表/编辑功能并行使用。

View File

@ -0,0 +1,117 @@
# 学校与专业标签联动接口补充文档
本文档仅说明本次新增字段:
- `/art/school`:新增 `schoolTags`
- `/art/schoolMajor`:新增 `majorTags`
## 1. /art/school 接口补充
### 1.1 GET `/art/school/{schoolId}`
返回对象 `data` 中新增:
| 字段 | 类型 | 说明 |
|---|---|---|
| schoolTags | string[] | 学校标签列表 |
示例:
```json
{
"code": 200,
"msg": "操作成功",
"data": {
"schoolId": 1001,
"mainCode": "10531",
"mainName": "某某大学",
"enrollCodes": ["10531", "A10531"],
"schoolTags": ["985", "211", "双一流"]
}
}
```
### 1.2 POST `/art/school`
请求体 `ArtSchoolSubmitBo` 新增:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| schoolTags | string[] | 否 | 学校标签列表(全量替换语义) |
### 1.3 PUT `/art/school`
请求体 `ArtSchoolSubmitBo` 新增:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| schoolTags | string[] | 否 | 学校标签列表(全量替换语义) |
### 1.4 `schoolTags` 处理语义
- `schoolTags = null`:不修改已有标签
- `schoolTags = []`:清空该学校全部标签
- `schoolTags = ["985","211"]`:按传入值全量覆盖(去重后保存)
---
## 2. /art/schoolMajor 接口补充
### 2.1 GET `/art/schoolMajor/{majorId}`
返回对象 `data` 中新增:
| 字段 | 类型 | 说明 |
|---|---|---|
| majorTags | string[] | 专业标签列表 |
示例:
```json
{
"code": 200,
"msg": "操作成功",
"data": {
"majorId": 9001,
"majorName": "视觉传达设计",
"majorTags": ["国家级特色专业", "一流本科专业"]
}
}
```
### 2.2 GET `/art/schoolMajor/list`
列表项中新增字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| majorTags | string[] | 专业标签列表 |
### 2.3 POST `/art/schoolMajor`
请求体 `ArtSchoolMajorBo` 新增:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| majorTags | string[] | 否 | 专业标签列表(全量替换语义) |
### 2.4 PUT `/art/schoolMajor`
请求体 `ArtSchoolMajorBo` 新增:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| majorTags | string[] | 否 | 专业标签列表(全量替换语义) |
### 2.5 `majorTags` 处理语义
- `majorTags = null`:不修改已有标签
- `majorTags = []`:清空该专业全部标签
- `majorTags = ["A","B"]`:按传入值全量覆盖(去重后保存)
---
## 3. 与旧字段兼容说明
- `ArtSchoolMajorBo` / `ArtSchoolMajorVo` 中旧字段 `tags` 仍保留,便于兼容历史前端。
- 新增推荐字段为 `majorTags`(数组),后续前端优先使用该字段。

View File

@ -0,0 +1,389 @@
package org.dromara.generator.util;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.lang.Dict;
import cn.hutool.core.util.StrUtil;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.apache.velocity.VelocityContext;
import org.dromara.common.core.utils.DateUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.mybatis.enums.DataBaseType;
import org.dromara.common.mybatis.helper.DataBaseHelper;
import org.dromara.generator.constant.GenConstants;
import org.dromara.generator.domain.GenTable;
import org.dromara.generator.domain.GenTableColumn;
import java.util.*;
/**
* 模板处理工具类
*
* @author ruoyi
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class VelocityUtils {
/**
* 项目空间路径
*/
private static final String PROJECT_PATH = "main/java";
/**
* mybatis空间路径
*/
private static final String MYBATIS_PATH = "main/resources/mapper";
/**
* 默认上级菜单系统工具
*/
private static final String DEFAULT_PARENT_MENU_ID = "3";
/**
* 设置模板变量信息
*
* @return 模板列表
*/
public static VelocityContext prepareContext(GenTable genTable) {
String moduleName = genTable.getModuleName();
String businessName = genTable.getBusinessName();
String packageName = genTable.getPackageName();
String tplCategory = genTable.getTplCategory();
String functionName = genTable.getFunctionName();
VelocityContext velocityContext = new VelocityContext();
velocityContext.put("tplCategory", genTable.getTplCategory());
velocityContext.put("tableName", genTable.getTableName());
velocityContext.put("functionName", StringUtils.isNotEmpty(functionName) ? functionName : "【请填写功能名称】");
velocityContext.put("ClassName", genTable.getClassName());
velocityContext.put("className", StringUtils.uncapitalize(genTable.getClassName()));
velocityContext.put("moduleName", StrUtil.toSymbolCase(genTable.getModuleName(), '-'));
velocityContext.put("BusinessName", StringUtils.capitalize(genTable.getBusinessName()));
velocityContext.put("businessName", genTable.getBusinessName());
velocityContext.put("business_name", StrUtil.toUnderlineCase(genTable.getBusinessName()));
velocityContext.put("business__name", StrUtil.toSymbolCase(genTable.getBusinessName(), '-'));
velocityContext.put("businessname", StrUtil.toSymbolCase(genTable.getBusinessName(), ' '));
velocityContext.put("basePackage", getPackagePrefix(packageName));
velocityContext.put("packageName", packageName);
velocityContext.put("author", genTable.getFunctionAuthor());
velocityContext.put("datetime", DateUtils.getDate());
velocityContext.put("pkColumn", genTable.getPkColumn());
velocityContext.put("importList", getImportList(genTable));
velocityContext.put("permissionPrefix", getPermissionPrefix(moduleName, businessName));
velocityContext.put("dicts", getDicts(genTable));
velocityContext.put("dictList", getDictList(genTable));
velocityContext.put("pkColumn", genTable.getPkColumn());
velocityContext.put("columns", genTable.getColumns());
velocityContext.put("table", genTable);
velocityContext.put("StrUtil", new StrUtil());
setMenuVelocityContext(velocityContext, genTable);
if (GenConstants.TPL_TREE.equals(tplCategory)) {
setTreeVelocityContext(velocityContext, genTable);
}
return velocityContext;
}
public static void setMenuVelocityContext(VelocityContext context, GenTable genTable) {
String options = genTable.getOptions();
Dict paramsObj = JsonUtils.parseMap(options);
String parentMenuId = getParentMenuId(paramsObj);
context.put("parentMenuId", parentMenuId);
}
public static void setTreeVelocityContext(VelocityContext context, GenTable genTable) {
String options = genTable.getOptions();
Dict paramsObj = JsonUtils.parseMap(options);
String treeCode = getTreecode(paramsObj);
String treeParentCode = getTreeParentCode(paramsObj);
String treeName = getTreeName(paramsObj);
context.put("treeCode", treeCode);
context.put("treeParentCode", treeParentCode);
context.put("treeName", treeName);
context.put("expandColumn", getExpandColumn(genTable));
if (paramsObj.containsKey(GenConstants.TREE_PARENT_CODE)) {
context.put("tree_parent_code", paramsObj.get(GenConstants.TREE_PARENT_CODE));
}
if (paramsObj.containsKey(GenConstants.TREE_NAME)) {
context.put("tree_name", paramsObj.get(GenConstants.TREE_NAME));
}
}
/**
* 获取模板信息
*
* @return 模板列表
*/
public static List<String> getTemplateList(String tplCategory) {
List<String> templates = new ArrayList<>();
templates.add("vm/java/domain.java.vm");
templates.add("vm/java/vo.java.vm");
templates.add("vm/java/bo.java.vm");
templates.add("vm/java/mapper.java.vm");
templates.add("vm/java/service.java.vm");
templates.add("vm/java/serviceImpl.java.vm");
templates.add("vm/java/controller.java.vm");
templates.add("vm/xml/mapper.xml.vm");
DataBaseType dataBaseType = DataBaseHelper.getDataBaseType();
if (dataBaseType.isOracle()) {
templates.add("vm/sql/oracle/sql.vm");
} else if (dataBaseType.isPostgreSql()) {
templates.add("vm/sql/postgres/sql.vm");
} else if (dataBaseType.isSqlServer()) {
templates.add("vm/sql/sqlserver/sql.vm");
} else {
templates.add("vm/sql/sql.vm");
}
templates.add("vm/soy/typings/api.d.ts.vm");
templates.add("vm/soy/api/api.ts.vm");
templates.add("vm/soy/modules/search.vue.vm");
templates.add("vm/soy/modules/operate-drawer.vue.vm");
if (GenConstants.TPL_CRUD.equals(tplCategory)) {
templates.add("vm/soy/index.vue.vm");
} else if (GenConstants.TPL_TREE.equals(tplCategory)) {
templates.add("vm/soy/index-tree.vue.vm");
}
return templates;
}
/**
* 获取文件名
*/
public static String getFileName(String template, GenTable genTable) {
// 文件名称
String fileName = "";
// 包路径
String packageName = genTable.getPackageName();
// 模块名
String moduleName = genTable.getModuleName();
// 大写类名
String className = genTable.getClassName();
// 业务名称
String businessName = genTable.getBusinessName();
String javaPath = PROJECT_PATH + "/" + StringUtils.replace(packageName, ".", "/");
String mybatisPath = MYBATIS_PATH + "/" + moduleName;
String soybeanPath = "soy";
String soybeanModuleName = StrUtil.toSymbolCase(moduleName, '-');
if (template.contains("domain.java.vm")) {
fileName = StringUtils.format("{}/domain/{}.java", javaPath, className);
}
if (template.contains("vo.java.vm")) {
fileName = StringUtils.format("{}/domain/vo/{}Vo.java", javaPath, className);
}
if (template.contains("bo.java.vm")) {
fileName = StringUtils.format("{}/domain/bo/{}Bo.java", javaPath, className);
}
if (template.contains("mapper.java.vm")) {
fileName = StringUtils.format("{}/mapper/{}Mapper.java", javaPath, className);
} else if (template.contains("service.java.vm")) {
fileName = StringUtils.format("{}/service/I{}Service.java", javaPath, className);
} else if (template.contains("serviceImpl.java.vm")) {
fileName = StringUtils.format("{}/service/impl/{}ServiceImpl.java", javaPath, className);
} else if (template.contains("controller.java.vm")) {
fileName = StringUtils.format("{}/controller/{}Controller.java", javaPath, className);
} else if (template.contains("mapper.xml.vm")) {
fileName = StringUtils.format("{}/{}Mapper.xml", mybatisPath, className);
} else if (template.contains("sql.vm")) {
fileName = businessName + "Menu.sql";
} else if (template.contains("index.vue.vm")) {
fileName = StringUtils.format("{}/views/{}/{}/index.vue", soybeanPath, soybeanModuleName, StrUtil.toSymbolCase(businessName, '-'));
} else if (template.contains("index-tree.vue.vm")) {
fileName = StringUtils.format("{}/views/{}/{}/index.vue", soybeanPath, soybeanModuleName, StrUtil.toSymbolCase(businessName, '-'));
} else if (template.contains("api.d.ts.vm")) {
fileName = StringUtils.format("{}/typings/api/{}.{}.api.d.ts", soybeanPath, soybeanModuleName, StrUtil.toSymbolCase(businessName, '-'));
} else if (template.contains("api.ts.vm")) {
fileName = StringUtils.format("{}/service/api/{}/{}.ts", soybeanPath, soybeanModuleName, StrUtil.toSymbolCase(businessName, '-'));
} else if (template.contains("search.vue.vm")) {
fileName = StringUtils.format("{}/views/{}/{}/modules/{}-search.vue", soybeanPath, soybeanModuleName, StrUtil.toSymbolCase(businessName, '-'), StrUtil.toSymbolCase(businessName, '-'));
} else if (template.contains("operate-drawer.vue.vm")) {
fileName = StringUtils.format("{}/views/{}/{}/modules/{}-operate-drawer.vue", soybeanPath, soybeanModuleName, StrUtil.toSymbolCase(businessName, '-'), StrUtil.toSymbolCase(businessName, '-'));
}
return fileName;
}
/**
* 获取包前缀
*
* @param packageName 包名称
* @return 包前缀名称
*/
public static String getPackagePrefix(String packageName) {
int lastIndex = packageName.lastIndexOf(".");
return StringUtils.substring(packageName, 0, lastIndex);
}
/**
* 根据列类型获取导入包
*
* @param genTable 业务表对象
* @return 返回需要导入的包列表
*/
public static HashSet<String> getImportList(GenTable genTable) {
List<GenTableColumn> columns = genTable.getColumns();
HashSet<String> importList = new HashSet<>();
for (GenTableColumn column : columns) {
if (!column.isSuperColumn() && GenConstants.TYPE_DATE.equals(column.getJavaType())) {
importList.add("java.util.Date");
importList.add("com.fasterxml.jackson.annotation.JsonFormat");
} else if (!column.isSuperColumn() && GenConstants.TYPE_BIGDECIMAL.equals(column.getJavaType())) {
importList.add("java.math.BigDecimal");
} else if (!column.isSuperColumn() && "imageUpload".equals(column.getHtmlType())) {
importList.add("org.dromara.common.translation.annotation.Translation");
importList.add("org.dromara.common.translation.constant.TransConstant");
}
}
return importList;
}
/**
* 根据列类型获取字典组
*
* @param genTable 业务表对象
* @return 返回字典组
*/
public static String getDicts(GenTable genTable) {
List<GenTableColumn> columns = genTable.getColumns();
Set<String> dicts = new HashSet<>();
addDicts(dicts, columns);
return StringUtils.join(dicts, ", ");
}
/**
* 添加字典列表
*
* @param dicts 字典列表
* @param columns 列集合
*/
public static void addDicts(Set<String> dicts, List<GenTableColumn> columns) {
for (GenTableColumn column : columns) {
if (!column.isSuperColumn() && StringUtils.isNotEmpty(column.getDictType()) && StringUtils.equalsAny(
column.getHtmlType(),
GenConstants.HTML_SELECT, GenConstants.HTML_RADIO, GenConstants.HTML_CHECKBOX)) {
dicts.add("'" + column.getDictType() + "'");
}
}
}
/**
* 根据列类型获取字典组
*
* @param genTable 业务表对象
* @return 返回字典组
*/
public static Set<Map<String, Object>> getDictList(GenTable genTable) {
List<GenTableColumn> columns = genTable.getColumns();
Set<Map<String, Object>> dicts = new HashSet<>();
addDictList(dicts, columns);
return dicts;
}
/**
* 添加字典列表
*
* @param dicts 字典列表
* @param columns 列集合
*/
public static void addDictList(Set<Map<String, Object>> dicts, List<GenTableColumn> columns) {
for (GenTableColumn column : columns) {
if (!column.isSuperColumn() && StringUtils.isNotEmpty(column.getDictType()) && StringUtils.equalsAny(
column.getHtmlType(),
GenConstants.HTML_SELECT, GenConstants.HTML_RADIO, GenConstants.HTML_CHECKBOX)) {
Map<String, Object> dict = new HashMap<>();
dict.put("type", column.getDictType());
dict.put("name", StringUtils.toCamelCase(column.getDictType()));
dict.put("immediate", column.isList());
dicts.add(dict);
}
}
}
/**
* 获取权限前缀
*
* @param moduleName 模块名称
* @param businessName 业务名称
* @return 返回权限前缀
*/
public static String getPermissionPrefix(String moduleName, String businessName) {
return StringUtils.format("{}:{}", moduleName, businessName);
}
/**
* 获取上级菜单ID字段
*
* @param paramsObj 生成其他选项
* @return 上级菜单ID字段
*/
public static String getParentMenuId(Dict paramsObj) {
if (CollUtil.isNotEmpty(paramsObj) && paramsObj.containsKey(GenConstants.PARENT_MENU_ID)
&& StringUtils.isNotEmpty(paramsObj.getStr(GenConstants.PARENT_MENU_ID))) {
return paramsObj.getStr(GenConstants.PARENT_MENU_ID);
}
return DEFAULT_PARENT_MENU_ID;
}
/**
* 获取树编码
*
* @param paramsObj 生成其他选项
* @return 树编码
*/
public static String getTreecode(Map<String, Object> paramsObj) {
if (CollUtil.isNotEmpty(paramsObj) && paramsObj.containsKey(GenConstants.TREE_CODE)) {
return StringUtils.toCamelCase(Convert.toStr(paramsObj.get(GenConstants.TREE_CODE)));
}
return StringUtils.EMPTY;
}
/**
* 获取树父编码
*
* @param paramsObj 生成其他选项
* @return 树父编码
*/
public static String getTreeParentCode(Dict paramsObj) {
if (CollUtil.isNotEmpty(paramsObj) && paramsObj.containsKey(GenConstants.TREE_PARENT_CODE)) {
return StringUtils.toCamelCase(paramsObj.getStr(GenConstants.TREE_PARENT_CODE));
}
return StringUtils.EMPTY;
}
/**
* 获取树名称
*
* @param paramsObj 生成其他选项
* @return 树名称
*/
public static String getTreeName(Dict paramsObj) {
if (CollUtil.isNotEmpty(paramsObj) && paramsObj.containsKey(GenConstants.TREE_NAME)) {
return StringUtils.toCamelCase(paramsObj.getStr(GenConstants.TREE_NAME));
}
return StringUtils.EMPTY;
}
/**
* 获取需要在哪一列上面显示展开按钮
*
* @param genTable 业务表对象
* @return 展开按钮列序号
*/
public static int getExpandColumn(GenTable genTable) {
String options = genTable.getOptions();
Dict paramsObj = JsonUtils.parseMap(options);
String treeName = paramsObj.getStr(GenConstants.TREE_NAME);
int num = 0;
for (GenTableColumn column : genTable.getColumns()) {
if (column.isList()) {
num++;
String columnName = column.getColumnName();
if (columnName.equals(treeName)) {
break;
}
}
}
return num;
}
}

View File

@ -0,0 +1,110 @@
# 专业/宿舍关联校区接口评估与需求
## 1. 当前可用能力评估
### 1.1 宿舍管理(可支撑)
- 已有校区列表接口:`GET /art/schoolCampus/list`
- 支持入参 `schoolId`,可按学校查询校区。
- 已有宿舍新增/修改接口:
- `POST /art/schoolDorm`
- `PUT /art/schoolDorm`
- 入参已包含 `campusId`,可直接做“校区下拉选择后提交关联”。
### 1.2 专业管理(当前不可完全支撑)
- 专业接口当前为:
- `GET /art/schoolMajor/list`
- `POST /art/schoolMajor`
- `PUT /art/schoolMajor`
- 现有专业模型/类型无 `campusId` 字段,仅有 `schoolId`、`collegeId` 等。
- 结论:前端可加载校区列表,但无法通过现有专业接口保存“专业 -> 校区”关联。
## 2. 专业管理所需接口改造(建议)
## 2.1 方案:在专业主模型中新增 `campusId`(单校区关联)
### 专业列表查询
- URL: `GET /art/schoolMajor/list`
- 新增查询入参:
- `campusId` `number|string` 可选
- 返回字段新增:
- `campusId` `number|string`
- `campusName` `string`(建议返回,便于表格展示)
#### 查询入参示例
```json
{
"pageNum": 1,
"pageSize": 10,
"schoolId": 1001,
"campusId": 2001,
"majorName": "视觉传达设计"
}
```
#### 查询返回 rows 单项示例
```json
{
"majorId": 3001,
"schoolId": 1001,
"campusId": 2001,
"campusName": "主校区",
"collegeId": 5001,
"majorName": "视觉传达设计"
}
```
### 专业新增
- URL: `POST /art/schoolMajor`
- 新增必填入参:
- `campusId` `number|string`
#### 新增入参示例
```json
{
"schoolId": 1001,
"campusId": 2001,
"collegeId": 5001,
"majorCode": "130502",
"majorName": "视觉传达设计",
"educationLevel": "本科",
"durationYears": 4
}
```
### 专业修改
- URL: `PUT /art/schoolMajor`
- 新增必填入参:
- `campusId` `number|string`
#### 修改入参示例
```json
{
"majorId": 3001,
"schoolId": 1001,
"campusId": 2002,
"collegeId": 5001,
"majorName": "视觉传达设计"
}
```
## 3. 统一返回格式建议
### 列表接口返回(保持现有 RuoYi 分页结构)
```json
{
"code": 200,
"msg": "操作成功",
"rows": [],
"total": 0
}
```
### 新增/修改接口返回
```json
{
"code": 200,
"msg": "操作成功",
"data": true
}
```

View File

@ -0,0 +1,59 @@
-- 修改字典数据表的 list_class 字段,将 danger 改为 error
UPDATE `sys_dict_data` SET `list_class` = 'error' WHERE `list_class` = 'danger';
-- 字典适配多语言
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_user_sex.male', `dict_type` = 'sys_user_sex' WHERE `dict_code` = 1;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_user_sex.female', `dict_type` = 'sys_user_sex' WHERE `dict_code` = 2;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_user_sex.unknown', `dict_type` = 'sys_user_sex' WHERE `dict_code` = 3;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_show_hide.show', `dict_type` = 'sys_show_hide' WHERE `dict_code` = 4;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_show_hide.hide', `dict_type` = 'sys_show_hide' WHERE `dict_code` = 5;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_normal_disable.normal', `dict_type` = 'sys_normal_disable' WHERE `dict_code` = 6;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_normal_disable.disable', `dict_type` = 'sys_normal_disable' WHERE `dict_code` = 7;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_yes_no.yes', `dict_type` = 'sys_yes_no' WHERE `dict_code` = 12;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_yes_no.no', `dict_type` = 'sys_yes_no' WHERE `dict_code` = 13;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_notice_type.notice', `dict_type` = 'sys_notice_type' WHERE `dict_code` = 14;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_notice_type.announcement', `dict_type` = 'sys_notice_type' WHERE `dict_code` = 15;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_notice_status.normal', `dict_type` = 'sys_notice_status' WHERE `dict_code` = 16;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_notice_status.close', `dict_type` = 'sys_notice_status' WHERE `dict_code` = 17;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.insert', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 18;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.update', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 19;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.delete', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 20;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.grant', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 21;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.export', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 22;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.import', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 23;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.force', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 24;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.gencode', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 25;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.clean', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 26;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_common_status.success', `dict_type` = 'sys_common_status' WHERE `dict_code` = 27;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_common_status.fail', `dict_type` = 'sys_common_status' WHERE `dict_code` = 28;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.other', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 29;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_grant_type.password', `dict_type` = 'sys_grant_type' WHERE `dict_code` = 30;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_grant_type.sms', `dict_type` = 'sys_grant_type' WHERE `dict_code` = 31;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_grant_type.email', `dict_type` = 'sys_grant_type' WHERE `dict_code` = 32;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_grant_type.miniapp', `dict_type` = 'sys_grant_type' WHERE `dict_code` = 33;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_grant_type.social', `dict_type` = 'sys_grant_type' WHERE `dict_code` = 34;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_device_type.pc', `dict_type` = 'sys_device_type' WHERE `dict_code` = 35;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_device_type.android', `dict_type` = 'sys_device_type' WHERE `dict_code` = 36;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_device_type.ios', `dict_type` = 'sys_device_type' WHERE `dict_code` = 37;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_device_type.miniapp', `dict_type` = 'sys_device_type' WHERE `dict_code` = 38;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.revoked', `dict_type` = 'wf_business_status' WHERE `dict_code` = 39;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.draft', `dict_type` = 'wf_business_status' WHERE `dict_code` = 40;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.pending', `dict_type` = 'wf_business_status' WHERE `dict_code` = 41;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.completed', `dict_type` = 'wf_business_status' WHERE `dict_code` = 42;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.cancelled', `dict_type` = 'wf_business_status' WHERE `dict_code` = 43;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.returned', `dict_type` = 'wf_business_status' WHERE `dict_code` = 44;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.terminated', `dict_type` = 'wf_business_status' WHERE `dict_code` = 45;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_form_type.custom_form', `dict_type` = 'wf_form_type' WHERE `dict_code` = 46;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_form_type.dynamic_form', `dict_type` = 'wf_form_type' WHERE `dict_code` = 47;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.revoke', `dict_type` = 'wf_task_status' WHERE `dict_code` = 48;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.pass', `dict_type` = 'wf_task_status' WHERE `dict_code` = 49;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.pending_review', `dict_type` = 'wf_task_status' WHERE `dict_code` = 50;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.cancel', `dict_type` = 'wf_task_status' WHERE `dict_code` = 51;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.return', `dict_type` = 'wf_task_status' WHERE `dict_code` = 52;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.terminate', `dict_type` = 'wf_task_status' WHERE `dict_code` = 53;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.transfer', `dict_type` = 'wf_task_status' WHERE `dict_code` = 54;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.delegate', `dict_type` = 'wf_task_status' WHERE `dict_code` = 55;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.copy', `dict_type` = 'wf_task_status' WHERE `dict_code` = 56;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.add_sign', `dict_type` = 'wf_task_status' WHERE `dict_code` = 57;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.minus_sign', `dict_type` = 'wf_task_status' WHERE `dict_code` = 58;
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.timeout', `dict_type` = 'wf_task_status' WHERE `dict_code` = 59;

43
docs/sql/sys_menu.sql Normal file
View File

@ -0,0 +1,43 @@
-- 目录类型菜单
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'carbon:cloud-service-management', `menu_name` = 'route.system' WHERE `menu_id` = 1;
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'stash:dashboard', `menu_name` = 'route.monitor' WHERE `menu_id` = 2;
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'tabler:tools', `menu_name` = 'route.tool' WHERE `menu_id` = 3;
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'material-symbols:kid-star-outline', `menu_name` = 'route.demo' WHERE `menu_id` = 5;
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'tabler:building-cog', `menu_name` = 'menu.system_tenant' WHERE `menu_id` = 6;
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'tabler:logs', `menu_name` = 'menu.system_log' WHERE `menu_id` = 108;
-- 页面类型
UPDATE `sys_menu` SET `icon` = 'ic:round-manage-accounts', `menu_name` = 'route.system_user' WHERE `menu_id` = 100;
UPDATE `sys_menu` SET `icon` = 'carbon:user-role', `menu_name` = 'route.system_role' WHERE `menu_id` = 101;
UPDATE `sys_menu` SET `icon` = 'material-symbols:route', `menu_name` = 'route.system_menu' WHERE `menu_id` = 102;
UPDATE `sys_menu` SET `icon` = 'mingcute:department-line', `menu_name` = 'route.system_dept' WHERE `menu_id` = 103;
UPDATE `sys_menu` SET `icon` = 'hugeicons:permanent-job', `menu_name` = 'route.system_post' WHERE `menu_id` = 104;
UPDATE `sys_menu` SET `icon` = 'qlementine-icons:dictionary-16', `menu_name` = 'route.system_dict' WHERE `menu_id` = 105;
UPDATE `sys_menu` SET `icon` = 'carbon:parameter', `menu_name` = 'route.system_config' WHERE `menu_id` = 106;
UPDATE `sys_menu` SET `icon` = 'solar:chat-line-outline', `menu_name` = 'route.system_notice' WHERE `menu_id` = 107;
UPDATE `sys_menu` SET `icon` = 'majesticons:status-online-line', `menu_name` = 'route.monitor_online' WHERE `menu_id` = 109;
UPDATE `sys_menu` SET `icon` = 'simple-icons:redis', `menu_name` = 'route.monitor_cache' WHERE `menu_id` = 113;
UPDATE `sys_menu` SET `icon` = 'material-symbols:code-blocks-outline', `menu_name` = 'route.tool_gen' WHERE `menu_id` = 115;
UPDATE `sys_menu` SET `icon` = 'material-symbols:attach-file', `menu_name` = 'route.system_oss' WHERE `menu_id` = 118;
UPDATE `sys_menu` SET `icon` = 'tabler:building-skyscraper', `menu_name` = 'route.system_tenant' WHERE `menu_id` = 121;
UPDATE `sys_menu` SET `icon` = 'lets-icons:package-box-alt', `menu_name` = 'route.system_tenant-package' WHERE `menu_id` = 122;
UPDATE `sys_menu` SET `icon` = 'tabler:device-imac-cog', `menu_name` = 'route.system_client' WHERE `menu_id` = 123;
UPDATE `sys_menu` SET `icon` = 'carbon:operations-record', `menu_name` = 'route.monitor_operlog' WHERE `menu_id` = 500;
UPDATE `sys_menu` SET `icon` = 'tabler:login-2', `menu_name` = 'route.monitor_logininfor' WHERE `menu_id` = 501;
UPDATE `sys_menu` SET `icon` = 'gg:debug', `menu_name` = 'route.demo_demo' WHERE `menu_id` = 1500;
UPDATE `sys_menu` SET `icon` = 'gg:debug', `menu_name` = 'route.demo_tree' WHERE `menu_id` = 1506;
UPDATE `sys_menu` SET `path` = 'oss/config', `component` = 'system/oss-config/index', `icon` = 'hugeicons:configuration-01', `menu_name` = 'route.system_oss-config' WHERE `menu_id` = 133;
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (9, 'route.about', 0, 99, 'about', 'about/index', '', 1, 1, 'C', '0', '0', '', 'fluent:book-information-24-regular', 103, 1, sysdate(), null, null, '关于页面') ON DUPLICATE KEY UPDATE `update_time` = sysdate();
-- IFrame 类型
UPDATE `sys_menu` SET `component` = 'FrameView', `query_param` = '{"url": "https://ruoyi.xlsea.cn/admin/"}', `is_frame` = 2, `icon` = 'bx:bxl-spring-boot', `menu_name` = 'menu.monitor_admin' WHERE `menu_id` = 117;
UPDATE `sys_menu` SET `component` = 'FrameView', `query_param` = '{"url": "https://preview.snailjob.opensnail.com/"}', `is_frame` = 2, `icon` = 'gridicons:scheduled', `menu_name` = 'menu.monitor_snail-job' WHERE `menu_id` = 120;
-- 外链类型
UPDATE `sys_menu` SET `menu_name` = 'RuoYi-Vue-Plus', `order_num` = 100, `path` = 'https://gitee.com/dromara/RuoYi-Vue-Plus', `component` = 'FrameView', `icon` = 'local-icon-gitee', `remark` = 'RuoYi-Vue-Plus 仓库地址' WHERE `menu_id` = 4;
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (7, 'Soybean Admin', 0, 100, 'https://github.com/soybeanjs', 'FrameView', '', 0, 0, 'M', '0', '0', '', 'mdi:github', 103, 1, sysdate(), null, null, 'Soybean Admin 仓库地址') ON DUPLICATE KEY UPDATE `update_time` = sysdate();
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (8, 'RuoYi-Plus-Soybean', 0, 100, 'https://gitee.com/xlsea/ruoyi-plus-soybean', 'FrameView', '', 0, 0, 'M', '0', '0', '', 'local-icon-gitee', 103, 1, sysdate(), null, null, 'RuoYi-Plus-Soybean 仓库地址') ON DUPLICATE KEY UPDATE `update_time` = sysdate();
-- plus-ui 需要禁用的页面
UPDATE `sys_menu` SET `status` = '1' WHERE `menu_id` IN ( '116', '130', '131', '132' );
-- 工作流需要禁用的页面
UPDATE `sys_menu` SET `status` = '1' WHERE `menu_id` IN ( '11616', '11618', '11638', '11700', '11701' );

41
docs/template/api/api.ts.vm vendored Normal file
View File

@ -0,0 +1,41 @@
import { request } from '@/service/request';
#set($responseType = "")
#if($tplCategory == "tree")
#set($responseType = "Api.${ModuleName}.${BusinessName}[]")
#else
#set($responseType = "Api.${ModuleName}.${BusinessName}List")
#end
/** 获取${functionName}列表 */
export function fetchGet${BusinessName}List (params?: Api.${ModuleName}.${BusinessName}SearchParams) {
return request<$responseType>({
url: '/${moduleName}/${businessName}/list',
method: 'get',
params
});
}
/** 新增${functionName} */
export function fetchCreate${BusinessName} (data: Api.${ModuleName}.${BusinessName}OperateParams) {
return request<boolean>({
url: '/${moduleName}/${businessName}',
method: 'post',
data
});
}
/** 修改${functionName} */
export function fetchUpdate${BusinessName} (data: Api.${ModuleName}.${BusinessName}OperateParams) {
return request<boolean>({
url: '/${moduleName}/${businessName}',
method: 'put',
data
});
}
/** 批量删除${functionName} */
export function fetchBatchDelete${BusinessName} (${pkColumn.javaField}s: CommonType.IdType[]) {
return request<boolean>({
url: `/${moduleName}/${businessName}/${${pkColumn.javaField}s.join(',')}`,
method: 'delete'
});
}

232
docs/template/index-tree.vue.vm vendored Normal file
View File

@ -0,0 +1,232 @@
<script setup lang="tsx">
import { ref } from 'vue';
import { NDivider } from 'naive-ui';
import { jsonClone } from '@sa/utils';
import { fetchBatchDelete${BusinessName}, fetchGet${BusinessName}List } from '@/service/api/${moduleName}/${business__name}';
import { useAppStore } from '@/store/modules/app';
import { useAuth } from '@/hooks/business/auth';
import { treeTransform, useNaiveTreeTable, useTableOperate } from '@/hooks/common/table';
import { useDownload } from '@/hooks/business/download';
import { $t } from '@/locales';
import ButtonIcon from '@/components/custom/button-icon.vue';
import ${BusinessName}OperateDrawer from './modules/${business__name}-operate-drawer.vue';
import ${BusinessName}Search from './modules/${business__name}-search.vue';
#if($dictList && $dictList.size() > 0)
import { useDict } from '@/hooks/business/dict';
#end
defineOptions({
name: '${BusinessName}List'
});
#if($dictList && $dictList.size() > 0)
#foreach($dict in $dictList)
useDict('${dict.type}'#if(!$dict.immediate), false#end);
#end#end
const appStore = useAppStore();
const { download } = useDownload();
const { hasAuth } = useAuth();
const searchParams = ref<Api.$ModuleName.${BusinessName}SearchParams>({
#foreach ($column in $columns)
#if($column.query)
$column.javaField: null#if($foreach.hasNext),#end
#end
#end
params: {}
});
const {
columns,
columnChecks,
data,
rows,
getData,
loading,
expandedRowKeys,
isCollapse,
expandAll,
collapseAll,
scrollX
} = useNaiveTreeTable({
keyField: '$pkColumn.javaField',
api: () => fetchGet${BusinessName}List(searchParams.value),
transform: response => treeTransform(response, { idField: '$pkColumn.javaField' }),
columns: () => [
{
type: 'selection',
align: 'center',
width: 48
},
{
key: 'index',
title: $t('common.index'),
align: 'center',
width: 64,
render: (_, index) => index + 1
},
#foreach ($column in $columns)
#if($column.list)
{
key: '$column.javaField',
title: '$column.columnComment',
align: 'center',
minWidth: 120#if($column.dictType),
render(row) {
return <DictTag value={row.$column.javaField} dictCode="$column.dictType" />;
}#end
},
#end
#end
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 130,
render: row => {
const addBtn = () => {
return (
<ButtonIcon
text
type="primary"
icon="material-symbols:add-2-rounded"
tooltipContent={$t('common.add')}
onClick={() => addInRow(row)}
/>
);
};
const editBtn = () => {
return (
<ButtonIcon
text
type="primary"
icon="material-symbols:drive-file-rename-outline-outline"
tooltipContent={$t('common.edit')}
onClick={() => edit(row.$pkColumn.javaField)}
/>
);
};
const deleteBtn = () => {
return (
<ButtonIcon
text
type="error"
icon="material-symbols:delete-outline"
tooltipContent={$t('common.delete')}
popconfirmContent={$t('common.confirmDelete')}
onPositiveClick={() => handleDelete(row.$pkColumn.javaField)}
/>
);
};
const buttons = [];
if (hasAuth('${moduleName}:${businessName}:add')) buttons.push(addBtn());
if (hasAuth('${moduleName}:${businessName}:edit')) buttons.push(editBtn());
if (hasAuth('${moduleName}:${businessName}:remove')) buttons.push(deleteBtn());
return (
<div class="flex-center gap-8px">
{buttons.map((btn, index) => (
<>
{index !== 0 && <NDivider vertical />}
{btn}
</>
))}
</div>
);
}
}
]
});
const { drawerVisible, operateType, editingData, handleAdd, handleEdit, checkedRowKeys, onBatchDeleted, onDeleted } =
useTableOperate(rows, '$pkColumn.javaField', getData);
async function handleBatchDelete() {
// request
const { error } = await fetchBatchDelete${BusinessName}(checkedRowKeys.value);
if (error) return;
onBatchDeleted();
}
async function handleDelete($pkColumn.javaField: CommonType.IdType) {
// request
const { error } = await fetchBatchDelete${BusinessName}([$pkColumn.javaField]);
if (error) return;
onDeleted();
}
function edit($pkColumn.javaField: CommonType.IdType) {
handleEdit($pkColumn.javaField);
}
function addInRow(row: Api.$ModuleName.${BusinessName}) {
editingData.value = jsonClone(row);
handleAdd();
}
function handleExport() {
download('/${moduleName}/${businessName}/export', searchParams.value, `${functionName}_#[[${new Date().getTime()}]]#.xlsx`);
}
</script>
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<${BusinessName}Search v-model:model="searchParams" @search="getData" />
<NCard title="${functionName}列表" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
<template #header-extra>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="checkedRowKeys.length === 0"
:loading="loading"
:show-add="hasAuth('${moduleName}:${businessName}:add')"
:show-delete="hasAuth('${moduleName}:${businessName}:remove')"
:show-export="false"
@add="handleAdd"
@delete="handleBatchDelete"
@export="handleExport"
@refresh="getData"
>
<template #prefix>
<NButton v-if="!isCollapse" :disabled="!data.length" size="small" @click="expandAll">
<template #icon>
<icon-quill-expand />
</template>
全部展开
</NButton>
<NButton v-if="isCollapse" :disabled="!data.length" size="small" @click="collapseAll">
<template #icon>
<icon-quill-collapse />
</template>
全部收起
</NButton>
</template>
</TableHeaderOperation>
</template>
<NDataTable
v-model:checked-row-keys="checkedRowKeys"
v-model:expanded-row-keys="expandedRowKeys"
:columns="columns"
:data="data"
size="small"
:flex-height="!appStore.isMobile"
:scroll-x="scrollX"
:loading="loading"
remote
:row-key="row => row.#foreach($column in $columns)#if($column.isPk == '1')$column.javaField#end#end"
class="sm:h-full"
/>
<${BusinessName}OperateDrawer
v-model:visible="drawerVisible"
:operate-type="operateType"
:row-data="editingData"
@submitted="getData"
/>
</NCard>
</div>
</template>
<style scoped></style>

198
docs/template/index.vue.vm vendored Normal file
View File

@ -0,0 +1,198 @@
<script setup lang="tsx">
import { ref } from 'vue';
import { NDivider } from 'naive-ui';
import { fetchBatchDelete${BusinessName}, fetchGet${BusinessName}List } from '@/service/api/${moduleName}/${business__name}';
import { useAppStore } from '@/store/modules/app';
import { useAuth } from '@/hooks/business/auth';
import { useDownload } from '@/hooks/business/download';
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table';
import { $t } from '@/locales';
import ButtonIcon from '@/components/custom/button-icon.vue';
import ${BusinessName}OperateDrawer from './modules/${business__name}-operate-drawer.vue';
import ${BusinessName}Search from './modules/${business__name}-search.vue';
#if($dictList && $dictList.size() > 0)
import { useDict } from '@/hooks/business/dict';
#end
defineOptions({
name: '${BusinessName}List'
});
#if($dictList && $dictList.size() > 0)
#foreach($dict in $dictList)
useDict('${dict.type}'#if(!$dict.immediate), false#end);
#end#end
const appStore = useAppStore();
const { download } = useDownload();
const { hasAuth } = useAuth();
const searchParams = ref<Api.$ModuleName.${BusinessName}SearchParams>({
pageNum: 1,
pageSize: 10,
#foreach ($column in $columns)
#if($column.query)
$column.javaField: null#if($foreach.hasNext),#end
#end
#end
params: {}
});
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination, scrollX } =
useNaivePaginatedTable({
api: () => fetchGet${BusinessName}List(searchParams.value),
transform: response => defaultTransform(response),
onPaginationParamsChange: params => {
searchParams.value.pageNum = params.page;
searchParams.value.pageSize = params.pageSize;
},
columns: () => [
{
type: 'selection',
align: 'center',
width: 48
},
{
key: 'index',
title: $t('common.index'),
align: 'center',
width: 64,
render: (_, index) => index + 1
},
#foreach ($column in $columns)
#if($column.list)
{
key: '$column.javaField',
title: '$column.columnComment',
align: 'center',
minWidth: 120#if($column.dictType),
render(row) {
return <DictTag value={row.$column.javaField} dictCode="$column.dictType" />;
}#end
},
#end
#end
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 130,
render: row => {
const divider = () => {
if (!hasAuth('${moduleName}:${businessName}:edit') || !hasAuth('${moduleName}:${businessName}:remove')) {
return null;
}
return <NDivider vertical />;
};
const editBtn = () => {
if (!hasAuth('${moduleName}:${businessName}:edit')) {
return null;
}
return (
<ButtonIcon
text
type="primary"
icon="material-symbols:drive-file-rename-outline-outline"
tooltipContent={$t('common.edit')}
onClick={() => edit(row.$pkColumn.javaField)}
/>
);
};
const deleteBtn = () => {
if (!hasAuth('${moduleName}:${businessName}:remove')) {
return null;
}
return (
<ButtonIcon
text
type="error"
icon="material-symbols:delete-outline"
tooltipContent={$t('common.delete')}
popconfirmContent={$t('common.confirmDelete')}
onPositiveClick={() => handleDelete(row.$pkColumn.javaField)}
/>
);
};
return (
<div class="flex-center gap-8px">
{editBtn()}
{divider()}
{deleteBtn()}
</div>
);
}
}
]
});
const { drawerVisible, operateType, editingData, handleAdd, handleEdit, checkedRowKeys, onBatchDeleted, onDeleted } =
useTableOperate(data, '$pkColumn.javaField', getData);
async function handleBatchDelete() {
// request
const { error } = await fetchBatchDelete${BusinessName}(checkedRowKeys.value);
if (error) return;
onBatchDeleted();
}
async function handleDelete($pkColumn.javaField: CommonType.IdType) {
// request
const { error } = await fetchBatchDelete${BusinessName}([$pkColumn.javaField]);
if (error) return;
onDeleted();
}
function edit($pkColumn.javaField: CommonType.IdType) {
handleEdit($pkColumn.javaField);
}
function handleExport() {
download('/${moduleName}/${businessName}/export', searchParams.value, `${functionName}_#[[${new Date().getTime()}]]#.xlsx`);
}
</script>
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<${BusinessName}Search v-model:model="searchParams" @search="getDataByPage" />
<NCard title="${functionName}列表" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
<template #header-extra>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="checkedRowKeys.length === 0"
:loading="loading"
:show-add="hasAuth('${moduleName}:${businessName}:add')"
:show-delete="hasAuth('${moduleName}:${businessName}:remove')"
:show-export="hasAuth('${moduleName}:${businessName}:export')"
@add="handleAdd"
@delete="handleBatchDelete"
@export="handleExport"
@refresh="getData"
/>
</template>
<NDataTable
v-model:checked-row-keys="checkedRowKeys"
:columns="columns"
:data="data"
size="small"
:flex-height="!appStore.isMobile"
:scroll-x="scrollX"
:loading="loading"
remote
:row-key="row => row.$pkColumn.javaField"
:pagination="mobilePagination"
class="sm:h-full"
/>
<${BusinessName}OperateDrawer
v-model:visible="drawerVisible"
:operate-type="operateType"
:row-data="editingData"
@submitted="getDataByPage"
/>
</NCard>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,257 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { jsonClone } from '@sa/utils';
import { fetchCreate${BusinessName}, fetchUpdate${BusinessName}#if($tplCategory == 'tree'), fetchGet${BusinessName}List#end } from '@/service/api/${moduleName}/${business__name}';
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
#if($tplCategory == 'tree')
import { handleTree } from '@/utils/common';
#end
import { $t } from '@/locales';
defineOptions({
name: '${BusinessName}OperateDrawer'
});
interface Props {
/** the type of operation */
operateType: NaiveUI.TableOperateType;
/** the edit row data */
rowData?: Api.$ModuleName.${BusinessName} | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
#if($tplCategory == 'tree')
const treeList = ref<Api.Demo.Tree[]>([]);
#end
const { formRef, validate, restoreValidation } = useNaiveForm();
const { createRequiredRule } = useFormRules();
const title = computed(() => {
const titles: Record<NaiveUI.TableOperateType, string> = {
add: '新增${functionName}',
edit: '编辑${functionName}'
};
return titles[props.operateType];
});
type Model = Api.$ModuleName.${BusinessName}OperateParams;
const model = ref<Model>(createDefaultModel());
function createDefaultModel(): Model {
return {
#foreach($column in $columns)
#if($column.insert() || $column.isEdit())
${column.javaField}:#if($column.javaType == 'String' || ($!column.dictType && $column.dictType != '')) ''#else null#end#if($foreach.hasNext),#end
#end
#end
};
}
type RuleKey = Extract<
keyof Model,
#foreach($column in $columns)
#if($column.required)
| '$column.javaField'#if($foreach.hasNext)#end
#end#end>;
const rules: Record<RuleKey, App.Global.FormRule> = {
#foreach($column in $columns)
#if($column.required)
$column.javaField: createRequiredRule('${column.columnComment}不能为空')#if($foreach.hasNext),#end
#end
#end
};
function handleUpdateModelWhenEdit() {
model.value = createDefaultModel();
#if($tplCategory == 'tree')
model.value.$treeParentCode = props.rowData?.$treeCode || 0;
#end
if (props.operateType === 'edit' && props.rowData) {
Object.assign(model.value, jsonClone(props.rowData));
}
}
function closeDrawer() {
visible.value = false;
}
async function handleSubmit() {
await validate();
#set($operateColumns = [])
#foreach($column in $columns)#if($column.insert || $column.edit)#set($dummy = $operateColumns.add($column))#end#end
const { #foreach($column in $operateColumns)$column.javaField#if($foreach.hasNext), #end#end } = model.value;
// request
if (props.operateType === 'add') {
#set($addFields = [])
#foreach($column in $columns)#if($column.insert)#set($dummy = $addFields.add($column.javaField))#end#end
const { error } = await fetchCreate${BusinessName}({ #foreach($field in $addFields)$field#if($foreach.hasNext), #end#end });
if (error) return;
}
if (props.operateType === 'edit') {
#set($editFields = [])
#foreach($column in $columns)#if($column.edit)#set($dummy = $editFields.add($column.javaField))#end#end
const { error } = await fetchUpdate${BusinessName}({ #foreach($field in $editFields)$field#if($foreach.hasNext), #end#end });
if (error) return;
}
window.$message?.success($t('common.updateSuccess'));
closeDrawer();
emit('submitted');
}
#if($tplCategory == 'tree')
async function getTreeList() {
const { data, error } = await fetchGet${BusinessName}List();
if (error) {
return;
}
const { tree } = handleTree(data);
treeList.value = tree;
}
#end
watch(visible, () => {
if (visible.value) {
handleUpdateModelWhenEdit();
restoreValidation();
getTreeList();
}
});
#if($tplCategory == 'tree')
const treeOptions = computed(() => {
return [
{
id: 0,
treeName: '顶级节点',
children: treeList.value
}
];
});
#end
</script>
<template>
<NDrawer v-model:show="visible" :title="title" display-directive="show" :width="800" class="max-w-90%">
<NDrawerContent :title="title" :native-scrollbar="false" closable>
<NForm ref="formRef" :model="model" :rules="rules">
#set($immediateDictList = [])
#foreach($column in $columns)
#set($field=$column.javaField)
#if(($column.insert || $column.edit) && !$column.pk)
#set($isImmediate = !$column.isList() && !$column.isQuery() && !$immediateDictList.contains($column.dictType))
<NFormItem label="$column.columnComment" path="$column.javaField">
#if($tplCategory == 'tree' && $column.javaField == $treeParentCode)
<NTreeSelect
v-model:value="model.$treeParentCode"
filterable
class="h-full"
key-field="$treeCode"
label-field="$treeName"
:options="treeOptions"
:default-expanded-keys="[0]"
/>
#elseif($column.htmlType == "textarea" || $column.htmlType == "editor")
<NInput
v-model:value="model.$column.javaField"
:rows="3"
type="textarea"
placeholder="请输入$column.columnComment"
/>
#elseif($column.htmlType == "select" && $column.dictType)
<DictSelect
v-model:value="model.$column.javaField"
placeholder="请选择$column.columnComment"
dict-code="$column.dictType"
clearable
#if($isImmediate)#set($void = $immediateDictList.add($column.dictType))
immediate
#end
/>
#elseif($column.htmlType == "select" && !$column.dictType)
<NSelect
v-model:value="model.$column.javaField"
placeholder="请选择$column.columnComment"
:options="[{ value: '0', label: '请选择字典生成' }]"
clearable
/>
#elseif($column.htmlType == "radio" && $column.dictType)
<DictRadio
v-model:value="model.$column.javaField"
placeholder="请选择$column.columnComment"
dict-code="$column.dictType"
clearable
#if($isImmediate)#set($void = $immediateDictList.add($column.dictType))
immediate
#end
/>
#elseif($column.htmlType == "radio" && !$column.dictType)
<NRadioGroup v-model:value="model.$column.javaField">
<NSpace>
<NRadio value="0" label="请选择字典生成" />
</NSpace>
</NRadioGroup>
#elseif($column.htmlType == "checkbox" && $column.dictType)
<DictCheckbox
v-model:value="model.$column.javaField"
placeholder="请选择$column.columnComment"
dict-code="$column.dictType"
clearable
#if($isImmediate)#set($void = $immediateDictList.add($column.dictType))
immediate
#end
/>
#elseif($column.htmlType == "checkbox" && $column.dictType)
<NCheckboxGroup v-model:value="model.$column.javaField">
<NSpace>
<NCheckbox value="0" label="请选择字典生成" />
</NSpace>
</NCheckboxGroup>
#elseif($column.htmlType == 'datetime')
<NDatePicker
v-model:formatted-value="model.$column.javaField"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
clearable
/>
#elseif($column.htmlType == "imageUpload")
<OssUpload v-model:value="model.$column.javaField" upload-type="image" />
#elseif($column.htmlType == "fileUpload")
<OssUpload v-model:value="model.$column.javaField" upload-type="file" />
#elseif($column.htmlType == "editor")
<TinymceEditor v-model:value="model.$column.javaField" />
#else <NInput v-model:value="model.$column.javaField" placeholder="请输入$column.columnComment" />
#end
</NFormItem>
#end
#end
</NForm>
<template #footer>
<NSpace :size="16">
<NButton @click="closeDrawer">{{ $t('common.cancel') }}</NButton>
<NButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</NButton>
</NSpace>
</template>
</NDrawerContent>
</NDrawer>
</template>
<style scoped></style>

130
docs/template/modules/search.vue.vm vendored Normal file
View File

@ -0,0 +1,130 @@
#set($ModuleName=$moduleName.substring(0, 1).toUpperCase() + $moduleName.substring(1))
<script setup lang="ts">
import { toRaw } from 'vue';
import { jsonClone } from '@sa/utils';
import { useNaiveForm } from '@/hooks/common/form';
import { $t } from '@/locales';
defineOptions({
name: '${BusinessName}Search'
});
interface Emits {
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const { formRef, validate, restoreValidation } = useNaiveForm();
#foreach ($column in $columns)
#if($column.htmlType == "datetime" && $column.queryType == "BETWEEN")
#set($AttrName=$column.javaField.substring(0,1).toUpperCase() + ${column.javaField.substring(1)})
const dateRange${AttrName} = ref<[string, string] | null>(null);
#end#end
const model = defineModel<Api.$ModuleName.${BusinessName}SearchParams>('model', { required: true });
const defaultModel = jsonClone(toRaw(model.value));
function resetModel() {
#foreach ($column in $columns)
#if($column.htmlType == "datetime" && $column.queryType == "BETWEEN")
#set($AttrName=$column.javaField.substring(0,1).toUpperCase() + ${column.javaField.substring(1)})
dateRange${AttrName}.value = null;
#end
#end
Object.assign(model.value, defaultModel);
}
async function reset() {
await restoreValidation();
resetModel();
emit('search');
}
async function search() {
await validate();
#foreach ($column in $columns)
#if($column.htmlType == "datetime" && $column.queryType == "BETWEEN")
#set($AttrName=$column.javaField.substring(0,1).toUpperCase() + ${column.javaField.substring(1)})
if (dateRange${AttrName}.value?.length) {
model.value.params!.begin${AttrName} = dateRange${AttrName}.value[0];
model.value.params!.end${AttrName} = dateRange${AttrName}.value[1];
}
#end
#end
emit('search');
}
</script>
<template>
<NCard :bordered="false" size="small" class="card-wrapper">
<NCollapse>
<NCollapseItem :title="$t('common.search')" name="$moduleName-${business__name}-search">
<NForm ref="formRef" :model="model" label-placement="left" :label-width="80">
<NGrid responsive="screen" item-responsive>
#set($immediateDictList = [])
#foreach($column in $columns)
#if($column.query)
#set($AttrName=$column.javaField.substring(0,1).toUpperCase() + ${column.javaField.substring(1)})
<NFormItemGi span="24 s:12 m:6" label="$column.columnComment" label-width="auto" path="$column.javaField" class="pr-24px">
#if($!StrUtil.contains("select, radio, checkbox", $column.htmlType) && $column.dictType)
<DictSelect
v-model:value="model.$column.javaField"
placeholder="请选择$column.columnComment"
dict-code="$column.dictType"
clearable
#if(!$column.isList() && !$immediateDictList.contains($column.dictType))#set($void = $immediateDictList.add($column.dictType))
immediate
#end
/>
#elseif($!StrUtil.contains("select, radio, checkbox", $column.htmlType))
<NSelect
v-model:value="model.$column.javaField"
placeholder="请选择$column.columnComment"
:options="[]"
clearable
/>
#elseif($column.htmlType == 'datetime' && $column.queryType != "BETWEEN")
<NDatePicker
v-model:formatted-value="model.$column.javaField"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
clearable
/>
#elseif($column.htmlType == 'datetime' && $column.queryType == "BETWEEN")
<NDatePicker
v-model:formatted-value="dateRange${AttrName}"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
clearable
/>
#else <NInput v-model:value="model.$column.javaField" placeholder="请输入$column.columnComment" />
#end
</NFormItemGi>
#end
#end
<NFormItemGi :show-feedback="false" span="24" class="pr-24px">
<NSpace class="w-full" justify="end">
<NButton @click="reset">
<template #icon>
<icon-ic-round-refresh class="text-icon" />
</template>
{{ $t('common.reset') }}
</NButton>
<NButton type="primary" ghost @click="search">
<template #icon>
<icon-ic-round-search class="text-icon" />
</template>
{{ $t('common.search') }}
</NButton>
</NSpace>
</NFormItemGi>
</NGrid>
</NForm>
</NCollapseItem>
</NCollapse>
</NCard>
</template>
<style scoped></style>

51
docs/template/typings/api.d.ts.vm vendored Normal file
View File

@ -0,0 +1,51 @@
#set($BaseEntity = ['createDept', 'createBy', 'createTime', 'updateBy', 'updateTime'])
#set($ModuleName = $moduleName.substring(0, 1).toUpperCase() + $moduleName.substring(1))
/**
* Namespace Api
*
* All backend api type
*/
declare namespace Api {
/**
* namespace ${ModuleName}
*
* backend api module: "${ModuleName}"
*/
namespace ${ModuleName} {
/** ${businessname} */
type ${BusinessName} = Common.CommonRecord<{
#foreach($column in $columns)#if(!$BaseEntity.contains($column.javaField))
/** $column.columnComment */
$column.javaField:#if($column.javaField.indexOf("id") != -1 || $column.javaField.indexOf("Id") != -1) CommonType.IdType; #elseif($column.javaType == 'Long' || $column.javaType == 'Integer' || $column.javaType == 'Double' || $column.javaType == 'Float' || $column.javaType == 'BigDecimal') number; #elseif($column.javaType == 'Boolean') boolean; #else string; #end
#end#end
}>;
/** ${businessname} search params */
type ${BusinessName}SearchParams = CommonType.RecordNullable<
Pick<
Api.${ModuleName}.${BusinessName},
#foreach($column in $columns)
#if($column.query && $column.queryType != 'BETWEEN')
| '${column.javaField}'
#end
#end
> &
Api.Common.CommonSearchParams
>;
/** ${businessname} operate params */
type ${BusinessName}OperateParams = CommonType.RecordNullable<
Pick<
Api.${ModuleName}.${BusinessName},
#foreach($column in $columns)
#if($column.insert || $column.edit)
| '${column.javaField}'
#end
#end
>
>;
/** ${businessname} list */
type ${BusinessName}List = Api.Common.PaginatingQueryRecord<${BusinessName}>;
}
}

24
eslint.config.js Normal file
View File

@ -0,0 +1,24 @@
import { defineConfig } from '@soybeanjs/eslint-config';
export default defineConfig(
{ vue: true, unocss: true },
{
rules: {
'vue/multi-word-component-names': [
'warn',
{
ignores: ['index', 'App', 'Register', '[id]', '[url]']
}
],
'vue/component-name-in-template-casing': [
'warn',
'PascalCase',
{
registeredComponentsOnly: false,
ignores: ['/^icon-/']
}
],
'unocss/order-attributify': 'off'
}
}
);

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>%VITE_APP_TITLE%</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

127
package.json Normal file
View File

@ -0,0 +1,127 @@
{
"name": "ruoyi-vue-plus",
"type": "module",
"version": "2.0.0",
"description": "结合了 RuoYi-Vue-Plus 的强大后端功能和 Soybean Admin 的现代化前端特性,为开发者提供了完整的企业管理解决方案。",
"author": {
"name": "xlsea",
"email": "m@xlsea.cn",
"url": "https://gitee.com/xlsea"
},
"license": "MIT",
"homepage": "https://docs.ruoyi.xlsea.cn",
"repository": {
"url": "https://gitee.com/xlsea/ruoyi-plus-soybean.git"
},
"bugs": {
"url": "https://gitee.com/xlsea/ruoyi-plus-soybean/issues"
},
"keywords": [
"RuoYi-Vue-Plus",
"Soybean Admin",
"Vue3 admin ",
"vue-admin-template",
"Vite7",
"TypeScript",
"naive-ui",
"naive-ui-admin",
"ant-design-vue v4",
"UnoCSS"
],
"contributors": [
{
"name": "Elio",
"email": "1983933789@qq.com",
"url": "https://gitee.com/elio-an"
}
],
"engines": {
"node": ">=20.19.0",
"pnpm": ">=10.5.0"
},
"scripts": {
"build": "vite build --mode prod",
"build:dev": "vite build --mode dev",
"build:test": "vite build --mode test",
"cleanup": "sa cleanup",
"commit": "sa git-commit",
"commit:zh": "sa git-commit -l=zh-cn",
"dev": "vite --mode dev",
"dev:prod": "vite --mode prod",
"dev:test": "vite --mode test",
"gen-route": "sa gen-route",
"lint": "eslint . --fix",
"prepare": "simple-git-hooks",
"preview": "vite preview",
"release": "sa release",
"typecheck": "vue-tsc --noEmit --skipLibCheck",
"update-pkg": "sa update-pkg"
},
"dependencies": {
"@better-scroll/core": "2.5.1",
"@iconify/vue": "5.0.0",
"@sa/axios": "workspace:*",
"@sa/color": "workspace:*",
"@sa/hooks": "workspace:*",
"@sa/materials": "workspace:*",
"@sa/utils": "workspace:*",
"@types/streamsaver": "^2.0.5",
"@umoteam/editor": "^9.0.1",
"@vueuse/core": "14.1.0",
"clipboard": "2.0.11",
"dayjs": "1.11.19",
"defu": "6.1.4",
"echarts": "6.0.0",
"highlight.js": "^11.11.1",
"jsencrypt": "^3.5.4",
"json5": "2.2.3",
"naive-ui": "2.43.2",
"nprogress": "0.2.0",
"pinia": "3.0.4",
"streamsaver": "^2.0.6",
"tailwind-merge": "3.4.0",
"vue": "3.5.26",
"vue-advanced-cropper": "^2.8.9",
"vue-draggable-plus": "0.6.0",
"vue-i18n": "11.2.7",
"vue-router": "4.6.4"
},
"devDependencies": {
"@elegant-router/vue": "0.3.8",
"@iconify/json": "2.2.417",
"@sa/scripts": "workspace:*",
"@sa/uno-preset": "workspace:*",
"@soybeanjs/eslint-config": "1.7.4",
"@types/node": "25.0.3",
"@types/nprogress": "0.2.3",
"@unocss/eslint-config": "66.5.10",
"@unocss/preset-icons": "66.5.10",
"@unocss/preset-uno": "66.5.10",
"@unocss/transformer-directives": "66.5.10",
"@unocss/transformer-variant-group": "66.5.10",
"@unocss/vite": "66.5.10",
"@vitejs/plugin-vue": "6.0.3",
"@vitejs/plugin-vue-jsx": "5.1.2",
"consola": "3.4.2",
"eslint": "9.39.2",
"eslint-plugin-vue": "10.6.2",
"kolorist": "1.8.0",
"sass": "1.97.1",
"simple-git-hooks": "2.13.1",
"tsx": "4.21.0",
"typescript": "5.9.3",
"unplugin-icons": "22.5.0",
"unplugin-vue-components": "30.0.0",
"vite": "7.3.0",
"vite-plugin-progress": "0.0.7",
"vite-plugin-svg-icons": "2.0.1",
"vite-plugin-vue-devtools": "8.0.5",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.2.1"
},
"simple-git-hooks": {
"commit-msg": "pnpm sa git-commit-verify",
"pre-commit": "pnpm typecheck && pnpm lint && git diff --exit-code"
},
"website": "https://ruoyi.xlsea.cn"
}

View File

@ -0,0 +1,20 @@
{
"name": "@sa/alova",
"version": "2.0.2",
"exports": {
".": "./src/index.ts",
"./fetch": "./src/fetch.ts",
"./client": "./src/client.ts",
"./mock": "./src/mock.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"dependencies": {
"@alova/mock": "2.0.18",
"@sa/utils": "workspace:*",
"alova": "3.4.1"
}
}

View File

@ -0,0 +1 @@
export * from 'alova/client';

View File

@ -0,0 +1,2 @@
/** the backend error code key */
export const BACKEND_ERROR_CODE = 'BACKEND_ERROR';

View File

@ -0,0 +1,2 @@
import adapterFetch from 'alova/fetch';
export default adapterFetch;

View File

@ -0,0 +1,77 @@
import { createAlova } from 'alova';
import type { AlovaDefaultCacheAdapter, AlovaGenerics, AlovaGlobalCacheAdapter, AlovaRequestAdapter } from 'alova';
import VueHook from 'alova/vue';
import type { VueHookType } from 'alova/vue';
import adapterFetch from 'alova/fetch';
import { createServerTokenAuthentication } from 'alova/client';
import type { FetchRequestInit } from 'alova/fetch';
import { BACKEND_ERROR_CODE } from './constant';
import type { CustomAlovaConfig, RequestOptions } from './type';
export const createAlovaRequest = <
RequestConfig = FetchRequestInit,
ResponseType = Response,
ResponseHeader = Headers,
L1Cache extends AlovaGlobalCacheAdapter = AlovaDefaultCacheAdapter,
L2Cache extends AlovaGlobalCacheAdapter = AlovaDefaultCacheAdapter
>(
customConfig: CustomAlovaConfig<
AlovaGenerics<any, any, RequestConfig, ResponseType, ResponseHeader, L1Cache, L2Cache, any>
>,
options: RequestOptions<AlovaGenerics<any, any, RequestConfig, ResponseType, ResponseHeader, L1Cache, L2Cache, any>>
) => {
const { tokenRefresher } = options;
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<
VueHookType,
AlovaRequestAdapter<RequestConfig, ResponseType, ResponseHeader>
>({
refreshTokenOnSuccess: {
isExpired: (response, method) => tokenRefresher?.isExpired(response, method) || false,
handler: async (response, method) => tokenRefresher?.handler(response, method)
},
refreshTokenOnError: {
isExpired: (response, method) => tokenRefresher?.isExpired(response, method) || false,
handler: async (response, method) => tokenRefresher?.handler(response, method)
}
});
const instance = createAlova({
...customConfig,
timeout: customConfig.timeout ?? 10 * 1000,
requestAdapter: (customConfig.requestAdapter as any) ?? adapterFetch(),
statesHook: VueHook,
beforeRequest: onAuthRequired(options.onRequest as any),
responded: onResponseRefreshToken({
onSuccess: async (response, method) => {
// check if http status is success
let error: any = null;
let transformedData: any = null;
try {
if (await options.isBackendSuccess(response)) {
transformedData = await options.transformBackendResponse(response);
} else {
error = new Error('the backend request error');
error.code = BACKEND_ERROR_CODE;
}
} catch (err) {
error = err;
}
if (error) {
await options.onError?.(error, response, method);
throw error;
}
return transformedData;
},
onComplete: options.onComplete,
onError: (error, method) => options.onError?.(error, null, method)
})
});
return instance;
};
export { BACKEND_ERROR_CODE };
export type * from './type';
export type * from 'alova';

View File

@ -0,0 +1 @@
export * from '@alova/mock';

View File

@ -0,0 +1,52 @@
import type { AlovaGenerics, AlovaOptions, AlovaRequestAdapter, Method, ResponseCompleteHandler } from 'alova';
export type CustomAlovaConfig<AG extends AlovaGenerics> = Omit<
AlovaOptions<AG>,
'statesHook' | 'beforeRequest' | 'responded' | 'requestAdapter'
> & {
/** request adapter. all request of alova will be sent by it. */
requestAdapter?: AlovaRequestAdapter<AG['RequestConfig'], AG['Response'], AG['ResponseHeader']>;
};
export interface RequestOptions<AG extends AlovaGenerics> {
/**
* The hook before request
*
* For example: You can add header token in this hook
*
* @param method alova Method Instance
*/
onRequest?: AlovaOptions<AG>['beforeRequest'];
/**
* The hook to check backend response is success or not
*
* @param response alova response
*/
isBackendSuccess: (response: AG['Response']) => Promise<boolean>;
/** The config to refresh token */
tokenRefresher?: {
/** detect the token is expired */
isExpired(response: AG['Response'], Method: Method<AG>): Promise<boolean> | boolean;
/** refresh token handler */
handler(response: AG['Response'], Method: Method<AG>): Promise<void>;
};
/** The hook after backend request complete */
onComplete?: ResponseCompleteHandler<AG>;
/**
* The hook to handle error
*
* For example: You can show error message in this hook
*
* @param error
*/
onError?: (error: any, response: AG['Response'] | null, methodInstance: Method<AG>) => any | Promise<any>;
/**
* transform backend response when the responseType is json
*
* @param response alova response
*/
transformBackendResponse: (response: AG['Response']) => any;
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,21 @@
{
"name": "@sa/axios",
"version": "2.0.2",
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"dependencies": {
"@sa/utils": "workspace:*",
"axios": "1.13.2",
"axios-retry": "4.5.0",
"qs": "6.14.0"
},
"devDependencies": {
"@types/qs": "6.14.0"
}
}

View File

@ -0,0 +1,8 @@
/** request id key */
export const REQUEST_ID_KEY = 'X-Request-Id';
/** the backend error code key */
export const BACKEND_ERROR_CODE = 'BACKEND_ERROR';
/** the request canceled code */
export const REQUEST_CANCELED_CODE = 'ERR_CANCELED';

179
packages/axios/src/index.ts Normal file
View File

@ -0,0 +1,179 @@
import axios, { AxiosError } from 'axios';
import type { AxiosResponse, CreateAxiosDefaults, InternalAxiosRequestConfig } from 'axios';
import axiosRetry from 'axios-retry';
import { nanoid } from '@sa/utils';
import { createAxiosConfig, createDefaultOptions, createRetryOptions } from './options';
import { transformResponse } from './shared';
import { BACKEND_ERROR_CODE, REQUEST_ID_KEY } from './constant';
import type {
CustomAxiosRequestConfig,
FlatRequestInstance,
MappedType,
RequestInstance,
RequestOption,
ResponseType
} from './type';
function createCommonRequest<
ResponseData,
ApiData = ResponseData,
State extends Record<string, unknown> = Record<string, unknown>
>(axiosConfig?: CreateAxiosDefaults, options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
const opts = createDefaultOptions<ResponseData, ApiData, State>(options);
const axiosConf = createAxiosConfig(axiosConfig);
const instance = axios.create(axiosConf);
const abortControllerMap = new Map<string, AbortController>();
// config axios retry
const retryOptions = createRetryOptions(axiosConf);
axiosRetry(instance, retryOptions);
instance.interceptors.request.use(conf => {
const config: InternalAxiosRequestConfig = { ...conf };
// set request id
const requestId = nanoid();
config.headers.set(REQUEST_ID_KEY, requestId);
// config abort controller
if (!config.signal) {
const abortController = new AbortController();
config.signal = abortController.signal;
abortControllerMap.set(requestId, abortController);
}
// handle config by hook
const handledConfig = opts.onRequest?.(config) || config;
return handledConfig;
});
instance.interceptors.response.use(
async response => {
const responseType: ResponseType = (response.config?.responseType as ResponseType) || 'json';
await transformResponse(response);
if (responseType !== 'json' || opts.isBackendSuccess(response)) {
return Promise.resolve(response);
}
const fail = await opts.onBackendFail(response, instance);
if (fail) {
return fail;
}
const backendError = new AxiosError<ResponseData>(
'the backend request error',
BACKEND_ERROR_CODE,
response.config,
response.request,
response
);
await opts.onError(backendError);
return Promise.reject(backendError);
},
async (error: AxiosError<ResponseData>) => {
await opts.onError(error);
return Promise.reject(error);
}
);
function cancelAllRequest() {
abortControllerMap.forEach(abortController => {
abortController.abort();
});
abortControllerMap.clear();
}
return {
instance,
opts,
cancelAllRequest
};
}
/**
* create a request instance
*
* @param axiosConfig axios config
* @param options request options
*/
export function createRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData, ApiData, State>>
) {
const { instance, opts, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(axiosConfig, options);
const request: RequestInstance<ApiData, State> = async function request<
T extends ApiData = ApiData,
R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
const response: AxiosResponse<ResponseData> = await instance(config);
const responseType = response.config?.responseType || 'json';
if (responseType === 'json') {
return opts.transform(response);
}
return response.data as MappedType<R, T>;
} as RequestInstance<ApiData, State>;
request.cancelAllRequest = cancelAllRequest;
request.state = {} as State;
return request;
}
/**
* create a flat request instance
*
* The response data is a flat object: { data: any, error: AxiosError }
*
* @param axiosConfig axios config
* @param options request options
*/
export function createFlatRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData, ApiData, State>>
) {
const { instance, opts, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(axiosConfig, options);
const flatRequest: FlatRequestInstance<ResponseData, ApiData, State> = async function flatRequest<
T extends ApiData = ApiData,
R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
try {
const response: AxiosResponse<ResponseData> = await instance(config);
const responseType = response.config?.responseType || 'json';
if (responseType === 'json') {
const data = await opts.transform(response);
return { data, error: null, response };
}
return { data: response.data as MappedType<R, T>, error: null, response };
} catch (error) {
return { data: null, error, response: (error as AxiosError<ResponseData>).response };
}
} as FlatRequestInstance<ResponseData, ApiData, State>;
flatRequest.cancelAllRequest = cancelAllRequest;
flatRequest.state = {
...opts.defaultState
} as State;
return flatRequest;
}
export { BACKEND_ERROR_CODE, REQUEST_ID_KEY };
export type * from './type';
export type { CreateAxiosDefaults, AxiosError };

View File

@ -0,0 +1,60 @@
import type { CreateAxiosDefaults } from 'axios';
import type { IAxiosRetryConfig } from 'axios-retry';
import { stringify } from 'qs';
import { isHttpSuccess } from './shared';
import type { RequestOption } from './type';
export function createDefaultOptions<
ResponseData,
ApiData = ResponseData,
State extends Record<string, unknown> = Record<string, unknown>
>(options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
const opts: RequestOption<ResponseData, ApiData, State> = {
defaultState: {} as State,
transform: async response => response.data as unknown as ApiData,
transformBackendResponse: async response => response.data as unknown as ApiData,
onRequest: async config => config,
isBackendSuccess: _response => true,
onBackendFail: async () => {},
onError: async () => {}
};
if (options?.transform) {
opts.transform = options.transform;
} else {
opts.transform = options?.transformBackendResponse || opts.transform;
}
Object.assign(opts, options);
return opts;
}
export function createRetryOptions(config?: Partial<CreateAxiosDefaults>) {
const retryConfig: IAxiosRetryConfig = {
retries: 0
};
Object.assign(retryConfig, config);
return retryConfig;
}
export function createAxiosConfig(config?: Partial<CreateAxiosDefaults>) {
const TEN_SECONDS = 10 * 1000;
const axiosConfig: CreateAxiosDefaults = {
timeout: TEN_SECONDS,
headers: {
'Content-Type': 'application/json'
},
validateStatus: isHttpSuccess,
paramsSerializer: params => {
return stringify(params);
}
};
Object.assign(axiosConfig, config);
return axiosConfig;
}

View File

@ -0,0 +1,79 @@
import type { AxiosHeaderValue, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import type { ResponseType } from './type';
export function getContentType(config: InternalAxiosRequestConfig) {
const contentType: AxiosHeaderValue = config.headers?.['Content-Type'] || 'application/json';
return contentType;
}
/**
* check if http status is success
*
* @param status
*/
export function isHttpSuccess(status: number) {
const isSuccessCode = status >= 200 && status < 300;
return isSuccessCode || status === 304;
}
/**
* is response json
*
* @param response axios response
*/
export function isResponseJson(response: AxiosResponse) {
const { responseType } = response.config;
return responseType === 'json' || responseType === undefined;
}
export async function transformResponse(response: AxiosResponse) {
const responseType: ResponseType = (response.config?.responseType as ResponseType) || 'json';
if (responseType === 'json') return;
const isJson = response.headers['content-type']?.includes('application/json');
if (!isJson) return;
if (responseType === 'blob') {
await transformBlobToJson(response);
}
if (responseType === 'arrayBuffer') {
await transformArrayBufferToJson(response);
}
}
export async function transformBlobToJson(response: AxiosResponse) {
try {
let data = response.data;
if (typeof data === 'string') {
data = JSON.parse(data);
}
if (Object.prototype.toString.call(data) === '[object Blob]') {
const json = await data.text();
data = JSON.parse(json);
}
response.data = data;
} catch {}
}
export async function transformArrayBufferToJson(response: AxiosResponse) {
try {
let data = response.data;
if (typeof data === 'string') {
data = JSON.parse(data);
}
if (Object.prototype.toString.call(data) === '[object ArrayBuffer]') {
const json = new TextDecoder().decode(data);
data = JSON.parse(json);
}
response.data = data;
} catch {}
}

130
packages/axios/src/type.ts Normal file
View File

@ -0,0 +1,130 @@
import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
export type ContentType =
| 'text/html'
| 'text/plain'
| 'multipart/form-data'
| 'application/json'
| 'application/x-www-form-urlencoded'
| 'application/octet-stream';
export type ResponseTransform<Input = any, Output = any> = (input: Input) => Output | Promise<Output>;
export interface RequestOption<
ResponseData,
ApiData = ResponseData,
State extends Record<string, unknown> = Record<string, unknown>
> {
/**
* The default state
*/
defaultState?: State;
/**
* transform the response data to the api data
*
* @param response Axios response
*/
transform: ResponseTransform<AxiosResponse<ResponseData>, ApiData>;
/**
* transform the response data to the api data
*
* @deprecated use `transform` instead, will be removed in the next major version v3
* @param response Axios response
*/
transformBackendResponse: ResponseTransform<AxiosResponse<ResponseData>, ApiData>;
/**
* The hook before request
*
* For example: You can add header token in this hook
*
* @param config Axios config
*/
onRequest: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>;
/**
* The hook to check backend response is success or not
*
* @param response Axios response
*/
isBackendSuccess: (response: AxiosResponse<ResponseData>) => boolean;
/**
* The hook after backend request fail
*
* For example: You can handle the expired token in this hook
*
* @param response Axios response
* @param instance Axios instance
*/
onBackendFail: (
response: AxiosResponse<ResponseData>,
instance: AxiosInstance
) => Promise<AxiosResponse | null> | Promise<void>;
/**
* The hook to handle error
*
* For example: You can show error message in this hook
*
* @param error
*/
onError: (error: AxiosError<ResponseData>) => void | Promise<void>;
}
interface ResponseMap {
blob: Blob;
text: string;
arrayBuffer: ArrayBuffer;
stream: ReadableStream<Uint8Array>;
document: Document;
}
export type ResponseType = keyof ResponseMap | 'json';
export type MappedType<R extends ResponseType, JsonType = any> = R extends keyof ResponseMap
? ResponseMap[R]
: JsonType;
export type CustomAxiosRequestConfig<R extends ResponseType = 'json'> = Omit<AxiosRequestConfig, 'responseType'> & {
responseType?: R;
};
export interface RequestInstanceCommon<State extends Record<string, unknown>> {
/**
* cancel all request
*
* if the request provide abort controller sign from config, it will not collect in the abort controller map
*/
cancelAllRequest: () => void;
/** you can set custom state in the request instance */
state: State;
}
/** The request instance */
export interface RequestInstance<ApiData, State extends Record<string, unknown>> extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R>
): Promise<MappedType<R, T>>;
}
export type FlatResponseSuccessData<ResponseData, ApiData> = {
data: ApiData;
error: null;
response: AxiosResponse<ResponseData>;
};
export type FlatResponseFailData<ResponseData> = {
data: null;
error: AxiosError<ResponseData>;
response: AxiosResponse<ResponseData>;
};
export type FlatResponseData<ResponseData, ApiData> =
| FlatResponseSuccessData<ResponseData, ApiData>
| FlatResponseFailData<ResponseData>;
export interface FlatRequestInstance<
ResponseData,
ApiData,
State extends Record<string, unknown>
> extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R>
): Promise<FlatResponseData<ResponseData, MappedType<R, T>>>;
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,16 @@
{
"name": "@sa/color",
"version": "2.0.2",
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"dependencies": {
"@sa/utils": "workspace:*",
"colord": "2.9.3"
}
}

View File

@ -0,0 +1,2 @@
export * from './name';
export * from './palette';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,356 @@
import type { ColorPaletteFamily } from '../types';
export const colorPalettes: ColorPaletteFamily[] = [
{
name: 'Slate',
palettes: [
{ hex: '#f8fafc', number: 50 },
{ hex: '#f1f5f9', number: 100 },
{ hex: '#e2e8f0', number: 200 },
{ hex: '#cbd5e1', number: 300 },
{ hex: '#94a3b8', number: 400 },
{ hex: '#64748b', number: 500 },
{ hex: '#475569', number: 600 },
{ hex: '#334155', number: 700 },
{ hex: '#1e293b', number: 800 },
{ hex: '#0f172a', number: 900 },
{ hex: '#020617', number: 950 }
]
},
{
name: 'Gray',
palettes: [
{ hex: '#f9fafb', number: 50 },
{ hex: '#f3f4f6', number: 100 },
{ hex: '#e5e7eb', number: 200 },
{ hex: '#d1d5db', number: 300 },
{ hex: '#9ca3af', number: 400 },
{ hex: '#6b7280', number: 500 },
{ hex: '#4b5563', number: 600 },
{ hex: '#374151', number: 700 },
{ hex: '#1f2937', number: 800 },
{ hex: '#111827', number: 900 },
{ hex: '#030712', number: 950 }
]
},
{
name: 'Zinc',
palettes: [
{ hex: '#fafafa', number: 50 },
{ hex: '#f4f4f5', number: 100 },
{ hex: '#e4e4e7', number: 200 },
{ hex: '#d4d4d8', number: 300 },
{ hex: '#a1a1aa', number: 400 },
{ hex: '#71717a', number: 500 },
{ hex: '#52525b', number: 600 },
{ hex: '#3f3f46', number: 700 },
{ hex: '#27272a', number: 800 },
{ hex: '#18181b', number: 900 },
{ hex: '#09090b', number: 950 }
]
},
{
name: 'Neutral',
palettes: [
{ hex: '#fafafa', number: 50 },
{ hex: '#f5f5f5', number: 100 },
{ hex: '#e5e5e5', number: 200 },
{ hex: '#d4d4d4', number: 300 },
{ hex: '#a3a3a3', number: 400 },
{ hex: '#737373', number: 500 },
{ hex: '#525252', number: 600 },
{ hex: '#404040', number: 700 },
{ hex: '#262626', number: 800 },
{ hex: '#171717', number: 900 },
{ hex: '#0a0a0a', number: 950 }
]
},
{
name: 'Stone',
palettes: [
{ hex: '#fafaf9', number: 50 },
{ hex: '#f5f5f4', number: 100 },
{ hex: '#e7e5e4', number: 200 },
{ hex: '#d6d3d1', number: 300 },
{ hex: '#a8a29e', number: 400 },
{ hex: '#78716c', number: 500 },
{ hex: '#57534e', number: 600 },
{ hex: '#44403c', number: 700 },
{ hex: '#292524', number: 800 },
{ hex: '#1c1917', number: 900 },
{ hex: '#0c0a09', number: 950 }
]
},
{
name: 'Red',
palettes: [
{ hex: '#fef2f2', number: 50 },
{ hex: '#fee2e2', number: 100 },
{ hex: '#fecaca', number: 200 },
{ hex: '#fca5a5', number: 300 },
{ hex: '#f87171', number: 400 },
{ hex: '#ef4444', number: 500 },
{ hex: '#dc2626', number: 600 },
{ hex: '#b91c1c', number: 700 },
{ hex: '#991b1b', number: 800 },
{ hex: '#7f1d1d', number: 900 },
{ hex: '#450a0a', number: 950 }
]
},
{
name: 'Orange',
palettes: [
{ hex: '#fff7ed', number: 50 },
{ hex: '#ffedd5', number: 100 },
{ hex: '#fed7aa', number: 200 },
{ hex: '#fdba74', number: 300 },
{ hex: '#fb923c', number: 400 },
{ hex: '#f97316', number: 500 },
{ hex: '#ea580c', number: 600 },
{ hex: '#c2410c', number: 700 },
{ hex: '#9a3412', number: 800 },
{ hex: '#7c2d12', number: 900 },
{ hex: '#431407', number: 950 }
]
},
{
name: 'Amber',
palettes: [
{ hex: '#fffbeb', number: 50 },
{ hex: '#fef3c7', number: 100 },
{ hex: '#fde68a', number: 200 },
{ hex: '#fcd34d', number: 300 },
{ hex: '#fbbf24', number: 400 },
{ hex: '#f59e0b', number: 500 },
{ hex: '#d97706', number: 600 },
{ hex: '#b45309', number: 700 },
{ hex: '#92400e', number: 800 },
{ hex: '#78350f', number: 900 },
{ hex: '#451a03', number: 950 }
]
},
{
name: 'Yellow',
palettes: [
{ hex: '#fefce8', number: 50 },
{ hex: '#fef9c3', number: 100 },
{ hex: '#fef08a', number: 200 },
{ hex: '#fde047', number: 300 },
{ hex: '#facc15', number: 400 },
{ hex: '#eab308', number: 500 },
{ hex: '#ca8a04', number: 600 },
{ hex: '#a16207', number: 700 },
{ hex: '#854d0e', number: 800 },
{ hex: '#713f12', number: 900 },
{ hex: '#422006', number: 950 }
]
},
{
name: 'Lime',
palettes: [
{ hex: '#f7fee7', number: 50 },
{ hex: '#ecfccb', number: 100 },
{ hex: '#d9f99d', number: 200 },
{ hex: '#bef264', number: 300 },
{ hex: '#a3e635', number: 400 },
{ hex: '#84cc16', number: 500 },
{ hex: '#65a30d', number: 600 },
{ hex: '#4d7c0f', number: 700 },
{ hex: '#3f6212', number: 800 },
{ hex: '#365314', number: 900 },
{ hex: '#1a2e05', number: 950 }
]
},
{
name: 'Green',
palettes: [
{ hex: '#f0fdf4', number: 50 },
{ hex: '#dcfce7', number: 100 },
{ hex: '#bbf7d0', number: 200 },
{ hex: '#86efac', number: 300 },
{ hex: '#4ade80', number: 400 },
{ hex: '#22c55e', number: 500 },
{ hex: '#16a34a', number: 600 },
{ hex: '#15803d', number: 700 },
{ hex: '#166534', number: 800 },
{ hex: '#14532d', number: 900 },
{ hex: '#052e16', number: 950 }
]
},
{
name: 'Emerald',
palettes: [
{ hex: '#ecfdf5', number: 50 },
{ hex: '#d1fae5', number: 100 },
{ hex: '#a7f3d0', number: 200 },
{ hex: '#6ee7b7', number: 300 },
{ hex: '#34d399', number: 400 },
{ hex: '#10b981', number: 500 },
{ hex: '#059669', number: 600 },
{ hex: '#047857', number: 700 },
{ hex: '#065f46', number: 800 },
{ hex: '#064e3b', number: 900 },
{ hex: '#022c22', number: 950 }
]
},
{
name: 'Teal',
palettes: [
{ hex: '#f0fdfa', number: 50 },
{ hex: '#ccfbf1', number: 100 },
{ hex: '#99f6e4', number: 200 },
{ hex: '#5eead4', number: 300 },
{ hex: '#2dd4bf', number: 400 },
{ hex: '#14b8a6', number: 500 },
{ hex: '#0d9488', number: 600 },
{ hex: '#0f766e', number: 700 },
{ hex: '#115e59', number: 800 },
{ hex: '#134e4a', number: 900 },
{ hex: '#042f2e', number: 950 }
]
},
{
name: 'Cyan',
palettes: [
{ hex: '#ecfeff', number: 50 },
{ hex: '#cffafe', number: 100 },
{ hex: '#a5f3fc', number: 200 },
{ hex: '#67e8f9', number: 300 },
{ hex: '#22d3ee', number: 400 },
{ hex: '#06b6d4', number: 500 },
{ hex: '#0891b2', number: 600 },
{ hex: '#0e7490', number: 700 },
{ hex: '#155e75', number: 800 },
{ hex: '#164e63', number: 900 },
{ hex: '#083344', number: 950 }
]
},
{
name: 'Sky',
palettes: [
{ hex: '#f0f9ff', number: 50 },
{ hex: '#e0f2fe', number: 100 },
{ hex: '#bae6fd', number: 200 },
{ hex: '#7dd3fc', number: 300 },
{ hex: '#38bdf8', number: 400 },
{ hex: '#0ea5e9', number: 500 },
{ hex: '#0284c7', number: 600 },
{ hex: '#0369a1', number: 700 },
{ hex: '#075985', number: 800 },
{ hex: '#0c4a6e', number: 900 },
{ hex: '#082f49', number: 950 }
]
},
{
name: 'Blue',
palettes: [
{ hex: '#eff6ff', number: 50 },
{ hex: '#dbeafe', number: 100 },
{ hex: '#bfdbfe', number: 200 },
{ hex: '#93c5fd', number: 300 },
{ hex: '#60a5fa', number: 400 },
{ hex: '#3b82f6', number: 500 },
{ hex: '#2563eb', number: 600 },
{ hex: '#1d4ed8', number: 700 },
{ hex: '#1e40af', number: 800 },
{ hex: '#1e3a8a', number: 900 },
{ hex: '#172554', number: 950 }
]
},
{
name: 'Indigo',
palettes: [
{ hex: '#eef2ff', number: 50 },
{ hex: '#e0e7ff', number: 100 },
{ hex: '#c7d2fe', number: 200 },
{ hex: '#a5b4fc', number: 300 },
{ hex: '#818cf8', number: 400 },
{ hex: '#6366f1', number: 500 },
{ hex: '#4f46e5', number: 600 },
{ hex: '#4338ca', number: 700 },
{ hex: '#3730a3', number: 800 },
{ hex: '#312e81', number: 900 },
{ hex: '#1e1b4b', number: 950 }
]
},
{
name: 'Violet',
palettes: [
{ hex: '#f5f3ff', number: 50 },
{ hex: '#ede9fe', number: 100 },
{ hex: '#ddd6fe', number: 200 },
{ hex: '#c4b5fd', number: 300 },
{ hex: '#a78bfa', number: 400 },
{ hex: '#8b5cf6', number: 500 },
{ hex: '#7c3aed', number: 600 },
{ hex: '#6d28d9', number: 700 },
{ hex: '#5b21b6', number: 800 },
{ hex: '#4c1d95', number: 900 },
{ hex: '#2e1065', number: 950 }
]
},
{
name: 'Purple',
palettes: [
{ hex: '#faf5ff', number: 50 },
{ hex: '#f3e8ff', number: 100 },
{ hex: '#e9d5ff', number: 200 },
{ hex: '#d8b4fe', number: 300 },
{ hex: '#c084fc', number: 400 },
{ hex: '#a855f7', number: 500 },
{ hex: '#9333ea', number: 600 },
{ hex: '#7e22ce', number: 700 },
{ hex: '#6b21a8', number: 800 },
{ hex: '#581c87', number: 900 },
{ hex: '#3b0764', number: 950 }
]
},
{
name: 'Fuchsia',
palettes: [
{ hex: '#fdf4ff', number: 50 },
{ hex: '#fae8ff', number: 100 },
{ hex: '#f5d0fe', number: 200 },
{ hex: '#f0abfc', number: 300 },
{ hex: '#e879f9', number: 400 },
{ hex: '#d946ef', number: 500 },
{ hex: '#c026d3', number: 600 },
{ hex: '#a21caf', number: 700 },
{ hex: '#86198f', number: 800 },
{ hex: '#701a75', number: 900 },
{ hex: '#4a044e', number: 950 }
]
},
{
name: 'Pink',
palettes: [
{ hex: '#fdf2f8', number: 50 },
{ hex: '#fce7f3', number: 100 },
{ hex: '#fbcfe8', number: 200 },
{ hex: '#f9a8d4', number: 300 },
{ hex: '#f472b6', number: 400 },
{ hex: '#ec4899', number: 500 },
{ hex: '#db2777', number: 600 },
{ hex: '#be185d', number: 700 },
{ hex: '#9d174d', number: 800 },
{ hex: '#831843', number: 900 },
{ hex: '#500724', number: 950 }
]
},
{
name: 'Rose',
palettes: [
{ hex: '#fff1f2', number: 50 },
{ hex: '#ffe4e6', number: 100 },
{ hex: '#fecdd3', number: 200 },
{ hex: '#fda4af', number: 300 },
{ hex: '#fb7185', number: 400 },
{ hex: '#f43f5e', number: 500 },
{ hex: '#e11d48', number: 600 },
{ hex: '#be123c', number: 700 },
{ hex: '#9f1239', number: 800 },
{ hex: '#881337', number: 900 },
{ hex: '#4c0519', number: 950 }
]
}
];

View File

@ -0,0 +1,7 @@
import { colorPalettes } from './constant';
export * from './palette';
export * from './shared';
export { colorPalettes };
export * from './types';

View File

@ -0,0 +1,176 @@
import type { AnyColor, HsvColor } from 'colord';
import { getHex, getHsv, isValidColor, mixColor } from '../shared';
import type { ColorIndex } from '../types';
/** Hue step */
const hueStep = 2;
/** Saturation step, light color part */
const saturationStep = 16;
/** Saturation step, dark color part */
const saturationStep2 = 5;
/** Brightness step, light color part */
const brightnessStep1 = 5;
/** Brightness step, dark color part */
const brightnessStep2 = 15;
/** Light color count, main color up */
const lightColorCount = 5;
/** Dark color count, main color down */
const darkColorCount = 4;
/**
* Get AntD palette color by index
*
* @param color - Color
* @param index - The color index of color palette (the main color index is 6)
* @returns Hex color
*/
export function getAntDPaletteColorByIndex(color: AnyColor, index: ColorIndex): string {
if (!isValidColor(color)) {
throw new Error('invalid input color value');
}
if (index === 6) {
return getHex(color);
}
const isLight = index < 6;
const hsv = getHsv(color);
const i = isLight ? lightColorCount + 1 - index : index - lightColorCount - 1;
const newHsv: HsvColor = {
h: getHue(hsv, i, isLight),
s: getSaturation(hsv, i, isLight),
v: getValue(hsv, i, isLight)
};
return getHex(newHsv);
}
/** Map of dark color index and opacity */
const darkColorMap = [
{ index: 7, opacity: 0.15 },
{ index: 6, opacity: 0.25 },
{ index: 5, opacity: 0.3 },
{ index: 5, opacity: 0.45 },
{ index: 5, opacity: 0.65 },
{ index: 5, opacity: 0.85 },
{ index: 5, opacity: 0.9 },
{ index: 4, opacity: 0.93 },
{ index: 3, opacity: 0.95 },
{ index: 2, opacity: 0.97 },
{ index: 1, opacity: 0.98 }
];
/**
* Get AntD color palette
*
* @param color - Color
* @param darkTheme - Dark theme
* @param darkThemeMixColor - Dark theme mix color (default: #141414)
*/
export function getAntDColorPalette(color: AnyColor, darkTheme = false, darkThemeMixColor = '#141414'): string[] {
const indexes: ColorIndex[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const patterns = indexes.map(index => getAntDPaletteColorByIndex(color, index));
if (darkTheme) {
const darkPatterns = darkColorMap.map(({ index, opacity }) => {
const darkColor = mixColor(darkThemeMixColor, patterns[index], opacity);
return darkColor;
});
return darkPatterns.map(item => getHex(item));
}
return patterns;
}
/**
* Get hue
*
* @param hsv - Hsv format color
* @param i - The relative distance from 6
* @param isLight - Is light color
*/
function getHue(hsv: HsvColor, i: number, isLight: boolean) {
let hue: number;
const hsvH = Math.round(hsv.h);
if (hsvH >= 60 && hsvH <= 240) {
hue = isLight ? hsvH - hueStep * i : hsvH + hueStep * i;
} else {
hue = isLight ? hsvH + hueStep * i : hsvH - hueStep * i;
}
if (hue < 0) {
hue += 360;
}
if (hue >= 360) {
hue -= 360;
}
return hue;
}
/**
* Get saturation
*
* @param hsv - Hsv format color
* @param i - The relative distance from 6
* @param isLight - Is light color
*/
function getSaturation(hsv: HsvColor, i: number, isLight: boolean) {
if (hsv.h === 0 && hsv.s === 0) {
return hsv.s;
}
let saturation: number;
if (isLight) {
saturation = hsv.s - saturationStep * i;
} else if (i === darkColorCount) {
saturation = hsv.s + saturationStep;
} else {
saturation = hsv.s + saturationStep2 * i;
}
if (saturation > 100) {
saturation = 100;
}
if (isLight && i === lightColorCount && saturation > 10) {
saturation = 10;
}
if (saturation < 6) {
saturation = 6;
}
return saturation;
}
/**
* Get value of hsv
*
* @param hsv - Hsv format color
* @param i - The relative distance from 6
* @param isLight - Is light color
*/
function getValue(hsv: HsvColor, i: number, isLight: boolean) {
let value: number;
if (isLight) {
value = hsv.v + brightnessStep1 * i;
} else {
value = hsv.v - brightnessStep2 * i;
}
if (value > 100) {
value = 100;
}
return value;
}

View File

@ -0,0 +1,45 @@
import type { AnyColor } from 'colord';
import { getHex } from '../shared';
import type { ColorPaletteNumber } from '../types';
import { getRecommendedColorPalette } from './recommend';
import { getAntDColorPalette } from './antd';
/**
* get color palette by provided color
*
* @param color
* @param recommended whether to get recommended color palette (the provided color may not be the main color)
*/
export function getColorPalette(color: AnyColor, recommended = false) {
const colorMap = new Map<ColorPaletteNumber, string>();
if (recommended) {
const colorPalette = getRecommendedColorPalette(getHex(color));
colorPalette.palettes.forEach(palette => {
colorMap.set(palette.number, palette.hex);
});
} else {
const colors = getAntDColorPalette(color);
const colorNumbers: ColorPaletteNumber[] = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
colorNumbers.forEach((number, index) => {
colorMap.set(number, colors[index]);
});
}
return colorMap;
}
/**
* get color palette color by number
*
* @param color the provided color
* @param number the color palette number
* @param recommended whether to get recommended color palette (the provided color may not be the main color)
*/
export function getPaletteColorByNumber(color: AnyColor, number: ColorPaletteNumber, recommended = false) {
const colorMap = getColorPalette(color, recommended);
return colorMap.get(number as ColorPaletteNumber)!;
}

View File

@ -0,0 +1,152 @@
import { getColorName, getDeltaE, getHsl, isValidColor, transformHslToHex } from '../shared';
import { colorPalettes } from '../constant';
import type {
ColorPalette,
ColorPaletteFamily,
ColorPaletteFamilyWithNearestPalette,
ColorPaletteMatch,
ColorPaletteNumber
} from '../types';
/**
* get recommended color palette by provided color
*
* @param color the provided color
*/
export function getRecommendedColorPalette(color: string) {
const colorPaletteFamily = getRecommendedColorPaletteFamily(color);
const colorMap = new Map<ColorPaletteNumber, ColorPalette>();
colorPaletteFamily.palettes.forEach(palette => {
colorMap.set(palette.number, palette);
});
const mainColor = colorMap.get(500)!;
const matchColor = colorPaletteFamily.palettes.find(palette => palette.hex === color)!;
const colorPalette: ColorPaletteMatch = {
...colorPaletteFamily,
colorMap,
main: mainColor,
match: matchColor
};
return colorPalette;
}
/**
* get recommended palette color by provided color
*
* @param color the provided color
* @param number the color palette number
*/
export function getRecommendedPaletteColorByNumber(color: string, number: ColorPaletteNumber) {
const colorPalette = getRecommendedColorPalette(color);
const { hex } = colorPalette.colorMap.get(number)!;
return hex;
}
/**
* get color palette family by provided color and color name
*
* @param color the provided color
*/
export function getRecommendedColorPaletteFamily(color: string) {
if (!isValidColor(color)) {
throw new Error('Invalid color, please check color value!');
}
let colorName = getColorName(color);
colorName = colorName.toLowerCase().replace(/\s/g, '-');
const { h: h1, s: s1 } = getHsl(color);
const { nearestLightnessPalette, palettes } = getNearestColorPaletteFamily(color, colorPalettes);
const { number, hex } = nearestLightnessPalette;
const { h: h2, s: s2 } = getHsl(hex);
const deltaH = h1 - h2;
const sRatio = s1 / s2;
const colorPaletteFamily: ColorPaletteFamily = {
name: colorName,
palettes: palettes.map(palette => {
let hexValue = color;
const isSame = number === palette.number;
if (!isSame) {
const { h: h3, s: s3, l } = getHsl(palette.hex);
const newH = deltaH < 0 ? h3 + deltaH : h3 - deltaH;
const newS = s3 * sRatio;
hexValue = transformHslToHex({
h: newH,
s: newS,
l
});
}
return {
hex: hexValue,
number: palette.number
};
})
};
return colorPaletteFamily;
}
/**
* get nearest color palette family
*
* @param color color
* @param families color palette families
*/
function getNearestColorPaletteFamily(color: string, families: ColorPaletteFamily[]) {
const familyWithConfig = families.map(family => {
const palettes = family.palettes.map(palette => {
return {
...palette,
delta: getDeltaE(color, palette.hex)
};
});
const nearestPalette = palettes.reduce((prev, curr) => (prev.delta < curr.delta ? prev : curr));
return {
...family,
palettes,
nearestPalette
};
});
const nearestPaletteFamily = familyWithConfig.reduce((prev, curr) =>
prev.nearestPalette.delta < curr.nearestPalette.delta ? prev : curr
);
const { l } = getHsl(color);
const paletteFamily: ColorPaletteFamilyWithNearestPalette = {
...nearestPaletteFamily,
nearestLightnessPalette: nearestPaletteFamily.palettes.reduce((prev, curr) => {
const { l: prevLightness } = getHsl(prev.hex);
const { l: currLightness } = getHsl(curr.hex);
const deltaPrev = Math.abs(prevLightness - l);
const deltaCurr = Math.abs(currLightness - l);
return deltaPrev < deltaCurr ? prev : curr;
})
};
return paletteFamily;
}

View File

@ -0,0 +1,93 @@
import { colord, extend } from 'colord';
import namesPlugin from 'colord/plugins/names';
import mixPlugin from 'colord/plugins/mix';
import labPlugin from 'colord/plugins/lab';
import type { AnyColor, HslColor, RgbColor } from 'colord';
extend([namesPlugin, mixPlugin, labPlugin]);
export function isValidColor(color: AnyColor) {
return colord(color).isValid();
}
export function getHex(color: AnyColor) {
return colord(color).toHex();
}
export function getRgb(color: AnyColor) {
return colord(color).toRgb();
}
export function getHsl(color: AnyColor) {
return colord(color).toHsl();
}
export function getHsv(color: AnyColor) {
return colord(color).toHsv();
}
export function getDeltaE(color1: AnyColor, color2: AnyColor) {
return colord(color1).delta(color2);
}
export function transformHslToHex(color: HslColor) {
return colord(color).toHex();
}
/**
* Add color alpha
*
* @param color - Color
* @param alpha - Alpha (0 - 1)
*/
export function addColorAlpha(color: AnyColor, alpha: number) {
return colord(color).alpha(alpha).toHex();
}
/**
* Mix color
*
* @param firstColor - First color
* @param secondColor - Second color
* @param ratio - The ratio of the second color (0 - 1)
*/
export function mixColor(firstColor: AnyColor, secondColor: AnyColor, ratio: number) {
return colord(firstColor).mix(secondColor, ratio).toHex();
}
/**
* Transform color with opacity to similar color without opacity
*
* @param color - Color
* @param alpha - Alpha (0 - 1)
* @param bgColor Background color (usually white or black)
*/
export function transformColorWithOpacity(color: AnyColor, alpha: number, bgColor = '#ffffff') {
const originColor = addColorAlpha(color, alpha);
const { r: oR, g: oG, b: oB } = colord(originColor).toRgb();
const { r: bgR, g: bgG, b: bgB } = colord(bgColor).toRgb();
function calRgb(or: number, bg: number, al: number) {
return bg + (or - bg) * al;
}
const resultRgb: RgbColor = {
r: calRgb(oR, bgR, alpha),
g: calRgb(oG, bgG, alpha),
b: calRgb(oB, bgB, alpha)
};
return colord(resultRgb).toHex();
}
/**
* Is white color
*
* @param color - Color
*/
export function isWhiteColor(color: AnyColor) {
return colord(color).isEqual('#ffffff');
}
export { colord };

View File

@ -0,0 +1,2 @@
export * from './colord';
export * from './name';

View File

@ -0,0 +1,49 @@
import { colorNames } from '../constant';
import { getHex, getHsl, getRgb } from './colord';
/**
* Get color name
*
* @param color
*/
export function getColorName(color: string) {
const hex = getHex(color);
const rgb = getRgb(color);
const hsl = getHsl(color);
let ndf = 0;
let ndf1 = 0;
let ndf2 = 0;
let cl = -1;
let df = -1;
let name = '';
colorNames.some((item, index) => {
const [hexValue, colorName] = item;
const match = hex === hexValue;
if (match) {
name = colorName;
} else {
const { r, g, b } = getRgb(hexValue);
const { h, s, l } = getHsl(hexValue);
ndf1 = (rgb.r - r) ** 2 + (rgb.g - g) ** 2 + (rgb.b - b) ** 2;
ndf2 = (hsl.h - h) ** 2 + (hsl.s - s) ** 2 + (hsl.l - l) ** 2;
ndf = ndf1 + ndf2 * 2;
if (df < 0 || df > ndf) {
df = ndf;
cl = index;
}
}
return match;
});
name = colorNames[cl][1];
return name;
}

View File

@ -0,0 +1,58 @@
/**
* the color palette number
*
* the main color number is 500
*/
export type ColorPaletteNumber = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950;
/** the color palette */
export type ColorPalette = {
/** the color hex value */
hex: string;
/**
* the color number
*
* - 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950
*/
number: ColorPaletteNumber;
};
/** the color palette family */
export type ColorPaletteFamily = {
/** the color palette family name */
name: string;
/** the color palettes */
palettes: ColorPalette[];
};
/** the color palette with delta */
export type ColorPaletteWithDelta = ColorPalette & {
delta: number;
};
/** the color palette family with nearest palette */
export type ColorPaletteFamilyWithNearestPalette = ColorPaletteFamily & {
nearestPalette: ColorPaletteWithDelta;
nearestLightnessPalette: ColorPaletteWithDelta;
};
/** the color palette match */
export type ColorPaletteMatch = ColorPaletteFamily & {
/** the color map of the palette */
colorMap: Map<ColorPaletteNumber, ColorPalette>;
/**
* the main color of the palette
*
* which number is 500
*/
main: ColorPalette;
/** the match color of the palette */
match: ColorPalette;
};
/**
* The color index of color palette
*
* From left to right, the color is from light to dark, 6 is main color
*/
export type ColorIndex = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,16 @@
{
"name": "@sa/hooks",
"version": "2.0.2",
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"dependencies": {
"@sa/axios": "workspace:*",
"@sa/utils": "workspace:*"
}
}

View File

@ -0,0 +1,9 @@
import useBoolean from './use-boolean';
import useLoading from './use-loading';
import useCountDown from './use-count-down';
import useContext from './use-context';
import useSvgIconRender from './use-svg-icon-render';
import useTable from './use-table';
export { useBoolean, useLoading, useCountDown, useContext, useSvgIconRender, useTable };
export type * from './use-table';

View File

@ -0,0 +1,31 @@
import { ref } from 'vue';
/**
* Boolean
*
* @param initValue Init value
*/
export default function useBoolean(initValue = false) {
const bool = ref(initValue);
function setBool(value: boolean) {
bool.value = value;
}
function setTrue() {
setBool(true);
}
function setFalse() {
setBool(false);
}
function toggle() {
setBool(!bool.value);
}
return {
bool,
setBool,
setTrue,
setFalse,
toggle
};
}

View File

@ -0,0 +1,96 @@
import { inject, provide } from 'vue';
/**
* Use context
*
* @example
* ```ts
* // there are three vue files: A.vue, B.vue, C.vue, and A.vue is the parent component of B.vue and C.vue
*
* // context.ts
* import { ref } from 'vue';
* import { useContext } from '@sa/hooks';
*
* export const [provideDemoContext, useDemoContext] = useContext('demo', () => {
* const count = ref(0);
*
* function increment() {
* count.value++;
* }
*
* function decrement() {
* count.value--;
* }
*
* return {
* count,
* increment,
* decrement
* };
* })
* ``` // A.vue
* ```vue
* <template>
* <div>A</div>
* </template>
* <script setup lang="ts">
* import { provideDemoContext } from './context';
*
* provideDemoContext();
* // const { increment } = provideDemoContext(); // also can control the store in the parent component
* </script>
* ``` // B.vue
* ```vue
* <template>
* <div>B</div>
* </template>
* <script setup lang="ts">
* import { useDemoContext } from './context';
*
* const { count, increment } = useDemoContext();
* </script>
* ```;
*
* // C.vue is same as B.vue
*
* @param contextName Context name
* @param fn Context function
*/
export default function useContext<Arguments extends Array<any>, T>(
contextName: string,
composable: (...args: Arguments) => T
) {
const key = Symbol(contextName);
/**
* Injects the context value.
*
* @param consumerName - The name of the component that is consuming the context. If provided, the component must be
* used within the context provider.
* @param defaultValue - The default value to return if the context is not provided.
* @returns The context value.
*/
const useInject = <N extends string | null | undefined = undefined>(
consumerName?: N,
defaultValue?: T
): N extends null | undefined ? T | null : T => {
const value = inject(key, defaultValue);
if (consumerName && !value) {
throw new Error(`\`${consumerName}\` must be used within \`${contextName}\``);
}
// @ts-expect-error - we want to return null if the value is undefined or null
return value || null;
};
const useProvide = (...args: Arguments) => {
const value = composable(...args);
provide(key, value);
return value;
};
return [useProvide, useInject] as const;
}

View File

@ -0,0 +1,68 @@
import { computed, onScopeDispose, ref } from 'vue';
import { useRafFn } from '@vueuse/core';
/**
* A hook for implementing a countdown timer. It uses `requestAnimationFrame` for smooth and accurate timing,
* independent of the screen refresh rate.
*
* @param initialSeconds - The total number of seconds for the countdown.
*/
export default function useCountDown(initialSeconds: number) {
const remainingSeconds = ref(0);
const count = computed(() => Math.ceil(remainingSeconds.value));
const isCounting = computed(() => remainingSeconds.value > 0);
const { pause, resume } = useRafFn(
({ delta }) => {
// delta: milliseconds elapsed since the last frame.
// If countdown already reached zero or below, ensure it's 0 and stop.
if (remainingSeconds.value <= 0) {
remainingSeconds.value = 0;
pause();
return;
}
// Calculate seconds passed since the last frame.
const secondsPassed = delta / 1000;
remainingSeconds.value -= secondsPassed;
// If countdown has finished after decrementing.
if (remainingSeconds.value <= 0) {
remainingSeconds.value = 0;
pause();
}
},
{ immediate: false } // The timer does not start automatically.
);
/**
* Starts the countdown.
*
* @param [updatedSeconds=initialSeconds] - Optionally, start with a new duration. Default is `initialSeconds`
*/
function start(updatedSeconds: number = initialSeconds) {
remainingSeconds.value = updatedSeconds;
resume();
}
/** Stops the countdown and resets the remaining time to 0. */
function stop() {
remainingSeconds.value = 0;
pause();
}
// Ensure the rAF loop is cleaned up when the component is unmounted.
onScopeDispose(() => {
pause();
});
return {
count,
isCounting,
start,
stop
};
}

View File

@ -0,0 +1,16 @@
import useBoolean from './use-boolean';
/**
* Loading
*
* @param initValue Init value
*/
export default function useLoading(initValue = false) {
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(initValue);
return {
loading,
startLoading,
endLoading
};
}

View File

@ -0,0 +1,82 @@
import { ref } from 'vue';
import type { Ref } from 'vue';
import { createFlatRequest } from '@sa/axios';
import type {
AxiosError,
CreateAxiosDefaults,
CustomAxiosRequestConfig,
MappedType,
RequestInstanceCommon,
RequestOption,
ResponseType
} from '@sa/axios';
import useLoading from './use-loading';
export type HookRequestInstanceResponseSuccessData<ApiData> = {
data: Ref<ApiData>;
error: Ref<null>;
};
export type HookRequestInstanceResponseFailData<ResponseData> = {
data: Ref<null>;
error: Ref<AxiosError<ResponseData>>;
};
export type HookRequestInstanceResponseData<ResponseData, ApiData> = {
loading: Ref<boolean>;
} & (HookRequestInstanceResponseSuccessData<ApiData> | HookRequestInstanceResponseFailData<ResponseData>);
export interface HookRequestInstance<
ResponseData,
ApiData,
State extends Record<string, unknown>
> extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig
): HookRequestInstanceResponseData<ResponseData, MappedType<R, T>>;
}
/**
* create a hook request instance
*
* @param axiosConfig
* @param options
*/
export default function createHookRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData, ApiData, State>>
) {
const request = createFlatRequest<ResponseData, ApiData, State>(axiosConfig, options);
const hookRequest: HookRequestInstance<ResponseData, ApiData, State> = function hookRequest<
T extends ApiData = ApiData,
R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
const { loading, startLoading, endLoading } = useLoading();
const data = ref(null) as Ref<MappedType<R, T>>;
const error = ref(null) as Ref<AxiosError<ResponseData> | null>;
startLoading();
request(config).then(res => {
if (res.data) {
data.value = res.data as MappedType<R, T>;
} else {
error.value = res.error;
}
endLoading();
});
return {
loading,
data,
error
};
} as HookRequestInstance<ResponseData, ApiData, State>;
hookRequest.cancelAllRequest = request.cancelAllRequest;
return hookRequest;
}

View File

@ -0,0 +1,50 @@
import { h } from 'vue';
import type { Component } from 'vue';
/**
* Svg icon render hook
*
* @param SvgIcon Svg icon component
*/
export default function useSvgIconRender(SvgIcon: Component) {
interface IconConfig {
/** Iconify icon name */
icon?: string;
/** Local icon name */
localIcon?: string;
/** Icon color */
color?: string;
/** Icon size */
fontSize?: number;
}
type IconStyle = Partial<Pick<CSSStyleDeclaration, 'color' | 'fontSize'>>;
/**
* Svg icon VNode
*
* @param config
*/
const SvgIconVNode = (config: IconConfig) => {
const { color, fontSize, icon, localIcon } = config;
const style: IconStyle = {};
if (color) {
style.color = color;
}
if (fontSize) {
style.fontSize = `${fontSize}px`;
}
if (!icon && !localIcon) {
return undefined;
}
return () => h(SvgIcon, { icon, localIcon, style });
};
return {
SvgIconVNode
};
}

View File

@ -0,0 +1,131 @@
import { computed, ref } from 'vue';
import type { Ref, VNodeChild } from 'vue';
import useBoolean from './use-boolean';
import useLoading from './use-loading';
export interface PaginationData<T> {
data: T[];
pageNum: number;
total: number;
}
type GetApiData<ApiData, Pagination extends boolean> = Pagination extends true ? PaginationData<ApiData> : ApiData[];
type Transform<ResponseData, ApiData, Pagination extends boolean> = (
response: ResponseData
) => GetApiData<ApiData, Pagination>;
export type TableColumnCheckTitle = string | ((...args: any) => VNodeChild);
export type TableColumnCheck = {
key: string;
title: TableColumnCheckTitle;
checked: boolean;
visible: boolean;
};
export interface UseTableOptions<ResponseData, ApiData, Column, Pagination extends boolean> {
/**
* api function to get table data
*/
api: () => Promise<ResponseData>;
/**
* whether to enable pagination
*/
pagination?: Pagination;
/**
* transform api response to table data
*/
transform: Transform<ResponseData, ApiData, Pagination>;
/**
* columns factory
*/
columns: () => Column[];
/**
* get column checks
*/
getColumnChecks: (columns: Column[]) => TableColumnCheck[];
/**
* get columns
*/
getColumns: (columns: Column[], checks: TableColumnCheck[]) => Column[];
/**
* callback when response fetched
*/
onFetched?: (data: GetApiData<ApiData, Pagination>) => void | Promise<void>;
/**
* whether to get data immediately
*
* @default true
*/
immediate?: boolean;
}
export default function useTable<ResponseData, ApiData, Column, Pagination extends boolean>(
options: UseTableOptions<ResponseData, ApiData, Column, Pagination>
) {
const { loading, startLoading, endLoading } = useLoading();
const { bool: empty, setBool: setEmpty } = useBoolean();
const { api, pagination, transform, columns, getColumnChecks, getColumns, onFetched, immediate = true } = options;
const data = ref([]) as Ref<ApiData[]>;
const columnChecks = ref(getColumnChecks(columns())) as Ref<TableColumnCheck[]>;
const $columns = computed(() => getColumns(columns(), columnChecks.value));
function reloadColumns() {
const checkMap = new Map(columnChecks.value.map(col => [col.key, col.checked]));
const defaultChecks = getColumnChecks(columns());
columnChecks.value = defaultChecks.map(col => ({
...col,
checked: checkMap.get(col.key) ?? col.checked
}));
}
async function getData() {
try {
startLoading();
const response = await api();
const transformed = transform(response);
data.value = getTableData(transformed, pagination);
setEmpty(data.value.length === 0);
await onFetched?.(transformed);
} finally {
endLoading();
}
}
if (immediate) {
getData();
}
return {
loading,
empty,
data,
columns: $columns,
columnChecks,
reloadColumns,
getData
};
}
function getTableData<ApiData, Pagination extends boolean>(
data: GetApiData<ApiData, Pagination>,
pagination?: Pagination
) {
if (pagination) {
return (data as PaginationData<ApiData>).data;
}
return data as ApiData[];
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,19 @@
{
"name": "@sa/materials",
"version": "2.0.2",
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"dependencies": {
"@sa/utils": "workspace:*",
"simplebar-vue": "2.4.2"
},
"devDependencies": {
"typed-css-modules": "0.9.1"
}
}

View File

@ -0,0 +1,6 @@
import AdminLayout, { LAYOUT_MAX_Z_INDEX, LAYOUT_SCROLL_EL_ID } from './libs/admin-layout';
import PageTab from './libs/page-tab';
import SimpleScrollbar from './libs/simple-scrollbar';
export { AdminLayout, LAYOUT_SCROLL_EL_ID, LAYOUT_MAX_Z_INDEX, PageTab, SimpleScrollbar };
export * from './types';

View File

@ -0,0 +1,63 @@
/* @type */
.layout-header,
.layout-header-placement {
height: var(--soy-header-height);
}
.layout-header {
z-index: var(--soy-header-z-index);
}
.layout-tab {
top: var(--soy-header-height);
height: var(--soy-tab-height);
z-index: var(--soy-tab-z-index);
}
.layout-tab-placement {
height: var(--soy-tab-height);
}
.layout-sider {
width: var(--soy-sider-width);
z-index: var(--soy-sider-z-index);
}
.layout-mobile-sider {
z-index: var(--soy-sider-z-index);
}
.layout-mobile-sider-mask {
z-index: var(--soy-mobile-sider-z-index);
}
.layout-sider_collapsed {
width: var(--soy-sider-collapsed-width);
z-index: var(--soy-sider-z-index);
}
.layout-footer,
.layout-footer-placement {
height: var(--soy-footer-height);
}
.layout-footer {
z-index: var(--soy-footer-z-index);
}
.left-gap {
padding-left: var(--soy-sider-width);
}
.left-gap_collapsed {
padding-left: var(--soy-sider-collapsed-width);
}
.sider-padding-top {
padding-top: var(--soy-header-height);
}
.sider-padding-bottom {
padding-bottom: var(--soy-footer-height);
}

View File

@ -0,0 +1,18 @@
declare const styles: {
readonly 'layout-header': string;
readonly 'layout-header-placement': string;
readonly 'layout-tab': string;
readonly 'layout-tab-placement': string;
readonly 'layout-sider': string;
readonly 'layout-mobile-sider': string;
readonly 'layout-mobile-sider-mask': string;
readonly 'layout-sider_collapsed': string;
readonly 'layout-footer': string;
readonly 'layout-footer-placement': string;
readonly 'left-gap': string;
readonly 'left-gap_collapsed': string;
readonly 'sider-padding-top': string;
readonly 'sider-padding-bottom': string;
};
export default styles;

View File

@ -0,0 +1,5 @@
import AdminLayout from './index.vue';
import { LAYOUT_MAX_Z_INDEX, LAYOUT_SCROLL_EL_ID } from './shared';
export default AdminLayout;
export { LAYOUT_SCROLL_EL_ID, LAYOUT_MAX_Z_INDEX };

View File

@ -0,0 +1,236 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { AdminLayoutProps } from '../../types';
import { LAYOUT_MAX_Z_INDEX, LAYOUT_SCROLL_EL_ID, createLayoutCssVars } from './shared';
import style from './index.module.css';
defineOptions({
name: 'AdminLayout'
});
const props = withDefaults(defineProps<AdminLayoutProps>(), {
mode: 'vertical',
scrollMode: 'content',
scrollElId: LAYOUT_SCROLL_EL_ID,
commonClass: 'transition-all-300',
fixedTop: true,
maxZIndex: LAYOUT_MAX_Z_INDEX,
headerVisible: true,
headerHeight: 56,
tabVisible: true,
tabHeight: 48,
siderVisible: true,
siderCollapse: false,
siderWidth: 220,
siderCollapsedWidth: 64,
footerVisible: true,
footerHeight: 48,
rightFooter: false
});
interface Emits {
/** Update siderCollapse */
(e: 'update:siderCollapse', collapse: boolean): void;
}
const emit = defineEmits<Emits>();
type SlotFn = (props?: Record<string, unknown>) => any;
type Slots = {
/** Main */
default?: SlotFn;
/** Header */
header?: SlotFn;
/** Tab */
tab?: SlotFn;
/** Sider */
sider?: SlotFn;
/** Footer */
footer?: SlotFn;
};
const slots = defineSlots<Slots>();
const cssVars = computed(() => createLayoutCssVars(props));
// config visible
const showHeader = computed(() => Boolean(slots.header) && props.headerVisible);
const showTab = computed(() => Boolean(slots.tab) && props.tabVisible);
const showSider = computed(() => !props.isMobile && Boolean(slots.sider) && props.siderVisible);
const showMobileSider = computed(() => props.isMobile && Boolean(slots.sider) && props.siderVisible);
const showFooter = computed(() => Boolean(slots.footer) && props.footerVisible);
// scroll mode
const isWrapperScroll = computed(() => props.scrollMode === 'wrapper');
const isContentScroll = computed(() => props.scrollMode === 'content');
// layout direction
const isVertical = computed(() => props.mode === 'vertical');
const isHorizontal = computed(() => props.mode === 'horizontal');
const fixedHeaderAndTab = computed(() => props.fixedTop || (isHorizontal.value && isWrapperScroll.value));
// css
const leftGapClass = computed(() => {
if (!props.fullContent && showSider.value) {
return props.siderCollapse ? style['left-gap_collapsed'] : style['left-gap'];
}
return '';
});
const headerLeftGapClass = computed(() => (isVertical.value ? leftGapClass.value : ''));
const footerLeftGapClass = computed(() => {
const condition1 = isVertical.value;
const condition2 = isHorizontal.value && isWrapperScroll.value && !props.fixedFooter;
const condition3 = Boolean(isHorizontal.value && props.rightFooter);
if (condition1 || condition2 || condition3) {
return leftGapClass.value;
}
return '';
});
const siderPaddingClass = computed(() => {
let cls = '';
if (showHeader.value && !headerLeftGapClass.value) {
cls += style['sider-padding-top'];
}
if (showFooter.value && !footerLeftGapClass.value) {
cls += ` ${style['sider-padding-bottom']}`;
}
return cls;
});
function handleClickMask() {
emit('update:siderCollapse', true);
}
</script>
<template>
<div class="relative h-full" :class="[commonClass]" :style="cssVars">
<div
:id="isWrapperScroll ? scrollElId : undefined"
class="h-full flex flex-col"
:class="[commonClass, scrollWrapperClass, { 'overflow-y-auto': isWrapperScroll }]"
>
<!-- Header -->
<template v-if="showHeader">
<header
v-show="!fullContent"
class="flex-shrink-0"
:class="[
style['layout-header'],
commonClass,
headerLeftGapClass,
{ 'absolute top-0 left-0 w-full': fixedHeaderAndTab }
]"
>
<slot name="header"></slot>
</header>
<div
v-show="!fullContent && fixedHeaderAndTab"
class="flex-shrink-0 overflow-hidden"
:class="[style['layout-header-placement']]"
></div>
</template>
<!-- Tab -->
<template v-if="showTab">
<div
class="flex-shrink-0"
:class="[
style['layout-tab'],
commonClass,
tabClass,
{ 'top-0!': fullContent || !showHeader },
leftGapClass,
{ 'absolute left-0 w-full': fixedHeaderAndTab }
]"
>
<slot name="tab"></slot>
</div>
<div
v-show="fullContent || fixedHeaderAndTab"
class="flex-shrink-0 overflow-hidden"
:class="[style['layout-tab-placement']]"
></div>
</template>
<!-- Sider -->
<template v-if="showSider">
<aside
v-show="!fullContent"
class="absolute left-0 top-0 h-full"
:class="[
commonClass,
siderClass,
siderPaddingClass,
siderCollapse ? style['layout-sider_collapsed'] : style['layout-sider']
]"
>
<slot name="sider"></slot>
</aside>
</template>
<!-- Mobile Sider -->
<template v-if="showMobileSider">
<aside
class="absolute left-0 top-0 h-full w-0 bg-white"
:class="[
commonClass,
mobileSiderClass,
style['layout-mobile-sider'],
siderCollapse ? 'overflow-hidden' : style['layout-sider']
]"
>
<slot name="sider"></slot>
</aside>
<div
v-show="!siderCollapse"
class="absolute left-0 top-0 h-full w-full bg-[rgba(0,0,0,0.2)]"
:class="[style['layout-mobile-sider-mask']]"
@click="handleClickMask"
></div>
</template>
<!-- Main Content -->
<main
:id="isContentScroll ? scrollElId : undefined"
class="flex flex-col flex-grow"
:class="[commonClass, contentClass, leftGapClass, { 'overflow-y-auto': isContentScroll }]"
>
<slot></slot>
</main>
<!-- Footer -->
<template v-if="showFooter">
<footer
v-show="!fullContent"
class="flex-shrink-0"
:class="[
style['layout-footer'],
commonClass,
footerClass,
footerLeftGapClass,
{ 'absolute left-0 bottom-0 w-full': fixedFooter }
]"
>
<slot name="footer"></slot>
</footer>
<div
v-show="!fullContent && fixedFooter"
class="flex-shrink-0 overflow-hidden"
:class="[style['layout-footer-placement']]"
></div>
</template>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,68 @@
import type { AdminLayoutProps, LayoutCssVars, LayoutCssVarsProps } from '../../types';
/** The id of the scroll element of the layout */
export const LAYOUT_SCROLL_EL_ID = '__SCROLL_EL_ID__';
/** The max z-index of the layout */
export const LAYOUT_MAX_Z_INDEX = 100;
/**
* Create layout css vars by css vars props
*
* @param props Css vars props
*/
function createLayoutCssVarsByCssVarsProps(props: LayoutCssVarsProps) {
const cssVars: LayoutCssVars = {
'--soy-header-height': `${props.headerHeight}px`,
'--soy-header-z-index': props.headerZIndex,
'--soy-tab-height': `${props.tabHeight}px`,
'--soy-tab-z-index': props.tabZIndex,
'--soy-sider-width': `${props.siderWidth}px`,
'--soy-sider-collapsed-width': `${props.siderCollapsedWidth}px`,
'--soy-sider-z-index': props.siderZIndex,
'--soy-mobile-sider-z-index': props.mobileSiderZIndex,
'--soy-footer-height': `${props.footerHeight}px`,
'--soy-footer-z-index': props.footerZIndex
};
return cssVars;
}
/**
* Create layout css vars
*
* @param props
*/
export function createLayoutCssVars(props: AdminLayoutProps) {
const {
mode,
isMobile,
maxZIndex = LAYOUT_MAX_Z_INDEX,
headerHeight,
tabHeight,
siderWidth,
siderCollapsedWidth,
footerHeight
} = props;
const headerZIndex = maxZIndex - 3;
const tabZIndex = maxZIndex - 5;
const siderZIndex = mode === 'vertical' || isMobile ? maxZIndex - 1 : maxZIndex - 4;
const mobileSiderZIndex = isMobile ? maxZIndex - 2 : 0;
const footerZIndex = maxZIndex - 5;
const cssProps: LayoutCssVarsProps = {
headerHeight,
headerZIndex,
tabHeight,
tabZIndex,
siderWidth,
siderZIndex,
mobileSiderZIndex,
siderCollapsedWidth,
footerHeight,
footerZIndex
};
return createLayoutCssVarsByCssVarsProps(cssProps);
}

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import type { PageTabProps } from '../../types';
import style from './index.module.css';
defineOptions({
name: 'ButtonTab'
});
defineProps<PageTabProps>();
type SlotFn = (props?: Record<string, unknown>) => any;
type Slots = {
/**
* Slot
*
* The center content of the tab
*/
default?: SlotFn;
/**
* Slot
*
* The left content of the tab
*/
prefix?: SlotFn;
/**
* Slot
*
* The right content of the tab
*/
suffix?: SlotFn;
};
defineSlots<Slots>();
</script>
<template>
<div
class=":soy: relative inline-flex cursor-pointer items-center justify-center gap-12px whitespace-nowrap border-(1px solid) rounded-4px px-12px py-4px"
:class="[
style['button-tab'],
{ [style['button-tab_dark']]: darkMode },
{ [style['button-tab_active']]: active },
{ [style['button-tab_active_dark']]: active && darkMode }
]"
>
<slot name="prefix"></slot>
<slot></slot>
<slot name="suffix"></slot>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
defineOptions({
name: 'ChromeTabBg'
});
</script>
<template>
<svg class="size-full">
<defs>
<symbol id="geometry-left" viewBox="0 0 214 36">
<path d="M17 0h197v36H0v-2c4.5 0 9-3.5 9-8V8c0-4.5 3.5-8 8-8z" />
</symbol>
<symbol id="geometry-right" viewBox="0 0 214 36">
<use xlink:href="#geometry-left" />
</symbol>
<clipPath>
<rect width="100%" height="100%" x="0" />
</clipPath>
</defs>
<svg width="51%" height="100%">
<use xlink:href="#geometry-left" width="214" height="36" fill="currentColor" />
</svg>
<g transform="scale(-1, 1)">
<svg width="51%" height="100%" x="-100%" y="0">
<use xlink:href="#geometry-right" width="214" height="36" fill="currentColor" />
</svg>
</g>
</svg>
</template>
<style scoped></style>

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import type { PageTabProps } from '../../types';
import ChromeTabBg from './chrome-tab-bg.vue';
import style from './index.module.css';
defineOptions({
name: 'ChromeTab'
});
defineProps<PageTabProps>();
type SlotFn = (props?: Record<string, unknown>) => any;
type Slots = {
/**
* Slot
*
* The center content of the tab
*/
default?: SlotFn;
/**
* Slot
*
* The left content of the tab
*/
prefix?: SlotFn;
/**
* Slot
*
* The right content of the tab
*/
suffix?: SlotFn;
};
defineSlots<Slots>();
</script>
<template>
<div
class=":soy: relative inline-flex cursor-pointer items-center justify-center gap-16px whitespace-nowrap px-24px py-6px -mr-18px"
:class="[
style['chrome-tab'],
{ [style['chrome-tab_dark']]: darkMode },
{ [style['chrome-tab_active']]: active },
{ [style['chrome-tab_active_dark']]: active && darkMode }
]"
>
<div class=":soy: pointer-events-none absolute left-0 top-0 h-full w-full -z-1" :class="[style['chrome-tab__bg']]">
<ChromeTabBg />
</div>
<slot name="prefix"></slot>
<slot></slot>
<slot name="suffix"></slot>
<div class=":soy: absolute right-7px h-16px w-1px bg-#1f2225" :class="[style['chrome-tab-divider']]"></div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,121 @@
/* @type */
.button-tab {
border-color: #e5e7eb;
}
.button-tab_dark {
border-color: #ffffff3d;
}
.button-tab:hover {
color: var(--soy-primary-color);
border-color: var(--soy-primary-color-opacity3);
}
.button-tab_active {
color: var(--soy-primary-color);
border-color: var(--soy-primary-color-opacity3);
background-color: var(--soy-primary-color-opacity1);
}
.button-tab_active_dark {
background-color: var(--soy-primary-color-opacity2);
}
.button-tab .svg-close:hover {
font-size: 12px;
color: #ffffff;
background-color: var(--soy-primary-color);
}
.button-tab_dark .svg-close:hover {
color: #000000;
}
.chrome-tab:hover {
z-index: 9;
}
.chrome-tab_active {
z-index: 10;
color: var(--soy-primary-color);
}
.chrome-tab__bg {
color: transparent;
}
.chrome-tab_active .chrome-tab__bg {
color: var(--soy-primary-color1);
}
.chrome-tab_active_dark .chrome-tab__bg {
color: var(--soy-primary-color2);
}
.chrome-tab:hover .chrome-tab__bg {
color: #dee1e6;
}
.chrome-tab_active:hover .chrome-tab__bg {
color: var(--soy-primary-color1);
}
.chrome-tab_dark:hover .chrome-tab__bg {
color: #333333;
}
.chrome-tab_active_dark:hover .chrome-tab__bg {
color: var(--soy-primary-color2);
}
.chrome-tab .svg-close:hover {
font-size: 12px;
color: #ffffff;
background-color: #9ca3af;
}
.chrome-tab_active .svg-close:hover {
background-color: var(--soy-primary-color);
}
.chrome-tab_dark .svg-close:hover {
color: #000000;
}
.chrome-tab_active .chrome-tab-divider {
opacity: 0;
}
.chrome-tab:hover .chrome-tab-divider {
opacity: 0;
}
.chrome-tab_dark .chrome-tab-divider {
background-color: rgba(255, 255, 255, 0.9);
}
.slider-tab {
background-color: transparent;
height: 100%;
border-bottom: 2px solid transparent;
}
.slider-tab_dark {
background-color: transparent;
}
.slider-tab:hover {
color: var(--soy-primary-color);
}
.slider-tab_active {
color: var(--soy-primary-color);
background-color: var(--soy-primary-color-opacity1);
border-bottom-color: var(--soy-primary-color);
}
.slider-tab_active_dark {
background-color: var(--soy-primary-color-opacity2);
}

View File

@ -0,0 +1,19 @@
declare const styles: {
readonly 'button-tab': string;
readonly 'button-tab_dark': string;
readonly 'button-tab_active': string;
readonly 'button-tab_active_dark': string;
readonly 'chrome-tab': string;
readonly 'chrome-tab_active': string;
readonly 'chrome-tab__bg': string;
readonly 'chrome-tab_active_dark': string;
readonly 'chrome-tab_dark': string;
readonly 'chrome-tab-divider': string;
readonly 'svg-close': string;
readonly 'slider-tab': string;
readonly 'slider-tab_active': string;
readonly 'slider-tab_active_dark': string;
readonly 'slider-tab_dark': string;
};
export default styles;

View File

@ -0,0 +1,3 @@
import PageTab from './index.vue';
export default PageTab;

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import type { PageTabMode, PageTabProps } from '../../types';
import { ACTIVE_COLOR, createTabCssVars } from './shared';
import ChromeTab from './chrome-tab.vue';
import ButtonTab from './button-tab.vue';
import SliderTab from './slider-tab.vue';
import SvgClose from './svg-close.vue';
import style from './index.module.css';
defineOptions({
name: 'PageTab'
});
const props = withDefaults(defineProps<PageTabProps>(), {
mode: 'chrome',
commonClass: 'transition-all-300',
activeColor: ACTIVE_COLOR,
closable: true
});
interface Emits {
(e: 'close'): void;
}
const emit = defineEmits<Emits>();
const activeTabComponent = computed(() => {
const { mode, chromeClass, buttonClass, sliderClass } = props;
const tabComponentMap = {
chrome: {
component: ChromeTab,
class: chromeClass
},
button: {
component: ButtonTab,
class: buttonClass
},
slider: {
component: SliderTab,
class: sliderClass
}
} satisfies Record<PageTabMode, { component: Component; class?: string }>;
return tabComponentMap[mode];
});
const cssVars = computed(() => createTabCssVars(props.activeColor));
const bindProps = computed(() => {
const { chromeClass: _chromeCls, buttonClass: _btnCls, sliderClass: _sliderCls, ...rest } = props;
return rest;
});
function handleClose() {
emit('close');
}
</script>
<template>
<component :is="activeTabComponent.component" :class="activeTabComponent.class" :style="cssVars" v-bind="bindProps">
<template #prefix>
<slot name="prefix"></slot>
</template>
<slot></slot>
<template #suffix>
<slot name="suffix">
<SvgClose v-if="closable" :class="[style['svg-close']]" @pointerdown.stop="handleClose" />
</slot>
</template>
</component>
</template>
<style scoped></style>

View File

@ -0,0 +1,31 @@
import { addColorAlpha, transformColorWithOpacity } from '@sa/color';
import type { PageTabCssVars, PageTabCssVarsProps } from '../../types';
/** The active color of the tab */
export const ACTIVE_COLOR = '#1890ff';
function createCssVars(props: PageTabCssVarsProps) {
const cssVars: PageTabCssVars = {
'--soy-primary-color': props.primaryColor,
'--soy-primary-color1': props.primaryColor1,
'--soy-primary-color2': props.primaryColor2,
'--soy-primary-color-opacity1': props.primaryColorOpacity1,
'--soy-primary-color-opacity2': props.primaryColorOpacity2,
'--soy-primary-color-opacity3': props.primaryColorOpacity3
};
return cssVars;
}
export function createTabCssVars(primaryColor: string) {
const cssProps: PageTabCssVarsProps = {
primaryColor,
primaryColor1: transformColorWithOpacity(primaryColor, 0.1, '#ffffff'),
primaryColor2: transformColorWithOpacity(primaryColor, 0.3, '#000000'),
primaryColorOpacity1: addColorAlpha(primaryColor, 0.1),
primaryColorOpacity2: addColorAlpha(primaryColor, 0.15),
primaryColorOpacity3: addColorAlpha(primaryColor, 0.3)
};
return createCssVars(cssProps);
}

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import type { PageTabProps } from '../../types';
import style from './index.module.css';
defineOptions({
name: 'SliderTab'
});
defineProps<PageTabProps>();
type SlotFn = (props?: Record<string, unknown>) => any;
type Slots = {
/**
* Slot
*
* The center content of the tab
*/
default?: SlotFn;
/**
* Slot
*
* The left content of the tab
*/
prefix?: SlotFn;
/**
* Slot
*
* The right content of the tab
*/
suffix?: SlotFn;
};
defineSlots<Slots>();
</script>
<template>
<div
class=":soy: relative inline-flex cursor-pointer items-center justify-center gap-6px whitespace-nowrap px-12px py-4px"
:class="[
style['slider-tab'],
{ [style['slider-tab_dark']]: darkMode },
{ [style['slider-tab_active']]: active },
{ [style['slider-tab_active_dark']]: active && darkMode }
]"
>
<slot name="prefix"></slot>
<slot></slot>
<slot name="suffix"></slot>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
defineOptions({
name: 'SvgClose'
});
</script>
<template>
<div class=":soy: relative h-16px w-16px inline-flex items-center justify-center rd-50% text-14px">
<svg width="1em" height="1em" viewBox="0 0 1024 1024">
<path
fill="currentColor"
d="m563.8 512l262.5-312.9c4.4-5.2.7-13.1-6.1-13.1h-79.8c-4.7 0-9.2 2.1-12.3 5.7L511.6 449.8L295.1 191.7c-3-3.6-7.5-5.7-12.3-5.7H203c-6.8 0-10.5 7.9-6.1 13.1L459.4 512L196.9 824.9A7.95 7.95 0 0 0 203 838h79.8c4.7 0 9.2-2.1 12.3-5.7l216.5-258.1l216.5 258.1c3 3.6 7.5 5.7 12.3 5.7h79.8c6.8 0 10.5-7.9 6.1-13.1L563.8 512z"
/>
</svg>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,3 @@
import SimpleScrollbar from './index.vue';
export default SimpleScrollbar;

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import Simplebar from 'simplebar-vue';
import 'simplebar-vue/dist/simplebar.min.css';
defineOptions({
name: 'SimpleScrollbar'
});
</script>
<template>
<div class="h-full flex-1-hidden">
<Simplebar class="h-full">
<slot />
</Simplebar>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,291 @@
/** Header config */
interface AdminLayoutHeaderConfig {
/**
* Whether header is visible
*
* @default true
*/
headerVisible?: boolean;
/**
* Header height
*
* @default 56px
*/
headerHeight?: number;
}
/** Tab config */
interface AdminLayoutTabConfig {
/**
* Whether tab is visible
*
* @default true
*/
tabVisible?: boolean;
/**
* Tab class
*
* @default ''
*/
tabClass?: string;
/**
* Tab height
*
* @default 48px
*/
tabHeight?: number;
}
/** Sider config */
interface AdminLayoutSiderConfig {
/**
* Whether sider is visible
*
* @default true
*/
siderVisible?: boolean;
/**
* Sider class
*
* @default ''
*/
siderClass?: string;
/**
* Mobile sider class
*
* @default ''
*/
mobileSiderClass?: string;
/**
* Sider collapse status
*
* @default false
*/
siderCollapse?: boolean;
/**
* Sider width when collapse is false
*
* @default '220px'
*/
siderWidth?: number;
/**
* Sider width when collapse is true
*
* @default '64px'
*/
siderCollapsedWidth?: number;
}
/** Content config */
export interface AdminLayoutContentConfig {
/**
* Content class
*
* @default ''
*/
contentClass?: string;
/**
* Whether content is full the page
*
* If true, other elements will be hidden by `display: none`
*/
fullContent?: boolean;
}
/** Footer config */
export interface AdminLayoutFooterConfig {
/**
* Whether footer is visible
*
* @default true
*/
footerVisible?: boolean;
/**
* Whether footer is fixed
*
* @default true
*/
fixedFooter?: boolean;
/**
* Footer class
*
* @default ''
*/
footerClass?: string;
/**
* Footer height
*
* @default 48px
*/
footerHeight?: number;
/**
* Whether footer is on the right side
*
* When the layout is vertical, the footer is on the right side
*/
rightFooter?: boolean;
}
/**
* Layout mode
*
* - Horizontal
* - Vertical
*/
export type LayoutMode = 'horizontal' | 'vertical';
/**
* The scroll mode when content overflow
*
* - Wrapper: the layout component's wrapper element has a scrollbar
* - Content: the layout component's content element has a scrollbar
*
* @default 'wrapper'
*/
export type LayoutScrollMode = 'wrapper' | 'content';
/** Admin layout props */
export interface AdminLayoutProps
extends
AdminLayoutHeaderConfig,
AdminLayoutTabConfig,
AdminLayoutSiderConfig,
AdminLayoutContentConfig,
AdminLayoutFooterConfig {
/**
* Layout mode
*
* - {@link LayoutMode}
*/
mode?: LayoutMode;
/** Is mobile layout */
isMobile?: boolean;
/**
* Scroll mode
*
* - {@link ScrollMode}
*/
scrollMode?: LayoutScrollMode;
/**
* The id of the scroll element of the layout
*
* It can be used to get the corresponding Dom and scroll it
*
* @example
* use the default id by import
* ```ts
* import { adminLayoutScrollElId } from '@sa/vue-materials';
* ```
*
* @default
* ```ts
* const adminLayoutScrollElId = '__ADMIN_LAYOUT_SCROLL_EL_ID__'
* ```
*/
scrollElId?: string;
/** The class of the scroll element */
scrollElClass?: string;
/** The class of the scroll wrapper element */
scrollWrapperClass?: string;
/**
* The common class of the layout
*
* Is can be used to configure the transition animation
*
* @default 'transition-all-300'
*/
commonClass?: string;
/**
* Whether fix the header and tab
*
* @default true
*/
fixedTop?: boolean;
/**
* The max z-index of the layout
*
* The z-index of Header,Tab,Sider and Footer will not exceed this value
*/
maxZIndex?: number;
}
type Kebab<S extends string> = S extends Uncapitalize<S> ? S : `-${Uncapitalize<S>}`;
type KebabCase<S extends string> = S extends `${infer Start}${infer End}`
? `${Uncapitalize<Start>}${KebabCase<Kebab<End>>}`
: S;
type Prefix = '--soy-';
export type LayoutCssVarsProps = Pick<
AdminLayoutProps,
'headerHeight' | 'tabHeight' | 'siderWidth' | 'siderCollapsedWidth' | 'footerHeight'
> & {
headerZIndex?: number;
tabZIndex?: number;
siderZIndex?: number;
mobileSiderZIndex?: number;
footerZIndex?: number;
};
export type LayoutCssVars = {
[K in keyof LayoutCssVarsProps as `${Prefix}${KebabCase<K>}`]: string | number;
};
/**
* The mode of the tab
*
* - Button: button style
* - Chrome: chrome style
*
* @default chrome
*/
export type PageTabMode = 'button' | 'chrome' | 'slider';
export interface PageTabProps {
/** Whether is dark mode */
darkMode?: boolean;
/**
* The mode of the tab
*
* - {@link TabMode}
*/
mode?: PageTabMode;
/**
* The common class of the layout
*
* Is can be used to configure the transition animation
*
* @default 'transition-all-300'
*/
commonClass?: string;
/** The class of the button tab */
buttonClass?: string;
/** The class of the chrome tab */
chromeClass?: string;
/** The class of the title tab */
sliderClass?: string;
/** Whether the tab is active */
active?: boolean;
/** The color of the active tab */
activeColor?: string;
/**
* Whether the tab is closable
*
* Show the close icon when true
*/
closable?: boolean;
}
export type PageTabCssVarsProps = {
primaryColor: string;
primaryColor1: string;
primaryColor2: string;
primaryColorOpacity1: string;
primaryColorOpacity2: string;
primaryColorOpacity3: string;
};
export type PageTabCssVars = {
[K in keyof PageTabCssVarsProps as `${Prefix}${KebabCase<K>}`]: string | number;
};

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

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