This commit is contained in:
zwt13703 2026-03-21 22:12:02 +08:00
commit f24b40eea0
625 changed files with 72017 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

27
.env.dev Normal file
View File

@ -0,0 +1,27 @@
# backend service base url, test environment
#VITE_SERVICE_BASE_URL=http://localhost:8080
VITE_SERVICE_BASE_URL=http://10.13.13.1:8090
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)**

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

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

3
packages/scripts/bin.ts Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env tsx
import './src/index.ts';

View File

@ -0,0 +1,28 @@
{
"name": "@sa/scripts",
"version": "2.0.2",
"bin": {
"sa": "./bin.ts"
},
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"devDependencies": {
"@soybeanjs/changelog": "0.3.25",
"bumpp": "10.3.2",
"c12": "3.3.3",
"cac": "6.7.14",
"consola": "3.4.2",
"enquirer": "2.4.1",
"execa": "9.6.1",
"kolorist": "1.8.0",
"npm-check-updates": "19.2.0",
"picomatch": "4.0.3",
"rimraf": "6.1.2"
}
}

View File

@ -0,0 +1,10 @@
import { generateChangelog, generateTotalChangelog } from '@soybeanjs/changelog';
import type { ChangelogOption } from '@soybeanjs/changelog';
export async function genChangelog(options?: Partial<ChangelogOption>, total = false) {
if (total) {
await generateTotalChangelog(options);
} else {
await generateChangelog(options);
}
}

View File

@ -0,0 +1,5 @@
import { rimraf } from 'rimraf';
export async function cleanup(paths: string[]) {
await rimraf(paths, { glob: true });
}

View File

@ -0,0 +1,84 @@
import path from 'node:path';
import { readFileSync } from 'node:fs';
import { prompt } from 'enquirer';
import { execCommand } from '../shared';
import { locales } from '../locales';
import type { Lang } from '../locales';
interface PromptObject {
types: string;
scopes: string;
description: string;
}
/**
* Git commit with Conventional Commits standard
*
* @param lang
*/
export async function gitCommit(lang: Lang = 'en-us') {
const { gitCommitMessages, gitCommitTypes, gitCommitScopes } = locales[lang];
const typesChoices = gitCommitTypes.map(([value, msg]) => {
const nameWithSuffix = `${value}:`;
const message = `${nameWithSuffix.padEnd(12)}${msg}`;
return {
name: value,
message
};
});
const scopesChoices = gitCommitScopes.map(([value, msg]) => ({
name: value,
message: `${value.padEnd(30)} (${msg})`
}));
const result = await prompt<PromptObject>([
{
name: 'types',
type: 'select',
message: gitCommitMessages.types,
choices: typesChoices
},
{
name: 'scopes',
type: 'select',
message: gitCommitMessages.scopes,
choices: scopesChoices
},
{
name: 'description',
type: 'text',
message: gitCommitMessages.description
}
]);
const breaking = result.description.startsWith('!') ? '!' : '';
const description = result.description.replace(/^!/, '').trim();
const commitMsg = `${result.types}(${result.scopes})${breaking}: ${description}`;
await execCommand('git', ['commit', '-m', commitMsg], { stdio: 'inherit' });
}
/** Git commit message verify */
export async function gitCommitVerify(lang: Lang = 'en-us', ignores: RegExp[] = []) {
const gitPath = await execCommand('git', ['rev-parse', '--show-toplevel']);
const gitMsgPath = path.join(gitPath, '.git', 'COMMIT_EDITMSG');
const commitMsg = readFileSync(gitMsgPath, 'utf8').trim();
if (ignores.some(regExp => regExp.test(commitMsg))) return;
const REG_EXP = /(?<type>[a-z]+)(?:\((?<scope>.+)\))?(?<breaking>!)?: (?<description>.+)/i;
if (!REG_EXP.test(commitMsg)) {
const errorMsg = locales[lang].gitCommitVerify;
throw new Error(errorMsg);
}
}

View File

@ -0,0 +1,6 @@
export * from './git-commit';
export * from './cleanup';
export * from './update-pkg';
export * from './changelog';
export * from './release';
export * from './router';

View File

@ -0,0 +1,12 @@
import { versionBump } from 'bumpp';
export async function release(execute = 'pnpm sa changelog', push = true) {
await versionBump({
files: ['**/package.json', '!**/node_modules'],
execute,
all: true,
tag: true,
commit: 'chore(projects): release v%s',
push
});
}

View File

@ -0,0 +1,90 @@
import process from 'node:process';
import path from 'node:path';
import { writeFile } from 'node:fs/promises';
import { existsSync, mkdirSync } from 'node:fs';
import { prompt } from 'enquirer';
import { green, red } from 'kolorist';
interface PromptObject {
routeName: string;
addRouteParams: boolean;
routeParams: string;
}
/** generate route */
export async function generateRoute() {
const result = await prompt<PromptObject>([
{
name: 'routeName',
type: 'text',
message: 'please enter route name',
initial: 'demo-route_child'
},
{
name: 'addRouteParams',
type: 'confirm',
message: 'add route params?',
initial: false
}
]);
if (result.addRouteParams) {
const answers = await prompt<PromptObject>({
name: 'routeParams',
type: 'text',
message: 'please enter route params',
initial: 'id'
});
Object.assign(result, answers);
}
const PAGE_DIR_NAME_PATTERN = /^[\w-]+[0-9a-zA-Z]+$/;
if (!PAGE_DIR_NAME_PATTERN.test(result.routeName)) {
throw new Error(`${red('route name is invalid, it only allow letters, numbers, "-" or "_"')}.
For example:
(1) one level route: ${green('demo-route')}
(2) two level route: ${green('demo-route_child')}
(3) multi level route: ${green('demo-route_child_child')}
(4) group route: ${green('_ignore_demo-route')}'
`);
}
const PARAM_REG = /^\w+$/g;
if (result.routeParams && !PARAM_REG.test(result.routeParams)) {
throw new Error(red('route params is invalid, it only allow letters, numbers or "_".'));
}
const cwd = process.cwd();
const [dir, ...rest] = result.routeName.split('_') as string[];
let routeDir = path.join(cwd, 'src', 'views', dir);
if (rest.length) {
routeDir = path.join(routeDir, rest.join('_'));
}
if (!existsSync(routeDir)) {
mkdirSync(routeDir, { recursive: true });
} else {
throw new Error(red('route already exists'));
}
const fileName = result.routeParams ? `[${result.routeParams}].vue` : 'index.vue';
const vueTemplate = `<script setup lang="ts"></script>
<template>
<div>${result.routeName}</div>
</template>
<style scoped></style>
`;
const filePath = path.join(routeDir, fileName);
await writeFile(filePath, vueTemplate);
}

View File

@ -0,0 +1,5 @@
import { execCommand } from '../shared';
export async function updatePkg(args: string[] = ['--deep', '-u']) {
execCommand('npx', ['npm-check-updates', ...args], { stdio: 'inherit' });
}

View File

@ -0,0 +1,39 @@
import process from 'node:process';
import { loadConfig } from 'c12';
import type { CliOption } from '../types';
const defaultOptions: CliOption = {
cwd: process.cwd(),
cleanupDirs: [
'dist',
'**/package-lock.json',
'**/yarn.lock',
'**/pnpm-lock.yaml',
'**/node_modules',
'!node_modules/**'
],
ncuCommandArgs: ['--deep', '-u'],
changelogOptions: {},
gitCommitVerifyIgnores: [
/^((Merge pull request)|(Merge (.*?) into (.*?)|(Merge branch (.*?)))(?:\r?\n)*$)/m,
/^(Merge tag (.*?))(?:\r?\n)*$/m,
/^(R|r)evert (.*)/,
/^(amend|fixup|squash)!/,
/^(Merged (.*?)(in|into) (.*)|Merged PR (.*): (.*))/,
/^Merge remote-tracking branch(\s*)(.*)/,
/^Automatic merge(.*)/,
/^Auto-merged (.*?) into (.*)/
]
};
export async function loadCliOptions(overrides?: Partial<CliOption>, cwd = process.cwd()) {
const { config } = await loadConfig<Partial<CliOption>>({
name: 'soybean',
defaults: defaultOptions,
overrides,
cwd,
packageJson: true
});
return config as CliOption;
}

109
packages/scripts/src/index.ts Executable file
View File

@ -0,0 +1,109 @@
import cac from 'cac';
import { blue, lightGreen } from 'kolorist';
import { version } from '../package.json';
import { cleanup, genChangelog, generateRoute, gitCommit, gitCommitVerify, release, updatePkg } from './commands';
import { loadCliOptions } from './config';
import type { Lang } from './locales';
type Command = 'cleanup' | 'update-pkg' | 'git-commit' | 'git-commit-verify' | 'changelog' | 'release' | 'gen-route';
type CommandAction<A extends object> = (args?: A) => Promise<void> | void;
type CommandWithAction<A extends object = object> = Record<Command, { desc: string; action: CommandAction<A> }>;
interface CommandArg {
/** Execute additional command after bumping and before git commit. Defaults to 'pnpm sa changelog' */
execute?: string;
/** Indicates whether to push the git commit and tag. Defaults to true */
push?: boolean;
/** Generate changelog by total tags */
total?: boolean;
/**
* The glob pattern of dirs to clean up
*
* If not set, it will use the default value
*
* Multiple values use "," to separate them
*/
cleanupDir?: string;
/**
* display lang of cli
*
* @default 'en-us'
*/
lang?: Lang;
}
export async function setupCli() {
const cliOptions = await loadCliOptions();
const cli = cac(blue('soybean-admin'));
cli
.version(lightGreen(version))
.option(
'-e, --execute [command]',
"Execute additional command after bumping and before git commit. Defaults to 'npx soy changelog'"
)
.option('-p, --push', 'Indicates whether to push the git commit and tag')
.option('-t, --total', 'Generate changelog by total tags')
.option(
'-c, --cleanupDir <dir>',
'The glob pattern of dirs to cleanup, If not set, it will use the default value, Multiple values use "," to separate them'
)
.option('-l, --lang <lang>', 'display lang of cli', { default: 'en-us', type: [String] })
.help();
const commands: CommandWithAction<CommandArg> = {
cleanup: {
desc: 'delete dirs: node_modules, dist, etc.',
action: async () => {
await cleanup(cliOptions.cleanupDirs);
}
},
'update-pkg': {
desc: 'update package.json dependencies versions',
action: async () => {
await updatePkg(cliOptions.ncuCommandArgs);
}
},
'git-commit': {
desc: 'git commit, generate commit message which match Conventional Commits standard',
action: async args => {
await gitCommit(args?.lang);
}
},
'git-commit-verify': {
desc: 'verify git commit message, make sure it match Conventional Commits standard',
action: async args => {
await gitCommitVerify(args?.lang, cliOptions.gitCommitVerifyIgnores);
}
},
changelog: {
desc: 'generate changelog',
action: async args => {
await genChangelog(cliOptions.changelogOptions, args?.total);
}
},
release: {
desc: 'release: update version, generate changelog, commit code',
action: async args => {
await release(args?.execute, args?.push);
}
},
'gen-route': {
desc: 'generate route',
action: async () => {
await generateRoute();
}
}
};
for (const [command, { desc, action }] of Object.entries(commands)) {
cli.command(command, lightGreen(desc)).action(action);
}
cli.parse();
}
setupCli();

View File

@ -0,0 +1,82 @@
import { bgRed, green, red, yellow } from 'kolorist';
export type Lang = 'zh-cn' | 'en-us';
export const locales = {
'zh-cn': {
gitCommitMessages: {
types: '请选择提交类型',
scopes: '请选择提交范围',
description: `请输入描述信息(${yellow('!')}开头表示破坏性改动`
},
gitCommitTypes: [
['feat', '新功能'],
['feat-wip', '开发中的功能,比如某功能的部分代码'],
['fix', '修复Bug'],
['docs', '只涉及文档更新'],
['typo', '代码或文档勘误,比如错误拼写'],
['style', '修改代码风格,不影响代码含义的变更'],
['refactor', '代码重构,既不修复 bug 也不添加功能的代码变更'],
['perf', '可提高性能的代码更改'],
['optimize', '优化代码质量的代码更改'],
['test', '添加缺失的测试或更正现有测试'],
['build', '影响构建系统或外部依赖项的更改'],
['ci', '对 CI 配置文件和脚本的更改'],
['chore', '没有修改src或测试文件的其他变更'],
['revert', '还原先前的提交']
] as [string, string][],
gitCommitScopes: [
['projects', '项目'],
['packages', '包'],
['components', '组件'],
['hooks', '钩子函数'],
['utils', '工具函数'],
['types', 'TS类型声明'],
['styles', '代码风格'],
['deps', '项目依赖'],
['release', '发布项目新版本'],
['other', '其他的变更']
] as [string, string][],
gitCommitVerify: `${bgRed(' 错误 ')} ${red('git 提交信息必须符合 Conventional Commits 标准!')}\n\n${green(
'推荐使用命令 `pnpm commit` 生成符合 Conventional Commits 标准的提交信息。\n获取有关 Conventional Commits 的更多信息,请访问此链接: https://conventionalcommits.org'
)}`
},
'en-us': {
gitCommitMessages: {
types: 'Please select a type',
scopes: 'Please select a scope',
description: `Please enter a description (add prefix ${yellow('!')} to indicate breaking change)`
},
gitCommitTypes: [
['feat', 'A new feature'],
['feat-wip', 'Features in development, such as partial code for a certain feature'],
['fix', 'A bug fix'],
['docs', 'Documentation only changes'],
['typo', 'Code or document corrections, such as spelling errors'],
['style', 'Changes that do not affect the meaning of the code'],
['refactor', 'A code change that neither fixes a bug nor adds a feature'],
['perf', 'A code change that improves performance'],
['optimize', 'A code change that optimizes code quality'],
['test', 'Adding missing tests or correcting existing tests'],
['build', 'Changes that affect the build system or external dependencies'],
['ci', 'Changes to our CI configuration files and scripts'],
['chore', "Other changes that don't modify src or test files"],
['revert', 'Reverts a previous commit']
] as [string, string][],
gitCommitScopes: [
['projects', 'project'],
['packages', 'packages'],
['components', 'components'],
['hooks', 'hook functions'],
['utils', 'utils functions'],
['types', 'TS declaration'],
['styles', 'style'],
['deps', 'project dependencies'],
['release', 'release project'],
['other', 'other changes']
] as [string, string][],
gitCommitVerify: `${bgRed(' ERROR ')} ${red('git commit message must match the Conventional Commits standard!')}\n\n${green(
'Recommended to use the command `pnpm commit` to generate Conventional Commits compliant commit information.\nGet more info about Conventional Commits, follow this link: https://conventionalcommits.org'
)}`
}
} satisfies Record<Lang, Record<string, unknown>>;

View File

@ -0,0 +1,7 @@
import type { Options } from 'execa';
export async function execCommand(cmd: string, args: string[], options?: Options) {
const { execa } = await import('execa');
const res = await execa(cmd, args, options);
return (res?.stdout as string)?.trim() || '';
}

View File

@ -0,0 +1,31 @@
import type { ChangelogOption } from '@soybeanjs/changelog';
export interface CliOption {
/** The project root directory */
cwd: string;
/**
* Cleanup dirs
*
* Glob pattern syntax {@link https://github.com/isaacs/minimatch}
*
* @default
* ```json
* ["** /dist", "** /pnpm-lock.yaml", "** /node_modules", "!node_modules/**"]
* ```
*/
cleanupDirs: string[];
/**
* Npm-check-updates command args
*
* @default ['--deep', '-u']
*/
ncuCommandArgs: string[];
/**
* Options of generate changelog
*
* @link https://github.com/soybeanjs/changelog
*/
changelogOptions: Partial<ChangelogOption>;
/** The ignore pattern list of git commit verify */
gitCommitVerifyIgnores: RegExp[];
}

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