Compare commits

...

85 Commits

Author SHA1 Message Date
Soybean
d5a3a25d3d fix(types): fix ts type error 2025-10-13 16:48:33 +08:00
Soybean
d567c057a8 chore(deps): update deps 2025-10-13 16:39:36 +08:00
HongxuanG
91fae50260 typo(other): Correct the typos in the Chinese translation and change "局右" to "居右"。 2025-10-04 00:08:25 +08:00
Lijun Shen
61fa4b7f3b feat(packages): materials support slider-tab. closed #823 2025-10-02 22:09:59 +08:00
Azir-11
ef7acc626f feat(projects): optimize tabs cache cleaning strategy. close #820. 2025-10-02 11:17:44 +08:00
恕瑞玛的皇帝
2a0c9f1b41 docs(projects): add github trendshift info. 2025-09-27 21:18:05 +08:00
Soybean
5be864a80b fix(packages): axios: fix json response. fixed #815 2025-09-17 17:06:01 +08:00
Soybean
b041fdd864 chore(deps): update deps 2025-09-17 14:38:20 +08:00
Soybean
3d72f954ed fix(types): fix proxy types 2025-08-28 00:11:13 +08:00
Soybean
1213531bef chore(deps): update deps 2025-08-28 00:09:07 +08:00
Soybean
805c338141 chore(packages): add picomatch to fix scripts 2025-08-28 00:08:44 +08:00
Azir-11
3c0a52825d feat(projects): modify the default value of the reset cache policy to 'refresh'. 2025-08-27 18:17:55 +08:00
Azir-11
100e0ea55d style(projects): format code. 2025-08-24 14:14:50 +08:00
Azir-11
257f1183fc feat(projects): support theme preset function. 2025-08-24 14:14:50 +08:00
NicholasLD
d73111116a refactor(menu): optimize the margin on the menu 2025-08-14 17:41:57 +08:00
Azir-11
29a2a5c66a feat(projects): add prompt information for scrolling mode and tab bar caching. 2025-08-13 15:54:15 +08:00
Azir-11
4005763c00 feat(components): replace NTooltip with IconTooltip and optimize the layout of related components. 2025-08-13 15:54:15 +08:00
Azir-11
a55b4dc073 feat(components): add the IconTooltip component. 2025-08-13 15:54:15 +08:00
Azir-11
be8f915a0c chore(other): update the ESLint validation configuration to support more file types. 2025-08-13 15:54:15 +08:00
Cell
358e129765 feat(hooks): add scrollX computation for total table width in useNaiveTable 2025-08-11 10:42:17 +08:00
Cell
9ea56c9b82 fix(packages): fix the parsing logic for stored data to ensure correct return of boolean values 2025-07-31 11:24:42 +08:00
Soybean
87adc35f2e refactor(hooks): remove useSignal hook and update exports 2025-07-20 00:31:03 +08:00
Soybean
ee4341457a refactor(hooks): streamline column visibility handling in useTable and table components 2025-07-20 00:30:16 +08:00
Soybean
8a7cd5934b fix(hooks): correct chart rendering logic in useEcharts 2025-07-19 20:09:20 +08:00
Soybean
c962f7b2c5 chore(deps): update deps 2025-07-19 19:45:49 +08:00
Soybean
8cc5177cda refactor(hooks)!: refactor useTable and enhance type definitions 2025-07-19 19:43:58 +08:00
Soybean
3a343eea33 optimize(projects): optimize api type file 2025-07-19 18:25:59 +08:00
Soybean
f83eefbc3e refactor(request): unify response transformation methods and deprecate transformBackendResponse 2025-07-19 12:04:00 +08:00
Soybean
936b834e62 optimize(hooks): optimize useEcharts 2025-07-19 02:53:46 +08:00
Soybean
c965140b87 refactor(hooks): optimize useContext and update useMixMenuContext 2025-07-19 02:40:25 +08:00
Soybean
32b8f99071 fix(table): add type annotations for records in useTable hook 2025-07-19 02:28:05 +08:00
Soybean
abaaa4a068 optimize(packages): remove ofetch package 2025-07-19 02:27:54 +08:00
Soybean
b4e125300e refactor(request)!: remove cancelRequest method and related logic from request instances 2025-07-19 02:24:14 +08:00
Soybean
50a5cba088 optimize(request): enhance request options and response handling with generic types 2025-07-19 02:17:50 +08:00
wenyuan
d6c8142bb4 refactor(projects): remove unnecessary logic in onRouteSwitchWhenLoggedIn 2025-07-15 22:04:18 +08:00
Soybean
8146858b96 optimize(projects): optimize theme drawer width 2025-07-14 00:48:17 +08:00
wenyuan
8b8a2083bb optimize(projects): improve robustness of second-level menu key logic 2025-07-14 00:48:16 +08:00
wenyuan
6207292d81 typo(projects): update description of vertical-hybrid-header-first layout mode 2025-07-14 00:48:16 +08:00
wenyuan
b4e5c6d990 feat(projects): add 'vertical-hybrid-header-first' layout mode 2025-07-14 00:48:16 +08:00
Azir
b6ac3106ce feat(projects)!: optimize layout mode, split horizontal mix component into two layouts, and rename the component. 2025-07-14 00:48:16 +08:00
Azir
d37ce04606 refactor(types): move Auth and Route namespaces to separate files and clean up api.d.ts 2025-07-14 00:48:16 +08:00
wenyuan
8439a60070 optimize(projects): improve theme drawer responsive width for mobile devices 2025-07-14 00:48:16 +08:00
wenyuan
f238fcbd47 feat(projects): Add current time display option for watermark (#772)
* feat(projects): Add current time display option for watermark

* perf(projects): add watermark timer controls
2025-07-14 00:48:16 +08:00
Azir
8ba71a0857 feat(projects): refactor theme drawer with tabbed layout for better UX. 2025-07-14 00:48:16 +08:00
Soybean
e89b86ce56 chore(deps): update deps 2025-07-14 00:44:44 +08:00
Azir
03dd64c543 chore(packages): update Vite version to 7 in package.json and documentation.
(cherry picked from commit ef806edd9d0c48ad8669863516d52e2eb8870d6f)
2025-07-03 22:08:50 +08:00
Soybean
aeb6369005 chore(deps): update deps 2025-07-03 22:06:30 +08:00
Soybean
133196f337 chore(vscode): remove unused vue.server.hybridMode setting from .vscode/settings.json 2025-07-03 22:02:30 +08:00
xiaobao
41191d54fb fix(projects): Fix i18n-ally not working when setting moduleResolution to bundler. fixed #780 2025-07-03 21:59:18 +08:00
Soybean
5cb1cebd88 chore(deps): update deps 2025-06-27 18:17:05 +08:00
Junior25306
a5c4b4e3b7 chore(deps): update NodeJS and pnpm version requirements in package.json and documentation 2025-06-25 18:12:25 +08:00
Soybean
87a675bf62 chore(projects): release v1.3.15 2025-06-24 21:40:19 +08:00
Soybean
4d42dcbea8 docs(readme): add warning about upcoming V2 version and link to plan list 2025-06-24 21:39:21 +08:00
Soybean
276d836c87 refactor(iframe-page): remove unused lifecycle hooks and clean up script setup 2025-06-24 21:31:57 +08:00
Soybean
7d84062e2c fix(app): replace console.error with window.console.error for consistency 2025-06-24 21:31:17 +08:00
wenyuan
afd604212b fix(projects): ensure proper text color when themes are inverted 2025-06-24 21:20:24 +08:00
wenyuan
fcb89883fa optimize(components): optimize spacing for lang-switch dropdown options 2025-06-24 21:20:20 +08:00
Soybean
dc674ce870 chore(deps): update deps 2025-06-24 21:11:08 +08:00
Soybean
dbd995c12c chore(projects): update deps & fix moduleResolution 2025-06-24 19:29:12 +08:00
Azir
7b2e510a2f docs(other): update docs with video tutorial link. 2025-06-24 10:17:50 +08:00
chenziwen
da149e5bbd fix(types): The environment variable VITE_ICON_LOCAL_PREFIX has the wrong type. 2025-06-14 22:01:01 +08:00
wenyuan
7c3dac4212 feat(projects): add configurable user name watermark option 2025-06-10 19:05:08 +08:00
Soybean
39b89a1234 chore(projects): release v1.3.14 2025-06-09 22:40:03 +08:00
Soybean
c57f88aad2 fix(auth): remove redundant authStore declaration in resetStore function 2025-06-09 22:34:57 +08:00
Soybean
3e4e17abd8 chore(deps): update deps 2025-06-09 22:10:30 +08:00
wenyuan
e6044d0fc7 optimize(projects): optimize tab deletion logic. closed #755 2025-06-09 14:19:44 +08:00
Soybean
2ed0b6484c feat(docs): add DartNode sponsorship badge to README files 2025-06-06 22:50:17 +08:00
恕瑞玛的皇帝
222187d3b0 optimize(hooks): update detection function to cover the exceptions that occur when the request fails. 2025-05-31 13:41:02 +08:00
t8y2
75455b006c feat(theme): global search button toggle
- 在主题设置中添加全局搜索按钮的显示控制选项
- 更新多语言文件,添加全局搜索按钮显示控制的翻译
- 修改全局头部组件,根据主题设置决定是否显示全局搜索按钮
- 在主题抽屉中添加全局搜索按钮显示控制的开关
2025-05-31 13:40:42 +08:00
Azir
dfb647a82c fix(hooks): refactor useCountDown hook for improved countdown logic and clarity. 2025-05-26 15:11:01 +08:00
Azir
7fb5c72f7e fix(projects): tab closure did not remove cache correctly. 2025-05-26 15:11:01 +08:00
Soybean
41b5f49341 chore(deps): update deps 2025-05-11 22:03:37 +08:00
Soybean
f35c250a89 docs(projects): add gitcode link 2025-05-11 22:01:47 +08:00
青菜白玉汤
1ff4d82d19 feat(projects): clear tabs cache when switching users. (#744) 2025-04-30 14:46:32 +08:00
青菜白玉汤
c3abc3df09 ci(hooks): remove lint-staged in git hook. (#743) close #724 2025-04-28 19:17:43 +08:00
WgoW
a013ea2c46 docs(README): Add supporting ecosystem tools to the open-source repository (#740)
Co-authored-by: a <a@gmail.com>
2025-04-23 22:23:13 +08:00
tu6ge
61244f0f2a chore(deps): add vscode recommend plugin (#739) close #738 2025-04-23 21:34:48 +08:00
青菜白玉汤
85e40b1943 fix(hooks): fixed the issue where loading was not properly closed in some cases. (#737) 2025-04-23 21:34:18 +08:00
WgoW
123d2c9074 feat(types): enhance Option type to support customizable label types (#735)
Co-authored-by: a <a@gmail.com>
2025-04-23 21:34:06 +08:00
Soybean
05dc11e258 feat(docs): add GitCode star badge to README files 2025-04-18 11:25:38 +08:00
青菜白玉汤
8527aa80b8 feat(utils): support quick generation of code templates. (#733) 2025-04-14 08:42:10 +08:00
青菜白玉汤
804860994e docs(deps): update the Vite version of the project description. (#732) 2025-04-12 15:27:27 +08:00
青菜白玉汤
29698bef69 feat(projects): support vite devtools specify the editor by launchEditor option. (#730) 2025-04-04 12:00:52 +08:00
青菜白玉汤
4e1b65b6c4 optimize(hooks): remove obsolete disabling cache. (#729) 2025-04-01 11:36:27 +08:00
XiaTianYa
3cbaf4f4bf docs(projects): update README (#726) 2025-03-21 12:40:38 +08:00
112 changed files with 5358 additions and 3969 deletions

4
.env
View File

@@ -54,3 +54,7 @@ VITE_AUTOMATICALLY_DETECT_UPDATE=Y
# show proxy url log in terminal # show proxy url log in terminal
VITE_PROXY_LOG=Y 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

View File

@@ -14,6 +14,7 @@
"sdras.vue-vscode-snippets", "sdras.vue-vscode-snippets",
"vue.volar", "vue.volar",
"whtouche.vscode-js-console-utils", "whtouche.vscode-js-console-utils",
"zhuangtongfa.material-theme" "zhuangtongfa.material-theme",
"tu6ge.naive-ui-intelligence"
] ]
} }

19
.vscode/settings.json vendored
View File

@@ -4,15 +4,28 @@
"source.organizeImports": "never" "source.organizeImports": "never"
}, },
"editor.formatOnSave": false, "editor.formatOnSave": false,
"eslint.validate": ["html", "css", "scss", "json", "jsonc"], "eslint.validate": [
"html",
"css",
"scss",
"json",
"jsonc",
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue"
],
"i18n-ally.displayLanguage": "zh-cn", "i18n-ally.displayLanguage": "zh-cn",
"i18n-ally.enabledParsers": ["ts"], "i18n-ally.enabledParsers": ["ts"],
"i18n-ally.enabledFrameworks": ["vue"], "i18n-ally.enabledFrameworks": ["vue"],
"i18n-ally.editor.preferEditor": true, "i18n-ally.editor.preferEditor": true,
"i18n-ally.keystyle": "nested", "i18n-ally.keystyle": "nested",
"i18n-ally.localesPaths": ["src/locales/langs"], "i18n-ally.localesPaths": ["src/locales/langs"],
"i18n-ally.parsers.typescript.compilerOptions": {
"moduleResolution": "node"
},
"prettier.enable": false, "prettier.enable": false,
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"unocss.root": ["./"], "unocss.root": ["./"]
"vue.server.hybridMode": true
} }

51
.vscode/vue3.code-snippets vendored Normal file
View File

@@ -0,0 +1,51 @@
{
"Print soy Vue3 SFC page": {
"scope": "vue",
"prefix": ["v3","page","view"],
"body": [
"<script lang=\"ts\" setup>",
"//$1",
"</script>\n",
"<template>",
" <div class=\"\">",
" <!-- page only one root element -->",
" $2",
" </div>",
"</template>\n",
],
},
"Print soy Vue3 SFC Component": {
"scope": "vue",
"prefix": ["component","comp"],
"body": [
"<script lang=\"ts\" setup>",
"//$1",
"</script>\n",
"<template>",
" <div class=\"\">",
" $2",
" </div>",
"</template>\n",
],
},
"Print soy style": {
"scope": "vue",
"prefix": "st",
"body": ["<style scoped>", "//", "</style>\n"],
},
"Print soy script": {
"scope": "vue",
"prefix": "sc",
"body": ["<script lang=\"ts\" setup>", "//$3", "</script>\n"],
},
"Print soy template": {
"scope": "vue",
"prefix": "te",
"body": [
"<template>",
" <div class=\"\">$1</div>",
"</template>\n",
],
},
}

View File

@@ -1,6 +1,102 @@
# Changelog # Changelog
## [v1.3.15](https://github.com/soybeanjs/soybean-admin/compare/v1.3.14...v1.3.15) (2025-06-24)
### &nbsp;&nbsp;&nbsp;🚀 Features
- **projects**: add configurable user name watermark option &nbsp;-&nbsp; by @wenyuanw [<samp>(7c3da)</samp>](https://github.com/soybeanjs/soybean-admin/commit/7c3dac42)
### &nbsp;&nbsp;&nbsp;🐞 Bug Fixes
- **app**: replace console.error with window.console.error for consistency &nbsp;-&nbsp; by @soybeanjs [<samp>(7d840)</samp>](https://github.com/soybeanjs/soybean-admin/commit/7d84062e)
- **projects**: ensure proper text color when themes are inverted &nbsp;-&nbsp; by @wenyuanw [<samp>(afd60)</samp>](https://github.com/soybeanjs/soybean-admin/commit/afd60421)
- **types**: The environment variable VITE_ICON_LOCAL_PREFIX has the wrong type. &nbsp;-&nbsp; by **chenziwen** [<samp>(da149)</samp>](https://github.com/soybeanjs/soybean-admin/commit/da149e5b)
### &nbsp;&nbsp;&nbsp;🛠 Optimizations
- **components**: optimize spacing for lang-switch dropdown options &nbsp;-&nbsp; by @wenyuanw [<samp>(fcb89)</samp>](https://github.com/soybeanjs/soybean-admin/commit/fcb89883)
### &nbsp;&nbsp;&nbsp;💅 Refactors
- **iframe-page**: remove unused lifecycle hooks and clean up script setup &nbsp;-&nbsp; by @soybeanjs [<samp>(276d8)</samp>](https://github.com/soybeanjs/soybean-admin/commit/276d836c)
### &nbsp;&nbsp;&nbsp;📖 Documentation
- **other**: update docs with video tutorial link. &nbsp;-&nbsp; by **Azir** [<samp>(7b2e5)</samp>](https://github.com/soybeanjs/soybean-admin/commit/7b2e510a)
- **readme**: add warning about upcoming `V2` version and link to plan list &nbsp;-&nbsp; by @soybeanjs [<samp>(4d42d)</samp>](https://github.com/soybeanjs/soybean-admin/commit/4d42dcbe)
### &nbsp;&nbsp;&nbsp;🏡 Chore
- **deps**: update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(dc674)</samp>](https://github.com/soybeanjs/soybean-admin/commit/dc674ce8)
- **projects**: update deps & fix `moduleResolution` &nbsp;-&nbsp; by @soybeanjs [<samp>(dbd99)</samp>](https://github.com/soybeanjs/soybean-admin/commit/dbd995c1)
### &nbsp;&nbsp;&nbsp;❤️ Contributors
[![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;
[Azir](mailto:2075125282@qq.com),&nbsp;[chenziwen](mailto:chenziwen@qesong.com)
## [v1.3.14](https://github.com/soybeanjs/soybean-admin/compare/v1.3.13...v1.3.14) (2025-06-09)
### &nbsp;&nbsp;&nbsp;🚀 Features
- **docs**:
- add GitCode star badge to README files &nbsp;-&nbsp; by @soybeanjs [<samp>(05dc1)</samp>](https://github.com/soybeanjs/soybean-admin/commit/05dc11e2)
- add DartNode sponsorship badge to README files &nbsp;-&nbsp; by @soybeanjs [<samp>(2ed0b)</samp>](https://github.com/soybeanjs/soybean-admin/commit/2ed0b648)
- **projects**:
- support vite devtools specify the editor by launchEditor option. &nbsp;-&nbsp; by @Azir-11 in https://github.com/soybeanjs/soybean-admin/issues/730 [<samp>(29698)</samp>](https://github.com/soybeanjs/soybean-admin/commit/29698bef)
- clear tabs cache when switching users. &nbsp;-&nbsp; by @Azir-11 in https://github.com/soybeanjs/soybean-admin/issues/744 [<samp>(1ff4d)</samp>](https://github.com/soybeanjs/soybean-admin/commit/1ff4d82d)
- **theme**:
- global search button toggle &nbsp;-&nbsp; by **t8y2** [<samp>(75455)</samp>](https://github.com/soybeanjs/soybean-admin/commit/75455b00)
- **types**:
- enhance Option type to support customizable label types &nbsp;-&nbsp; by @WgoW and @testbrate in https://github.com/soybeanjs/soybean-admin/issues/735 [<samp>(123d2)</samp>](https://github.com/soybeanjs/soybean-admin/commit/123d2c90)
- **utils**:
- support quick generation of code templates. &nbsp;-&nbsp; by @Azir-11 in https://github.com/soybeanjs/soybean-admin/issues/733 [<samp>(8527a)</samp>](https://github.com/soybeanjs/soybean-admin/commit/8527aa80)
### &nbsp;&nbsp;&nbsp;🐞 Bug Fixes
- **auth**:
- remove redundant authStore declaration in resetStore function &nbsp;-&nbsp; by @soybeanjs [<samp>(c57f8)</samp>](https://github.com/soybeanjs/soybean-admin/commit/c57f88aa)
- **hooks**:
- fixed the issue where loading was not properly closed in some cases. &nbsp;-&nbsp; by @Azir-11 in https://github.com/soybeanjs/soybean-admin/issues/737 [<samp>(85e40)</samp>](https://github.com/soybeanjs/soybean-admin/commit/85e40b19)
- refactor useCountDown hook for improved countdown logic and clarity. &nbsp;-&nbsp; by **Azir** [<samp>(dfb64)</samp>](https://github.com/soybeanjs/soybean-admin/commit/dfb647a8)
- **projects**:
- tab closure did not remove cache correctly. &nbsp;-&nbsp; by **Azir** [<samp>(7fb5c)</samp>](https://github.com/soybeanjs/soybean-admin/commit/7fb5c72f)
### &nbsp;&nbsp;&nbsp;🛠 Optimizations
- **hooks**:
- remove obsolete disabling cache. &nbsp;-&nbsp; by @Azir-11 in https://github.com/soybeanjs/soybean-admin/issues/729 [<samp>(4e1b6)</samp>](https://github.com/soybeanjs/soybean-admin/commit/4e1b65b6)
- update detection function to cover the exceptions that occur when the request fails. &nbsp;-&nbsp; by **恕瑞玛的皇帝** [<samp>(22218)</samp>](https://github.com/soybeanjs/soybean-admin/commit/222187d3)
- **projects**:
- optimize tab deletion logic. closed #755 &nbsp;-&nbsp; by @wenyuanw in https://github.com/soybeanjs/soybean-admin/issues/755 [<samp>(e6044)</samp>](https://github.com/soybeanjs/soybean-admin/commit/e6044d0f)
### &nbsp;&nbsp;&nbsp;📖 Documentation
- **README**:
- Add supporting ecosystem tools to the open-source repository &nbsp;-&nbsp; by @WgoW and @testbrate in https://github.com/soybeanjs/soybean-admin/issues/740 [<samp>(a013e)</samp>](https://github.com/soybeanjs/soybean-admin/commit/a013ea2c)
- **deps**:
- update the Vite version of the project description. &nbsp;-&nbsp; by @Azir-11 in https://github.com/soybeanjs/soybean-admin/issues/732 [<samp>(80486)</samp>](https://github.com/soybeanjs/soybean-admin/commit/80486099)
- **projects**:
- update README &nbsp;-&nbsp; by @xiatianYa in https://github.com/soybeanjs/soybean-admin/issues/726 [<samp>(3cbaf)</samp>](https://github.com/soybeanjs/soybean-admin/commit/3cbaf4f4)
- add gitcode link &nbsp;-&nbsp; by @soybeanjs [<samp>(f35c2)</samp>](https://github.com/soybeanjs/soybean-admin/commit/f35c250a)
### &nbsp;&nbsp;&nbsp;🏡 Chore
- **deps**:
- add vscode recommend plugin close #738 &nbsp;-&nbsp; by @tu6ge in https://github.com/soybeanjs/soybean-admin/issues/739 and https://github.com/soybeanjs/soybean-admin/issues/738 [<samp>(61244)</samp>](https://github.com/soybeanjs/soybean-admin/commit/61244f0f)
- update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(41b5f)</samp>](https://github.com/soybeanjs/soybean-admin/commit/41b5f493)
- update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(3e4e1)</samp>](https://github.com/soybeanjs/soybean-admin/commit/3e4e17ab)
### &nbsp;&nbsp;&nbsp;🤖 CI
- **hooks**: remove lint-staged in git hook. close #724 &nbsp;-&nbsp; by @Azir-11 in https://github.com/soybeanjs/soybean-admin/issues/743 and https://github.com/soybeanjs/soybean-admin/issues/724 [<samp>(c3abc)</samp>](https://github.com/soybeanjs/soybean-admin/commit/c3abc3df)
### &nbsp;&nbsp;&nbsp;❤️ Contributors
[![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;[![Azir-11](https://github.com/Azir-11.png?size=48)](https://github.com/Azir-11)&nbsp;&nbsp;[![WgoW](https://github.com/WgoW.png?size=48)](https://github.com/WgoW)&nbsp;&nbsp;[![testbrate](https://github.com/testbrate.png?size=48)](https://github.com/testbrate)&nbsp;&nbsp;[![tu6ge](https://github.com/tu6ge.png?size=48)](https://github.com/tu6ge)&nbsp;&nbsp;[![xiatianYa](https://github.com/xiatianYa.png?size=48)](https://github.com/xiatianYa)&nbsp;&nbsp;
[恕瑞玛的皇帝](mailto:2075125282@qq.com),&nbsp;[t8y2](mailto:1156263951@qq.com),&nbsp;
## [v1.3.13](https://github.com/soybeanjs/soybean-admin/compare/v1.3.12...v1.3.13) (2025-03-19) ## [v1.3.13](https://github.com/soybeanjs/soybean-admin/compare/v1.3.12...v1.3.13) (2025-03-19)
### &nbsp;&nbsp;&nbsp;🐞 Bug Fixes ### &nbsp;&nbsp;&nbsp;🐞 Bug Fixes

View File

@@ -10,21 +10,31 @@
[![github stars](https://img.shields.io/github/stars/soybeanjs/soybean-admin)](https://github.com/soybeanjs/soybean-admin) [![github stars](https://img.shields.io/github/stars/soybeanjs/soybean-admin)](https://github.com/soybeanjs/soybean-admin)
[![github forks](https://img.shields.io/github/forks/soybeanjs/soybean-admin)](https://github.com/soybeanjs/soybean-admin) [![github forks](https://img.shields.io/github/forks/soybeanjs/soybean-admin)](https://github.com/soybeanjs/soybean-admin)
[![gitee stars](https://gitee.com/honghuangdc/soybean-admin/badge/star.svg)](https://gitee.com/honghuangdc/soybean-admin) [![gitee stars](https://gitee.com/honghuangdc/soybean-admin/badge/star.svg)](https://gitee.com/honghuangdc/soybean-admin)
[![gitcode star](https://gitcode.com/soybeanjs/soybean-admin/star/badge.svg)](https://gitcode.com/soybeanjs/soybean-admin)
[![Powered by DartNode](https://dartnode.com/branding/DN-Open-Source-sm.png)](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")
<a href="https://hellogithub.com/repository/1298f27d5fe54959a16cf9686516ddb3" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=1298f27d5fe54959a16cf9686516ddb3&claim_uid=IiDXWmP4TEntjbV" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a> <div style="display: flex; gap: 12px; align-items: center;">
<a href="https://trendshift.io/repositories/7963" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7963" alt="soybeanjs%2Fsoybean-admin | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/1298f27d5fe54959a16cf9686516ddb3" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=1298f27d5fe54959a16cf9686516ddb3&claim_uid=IiDXWmP4TEntjbV" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
> [!NOTE] > [!NOTE]
> If you think `SoybeanAdmin` is helpful to you, or you like our project, please give us a ⭐️ on GitHub. Your support is the driving force for us to continue to improve and add new features! Thank you for your support! > If you think `SoybeanAdmin` is helpful to you, or you like our project, please give us a ⭐️ on GitHub. Your support is the driving force for us to continue to improve and add new features! Thank you for your support!
> [!NOTE]
> The `SoybeanAdmin` quick start series videos have been uploaded to [Bilibili](https://www.bilibili.com/video/BV1YKdRYXELC) Go online [click here](https://www.bilibili.com/video/BV1YKdRYXELC) Go check it out
> [!WARNING]
> `SoybeanAdmin` is planning to develop a `V2` version, see [plan list](https://github.com/soybeanjs/soybean-admin/issues/767)
## Introduction ## Introduction
[`SoybeanAdmin`](https://github.com/soybeanjs/soybean-admin) is a clean, elegant, beautiful and powerful admin template, based on the latest front-end technology stack, including Vue3, Vite5, TypeScript, Pinia and UnoCSS. It has built-in rich theme configuration and components, strict code specifications, and an automated file routing system. In addition, it also uses the online mock data solution based on ApiFox. `SoybeanAdmin` provides you with a one-stop admin solution, no additional configuration, and out of the box. It is also a best practice for learning cutting-edge technologies quickly. [`SoybeanAdmin`](https://github.com/soybeanjs/soybean-admin) is a clean, elegant, beautiful and powerful admin template, based on the latest front-end technology stack, including Vue3, Vite7, TypeScript, Pinia and UnoCSS. It has built-in rich theme configuration and components, strict code specifications, and an automated file routing system. In addition, it also uses the online mock data solution based on ApiFox. `SoybeanAdmin` provides you with a one-stop admin solution, no additional configuration, and out of the box. It is also a best practice for learning cutting-edge technologies quickly.
## Features ## Features
- **Cutting-edge technology application**: using the latest popular technology stack such as Vue3, Vite5, TypeScript, Pinia and UnoCSS. - **Cutting-edge technology application**: using the latest popular technology stack such as Vue3, Vite7, TypeScript, Pinia and UnoCSS.
- **Clear project architecture**: using pnpm monorepo architecture, clear structure, elegant and easy to understand. - **Clear project architecture**: using pnpm monorepo architecture, clear structure, elegant and easy to understand.
- **Strict code specifications**: follow the [SoybeanJS specification](https://docs.soybeanjs.cn/standard), integrate eslint, prettier and simple-git-hooks to ensure the code is standardized. - **Strict code specifications**: follow the [SoybeanJS specification](https://docs.soybeanjs.cn/standard), integrate eslint, prettier and simple-git-hooks to ensure the code is standardized.
- **TypeScript**: support strict type checking to improve code maintainability. - **TypeScript**: support strict type checking to improve code maintainability.
@@ -43,19 +53,25 @@
- [Preview Link](https://naive.soybeanjs.cn/) - [Preview Link](https://naive.soybeanjs.cn/)
- [Github Repository](https://github.com/soybeanjs/soybean-admin) - [Github Repository](https://github.com/soybeanjs/soybean-admin)
- [Gitee Repository](https://gitee.com/honghuangdc/soybean-admin) - [Gitee Repository](https://gitee.com/honghuangdc/soybean-admin)
- [Gitcode Repository](https://gitcode.com/soybeanjs/soybean-admin)
- **AntDesignVue Version:** - **AntDesignVue Version:**
- [Preview Link](https://antd.soybeanjs.cn/) - [Preview Link](https://antd.soybeanjs.cn/)
- [Github Repository](https://github.com/soybeanjs/soybean-admin-antd) - [Github Repository](https://github.com/soybeanjs/soybean-admin-antd)
- [Gitee Repository](https://gitee.com/honghuangdc/soybean-admin-antd) - [Gitee Repository](https://gitee.com/honghuangdc/soybean-admin-antd)
- [Gitcode Repository](https://gitcode.com/soybeanjs/soybean-admin-antd)
- **ElementPlus Version:** - **ElementPlus Version:**
- [Preview Link](https://elp.soybeanjs.cn/) - [Preview Link](https://elp.soybeanjs.cn/)
- [Github Repository](https://github.com/soybeanjs/soybean-admin-element-plus) - [Github Repository](https://github.com/soybeanjs/soybean-admin-element-plus)
- [Gitee Repository](https://gitee.com/honghuangdc/soybean-admin-element-plus)
- [Gitcode Repository](https://gitcode.com/soybeanjs/soybean-admin-element-plus)
- **Legacy Version:** - **Legacy Version:**
- [Preview Link](https://legacy.soybeanjs.cn/) - [Preview Link](https://legacy.soybeanjs.cn/)
- [Github Repository](https://github.com/soybeanjs/soybean-admin/tree/legacy) - [Github Repository](https://github.com/soybeanjs/soybean-admin/tree/legacy)
- [Gitee Repository](https://gitee.com/honghuangdc/soybean-admin/tree/legacy)
- [Gitcode Repository](https://gitcode.com/soybeanjs/soybean-admin/tree/legacy)
## Documentation ## Documentation
@@ -85,13 +101,18 @@
Make sure your environment meets the following requirements: Make sure your environment meets the following requirements:
- **git**: you need git to clone and manage project versions. - **git**: you need git to clone and manage project versions.
- **NodeJS**: >=18.12.0, recommended 18.19.0 or higher. - **NodeJS**: >=20.19.0, recommended 20.19.0 or higher.
- **pnpm**: >= 8.7.0, recommended 8.14.0 or higher. - **pnpm**: >= 10.5.0, recommended 10.5.0 or higher.
**Clone Project** **Clone Project**
```bash ```bash
# github
git clone https://github.com/soybeanjs/soybean-admin.git git clone https://github.com/soybeanjs/soybean-admin.git
# gitee
git clone https://gitee.com/honghuangdc/soybean-admin.git
# gitcode
git clone https://gitcode.com/soybeanjs/soybean-admin.git
``` ```
**Install Dependencies** **Install Dependencies**
@@ -128,6 +149,8 @@ Refer to the [Code Synchronization](https://docs.soybeanjs.cn/guide/sync) docume
- [snail-job](https://github.com/aizuda/snail-job): A distributed task retry and task scheduling platform with "high performance, high value and high activity". - [snail-job](https://github.com/aizuda/snail-job): A distributed task retry and task scheduling platform with "high performance, high value and high activity".
- [SuperApi](https://github.com/TmmTop/SuperApi): Quickly turn your idea into an online stable product! Entity-less library and table building, add, delete, change and check entity-less library table, support 15 kinds of condition query, as well as paging, list, unlimited tree list and other functions of the API deployment! With interface documentation, Auth authorisation, interface flow restriction, access to the client's real IP, advanced server caching components, dynamic APIs and other features, we look forward to your experience! - [SuperApi](https://github.com/TmmTop/SuperApi): Quickly turn your idea into an online stable product! Entity-less library and table building, add, delete, change and check entity-less library table, support 15 kinds of condition query, as well as paging, list, unlimited tree list and other functions of the API deployment! With interface documentation, Auth authorisation, interface flow restriction, access to the client's real IP, advanced server caching components, dynamic APIs and other features, we look forward to your experience!
- [FastSoyAdmin](https://github.com/sleep1223/fast-soy-admin): A modern Management Platform based on FastAPI+Vue3+Naive UI. - [FastSoyAdmin](https://github.com/sleep1223/fast-soy-admin): A modern Management Platform based on FastAPI+Vue3+Naive UI.
- [ba](https://github.com/xiatianYa/Ba-Server): Backend service docking with soybean admin based on goFrame framework, adapted to dynamic routing, and interface authentication permissions.
- [soybean-admin-go](https://github.com/WgoW/soybean-admin-go):A Go backend service developed based on the Gin and GORM frameworks, integrated with the example branch of Soybean Admin. It supports dynamic routing and API permission authentication.
## How to Contribute ## How to Contribute

View File

@@ -7,22 +7,33 @@
--- ---
[![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) [![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
[![github stars](https://img.shields.io/github/stars/honghuangdc/soybean-admin)](https://github.com/soybeanjs/soybean-admin) [![github stars](https://img.shields.io/github/stars/soybeanjs/soybean-admin)](https://github.com/soybeanjs/soybean-admin)
[![github forks](https://img.shields.io/github/forks/honghuangdc/soybean-admin)](https://github.com/soybeanjs/soybean-admin) [![github forks](https://img.shields.io/github/forks/soybeanjs/soybean-admin)](https://github.com/soybeanjs/soybean-admin)
[![gitee stars](https://gitee.com/honghuangdc/soybean-admin/badge/star.svg)](https://gitee.com/honghuangdc/soybean-admin) [![gitee stars](https://gitee.com/honghuangdc/soybean-admin/badge/star.svg)](https://gitee.com/honghuangdc/soybean-admin)
[![gitcode star](https://gitcode.com/soybeanjs/soybean-admin/star/badge.svg)](https://gitcode.com/soybeanjs/soybean-admin)
[![Powered by DartNode](https://dartnode.com/branding/DN-Open-Source-sm.png)](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")
<a href="https://hellogithub.com/repository/1298f27d5fe54959a16cf9686516ddb3" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=1298f27d5fe54959a16cf9686516ddb3&claim_uid=IiDXWmP4TEntjbV" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a> <div style="display: flex; gap: 12px; align-items: center;">
<a href="https://trendshift.io/repositories/7963" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7963" alt="soybeanjs%2Fsoybean-admin | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/1298f27d5fe54959a16cf9686516ddb3" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=1298f27d5fe54959a16cf9686516ddb3&claim_uid=IiDXWmP4TEntjbV" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
> [!NOTE] > [!NOTE]
> 如果您觉得 `SoybeanAdmin`对您有所帮助,或者您喜欢我们的项目,请在 GitHub 上给我们一个 ⭐️。您的支持是我们持续改进和增加新功能的动力!感谢您的支持! > 如果您觉得 `SoybeanAdmin`对您有所帮助,或者您喜欢我们的项目,请在 GitHub 上给我们一个 ⭐️。您的支持是我们持续改进和增加新功能的动力!感谢您的支持!
> [!NOTE]
> `SoybeanAdmin` 快速上手系列视频已在 [Bilibili](https://www.bilibili.com/video/BV1YKdRYXELC) 上线 [点击这里](https://www.bilibili.com/video/BV1YKdRYXELC) 前往查看
> [!WARNING]
> `SoybeanAdmin` 正在计划开发 `V2` 版本,详情见[计划清单](https://github.com/soybeanjs/soybean-admin/issues/767)
## 简介 ## 简介
[`SoybeanAdmin`](https://github.com/soybeanjs/soybean-admin) 是一个清新优雅、高颜值且功能强大的后台管理模板,基于最新的前端技术栈,包括 Vue3, Vite5, TypeScript, Pinia 和 UnoCSS。它内置了丰富的主题配置和组件代码规范严谨实现了自动化的文件路由系统。此外它还采用了基于 ApiFox 的在线Mock数据方案。`SoybeanAdmin` 为您提供了一站式的后台管理解决方案,无需额外配置,开箱即用。同样是一个快速学习前沿技术的最佳实践。 [`SoybeanAdmin`](https://github.com/soybeanjs/soybean-admin) 是一个清新优雅、高颜值且功能强大的后台管理模板,基于最新的前端技术栈,包括 Vue3, Vite7, TypeScript, Pinia 和 UnoCSS。它内置了丰富的主题配置和组件代码规范严谨实现了自动化的文件路由系统。此外它还采用了基于 ApiFox 的在线Mock数据方案。`SoybeanAdmin` 为您提供了一站式的后台管理解决方案,无需额外配置,开箱即用。同样是一个快速学习前沿技术的最佳实践。
## 特性 ## 特性
- **前沿技术应用**:采用 Vue3, Vite5, TypeScript, Pinia 和 UnoCSS 等最新流行的技术栈。 - **前沿技术应用**:采用 Vue3, Vite7, TypeScript, Pinia 和 UnoCSS 等最新流行的技术栈。
- **清晰的项目架构**:采用 pnpm monorepo 架构,结构清晰,优雅易懂。 - **清晰的项目架构**:采用 pnpm monorepo 架构,结构清晰,优雅易懂。
- **严格的代码规范**:遵循 [SoybeanJS 规范](https://docs.soybeanjs.cn/zh/standard)集成了eslint, prettier 和 simple-git-hooks保证代码的规范性。 - **严格的代码规范**:遵循 [SoybeanJS 规范](https://docs.soybeanjs.cn/zh/standard)集成了eslint, prettier 和 simple-git-hooks保证代码的规范性。
- **TypeScript** 支持严格的类型检查,提高代码的可维护性。 - **TypeScript** 支持严格的类型检查,提高代码的可维护性。
@@ -41,16 +52,22 @@
- [预览地址](https://naive.soybeanjs.cn/) - [预览地址](https://naive.soybeanjs.cn/)
- [Github 仓库](https://github.com/soybeanjs/soybean-admin) - [Github 仓库](https://github.com/soybeanjs/soybean-admin)
- [Gitee 仓库](https://gitee.com/honghuangdc/soybean-admin) - [Gitee 仓库](https://gitee.com/honghuangdc/soybean-admin)
- [Gitcode 仓库](https://gitcode.com/soybeanjs/soybean-admin)
- **AntDesignVue 版本:** - **AntDesignVue 版本:**
- [预览地址](https://antd.soybeanjs.cn/) - [预览地址](https://antd.soybeanjs.cn/)
- [Github 仓库](https://github.com/soybeanjs/soybean-admin-antd) - [Github 仓库](https://github.com/soybeanjs/soybean-admin-antd)
- [Gitee 仓库](https://gitee.com/honghuangdc/soybean-admin-antd) - [Gitee 仓库](https://gitee.com/honghuangdc/soybean-admin-antd)
- [Gitcode 仓库](https://gitcode.com/soybeanjs/soybean-admin-antd)
- **ElementPlus 版本:** - **ElementPlus 版本:**
- [预览地址](https://elp.soybeanjs.cn/) - [预览地址](https://elp.soybeanjs.cn/)
- [Github 仓库](https://github.com/soybeanjs/soybean-admin-element-plus) - [Github 仓库](https://github.com/soybeanjs/soybean-admin-element-plus)
- [Gitee 仓库](https://gitee.com/honghuangdc/soybean-admin-element-plus)
- [Gitcode 仓库](https://gitcode.com/soybeanjs/soybean-admin-element-plus)
- **旧版:** - **旧版:**
- [预览地址](https://legacy.soybeanjs.cn/) - [预览地址](https://legacy.soybeanjs.cn/)
- [Github 仓库](https://github.com/soybeanjs/soybean-admin/tree/legacy) - [Github 仓库](https://github.com/soybeanjs/soybean-admin/tree/legacy)
- [Gitee 仓库](https://gitee.com/honghuangdc/soybean-admin/tree/legacy)
- [Gitcode 仓库](https://gitcode.com/soybeanjs/soybean-admin/tree/legacy)
## 文档 ## 文档
@@ -110,13 +127,18 @@
确保你的环境满足以下要求: 确保你的环境满足以下要求:
- **git**: 你需要git来克隆和管理项目版本。 - **git**: 你需要git来克隆和管理项目版本。
- **NodeJS**: >=18.12.0,推荐 18.19.0 或更高。 - **NodeJS**: >=20.19.0,推荐 20.19.0 或更高。
- **pnpm**: >= 8.7.0,推荐 8.14.0 或更高。 - **pnpm**: >= 10.5.0,推荐 10.5.0 或更高。
**克隆项目** **克隆项目**
```bash ```bash
# github
git clone https://github.com/soybeanjs/soybean-admin.git git clone https://github.com/soybeanjs/soybean-admin.git
# gitee
git clone https://gitee.com/honghuangdc/soybean-admin.git
# gitcode
git clone https://gitcode.com/soybeanjs/soybean-admin.git
``` ```
**安装依赖** **安装依赖**
@@ -153,6 +175,8 @@ pnpm build
- [snail-job](https://github.com/aizuda/snail-job): 一款兼具 “高性能、高颜值、高活跃” 的分布式任务重试和分布式任务调度平台。 - [snail-job](https://github.com/aizuda/snail-job): 一款兼具 “高性能、高颜值、高活跃” 的分布式任务重试和分布式任务调度平台。
- [SuperApi](https://github.com/TmmTop/SuperApi): 快速将你的 idea 变成线上稳定运行的产品! 无实体建库建表,对无实体库表进行增删改查,支持 15 种条件查询,以及分页,列表,无限级树形列表 等功能的 API 部署! 拥有接口文档Auth 授权,接口限流,获取客户端真实 IP先进的服务器缓存组件动态 API 等功能,期待您的体验! - [SuperApi](https://github.com/TmmTop/SuperApi): 快速将你的 idea 变成线上稳定运行的产品! 无实体建库建表,对无实体库表进行增删改查,支持 15 种条件查询,以及分页,列表,无限级树形列表 等功能的 API 部署! 拥有接口文档Auth 授权,接口限流,获取客户端真实 IP先进的服务器缓存组件动态 API 等功能,期待您的体验!
- [FastSoyAdmin](https://github.com/sleep1223/fast-soy-admin): 基于 FastAPI+Vue3+Naive UI 的现代化轻量管理平台. - [FastSoyAdmin](https://github.com/sleep1223/fast-soy-admin): 基于 FastAPI+Vue3+Naive UI 的现代化轻量管理平台.
- [ba](https://github.com/xiatianYa/Ba-Server): 基于goFrame框架开发的后端服务对接soybean-admin,适配动态路由,接口鉴权限。
- [soybean-admin-go](https://github.com/WgoW/soybean-admin-go):基于gin+gorm框架开发的go语言后端服务对接soybean-admin的example分支,适配动态路由,接口鉴权限。
## 如何贡献 ## 如何贡献

View File

@@ -1,4 +1,4 @@
import type { HttpProxy, ProxyOptions } from 'vite'; import type { ProxyOptions } from 'vite';
import { bgRed, bgYellow, green, lightBlue } from 'kolorist'; import { bgRed, bgYellow, green, lightBlue } from 'kolorist';
import { consola } from 'consola'; import { consola } from 'consola';
import { createServiceConfig } from '../../src/utils/service'; import { createServiceConfig } from '../../src/utils/service';
@@ -33,7 +33,7 @@ function createProxyItem(item: App.Service.ServiceConfigItem, enableLog: boolean
proxy[item.proxyPattern] = { proxy[item.proxyPattern] = {
target: item.baseURL, target: item.baseURL,
changeOrigin: true, changeOrigin: true,
configure: (_proxy: HttpProxy.Server, options: ProxyOptions) => { configure: (_proxy, options) => {
_proxy.on('proxyReq', (_proxyReq, req, _res) => { _proxy.on('proxyReq', (_proxyReq, req, _res) => {
if (!enableLog) return; if (!enableLog) return;

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

View File

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

View File

@@ -1,8 +1,8 @@
{ {
"name": "soybean-admin", "name": "soybean-admin",
"type": "module", "type": "module",
"version": "1.3.13", "version": "1.3.15",
"description": "A fresh and elegant admin template, based on Vue3、Vite3、TypeScript、NaiveUI and UnoCSS. 一个基于Vue3、Vite3、TypeScript、NaiveUI and UnoCSS的清新优雅的中后台模版。", "description": "A fresh and elegant admin template, based on Vue3、Vite7、TypeScript、NaiveUI and UnoCSS. 一个基于Vue3、Vite7、TypeScript、NaiveUI and UnoCSS的清新优雅的中后台模版。",
"author": { "author": {
"name": "Soybean", "name": "Soybean",
"email": "soybeanjs@outlook.com", "email": "soybeanjs@outlook.com",
@@ -19,7 +19,7 @@
"keywords": [ "keywords": [
"Vue3 admin ", "Vue3 admin ",
"vue-admin-template", "vue-admin-template",
"Vite5", "Vite7",
"TypeScript", "TypeScript",
"naive-ui", "naive-ui",
"naive-ui-admin", "naive-ui-admin",
@@ -27,8 +27,8 @@
"UnoCSS" "UnoCSS"
], ],
"engines": { "engines": {
"node": ">=18.20.0", "node": ">=20.19.0",
"pnpm": ">=8.7.0" "pnpm": ">=10.5.0"
}, },
"scripts": { "scripts": {
"build": "vite build --mode prod", "build": "vite build --mode prod",
@@ -48,67 +48,63 @@
}, },
"dependencies": { "dependencies": {
"@better-scroll/core": "2.5.1", "@better-scroll/core": "2.5.1",
"@iconify/vue": "4.3.0", "@iconify/vue": "5.0.0",
"@sa/axios": "workspace:*", "@sa/axios": "workspace:*",
"@sa/color": "workspace:*", "@sa/color": "workspace:*",
"@sa/hooks": "workspace:*", "@sa/hooks": "workspace:*",
"@sa/materials": "workspace:*", "@sa/materials": "workspace:*",
"@sa/utils": "workspace:*", "@sa/utils": "workspace:*",
"@vueuse/core": "13.0.0", "@vueuse/core": "13.9.0",
"clipboard": "2.0.11", "clipboard": "2.0.11",
"dayjs": "1.11.13", "dayjs": "1.11.18",
"defu": "6.1.4", "defu": "6.1.4",
"echarts": "5.6.0", "echarts": "6.0.0",
"json5": "2.2.3", "json5": "2.2.3",
"naive-ui": "2.41.0", "naive-ui": "2.43.1",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"pinia": "3.0.1", "pinia": "3.0.3",
"tailwind-merge": "3.0.2", "tailwind-merge": "3.3.1",
"vue": "3.5.13", "vue": "3.5.22",
"vue-draggable-plus": "0.6.0", "vue-draggable-plus": "0.6.0",
"vue-i18n": "11.1.2", "vue-i18n": "11.1.12",
"vue-router": "4.5.0" "vue-router": "4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@elegant-router/vue": "0.3.8", "@elegant-router/vue": "0.3.8",
"@iconify/json": "2.2.318", "@iconify/json": "2.2.395",
"@sa/scripts": "workspace:*", "@sa/scripts": "workspace:*",
"@sa/uno-preset": "workspace:*", "@sa/uno-preset": "workspace:*",
"@soybeanjs/eslint-config": "1.6.0", "@soybeanjs/eslint-config": "1.7.1",
"@types/node": "22.13.10", "@types/node": "24.7.2",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@unocss/eslint-config": "66.0.0", "@unocss/eslint-config": "66.5.3",
"@unocss/preset-icons": "66.0.0", "@unocss/preset-icons": "66.5.3",
"@unocss/preset-uno": "66.0.0", "@unocss/preset-uno": "66.5.3",
"@unocss/transformer-directives": "66.0.0", "@unocss/transformer-directives": "66.5.3",
"@unocss/transformer-variant-group": "66.0.0", "@unocss/transformer-variant-group": "66.5.3",
"@unocss/vite": "66.0.0", "@unocss/vite": "66.5.3",
"@vitejs/plugin-vue": "5.2.3", "@vitejs/plugin-vue": "6.0.1",
"@vitejs/plugin-vue-jsx": "4.1.2", "@vitejs/plugin-vue-jsx": "5.1.1",
"consola": "3.4.2", "consola": "3.4.2",
"eslint": "9.22.0", "eslint": "9.37.0",
"eslint-plugin-vue": "10.0.0", "eslint-plugin-vue": "10.5.0",
"kolorist": "1.8.0", "kolorist": "1.8.0",
"lint-staged": "15.5.0", "sass": "1.93.2",
"sass": "1.86.0", "simple-git-hooks": "2.13.1",
"simple-git-hooks": "2.11.1", "tsx": "4.20.6",
"tsx": "4.19.3", "typescript": "5.9.3",
"typescript": "5.8.2", "unplugin-icons": "22.4.2",
"unplugin-icons": "22.1.0", "unplugin-vue-components": "29.1.0",
"unplugin-vue-components": "28.4.1", "vite": "7.1.9",
"vite": "6.2.2",
"vite-plugin-progress": "0.0.7", "vite-plugin-progress": "0.0.7",
"vite-plugin-svg-icons": "2.0.1", "vite-plugin-svg-icons": "2.0.1",
"vite-plugin-vue-devtools": "7.7.2", "vite-plugin-vue-devtools": "8.0.2",
"vue-eslint-parser": "10.1.1", "vue-eslint-parser": "10.2.0",
"vue-tsc": "2.2.8" "vue-tsc": "3.1.1"
}, },
"simple-git-hooks": { "simple-git-hooks": {
"commit-msg": "pnpm sa git-commit-verify", "commit-msg": "pnpm sa git-commit-verify",
"pre-commit": "pnpm typecheck && pnpm lint-staged" "pre-commit": "pnpm typecheck && pnpm lint && git diff --exit-code"
},
"lint-staged": {
"*": "eslint --fix"
}, },
"website": "https://admin.soybeanjs.cn" "website": "https://admin.soybeanjs.cn"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/alova", "name": "@sa/alova",
"version": "1.3.13", "version": "1.3.15",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./fetch": "./src/fetch.ts", "./fetch": "./src/fetch.ts",
@@ -13,8 +13,8 @@
} }
}, },
"dependencies": { "dependencies": {
"@alova/mock": "2.0.12", "@alova/mock": "2.0.17",
"@sa/utils": "workspace:*", "@sa/utils": "workspace:*",
"alova": "3.2.10" "alova": "3.3.4"
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/axios", "name": "@sa/axios",
"version": "1.3.13", "version": "1.3.15",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },
@@ -11,11 +11,11 @@
}, },
"dependencies": { "dependencies": {
"@sa/utils": "workspace:*", "@sa/utils": "workspace:*",
"axios": "1.8.3", "axios": "1.12.2",
"axios-retry": "4.5.0", "axios-retry": "4.5.0",
"qs": "6.14.0" "qs": "6.14.0"
}, },
"devDependencies": { "devDependencies": {
"@types/qs": "6.9.18" "@types/qs": "6.14.0"
} }
} }

View File

@@ -3,6 +3,7 @@ import type { AxiosResponse, CreateAxiosDefaults, InternalAxiosRequestConfig } f
import axiosRetry from 'axios-retry'; import axiosRetry from 'axios-retry';
import { nanoid } from '@sa/utils'; import { nanoid } from '@sa/utils';
import { createAxiosConfig, createDefaultOptions, createRetryOptions } from './options'; import { createAxiosConfig, createDefaultOptions, createRetryOptions } from './options';
import { transformResponse } from './shared';
import { BACKEND_ERROR_CODE, REQUEST_ID_KEY } from './constant'; import { BACKEND_ERROR_CODE, REQUEST_ID_KEY } from './constant';
import type { import type {
CustomAxiosRequestConfig, CustomAxiosRequestConfig,
@@ -13,11 +14,12 @@ import type {
ResponseType ResponseType
} from './type'; } from './type';
function createCommonRequest<ResponseData = any>( function createCommonRequest<
axiosConfig?: CreateAxiosDefaults, ResponseData,
options?: Partial<RequestOption<ResponseData>> ApiData = ResponseData,
) { State extends Record<string, unknown> = Record<string, unknown>
const opts = createDefaultOptions<ResponseData>(options); >(axiosConfig?: CreateAxiosDefaults, options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
const opts = createDefaultOptions<ResponseData, ApiData, State>(options);
const axiosConf = createAxiosConfig(axiosConfig); const axiosConf = createAxiosConfig(axiosConfig);
const instance = axios.create(axiosConf); const instance = axios.create(axiosConf);
@@ -52,6 +54,8 @@ function createCommonRequest<ResponseData = any>(
async response => { async response => {
const responseType: ResponseType = (response.config?.responseType as ResponseType) || 'json'; const responseType: ResponseType = (response.config?.responseType as ResponseType) || 'json';
await transformResponse(response);
if (responseType !== 'json' || opts.isBackendSuccess(response)) { if (responseType !== 'json' || opts.isBackendSuccess(response)) {
return Promise.resolve(response); return Promise.resolve(response);
} }
@@ -80,14 +84,6 @@ function createCommonRequest<ResponseData = any>(
} }
); );
function cancelRequest(requestId: string) {
const abortController = abortControllerMap.get(requestId);
if (abortController) {
abortController.abort();
abortControllerMap.delete(requestId);
}
}
function cancelAllRequest() { function cancelAllRequest() {
abortControllerMap.forEach(abortController => { abortControllerMap.forEach(abortController => {
abortController.abort(); abortController.abort();
@@ -98,7 +94,6 @@ function createCommonRequest<ResponseData = any>(
return { return {
instance, instance,
opts, opts,
cancelRequest,
cancelAllRequest cancelAllRequest
}; };
} }
@@ -109,27 +104,27 @@ function createCommonRequest<ResponseData = any>(
* @param axiosConfig axios config * @param axiosConfig axios config
* @param options request options * @param options request options
*/ */
export function createRequest<ResponseData = any, State = Record<string, unknown>>( export function createRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults, axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>> options?: Partial<RequestOption<ResponseData, ApiData, State>>
) { ) {
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options); const { instance, opts, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(axiosConfig, options);
const request: RequestInstance<State> = async function request<T = any, R extends ResponseType = 'json'>( const request: RequestInstance<ApiData, State> = async function request<
config: CustomAxiosRequestConfig T extends ApiData = ApiData,
) { R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
const response: AxiosResponse<ResponseData> = await instance(config); const response: AxiosResponse<ResponseData> = await instance(config);
const responseType = response.config?.responseType || 'json'; const responseType = response.config?.responseType || 'json';
if (responseType === 'json') { if (responseType === 'json') {
return opts.transformBackendResponse(response); return opts.transform(response);
} }
return response.data as MappedType<R, T>; return response.data as MappedType<R, T>;
} as RequestInstance<State>; } as RequestInstance<ApiData, State>;
request.cancelRequest = cancelRequest;
request.cancelAllRequest = cancelAllRequest; request.cancelAllRequest = cancelAllRequest;
request.state = {} as State; request.state = {} as State;
@@ -144,14 +139,14 @@ export function createRequest<ResponseData = any, State = Record<string, unknown
* @param axiosConfig axios config * @param axiosConfig axios config
* @param options request options * @param options request options
*/ */
export function createFlatRequest<ResponseData = any, State = Record<string, unknown>>( export function createFlatRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults, axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>> options?: Partial<RequestOption<ResponseData, ApiData, State>>
) { ) {
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options); const { instance, opts, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(axiosConfig, options);
const flatRequest: FlatRequestInstance<State, ResponseData> = async function flatRequest< const flatRequest: FlatRequestInstance<ResponseData, ApiData, State> = async function flatRequest<
T = any, T extends ApiData = ApiData,
R extends ResponseType = 'json' R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) { >(config: CustomAxiosRequestConfig) {
try { try {
@@ -160,20 +155,21 @@ export function createFlatRequest<ResponseData = any, State = Record<string, unk
const responseType = response.config?.responseType || 'json'; const responseType = response.config?.responseType || 'json';
if (responseType === 'json') { if (responseType === 'json') {
const data = opts.transformBackendResponse(response); const data = await opts.transform(response);
return { data, error: null, response }; return { data, error: null, response };
} }
return { data: response.data as MappedType<R, T>, error: null }; return { data: response.data as MappedType<R, T>, error: null, response };
} catch (error) { } catch (error) {
return { data: null, error, response: (error as AxiosError<ResponseData>).response }; return { data: null, error, response: (error as AxiosError<ResponseData>).response };
} }
} as FlatRequestInstance<State, ResponseData>; } as FlatRequestInstance<ResponseData, ApiData, State>;
flatRequest.cancelRequest = cancelRequest;
flatRequest.cancelAllRequest = cancelAllRequest; flatRequest.cancelAllRequest = cancelAllRequest;
flatRequest.state = {} as State; flatRequest.state = {
...opts.defaultState
} as State;
return flatRequest; return flatRequest;
} }

View File

@@ -4,15 +4,27 @@ import { stringify } from 'qs';
import { isHttpSuccess } from './shared'; import { isHttpSuccess } from './shared';
import type { RequestOption } from './type'; import type { RequestOption } from './type';
export function createDefaultOptions<ResponseData = any>(options?: Partial<RequestOption<ResponseData>>) { export function createDefaultOptions<
const opts: RequestOption<ResponseData> = { 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, onRequest: async config => config,
isBackendSuccess: _response => true, isBackendSuccess: _response => true,
onBackendFail: async () => {}, onBackendFail: async () => {},
transformBackendResponse: async response => response.data,
onError: async () => {} onError: async () => {}
}; };
if (options?.transform) {
opts.transform = options.transform;
} else {
opts.transform = options?.transformBackendResponse || opts.transform;
}
Object.assign(opts, options); Object.assign(opts, options);
return opts; return opts;

View File

@@ -1,4 +1,5 @@
import type { AxiosHeaderValue, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import type { AxiosHeaderValue, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import type { ResponseType } from './type';
export function getContentType(config: InternalAxiosRequestConfig) { export function getContentType(config: InternalAxiosRequestConfig) {
const contentType: AxiosHeaderValue = config.headers?.['Content-Type'] || 'application/json'; const contentType: AxiosHeaderValue = config.headers?.['Content-Type'] || 'application/json';
@@ -26,3 +27,53 @@ export function isResponseJson(response: AxiosResponse) {
return responseType === 'json' || responseType === undefined; 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 {}
}

View File

@@ -8,7 +8,30 @@ export type ContentType =
| 'application/x-www-form-urlencoded' | 'application/x-www-form-urlencoded'
| 'application/octet-stream'; | 'application/octet-stream';
export interface RequestOption<ResponseData = any> { 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 * The hook before request
* *
@@ -35,12 +58,6 @@ export interface RequestOption<ResponseData = any> {
response: AxiosResponse<ResponseData>, response: AxiosResponse<ResponseData>,
instance: AxiosInstance instance: AxiosInstance
) => Promise<AxiosResponse | null> | Promise<void>; ) => Promise<AxiosResponse | null> | Promise<void>;
/**
* transform backend response when the responseType is json
*
* @param response Axios response
*/
transformBackendResponse(response: AxiosResponse<ResponseData>): any | Promise<any>;
/** /**
* The hook to handle error * The hook to handle error
* *
@@ -68,15 +85,7 @@ export type CustomAxiosRequestConfig<R extends ResponseType = 'json'> = Omit<Axi
responseType?: R; responseType?: R;
}; };
export interface RequestInstanceCommon<T> { export interface RequestInstanceCommon<State extends Record<string, unknown>> {
/**
* cancel the request by request id
*
* if the request provide abort controller sign from config, it will not collect in the abort controller map
*
* @param requestId
*/
cancelRequest: (requestId: string) => void;
/** /**
* cancel all request * cancel all request
* *
@@ -84,32 +93,35 @@ export interface RequestInstanceCommon<T> {
*/ */
cancelAllRequest: () => void; cancelAllRequest: () => void;
/** you can set custom state in the request instance */ /** you can set custom state in the request instance */
state: T; state: State;
} }
/** The request instance */ /** The request instance */
export interface RequestInstance<S = Record<string, unknown>> extends RequestInstanceCommon<S> { export interface RequestInstance<ApiData, State extends Record<string, unknown>> extends RequestInstanceCommon<State> {
<T = any, R extends ResponseType = 'json'>(config: CustomAxiosRequestConfig<R>): Promise<MappedType<R, T>>; <T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R>
): Promise<MappedType<R, T>>;
} }
export type FlatResponseSuccessData<T = any, ResponseData = any> = { export type FlatResponseSuccessData<ResponseData, ApiData> = {
data: T; data: ApiData;
error: null; error: null;
response: AxiosResponse<ResponseData>; response: AxiosResponse<ResponseData>;
}; };
export type FlatResponseFailData<ResponseData = any> = { export type FlatResponseFailData<ResponseData> = {
data: null; data: null;
error: AxiosError<ResponseData>; error: AxiosError<ResponseData>;
response: AxiosResponse<ResponseData>; response: AxiosResponse<ResponseData>;
}; };
export type FlatResponseData<T = any, ResponseData = any> = export type FlatResponseData<ResponseData, ApiData> =
| FlatResponseSuccessData<T, ResponseData> | FlatResponseSuccessData<ResponseData, ApiData>
| FlatResponseFailData<ResponseData>; | FlatResponseFailData<ResponseData>;
export interface FlatRequestInstance<S = Record<string, unknown>, ResponseData = any> extends RequestInstanceCommon<S> { export interface FlatRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
<T = any, R extends ResponseType = 'json'>( extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R> config: CustomAxiosRequestConfig<R>
): Promise<FlatResponseData<MappedType<R, T>, ResponseData>>; ): Promise<FlatResponseData<ResponseData, MappedType<R, T>>>;
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/color", "name": "@sa/color",
"version": "1.3.13", "version": "1.3.15",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/hooks", "name": "@sa/hooks",
"version": "1.3.13", "version": "1.3.15",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

View File

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

View File

@@ -1,5 +1,4 @@
import { inject, provide } from 'vue'; import { inject, provide } from 'vue';
import type { InjectionKey } from 'vue';
/** /**
* Use context * Use context
@@ -12,7 +11,7 @@ import type { InjectionKey } from 'vue';
* import { ref } from 'vue'; * import { ref } from 'vue';
* import { useContext } from '@sa/hooks'; * import { useContext } from '@sa/hooks';
* *
* export const { setupStore, useStore } = useContext('demo', () => { * export const [provideDemoContext, useDemoContext] = useContext('demo', () => {
* const count = ref(0); * const count = ref(0);
* *
* function increment() { * function increment() {
@@ -35,10 +34,10 @@ import type { InjectionKey } from 'vue';
* <div>A</div> * <div>A</div>
* </template> * </template>
* <script setup lang="ts"> * <script setup lang="ts">
* import { setupStore } from './context'; * import { provideDemoContext } from './context';
* *
* setupStore(); * provideDemoContext();
* // const { increment } = setupStore(); // also can control the store in the parent component * // const { increment } = provideDemoContext(); // also can control the store in the parent component
* </script> * </script>
* ``` // B.vue * ``` // B.vue
* ```vue * ```vue
@@ -46,9 +45,9 @@ import type { InjectionKey } from 'vue';
* <div>B</div> * <div>B</div>
* </template> * </template>
* <script setup lang="ts"> * <script setup lang="ts">
* import { useStore } from './context'; * import { useDemoContext } from './context';
* *
* const { count, increment } = useStore(); * const { count, increment } = useDemoContext();
* </script> * </script>
* ```; * ```;
* *
@@ -57,40 +56,41 @@ import type { InjectionKey } from 'vue';
* @param contextName Context name * @param contextName Context name
* @param fn Context function * @param fn Context function
*/ */
export default function useContext<T extends (...args: any[]) => any>(contextName: string, fn: T) { export default function useContext<Arguments extends Array<any>, T>(
type Context = ReturnType<T>; contextName: string,
composable: (...args: Arguments) => T
) {
const key = Symbol(contextName);
const { useProvide, useInject: useStore } = createContext<Context>(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);
function setupStore(...args: Parameters<T>) { if (consumerName && !value) {
const context: Context = fn(...args); throw new Error(`\`${consumerName}\` must be used within \`${contextName}\``);
return useProvide(context);
} }
return { // @ts-expect-error - we want to return null if the value is undefined or null
/** Setup store in the parent component */ return value || null;
setupStore,
/** Use store in the child component */
useStore
}; };
}
/** Create context */ const useProvide = (...args: Arguments) => {
function createContext<T>(contextName: string) { const value = composable(...args);
const injectKey: InjectionKey<T> = Symbol(contextName);
function useProvide(context: T) { provide(key, value);
provide(injectKey, context);
return context; return value;
}
function useInject() {
return inject(injectKey) as T;
}
return {
useProvide,
useInject
}; };
return [useProvide, useInject] as const;
} }

View File

@@ -2,40 +2,59 @@ import { computed, onScopeDispose, ref } from 'vue';
import { useRafFn } from '@vueuse/core'; import { useRafFn } from '@vueuse/core';
/** /**
* count down * A hook for implementing a countdown timer. It uses `requestAnimationFrame` for smooth and accurate timing,
* independent of the screen refresh rate.
* *
* @param seconds - count down seconds * @param initialSeconds - The total number of seconds for the countdown.
*/ */
export default function useCountDown(seconds: number) { export default function useCountDown(initialSeconds: number) {
const FPS_PER_SECOND = 60; const remainingSeconds = ref(0);
const fps = ref(0); const count = computed(() => Math.ceil(remainingSeconds.value));
const count = computed(() => Math.ceil(fps.value / FPS_PER_SECOND)); const isCounting = computed(() => remainingSeconds.value > 0);
const isCounting = computed(() => fps.value > 0);
const { pause, resume } = useRafFn( const { pause, resume } = useRafFn(
() => { ({ delta }) => {
if (fps.value > 0) { // delta: milliseconds elapsed since the last frame.
fps.value -= 1;
} else { // 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(); pause();
} }
}, },
{ immediate: false } { immediate: false } // The timer does not start automatically.
); );
function start(updateSeconds: number = seconds) { /**
fps.value = FPS_PER_SECOND * updateSeconds; * Starts the countdown.
*
* @param [updatedSeconds=initialSeconds] - Optionally, start with a new duration. Default is `initialSeconds`
*/
function start(updatedSeconds: number = initialSeconds) {
remainingSeconds.value = updatedSeconds;
resume(); resume();
} }
/** Stops the countdown and resets the remaining time to 0. */
function stop() { function stop() {
fps.value = 0; remainingSeconds.value = 0;
pause(); pause();
} }
// Ensure the rAF loop is cleaned up when the component is unmounted.
onScopeDispose(() => { onScopeDispose(() => {
pause(); pause();
}); });

View File

@@ -6,31 +6,31 @@ import type {
CreateAxiosDefaults, CreateAxiosDefaults,
CustomAxiosRequestConfig, CustomAxiosRequestConfig,
MappedType, MappedType,
RequestInstanceCommon,
RequestOption, RequestOption,
ResponseType ResponseType
} from '@sa/axios'; } from '@sa/axios';
import useLoading from './use-loading'; import useLoading from './use-loading';
export type HookRequestInstanceResponseSuccessData<T = any> = { export type HookRequestInstanceResponseSuccessData<ApiData> = {
data: Ref<T>; data: Ref<ApiData>;
error: Ref<null>; error: Ref<null>;
}; };
export type HookRequestInstanceResponseFailData<ResponseData = any> = { export type HookRequestInstanceResponseFailData<ResponseData> = {
data: Ref<null>; data: Ref<null>;
error: Ref<AxiosError<ResponseData>>; error: Ref<AxiosError<ResponseData>>;
}; };
export type HookRequestInstanceResponseData<T = any, ResponseData = any> = { export type HookRequestInstanceResponseData<ResponseData, ApiData> = {
loading: Ref<boolean>; loading: Ref<boolean>;
} & (HookRequestInstanceResponseSuccessData<T> | HookRequestInstanceResponseFailData<ResponseData>); } & (HookRequestInstanceResponseSuccessData<ApiData> | HookRequestInstanceResponseFailData<ResponseData>);
export interface HookRequestInstance<ResponseData = any> { export interface HookRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
<T = any, R extends ResponseType = 'json'>( extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig config: CustomAxiosRequestConfig
): HookRequestInstanceResponseData<MappedType<R, T>, ResponseData>; ): HookRequestInstanceResponseData<ResponseData, MappedType<R, T>>;
cancelRequest: (requestId: string) => void;
cancelAllRequest: () => void;
} }
/** /**
@@ -39,25 +39,26 @@ export interface HookRequestInstance<ResponseData = any> {
* @param axiosConfig * @param axiosConfig
* @param options * @param options
*/ */
export default function createHookRequest<ResponseData = any>( export default function createHookRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults, axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>> options?: Partial<RequestOption<ResponseData, ApiData, State>>
) { ) {
const request = createFlatRequest<ResponseData>(axiosConfig, options); const request = createFlatRequest<ResponseData, ApiData, State>(axiosConfig, options);
const hookRequest: HookRequestInstance<ResponseData> = function hookRequest<T = any, R extends ResponseType = 'json'>( const hookRequest: HookRequestInstance<ResponseData, ApiData, State> = function hookRequest<
config: CustomAxiosRequestConfig T extends ApiData = ApiData,
) { R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
const { loading, startLoading, endLoading } = useLoading(); const { loading, startLoading, endLoading } = useLoading();
const data = ref<MappedType<R, T> | null>(null) as Ref<MappedType<R, T>>; const data = ref(null) as Ref<MappedType<R, T>>;
const error = ref<AxiosError<ResponseData> | null>(null) as Ref<AxiosError<ResponseData> | null>; const error = ref(null) as Ref<AxiosError<ResponseData> | null>;
startLoading(); startLoading();
request(config).then(res => { request(config).then(res => {
if (res.data) { if (res.data) {
data.value = res.data; data.value = res.data as MappedType<R, T>;
} else { } else {
error.value = res.error; error.value = res.error;
} }
@@ -70,9 +71,8 @@ export default function createHookRequest<ResponseData = any>(
data, data,
error error
}; };
} as HookRequestInstance<ResponseData>; } as HookRequestInstance<ResponseData, ApiData, State>;
hookRequest.cancelRequest = request.cancelRequest;
hookRequest.cancelAllRequest = request.cancelAllRequest; hookRequest.cancelAllRequest = request.cancelAllRequest;
return hookRequest; return hookRequest;

View File

@@ -1,144 +0,0 @@
import { computed, ref, shallowRef, triggerRef } from 'vue';
import type {
ComputedGetter,
DebuggerOptions,
Ref,
ShallowRef,
WritableComputedOptions,
WritableComputedRef
} from 'vue';
type Updater<T> = (value: T) => T;
type Mutator<T> = (value: T) => void;
/**
* Signal is a reactive value that can be set, updated or mutated
*
* @example
* ```ts
* const count = useSignal(0);
*
* // `watchEffect`
* watchEffect(() => {
* console.log(count());
* });
*
* // watch
* watch(count, value => {
* console.log(value);
* });
*
* // useComputed
* const double = useComputed(() => count() * 2);
* const writeableDouble = useComputed({
* get: () => count() * 2,
* set: value => count.set(value / 2)
* });
* ```
*/
export interface Signal<T> {
(): Readonly<T>;
/**
* Set the value of the signal
*
* It recommend use `set` for primitive values
*
* @param value
*/
set(value: T): void;
/**
* Update the value of the signal using an updater function
*
* It recommend use `update` for non-primitive values, only the first level of the object will be reactive.
*
* @param updater
*/
update(updater: Updater<T>): void;
/**
* Mutate the value of the signal using a mutator function
*
* this action will call `triggerRef`, so the value will be tracked on `watchEffect`.
*
* It recommend use `mutate` for non-primitive values, all levels of the object will be reactive.
*
* @param mutator
*/
mutate(mutator: Mutator<T>): void;
/**
* Get the reference of the signal
*
* Sometimes it can be useful to make `v-model` work with the signal
*
* ```vue
* <template>
* <input v-model="model.count" />
* </template>;
*
* <script setup lang="ts">
* const state = useSignal({ count: 0 }, { useRef: true });
*
* const model = state.getRef();
* </script>
* ```
*/
getRef(): Readonly<ShallowRef<Readonly<T>>>;
}
export interface ReadonlySignal<T> {
(): Readonly<T>;
}
export interface SignalOptions {
/**
* Whether to use `ref` to store the value
*
* @default false use `sharedRef` to store the value
*/
useRef?: boolean;
}
export function useSignal<T>(initialValue: T, options?: SignalOptions): Signal<T> {
const { useRef } = options || {};
const state = useRef ? (ref(initialValue) as Ref<T>) : shallowRef(initialValue);
return createSignal(state);
}
export function useComputed<T>(getter: ComputedGetter<T>, debugOptions?: DebuggerOptions): ReadonlySignal<T>;
export function useComputed<T>(options: WritableComputedOptions<T>, debugOptions?: DebuggerOptions): Signal<T>;
export function useComputed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
debugOptions?: DebuggerOptions
) {
const isGetter = typeof getterOrOptions === 'function';
const computedValue = computed(getterOrOptions as any, debugOptions);
if (isGetter) {
return () => computedValue.value as ReadonlySignal<T>;
}
return createSignal(computedValue);
}
function createSignal<T>(state: ShallowRef<T> | WritableComputedRef<T>): Signal<T> {
const signal = () => state.value;
signal.set = (value: T) => {
state.value = value;
};
signal.update = (updater: Updater<T>) => {
state.value = updater(state.value);
};
signal.mutate = (mutator: Mutator<T>) => {
mutator(state.value);
triggerRef(state);
};
signal.getRef = () => state as Readonly<ShallowRef<Readonly<T>>>;
return signal;
}

View File

@@ -1,12 +1,20 @@
import { computed, reactive, ref } from 'vue'; import { computed, ref } from 'vue';
import type { Ref, VNodeChild } from 'vue'; import type { Ref, VNodeChild } from 'vue';
import { jsonClone } from '@sa/utils';
import useBoolean from './use-boolean'; import useBoolean from './use-boolean';
import useLoading from './use-loading'; import useLoading from './use-loading';
export type MaybePromise<T> = T | Promise<T>; export interface PaginationData<T> {
data: T[];
pageNum: number;
pageSize: number;
total: number;
}
export type ApiFn = (args: any) => Promise<unknown>; 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 TableColumnCheckTitle = string | ((...args: any) => VNodeChild);
@@ -14,76 +22,64 @@ export type TableColumnCheck = {
key: string; key: string;
title: TableColumnCheckTitle; title: TableColumnCheckTitle;
checked: boolean; checked: boolean;
visible: boolean;
}; };
export type TableDataWithIndex<T> = T & { index: number }; export interface UseTableOptions<ResponseData, ApiData, Column, Pagination extends boolean> {
/**
export type TransformedData<T> = { * api function to get table data
data: TableDataWithIndex<T>[]; */
pageNum: number; api: () => Promise<ResponseData>;
pageSize: number; /**
total: number; * whether to enable pagination
}; */
pagination?: Pagination;
export type Transformer<T, Response> = (response: Response) => TransformedData<T>; /**
* transform api response to table data
export type TableConfig<A extends ApiFn, T, C> = { */
/** api function to get table data */ transform: Transform<ResponseData, ApiData, Pagination>;
apiFn: A; /**
/** api params */ * columns factory
apiParams?: Parameters<A>[0]; */
/** transform api response to table data */ columns: () => Column[];
transformer: Transformer<T, Awaited<ReturnType<A>>>;
/** columns factory */
columns: () => C[];
/** /**
* get column checks * get column checks
*
* @param columns
*/ */
getColumnChecks: (columns: C[]) => TableColumnCheck[]; getColumnChecks: (columns: Column[]) => TableColumnCheck[];
/** /**
* get columns * get columns
*
* @param columns
*/ */
getColumns: (columns: C[], checks: TableColumnCheck[]) => C[]; getColumns: (columns: Column[], checks: TableColumnCheck[]) => Column[];
/** /**
* callback when response fetched * callback when response fetched
*
* @param transformed transformed data
*/ */
onFetched?: (transformed: TransformedData<T>) => MaybePromise<void>; onFetched?: (data: GetApiData<ApiData, Pagination>) => void | Promise<void>;
/** /**
* whether to get data immediately * whether to get data immediately
* *
* @default true * @default true
*/ */
immediate?: boolean; immediate?: boolean;
}; }
export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<A, T, C>) { export default function useTable<ResponseData, ApiData, Column, Pagination extends boolean>(
options: UseTableOptions<ResponseData, ApiData, Column, Pagination>
) {
const { loading, startLoading, endLoading } = useLoading(); const { loading, startLoading, endLoading } = useLoading();
const { bool: empty, setBool: setEmpty } = useBoolean(); const { bool: empty, setBool: setEmpty } = useBoolean();
const { apiFn, apiParams, transformer, immediate = true, getColumnChecks, getColumns } = config; const { api, pagination, transform, columns, getColumnChecks, getColumns, onFetched, immediate = true } = options;
const searchParams: NonNullable<Parameters<A>[0]> = reactive(jsonClone({ ...apiParams })); const data = ref([]) as Ref<ApiData[]>;
const allColumns = ref(config.columns()) as Ref<C[]>; const columnChecks = ref(getColumnChecks(columns())) as Ref<TableColumnCheck[]>;
const data: Ref<TableDataWithIndex<T>[]> = ref([]); const $columns = computed(() => getColumns(columns(), columnChecks.value));
const columnChecks: Ref<TableColumnCheck[]> = ref(getColumnChecks(config.columns()));
const columns = computed(() => getColumns(allColumns.value, columnChecks.value));
function reloadColumns() { function reloadColumns() {
allColumns.value = config.columns();
const checkMap = new Map(columnChecks.value.map(col => [col.key, col.checked])); const checkMap = new Map(columnChecks.value.map(col => [col.key, col.checked]));
const defaultChecks = getColumnChecks(allColumns.value); const defaultChecks = getColumnChecks(columns());
columnChecks.value = defaultChecks.map(col => ({ columnChecks.value = defaultChecks.map(col => ({
...col, ...col,
@@ -92,47 +88,21 @@ export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<
} }
async function getData() { async function getData() {
try {
startLoading(); startLoading();
const formattedParams = formatSearchParams(searchParams); const response = await api();
const response = await apiFn(formattedParams); const transformed = transform(response);
const transformed = transformer(response as Awaited<ReturnType<A>>); data.value = getTableData(transformed, pagination);
data.value = transformed.data; setEmpty(data.value.length === 0);
setEmpty(transformed.data.length === 0);
await config.onFetched?.(transformed);
await onFetched?.(transformed);
} finally {
endLoading(); endLoading();
} }
function formatSearchParams(params: Record<string, unknown>) {
const formattedParams: Record<string, unknown> = {};
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formattedParams[key] = value;
}
});
return formattedParams;
}
/**
* update search params
*
* @param params
*/
function updateSearchParams(params: Partial<Parameters<A>[0]>) {
Object.assign(searchParams, params);
}
/** reset search params */
function resetSearchParams() {
Object.assign(searchParams, jsonClone(apiParams));
} }
if (immediate) { if (immediate) {
@@ -143,12 +113,20 @@ export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<
loading, loading,
empty, empty,
data, data,
columns, columns: $columns,
columnChecks, columnChecks,
reloadColumns, reloadColumns,
getData, getData
searchParams,
updateSearchParams,
resetSearchParams
}; };
} }
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

@@ -1,6 +1,6 @@
{ {
"name": "@sa/materials", "name": "@sa/materials",
"version": "1.3.13", "version": "1.3.15",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },
@@ -11,7 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@sa/utils": "workspace:*", "@sa/utils": "workspace:*",
"simplebar-vue": "2.4.0" "simplebar-vue": "2.4.2"
}, },
"devDependencies": { "devDependencies": {
"typed-css-modules": "0.9.1" "typed-css-modules": "0.9.1"

View File

@@ -127,7 +127,6 @@ function handleClickMask() {
:class="[ :class="[
style['layout-header'], style['layout-header'],
commonClass, commonClass,
headerClass,
headerLeftGapClass, headerLeftGapClass,
{ 'absolute top-0 left-0 w-full': fixedHeaderAndTab } { 'absolute top-0 left-0 w-full': fixedHeaderAndTab }
]" ]"

View File

@@ -95,3 +95,27 @@
.chrome-tab_dark .chrome-tab-divider { .chrome-tab_dark .chrome-tab-divider {
background-color: rgba(255, 255, 255, 0.9); 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

@@ -10,6 +10,10 @@ declare const styles: {
readonly 'chrome-tab_dark': string; readonly 'chrome-tab_dark': string;
readonly 'chrome-tab-divider': string; readonly 'chrome-tab-divider': string;
readonly 'svg-close': 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; export default styles;

View File

@@ -5,6 +5,7 @@ import type { PageTabMode, PageTabProps } from '../../types';
import { ACTIVE_COLOR, createTabCssVars } from './shared'; import { ACTIVE_COLOR, createTabCssVars } from './shared';
import ChromeTab from './chrome-tab.vue'; import ChromeTab from './chrome-tab.vue';
import ButtonTab from './button-tab.vue'; import ButtonTab from './button-tab.vue';
import SliderTab from './slider-tab.vue';
import SvgClose from './svg-close.vue'; import SvgClose from './svg-close.vue';
import style from './index.module.css'; import style from './index.module.css';
@@ -26,7 +27,7 @@ interface Emits {
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const activeTabComponent = computed(() => { const activeTabComponent = computed(() => {
const { mode, chromeClass, buttonClass } = props; const { mode, chromeClass, buttonClass, sliderClass } = props;
const tabComponentMap = { const tabComponentMap = {
chrome: { chrome: {
@@ -36,6 +37,10 @@ const activeTabComponent = computed(() => {
button: { button: {
component: ButtonTab, component: ButtonTab,
class: buttonClass class: buttonClass
},
slider: {
component: SliderTab,
class: sliderClass
} }
} satisfies Record<PageTabMode, { component: Component; class?: string }>; } satisfies Record<PageTabMode, { component: Component; class?: string }>;
@@ -45,7 +50,7 @@ const activeTabComponent = computed(() => {
const cssVars = computed(() => createTabCssVars(props.activeColor)); const cssVars = computed(() => createTabCssVars(props.activeColor));
const bindProps = computed(() => { const bindProps = computed(() => {
const { chromeClass: _chromeCls, buttonClass: _btnCls, ...rest } = props; const { chromeClass: _chromeCls, buttonClass: _btnCls, sliderClass: _sliderCls, ...rest } = props;
return rest; return rest;
}); });

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

@@ -6,12 +6,6 @@ interface AdminLayoutHeaderConfig {
* @default true * @default true
*/ */
headerVisible?: boolean; headerVisible?: boolean;
/**
* Header class
*
* @default ''
*/
headerClass?: string;
/** /**
* Header height * Header height
* *
@@ -245,7 +239,7 @@ export type LayoutCssVars = {
* *
* @default chrome * @default chrome
*/ */
export type PageTabMode = 'button' | 'chrome'; export type PageTabMode = 'button' | 'chrome' | 'slider';
export interface PageTabProps { export interface PageTabProps {
/** Whether is dark mode */ /** Whether is dark mode */
@@ -268,6 +262,8 @@ export interface PageTabProps {
buttonClass?: string; buttonClass?: string;
/** The class of the chrome tab */ /** The class of the chrome tab */
chromeClass?: string; chromeClass?: string;
/** The class of the title tab */
sliderClass?: string;
/** Whether the tab is active */ /** Whether the tab is active */
active?: boolean; active?: boolean;
/** The color of the active tab */ /** The color of the active tab */

View File

@@ -1,15 +0,0 @@
{
"name": "@sa/fetch",
"version": "1.3.13",
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"dependencies": {
"ofetch": "1.4.1"
}
}

View File

@@ -1,10 +0,0 @@
import { ofetch } from 'ofetch';
import type { FetchOptions } from 'ofetch';
export function createRequest(options: FetchOptions) {
const request = ofetch.create(options);
return request;
}
export default createRequest;

View File

@@ -1,20 +0,0 @@
{
"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

@@ -1,6 +1,6 @@
{ {
"name": "@sa/scripts", "name": "@sa/scripts",
"version": "1.3.13", "version": "1.3.15",
"bin": { "bin": {
"sa": "./bin.ts" "sa": "./bin.ts"
}, },
@@ -13,15 +13,16 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@soybeanjs/changelog": "0.3.24", "@soybeanjs/changelog": "0.3.25",
"bumpp": "10.1.0", "bumpp": "10.3.1",
"c12": "3.0.2", "c12": "3.3.0",
"cac": "6.7.14", "cac": "6.7.14",
"consola": "3.4.2", "consola": "3.4.2",
"enquirer": "2.4.1", "enquirer": "2.4.1",
"execa": "9.5.2", "execa": "9.6.0",
"kolorist": "1.8.0", "kolorist": "1.8.0",
"npm-check-updates": "17.1.15", "npm-check-updates": "19.0.0",
"picomatch": "4.0.3",
"rimraf": "6.0.1" "rimraf": "6.0.1"
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/uno-preset", "name": "@sa/uno-preset",
"version": "1.3.13", "version": "1.3.15",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/utils", "name": "@sa/utils",
"version": "1.3.13", "version": "1.3.15",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },
@@ -14,7 +14,7 @@
"crypto-js": "4.2.0", "crypto-js": "4.2.0",
"klona": "2.0.6", "klona": "2.0.6",
"localforage": "1.10.0", "localforage": "1.10.0",
"nanoid": "5.1.5" "nanoid": "5.1.6"
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "4.2.2" "@types/crypto-js": "4.2.2"

View File

@@ -32,7 +32,8 @@ export function createStorage<T extends object>(type: StorageType, storagePrefix
storageData = JSON.parse(json); storageData = JSON.parse(json);
} catch {} } catch {}
if (storageData) { // storageData may be `false` if it is boolean type
if (storageData !== null) {
return storageData as T[K]; return storageData as T[K];
} }
} }

4536
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@ const naiveDateLocale = computed(() => {
const watermarkProps = computed<WatermarkProps>(() => { const watermarkProps = computed<WatermarkProps>(() => {
return { return {
content: themeStore.watermark.text, content: themeStore.watermarkContent,
cross: true, cross: true,
fullscreen: true, fullscreen: true,
fontSize: 16, fontSize: 16,

View File

@@ -22,7 +22,12 @@ const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
</NButton> </NButton>
</template> </template>
<VueDraggable v-model="columns" :animation="150" filter=".none_draggable"> <VueDraggable v-model="columns" :animation="150" filter=".none_draggable">
<div v-for="item in columns" :key="item.key" class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)"> <div
v-for="item in columns"
:key="item.key"
class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)"
:class="{ hidden: !item.visible }"
>
<icon-mdi-drag class="mr-8px h-full cursor-move text-icon" /> <icon-mdi-drag class="mr-8px h-full cursor-move text-icon" />
<NCheckbox v-model:checked="item.checked" class="none_draggable flex-1"> <NCheckbox v-model:checked="item.checked" class="none_draggable flex-1">
<template v-if="typeof item.title === 'function'"> <template v-if="typeof item.title === 'function'">

View File

@@ -0,0 +1,42 @@
<script lang="ts" setup>
import { computed, useSlots } from 'vue';
import type { PopoverPlacement } from 'naive-ui';
defineOptions({ name: 'IconTooltip' });
interface Props {
icon?: string;
localIcon?: string;
desc?: string;
placement?: PopoverPlacement;
}
const props = withDefaults(defineProps<Props>(), {
icon: 'mdi-help-circle',
localIcon: '',
desc: '',
placement: 'top'
});
const slots = useSlots();
const hasCustomTrigger = computed(() => Boolean(slots.trigger));
if (!hasCustomTrigger.value && !props.icon && !props.localIcon) {
throw new Error('icon or localIcon is required when no custom trigger slot is provided');
}
</script>
<template>
<NTooltip :placement="placement">
<template #trigger>
<slot name="trigger">
<div class="cursor-pointer">
<SvgIcon :icon="icon" :local-icon="localIcon" />
</div>
</slot>
</template>
<slot>
<span>{{ desc }}</span>
</slot>
</NTooltip>
</template>

View File

@@ -31,13 +31,25 @@ const tooltipContent = computed(() => {
return $t('icon.lang'); return $t('icon.lang');
}); });
/** Add bottom margin to all options except the last one for proper visual separation */
const dropdownOptions = computed(() => {
const lastIndex = props.langOptions.length - 1;
return props.langOptions.map((option, index) => ({
...option,
props: {
class: index < lastIndex ? 'mb-1' : undefined
}
}));
});
function changeLang(lang: App.I18n.LangType) { function changeLang(lang: App.I18n.LangType) {
emit('changeLang', lang); emit('changeLang', lang);
} }
</script> </script>
<template> <template>
<NDropdown :value="lang" :options="langOptions" trigger="hover" @select="changeLang"> <NDropdown :value="lang" :options="dropdownOptions" trigger="hover" @select="changeLang">
<div> <div>
<ButtonIcon :tooltip-content="tooltipContent" tooltip-placement="left"> <ButtonIcon :tooltip-content="tooltipContent" tooltip-placement="left">
<SvgIcon icon="heroicons:language" /> <SvgIcon icon="heroicons:language" />

View File

@@ -5,9 +5,9 @@ export const GLOBAL_HEADER_MENU_ID = '__GLOBAL_HEADER_MENU__';
export const GLOBAL_SIDER_MENU_ID = '__GLOBAL_SIDER_MENU__'; export const GLOBAL_SIDER_MENU_ID = '__GLOBAL_SIDER_MENU__';
export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = { export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = {
light: 'theme.themeSchema.light', light: 'theme.appearance.themeSchema.light',
dark: 'theme.themeSchema.dark', dark: 'theme.appearance.themeSchema.dark',
auto: 'theme.themeSchema.auto' auto: 'theme.appearance.themeSchema.auto'
}; };
export const themeSchemaOptions = transformRecordToOption(themeSchemaRecord); export const themeSchemaOptions = transformRecordToOption(themeSchemaRecord);
@@ -21,45 +21,51 @@ export const loginModuleRecord: Record<UnionKey.LoginModule, App.I18n.I18nKey> =
}; };
export const themeLayoutModeRecord: Record<UnionKey.ThemeLayoutMode, App.I18n.I18nKey> = { export const themeLayoutModeRecord: Record<UnionKey.ThemeLayoutMode, App.I18n.I18nKey> = {
vertical: 'theme.layoutMode.vertical', vertical: 'theme.layout.layoutMode.vertical',
'vertical-mix': 'theme.layoutMode.vertical-mix', 'vertical-mix': 'theme.layout.layoutMode.vertical-mix',
horizontal: 'theme.layoutMode.horizontal', 'vertical-hybrid-header-first': 'theme.layout.layoutMode.vertical-hybrid-header-first',
'horizontal-mix': 'theme.layoutMode.horizontal-mix' horizontal: 'theme.layout.layoutMode.horizontal',
'top-hybrid-sidebar-first': 'theme.layout.layoutMode.top-hybrid-sidebar-first',
'top-hybrid-header-first': 'theme.layout.layoutMode.top-hybrid-header-first'
}; };
export const themeLayoutModeOptions = transformRecordToOption(themeLayoutModeRecord); export const themeLayoutModeOptions = transformRecordToOption(themeLayoutModeRecord);
export const themeScrollModeRecord: Record<UnionKey.ThemeScrollMode, App.I18n.I18nKey> = { export const themeScrollModeRecord: Record<UnionKey.ThemeScrollMode, App.I18n.I18nKey> = {
wrapper: 'theme.scrollMode.wrapper', wrapper: 'theme.layout.content.scrollMode.wrapper',
content: 'theme.scrollMode.content' content: 'theme.layout.content.scrollMode.content'
}; };
export const themeScrollModeOptions = transformRecordToOption(themeScrollModeRecord); export const themeScrollModeOptions = transformRecordToOption(themeScrollModeRecord);
export const themeTabModeRecord: Record<UnionKey.ThemeTabMode, App.I18n.I18nKey> = { export const themeTabModeRecord: Record<UnionKey.ThemeTabMode, App.I18n.I18nKey> = {
chrome: 'theme.tab.mode.chrome', chrome: 'theme.layout.tab.mode.chrome',
button: 'theme.tab.mode.button' button: 'theme.layout.tab.mode.button',
slider: 'theme.layout.tab.mode.slider'
}; };
export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord); export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord);
export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = { export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = {
'fade-slide': 'theme.page.mode.fade-slide', 'fade-slide': 'theme.layout.content.page.mode.fade-slide',
fade: 'theme.page.mode.fade', fade: 'theme.layout.content.page.mode.fade',
'fade-bottom': 'theme.page.mode.fade-bottom', 'fade-bottom': 'theme.layout.content.page.mode.fade-bottom',
'fade-scale': 'theme.page.mode.fade-scale', 'fade-scale': 'theme.layout.content.page.mode.fade-scale',
'zoom-fade': 'theme.page.mode.zoom-fade', 'zoom-fade': 'theme.layout.content.page.mode.zoom-fade',
'zoom-out': 'theme.page.mode.zoom-out', 'zoom-out': 'theme.layout.content.page.mode.zoom-out',
none: 'theme.page.mode.none' none: 'theme.layout.content.page.mode.none'
}; };
export const themePageAnimationModeOptions = transformRecordToOption(themePageAnimationModeRecord); export const themePageAnimationModeOptions = transformRecordToOption(themePageAnimationModeRecord);
export const resetCacheStrategyRecord: Record<UnionKey.ResetCacheStrategy, App.I18n.I18nKey> = {
close: 'theme.resetCacheStrategy.close',
refresh: 'theme.resetCacheStrategy.refresh'
};
export const resetCacheStrategyOptions = transformRecordToOption(resetCacheStrategyRecord);
export const DARK_CLASS = 'dark'; export const DARK_CLASS = 'dark';
export const watermarkTimeFormatOptions = [
{ label: 'YYYY-MM-DD HH:mm', value: 'YYYY-MM-DD HH:mm' },
{ label: 'YYYY-MM-DD HH:mm:ss', value: 'YYYY-MM-DD HH:mm:ss' },
{ label: 'YYYY/MM/DD HH:mm', value: 'YYYY/MM/DD HH:mm' },
{ label: 'YYYY/MM/DD HH:mm:ss', value: 'YYYY/MM/DD HH:mm:ss' },
{ label: 'HH:mm', value: 'HH:mm' },
{ label: 'HH:mm:ss', value: 'HH:mm:ss' },
{ label: 'MM-DD HH:mm', value: 'MM-DD HH:mm' }
];

View File

@@ -1,4 +1,4 @@
import { computed, effectScope, nextTick, onScopeDispose, ref, watch } from 'vue'; import { computed, effectScope, nextTick, onScopeDispose, shallowRef, watch } from 'vue';
import { useElementSize } from '@vueuse/core'; import { useElementSize } from '@vueuse/core';
import * as echarts from 'echarts/core'; import * as echarts from 'echarts/core';
import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts'; import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts';
@@ -86,11 +86,11 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const darkMode = computed(() => themeStore.darkMode); const darkMode = computed(() => themeStore.darkMode);
const domRef = ref<HTMLElement | null>(null); const domRef = shallowRef<HTMLElement | null>(null);
const initialSize = { width: 0, height: 0 }; const initialSize = { width: 0, height: 0 };
const { width, height } = useElementSize(domRef, initialSize); const { width, height } = useElementSize(domRef, initialSize);
let chart: echarts.ECharts | null = null; const chart = shallowRef<echarts.ECharts | null>(null);
const chartOptions: T = optionsFactory(); const chartOptions: T = optionsFactory();
const { const {
@@ -111,18 +111,9 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
onDestroy onDestroy
} = hooks; } = hooks;
/**
* whether can render chart
*
* when domRef is ready and initialSize is valid
*/
function canRender() {
return domRef.value && initialSize.width > 0 && initialSize.height > 0;
}
/** is chart rendered */ /** is chart rendered */
function isRendered() { function isRendered() {
return Boolean(domRef.value && chart); return Boolean(domRef.value && chart.value);
} }
/** /**
@@ -131,59 +122,59 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
* @param callback callback function * @param callback callback function
*/ */
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) { async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
if (!isRendered()) return;
const updatedOpts = callback(chartOptions, optionsFactory); const updatedOpts = callback(chartOptions, optionsFactory);
Object.assign(chartOptions, updatedOpts); Object.assign(chartOptions, updatedOpts);
await nextTick();
if (!isRendered()) return;
if (isRendered()) { if (isRendered()) {
chart?.clear(); chart.value?.clear();
} }
chart?.setOption({ ...updatedOpts, backgroundColor: 'transparent' }); chart.value?.setOption({ ...updatedOpts, backgroundColor: 'transparent' });
await onUpdated?.(chart!); await onUpdated?.(chart.value!);
} }
function setOptions(options: T) { function setOptions(options: T) {
chart?.setOption(options); chart.value?.setOption(options);
} }
/** render chart */ /** render chart */
async function render() { async function render() {
if (!isRendered()) { if (isRendered()) return;
const chartTheme = darkMode.value ? 'dark' : 'light'; const chartTheme = darkMode.value ? 'dark' : 'light';
await nextTick(); chart.value = echarts.init(domRef.value, chartTheme);
chart = echarts.init(domRef.value, chartTheme); chart.value?.setOption({ ...chartOptions, backgroundColor: 'transparent' });
chart.setOption({ ...chartOptions, backgroundColor: 'transparent' }); await onRender?.(chart.value!);
await onRender?.(chart);
}
} }
/** resize chart */ /** resize chart */
function resize() { function resize() {
chart?.resize(); chart.value?.resize();
} }
/** destroy chart */ /** destroy chart */
async function destroy() { async function destroy() {
if (!chart) return; if (!chart.value) return;
await onDestroy?.(chart); await onDestroy?.(chart.value);
chart?.dispose(); chart.value?.dispose();
chart = null; chart.value = null;
} }
/** change chart theme */ /** change chart theme */
async function changeTheme() { async function changeTheme() {
await destroy(); await destroy();
await render(); await render();
await onUpdated?.(chart!); await onUpdated?.(chart.value!);
} }
/** /**
@@ -196,26 +187,29 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
initialSize.width = w; initialSize.width = w;
initialSize.height = h; initialSize.height = h;
// size is abnormal, destroy chart // resize chart
if (!canRender()) { if (isRendered()) {
await destroy(); resize();
return; return;
} }
// resize chart
if (isRendered()) {
resize();
}
// render chart // render chart
await render(); await render();
if (chart.value) {
await onUpdated?.(chart.value);
}
} }
scope.run(() => { scope.run(() => {
watch([width, height], ([newWidth, newHeight]) => { watch(
[width, height],
([newWidth, newHeight]) => {
renderChartBySize(newWidth, newHeight); renderChartBySize(newWidth, newHeight);
}); },
{ flush: 'post' }
);
watch(darkMode, () => { watch(darkMode, () => {
changeTheme(); changeTheme();
@@ -229,6 +223,7 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
return { return {
domRef, domRef,
chart,
updateOptions, updateOptions,
setOptions setOptions
}; };

View File

@@ -1,192 +1,55 @@
import { computed, effectScope, onScopeDispose, reactive, ref, watch } from 'vue'; import { computed, effectScope, onScopeDispose, reactive, shallowRef, watch } from 'vue';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import type { PaginationProps } from 'naive-ui'; import type { PaginationProps } from 'naive-ui';
import { useBoolean, useTable } from '@sa/hooks';
import type { PaginationData, TableColumnCheck, UseTableOptions } from '@sa/hooks';
import type { FlatResponseData } from '@sa/axios';
import { jsonClone } from '@sa/utils'; import { jsonClone } from '@sa/utils';
import { useBoolean, useHookTable } from '@sa/hooks';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales'; import { $t } from '@/locales';
type TableData = NaiveUI.TableData; export type UseNaiveTableOptions<ResponseData, ApiData, Pagination extends boolean> = Omit<
type GetTableData<A extends NaiveUI.TableApiFn> = NaiveUI.GetTableData<A>; UseTableOptions<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, Pagination>,
type TableColumn<T> = NaiveUI.TableColumn<T>; 'pagination' | 'getColumnChecks' | 'getColumns'
> & {
/**
* get column visible
*
* @param column
*
* @default true
*
* @returns true if the column is visible, false otherwise
*/
getColumnVisible?: (column: NaiveUI.TableColumn<ApiData>) => boolean;
};
export function useTable<A extends NaiveUI.TableApiFn>(config: NaiveUI.NaiveTableConfig<A>) { const SELECTION_KEY = '__selection__';
const EXPAND_KEY = '__expand__';
export function useNaiveTable<ResponseData, ApiData>(options: UseNaiveTableOptions<ResponseData, ApiData, false>) {
const scope = effectScope(); const scope = effectScope();
const appStore = useAppStore(); const appStore = useAppStore();
const isMobile = computed(() => appStore.isMobile); const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
...options,
const { apiFn, apiParams, immediate, showTotal } = config; getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns
const SELECTION_KEY = '__selection__';
const EXPAND_KEY = '__expand__';
const {
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
getData,
searchParams,
updateSearchParams,
resetSearchParams
} = useHookTable<A, GetTableData<A>, TableColumn<NaiveUI.TableDataWithIndex<GetTableData<A>>>>({
apiFn,
apiParams,
columns: config.columns,
transformer: res => {
const { records = [], current = 1, size = 10, total = 0 } = res.data || {};
// Ensure that the size is greater than 0, If it is less than 0, it will cause paging calculation errors.
const pageSize = size <= 0 ? 10 : size;
const recordsWithIndex = records.map((item, index) => {
return {
...item,
index: (current - 1) * pageSize + index + 1
};
}); });
return { // calculate the total width of the table this is used for horizontal scrolling
data: recordsWithIndex, const scrollX = computed(() => {
pageNum: current, return result.columns.value.reduce((acc, column) => {
pageSize, return acc + Number(column.width ?? column.minWidth ?? 120);
total }, 0);
};
},
getColumnChecks: cols => {
const checks: NaiveUI.TableColumnCheck[] = [];
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
checks.push({
key: column.key as string,
title: column.title!,
checked: true
}); });
} else if (column.type === 'selection') {
checks.push({
key: SELECTION_KEY,
title: $t('common.check'),
checked: true
});
} else if (column.type === 'expand') {
checks.push({
key: EXPAND_KEY,
title: $t('common.expandColumn'),
checked: true
});
}
});
return checks;
},
getColumns: (cols, checks) => {
const columnMap = new Map<string, TableColumn<GetTableData<A>>>();
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
columnMap.set(column.key as string, column);
} else if (column.type === 'selection') {
columnMap.set(SELECTION_KEY, column);
} else if (column.type === 'expand') {
columnMap.set(EXPAND_KEY, column);
}
});
const filteredColumns = checks
.filter(item => item.checked)
.map(check => columnMap.get(check.key) as TableColumn<GetTableData<A>>);
return filteredColumns;
},
onFetched: async transformed => {
const { pageNum, pageSize, total } = transformed;
updatePagination({
page: pageNum,
pageSize,
itemCount: total
});
},
immediate
});
const pagination: PaginationProps = reactive({
page: 1,
pageSize: 10,
showSizePicker: true,
itemCount: 0,
pageSizes: [10, 15, 20, 25, 30],
onUpdatePage: async (page: number) => {
pagination.page = page;
updateSearchParams({
current: page,
size: pagination.pageSize!
});
getData();
},
onUpdatePageSize: async (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
updateSearchParams({
current: pagination.page,
size: pageSize
});
getData();
},
...(showTotal
? {
prefix: page => $t('datatable.itemCount', { total: page.itemCount })
}
: {})
});
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
const mobilePagination = computed(() => {
const p: PaginationProps = {
...pagination,
pageSlot: isMobile.value ? 3 : 9,
prefix: !isMobile.value && showTotal ? pagination.prefix : undefined
};
return p;
});
function updatePagination(update: Partial<PaginationProps>) {
Object.assign(pagination, update);
}
/**
* get data by page number
*
* @param pageNum the page number. default is 1
*/
async function getDataByPage(pageNum: number = 1) {
updatePagination({
page: pageNum
});
updateSearchParams({
current: pageNum,
size: pagination.pageSize!
});
await getData();
}
scope.run(() => { scope.run(() => {
watch( watch(
() => appStore.locale, () => appStore.locale,
() => { () => {
reloadColumns(); result.reloadColumns();
} }
); );
}); });
@@ -196,27 +59,126 @@ export function useTable<A extends NaiveUI.TableApiFn>(config: NaiveUI.NaiveTabl
}); });
return { return {
loading, ...result,
empty, scrollX
data,
columns,
columnChecks,
reloadColumns,
pagination,
mobilePagination,
updatePagination,
getData,
getDataByPage,
searchParams,
updateSearchParams,
resetSearchParams
}; };
} }
export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>, getData: () => Promise<void>) { type PaginationParams = Pick<PaginationProps, 'page' | 'pageSize'>;
type UseNaivePaginatedTableOptions<ResponseData, ApiData> = UseNaiveTableOptions<ResponseData, ApiData, true> & {
paginationProps?: Omit<PaginationProps, 'page' | 'pageSize' | 'itemCount'>;
/**
* whether to show the total count of the table
*
* @default true
*/
showTotal?: boolean;
onPaginationParamsChange?: (params: PaginationParams) => void | Promise<void>;
};
export function useNaivePaginatedTable<ResponseData, ApiData>(
options: UseNaivePaginatedTableOptions<ResponseData, ApiData>
) {
const scope = effectScope();
const appStore = useAppStore();
const isMobile = computed(() => appStore.isMobile);
const showTotal = computed(() => options.showTotal ?? true);
const pagination = reactive({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 15, 20, 25, 30],
prefix: showTotal.value ? page => $t('datatable.itemCount', { total: page.itemCount }) : undefined,
onUpdatePage(page) {
pagination.page = page;
},
onUpdatePageSize(pageSize) {
pagination.pageSize = pageSize;
pagination.page = 1;
},
...options.paginationProps
}) as PaginationProps;
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
const mobilePagination = computed(() => {
const p: PaginationProps = {
...pagination,
pageSlot: isMobile.value ? 3 : 9,
prefix: !isMobile.value && showTotal.value ? pagination.prefix : undefined
};
return p;
});
const paginationParams = computed(() => {
const { page, pageSize } = pagination;
return {
page,
pageSize
};
});
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, true>({
...options,
pagination: true,
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns,
onFetched: data => {
pagination.itemCount = data.total;
}
});
async function getDataByPage(page: number = 1) {
if (page !== pagination.page) {
pagination.page = page;
return;
}
await result.getData();
}
scope.run(() => {
watch(
() => appStore.locale,
() => {
result.reloadColumns();
}
);
watch(paginationParams, async newVal => {
await options.onPaginationParamsChange?.(newVal);
await result.getData();
});
});
onScopeDispose(() => {
scope.stop();
});
return {
...result,
getDataByPage,
pagination,
mobilePagination
};
}
export function useTableOperate<TableData>(
data: Ref<TableData[]>,
idKey: keyof TableData,
getData: () => Promise<void>
) {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean(); const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
const operateType = ref<NaiveUI.TableOperateType>('add'); const operateType = shallowRef<NaiveUI.TableOperateType>('add');
function handleAdd() { function handleAdd() {
operateType.value = 'add'; operateType.value = 'add';
@@ -224,18 +186,18 @@ export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>,
} }
/** the editing row data */ /** the editing row data */
const editingData: Ref<T | null> = ref(null); const editingData = shallowRef<TableData | null>(null);
function handleEdit(id: T['id']) { function handleEdit(id: TableData[keyof TableData]) {
operateType.value = 'edit'; operateType.value = 'edit';
const findItem = data.value.find(item => item.id === id) || null; const findItem = data.value.find(item => item[idKey] === id) || null;
editingData.value = jsonClone(findItem); editingData.value = jsonClone(findItem);
openDrawer(); openDrawer();
} }
/** the checked row keys of table */ /** the checked row keys of table */
const checkedRowKeys = ref<string[]>([]); const checkedRowKeys = shallowRef<string[]>([]);
/** the hook after the batch delete operation is completed */ /** the hook after the batch delete operation is completed */
async function onBatchDeleted() { async function onBatchDeleted() {
@@ -267,6 +229,82 @@ export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>,
}; };
} }
function isTableColumnHasKey<T>(column: TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> { export function defaultTransform<ApiData>(
response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>
): PaginationData<ApiData> {
const { data, error } = response;
if (!error) {
const { records, current, size, total } = data;
return {
data: records,
pageNum: current,
pageSize: size,
total
};
}
return {
data: [],
pageNum: 1,
pageSize: 10,
total: 0
};
}
function getColumnChecks<Column extends NaiveUI.TableColumn<any>>(
cols: Column[],
getColumnVisible?: (column: Column) => boolean
) {
const checks: TableColumnCheck[] = [];
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
checks.push({
key: column.key as string,
title: column.title!,
checked: true,
visible: getColumnVisible?.(column) ?? true
});
} else if (column.type === 'selection') {
checks.push({
key: SELECTION_KEY,
title: $t('common.check'),
checked: true,
visible: getColumnVisible?.(column) ?? false
});
} else if (column.type === 'expand') {
checks.push({
key: EXPAND_KEY,
title: $t('common.expandColumn'),
checked: true,
visible: getColumnVisible?.(column) ?? false
});
}
});
return checks;
}
function getColumns<Column extends NaiveUI.TableColumn<any>>(cols: Column[], checks: TableColumnCheck[]) {
const columnMap = new Map<string, Column>();
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
columnMap.set(column.key as string, column);
} else if (column.type === 'selection') {
columnMap.set(SELECTION_KEY, column);
} else if (column.type === 'expand') {
columnMap.set(EXPAND_KEY, column);
}
});
const filteredColumns = checks.filter(item => item.checked).map(check => columnMap.get(check.key) as Column);
return filteredColumns;
}
export function isTableColumnHasKey<T>(column: NaiveUI.TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> {
return Boolean((column as NaiveUI.TableColumnWithKey<T>).key); return Boolean((column as NaiveUI.TableColumnWithKey<T>).key);
} }

View File

@@ -10,7 +10,7 @@ import GlobalTab from '../modules/global-tab/index.vue';
import GlobalContent from '../modules/global-content/index.vue'; import GlobalContent from '../modules/global-content/index.vue';
import GlobalFooter from '../modules/global-footer/index.vue'; import GlobalFooter from '../modules/global-footer/index.vue';
import ThemeDrawer from '../modules/theme-drawer/index.vue'; import ThemeDrawer from '../modules/theme-drawer/index.vue';
import { setupMixMenuContext } from '../context'; import { provideMixMenuContext } from '../modules/global-menu/context';
defineOptions({ defineOptions({
name: 'BaseLayout' name: 'BaseLayout'
@@ -18,7 +18,7 @@ defineOptions({
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = setupMixMenuContext(); const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = provideMixMenuContext();
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue')); const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'));
@@ -29,7 +29,7 @@ const layoutMode = computed(() => {
}); });
const headerProps = computed(() => { const headerProps = computed(() => {
const { mode, reverseHorizontalMix } = themeStore.layout; const { mode } = themeStore.layout;
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = { const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
vertical: { vertical: {
@@ -42,15 +42,25 @@ const headerProps = computed(() => {
showMenu: false, showMenu: false,
showMenuToggler: false showMenuToggler: false
}, },
'vertical-hybrid-header-first': {
showLogo: !isActiveFirstLevelMenuHasChildren.value,
showMenu: true,
showMenuToggler: false
},
horizontal: { horizontal: {
showLogo: true, showLogo: true,
showMenu: true, showMenu: true,
showMenuToggler: false showMenuToggler: false
}, },
'horizontal-mix': { 'top-hybrid-sidebar-first': {
showLogo: true, showLogo: true,
showMenu: true, showMenu: true,
showMenuToggler: reverseHorizontalMix && isActiveFirstLevelMenuHasChildren.value showMenuToggler: false
},
'top-hybrid-header-first': {
showLogo: true,
showMenu: true,
showMenuToggler: isActiveFirstLevelMenuHasChildren.value
} }
}; };
@@ -61,44 +71,56 @@ const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal');
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix'); const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix'); const isVerticalHybridHeaderFirst = computed(() => themeStore.layout.mode === 'vertical-hybrid-header-first');
const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first');
const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first');
const siderWidth = computed(() => getSiderWidth()); const siderWidth = computed(() => getSiderWidth());
const siderCollapsedWidth = computed(() => getSiderCollapsedWidth()); const siderCollapsedWidth = computed(() => getSiderCollapsedWidth());
function getSiderWidth() { function getSiderAndCollapsedWidth(isCollapsed: boolean) {
const { reverseHorizontalMix } = themeStore.layout; const {
const { width, mixWidth, mixChildMenuWidth } = themeStore.sider; mixChildMenuWidth,
collapsedWidth,
width: themeWidth,
mixCollapsedWidth,
mixWidth: themeMixWidth
} = themeStore.sider;
if (isHorizontalMix.value && reverseHorizontalMix) { const width = isCollapsed ? collapsedWidth : themeWidth;
const mixWidth = isCollapsed ? mixCollapsedWidth : themeMixWidth;
if (isTopHybridHeaderFirst.value) {
return isActiveFirstLevelMenuHasChildren.value ? width : 0; return isActiveFirstLevelMenuHasChildren.value ? width : 0;
} }
let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width; if (isVerticalHybridHeaderFirst.value && !isActiveFirstLevelMenuHasChildren.value) {
return 0;
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
w += mixChildMenuWidth;
} }
return w; const isMixMode = isVerticalMix.value || isTopHybridSidebarFirst.value || isVerticalHybridHeaderFirst.value;
let finalWidth = isMixMode ? mixWidth : width;
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
finalWidth += mixChildMenuWidth;
}
if (isVerticalHybridHeaderFirst.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
finalWidth += mixChildMenuWidth;
}
return finalWidth;
}
function getSiderWidth() {
return getSiderAndCollapsedWidth(false);
} }
function getSiderCollapsedWidth() { function getSiderCollapsedWidth() {
const { reverseHorizontalMix } = themeStore.layout; return getSiderAndCollapsedWidth(true);
const { collapsedWidth, mixCollapsedWidth, mixChildMenuWidth } = themeStore.sider;
if (isHorizontalMix.value && reverseHorizontalMix) {
return isActiveFirstLevelMenuHasChildren.value ? collapsedWidth : 0;
}
let w = isVerticalMix.value || isHorizontalMix.value ? mixCollapsedWidth : collapsedWidth;
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
w += mixChildMenuWidth;
}
return w;
} }
</script> </script>

View File

@@ -1,83 +0,0 @@
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useContext } from '@sa/hooks';
import { useRouteStore } from '@/store/modules/route';
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);
function useMixMenu() {
const route = useRoute();
const routeStore = useRouteStore();
const { selectedKey } = useMenu();
const activeFirstLevelMenuKey = ref('');
function setActiveFirstLevelMenuKey(key: string) {
activeFirstLevelMenuKey.value = key;
}
function getActiveFirstLevelMenuKey() {
const [firstLevelRouteName] = selectedKey.value.split('_');
setActiveFirstLevelMenuKey(firstLevelRouteName);
}
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
routeStore.menus.map(menu => {
const { children: _, ...rest } = menu;
return rest;
})
);
const childLevelMenus = computed<App.Global.Menu[]>(
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
);
const isActiveFirstLevelMenuHasChildren = computed(() => {
if (!activeFirstLevelMenuKey.value) {
return false;
}
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value);
return Boolean(findItem?.children?.length);
});
watch(
() => route.name,
() => {
getActiveFirstLevelMenuKey();
},
{ immediate: true }
);
return {
allMenus,
firstLevelMenus,
childLevelMenus,
isActiveFirstLevelMenuHasChildren,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
getActiveFirstLevelMenuKey
};
}
export function useMenu() {
const route = useRoute();
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
return {
selectedKey
};
}

View File

@@ -38,7 +38,7 @@ const { isFullscreen, toggle } = useFullscreen();
<GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" /> <GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" />
</div> </div>
<div class="h-full flex-y-center justify-end"> <div class="h-full flex-y-center justify-end">
<GlobalSearch /> <GlobalSearch v-if="themeStore.header.globalSearch.visible" />
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" /> <FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
<LangSwitch <LangSwitch
v-if="themeStore.header.multilingual.visible" v-if="themeStore.header.multilingual.visible"

View File

@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { createReusableTemplate } from '@vueuse/core'; import { createReusableTemplate } from '@vueuse/core';
import { SimpleScrollbar } from '@sa/materials'; import { SimpleScrollbar } from '@sa/materials';
import { transformColorWithOpacity } from '@sa/color'; import { transformColorWithOpacity } from '@sa/color';
import type { RouteKey } from '@elegant-router/types';
defineOptions({ defineOptions({
name: 'FirstLevelMenu' name: 'FirstLevelMenu'
@@ -20,7 +21,7 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
interface Emits { interface Emits {
(e: 'select', menu: App.Global.Menu): boolean; (e: 'select', menuKey: RouteKey): boolean;
(e: 'toggleSiderCollapse'): void; (e: 'toggleSiderCollapse'): void;
} }
@@ -47,8 +48,8 @@ const selectedBgColor = computed(() => {
return darkMode ? dark : light; return darkMode ? dark : light;
}); });
function handleClickMixMenu(menu: App.Global.Menu) { function handleClickMixMenu(menuKey: RouteKey) {
emit('select', menu); emit('select', menuKey);
} }
function toggleSiderCollapse() { function toggleSiderCollapse() {
@@ -88,7 +89,7 @@ function toggleSiderCollapse() {
:icon="menu.icon" :icon="menu.icon"
:active="menu.key === activeMenuKey" :active="menu.key === activeMenuKey"
:is-mini="siderCollapse" :is-mini="siderCollapse"
@click="handleClickMixMenu(menu)" @click="handleClickMixMenu(menu.routeKey)"
/> />
</SimpleScrollbar> </SimpleScrollbar>
<MenuToggler <MenuToggler

View File

@@ -0,0 +1,143 @@
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useContext } from '@sa/hooks';
import type { RouteKey } from '@elegant-router/types';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', useMixMenu);
function useMixMenu() {
const route = useRoute();
const routeStore = useRouteStore();
const { selectedKey } = useMenu();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
routeStore.menus.map(menu => {
const { children: _, ...rest } = menu;
return rest;
})
);
const activeFirstLevelMenuKey = ref('');
function setActiveFirstLevelMenuKey(key: string) {
activeFirstLevelMenuKey.value = key;
}
function getActiveFirstLevelMenuKey() {
const [firstLevelRouteName] = selectedKey.value.split('_');
setActiveFirstLevelMenuKey(firstLevelRouteName);
}
const isActiveFirstLevelMenuHasChildren = computed(() => {
if (!activeFirstLevelMenuKey.value) {
return false;
}
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value);
return Boolean(findItem?.children?.length);
});
function handleSelectFirstLevelMenu(key: RouteKey) {
setActiveFirstLevelMenuKey(key);
if (!isActiveFirstLevelMenuHasChildren.value) {
routerPushByKeyWithMetaQuery(key);
}
}
const secondLevelMenus = computed<App.Global.Menu[]>(
() => allMenus.value.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
);
const activeSecondLevelMenuKey = ref('');
function setActiveSecondLevelMenuKey(key: string) {
activeSecondLevelMenuKey.value = key;
}
function getActiveSecondLevelMenuKey() {
const keys = selectedKey.value.split('_');
if (keys.length < 2) {
setActiveSecondLevelMenuKey('');
return;
}
const [firstLevelRouteName, level2SuffixName] = keys;
const secondLevelRouteName = `${firstLevelRouteName}_${level2SuffixName}`;
setActiveSecondLevelMenuKey(secondLevelRouteName);
}
const isActiveSecondLevelMenuHasChildren = computed(() => {
if (!activeSecondLevelMenuKey.value) {
return false;
}
const findItem = secondLevelMenus.value.find(item => item.key === activeSecondLevelMenuKey.value);
return Boolean(findItem?.children?.length);
});
function handleSelectSecondLevelMenu(key: RouteKey) {
setActiveSecondLevelMenuKey(key);
if (!isActiveSecondLevelMenuHasChildren.value) {
routerPushByKeyWithMetaQuery(key);
}
}
const childLevelMenus = computed<App.Global.Menu[]>(
() => secondLevelMenus.value.find(menu => menu.key === activeSecondLevelMenuKey.value)?.children || []
);
watch(
() => route.name,
() => {
getActiveFirstLevelMenuKey();
},
{ immediate: true }
);
return {
firstLevelMenus,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
isActiveFirstLevelMenuHasChildren,
handleSelectFirstLevelMenu,
getActiveFirstLevelMenuKey,
secondLevelMenus,
activeSecondLevelMenuKey,
setActiveSecondLevelMenuKey,
isActiveSecondLevelMenuHasChildren,
handleSelectSecondLevelMenu,
getActiveSecondLevelMenuKey,
childLevelMenus
};
}
export function useMenu() {
const route = useRoute();
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
return {
selectedKey
};
}

View File

@@ -5,9 +5,10 @@ import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import VerticalMenu from './modules/vertical-menu.vue'; import VerticalMenu from './modules/vertical-menu.vue';
import VerticalMixMenu from './modules/vertical-mix-menu.vue'; import VerticalMixMenu from './modules/vertical-mix-menu.vue';
import VerticalHybridHeaderFirst from './modules/vertical-hybrid-header-first.vue';
import HorizontalMenu from './modules/horizontal-menu.vue'; import HorizontalMenu from './modules/horizontal-menu.vue';
import HorizontalMixMenu from './modules/horizontal-mix-menu.vue'; import TopHybridSidebarFirst from './modules/top-hybrid-sidebar-first.vue';
import ReversedHorizontalMixMenu from './modules/reversed-horizontal-mix-menu.vue'; import TopHybridHeaderFirst from './modules/top-hybrid-header-first.vue';
defineOptions({ defineOptions({
name: 'GlobalMenu' name: 'GlobalMenu'
@@ -20,8 +21,10 @@ const activeMenu = computed(() => {
const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = { const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = {
vertical: VerticalMenu, vertical: VerticalMenu,
'vertical-mix': VerticalMixMenu, 'vertical-mix': VerticalMixMenu,
'vertical-hybrid-header-first': VerticalHybridHeaderFirst,
horizontal: HorizontalMenu, horizontal: HorizontalMenu,
'horizontal-mix': themeStore.layout.reverseHorizontalMix ? ReversedHorizontalMixMenu : HorizontalMixMenu 'top-hybrid-sidebar-first': TopHybridSidebarFirst,
'top-hybrid-header-first': TopHybridHeaderFirst
}; };
return menuMap[themeStore.layout.mode]; return menuMap[themeStore.layout.mode];

View File

@@ -2,7 +2,7 @@
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app'; import { GLOBAL_HEADER_MENU_ID } from '@/constants/app';
import { useRouteStore } from '@/store/modules/route'; import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { useMenu } from '../../../context'; import { useMenu } from '../context';
defineOptions({ defineOptions({
name: 'HorizontalMenu' name: 'HorizontalMenu'

View File

@@ -1,17 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import type { RouteKey } from '@elegant-router/types';
import { SimpleScrollbar } from '@sa/materials'; import { SimpleScrollbar } from '@sa/materials';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'; import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route'; import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { useMenu, useMixMenuContext } from '../../../context'; import { useMenu, useMixMenuContext } from '../context';
defineOptions({ defineOptions({
name: 'ReversedHorizontalMixMenu' name: 'TopHybridHeaderFirst'
}); });
const route = useRoute(); const route = useRoute();
@@ -19,23 +18,10 @@ const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const routeStore = useRouteStore(); const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush(); const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } =
firstLevelMenus, useMixMenuContext('TopHybridHeaderFirst');
childLevelMenus,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
isActiveFirstLevelMenuHasChildren
} = useMixMenuContext();
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
function handleSelectMixMenu(key: RouteKey) {
setActiveFirstLevelMenuKey(key);
if (!isActiveFirstLevelMenuHasChildren.value) {
routerPushByKeyWithMetaQuery(key);
}
}
const expandedKeys = ref<string[]>([]); const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() { function updateExpandedKeys() {
@@ -63,7 +49,7 @@ watch(
:options="firstLevelMenus" :options="firstLevelMenus"
:indent="18" :indent="18"
responsive responsive
@update:value="handleSelectMixMenu" @update:value="handleSelectFirstLevelMenu"
/> />
</Teleport> </Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
@@ -75,7 +61,7 @@ watch(
:collapsed="appStore.siderCollapse" :collapsed="appStore.siderCollapse"
:collapsed-width="themeStore.sider.collapsedWidth" :collapsed-width="themeStore.sider.collapsedWidth"
:collapsed-icon-size="22" :collapsed-icon-size="22"
:options="childLevelMenus" :options="secondLevelMenus"
:indent="18" :indent="18"
@update:value="routerPushByKeyWithMetaQuery" @update:value="routerPushByKeyWithMetaQuery"
/> />

View File

@@ -4,25 +4,18 @@ import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import FirstLevelMenu from '../components/first-level-menu.vue'; import FirstLevelMenu from '../components/first-level-menu.vue';
import { useMenu, useMixMenuContext } from '../../../context'; import { useMenu, useMixMenuContext } from '../context';
defineOptions({ defineOptions({
name: 'HorizontalMixMenu' name: 'TopHybridSidebarFirst'
}); });
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush(); const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { allMenus, childLevelMenus, activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext(); const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } =
useMixMenuContext('TopHybridSidebarFirst');
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (!menu.children?.length) {
routerPushByKeyWithMetaQuery(menu.routeKey);
}
}
</script> </script>
<template> <template>
@@ -30,22 +23,24 @@ function handleSelectMixMenu(menu: App.Global.Menu) {
<NMenu <NMenu
mode="horizontal" mode="horizontal"
:value="selectedKey" :value="selectedKey"
:options="childLevelMenus" :options="secondLevelMenus"
:indent="18" :indent="18"
responsive responsive
@update:value="routerPushByKeyWithMetaQuery" @update:value="routerPushByKeyWithMetaQuery"
/> />
</Teleport> </Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full pt-2">
<FirstLevelMenu <FirstLevelMenu
:menus="allMenus" :menus="firstLevelMenus"
:active-menu-key="activeFirstLevelMenuKey" :active-menu-key="activeFirstLevelMenuKey"
:sider-collapse="appStore.siderCollapse" :sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode" :dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor" :theme-color="themeStore.themeColor"
@select="handleSelectMixMenu" @select="handleSelectFirstLevelMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse" @toggle-sider-collapse="appStore.toggleSiderCollapse"
/> />
</div>
</Teleport> </Teleport>
</template> </template>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import type { RouteKey } from '@elegant-router/types';
import { SimpleScrollbar } from '@sa/materials';
import { useBoolean } from '@sa/hooks';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { useMenu, useMixMenuContext } from '../context';
import FirstLevelMenu from '../components/first-level-menu.vue';
import GlobalLogo from '../../global-logo/index.vue';
defineOptions({
name: 'VerticalHybridHeaderFirst'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
const {
firstLevelMenus,
activeFirstLevelMenuKey,
handleSelectFirstLevelMenu,
getActiveFirstLevelMenuKey,
secondLevelMenus,
activeSecondLevelMenuKey,
isActiveSecondLevelMenuHasChildren,
handleSelectSecondLevelMenu,
getActiveSecondLevelMenuKey,
childLevelMenus
} = useMixMenuContext('VerticalHybridHeaderFirst');
const { selectedKey } = useMenu();
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const hasChildMenus = computed(() => childLevelMenus.value.length > 0);
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
function handleSelectMixMenu(key: RouteKey) {
handleSelectSecondLevelMenu(key);
if (isActiveSecondLevelMenuHasChildren.value) {
setDrawerVisible(true);
}
}
function handleSelectMenu(key: RouteKey) {
handleSelectFirstLevelMenu(key);
if (secondLevelMenus.value.length > 0) {
handleSelectMixMenu(secondLevelMenus.value[0].routeKey);
}
}
function handleResetActiveMenu() {
setDrawerVisible(false);
if (!appStore.mixSiderFixed) {
getActiveFirstLevelMenuKey();
getActiveSecondLevelMenuKey();
}
}
const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = [];
return;
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
}
watch(
() => route.name,
() => {
updateExpandedKeys();
},
{ immediate: true }
);
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<NMenu
mode="horizontal"
:value="activeFirstLevelMenuKey"
:options="firstLevelMenus"
:indent="18"
responsive
@update:value="handleSelectMenu"
/>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu
:menus="secondLevelMenus"
:active-menu-key="activeSecondLevelMenuKey"
:inverted="inverted"
:sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor"
@select="handleSelectMixMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse"
>
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
</FirstLevelMenu>
<div
class="relative h-full transition-width-300"
:style="{ width: appStore.mixSiderFixed && hasChildMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<DarkModeContainer
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
:inverted="inverted"
:style="{ width: showDrawer ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<header class="flex-y-center justify-between px-12px" :style="{ height: themeStore.header.height + 'px' }">
<h2 class="text-16px text-primary font-bold">{{ $t('system.title') }}</h2>
<PinToggler
:pin="appStore.mixSiderFixed"
:class="{ 'text-white:88 !hover:text-white': inverted }"
@click="appStore.toggleMixSiderFixed"
/>
</header>
<SimpleScrollbar>
<NMenu
v-model:expanded-keys="expandedKeys"
mode="vertical"
:value="selectedKey"
:options="childLevelMenus"
:inverted="inverted"
:indent="18"
@update:value="routerPushByKeyWithMetaQuery"
/>
</SimpleScrollbar>
</DarkModeContainer>
</div>
</div>
</Teleport>
</template>
<style scoped></style>

View File

@@ -7,7 +7,7 @@ import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route'; import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { useMenu } from '../../../context'; import { useMenu } from '../context';
defineOptions({ defineOptions({
name: 'VerticalMenu' name: 'VerticalMenu'

View File

@@ -3,13 +3,14 @@ import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { SimpleScrollbar } from '@sa/materials'; import { SimpleScrollbar } from '@sa/materials';
import { useBoolean } from '@sa/hooks'; import { useBoolean } from '@sa/hooks';
import type { RouteKey } from '@elegant-router/types';
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app'; import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route'; import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales'; import { $t } from '@/locales';
import { useMenu, useMixMenuContext } from '../../../context'; import { useMenu, useMixMenuContext } from '../context';
import FirstLevelMenu from '../components/first-level-menu.vue'; import FirstLevelMenu from '../components/first-level-menu.vue';
import GlobalLogo from '../../global-logo/index.vue'; import GlobalLogo from '../../global-logo/index.vue';
@@ -24,28 +25,26 @@ const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush(); const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean(); const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
const { const {
allMenus, firstLevelMenus,
childLevelMenus, secondLevelMenus,
activeFirstLevelMenuKey, activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey, isActiveFirstLevelMenuHasChildren,
getActiveFirstLevelMenuKey getActiveFirstLevelMenuKey,
// handleSelectFirstLevelMenu
} = useMixMenuContext(); } = useMixMenuContext('VerticalMixMenu');
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted); const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const hasChildMenus = computed(() => childLevelMenus.value.length > 0); const hasChildMenus = computed(() => secondLevelMenus.value.length > 0);
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed)); const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
function handleSelectMixMenu(menu: App.Global.Menu) { function handleSelectMenu(key: RouteKey) {
setActiveFirstLevelMenuKey(menu.key); handleSelectFirstLevelMenu(key);
if (menu.children?.length) { if (isActiveFirstLevelMenuHasChildren.value) {
setDrawerVisible(true); setDrawerVisible(true);
} else {
routerPushByKeyWithMetaQuery(menu.routeKey);
} }
} }
@@ -80,13 +79,13 @@ watch(
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full flex" @mouseleave="handleResetActiveMenu"> <div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu <FirstLevelMenu
:menus="allMenus" :menus="firstLevelMenus"
:active-menu-key="activeFirstLevelMenuKey" :active-menu-key="activeFirstLevelMenuKey"
:inverted="inverted" :inverted="inverted"
:sider-collapse="appStore.siderCollapse" :sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode" :dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor" :theme-color="themeStore.themeColor"
@select="handleSelectMixMenu" @select="handleSelectMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse" @toggle-sider-collapse="appStore.toggleSiderCollapse"
> >
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" /> <GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
@@ -113,7 +112,7 @@ watch(
v-model:expanded-keys="expandedKeys" v-model:expanded-keys="expandedKeys"
mode="vertical" mode="vertical"
:value="selectedKey" :value="selectedKey"
:options="childLevelMenus" :options="secondLevelMenus"
:inverted="inverted" :inverted="inverted"
:indent="18" :indent="18"
@update:value="routerPushByKeyWithMetaQuery" @update:value="routerPushByKeyWithMetaQuery"

View File

@@ -12,10 +12,13 @@ defineOptions({
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix'); const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first');
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix'); const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first');
const darkMenu = computed(() => !themeStore.darkMode && !isHorizontalMix.value && themeStore.sider.inverted); const darkMenu = computed(
const showLogo = computed(() => !isVerticalMix.value && !isHorizontalMix.value); () =>
!themeStore.darkMode && !isTopHybridSidebarFirst.value && !isTopHybridHeaderFirst.value && themeStore.sider.inverted
);
const showLogo = computed(() => themeStore.layout.mode === 'vertical');
const menuWrapperClass = computed(() => (showLogo.value ? 'flex-1-hidden' : 'h-full')); const menuWrapperClass = computed(() => (showLogo.value ? 'flex-1-hidden' : 'h-full'));
</script> </script>

View File

@@ -5,7 +5,6 @@ import { useElementBounding } from '@vueuse/core';
import { PageTab } from '@sa/materials'; import { PageTab } from '@sa/materials';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useTabStore } from '@/store/modules/tab'; import { useTabStore } from '@/store/modules/tab';
import { isPC } from '@/utils/agent'; import { isPC } from '@/utils/agent';
import BetterScroll from '@/components/custom/better-scroll.vue'; import BetterScroll from '@/components/custom/better-scroll.vue';
@@ -18,7 +17,6 @@ defineOptions({
const route = useRoute(); const route = useRoute();
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const routeStore = useRouteStore();
const tabStore = useTabStore(); const tabStore = useTabStore();
const bsWrapper = ref<HTMLElement>(); const bsWrapper = ref<HTMLElement>();
@@ -82,12 +80,8 @@ function getContextMenuDisabledKeys(tabId: string) {
return disabledKeys; return disabledKeys;
} }
async function handleCloseTab(tab: App.Global.Tab) { function handleCloseTab(tab: App.Global.Tab) {
await tabStore.removeTab(tab.id); tabStore.removeTab(tab.id);
if (themeStore.resetCacheStrategy === 'close') {
routeStore.resetRouteCache(tab.routeKey);
}
} }
async function refresh() { async function refresh() {
@@ -175,7 +169,9 @@ init();
<div <div
ref="tabRef" ref="tabRef"
class="h-full flex pr-18px" class="h-full flex pr-18px"
:class="[themeStore.tab.mode === 'chrome' ? 'items-end' : 'items-center gap-12px']" :class="[
themeStore.tab.mode === 'chrome' || themeStore.tab.mode === 'slider' ? 'items-end' : 'items-center gap-12px'
]"
> >
<PageTab <PageTab
v-for="tab in tabStore.tabs" v-for="tab in tabStore.tabs"

View File

@@ -27,7 +27,6 @@ type LayoutConfig = Record<
UnionKey.ThemeLayoutMode, UnionKey.ThemeLayoutMode,
{ {
placement: PopoverPlacement; placement: PopoverPlacement;
headerClass: string;
menuClass: string; menuClass: string;
mainClass: string; mainClass: string;
} }
@@ -36,25 +35,31 @@ type LayoutConfig = Record<
const layoutConfig: LayoutConfig = { const layoutConfig: LayoutConfig = {
vertical: { vertical: {
placement: 'bottom', placement: 'bottom',
headerClass: '',
menuClass: 'w-1/3 h-full', menuClass: 'w-1/3 h-full',
mainClass: 'w-2/3 h-3/4' mainClass: 'w-2/3 h-3/4'
}, },
'vertical-mix': { 'vertical-mix': {
placement: 'bottom', placement: 'bottom',
headerClass: '', menuClass: 'w-1/4 h-full',
mainClass: 'w-2/3 h-3/4'
},
'vertical-hybrid-header-first': {
placement: 'bottom',
menuClass: 'w-1/4 h-full', menuClass: 'w-1/4 h-full',
mainClass: 'w-2/3 h-3/4' mainClass: 'w-2/3 h-3/4'
}, },
horizontal: { horizontal: {
placement: 'bottom', placement: 'bottom',
headerClass: '',
menuClass: 'w-full h-1/4', menuClass: 'w-full h-1/4',
mainClass: 'w-full h-3/4' mainClass: 'w-full h-3/4'
}, },
'horizontal-mix': { 'top-hybrid-sidebar-first': {
placement: 'bottom',
menuClass: 'w-full h-1/4',
mainClass: 'w-2/3 h-3/4'
},
'top-hybrid-header-first': {
placement: 'bottom', placement: 'bottom',
headerClass: '',
menuClass: 'w-full h-1/4', menuClass: 'w-full h-1/4',
mainClass: 'w-2/3 h-3/4' mainClass: 'w-2/3 h-3/4'
} }
@@ -68,25 +73,27 @@ function handleChangeMode(mode: UnionKey.ThemeLayoutMode) {
</script> </script>
<template> <template>
<div class="flex-center flex-wrap gap-x-32px gap-y-16px"> <div class="grid grid-cols-2 gap-x-16px gap-y-12px md:grid-cols-3">
<div <div
v-for="(item, key) in layoutConfig" v-for="(item, key) in layoutConfig"
:key="key" :key="key"
class="flex cursor-pointer border-2px rounded-6px hover:border-primary" class="flex-col-center cursor-pointer"
:class="[mode === key ? 'border-primary' : 'border-transparent']"
@click="handleChangeMode(key)" @click="handleChangeMode(key)"
> >
<NTooltip :placement="item.placement"> <IconTooltip :placement="item.placement">
<template #trigger> <template #trigger>
<div <div
class="h-64px w-96px gap-6px rd-4px p-6px shadow dark:shadow-coolGray-5" class="h-64px w-96px gap-6px rd-4px p-6px shadow ring-2 ring-transparent transition-all hover:ring-primary"
:class="[key.includes('vertical') ? 'flex' : 'flex-col']" :class="{ '!ring-primary': mode === key }"
> >
<div class="h-full w-full gap-1" :class="[key.includes('vertical') ? 'flex' : 'flex-col']">
<slot :name="key"></slot> <slot :name="key"></slot>
</div> </div>
</div>
</template> </template>
{{ $t(themeLayoutModeRecord[key]) }} {{ $t(`theme.layout.layoutMode.${key}_detail`) }}
</NTooltip> </IconTooltip>
<p class="mt-8px text-12px">{{ $t(themeLayoutModeRecord[key]) }}</p>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -13,7 +13,7 @@ defineProps<Props>();
<template> <template>
<div class="w-full flex-y-center justify-between"> <div class="w-full flex-y-center justify-between">
<div> <div class="flex-y-center">
<span class="pr-8px text-base-text">{{ label }}</span> <span class="pr-8px text-base-text">{{ label }}</span>
<slot name="suffix"></slot> <slot name="suffix"></slot>
</div> </div>

View File

@@ -1,26 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales'; import { $t } from '@/locales';
import DarkMode from './modules/dark-mode.vue'; import AppearanceSettings from './modules/appearance/index.vue';
import LayoutMode from './modules/layout-mode.vue'; import LayoutSettings from './modules/layout/index.vue';
import ThemeColor from './modules/theme-color.vue'; import GeneralSettings from './modules/general/index.vue';
import PageFun from './modules/page-fun.vue';
import ConfigOperation from './modules/config-operation.vue'; import ConfigOperation from './modules/config-operation.vue';
import PresetSettings from './modules/preset/index.vue';
defineOptions({ defineOptions({
name: 'ThemeDrawer' name: 'ThemeDrawer'
}); });
const appStore = useAppStore(); const appStore = useAppStore();
const activeTab = ref('appearance');
const drawerWidth = computed(() => {
const width = 400;
// On mobile devices, use 90% of viewport width with a maximum of 400px
if (appStore.isMobile) {
return `min(90vw, ${width}px)`;
}
return width;
});
</script> </script>
<template> <template>
<NDrawer v-model:show="appStore.themeDrawerVisible" display-directive="show" :width="360"> <NDrawer v-model:show="appStore.themeDrawerVisible" display-directive="show" :width="drawerWidth">
<NDrawerContent :title="$t('theme.themeDrawerTitle')" :native-scrollbar="false" closable> <NDrawerContent :title="$t('theme.themeDrawerTitle')" :native-scrollbar="false" closable>
<DarkMode /> <NTabs v-model:value="activeTab" type="segment" size="medium" class="mb-16px">
<LayoutMode /> <NTab name="appearance" :tab="$t('theme.tabs.appearance')"></NTab>
<ThemeColor /> <NTab name="layout" :tab="$t('theme.tabs.layout')"></NTab>
<PageFun /> <NTab name="general" :tab="$t('theme.tabs.general')"></NTab>
<NTab name="preset" :tab="$t('theme.tabs.preset')"></NTab>
</NTabs>
<div class="min-h-400px">
<KeepAlive>
<AppearanceSettings v-if="activeTab === 'appearance'" />
<LayoutSettings v-else-if="activeTab === 'layout'" />
<GeneralSettings v-else-if="activeTab === 'general'" />
<PresetSettings v-else-if="activeTab === 'preset'" />
</KeepAlive>
</div>
<template #footer> <template #footer>
<ConfigOperation /> <ConfigOperation />
</template> </template>
@@ -28,4 +53,14 @@ const appStore = useAppStore();
</NDrawer> </NDrawer>
</template> </template>
<style scoped></style> <style scoped>
:deep(.n-tab) {
display: flex;
align-items: center;
gap: 8px;
}
:deep(.n-tab-pane) {
padding: 0;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import ThemeSchema from './modules/theme-schema.vue';
import ThemeColor from './modules/theme-color.vue';
defineOptions({
name: 'AppearanceSettings'
});
</script>
<template>
<div class="flex-col-stretch gap-16px">
<ThemeSchema />
<ThemeColor />
</div>
</template>
<style scoped></style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales'; import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue'; import SettingItem from '../../../components/setting-item.vue';
defineOptions({ defineOptions({
name: 'ThemeColor' name: 'ThemeColor'
@@ -34,16 +34,13 @@ const swatches: string[] = [
</script> </script>
<template> <template>
<NDivider>{{ $t('theme.themeColor.title') }}</NDivider> <NDivider>{{ $t('theme.appearance.themeColor.title') }}</NDivider>
<div class="flex-col-stretch gap-12px"> <div class="flex-col-stretch gap-12px">
<NTooltip placement="top-start"> <SettingItem key="recommend-color" :label="$t('theme.appearance.recommendColor')">
<template #trigger> <template #suffix>
<SettingItem key="recommend-color" :label="$t('theme.recommendColor')"> <IconTooltip>
<NSwitch v-model:value="themeStore.recommendColor" />
</SettingItem>
</template>
<p> <p>
<span class="pr-12px">{{ $t('theme.recommendColorDesc') }}</span> <span class="pr-12px">{{ $t('theme.appearance.recommendColorDesc') }}</span>
<br /> <br />
<NButton <NButton
text text
@@ -56,11 +53,19 @@ const swatches: string[] = [
https://uicolors.app/create https://uicolors.app/create
</NButton> </NButton>
</p> </p>
</NTooltip> </IconTooltip>
<SettingItem v-for="(_, key) in themeStore.themeColors" :key="key" :label="$t(`theme.themeColor.${key}`)"> </template>
<NSwitch v-model:value="themeStore.recommendColor" />
</SettingItem>
<SettingItem
v-for="(_, key) in themeStore.themeColors"
:key="key"
:label="$t(`theme.appearance.themeColor.${key}`)"
>
<template v-if="key === 'info'" #suffix> <template v-if="key === 'info'" #suffix>
<NCheckbox v-model:checked="themeStore.isInfoFollowPrimary"> <NCheckbox v-model:checked="themeStore.isInfoFollowPrimary">
{{ $t('theme.themeColor.followPrimary') }} {{ $t('theme.appearance.themeColor.followPrimary') }}
</NCheckbox> </NCheckbox>
</template> </template>
<NColorPicker <NColorPicker

View File

@@ -3,10 +3,10 @@ import { computed } from 'vue';
import { themeSchemaRecord } from '@/constants/app'; import { themeSchemaRecord } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales'; import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue'; import SettingItem from '../../../components/setting-item.vue';
defineOptions({ defineOptions({
name: 'DarkMode' name: 'ThemeSchema'
}); });
const themeStore = useThemeStore(); const themeStore = useThemeStore();
@@ -33,7 +33,7 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
</script> </script>
<template> <template>
<NDivider>{{ $t('theme.themeSchema.title') }}</NDivider> <NDivider>{{ $t('theme.appearance.themeSchema.title') }}</NDivider>
<div class="flex-col-stretch gap-16px"> <div class="flex-col-stretch gap-16px">
<div class="i-flex-center"> <div class="i-flex-center">
<NTabs <NTabs
@@ -50,14 +50,14 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
</NTabs> </NTabs>
</div> </div>
<Transition name="sider-inverted"> <Transition name="sider-inverted">
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')"> <SettingItem v-if="showSiderInverted" :label="$t('theme.layout.sider.inverted')">
<NSwitch v-model:value="themeStore.sider.inverted" /> <NSwitch v-model:value="themeStore.sider.inverted" />
</SettingItem> </SettingItem>
</Transition> </Transition>
<SettingItem :label="$t('theme.grayscale')"> <SettingItem :label="$t('theme.appearance.grayscale')">
<NSwitch :value="themeStore.grayscale" @update:value="handleGrayscaleChange" /> <NSwitch :value="themeStore.grayscale" @update:value="handleGrayscaleChange" />
</SettingItem> </SettingItem>
<SettingItem :label="$t('theme.colourWeakness')"> <SettingItem :label="$t('theme.appearance.colourWeakness')">
<NSwitch :value="themeStore.colourWeakness" @update:value="handleColourWeaknessChange" /> <NSwitch :value="themeStore.colourWeakness" @update:value="handleColourWeaknessChange" />
</SettingItem> </SettingItem>
</div> </div>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import GlobalSettings from './modules/global-settings.vue';
import WatermarkSettings from './modules/watermark-settings.vue';
defineOptions({
name: 'GeneralSettings'
});
</script>
<template>
<div class="flex-col-stretch gap-16px">
<GlobalSettings />
<WatermarkSettings />
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'GlobalSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.general.title') }}</NDivider>
<SettingItem :label="$t('theme.general.multilingual.visible')">
<NSwitch v-model:value="themeStore.header.multilingual.visible" />
</SettingItem>
<SettingItem :label="$t('theme.general.globalSearch.visible')">
<NSwitch v-model:value="themeStore.header.globalSearch.visible" />
</SettingItem>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed } from 'vue';
import { watermarkTimeFormatOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'WatermarkSettings'
});
const themeStore = useThemeStore();
const isWatermarkTextVisible = computed(
() => themeStore.watermark.visible && !themeStore.watermark.enableUserName && !themeStore.watermark.enableTime
);
</script>
<template>
<NDivider>{{ $t('theme.general.watermark.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.general.watermark.visible')">
<NSwitch v-model:value="themeStore.watermark.visible" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="2" :label="$t('theme.general.watermark.enableUserName')">
<NSwitch :value="themeStore.watermark.enableUserName" @update:value="themeStore.setWatermarkEnableUserName" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="3" :label="$t('theme.general.watermark.enableTime')">
<NSwitch :value="themeStore.watermark.enableTime" @update:value="themeStore.setWatermarkEnableTime" />
</SettingItem>
<SettingItem
v-if="themeStore.watermark.visible && themeStore.watermark.enableTime"
key="4"
:label="$t('theme.general.watermark.timeFormat')"
>
<NSelect
v-model:value="themeStore.watermark.timeFormat"
:options="watermarkTimeFormatOptions"
size="small"
class="w-210px"
/>
</SettingItem>
<SettingItem v-if="isWatermarkTextVisible" key="5" :label="$t('theme.general.watermark.text')">
<NInput
v-model:value="themeStore.watermark.text"
autosize
type="text"
size="small"
class="w-120px"
placeholder="SoybeanAdmin"
/>
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme';
import LayoutMode from './modules/layout-mode.vue';
import TabSettings from './modules/tab-settings.vue';
import HeaderSettings from './modules/header-settings.vue';
import SiderSettings from './modules/sider-settings.vue';
import FooterSettings from './modules/footer-settings.vue';
import ContentSettings from './modules/content-settings.vue';
defineOptions({
name: 'LayoutSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<div class="flex-col-stretch gap-16px">
<LayoutMode />
<TabSettings />
<HeaderSettings />
<!-- The top menu mode does not have a sidebar -->
<SiderSettings v-if="themeStore.layout.mode !== 'horizontal'" />
<FooterSettings />
<ContentSettings />
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue';
import { themePageAnimationModeOptions, themeScrollModeOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'ContentSettings'
});
const themeStore = useThemeStore();
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
</script>
<template>
<NDivider>{{ $t('theme.layout.content.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.content.scrollMode.title')">
<template #suffix>
<IconTooltip :desc="$t('theme.layout.content.scrollMode.tip')" />
</template>
<NSelect
v-model:value="themeStore.layout.scrollMode"
:options="translateOptions(themeScrollModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="2" :label="$t('theme.layout.content.page.animate')">
<NSwitch v-model:value="themeStore.page.animate" />
</SettingItem>
<SettingItem v-if="themeStore.page.animate" key="3" :label="$t('theme.layout.content.page.mode.title')">
<NSelect
v-model:value="themeStore.page.animateMode"
:options="translateOptions(themePageAnimationModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem v-if="isWrapperScrollMode" key="4" :label="$t('theme.layout.content.fixedHeaderAndTab')">
<NSwitch v-model:value="themeStore.fixedHeaderAndTab" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'FooterSettings'
});
const themeStore = useThemeStore();
const layoutMode = computed(() => themeStore.layout.mode);
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
const isMixHorizontalMode = computed(() =>
['top-hybrid-sidebar-first', 'top-hybrid-header-first'].includes(layoutMode.value)
);
</script>
<template>
<NDivider>{{ $t('theme.layout.footer.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.footer.visible')">
<NSwitch v-model:value="themeStore.footer.visible" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && isWrapperScrollMode"
key="2"
:label="$t('theme.layout.footer.fixed')"
>
<NSwitch v-model:value="themeStore.footer.fixed" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible" key="3" :label="$t('theme.layout.footer.height')">
<NInputNumber v-model:value="themeStore.footer.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && isMixHorizontalMode"
key="4"
:label="$t('theme.layout.footer.right')"
>
<NSwitch v-model:value="themeStore.footer.right" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'HeaderSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.layout.header.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.header.height')">
<NInputNumber v-model:value="themeStore.header.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="2" :label="$t('theme.layout.header.breadcrumb.visible')">
<NSwitch v-model:value="themeStore.header.breadcrumb.visible" />
</SettingItem>
<SettingItem
v-if="themeStore.header.breadcrumb.visible"
key="3"
:label="$t('theme.layout.header.breadcrumb.showIcon')"
>
<NSwitch v-model:value="themeStore.header.breadcrumb.showIcon" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@@ -2,8 +2,7 @@
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales'; import { $t } from '@/locales';
import LayoutModeCard from '../components/layout-mode-card.vue'; import LayoutModeCard from '../../../components/layout-mode-card.vue';
import SettingItem from '../components/setting-item.vue';
defineOptions({ defineOptions({
name: 'LayoutMode' name: 'LayoutMode'
@@ -11,56 +10,60 @@ defineOptions({
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
function handleReverseHorizontalMixChange(value: boolean) {
themeStore.setLayoutReverseHorizontalMix(value);
}
</script> </script>
<template> <template>
<NDivider>{{ $t('theme.layoutMode.title') }}</NDivider> <NDivider>{{ $t('theme.layout.layoutMode.title') }}</NDivider>
<LayoutModeCard v-model:mode="themeStore.layout.mode" :disabled="appStore.isMobile"> <LayoutModeCard v-model:mode="themeStore.layout.mode" :disabled="appStore.isMobile">
<template #vertical> <template #vertical>
<div class="layout-sider h-full w-18px"></div> <div class="layout-sider h-full w-18px !bg-primary"></div>
<div class="vertical-wrapper"> <div class="vertical-wrapper">
<div class="layout-header"></div> <div class="layout-header bg-primary-200"></div>
<div class="layout-main"></div> <div class="layout-main"></div>
</div> </div>
</template> </template>
<template #vertical-mix> <template #vertical-mix>
<div class="layout-sider h-full w-8px"></div> <div class="layout-sider h-full w-8px !bg-primary"></div>
<div class="layout-sider h-full w-16px"></div> <div class="layout-sider h-full w-16px !bg-primary-300"></div>
<div class="vertical-wrapper"> <div class="vertical-wrapper">
<div class="layout-header"></div> <div class="layout-header bg-primary-200"></div>
<div class="layout-main"></div>
</div>
</template>
<template #vertical-hybrid-header-first>
<div class="layout-sider h-full w-8px !bg-primary"></div>
<div class="layout-sider h-full w-16px !bg-primary-300"></div>
<div class="vertical-wrapper">
<div class="layout-header bg-primary"></div>
<div class="layout-main"></div> <div class="layout-main"></div>
</div> </div>
</template> </template>
<template #horizontal> <template #horizontal>
<div class="layout-header"></div> <div class="layout-header !bg-primary"></div>
<div class="horizontal-wrapper"> <div class="horizontal-wrapper">
<div class="layout-main"></div> <div class="layout-main"></div>
</div> </div>
</template> </template>
<template #horizontal-mix> <template #top-hybrid-sidebar-first>
<div class="layout-header"></div> <div class="layout-header !bg-primary-300"></div>
<div class="horizontal-wrapper">
<div class="layout-sider w-18px !bg-primary"></div>
<div class="layout-main"></div>
</div>
</template>
<template #top-hybrid-header-first>
<div class="layout-header bg-primary"></div>
<div class="horizontal-wrapper"> <div class="horizontal-wrapper">
<div class="layout-sider w-18px"></div> <div class="layout-sider w-18px"></div>
<div class="layout-main"></div> <div class="layout-main"></div>
</div> </div>
</template> </template>
</LayoutModeCard> </LayoutModeCard>
<SettingItem
v-if="themeStore.layout.mode === 'horizontal-mix'"
:label="$t('theme.layoutMode.reverseHorizontalMix')"
class="mt-16px"
>
<NSwitch :value="themeStore.layout.reverseHorizontalMix" @update:value="handleReverseHorizontalMixChange" />
</SettingItem>
</template> </template>
<style scoped> <style scoped>
.layout-header { .layout-header {
--uno: h-16px bg-primary rd-4px; --uno: h-16px rd-4px;
} }
.layout-sider { .layout-sider {

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'SiderSettings'
});
const themeStore = useThemeStore();
const layoutMode = computed(() => themeStore.layout.mode);
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix') || layoutMode.value.includes('hybrid'));
</script>
<template>
<NDivider>{{ $t('theme.layout.sider.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem v-if="layoutMode === 'vertical'" key="1" :label="$t('theme.layout.sider.width')">
<NInputNumber v-model:value="themeStore.sider.width" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="2" :label="$t('theme.layout.sider.collapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.collapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="3" :label="$t('theme.layout.sider.mixWidth')">
<NInputNumber v-model:value="themeStore.sider.mixWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="4" :label="$t('theme.layout.sider.mixCollapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.mixCollapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical-mix'" key="5" :label="$t('theme.layout.sider.mixChildMenuWidth')">
<NInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { themeTabModeOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'TabSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.layout.tab.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.tab.visible')">
<NSwitch v-model:value="themeStore.tab.visible" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="2" :label="$t('theme.layout.tab.cache')">
<template #suffix>
<IconTooltip :desc="$t('theme.layout.tab.cacheTip')" />
</template>
<NSwitch v-model:value="themeStore.tab.cache" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="3" :label="$t('theme.layout.tab.height')">
<NInputNumber v-model:value="themeStore.tab.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="4" :label="$t('theme.layout.tab.mode.title')">
<NSelect
v-model:value="themeStore.tab.mode"
:options="translateOptions(themeTabModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@@ -1,151 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
resetCacheStrategyOptions,
themePageAnimationModeOptions,
themeScrollModeOptions,
themeTabModeOptions
} from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue';
defineOptions({
name: 'PageFun'
});
const themeStore = useThemeStore();
const layoutMode = computed(() => themeStore.layout.mode);
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix'));
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
</script>
<template>
<NDivider>{{ $t('theme.pageFunTitle') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="0" :label="$t('theme.resetCacheStrategy.title')">
<NSelect
v-model:value="themeStore.resetCacheStrategy"
:options="translateOptions(resetCacheStrategyOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="1" :label="$t('theme.scrollMode.title')">
<NSelect
v-model:value="themeStore.layout.scrollMode"
:options="translateOptions(themeScrollModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="1-1" :label="$t('theme.page.animate')">
<NSwitch v-model:value="themeStore.page.animate" />
</SettingItem>
<SettingItem v-if="themeStore.page.animate" key="1-2" :label="$t('theme.page.mode.title')">
<NSelect
v-model:value="themeStore.page.animateMode"
:options="translateOptions(themePageAnimationModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem v-if="isWrapperScrollMode" key="2" :label="$t('theme.fixedHeaderAndTab')">
<NSwitch v-model:value="themeStore.fixedHeaderAndTab" />
</SettingItem>
<SettingItem key="3" :label="$t('theme.header.height')">
<NInputNumber v-model:value="themeStore.header.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="4" :label="$t('theme.header.breadcrumb.visible')">
<NSwitch v-model:value="themeStore.header.breadcrumb.visible" />
</SettingItem>
<SettingItem v-if="themeStore.header.breadcrumb.visible" key="4-1" :label="$t('theme.header.breadcrumb.showIcon')">
<NSwitch v-model:value="themeStore.header.breadcrumb.showIcon" />
</SettingItem>
<SettingItem key="5" :label="$t('theme.tab.visible')">
<NSwitch v-model:value="themeStore.tab.visible" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-1" :label="$t('theme.tab.cache')">
<NSwitch v-model:value="themeStore.tab.cache" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-2" :label="$t('theme.tab.height')">
<NInputNumber v-model:value="themeStore.tab.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-3" :label="$t('theme.tab.mode.title')">
<NSelect
v-model:value="themeStore.tab.mode"
:options="translateOptions(themeTabModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-1" :label="$t('theme.sider.width')">
<NInputNumber v-model:value="themeStore.sider.width" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-2" :label="$t('theme.sider.collapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.collapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-3" :label="$t('theme.sider.mixWidth')">
<NInputNumber v-model:value="themeStore.sider.mixWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-4" :label="$t('theme.sider.mixCollapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.mixCollapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical-mix'" key="6-5" :label="$t('theme.sider.mixChildMenuWidth')">
<NInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="7" :label="$t('theme.footer.visible')">
<NSwitch v-model:value="themeStore.footer.visible" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible && isWrapperScrollMode" key="7-1" :label="$t('theme.footer.fixed')">
<NSwitch v-model:value="themeStore.footer.fixed" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible" key="7-2" :label="$t('theme.footer.height')">
<NInputNumber v-model:value="themeStore.footer.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && layoutMode === 'horizontal-mix'"
key="7-3"
:label="$t('theme.footer.right')"
>
<NSwitch v-model:value="themeStore.footer.right" />
</SettingItem>
<SettingItem key="8" :label="$t('theme.watermark.visible')">
<NSwitch v-model:value="themeStore.watermark.visible" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="8-1" :label="$t('theme.watermark.text')">
<NInput
v-model:value="themeStore.watermark.text"
autosize
type="text"
size="small"
class="w-120px"
placeholder="SoybeanAdmin"
/>
</SettingItem>
<SettingItem key="9" :label="$t('theme.header.multilingual.visible')">
<NSwitch v-model:value="themeStore.header.multilingual.visible" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import ThemePreset from './modules/theme-preset.vue';
defineOptions({
name: 'PresetSettings'
});
</script>
<template>
<div class="flex-col-stretch gap-16px">
<ThemePreset />
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,147 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
defineOptions({
name: 'ThemePreset'
});
type ThemePreset = Pick<
App.Theme.ThemeSetting,
| 'themeScheme'
| 'grayscale'
| 'colourWeakness'
| 'recommendColor'
| 'themeColor'
| 'otherColor'
| 'isInfoFollowPrimary'
| 'layout'
| 'page'
| 'header'
| 'tab'
| 'fixedHeaderAndTab'
| 'sider'
| 'footer'
| 'watermark'
| 'tokens'
> & {
name: string;
desc: string;
i18nkey?: string;
version: string;
};
const presetModules = import.meta.glob('@/theme/preset/*.json', { eager: true, import: 'default' });
const themeStore = useThemeStore();
// Extract preset data
const presets = computed(() =>
Object.entries(presetModules)
.map(([path, presetData]) => {
const fileName = path.split('/').pop()?.replace('.json', '') || '';
return {
id: fileName,
...(presetData as ThemePreset)
};
})
.sort((a, b) => {
if (a.name === 'default') return -1;
if (b.name === 'default') return 1;
return a.name.localeCompare(b.name);
})
);
const getPresetName = (preset: ThemePreset): string => {
if (!preset.i18nkey) return preset.name;
try {
const key = `${preset.i18nkey}.name` as App.I18n.I18nKey;
const translated = $t(key);
return translated !== key ? translated : preset.name;
} catch {
return preset.name;
}
};
const getPresetDesc = (preset: ThemePreset): string => {
if (!preset.i18nkey) return preset.desc;
try {
const key = `${preset.i18nkey}.desc` as App.I18n.I18nKey;
const translated = $t(key);
return translated !== key ? translated : preset.desc;
} catch {
return preset.desc;
}
};
const applyPreset = ({ themeScheme, grayscale, colourWeakness, layout, watermark, ...rest }: ThemePreset): void => {
themeStore.setThemeScheme(themeScheme);
themeStore.setGrayscale(grayscale);
themeStore.setColourWeakness(colourWeakness);
themeStore.setThemeLayout(layout.mode);
themeStore.setWatermarkEnableUserName(watermark.enableUserName);
themeStore.setWatermarkEnableTime(watermark.enableTime);
Object.assign(themeStore, {
...rest,
layout: { ...themeStore.layout, scrollMode: layout.scrollMode },
page: { ...rest.page },
header: { ...rest.header },
tab: { ...rest.tab },
sider: { ...rest.sider },
footer: { ...rest.footer },
watermark: { ...watermark },
tokens: { ...rest.tokens }
});
window.$message?.success($t('theme.appearance.preset.applySuccess'));
};
</script>
<template>
<NDivider>{{ $t('theme.appearance.preset.title') }}</NDivider>
<div class="flex flex-col gap-3">
<div
v-for="preset in presets"
:key="preset.id"
class="border border-primary/10 rounded-lg border-solid bg-white/5 p-3 backdrop-blur-10 transition-all duration-300 hover:(shadow-md -translate-y-0.5)"
>
<div class="mb-2 flex items-center justify-between">
<div class="min-w-0 w-full flex flex-1 items-center justify-between gap-2">
<h5 class="m-0 truncate text-sm text-primary font-600">
{{ getPresetName(preset) }}
</h5>
<NBadge :value="`v${preset.version}`" type="info" size="small" class="flex-shrink-0 opacity-80" />
</div>
<NButton type="primary" size="tiny" ghost round class="ml-2 flex-shrink-0" @click="applyPreset(preset)">
{{ $t('theme.appearance.preset.apply') }}
</NButton>
</div>
<p class="line-clamp-2 mb-3 text-xs text-gray-500 leading-4">{{ getPresetDesc(preset) }}</p>
<div class="flex items-center justify-between">
<div class="flex gap-1">
<div
v-for="(color, key) in { primary: preset.themeColor, ...preset.otherColor }"
:key="key"
class="h-3 w-3 cursor-pointer border border-white/30 rounded-full transition-transform hover:scale-110"
:style="{ backgroundColor: color }"
:class="{ 'ring-1 ring-primary/50': key === 'primary' }"
:title="key"
/>
</div>
<div class="flex items-center gap-1">
<div class="text-lg">
{{ preset.themeScheme === 'dark' ? '🌙' : '☀️' }}
</div>
<div class="text-lg">
{{ preset.grayscale ? '🎨' : '' }}
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -58,6 +58,14 @@ const local: App.I18n.Schema = {
tokenExpired: 'The requested token has expired' tokenExpired: 'The requested token has expired'
}, },
theme: { theme: {
themeDrawerTitle: 'Theme Configuration',
tabs: {
appearance: 'Appearance',
layout: 'Layout',
general: 'General',
preset: 'Preset'
},
appearance: {
themeSchema: { themeSchema: {
title: 'Theme Schema', title: 'Theme Schema',
light: 'Light', light: 'Light',
@@ -66,16 +74,6 @@ const local: App.I18n.Schema = {
}, },
grayscale: 'Grayscale', grayscale: 'Grayscale',
colourWeakness: 'Colour Weakness', colourWeakness: 'Colour Weakness',
layoutMode: {
title: 'Layout Mode',
vertical: 'Vertical Menu Mode',
horizontal: 'Horizontal Menu Mode',
'vertical-mix': 'Vertical Mix Menu Mode',
'horizontal-mix': 'Horizontal Mix menu Mode',
reverseHorizontalMix: 'Reverse first level menus and child level menus position'
},
recommendColor: 'Apply Recommended Color Algorithm',
recommendColorDesc: 'The recommended color algorithm refers to',
themeColor: { themeColor: {
title: 'Theme Color', title: 'Theme Color',
primary: 'Primary', primary: 'Primary',
@@ -85,8 +83,92 @@ const local: App.I18n.Schema = {
error: 'Error', error: 'Error',
followPrimary: 'Follow Primary' followPrimary: 'Follow Primary'
}, },
recommendColor: 'Apply Recommended Color Algorithm',
recommendColorDesc: 'The recommended color algorithm refers to',
preset: {
title: 'Theme Presets',
apply: 'Apply',
applySuccess: 'Preset applied successfully',
default: {
name: 'Default Preset',
desc: 'Default theme preset with balanced settings'
},
dark: {
name: 'Dark Preset',
desc: 'Dark theme preset for night time usage'
},
compact: {
name: 'Compact Preset',
desc: 'Compact layout preset for small screens'
},
azir: {
name: "Azir's Preset",
desc: 'It is a cold and elegant preset that Azir likes'
}
}
},
layout: {
layoutMode: {
title: 'Layout Mode',
vertical: 'Vertical Mode',
horizontal: 'Horizontal Mode',
'vertical-mix': 'Vertical Mix Mode',
'vertical-hybrid-header-first': 'Left Hybrid Header-First',
'top-hybrid-sidebar-first': 'Top-Hybrid Sidebar-First',
'top-hybrid-header-first': 'Top-Hybrid Header-First',
vertical_detail: 'Vertical menu layout, with the menu on the left and content on the right.',
'vertical-mix_detail':
'Vertical mix-menu layout, with the primary menu on the dark left side and the secondary menu on the lighter left side.',
'vertical-hybrid-header-first_detail':
'Left hybrid layout, with the primary menu at the top, the secondary menu on the dark left side, and the tertiary menu on the lighter left side.',
horizontal_detail: 'Horizontal menu layout, with the menu at the top and content below.',
'top-hybrid-sidebar-first_detail':
'Top hybrid layout, with the primary menu on the left and the secondary menu at the top.',
'top-hybrid-header-first_detail':
'Top hybrid layout, with the primary menu at the top and the secondary menu on the left.'
},
tab: {
title: 'Tab Settings',
visible: 'Tab Visible',
cache: 'Tag Bar Info Cache',
cacheTip: 'One-click to open/close global keepalive',
height: 'Tab Height',
mode: {
title: 'Tab Mode',
slider: 'Slider',
chrome: 'Chrome',
button: 'Button'
}
},
header: {
title: 'Header Settings',
height: 'Header Height',
breadcrumb: {
visible: 'Breadcrumb Visible',
showIcon: 'Breadcrumb Icon Visible'
}
},
sider: {
title: 'Sider Settings',
inverted: 'Dark Sider',
width: 'Sider Width',
collapsedWidth: 'Sider Collapsed Width',
mixWidth: 'Mix Sider Width',
mixCollapsedWidth: 'Mix Sider Collapse Width',
mixChildMenuWidth: 'Mix Child Menu Width'
},
footer: {
title: 'Footer Settings',
visible: 'Footer Visible',
fixed: 'Fixed Footer',
height: 'Footer Height',
right: 'Right Footer'
},
content: {
title: 'Content Area Settings',
scrollMode: { scrollMode: {
title: 'Scroll Mode', title: 'Scroll Mode',
tip: 'The theme scroll only scrolls the main part, the outer scroll can carry the header and footer together',
wrapper: 'Wrapper', wrapper: 'Wrapper',
content: 'Content' content: 'Content'
}, },
@@ -103,52 +185,28 @@ const local: App.I18n.Schema = {
none: 'None' none: 'None'
} }
}, },
fixedHeaderAndTab: 'Fixed Header And Tab', fixedHeaderAndTab: 'Fixed Header And Tab'
header: { }
height: 'Header Height', },
breadcrumb: { general: {
visible: 'Breadcrumb Visible', title: 'General Settings',
showIcon: 'Breadcrumb Icon Visible' watermark: {
title: 'Watermark Settings',
visible: 'Watermark Full Screen Visible',
text: 'Custom Watermark Text',
enableUserName: 'Enable User Name Watermark',
enableTime: 'Show Current Time',
timeFormat: 'Time Format'
}, },
multilingual: { multilingual: {
title: 'Multilingual Settings',
visible: 'Display multilingual button' visible: 'Display multilingual button'
},
globalSearch: {
title: 'Global Search Settings',
visible: 'Display GlobalSearch button'
} }
}, },
tab: {
visible: 'Tab Visible',
cache: 'Tag Bar Info Cache',
height: 'Tab Height',
mode: {
title: 'Tab Mode',
chrome: 'Chrome',
button: 'Button'
}
},
sider: {
inverted: 'Dark Sider',
width: 'Sider Width',
collapsedWidth: 'Sider Collapsed Width',
mixWidth: 'Mix Sider Width',
mixCollapsedWidth: 'Mix Sider Collapse Width',
mixChildMenuWidth: 'Mix Child Menu Width'
},
footer: {
visible: 'Footer Visible',
fixed: 'Fixed Footer',
height: 'Footer Height',
right: 'Right Footer'
},
watermark: {
visible: 'Watermark Full Screen Visible',
text: 'Watermark Text'
},
themeDrawerTitle: 'Theme Configuration',
pageFunTitle: 'Page Function',
resetCacheStrategy: {
title: 'Reset Cache Strategy',
close: 'Close Page',
refresh: 'Refresh Page'
},
configOperation: { configOperation: {
copyConfig: 'Copy Config', copyConfig: 'Copy Config',
copySuccessMsg: 'Copy Success, Please replace the variable "themeSettings" in "src/theme/settings.ts"', copySuccessMsg: 'Copy Success, Please replace the variable "themeSettings" in "src/theme/settings.ts"',

View File

@@ -58,6 +58,14 @@ const local: App.I18n.Schema = {
tokenExpired: 'token已过期' tokenExpired: 'token已过期'
}, },
theme: { theme: {
themeDrawerTitle: '主题配置',
tabs: {
appearance: '外观',
layout: '布局',
general: '通用',
preset: '预设'
},
appearance: {
themeSchema: { themeSchema: {
title: '主题模式', title: '主题模式',
light: '亮色模式', light: '亮色模式',
@@ -66,16 +74,6 @@ const local: App.I18n.Schema = {
}, },
grayscale: '灰色模式', grayscale: '灰色模式',
colourWeakness: '色弱模式', colourWeakness: '色弱模式',
layoutMode: {
title: '布局模式',
vertical: '左侧菜单模式',
'vertical-mix': '左侧菜单混合模式',
horizontal: '顶部菜单模式',
'horizontal-mix': '顶部菜单混合模式',
reverseHorizontalMix: '一级菜单与子级菜单位置反转'
},
recommendColor: '应用推荐算法的颜色',
recommendColorDesc: '推荐颜色的算法参照',
themeColor: { themeColor: {
title: '主题颜色', title: '主题颜色',
primary: '主色', primary: '主色',
@@ -85,8 +83,89 @@ const local: App.I18n.Schema = {
error: '错误色', error: '错误色',
followPrimary: '跟随主色' followPrimary: '跟随主色'
}, },
recommendColor: '应用推荐算法的颜色',
recommendColorDesc: '推荐颜色的算法参照',
preset: {
title: '主题预设',
apply: '应用',
applySuccess: '预设应用成功',
default: {
name: '默认预设',
desc: 'Soybean 默认主题预设'
},
dark: {
name: '暗色预设',
desc: '适用于夜间使用的暗色主题预设'
},
compact: {
name: '紧凑型',
desc: '适用于小屏幕的紧凑布局预设'
},
azir: {
name: 'Azir的预设',
desc: '是 Azir 比较喜欢的莫兰迪色系冷淡风'
}
}
},
layout: {
layoutMode: {
title: '布局模式',
vertical: '左侧菜单模式',
'vertical-mix': '左侧菜单混合模式',
'vertical-hybrid-header-first': '左侧混合-顶部优先',
horizontal: '顶部菜单模式',
'top-hybrid-sidebar-first': '顶部混合-侧边优先',
'top-hybrid-header-first': '顶部混合-顶部优先',
vertical_detail: '左侧菜单布局,菜单在左,内容在右。',
'vertical-mix_detail': '左侧双菜单布局,一级菜单在左侧深色区域,二级菜单在左侧浅色区域。',
'vertical-hybrid-header-first_detail':
'左侧混合布局,一级菜单在顶部,二级菜单在左侧深色区域,三级菜单在左侧浅色区域。',
horizontal_detail: '顶部菜单布局,菜单在顶部,内容在下方。',
'top-hybrid-sidebar-first_detail': '顶部混合布局,一级菜单在左侧,二级菜单在顶部。',
'top-hybrid-header-first_detail': '顶部混合布局,一级菜单在顶部,二级菜单在左侧。'
},
tab: {
title: '标签栏设置',
visible: '显示标签栏',
cache: '标签栏信息缓存',
cacheTip: '一键开启/关闭全局 keepalive',
height: '标签栏高度',
mode: {
title: '标签栏风格',
slider: '滑块风格',
chrome: '谷歌风格',
button: '按钮风格'
}
},
header: {
title: '头部设置',
height: '头部高度',
breadcrumb: {
visible: '显示面包屑',
showIcon: '显示面包屑图标'
}
},
sider: {
title: '侧边栏设置',
inverted: '深色侧边栏',
width: '侧边栏宽度',
collapsedWidth: '侧边栏折叠宽度',
mixWidth: '混合布局侧边栏宽度',
mixCollapsedWidth: '混合布局侧边栏折叠宽度',
mixChildMenuWidth: '混合布局子菜单宽度'
},
footer: {
title: '底部设置',
visible: '显示底部',
fixed: '固定底部',
height: '底部高度',
right: '底部居右'
},
content: {
title: '内容区域设置',
scrollMode: { scrollMode: {
title: '滚动模式', title: '滚动模式',
tip: '主题滚动仅 main 部分滚动,外层滚动可携带头部底部一起滚动',
wrapper: '外层滚动', wrapper: '外层滚动',
content: '主体滚动' content: '主体滚动'
}, },
@@ -103,52 +182,28 @@ const local: App.I18n.Schema = {
none: '无' none: '无'
} }
}, },
fixedHeaderAndTab: '固定头部和标签栏', fixedHeaderAndTab: '固定头部和标签栏'
header: { }
height: '头部高度', },
breadcrumb: { general: {
visible: '显示面包屑', title: '通用设置',
showIcon: '显示面包屑图标' watermark: {
title: '水印设置',
visible: '显示全屏水印',
text: '自定义水印文本',
enableUserName: '启用用户名水印',
enableTime: '显示当前时间',
timeFormat: '时间格式'
}, },
multilingual: { multilingual: {
title: '多语言设置',
visible: '显示多语言按钮' visible: '显示多语言按钮'
},
globalSearch: {
title: '全局搜索设置',
visible: '显示全局搜索按钮'
} }
}, },
tab: {
visible: '显示标签栏',
cache: '标签栏信息缓存',
height: '标签栏高度',
mode: {
title: '标签栏风格',
chrome: '谷歌风格',
button: '按钮风格'
}
},
sider: {
inverted: '深色侧边栏',
width: '侧边栏宽度',
collapsedWidth: '侧边栏折叠宽度',
mixWidth: '混合布局侧边栏宽度',
mixCollapsedWidth: '混合布局侧边栏折叠宽度',
mixChildMenuWidth: '混合布局子菜单宽度'
},
footer: {
visible: '显示底部',
fixed: '固定底部',
height: '底部高度',
right: '底部局右'
},
watermark: {
visible: '显示全屏水印',
text: '水印文本'
},
themeDrawerTitle: '主题配置',
pageFunTitle: '页面功能',
resetCacheStrategy: {
title: '重置缓存策略',
close: '关闭页面',
refresh: '刷新页面'
},
configOperation: { configOperation: {
copyConfig: '复制配置', copyConfig: '复制配置',
copySuccessMsg: '复制成功,请替换 src/theme/settings.ts 中的变量 themeSettings', copySuccessMsg: '复制成功,请替换 src/theme/settings.ts 中的变量 themeSettings',

View File

@@ -25,8 +25,8 @@ export function setupAppVersionNotification() {
const buildTime = await getHtmlBuildTime(); const buildTime = await getHtmlBuildTime();
// If build time hasn't changed, no update is needed // If failed to get build time or build time hasn't changed, no update is needed.
if (buildTime === BUILD_TIME) { if (!buildTime || buildTime === BUILD_TIME) {
return; return;
} }
@@ -88,16 +88,21 @@ export function setupAppVersionNotification() {
} }
} }
async function getHtmlBuildTime() { async function getHtmlBuildTime(): Promise<string | null> {
const baseUrl = import.meta.env.VITE_BASE_URL || '/'; const baseUrl = import.meta.env.VITE_BASE_URL || '/';
try {
const res = await fetch(`${baseUrl}index.html?time=${Date.now()}`); const res = await fetch(`${baseUrl}index.html?time=${Date.now()}`);
if (!res.ok) {
return null;
}
const html = await res.text(); const html = await res.text();
const match = html.match(/<meta name="buildTime" content="(.*)">/); const match = html.match(/<meta name="buildTime" content="(.*)">/);
return match?.[1] || null;
const buildTime = match?.[1] || ''; } catch (error) {
window.console.error('getHtmlBuildTime error:', error);
return buildTime; return null;
}
} }

View File

@@ -1,4 +1,4 @@
import { addAPIProvider, disableCache } from '@iconify/vue'; import { addAPIProvider } from '@iconify/vue';
/** Setup the iconify offline */ /** Setup the iconify offline */
export function setupIconifyOffline() { export function setupIconifyOffline() {
@@ -6,7 +6,5 @@ export function setupIconifyOffline() {
if (VITE_ICONIFY_URL) { if (VITE_ICONIFY_URL) {
addAPIProvider('', { resources: [VITE_ICONIFY_URL] }); addAPIProvider('', { resources: [VITE_ICONIFY_URL] });
disableCache('all');
} }
} }

View File

@@ -10,7 +10,7 @@ import type { RequestInstanceState } from './type';
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'; const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy); const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
export const request = createFlatRequest<App.Service.Response, RequestInstanceState>( export const request = createFlatRequest(
{ {
baseURL, baseURL,
headers: { headers: {
@@ -18,6 +18,13 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
} }
}, },
{ {
defaultState: {
errMsgStack: [],
refreshTokenPromise: null
} as RequestInstanceState,
transform(response: AxiosResponse<App.Service.Response<any>>) {
return response.data.data;
},
async onRequest(config) { async onRequest(config) {
const Authorization = getAuthorization(); const Authorization = getAuthorization();
Object.assign(config.headers, { Authorization }); Object.assign(config.headers, { Authorization });
@@ -91,9 +98,6 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
return null; return null;
}, },
transformBackendResponse(response) {
return response.data.data;
},
onError(error) { onError(error) {
// when the request is fail, you can show error message // when the request is fail, you can show error message
@@ -123,11 +127,14 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
} }
); );
export const demoRequest = createRequest<App.Service.DemoResponse>( export const demoRequest = createRequest(
{ {
baseURL: otherBaseURL.demo baseURL: otherBaseURL.demo
}, },
{ {
transform(response: AxiosResponse<App.Service.DemoResponse>) {
return response.data.result;
},
async onRequest(config) { async onRequest(config) {
const { headers } = config; const { headers } = config;
@@ -147,9 +154,6 @@ export const demoRequest = createRequest<App.Service.DemoResponse>(
// when the backend response code is not "200", it means the request is fail // when the backend response code is not "200", it means the request is fail
// for example: the token is expired, refresh token and retry request // for example: the token is expired, refresh token and retry request
}, },
transformBackendResponse(response) {
return response.data.result;
},
onError(error) { onError(error) {
// when the request is fail, you can show error message // when the request is fail, you can show error message

View File

@@ -28,14 +28,14 @@ async function handleRefreshToken() {
} }
export async function handleExpiredRequest(state: RequestInstanceState) { export async function handleExpiredRequest(state: RequestInstanceState) {
if (!state.refreshTokenFn) { if (!state.refreshTokenPromise) {
state.refreshTokenFn = handleRefreshToken(); state.refreshTokenPromise = handleRefreshToken();
} }
const success = await state.refreshTokenFn; const success = await state.refreshTokenPromise;
setTimeout(() => { setTimeout(() => {
state.refreshTokenFn = null; state.refreshTokenPromise = null;
}, 1000); }, 1000);
return success; return success;

View File

@@ -1,6 +1,7 @@
export interface RequestInstanceState { export interface RequestInstanceState {
/** whether the request is refreshing token */ /** the promise of refreshing token */
refreshTokenFn: Promise<boolean> | null; refreshTokenPromise: Promise<boolean> | null;
/** the request error message stack */ /** the request error message stack */
errMsgStack: string[]; errMsgStack: string[];
[key: string]: unknown;
} }

View File

@@ -46,11 +46,8 @@ export const useAppStore = defineStore(SetupStoreId.App, () => {
}); });
setReloadFlag(true); setReloadFlag(true);
if (themeStore.resetCacheStrategy === 'refresh') {
routeStore.resetRouteCache(); routeStore.resetRouteCache();
} }
}
const locale = ref<App.I18n.LangType>(localStg.get('lang') || 'zh-CN'); const locale = ref<App.I18n.LangType>(localStg.get('lang') || 'zh-CN');

View File

@@ -13,6 +13,7 @@ import { clearAuthStorage, getToken } from './shared';
export const useAuthStore = defineStore(SetupStoreId.Auth, () => { export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const route = useRoute(); const route = useRoute();
const authStore = useAuthStore();
const routeStore = useRouteStore(); const routeStore = useRouteStore();
const tabStore = useTabStore(); const tabStore = useTabStore();
const { toLogin, redirectFromLogin } = useRouterPush(false); const { toLogin, redirectFromLogin } = useRouterPush(false);
@@ -39,7 +40,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
/** Reset auth store */ /** Reset auth store */
async function resetStore() { async function resetStore() {
const authStore = useAuthStore(); recordUserId();
clearAuthStorage(); clearAuthStorage();
@@ -53,6 +54,41 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
routeStore.resetStore(); routeStore.resetStore();
} }
/** Record the user ID of the previous login session Used to compare with the current user ID on next login */
function recordUserId() {
if (!userInfo.userId) {
return;
}
// Store current user ID locally for next login comparison
localStg.set('lastLoginUserId', userInfo.userId);
}
/**
* Check if current login user is different from previous login user If different, clear all tabs
*
* @returns {boolean} Whether to clear all tabs
*/
function checkTabClear(): boolean {
if (!userInfo.userId) {
return false;
}
const lastLoginUserId = localStg.get('lastLoginUserId');
// Clear all tabs if current user is different from previous user
if (!lastLoginUserId || lastLoginUserId !== userInfo.userId) {
localStg.remove('globalTabs');
tabStore.clearTabs();
localStg.remove('lastLoginUserId');
return true;
}
localStg.remove('lastLoginUserId');
return false;
}
/** /**
* Login * Login
* *
@@ -69,7 +105,15 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const pass = await loginByToken(loginToken); const pass = await loginByToken(loginToken);
if (pass) { if (pass) {
await redirectFromLogin(redirect); // Check if the tab needs to be cleared
const isClear = checkTabClear();
let needRedirect = redirect;
if (isClear) {
// If the tab needs to be cleared,it means we don't need to redirect.
needRedirect = false;
}
await redirectFromLogin(needRedirect);
window.$notification?.success({ window.$notification?.success({
title: $t('page.login.common.loginSuccess'), title: $t('page.login.common.loginSuccess'),

View File

@@ -318,7 +318,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
} }
async function onRouteSwitchWhenLoggedIn() { async function onRouteSwitchWhenLoggedIn() {
await authStore.initUserInfo(); // some global init logic when logged in and switch route
} }
async function onRouteSwitchWhenNotLoggedIn() { async function onRouteSwitchWhenNotLoggedIn() {

View File

@@ -98,13 +98,22 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
const removeTabIndex = tabs.value.findIndex(tab => tab.id === tabId); const removeTabIndex = tabs.value.findIndex(tab => tab.id === tabId);
if (removeTabIndex === -1) return; if (removeTabIndex === -1) return;
const removedTabRouteKey = tabs.value[removeTabIndex].routeKey;
const isRemoveActiveTab = activeTabId.value === tabId; const isRemoveActiveTab = activeTabId.value === tabId;
const nextTab = tabs.value[removeTabIndex + 1] || homeTab.value;
// if remove the last tab, then switch to the second last tab
const nextTab = tabs.value[removeTabIndex + 1] || tabs.value[removeTabIndex - 1] || homeTab.value;
// remove tab
tabs.value.splice(removeTabIndex, 1); tabs.value.splice(removeTabIndex, 1);
// if current tab is removed, then switch to next tab
if (isRemoveActiveTab && nextTab) { if (isRemoveActiveTab && nextTab) {
await switchRouteByTab(nextTab); await switchRouteByTab(nextTab);
} }
// reset route cache
routeStore.resetRouteCache(removedTabRouteKey);
} }
/** remove active tab */ /** remove active tab */
@@ -131,9 +140,24 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
*/ */
async function clearTabs(excludes: string[] = []) { async function clearTabs(excludes: string[] = []) {
const remainTabIds = [...getFixedTabIds(tabs.value), ...excludes]; const remainTabIds = [...getFixedTabIds(tabs.value), ...excludes];
const removedTabsIds = tabs.value.map(tab => tab.id).filter(id => !remainTabIds.includes(id));
// Identify tabs to be removed and collect their routeKeys if strategy is 'close'
const tabsToRemove = tabs.value.filter(tab => !remainTabIds.includes(tab.id));
const routeKeysToReset: RouteKey[] = [];
for (const tab of tabsToRemove) {
routeKeysToReset.push(tab.routeKey);
}
const removedTabsIds = tabsToRemove.map(tab => tab.id);
// If no tabs are actually being removed based on excludes and fixed tabs, exit
if (removedTabsIds.length === 0) {
return;
}
const isRemoveActiveTab = removedTabsIds.includes(activeTabId.value); const isRemoveActiveTab = removedTabsIds.includes(activeTabId.value);
// filterTabsByIds returns tabs NOT in removedTabsIds, so these are the tabs that will remain
const updatedTabs = filterTabsByIds(removedTabsIds, tabs.value); const updatedTabs = filterTabsByIds(removedTabsIds, tabs.value);
function update() { function update() {
@@ -142,13 +166,21 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
if (!isRemoveActiveTab) { if (!isRemoveActiveTab) {
update(); update();
return; } else {
const activeTabCandidate = updatedTabs[updatedTabs.length - 1] || homeTab.value;
if (activeTabCandidate) {
// Ensure there's a tab to switch to
await switchRouteByTab(activeTabCandidate);
}
// Update the tabs array regardless of switch success or if a candidate was found
update();
} }
const activeTab = updatedTabs[updatedTabs.length - 1] || homeTab.value; // After tabs are updated and route potentially switched, reset cache for removed tabs
for (const routeKey of routeKeysToReset) {
await switchRouteByTab(activeTab); routeStore.resetRouteCache(routeKey);
update(); }
} }
const { routerPushByKey } = useRouterPush(); const { routerPushByKey } = useRouterPush();

View File

@@ -1,10 +1,11 @@
import { computed, effectScope, onScopeDispose, ref, toRefs, watch } from 'vue'; import { computed, effectScope, onScopeDispose, ref, toRefs, watch } from 'vue';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { useEventListener, usePreferredColorScheme } from '@vueuse/core'; import { useDateFormat, useEventListener, useNow, usePreferredColorScheme } from '@vueuse/core';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { getPaletteColorByNumber } from '@sa/color'; import { getPaletteColorByNumber } from '@sa/color';
import { localStg } from '@/utils/storage'; import { localStg } from '@/utils/storage';
import { SetupStoreId } from '@/enum'; import { SetupStoreId } from '@/enum';
import { useAuthStore } from '../auth';
import { import {
addThemeVarsToGlobal, addThemeVarsToGlobal,
createThemeToken, createThemeToken,
@@ -18,10 +19,14 @@ import {
export const useThemeStore = defineStore(SetupStoreId.Theme, () => { export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
const scope = effectScope(); const scope = effectScope();
const osTheme = usePreferredColorScheme(); const osTheme = usePreferredColorScheme();
const authStore = useAuthStore();
/** Theme settings */ /** Theme settings */
const settings: Ref<App.Theme.ThemeSetting> = ref(initThemeSettings()); const settings: Ref<App.Theme.ThemeSetting> = ref(initThemeSettings());
/** Watermark time instance with controls */
const { now: watermarkTime, pause: pauseWatermarkTime, resume: resumeWatermarkTime } = useNow({ controls: true });
/** Dark mode */ /** Dark mode */
const darkMode = computed(() => { const darkMode = computed(() => {
if (settings.value.themeScheme === 'auto') { if (settings.value.themeScheme === 'auto') {
@@ -57,6 +62,28 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
*/ */
const settingsJson = computed(() => JSON.stringify(settings.value)); const settingsJson = computed(() => JSON.stringify(settings.value));
/** Watermark time date formatter */
const formattedWatermarkTime = computed(() => {
const { watermark } = settings.value;
const date = useDateFormat(watermarkTime, watermark.timeFormat);
return date.value;
});
/** Watermark content */
const watermarkContent = computed(() => {
const { watermark } = settings.value;
if (watermark.enableUserName && authStore.userInfo.userName) {
return authStore.userInfo.userName;
}
if (watermark.enableTime) {
return formattedWatermarkTime.value;
}
return watermark.text;
});
/** Reset store */ /** Reset store */
function resetStore() { function resetStore() {
const themeStore = useThemeStore(); const themeStore = useThemeStore();
@@ -144,13 +171,43 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
); );
addThemeVarsToGlobal(themeTokens, darkThemeTokens); addThemeVarsToGlobal(themeTokens, darkThemeTokens);
} }
/** /**
* Set layout reverse horizontal mix * Set watermark enable user name
* *
* @param reverse Reverse horizontal mix * @param enable Whether to enable user name watermark
*/ */
function setLayoutReverseHorizontalMix(reverse: boolean) { function setWatermarkEnableUserName(enable: boolean) {
settings.value.layout.reverseHorizontalMix = reverse; settings.value.watermark.enableUserName = enable;
if (enable) {
settings.value.watermark.enableTime = false;
}
}
/**
* Set watermark enable time
*
* @param enable Whether to enable time watermark
*/
function setWatermarkEnableTime(enable: boolean) {
settings.value.watermark.enableTime = enable;
if (enable) {
settings.value.watermark.enableUserName = false;
}
}
/** Only run timer when watermark is visible and time display is enabled */
function updateWatermarkTimer() {
const { watermark } = settings.value;
const shouldRunTimer = watermark.visible && watermark.enableTime;
if (shouldRunTimer) {
resumeWatermarkTime();
} else {
pauseWatermarkTime();
}
} }
/** Cache theme settings */ /** Cache theme settings */
@@ -196,6 +253,15 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
}, },
{ immediate: true } { immediate: true }
); );
// watch watermark settings to control timer
watch(
() => [settings.value.watermark.visible, settings.value.watermark.enableTime],
() => {
updateWatermarkTimer();
},
{ immediate: true }
);
}); });
/** On scope dispose */ /** On scope dispose */
@@ -209,6 +275,7 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
themeColors, themeColors,
naiveTheme, naiveTheme,
settingsJson, settingsJson,
watermarkContent,
setGrayscale, setGrayscale,
setColourWeakness, setColourWeakness,
resetStore, resetStore,
@@ -216,6 +283,7 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
toggleThemeScheme, toggleThemeScheme,
updateThemeColors, updateThemeColors,
setThemeLayout, setThemeLayout,
setLayoutReverseHorizontalMix setWatermarkEnableUserName,
setWatermarkEnableTime
}; };
}); });

View File

@@ -10,4 +10,5 @@ body,
html { html {
overflow-x: hidden; overflow-x: hidden;
color: rgb(var(--base-text-color));
} }

View File

@@ -0,0 +1,89 @@
{
"name": "Azir's Preset",
"desc": "It is a cold and elegant preset that Azir likes",
"i18nkey": "theme.appearance.preset.azir",
"version": "1.0.0",
"themeScheme": "light",
"grayscale": false,
"colourWeakness": false,
"recommendColor": true,
"themeColor": "#78a878",
"otherColor": {
"info": "#89b989",
"success": "#99c299",
"warning": "#d4bb9d",
"error": "#c49a9a"
},
"isInfoFollowPrimary": true,
"layout": {
"mode": "vertical-mix",
"scrollMode": "wrapper"
},
"page": {
"animate": true,
"animateMode": "zoom-fade"
},
"header": {
"height": 64,
"breadcrumb": {
"visible": true,
"showIcon": true
},
"multilingual": {
"visible": true
},
"globalSearch": {
"visible": true
}
},
"tab": {
"visible": true,
"cache": true,
"height": 48,
"mode": "chrome"
},
"fixedHeaderAndTab": true,
"sider": {
"inverted": false,
"width": 220,
"collapsedWidth": 64,
"mixWidth": 90,
"mixCollapsedWidth": 64,
"mixChildMenuWidth": 200
},
"footer": {
"visible": true,
"fixed": true,
"height": 56,
"right": true
},
"watermark": {
"visible": false,
"text": "SoybeanAdmin",
"enableUserName": false,
"enableTime": true,
"timeFormat": "YYYY-MM-DD HH:mm:ss"
},
"tokens": {
"light": {
"colors": {
"container": "rgb(255, 255, 255)",
"layout": "rgb(247, 250, 252)",
"inverted": "rgb(0, 20, 40)",
"base-text": "rgb(31, 31, 31)"
},
"boxShadow": {
"header": "0 1px 2px rgb(0, 21, 41, 0.08)",
"sider": "2px 0 8px 0 rgb(29, 35, 41, 0.05)",
"tab": "0 1px 2px rgb(0, 21, 41, 0.08)"
}
},
"dark": {
"colors": {
"container": "rgb(28, 28, 28)",
"layout": "rgb(18, 18, 18)",
"base-text": "rgb(224, 224, 224)"
}
}
}
}

View File

@@ -0,0 +1,89 @@
{
"name": "Compact Preset",
"desc": "Compact layout preset for small screens",
"i18nkey": "theme.appearance.preset.compact",
"version": "1.0.0",
"themeScheme": "light",
"grayscale": false,
"colourWeakness": false,
"recommendColor": false,
"themeColor": "#646cff",
"otherColor": {
"info": "#2080f0",
"success": "#52c41a",
"warning": "#faad14",
"error": "#f5222d"
},
"isInfoFollowPrimary": true,
"layout": {
"mode": "vertical",
"scrollMode": "content"
},
"page": {
"animate": true,
"animateMode": "fade-slide"
},
"header": {
"height": 48,
"breadcrumb": {
"visible": true,
"showIcon": true
},
"multilingual": {
"visible": false
},
"globalSearch": {
"visible": false
}
},
"tab": {
"visible": true,
"cache": true,
"height": 36,
"mode": "button"
},
"fixedHeaderAndTab": true,
"sider": {
"inverted": false,
"width": 180,
"collapsedWidth": 48,
"mixWidth": 80,
"mixCollapsedWidth": 48,
"mixChildMenuWidth": 180
},
"footer": {
"visible": false,
"fixed": false,
"height": 40,
"right": true
},
"watermark": {
"visible": false,
"text": "SoybeanAdmin",
"enableUserName": false,
"enableTime": false,
"timeFormat": "YYYY-MM-DD HH:mm"
},
"tokens": {
"light": {
"colors": {
"container": "rgb(255, 255, 255)",
"layout": "rgb(247, 250, 252)",
"inverted": "rgb(0, 20, 40)",
"base-text": "rgb(31, 31, 31)"
},
"boxShadow": {
"header": "0 1px 2px rgb(0, 21, 41, 0.08)",
"sider": "2px 0 8px 0 rgb(29, 35, 41, 0.05)",
"tab": "0 1px 2px rgb(0, 21, 41, 0.08)"
}
},
"dark": {
"colors": {
"container": "rgb(28, 28, 28)",
"layout": "rgb(18, 18, 18)",
"base-text": "rgb(224, 224, 224)"
}
}
}
}

View File

@@ -0,0 +1,89 @@
{
"name": "Dark Preset",
"desc": "Dark theme preset for night time usage",
"i18nkey": "theme.appearance.preset.dark",
"version": "1.0.0",
"themeScheme": "dark",
"grayscale": false,
"colourWeakness": false,
"recommendColor": false,
"themeColor": "#409eff",
"otherColor": {
"info": "#2080f0",
"success": "#52c41a",
"warning": "#faad14",
"error": "#f5222d"
},
"isInfoFollowPrimary": true,
"layout": {
"mode": "vertical",
"scrollMode": "content"
},
"page": {
"animate": true,
"animateMode": "fade-slide"
},
"header": {
"height": 56,
"breadcrumb": {
"visible": true,
"showIcon": true
},
"multilingual": {
"visible": true
},
"globalSearch": {
"visible": true
}
},
"tab": {
"visible": true,
"cache": true,
"height": 44,
"mode": "chrome"
},
"fixedHeaderAndTab": true,
"sider": {
"inverted": true,
"width": 220,
"collapsedWidth": 64,
"mixWidth": 90,
"mixCollapsedWidth": 64,
"mixChildMenuWidth": 200
},
"footer": {
"visible": true,
"fixed": false,
"height": 48,
"right": true
},
"watermark": {
"visible": false,
"text": "SoybeanAdmin",
"enableUserName": false,
"enableTime": false,
"timeFormat": "YYYY-MM-DD HH:mm"
},
"tokens": {
"light": {
"colors": {
"container": "rgb(255, 255, 255)",
"layout": "rgb(247, 250, 252)",
"inverted": "rgb(0, 20, 40)",
"base-text": "rgb(31, 31, 31)"
},
"boxShadow": {
"header": "0 1px 2px rgb(0, 21, 41, 0.08)",
"sider": "2px 0 8px 0 rgb(29, 35, 41, 0.05)",
"tab": "0 1px 2px rgb(0, 21, 41, 0.08)"
}
},
"dark": {
"colors": {
"container": "rgb(28, 28, 28)",
"layout": "rgb(18, 18, 18)",
"base-text": "rgb(224, 224, 224)"
}
}
}
}

View File

@@ -0,0 +1,89 @@
{
"name": "default",
"desc": "Default theme preset with balanced settings",
"i18nkey": "theme.appearance.preset.default",
"version": "1.0.0",
"themeScheme": "light",
"grayscale": false,
"colourWeakness": false,
"recommendColor": false,
"themeColor": "#646cff",
"otherColor": {
"info": "#2080f0",
"success": "#52c41a",
"warning": "#faad14",
"error": "#f5222d"
},
"isInfoFollowPrimary": true,
"layout": {
"mode": "vertical",
"scrollMode": "content"
},
"page": {
"animate": true,
"animateMode": "fade-slide"
},
"header": {
"height": 56,
"breadcrumb": {
"visible": true,
"showIcon": true
},
"multilingual": {
"visible": true
},
"globalSearch": {
"visible": true
}
},
"tab": {
"visible": true,
"cache": true,
"height": 44,
"mode": "chrome"
},
"fixedHeaderAndTab": true,
"sider": {
"inverted": false,
"width": 220,
"collapsedWidth": 64,
"mixWidth": 90,
"mixCollapsedWidth": 64,
"mixChildMenuWidth": 200
},
"footer": {
"visible": true,
"fixed": false,
"height": 48,
"right": true
},
"watermark": {
"visible": false,
"text": "SoybeanAdmin",
"enableUserName": false,
"enableTime": false,
"timeFormat": "YYYY-MM-DD HH:mm"
},
"tokens": {
"light": {
"colors": {
"container": "rgb(255, 255, 255)",
"layout": "rgb(247, 250, 252)",
"inverted": "rgb(0, 20, 40)",
"base-text": "rgb(31, 31, 31)"
},
"boxShadow": {
"header": "0 1px 2px rgb(0, 21, 41, 0.08)",
"sider": "2px 0 8px 0 rgb(29, 35, 41, 0.05)",
"tab": "0 1px 2px rgb(0, 21, 41, 0.08)"
}
},
"dark": {
"colors": {
"container": "rgb(28, 28, 28)",
"layout": "rgb(18, 18, 18)",
"base-text": "rgb(224, 224, 224)"
}
}
}
}

View File

@@ -12,11 +12,9 @@ export const themeSettings: App.Theme.ThemeSetting = {
error: '#f5222d' error: '#f5222d'
}, },
isInfoFollowPrimary: true, isInfoFollowPrimary: true,
resetCacheStrategy: 'close',
layout: { layout: {
mode: 'vertical', mode: 'vertical',
scrollMode: 'content', scrollMode: 'content'
reverseHorizontalMix: false
}, },
page: { page: {
animate: true, animate: true,
@@ -30,6 +28,9 @@ export const themeSettings: App.Theme.ThemeSetting = {
}, },
multilingual: { multilingual: {
visible: true visible: true
},
globalSearch: {
visible: true
} }
}, },
tab: { tab: {
@@ -55,7 +56,10 @@ export const themeSettings: App.Theme.ThemeSetting = {
}, },
watermark: { watermark: {
visible: false, visible: false,
text: 'SoybeanAdmin' text: 'SoybeanAdmin',
enableUserName: false,
enableTime: false,
timeFormat: 'YYYY-MM-DD HH:mm'
}, },
tokens: { tokens: {
light: { light: {

20
src/typings/api/auth.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
declare namespace Api {
/**
* namespace Auth
*
* backend api module: "auth"
*/
namespace Auth {
interface LoginToken {
token: string;
refreshToken: string;
}
interface UserInfo {
userId: string;
userName: string;
roles: string[];
buttons: string[];
}
}
}

View File

@@ -47,41 +47,4 @@ declare namespace Api {
status: EnableStatus | null; status: EnableStatus | null;
} & T; } & T;
} }
/**
* namespace Auth
*
* backend api module: "auth"
*/
namespace Auth {
interface LoginToken {
token: string;
refreshToken: string;
}
interface UserInfo {
userId: string;
userName: string;
roles: string[];
buttons: string[];
}
}
/**
* namespace Route
*
* backend api module: "route"
*/
namespace Route {
type ElegantConstRoute = import('@elegant-router/types').ElegantConstRoute;
interface MenuRoute extends ElegantConstRoute {
id: string;
}
interface UserRoute {
routes: MenuRoute[];
home: import('@elegant-router/types').LastLevelRouteKey;
}
}
} }

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