mirror of
https://github.com/soybeanjs/soybean-admin.git
synced 2025-10-12 04:43:42 +08:00
Compare commits
98 Commits
ff83d333a5
...
v2.0-examp
Author | SHA1 | Date | |
---|---|---|---|
|
47e7ade11b | ||
|
54c9511bf8 | ||
|
cfe62d5afb | ||
|
5be864a80b | ||
|
fd087f59fa | ||
|
b041fdd864 | ||
|
9fa951aa06 | ||
|
12b25e0d58 | ||
|
e33f944a74 | ||
|
3d72f954ed | ||
|
1213531bef | ||
|
805c338141 | ||
|
3c0a52825d | ||
|
100e0ea55d | ||
|
257f1183fc | ||
|
d73111116a | ||
|
29a2a5c66a | ||
|
4005763c00 | ||
|
a55b4dc073 | ||
|
be8f915a0c | ||
|
8d7f91dccf | ||
|
33ade53904 | ||
|
358e129765 | ||
|
9ea56c9b82 | ||
|
923eb98a5c | ||
|
87adc35f2e | ||
|
ee4341457a | ||
|
8a7cd5934b | ||
|
c962f7b2c5 | ||
|
8cc5177cda | ||
|
3a343eea33 | ||
|
f83eefbc3e | ||
|
936b834e62 | ||
|
c965140b87 | ||
|
32b8f99071 | ||
|
abaaa4a068 | ||
|
b4e125300e | ||
|
50a5cba088 | ||
|
d6c8142bb4 | ||
|
8146858b96 | ||
|
8b8a2083bb | ||
|
6207292d81 | ||
|
b4e5c6d990 | ||
|
b6ac3106ce | ||
|
d37ce04606 | ||
|
8439a60070 | ||
|
f238fcbd47 | ||
|
8ba71a0857 | ||
|
1740ca2cfa | ||
|
e89b86ce56 | ||
|
03dd64c543 | ||
|
aeb6369005 | ||
|
133196f337 | ||
|
41191d54fb | ||
|
643ccec544 | ||
|
bf9baf0d14 | ||
|
bbadd41e0e | ||
|
7e1e1716f9 | ||
|
5cb1cebd88 | ||
|
a5c4b4e3b7 | ||
|
a63a343e9c | ||
|
87a675bf62 | ||
|
4d42dcbea8 | ||
|
276d836c87 | ||
|
7d84062e2c | ||
|
afd604212b | ||
|
fcb89883fa | ||
|
dc674ce870 | ||
|
dbd995c12c | ||
|
7b2e510a2f | ||
|
c816f4dc3b | ||
|
da149e5bbd | ||
|
7c3dac4212 | ||
|
ec20829a22 | ||
|
39b89a1234 | ||
|
f5e6a205b3 | ||
|
c57f88aad2 | ||
|
3e4e17abd8 | ||
|
e6044d0fc7 | ||
|
2ed0b6484c | ||
|
222187d3b0 | ||
|
75455b006c | ||
|
dfb647a82c | ||
|
7fb5c72f7e | ||
|
41b5f49341 | ||
|
f35c250a89 | ||
|
1ff4d82d19 | ||
|
c3abc3df09 | ||
|
a013ea2c46 | ||
|
61244f0f2a | ||
|
85e40b1943 | ||
|
123d2c9074 | ||
|
05dc11e258 | ||
|
8527aa80b8 | ||
|
804860994e | ||
|
29698bef69 | ||
|
4e1b65b6c4 | ||
|
3cbaf4f4bf |
4
.env
4
.env
@@ -54,3 +54,7 @@ VITE_AUTOMATICALLY_DETECT_UPDATE=Y
|
||||
|
||||
# show proxy url log in terminal
|
||||
VITE_PROXY_LOG=Y
|
||||
|
||||
# used to control whether to launch editor
|
||||
# by the way, this plugin is only available in dev mode, not in build mode
|
||||
VITE_DEVTOOLS_LAUNCH_EDITOR=code
|
||||
|
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -14,6 +14,7 @@
|
||||
"sdras.vue-vscode-snippets",
|
||||
"vue.volar",
|
||||
"whtouche.vscode-js-console-utils",
|
||||
"zhuangtongfa.material-theme"
|
||||
"zhuangtongfa.material-theme",
|
||||
"tu6ge.naive-ui-intelligence"
|
||||
]
|
||||
}
|
||||
|
19
.vscode/settings.json
vendored
19
.vscode/settings.json
vendored
@@ -4,15 +4,28 @@
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"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.enabledParsers": ["ts"],
|
||||
"i18n-ally.enabledFrameworks": ["vue"],
|
||||
"i18n-ally.editor.preferEditor": true,
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.localesPaths": ["src/locales/langs"],
|
||||
"i18n-ally.parsers.typescript.compilerOptions": {
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"prettier.enable": false,
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"unocss.root": ["./"],
|
||||
"vue.server.hybridMode": true
|
||||
"unocss.root": ["./"]
|
||||
}
|
||||
|
51
.vscode/vue3.code-snippets
vendored
Normal file
51
.vscode/vue3.code-snippets
vendored
Normal 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",
|
||||
],
|
||||
},
|
||||
}
|
96
CHANGELOG.md
96
CHANGELOG.md
@@ -1,6 +1,102 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## [v1.3.15](https://github.com/soybeanjs/soybean-admin/compare/v1.3.14...v1.3.15) (2025-06-24)
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- **projects**: add configurable user name watermark option - by @wenyuanw [<samp>(7c3da)</samp>](https://github.com/soybeanjs/soybean-admin/commit/7c3dac42)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
|
||||
- **app**: replace console.error with window.console.error for consistency - by @soybeanjs [<samp>(7d840)</samp>](https://github.com/soybeanjs/soybean-admin/commit/7d84062e)
|
||||
- **projects**: ensure proper text color when themes are inverted - 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. - by **chenziwen** [<samp>(da149)</samp>](https://github.com/soybeanjs/soybean-admin/commit/da149e5b)
|
||||
|
||||
### 🛠 Optimizations
|
||||
|
||||
- **components**: optimize spacing for lang-switch dropdown options - by @wenyuanw [<samp>(fcb89)</samp>](https://github.com/soybeanjs/soybean-admin/commit/fcb89883)
|
||||
|
||||
### 💅 Refactors
|
||||
|
||||
- **iframe-page**: remove unused lifecycle hooks and clean up script setup - by @soybeanjs [<samp>(276d8)</samp>](https://github.com/soybeanjs/soybean-admin/commit/276d836c)
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- **other**: update docs with video tutorial link. - 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 - by @soybeanjs [<samp>(4d42d)</samp>](https://github.com/soybeanjs/soybean-admin/commit/4d42dcbe)
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- **deps**: update deps - by @soybeanjs [<samp>(dc674)</samp>](https://github.com/soybeanjs/soybean-admin/commit/dc674ce8)
|
||||
- **projects**: update deps & fix `moduleResolution` - by @soybeanjs [<samp>(dbd99)</samp>](https://github.com/soybeanjs/soybean-admin/commit/dbd995c1)
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
[](https://github.com/soybeanjs) [](https://github.com/wenyuanw)
|
||||
[Azir](mailto:2075125282@qq.com), [chenziwen](mailto:chenziwen@qesong.com)
|
||||
|
||||
## [v1.3.14](https://github.com/soybeanjs/soybean-admin/compare/v1.3.13...v1.3.14) (2025-06-09)
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- **docs**:
|
||||
- add GitCode star badge to README files - by @soybeanjs [<samp>(05dc1)</samp>](https://github.com/soybeanjs/soybean-admin/commit/05dc11e2)
|
||||
- add DartNode sponsorship badge to README files - by @soybeanjs [<samp>(2ed0b)</samp>](https://github.com/soybeanjs/soybean-admin/commit/2ed0b648)
|
||||
- **projects**:
|
||||
- support vite devtools specify the editor by launchEditor option. - 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. - 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 - by **t8y2** [<samp>(75455)</samp>](https://github.com/soybeanjs/soybean-admin/commit/75455b00)
|
||||
- **types**:
|
||||
- enhance Option type to support customizable label types - 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. - by @Azir-11 in https://github.com/soybeanjs/soybean-admin/issues/733 [<samp>(8527a)</samp>](https://github.com/soybeanjs/soybean-admin/commit/8527aa80)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
|
||||
- **auth**:
|
||||
- remove redundant authStore declaration in resetStore function - 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. - 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. - by **Azir** [<samp>(dfb64)</samp>](https://github.com/soybeanjs/soybean-admin/commit/dfb647a8)
|
||||
- **projects**:
|
||||
- tab closure did not remove cache correctly. - by **Azir** [<samp>(7fb5c)</samp>](https://github.com/soybeanjs/soybean-admin/commit/7fb5c72f)
|
||||
|
||||
### 🛠 Optimizations
|
||||
|
||||
- **hooks**:
|
||||
- remove obsolete disabling cache. - 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. - by **恕瑞玛的皇帝** [<samp>(22218)</samp>](https://github.com/soybeanjs/soybean-admin/commit/222187d3)
|
||||
- **projects**:
|
||||
- optimize tab deletion logic. closed #755 - by @wenyuanw in https://github.com/soybeanjs/soybean-admin/issues/755 [<samp>(e6044)</samp>](https://github.com/soybeanjs/soybean-admin/commit/e6044d0f)
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- **README**:
|
||||
- Add supporting ecosystem tools to the open-source repository - 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. - 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 - 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 - by @soybeanjs [<samp>(f35c2)</samp>](https://github.com/soybeanjs/soybean-admin/commit/f35c250a)
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- **deps**:
|
||||
- add vscode recommend plugin close #738 - 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 - by @soybeanjs [<samp>(41b5f)</samp>](https://github.com/soybeanjs/soybean-admin/commit/41b5f493)
|
||||
- update deps - by @soybeanjs [<samp>(3e4e1)</samp>](https://github.com/soybeanjs/soybean-admin/commit/3e4e17ab)
|
||||
|
||||
### 🤖 CI
|
||||
|
||||
- **hooks**: remove lint-staged in git hook. close #724 - 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)
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
[](https://github.com/soybeanjs) [](https://github.com/wenyuanw) [](https://github.com/Azir-11) [](https://github.com/WgoW) [](https://github.com/testbrate) [](https://github.com/tu6ge) [](https://github.com/xiatianYa)
|
||||
[恕瑞玛的皇帝](mailto:2075125282@qq.com), [t8y2](mailto:1156263951@qq.com),
|
||||
|
||||
## [v1.3.13](https://github.com/soybeanjs/soybean-admin/compare/v1.3.12...v1.3.13) (2025-03-19)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
|
@@ -7,9 +7,10 @@
|
||||
---
|
||||
|
||||
[](./LICENSE)
|
||||
[](https://github.com/soybeanjs/soybean-admin)
|
||||
[](https://github.com/soybeanjs/soybean-admin)
|
||||
[](https://github.com/soybeanjs/soybean-admin)
|
||||
[](https://github.com/soybeanjs/soybean-admin)
|
||||
[](https://gitee.com/honghuangdc/soybean-admin)
|
||||
[](https://gitcode.com/soybeanjs/soybean-admin)
|
||||
|
||||
<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="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
@@ -17,14 +18,20 @@
|
||||
> [!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!
|
||||
|
||||
> [!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
|
||||
|
||||
[`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
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -43,19 +50,25 @@
|
||||
- [Preview Link](https://naive.soybeanjs.cn/)
|
||||
- [Github Repository](https://github.com/soybeanjs/soybean-admin)
|
||||
- [Gitee Repository](https://gitee.com/honghuangdc/soybean-admin)
|
||||
- [Gitcode Repository](https://gitcode.com/soybeanjs/soybean-admin)
|
||||
|
||||
- **AntDesignVue Version:**
|
||||
- [Preview Link](https://antd.soybeanjs.cn/)
|
||||
- [Github Repository](https://github.com/soybeanjs/soybean-admin-antd)
|
||||
- [Gitee Repository](https://gitee.com/honghuangdc/soybean-admin-antd)
|
||||
- [Gitcode Repository](https://gitcode.com/soybeanjs/soybean-admin-antd)
|
||||
|
||||
- **ElementPlus Version:**
|
||||
- [Preview Link](https://elp.soybeanjs.cn/)
|
||||
- [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:**
|
||||
- [Preview Link](https://legacy.soybeanjs.cn/)
|
||||
- [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
|
||||
@@ -85,13 +98,18 @@
|
||||
Make sure your environment meets the following requirements:
|
||||
|
||||
- **git**: you need git to clone and manage project versions.
|
||||
- **NodeJS**: >=18.12.0, recommended 18.19.0 or higher.
|
||||
- **pnpm**: >= 8.7.0, recommended 8.14.0 or higher.
|
||||
- **NodeJS**: >=20.19.0, recommended 20.19.0 or higher.
|
||||
- **pnpm**: >= 10.5.0, recommended 10.5.0 or higher.
|
||||
|
||||
**Clone Project**
|
||||
|
||||
```bash
|
||||
# github
|
||||
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**
|
||||
@@ -128,6 +146,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".
|
||||
- [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.
|
||||
- [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
|
||||
|
28
README.md
28
README.md
@@ -10,19 +10,26 @@
|
||||
[](https://github.com/soybeanjs/soybean-admin)
|
||||
[](https://github.com/soybeanjs/soybean-admin)
|
||||
[](https://gitee.com/honghuangdc/soybean-admin)
|
||||
[](https://gitcode.com/soybeanjs/soybean-admin)
|
||||
|
||||
<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="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
> [!NOTE]
|
||||
> 如果您觉得 `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 架构,结构清晰,优雅易懂。
|
||||
- **严格的代码规范**:遵循 [SoybeanJS 规范](https://docs.soybeanjs.cn/zh/standard),集成了eslint, prettier 和 simple-git-hooks,保证代码的规范性。
|
||||
- **TypeScript**: 支持严格的类型检查,提高代码的可维护性。
|
||||
@@ -41,16 +48,22 @@
|
||||
- [预览地址](https://naive.soybeanjs.cn/)
|
||||
- [Github 仓库](https://github.com/soybeanjs/soybean-admin)
|
||||
- [Gitee 仓库](https://gitee.com/honghuangdc/soybean-admin)
|
||||
- [Gitcode 仓库](https://gitcode.com/soybeanjs/soybean-admin)
|
||||
- **AntDesignVue 版本:**
|
||||
- [预览地址](https://antd.soybeanjs.cn/)
|
||||
- [Github 仓库](https://github.com/soybeanjs/soybean-admin-antd)
|
||||
- [Gitee 仓库](https://gitee.com/honghuangdc/soybean-admin-antd)
|
||||
- [Gitcode 仓库](https://gitcode.com/soybeanjs/soybean-admin-antd)
|
||||
- **ElementPlus 版本:**
|
||||
- [预览地址](https://elp.soybeanjs.cn/)
|
||||
- [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/)
|
||||
- [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 +123,18 @@
|
||||
确保你的环境满足以下要求:
|
||||
|
||||
- **git**: 你需要git来克隆和管理项目版本。
|
||||
- **NodeJS**: >=18.12.0,推荐 18.19.0 或更高。
|
||||
- **pnpm**: >= 8.7.0,推荐 8.14.0 或更高。
|
||||
- **NodeJS**: >=20.19.0,推荐 20.19.0 或更高。
|
||||
- **pnpm**: >= 10.5.0,推荐 10.5.0 或更高。
|
||||
|
||||
**克隆项目**
|
||||
|
||||
```bash
|
||||
# github
|
||||
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 +171,8 @@ pnpm build
|
||||
- [snail-job](https://github.com/aizuda/snail-job): 一款兼具 “高性能、高颜值、高活跃” 的分布式任务重试和分布式任务调度平台。
|
||||
- [SuperApi](https://github.com/TmmTop/SuperApi): 快速将你的 idea 变成线上稳定运行的产品! 无实体建库建表,对无实体库表进行增删改查,支持 15 种条件查询,以及分页,列表,无限级树形列表 等功能的 API 部署! 拥有接口文档,Auth 授权,接口限流,获取客户端真实 IP,先进的服务器缓存组件,动态 API 等功能,期待您的体验!
|
||||
- [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分支,适配动态路由,接口鉴权限。
|
||||
|
||||
|
||||
## 如何贡献
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import type { HttpProxy, ProxyOptions } from 'vite';
|
||||
import type { ProxyOptions } from 'vite';
|
||||
import { bgRed, bgYellow, green, lightBlue } from 'kolorist';
|
||||
import { consola } from 'consola';
|
||||
import { createServiceConfig } from '../../src/utils/service';
|
||||
@@ -33,7 +33,7 @@ function createProxyItem(item: App.Service.ServiceConfigItem, enableLog: boolean
|
||||
proxy[item.proxyPattern] = {
|
||||
target: item.baseURL,
|
||||
changeOrigin: true,
|
||||
configure: (_proxy: HttpProxy.Server, options: ProxyOptions) => {
|
||||
configure: (_proxy, options) => {
|
||||
_proxy.on('proxyReq', (_proxyReq, req, _res) => {
|
||||
if (!enableLog) return;
|
||||
|
||||
|
9
build/plugins/devtools.ts
Normal file
9
build/plugins/devtools.ts
Normal 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
|
||||
});
|
||||
}
|
@@ -1,18 +1,18 @@
|
||||
import type { PluginOption } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx';
|
||||
import VueDevtools from 'vite-plugin-vue-devtools';
|
||||
import progress from 'vite-plugin-progress';
|
||||
import { setupElegantRouter } from './router';
|
||||
import { setupUnocss } from './unocss';
|
||||
import { setupUnplugin } from './unplugin';
|
||||
import { setupHtmlPlugin } from './html';
|
||||
import { setupDevtoolsPlugin } from './devtools';
|
||||
|
||||
export function setupVitePlugins(viteEnv: Env.ImportMeta, buildTime: string) {
|
||||
const plugins: PluginOption = [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
VueDevtools(),
|
||||
setupDevtoolsPlugin(viteEnv),
|
||||
setupElegantRouter(),
|
||||
setupUnocss(viteEnv),
|
||||
...setupUnplugin(viteEnv),
|
||||
|
@@ -15,6 +15,7 @@ export function setupElegantRouter() {
|
||||
'exception_500',
|
||||
'document_project',
|
||||
'document_project-link',
|
||||
'document_video',
|
||||
'document_vue',
|
||||
'document_vite',
|
||||
'document_unocss',
|
||||
|
117
package.json
117
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "soybean-admin",
|
||||
"type": "module",
|
||||
"version": "1.3.13",
|
||||
"description": "A fresh and elegant admin template, based on Vue3、Vite3、TypeScript、NaiveUI and UnoCSS. 一个基于Vue3、Vite3、TypeScript、NaiveUI and UnoCSS的清新优雅的中后台模版。",
|
||||
"version": "1.3.15",
|
||||
"description": "A fresh and elegant admin template, based on Vue3、Vite7、TypeScript、NaiveUI and UnoCSS. 一个基于Vue3、Vite7、TypeScript、NaiveUI and UnoCSS的清新优雅的中后台模版。",
|
||||
"author": {
|
||||
"name": "Soybean",
|
||||
"email": "soybeanjs@outlook.com",
|
||||
@@ -19,7 +19,7 @@
|
||||
"keywords": [
|
||||
"Vue3 admin ",
|
||||
"vue-admin-template",
|
||||
"Vite5",
|
||||
"Vite7",
|
||||
"TypeScript",
|
||||
"naive-ui",
|
||||
"naive-ui-admin",
|
||||
@@ -27,8 +27,8 @@
|
||||
"UnoCSS"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18.20.0",
|
||||
"pnpm": ">=8.7.0"
|
||||
"node": ">=20.19.0",
|
||||
"pnpm": ">=10.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build --mode prod",
|
||||
@@ -48,93 +48,90 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@antv/data-set": "0.11.8",
|
||||
"@antv/g2": "5.2.12",
|
||||
"@antv/g6": "5.0.44",
|
||||
"@antv/g2": "5.4.0",
|
||||
"@antv/g6": "5.0.49",
|
||||
"@better-scroll/core": "2.5.1",
|
||||
"@iconify/vue": "4.3.0",
|
||||
"@iconify/vue": "5.0.0",
|
||||
"@sa/alova": "workspace:*",
|
||||
"@sa/axios": "workspace:*",
|
||||
"@sa/color": "workspace:*",
|
||||
"@sa/hooks": "workspace:*",
|
||||
"@sa/materials": "workspace:*",
|
||||
"@sa/utils": "workspace:*",
|
||||
"@visactor/vchart": "1.13.7",
|
||||
"@visactor/vchart": "2.0.4",
|
||||
"@visactor/vchart-theme": "1.12.2",
|
||||
"@visactor/vtable-editors": "1.17.2",
|
||||
"@visactor/vtable-gantt": "1.17.2",
|
||||
"@visactor/vue-vtable": "1.17.2",
|
||||
"@vueuse/components": "13.0.0",
|
||||
"@vueuse/core": "13.0.0",
|
||||
"@visactor/vtable-editors": "1.19.9",
|
||||
"@visactor/vtable-gantt": "1.19.9",
|
||||
"@visactor/vue-vtable": "1.19.9",
|
||||
"@vueuse/components": "13.9.0",
|
||||
"@vueuse/core": "13.9.0",
|
||||
"clipboard": "2.0.11",
|
||||
"dayjs": "1.11.13",
|
||||
"dayjs": "1.11.18",
|
||||
"defu": "6.1.4",
|
||||
"dhtmlx-gantt": "9.0.6",
|
||||
"dompurify": "3.2.4",
|
||||
"echarts": "5.6.0",
|
||||
"jsbarcode": "3.11.6",
|
||||
"dhtmlx-gantt": "9.0.14",
|
||||
"dompurify": "3.2.6",
|
||||
"echarts": "6.0.0",
|
||||
"jsbarcode": "3.12.1",
|
||||
"json5": "2.2.3",
|
||||
"naive-ui": "2.41.0",
|
||||
"naive-ui": "2.43.1",
|
||||
"nprogress": "0.2.0",
|
||||
"pinia": "3.0.1",
|
||||
"pinyin-pro": "3.26.0",
|
||||
"pinia": "3.0.3",
|
||||
"pinyin-pro": "3.27.0",
|
||||
"print-js": "1.6.0",
|
||||
"pro-naive-ui": "^2.1.2",
|
||||
"swiper": "11.2.5",
|
||||
"tailwind-merge": "3.0.2",
|
||||
"pro-naive-ui": "3.1.1",
|
||||
"swiper": "12.0.1",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"typeit": "8.8.7",
|
||||
"vditor": "3.10.9",
|
||||
"vue": "3.5.13",
|
||||
"vditor": "3.11.2",
|
||||
"vue": "3.5.21",
|
||||
"vue-draggable-plus": "0.6.0",
|
||||
"vue-i18n": "11.1.2",
|
||||
"vue-pdf-embed": "2.1.2",
|
||||
"vue-router": "4.5.0",
|
||||
"vue-i18n": "11.1.12",
|
||||
"vue-pdf-embed": "2.1.3",
|
||||
"vue-router": "4.5.1",
|
||||
"wangeditor": "4.7.15",
|
||||
"xgplayer": "3.0.21",
|
||||
"xgplayer": "3.0.23",
|
||||
"xlsx": "0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@amap/amap-jsapi-types": "0.0.15",
|
||||
"@elegant-router/vue": "0.3.8",
|
||||
"@iconify/json": "2.2.318",
|
||||
"@iconify/json": "2.2.385",
|
||||
"@sa/scripts": "workspace:*",
|
||||
"@sa/uno-preset": "workspace:*",
|
||||
"@soybeanjs/eslint-config": "1.6.0",
|
||||
"@soybeanjs/eslint-config": "1.7.1",
|
||||
"@types/bmapgl": "0.0.7",
|
||||
"@types/node": "22.13.10",
|
||||
"@types/node": "24.5.1",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@unocss/eslint-config": "66.0.0",
|
||||
"@unocss/preset-icons": "66.0.0",
|
||||
"@unocss/preset-uno": "66.0.0",
|
||||
"@unocss/transformer-directives": "66.0.0",
|
||||
"@unocss/transformer-variant-group": "66.0.0",
|
||||
"@unocss/vite": "66.0.0",
|
||||
"@vitejs/plugin-vue": "5.2.3",
|
||||
"@vitejs/plugin-vue-jsx": "4.1.2",
|
||||
"@unocss/eslint-config": "66.5.1",
|
||||
"@unocss/preset-icons": "66.5.1",
|
||||
"@unocss/preset-uno": "66.5.1",
|
||||
"@unocss/transformer-directives": "66.5.1",
|
||||
"@unocss/transformer-variant-group": "66.5.1",
|
||||
"@unocss/vite": "66.5.1",
|
||||
"@vitejs/plugin-vue": "6.0.1",
|
||||
"@vitejs/plugin-vue-jsx": "5.1.1",
|
||||
"consola": "3.4.2",
|
||||
"eslint": "9.22.0",
|
||||
"eslint-plugin-vue": "10.0.0",
|
||||
"eslint": "9.35.0",
|
||||
"eslint-plugin-vue": "10.4.0",
|
||||
"kolorist": "1.8.0",
|
||||
"lint-staged": "15.5.0",
|
||||
"pro-naive-ui-resolver": "^1.0.2",
|
||||
"sass": "1.86.0",
|
||||
"simple-git-hooks": "2.11.1",
|
||||
"tsx": "4.19.3",
|
||||
"typescript": "5.8.2",
|
||||
"unplugin-icons": "22.1.0",
|
||||
"unplugin-vue-components": "28.4.1",
|
||||
"vite": "6.2.2",
|
||||
"lint-staged": "16.1.6",
|
||||
"pro-naive-ui-resolver": "1.0.2",
|
||||
"sass": "1.92.1",
|
||||
"simple-git-hooks": "2.13.1",
|
||||
"tsx": "4.20.5",
|
||||
"typescript": "5.9.2",
|
||||
"unplugin-icons": "22.3.0",
|
||||
"unplugin-vue-components": "29.0.0",
|
||||
"vite": "7.1.5",
|
||||
"vite-plugin-progress": "0.0.7",
|
||||
"vite-plugin-svg-icons": "2.0.1",
|
||||
"vite-plugin-vue-devtools": "7.7.2",
|
||||
"vue-eslint-parser": "10.1.1",
|
||||
"vue-tsc": "2.2.8"
|
||||
"vite-plugin-vue-devtools": "8.0.2",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-tsc": "3.0.7"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"commit-msg": "pnpm sa git-commit-verify",
|
||||
"pre-commit": "pnpm typecheck && pnpm lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "eslint --fix"
|
||||
"pre-commit": "pnpm typecheck && pnpm lint && git diff --exit-code"
|
||||
},
|
||||
"website": "https://admin.soybeanjs.cn"
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/alova",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./fetch": "./src/fetch.ts",
|
||||
@@ -13,8 +13,8 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@alova/mock": "2.0.12",
|
||||
"@alova/mock": "2.0.17",
|
||||
"@sa/utils": "workspace:*",
|
||||
"alova": "3.2.10"
|
||||
"alova": "3.3.4"
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/axios",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
@@ -11,11 +11,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sa/utils": "workspace:*",
|
||||
"axios": "1.8.3",
|
||||
"axios": "1.12.2",
|
||||
"axios-retry": "4.5.0",
|
||||
"qs": "6.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qs": "6.9.18"
|
||||
"@types/qs": "6.14.0"
|
||||
}
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ import type { AxiosResponse, CreateAxiosDefaults, InternalAxiosRequestConfig } f
|
||||
import axiosRetry from 'axios-retry';
|
||||
import { nanoid } from '@sa/utils';
|
||||
import { createAxiosConfig, createDefaultOptions, createRetryOptions } from './options';
|
||||
import { transformResponse } from './shared';
|
||||
import { BACKEND_ERROR_CODE, REQUEST_ID_KEY } from './constant';
|
||||
import type {
|
||||
CustomAxiosRequestConfig,
|
||||
@@ -13,11 +14,12 @@ import type {
|
||||
ResponseType
|
||||
} from './type';
|
||||
|
||||
function createCommonRequest<ResponseData = any>(
|
||||
axiosConfig?: CreateAxiosDefaults,
|
||||
options?: Partial<RequestOption<ResponseData>>
|
||||
) {
|
||||
const opts = createDefaultOptions<ResponseData>(options);
|
||||
function createCommonRequest<
|
||||
ResponseData,
|
||||
ApiData = ResponseData,
|
||||
State extends Record<string, unknown> = Record<string, unknown>
|
||||
>(axiosConfig?: CreateAxiosDefaults, options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
|
||||
const opts = createDefaultOptions<ResponseData, ApiData, State>(options);
|
||||
|
||||
const axiosConf = createAxiosConfig(axiosConfig);
|
||||
const instance = axios.create(axiosConf);
|
||||
@@ -52,6 +54,8 @@ function createCommonRequest<ResponseData = any>(
|
||||
async response => {
|
||||
const responseType: ResponseType = (response.config?.responseType as ResponseType) || 'json';
|
||||
|
||||
await transformResponse(response);
|
||||
|
||||
if (responseType !== 'json' || opts.isBackendSuccess(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() {
|
||||
abortControllerMap.forEach(abortController => {
|
||||
abortController.abort();
|
||||
@@ -98,7 +94,6 @@ function createCommonRequest<ResponseData = any>(
|
||||
return {
|
||||
instance,
|
||||
opts,
|
||||
cancelRequest,
|
||||
cancelAllRequest
|
||||
};
|
||||
}
|
||||
@@ -109,27 +104,27 @@ function createCommonRequest<ResponseData = any>(
|
||||
* @param axiosConfig axios config
|
||||
* @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,
|
||||
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'>(
|
||||
config: CustomAxiosRequestConfig
|
||||
) {
|
||||
const request: RequestInstance<ApiData, State> = async function request<
|
||||
T extends ApiData = ApiData,
|
||||
R extends ResponseType = 'json'
|
||||
>(config: CustomAxiosRequestConfig) {
|
||||
const response: AxiosResponse<ResponseData> = await instance(config);
|
||||
|
||||
const responseType = response.config?.responseType || 'json';
|
||||
|
||||
if (responseType === 'json') {
|
||||
return opts.transformBackendResponse(response);
|
||||
return opts.transform(response);
|
||||
}
|
||||
|
||||
return response.data as MappedType<R, T>;
|
||||
} as RequestInstance<State>;
|
||||
} as RequestInstance<ApiData, State>;
|
||||
|
||||
request.cancelRequest = cancelRequest;
|
||||
request.cancelAllRequest = cancelAllRequest;
|
||||
request.state = {} as State;
|
||||
|
||||
@@ -144,14 +139,14 @@ export function createRequest<ResponseData = any, State = Record<string, unknown
|
||||
* @param axiosConfig axios config
|
||||
* @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,
|
||||
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<
|
||||
T = any,
|
||||
const flatRequest: FlatRequestInstance<ResponseData, ApiData, State> = async function flatRequest<
|
||||
T extends ApiData = ApiData,
|
||||
R extends ResponseType = 'json'
|
||||
>(config: CustomAxiosRequestConfig) {
|
||||
try {
|
||||
@@ -160,20 +155,21 @@ export function createFlatRequest<ResponseData = any, State = Record<string, unk
|
||||
const responseType = response.config?.responseType || 'json';
|
||||
|
||||
if (responseType === 'json') {
|
||||
const data = opts.transformBackendResponse(response);
|
||||
const data = await opts.transform(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) {
|
||||
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.state = {} as State;
|
||||
flatRequest.state = {
|
||||
...opts.defaultState
|
||||
} as State;
|
||||
|
||||
return flatRequest;
|
||||
}
|
||||
|
@@ -4,15 +4,27 @@ import { stringify } from 'qs';
|
||||
import { isHttpSuccess } from './shared';
|
||||
import type { RequestOption } from './type';
|
||||
|
||||
export function createDefaultOptions<ResponseData = any>(options?: Partial<RequestOption<ResponseData>>) {
|
||||
const opts: RequestOption<ResponseData> = {
|
||||
export function createDefaultOptions<
|
||||
ResponseData,
|
||||
ApiData = ResponseData,
|
||||
State extends Record<string, unknown> = Record<string, unknown>
|
||||
>(options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
|
||||
const opts: RequestOption<ResponseData, ApiData, State> = {
|
||||
defaultState: {} as State,
|
||||
transform: async response => response.data as unknown as ApiData,
|
||||
transformBackendResponse: async response => response.data as unknown as ApiData,
|
||||
onRequest: async config => config,
|
||||
isBackendSuccess: _response => true,
|
||||
onBackendFail: async () => {},
|
||||
transformBackendResponse: async response => response.data,
|
||||
onError: async () => {}
|
||||
};
|
||||
|
||||
if (options?.transform) {
|
||||
opts.transform = options.transform;
|
||||
} else {
|
||||
opts.transform = options?.transformBackendResponse || opts.transform;
|
||||
}
|
||||
|
||||
Object.assign(opts, options);
|
||||
|
||||
return opts;
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import type { AxiosHeaderValue, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import type { ResponseType } from './type';
|
||||
|
||||
export function getContentType(config: InternalAxiosRequestConfig) {
|
||||
const contentType: AxiosHeaderValue = config.headers?.['Content-Type'] || 'application/json';
|
||||
@@ -26,3 +27,53 @@ export function isResponseJson(response: AxiosResponse) {
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
@@ -8,7 +8,30 @@ export type ContentType =
|
||||
| 'application/x-www-form-urlencoded'
|
||||
| '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
|
||||
*
|
||||
@@ -35,12 +58,6 @@ export interface RequestOption<ResponseData = any> {
|
||||
response: AxiosResponse<ResponseData>,
|
||||
instance: AxiosInstance
|
||||
) => 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
|
||||
*
|
||||
@@ -68,15 +85,7 @@ export type CustomAxiosRequestConfig<R extends ResponseType = 'json'> = Omit<Axi
|
||||
responseType?: R;
|
||||
};
|
||||
|
||||
export interface RequestInstanceCommon<T> {
|
||||
/**
|
||||
* 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;
|
||||
export interface RequestInstanceCommon<State extends Record<string, unknown>> {
|
||||
/**
|
||||
* cancel all request
|
||||
*
|
||||
@@ -84,32 +93,35 @@ export interface RequestInstanceCommon<T> {
|
||||
*/
|
||||
cancelAllRequest: () => void;
|
||||
/** you can set custom state in the request instance */
|
||||
state: T;
|
||||
state: State;
|
||||
}
|
||||
|
||||
/** The request instance */
|
||||
export interface RequestInstance<S = Record<string, unknown>> extends RequestInstanceCommon<S> {
|
||||
<T = any, R extends ResponseType = 'json'>(config: CustomAxiosRequestConfig<R>): Promise<MappedType<R, T>>;
|
||||
export interface RequestInstance<ApiData, State extends Record<string, unknown>> extends RequestInstanceCommon<State> {
|
||||
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
|
||||
config: CustomAxiosRequestConfig<R>
|
||||
): Promise<MappedType<R, T>>;
|
||||
}
|
||||
|
||||
export type FlatResponseSuccessData<T = any, ResponseData = any> = {
|
||||
data: T;
|
||||
export type FlatResponseSuccessData<ResponseData, ApiData> = {
|
||||
data: ApiData;
|
||||
error: null;
|
||||
response: AxiosResponse<ResponseData>;
|
||||
};
|
||||
|
||||
export type FlatResponseFailData<ResponseData = any> = {
|
||||
export type FlatResponseFailData<ResponseData> = {
|
||||
data: null;
|
||||
error: AxiosError<ResponseData>;
|
||||
response: AxiosResponse<ResponseData>;
|
||||
};
|
||||
|
||||
export type FlatResponseData<T = any, ResponseData = any> =
|
||||
| FlatResponseSuccessData<T, ResponseData>
|
||||
export type FlatResponseData<ResponseData, ApiData> =
|
||||
| FlatResponseSuccessData<ResponseData, ApiData>
|
||||
| FlatResponseFailData<ResponseData>;
|
||||
|
||||
export interface FlatRequestInstance<S = Record<string, unknown>, ResponseData = any> extends RequestInstanceCommon<S> {
|
||||
<T = any, R extends ResponseType = 'json'>(
|
||||
export interface FlatRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
|
||||
extends RequestInstanceCommon<State> {
|
||||
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
|
||||
config: CustomAxiosRequestConfig<R>
|
||||
): Promise<FlatResponseData<MappedType<R, T>, ResponseData>>;
|
||||
): Promise<FlatResponseData<ResponseData, MappedType<R, T>>>;
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/color",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/hooks",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
|
@@ -3,9 +3,7 @@ import useLoading from './use-loading';
|
||||
import useCountDown from './use-count-down';
|
||||
import useContext from './use-context';
|
||||
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 * from './use-signal';
|
||||
export * from './use-table';
|
||||
export { useBoolean, useLoading, useCountDown, useContext, useSvgIconRender, useTable };
|
||||
export type * from './use-table';
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { inject, provide } from 'vue';
|
||||
import type { InjectionKey } from 'vue';
|
||||
|
||||
/**
|
||||
* Use context
|
||||
@@ -12,7 +11,7 @@ import type { InjectionKey } from 'vue';
|
||||
* import { ref } from 'vue';
|
||||
* import { useContext } from '@sa/hooks';
|
||||
*
|
||||
* export const { setupStore, useStore } = useContext('demo', () => {
|
||||
* export const [provideDemoContext, useDemoContext] = useContext('demo', () => {
|
||||
* const count = ref(0);
|
||||
*
|
||||
* function increment() {
|
||||
@@ -35,10 +34,10 @@ import type { InjectionKey } from 'vue';
|
||||
* <div>A</div>
|
||||
* </template>
|
||||
* <script setup lang="ts">
|
||||
* import { setupStore } from './context';
|
||||
* import { provideDemoContext } from './context';
|
||||
*
|
||||
* setupStore();
|
||||
* // const { increment } = setupStore(); // also can control the store in the parent component
|
||||
* provideDemoContext();
|
||||
* // const { increment } = provideDemoContext(); // also can control the store in the parent component
|
||||
* </script>
|
||||
* ``` // B.vue
|
||||
* ```vue
|
||||
@@ -46,9 +45,9 @@ import type { InjectionKey } from 'vue';
|
||||
* <div>B</div>
|
||||
* </template>
|
||||
* <script setup lang="ts">
|
||||
* import { useStore } from './context';
|
||||
* import { useDemoContext } from './context';
|
||||
*
|
||||
* const { count, increment } = useStore();
|
||||
* const { count, increment } = useDemoContext();
|
||||
* </script>
|
||||
* ```;
|
||||
*
|
||||
@@ -57,40 +56,41 @@ import type { InjectionKey } from 'vue';
|
||||
* @param contextName Context name
|
||||
* @param fn Context function
|
||||
*/
|
||||
export default function useContext<T extends (...args: any[]) => any>(contextName: string, fn: T) {
|
||||
type Context = ReturnType<T>;
|
||||
export default function useContext<Arguments extends Array<any>, 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>) {
|
||||
const context: Context = fn(...args);
|
||||
return useProvide(context);
|
||||
}
|
||||
if (consumerName && !value) {
|
||||
throw new Error(`\`${consumerName}\` must be used within \`${contextName}\``);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Setup store in the parent component */
|
||||
setupStore,
|
||||
/** Use store in the child component */
|
||||
useStore
|
||||
// @ts-expect-error - we want to return null if the value is undefined or null
|
||||
return value || null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Create context */
|
||||
function createContext<T>(contextName: string) {
|
||||
const injectKey: InjectionKey<T> = Symbol(contextName);
|
||||
const useProvide = (...args: Arguments) => {
|
||||
const value = composable(...args);
|
||||
|
||||
function useProvide(context: T) {
|
||||
provide(injectKey, context);
|
||||
provide(key, value);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function useInject() {
|
||||
return inject(injectKey) as T;
|
||||
}
|
||||
|
||||
return {
|
||||
useProvide,
|
||||
useInject
|
||||
return value;
|
||||
};
|
||||
|
||||
return [useProvide, useInject] as const;
|
||||
}
|
||||
|
@@ -2,40 +2,59 @@ import { computed, onScopeDispose, ref } from 'vue';
|
||||
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) {
|
||||
const FPS_PER_SECOND = 60;
|
||||
export default function useCountDown(initialSeconds: number) {
|
||||
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(() => fps.value > 0);
|
||||
const isCounting = computed(() => remainingSeconds.value > 0);
|
||||
|
||||
const { pause, resume } = useRafFn(
|
||||
() => {
|
||||
if (fps.value > 0) {
|
||||
fps.value -= 1;
|
||||
} else {
|
||||
({ delta }) => {
|
||||
// delta: milliseconds elapsed since the last frame.
|
||||
|
||||
// If countdown already reached zero or below, ensure it's 0 and stop.
|
||||
if (remainingSeconds.value <= 0) {
|
||||
remainingSeconds.value = 0;
|
||||
pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate seconds passed since the last frame.
|
||||
const secondsPassed = delta / 1000;
|
||||
remainingSeconds.value -= secondsPassed;
|
||||
|
||||
// If countdown has finished after decrementing.
|
||||
if (remainingSeconds.value <= 0) {
|
||||
remainingSeconds.value = 0;
|
||||
pause();
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
{ 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();
|
||||
}
|
||||
|
||||
/** Stops the countdown and resets the remaining time to 0. */
|
||||
function stop() {
|
||||
fps.value = 0;
|
||||
remainingSeconds.value = 0;
|
||||
pause();
|
||||
}
|
||||
|
||||
// Ensure the rAF loop is cleaned up when the component is unmounted.
|
||||
onScopeDispose(() => {
|
||||
pause();
|
||||
});
|
||||
|
@@ -6,31 +6,31 @@ import type {
|
||||
CreateAxiosDefaults,
|
||||
CustomAxiosRequestConfig,
|
||||
MappedType,
|
||||
RequestInstanceCommon,
|
||||
RequestOption,
|
||||
ResponseType
|
||||
} from '@sa/axios';
|
||||
import useLoading from './use-loading';
|
||||
|
||||
export type HookRequestInstanceResponseSuccessData<T = any> = {
|
||||
data: Ref<T>;
|
||||
export type HookRequestInstanceResponseSuccessData<ApiData> = {
|
||||
data: Ref<ApiData>;
|
||||
error: Ref<null>;
|
||||
};
|
||||
|
||||
export type HookRequestInstanceResponseFailData<ResponseData = any> = {
|
||||
export type HookRequestInstanceResponseFailData<ResponseData> = {
|
||||
data: Ref<null>;
|
||||
error: Ref<AxiosError<ResponseData>>;
|
||||
};
|
||||
|
||||
export type HookRequestInstanceResponseData<T = any, ResponseData = any> = {
|
||||
export type HookRequestInstanceResponseData<ResponseData, ApiData> = {
|
||||
loading: Ref<boolean>;
|
||||
} & (HookRequestInstanceResponseSuccessData<T> | HookRequestInstanceResponseFailData<ResponseData>);
|
||||
} & (HookRequestInstanceResponseSuccessData<ApiData> | HookRequestInstanceResponseFailData<ResponseData>);
|
||||
|
||||
export interface HookRequestInstance<ResponseData = any> {
|
||||
<T = any, R extends ResponseType = 'json'>(
|
||||
export interface HookRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
|
||||
extends RequestInstanceCommon<State> {
|
||||
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
|
||||
config: CustomAxiosRequestConfig
|
||||
): HookRequestInstanceResponseData<MappedType<R, T>, ResponseData>;
|
||||
cancelRequest: (requestId: string) => void;
|
||||
cancelAllRequest: () => void;
|
||||
): HookRequestInstanceResponseData<ResponseData, MappedType<R, T>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,25 +39,26 @@ export interface HookRequestInstance<ResponseData = any> {
|
||||
* @param axiosConfig
|
||||
* @param options
|
||||
*/
|
||||
export default function createHookRequest<ResponseData = any>(
|
||||
export default function createHookRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
|
||||
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'>(
|
||||
config: CustomAxiosRequestConfig
|
||||
) {
|
||||
const hookRequest: HookRequestInstance<ResponseData, ApiData, State> = function hookRequest<
|
||||
T extends ApiData = ApiData,
|
||||
R extends ResponseType = 'json'
|
||||
>(config: CustomAxiosRequestConfig) {
|
||||
const { loading, startLoading, endLoading } = useLoading();
|
||||
|
||||
const data = ref<MappedType<R, T> | null>(null) as Ref<MappedType<R, T>>;
|
||||
const error = ref<AxiosError<ResponseData> | null>(null) as Ref<AxiosError<ResponseData> | null>;
|
||||
const data = ref(null) as Ref<MappedType<R, T>>;
|
||||
const error = ref(null) as Ref<AxiosError<ResponseData> | null>;
|
||||
|
||||
startLoading();
|
||||
|
||||
request(config).then(res => {
|
||||
if (res.data) {
|
||||
data.value = res.data;
|
||||
data.value = res.data as MappedType<R, T>;
|
||||
} else {
|
||||
error.value = res.error;
|
||||
}
|
||||
@@ -70,9 +71,8 @@ export default function createHookRequest<ResponseData = any>(
|
||||
data,
|
||||
error
|
||||
};
|
||||
} as HookRequestInstance<ResponseData>;
|
||||
} as HookRequestInstance<ResponseData, ApiData, State>;
|
||||
|
||||
hookRequest.cancelRequest = request.cancelRequest;
|
||||
hookRequest.cancelAllRequest = request.cancelAllRequest;
|
||||
|
||||
return hookRequest;
|
||||
|
@@ -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;
|
||||
}
|
@@ -1,12 +1,20 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { Ref, VNodeChild } from 'vue';
|
||||
import { jsonClone } from '@sa/utils';
|
||||
import useBoolean from './use-boolean';
|
||||
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);
|
||||
|
||||
@@ -14,76 +22,64 @@ export type TableColumnCheck = {
|
||||
key: string;
|
||||
title: TableColumnCheckTitle;
|
||||
checked: boolean;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export type TableDataWithIndex<T> = T & { index: number };
|
||||
|
||||
export type TransformedData<T> = {
|
||||
data: TableDataWithIndex<T>[];
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type Transformer<T, Response> = (response: Response) => TransformedData<T>;
|
||||
|
||||
export type TableConfig<A extends ApiFn, T, C> = {
|
||||
/** api function to get table data */
|
||||
apiFn: A;
|
||||
/** api params */
|
||||
apiParams?: Parameters<A>[0];
|
||||
/** transform api response to table data */
|
||||
transformer: Transformer<T, Awaited<ReturnType<A>>>;
|
||||
/** columns factory */
|
||||
columns: () => C[];
|
||||
export interface UseTableOptions<ResponseData, ApiData, Column, Pagination extends boolean> {
|
||||
/**
|
||||
* api function to get table data
|
||||
*/
|
||||
api: () => Promise<ResponseData>;
|
||||
/**
|
||||
* whether to enable pagination
|
||||
*/
|
||||
pagination?: Pagination;
|
||||
/**
|
||||
* transform api response to table data
|
||||
*/
|
||||
transform: Transform<ResponseData, ApiData, Pagination>;
|
||||
/**
|
||||
* columns factory
|
||||
*/
|
||||
columns: () => Column[];
|
||||
/**
|
||||
* get column checks
|
||||
*
|
||||
* @param columns
|
||||
*/
|
||||
getColumnChecks: (columns: C[]) => TableColumnCheck[];
|
||||
getColumnChecks: (columns: Column[]) => TableColumnCheck[];
|
||||
/**
|
||||
* get columns
|
||||
*
|
||||
* @param columns
|
||||
*/
|
||||
getColumns: (columns: C[], checks: TableColumnCheck[]) => C[];
|
||||
getColumns: (columns: Column[], checks: TableColumnCheck[]) => Column[];
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
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 { 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 columnChecks: Ref<TableColumnCheck[]> = ref(getColumnChecks(config.columns()));
|
||||
|
||||
const columns = computed(() => getColumns(allColumns.value, columnChecks.value));
|
||||
const $columns = computed(() => getColumns(columns(), columnChecks.value));
|
||||
|
||||
function reloadColumns() {
|
||||
allColumns.value = config.columns();
|
||||
|
||||
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 => ({
|
||||
...col,
|
||||
@@ -92,47 +88,21 @@ export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<
|
||||
}
|
||||
|
||||
async function getData() {
|
||||
startLoading();
|
||||
try {
|
||||
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);
|
||||
|
||||
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));
|
||||
await onFetched?.(transformed);
|
||||
} finally {
|
||||
endLoading();
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
@@ -143,12 +113,20 @@ export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<
|
||||
loading,
|
||||
empty,
|
||||
data,
|
||||
columns,
|
||||
columns: $columns,
|
||||
columnChecks,
|
||||
reloadColumns,
|
||||
getData,
|
||||
searchParams,
|
||||
updateSearchParams,
|
||||
resetSearchParams
|
||||
getData
|
||||
};
|
||||
}
|
||||
|
||||
function getTableData<ApiData, Pagination extends boolean>(
|
||||
data: GetApiData<ApiData, Pagination>,
|
||||
pagination?: Pagination
|
||||
) {
|
||||
if (pagination) {
|
||||
return (data as PaginationData<ApiData>).data;
|
||||
}
|
||||
|
||||
return data as ApiData[];
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/materials",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sa/utils": "workspace:*",
|
||||
"simplebar-vue": "2.4.0"
|
||||
"simplebar-vue": "2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typed-css-modules": "0.9.1"
|
||||
|
@@ -127,7 +127,6 @@ function handleClickMask() {
|
||||
:class="[
|
||||
style['layout-header'],
|
||||
commonClass,
|
||||
headerClass,
|
||||
headerLeftGapClass,
|
||||
{ 'absolute top-0 left-0 w-full': fixedHeaderAndTab }
|
||||
]"
|
||||
|
@@ -6,12 +6,6 @@ interface AdminLayoutHeaderConfig {
|
||||
* @default true
|
||||
*/
|
||||
headerVisible?: boolean;
|
||||
/**
|
||||
* Header class
|
||||
*
|
||||
* @default ''
|
||||
*/
|
||||
headerClass?: string;
|
||||
/**
|
||||
* Header height
|
||||
*
|
||||
|
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@sa/fetch",
|
||||
"version": "1.3.13",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ofetch": "1.4.1"
|
||||
}
|
||||
}
|
@@ -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;
|
@@ -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"]
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/scripts",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"bin": {
|
||||
"sa": "./bin.ts"
|
||||
},
|
||||
@@ -13,15 +13,16 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@soybeanjs/changelog": "0.3.24",
|
||||
"bumpp": "10.1.0",
|
||||
"c12": "3.0.2",
|
||||
"@soybeanjs/changelog": "0.3.25",
|
||||
"bumpp": "10.2.3",
|
||||
"c12": "3.3.0",
|
||||
"cac": "6.7.14",
|
||||
"consola": "3.4.2",
|
||||
"enquirer": "2.4.1",
|
||||
"execa": "9.5.2",
|
||||
"execa": "9.6.0",
|
||||
"kolorist": "1.8.0",
|
||||
"npm-check-updates": "17.1.15",
|
||||
"npm-check-updates": "18.1.1",
|
||||
"picomatch": "4.0.3",
|
||||
"rimraf": "6.0.1"
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/uno-preset",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/utils",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
|
@@ -32,7 +32,8 @@ export function createStorage<T extends object>(type: StorageType, storagePrefix
|
||||
storageData = JSON.parse(json);
|
||||
} catch {}
|
||||
|
||||
if (storageData) {
|
||||
// storageData may be `false` if it is boolean type
|
||||
if (storageData !== null) {
|
||||
return storageData as T[K];
|
||||
}
|
||||
}
|
||||
|
5890
pnpm-lock.yaml
generated
5890
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@ const naiveDateLocale = computed(() => {
|
||||
|
||||
const watermarkProps = computed<WatermarkProps>(() => {
|
||||
return {
|
||||
content: themeStore.watermark.text,
|
||||
content: themeStore.watermarkContent,
|
||||
cross: true,
|
||||
fullscreen: true,
|
||||
fontSize: 16,
|
||||
|
@@ -22,7 +22,12 @@ const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
|
||||
</NButton>
|
||||
</template>
|
||||
<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" />
|
||||
<NCheckbox v-model:checked="item.checked" class="none_draggable flex-1">
|
||||
<template v-if="typeof item.title === 'function'">
|
||||
|
42
src/components/common/icon-tooltip.vue
Normal file
42
src/components/common/icon-tooltip.vue
Normal 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>
|
@@ -31,13 +31,25 @@ const tooltipContent = computed(() => {
|
||||
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) {
|
||||
emit('changeLang', lang);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDropdown :value="lang" :options="langOptions" trigger="hover" @select="changeLang">
|
||||
<NDropdown :value="lang" :options="dropdownOptions" trigger="hover" @select="changeLang">
|
||||
<div>
|
||||
<ButtonIcon :tooltip-content="tooltipContent" tooltip-placement="left">
|
||||
<SvgIcon icon="heroicons:language" />
|
||||
|
@@ -5,9 +5,9 @@ export const GLOBAL_HEADER_MENU_ID = '__GLOBAL_HEADER_MENU__';
|
||||
export const GLOBAL_SIDER_MENU_ID = '__GLOBAL_SIDER_MENU__';
|
||||
|
||||
export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = {
|
||||
light: 'theme.themeSchema.light',
|
||||
dark: 'theme.themeSchema.dark',
|
||||
auto: 'theme.themeSchema.auto'
|
||||
light: 'theme.appearance.themeSchema.light',
|
||||
dark: 'theme.appearance.themeSchema.dark',
|
||||
auto: 'theme.appearance.themeSchema.auto'
|
||||
};
|
||||
|
||||
export const themeSchemaOptions = transformRecordToOption(themeSchemaRecord);
|
||||
@@ -21,45 +21,57 @@ export const loginModuleRecord: Record<UnionKey.LoginModule, App.I18n.I18nKey> =
|
||||
};
|
||||
|
||||
export const themeLayoutModeRecord: Record<UnionKey.ThemeLayoutMode, App.I18n.I18nKey> = {
|
||||
vertical: 'theme.layoutMode.vertical',
|
||||
'vertical-mix': 'theme.layoutMode.vertical-mix',
|
||||
horizontal: 'theme.layoutMode.horizontal',
|
||||
'horizontal-mix': 'theme.layoutMode.horizontal-mix'
|
||||
vertical: 'theme.layout.layoutMode.vertical',
|
||||
'vertical-mix': 'theme.layout.layoutMode.vertical-mix',
|
||||
'vertical-hybrid-header-first': 'theme.layout.layoutMode.vertical-hybrid-header-first',
|
||||
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 themeScrollModeRecord: Record<UnionKey.ThemeScrollMode, App.I18n.I18nKey> = {
|
||||
wrapper: 'theme.scrollMode.wrapper',
|
||||
content: 'theme.scrollMode.content'
|
||||
wrapper: 'theme.layout.content.scrollMode.wrapper',
|
||||
content: 'theme.layout.content.scrollMode.content'
|
||||
};
|
||||
|
||||
export const themeScrollModeOptions = transformRecordToOption(themeScrollModeRecord);
|
||||
|
||||
export const themeTabModeRecord: Record<UnionKey.ThemeTabMode, App.I18n.I18nKey> = {
|
||||
chrome: 'theme.tab.mode.chrome',
|
||||
button: 'theme.tab.mode.button'
|
||||
chrome: 'theme.layout.tab.mode.chrome',
|
||||
button: 'theme.layout.tab.mode.button'
|
||||
};
|
||||
|
||||
export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord);
|
||||
|
||||
export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = {
|
||||
'fade-slide': 'theme.page.mode.fade-slide',
|
||||
fade: 'theme.page.mode.fade',
|
||||
'fade-bottom': 'theme.page.mode.fade-bottom',
|
||||
'fade-scale': 'theme.page.mode.fade-scale',
|
||||
'zoom-fade': 'theme.page.mode.zoom-fade',
|
||||
'zoom-out': 'theme.page.mode.zoom-out',
|
||||
none: 'theme.page.mode.none'
|
||||
'fade-slide': 'theme.layout.content.page.mode.fade-slide',
|
||||
fade: 'theme.layout.content.page.mode.fade',
|
||||
'fade-bottom': 'theme.layout.content.page.mode.fade-bottom',
|
||||
'fade-scale': 'theme.layout.content.page.mode.fade-scale',
|
||||
'zoom-fade': 'theme.layout.content.page.mode.zoom-fade',
|
||||
'zoom-out': 'theme.layout.content.page.mode.zoom-out',
|
||||
none: 'theme.layout.content.page.mode.none'
|
||||
};
|
||||
|
||||
export const themePageAnimationModeOptions = transformRecordToOption(themePageAnimationModeRecord);
|
||||
|
||||
export const resetCacheStrategyRecord: Record<UnionKey.ResetCacheStrategy, App.I18n.I18nKey> = {
|
||||
close: 'theme.resetCacheStrategy.close',
|
||||
refresh: 'theme.resetCacheStrategy.refresh'
|
||||
refresh: 'theme.layout.resetCacheStrategy.refresh',
|
||||
close: 'theme.layout.resetCacheStrategy.close'
|
||||
};
|
||||
|
||||
export const resetCacheStrategyOptions = transformRecordToOption(resetCacheStrategyRecord);
|
||||
|
||||
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' }
|
||||
];
|
||||
|
@@ -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 * as echarts from 'echarts/core';
|
||||
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 darkMode = computed(() => themeStore.darkMode);
|
||||
|
||||
const domRef = ref<HTMLElement | null>(null);
|
||||
const domRef = shallowRef<HTMLElement | null>(null);
|
||||
const initialSize = { width: 0, height: 0 };
|
||||
const { width, height } = useElementSize(domRef, initialSize);
|
||||
|
||||
let chart: echarts.ECharts | null = null;
|
||||
const chart = shallowRef<echarts.ECharts | null>(null);
|
||||
const chartOptions: T = optionsFactory();
|
||||
|
||||
const {
|
||||
@@ -111,18 +111,9 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
|
||||
onDestroy
|
||||
} = 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 */
|
||||
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
|
||||
*/
|
||||
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
|
||||
if (!isRendered()) return;
|
||||
|
||||
const updatedOpts = callback(chartOptions, optionsFactory);
|
||||
|
||||
Object.assign(chartOptions, updatedOpts);
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (!isRendered()) return;
|
||||
|
||||
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) {
|
||||
chart?.setOption(options);
|
||||
chart.value?.setOption(options);
|
||||
}
|
||||
|
||||
/** render chart */
|
||||
async function render() {
|
||||
if (!isRendered()) {
|
||||
const chartTheme = darkMode.value ? 'dark' : 'light';
|
||||
if (isRendered()) return;
|
||||
|
||||
await nextTick();
|
||||
const chartTheme = darkMode.value ? 'dark' : 'light';
|
||||
|
||||
chart = echarts.init(domRef.value, chartTheme);
|
||||
chart.value = echarts.init(domRef.value, chartTheme);
|
||||
|
||||
chart.setOption({ ...chartOptions, backgroundColor: 'transparent' });
|
||||
chart.value?.setOption({ ...chartOptions, backgroundColor: 'transparent' });
|
||||
|
||||
await onRender?.(chart);
|
||||
}
|
||||
await onRender?.(chart.value!);
|
||||
}
|
||||
|
||||
/** resize chart */
|
||||
function resize() {
|
||||
chart?.resize();
|
||||
chart.value?.resize();
|
||||
}
|
||||
|
||||
/** destroy chart */
|
||||
async function destroy() {
|
||||
if (!chart) return;
|
||||
if (!chart.value) return;
|
||||
|
||||
await onDestroy?.(chart);
|
||||
chart?.dispose();
|
||||
chart = null;
|
||||
await onDestroy?.(chart.value);
|
||||
chart.value?.dispose();
|
||||
chart.value = null;
|
||||
}
|
||||
|
||||
/** change chart theme */
|
||||
async function changeTheme() {
|
||||
await destroy();
|
||||
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.height = h;
|
||||
|
||||
// size is abnormal, destroy chart
|
||||
if (!canRender()) {
|
||||
await destroy();
|
||||
// resize chart
|
||||
if (isRendered()) {
|
||||
resize();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// resize chart
|
||||
if (isRendered()) {
|
||||
resize();
|
||||
}
|
||||
|
||||
// render chart
|
||||
await render();
|
||||
|
||||
if (chart.value) {
|
||||
await onUpdated?.(chart.value);
|
||||
}
|
||||
}
|
||||
|
||||
scope.run(() => {
|
||||
watch([width, height], ([newWidth, newHeight]) => {
|
||||
renderChartBySize(newWidth, newHeight);
|
||||
});
|
||||
watch(
|
||||
[width, height],
|
||||
([newWidth, newHeight]) => {
|
||||
renderChartBySize(newWidth, newHeight);
|
||||
},
|
||||
{ flush: 'post' }
|
||||
);
|
||||
|
||||
watch(darkMode, () => {
|
||||
changeTheme();
|
||||
@@ -229,6 +223,7 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
|
||||
|
||||
return {
|
||||
domRef,
|
||||
chart,
|
||||
updateOptions,
|
||||
setOptions
|
||||
};
|
||||
|
@@ -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 { 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 { useBoolean, useHookTable } from '@sa/hooks';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
type TableData = NaiveUI.TableData;
|
||||
type GetTableData<A extends NaiveUI.TableApiFn> = NaiveUI.GetTableData<A>;
|
||||
type TableColumn<T> = NaiveUI.TableColumn<T>;
|
||||
export type UseNaiveTableOptions<ResponseData, ApiData, Pagination extends boolean> = Omit<
|
||||
UseTableOptions<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, Pagination>,
|
||||
'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 appStore = useAppStore();
|
||||
|
||||
const isMobile = computed(() => appStore.isMobile);
|
||||
|
||||
const { apiFn, apiParams, immediate, showTotal } = config;
|
||||
|
||||
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 {
|
||||
data: recordsWithIndex,
|
||||
pageNum: current,
|
||||
pageSize,
|
||||
total
|
||||
};
|
||||
},
|
||||
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 result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
|
||||
...options,
|
||||
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
|
||||
getColumns
|
||||
});
|
||||
|
||||
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 })
|
||||
}
|
||||
: {})
|
||||
// calculate the total width of the table this is used for horizontal scrolling
|
||||
const scrollX = computed(() => {
|
||||
return result.columns.value.reduce((acc, column) => {
|
||||
return acc + Number(column.width ?? column.minWidth ?? 120);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// 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(() => {
|
||||
watch(
|
||||
() => appStore.locale,
|
||||
() => {
|
||||
reloadColumns();
|
||||
result.reloadColumns();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -196,27 +59,126 @@ export function useTable<A extends NaiveUI.TableApiFn>(config: NaiveUI.NaiveTabl
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
empty,
|
||||
data,
|
||||
columns,
|
||||
columnChecks,
|
||||
reloadColumns,
|
||||
pagination,
|
||||
mobilePagination,
|
||||
updatePagination,
|
||||
getData,
|
||||
getDataByPage,
|
||||
searchParams,
|
||||
updateSearchParams,
|
||||
resetSearchParams
|
||||
...result,
|
||||
scrollX
|
||||
};
|
||||
}
|
||||
|
||||
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 operateType = ref<NaiveUI.TableOperateType>('add');
|
||||
const operateType = shallowRef<NaiveUI.TableOperateType>('add');
|
||||
|
||||
function handleAdd() {
|
||||
operateType.value = 'add';
|
||||
@@ -224,18 +186,18 @@ export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>,
|
||||
}
|
||||
|
||||
/** 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';
|
||||
const findItem = data.value.find(item => item.id === id) || null;
|
||||
const findItem = data.value.find(item => item[idKey] === id) || null;
|
||||
editingData.value = jsonClone(findItem);
|
||||
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
/** the checked row keys of table */
|
||||
const checkedRowKeys = ref<string[]>([]);
|
||||
const checkedRowKeys = shallowRef<string[]>([]);
|
||||
|
||||
/** the hook after the batch delete operation is completed */
|
||||
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);
|
||||
}
|
||||
|
@@ -10,7 +10,7 @@ import GlobalTab from '../modules/global-tab/index.vue';
|
||||
import GlobalContent from '../modules/global-content/index.vue';
|
||||
import GlobalFooter from '../modules/global-footer/index.vue';
|
||||
import ThemeDrawer from '../modules/theme-drawer/index.vue';
|
||||
import { setupMixMenuContext } from '../context';
|
||||
import { provideMixMenuContext } from '../modules/global-menu/context';
|
||||
|
||||
defineOptions({
|
||||
name: 'BaseLayout'
|
||||
@@ -18,7 +18,7 @@ defineOptions({
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = setupMixMenuContext();
|
||||
const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = provideMixMenuContext();
|
||||
|
||||
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'));
|
||||
|
||||
@@ -29,7 +29,7 @@ const layoutMode = computed(() => {
|
||||
});
|
||||
|
||||
const headerProps = computed(() => {
|
||||
const { mode, reverseHorizontalMix } = themeStore.layout;
|
||||
const { mode } = themeStore.layout;
|
||||
|
||||
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
|
||||
vertical: {
|
||||
@@ -42,15 +42,25 @@ const headerProps = computed(() => {
|
||||
showMenu: false,
|
||||
showMenuToggler: false
|
||||
},
|
||||
'vertical-hybrid-header-first': {
|
||||
showLogo: !isActiveFirstLevelMenuHasChildren.value,
|
||||
showMenu: true,
|
||||
showMenuToggler: false
|
||||
},
|
||||
horizontal: {
|
||||
showLogo: true,
|
||||
showMenu: true,
|
||||
showMenuToggler: false
|
||||
},
|
||||
'horizontal-mix': {
|
||||
'top-hybrid-sidebar-first': {
|
||||
showLogo: 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 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 siderCollapsedWidth = computed(() => getSiderCollapsedWidth());
|
||||
|
||||
function getSiderWidth() {
|
||||
const { reverseHorizontalMix } = themeStore.layout;
|
||||
const { width, mixWidth, mixChildMenuWidth } = themeStore.sider;
|
||||
function getSiderAndCollapsedWidth(isCollapsed: boolean) {
|
||||
const {
|
||||
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;
|
||||
}
|
||||
|
||||
let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width;
|
||||
|
||||
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
||||
w += mixChildMenuWidth;
|
||||
if (isVerticalHybridHeaderFirst.value && !isActiveFirstLevelMenuHasChildren.value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
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() {
|
||||
const { reverseHorizontalMix } = themeStore.layout;
|
||||
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;
|
||||
return getSiderAndCollapsedWidth(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@@ -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
|
||||
};
|
||||
}
|
@@ -38,7 +38,7 @@ const { isFullscreen, toggle } = useFullscreen();
|
||||
<GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" />
|
||||
</div>
|
||||
<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" />
|
||||
<LangSwitch
|
||||
v-if="themeStore.header.multilingual.visible"
|
||||
|
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { createReusableTemplate } from '@vueuse/core';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import { transformColorWithOpacity } from '@sa/color';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
|
||||
defineOptions({
|
||||
name: 'FirstLevelMenu'
|
||||
@@ -20,7 +21,7 @@ interface Props {
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', menu: App.Global.Menu): boolean;
|
||||
(e: 'select', menuKey: RouteKey): boolean;
|
||||
(e: 'toggleSiderCollapse'): void;
|
||||
}
|
||||
|
||||
@@ -47,8 +48,8 @@ const selectedBgColor = computed(() => {
|
||||
return darkMode ? dark : light;
|
||||
});
|
||||
|
||||
function handleClickMixMenu(menu: App.Global.Menu) {
|
||||
emit('select', menu);
|
||||
function handleClickMixMenu(menuKey: RouteKey) {
|
||||
emit('select', menuKey);
|
||||
}
|
||||
|
||||
function toggleSiderCollapse() {
|
||||
@@ -88,7 +89,7 @@ function toggleSiderCollapse() {
|
||||
:icon="menu.icon"
|
||||
:active="menu.key === activeMenuKey"
|
||||
:is-mini="siderCollapse"
|
||||
@click="handleClickMixMenu(menu)"
|
||||
@click="handleClickMixMenu(menu.routeKey)"
|
||||
/>
|
||||
</SimpleScrollbar>
|
||||
<MenuToggler
|
||||
|
143
src/layouts/modules/global-menu/context/index.ts
Normal file
143
src/layouts/modules/global-menu/context/index.ts
Normal 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
|
||||
};
|
||||
}
|
@@ -5,9 +5,10 @@ import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import VerticalMenu from './modules/vertical-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 HorizontalMixMenu from './modules/horizontal-mix-menu.vue';
|
||||
import ReversedHorizontalMixMenu from './modules/reversed-horizontal-mix-menu.vue';
|
||||
import TopHybridSidebarFirst from './modules/top-hybrid-sidebar-first.vue';
|
||||
import TopHybridHeaderFirst from './modules/top-hybrid-header-first.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalMenu'
|
||||
@@ -20,8 +21,10 @@ const activeMenu = computed(() => {
|
||||
const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = {
|
||||
vertical: VerticalMenu,
|
||||
'vertical-mix': VerticalMixMenu,
|
||||
'vertical-hybrid-header-first': VerticalHybridHeaderFirst,
|
||||
horizontal: HorizontalMenu,
|
||||
'horizontal-mix': themeStore.layout.reverseHorizontalMix ? ReversedHorizontalMixMenu : HorizontalMixMenu
|
||||
'top-hybrid-sidebar-first': TopHybridSidebarFirst,
|
||||
'top-hybrid-header-first': TopHybridHeaderFirst
|
||||
};
|
||||
|
||||
return menuMap[themeStore.layout.mode];
|
||||
|
@@ -2,7 +2,7 @@
|
||||
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useMenu } from '../../../context';
|
||||
import { useMenu } from '../context';
|
||||
|
||||
defineOptions({
|
||||
name: 'HorizontalMenu'
|
||||
|
@@ -1,17 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
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 { useMenu, useMixMenuContext } from '../context';
|
||||
|
||||
defineOptions({
|
||||
name: 'ReversedHorizontalMixMenu'
|
||||
name: 'TopHybridHeaderFirst'
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
@@ -19,23 +18,10 @@ const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const {
|
||||
firstLevelMenus,
|
||||
childLevelMenus,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
isActiveFirstLevelMenuHasChildren
|
||||
} = useMixMenuContext();
|
||||
const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } =
|
||||
useMixMenuContext('TopHybridHeaderFirst');
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
function handleSelectMixMenu(key: RouteKey) {
|
||||
setActiveFirstLevelMenuKey(key);
|
||||
|
||||
if (!isActiveFirstLevelMenuHasChildren.value) {
|
||||
routerPushByKeyWithMetaQuery(key);
|
||||
}
|
||||
}
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
|
||||
function updateExpandedKeys() {
|
||||
@@ -63,7 +49,7 @@ watch(
|
||||
:options="firstLevelMenus"
|
||||
:indent="18"
|
||||
responsive
|
||||
@update:value="handleSelectMixMenu"
|
||||
@update:value="handleSelectFirstLevelMenu"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
@@ -75,7 +61,7 @@ watch(
|
||||
:collapsed="appStore.siderCollapse"
|
||||
:collapsed-width="themeStore.sider.collapsedWidth"
|
||||
:collapsed-icon-size="22"
|
||||
:options="childLevelMenus"
|
||||
:options="secondLevelMenus"
|
||||
:indent="18"
|
||||
@update:value="routerPushByKeyWithMetaQuery"
|
||||
/>
|
@@ -4,25 +4,18 @@ import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
import { useMenu, useMixMenuContext } from '../../../context';
|
||||
import { useMenu, useMixMenuContext } from '../context';
|
||||
|
||||
defineOptions({
|
||||
name: 'HorizontalMixMenu'
|
||||
name: 'TopHybridSidebarFirst'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { allMenus, childLevelMenus, activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext();
|
||||
const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } =
|
||||
useMixMenuContext('TopHybridSidebarFirst');
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
function handleSelectMixMenu(menu: App.Global.Menu) {
|
||||
setActiveFirstLevelMenuKey(menu.key);
|
||||
|
||||
if (!menu.children?.length) {
|
||||
routerPushByKeyWithMetaQuery(menu.routeKey);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -30,22 +23,24 @@ function handleSelectMixMenu(menu: App.Global.Menu) {
|
||||
<NMenu
|
||||
mode="horizontal"
|
||||
:value="selectedKey"
|
||||
:options="childLevelMenus"
|
||||
:options="secondLevelMenus"
|
||||
:indent="18"
|
||||
responsive
|
||||
@update:value="routerPushByKeyWithMetaQuery"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<FirstLevelMenu
|
||||
:menus="allMenus"
|
||||
:active-menu-key="activeFirstLevelMenuKey"
|
||||
:sider-collapse="appStore.siderCollapse"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:theme-color="themeStore.themeColor"
|
||||
@select="handleSelectMixMenu"
|
||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
||||
/>
|
||||
<div class="h-full pt-2">
|
||||
<FirstLevelMenu
|
||||
:menus="firstLevelMenus"
|
||||
:active-menu-key="activeFirstLevelMenuKey"
|
||||
:sider-collapse="appStore.siderCollapse"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:theme-color="themeStore.themeColor"
|
||||
@select="handleSelectFirstLevelMenu"
|
||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
@@ -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>
|
@@ -7,7 +7,7 @@ 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 } from '../../../context';
|
||||
import { useMenu } from '../context';
|
||||
|
||||
defineOptions({
|
||||
name: 'VerticalMenu'
|
||||
|
@@ -3,13 +3,14 @@ import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { 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 { $t } from '@/locales';
|
||||
import { useMenu, useMixMenuContext } from '../../../context';
|
||||
import { useMenu, useMixMenuContext } from '../context';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
import GlobalLogo from '../../global-logo/index.vue';
|
||||
|
||||
@@ -24,28 +25,26 @@ const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
|
||||
const {
|
||||
allMenus,
|
||||
childLevelMenus,
|
||||
firstLevelMenus,
|
||||
secondLevelMenus,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
getActiveFirstLevelMenuKey
|
||||
//
|
||||
} = useMixMenuContext();
|
||||
isActiveFirstLevelMenuHasChildren,
|
||||
getActiveFirstLevelMenuKey,
|
||||
handleSelectFirstLevelMenu
|
||||
} = useMixMenuContext('VerticalMixMenu');
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
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));
|
||||
|
||||
function handleSelectMixMenu(menu: App.Global.Menu) {
|
||||
setActiveFirstLevelMenuKey(menu.key);
|
||||
function handleSelectMenu(key: RouteKey) {
|
||||
handleSelectFirstLevelMenu(key);
|
||||
|
||||
if (menu.children?.length) {
|
||||
if (isActiveFirstLevelMenuHasChildren.value) {
|
||||
setDrawerVisible(true);
|
||||
} else {
|
||||
routerPushByKeyWithMetaQuery(menu.routeKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,13 +79,13 @@ watch(
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
|
||||
<FirstLevelMenu
|
||||
:menus="allMenus"
|
||||
:menus="firstLevelMenus"
|
||||
:active-menu-key="activeFirstLevelMenuKey"
|
||||
:inverted="inverted"
|
||||
:sider-collapse="appStore.siderCollapse"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:theme-color="themeStore.themeColor"
|
||||
@select="handleSelectMixMenu"
|
||||
@select="handleSelectMenu"
|
||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
||||
>
|
||||
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
|
||||
@@ -113,7 +112,7 @@ watch(
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
mode="vertical"
|
||||
:value="selectedKey"
|
||||
:options="childLevelMenus"
|
||||
:options="secondLevelMenus"
|
||||
:inverted="inverted"
|
||||
:indent="18"
|
||||
@update:value="routerPushByKeyWithMetaQuery"
|
||||
|
@@ -12,10 +12,13 @@ defineOptions({
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
|
||||
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
|
||||
const darkMenu = computed(() => !themeStore.darkMode && !isHorizontalMix.value && themeStore.sider.inverted);
|
||||
const showLogo = computed(() => !isVerticalMix.value && !isHorizontalMix.value);
|
||||
const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first');
|
||||
const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first');
|
||||
const darkMenu = computed(
|
||||
() =>
|
||||
!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'));
|
||||
</script>
|
||||
|
||||
|
@@ -5,7 +5,6 @@ import { useElementBounding } from '@vueuse/core';
|
||||
import { PageTab } from '@sa/materials';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useTabStore } from '@/store/modules/tab';
|
||||
import { isPC } from '@/utils/agent';
|
||||
import BetterScroll from '@/components/custom/better-scroll.vue';
|
||||
@@ -18,7 +17,6 @@ defineOptions({
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const tabStore = useTabStore();
|
||||
|
||||
const bsWrapper = ref<HTMLElement>();
|
||||
@@ -82,12 +80,8 @@ function getContextMenuDisabledKeys(tabId: string) {
|
||||
return disabledKeys;
|
||||
}
|
||||
|
||||
async function handleCloseTab(tab: App.Global.Tab) {
|
||||
await tabStore.removeTab(tab.id);
|
||||
|
||||
if (themeStore.resetCacheStrategy === 'close') {
|
||||
routeStore.resetRouteCache(tab.routeKey);
|
||||
}
|
||||
function handleCloseTab(tab: App.Global.Tab) {
|
||||
tabStore.removeTab(tab.id);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
|
@@ -27,7 +27,6 @@ type LayoutConfig = Record<
|
||||
UnionKey.ThemeLayoutMode,
|
||||
{
|
||||
placement: PopoverPlacement;
|
||||
headerClass: string;
|
||||
menuClass: string;
|
||||
mainClass: string;
|
||||
}
|
||||
@@ -36,25 +35,31 @@ type LayoutConfig = Record<
|
||||
const layoutConfig: LayoutConfig = {
|
||||
vertical: {
|
||||
placement: 'bottom',
|
||||
headerClass: '',
|
||||
menuClass: 'w-1/3 h-full',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
},
|
||||
'vertical-mix': {
|
||||
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',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
},
|
||||
horizontal: {
|
||||
placement: 'bottom',
|
||||
headerClass: '',
|
||||
menuClass: 'w-full h-1/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',
|
||||
headerClass: '',
|
||||
menuClass: 'w-full h-1/4',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
}
|
||||
@@ -68,25 +73,27 @@ function handleChangeMode(mode: UnionKey.ThemeLayoutMode) {
|
||||
</script>
|
||||
|
||||
<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
|
||||
v-for="(item, key) in layoutConfig"
|
||||
:key="key"
|
||||
class="flex cursor-pointer border-2px rounded-6px hover:border-primary"
|
||||
:class="[mode === key ? 'border-primary' : 'border-transparent']"
|
||||
class="flex-col-center cursor-pointer"
|
||||
@click="handleChangeMode(key)"
|
||||
>
|
||||
<NTooltip :placement="item.placement">
|
||||
<IconTooltip :placement="item.placement">
|
||||
<template #trigger>
|
||||
<div
|
||||
class="h-64px w-96px gap-6px rd-4px p-6px shadow dark:shadow-coolGray-5"
|
||||
:class="[key.includes('vertical') ? 'flex' : 'flex-col']"
|
||||
class="h-64px w-96px gap-6px rd-4px p-6px shadow ring-2 ring-transparent transition-all hover:ring-primary"
|
||||
:class="{ '!ring-primary': mode === key }"
|
||||
>
|
||||
<slot :name="key"></slot>
|
||||
<div class="h-full w-full gap-1" :class="[key.includes('vertical') ? 'flex' : 'flex-col']">
|
||||
<slot :name="key"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
{{ $t(themeLayoutModeRecord[key]) }}
|
||||
</NTooltip>
|
||||
{{ $t(`theme.layout.layoutMode.${key}_detail`) }}
|
||||
</IconTooltip>
|
||||
<p class="mt-8px text-12px">{{ $t(themeLayoutModeRecord[key]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@@ -13,7 +13,7 @@ defineProps<Props>();
|
||||
|
||||
<template>
|
||||
<div class="w-full flex-y-center justify-between">
|
||||
<div>
|
||||
<div class="flex-y-center">
|
||||
<span class="pr-8px text-base-text">{{ label }}</span>
|
||||
<slot name="suffix"></slot>
|
||||
</div>
|
||||
|
@@ -1,26 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
import DarkMode from './modules/dark-mode.vue';
|
||||
import LayoutMode from './modules/layout-mode.vue';
|
||||
import ThemeColor from './modules/theme-color.vue';
|
||||
import PageFun from './modules/page-fun.vue';
|
||||
import AppearanceSettings from './modules/appearance/index.vue';
|
||||
import LayoutSettings from './modules/layout/index.vue';
|
||||
import GeneralSettings from './modules/general/index.vue';
|
||||
import ConfigOperation from './modules/config-operation.vue';
|
||||
import PresetSettings from './modules/preset/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ThemeDrawer'
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
<DarkMode />
|
||||
<LayoutMode />
|
||||
<ThemeColor />
|
||||
<PageFun />
|
||||
<NTabs v-model:value="activeTab" type="segment" size="medium" class="mb-16px">
|
||||
<NTab name="appearance" :tab="$t('theme.tabs.appearance')"></NTab>
|
||||
<NTab name="layout" :tab="$t('theme.tabs.layout')"></NTab>
|
||||
<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>
|
||||
<ConfigOperation />
|
||||
</template>
|
||||
@@ -28,4 +53,14 @@ const appStore = useAppStore();
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
:deep(.n-tab) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.n-tab-pane) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
@@ -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>
|
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../components/setting-item.vue';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ThemeColor'
|
||||
@@ -34,33 +34,38 @@ const swatches: string[] = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.themeColor.title') }}</NDivider>
|
||||
<NDivider>{{ $t('theme.appearance.themeColor.title') }}</NDivider>
|
||||
<div class="flex-col-stretch gap-12px">
|
||||
<NTooltip placement="top-start">
|
||||
<template #trigger>
|
||||
<SettingItem key="recommend-color" :label="$t('theme.recommendColor')">
|
||||
<NSwitch v-model:value="themeStore.recommendColor" />
|
||||
</SettingItem>
|
||||
<SettingItem key="recommend-color" :label="$t('theme.appearance.recommendColor')">
|
||||
<template #suffix>
|
||||
<IconTooltip>
|
||||
<p>
|
||||
<span class="pr-12px">{{ $t('theme.appearance.recommendColorDesc') }}</span>
|
||||
<br />
|
||||
<NButton
|
||||
text
|
||||
tag="a"
|
||||
href="https://uicolors.app/create"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-gray"
|
||||
>
|
||||
https://uicolors.app/create
|
||||
</NButton>
|
||||
</p>
|
||||
</IconTooltip>
|
||||
</template>
|
||||
<p>
|
||||
<span class="pr-12px">{{ $t('theme.recommendColorDesc') }}</span>
|
||||
<br />
|
||||
<NButton
|
||||
text
|
||||
tag="a"
|
||||
href="https://uicolors.app/create"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-gray"
|
||||
>
|
||||
https://uicolors.app/create
|
||||
</NButton>
|
||||
</p>
|
||||
</NTooltip>
|
||||
<SettingItem v-for="(_, key) in themeStore.themeColors" :key="key" :label="$t(`theme.themeColor.${key}`)">
|
||||
<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>
|
||||
<NCheckbox v-model:checked="themeStore.isInfoFollowPrimary">
|
||||
{{ $t('theme.themeColor.followPrimary') }}
|
||||
{{ $t('theme.appearance.themeColor.followPrimary') }}
|
||||
</NCheckbox>
|
||||
</template>
|
||||
<NColorPicker
|
@@ -3,10 +3,10 @@ import { computed } from 'vue';
|
||||
import { themeSchemaRecord } from '@/constants/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../components/setting-item.vue';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'DarkMode'
|
||||
name: 'ThemeSchema'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
@@ -33,7 +33,7 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.themeSchema.title') }}</NDivider>
|
||||
<NDivider>{{ $t('theme.appearance.themeSchema.title') }}</NDivider>
|
||||
<div class="flex-col-stretch gap-16px">
|
||||
<div class="i-flex-center">
|
||||
<NTabs
|
||||
@@ -50,14 +50,14 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
|
||||
</NTabs>
|
||||
</div>
|
||||
<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" />
|
||||
</SettingItem>
|
||||
</Transition>
|
||||
<SettingItem :label="$t('theme.grayscale')">
|
||||
<SettingItem :label="$t('theme.appearance.grayscale')">
|
||||
<NSwitch :value="themeStore.grayscale" @update:value="handleGrayscaleChange" />
|
||||
</SettingItem>
|
||||
<SettingItem :label="$t('theme.colourWeakness')">
|
||||
<SettingItem :label="$t('theme.appearance.colourWeakness')">
|
||||
<NSwitch :value="themeStore.colourWeakness" @update:value="handleColourWeaknessChange" />
|
||||
</SettingItem>
|
||||
</div>
|
17
src/layouts/modules/theme-drawer/modules/general/index.vue
Normal file
17
src/layouts/modules/theme-drawer/modules/general/index.vue
Normal 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>
|
@@ -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>
|
@@ -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>
|
29
src/layouts/modules/theme-drawer/modules/layout/index.vue
Normal file
29
src/layouts/modules/theme-drawer/modules/layout/index.vue
Normal 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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -2,8 +2,7 @@
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import LayoutModeCard from '../components/layout-mode-card.vue';
|
||||
import SettingItem from '../components/setting-item.vue';
|
||||
import LayoutModeCard from '../../../components/layout-mode-card.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'LayoutMode'
|
||||
@@ -11,56 +10,60 @@ defineOptions({
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
function handleReverseHorizontalMixChange(value: boolean) {
|
||||
themeStore.setLayoutReverseHorizontalMix(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.layoutMode.title') }}</NDivider>
|
||||
<NDivider>{{ $t('theme.layout.layoutMode.title') }}</NDivider>
|
||||
<LayoutModeCard v-model:mode="themeStore.layout.mode" :disabled="appStore.isMobile">
|
||||
<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="layout-header"></div>
|
||||
<div class="layout-header bg-primary-200"></div>
|
||||
<div class="layout-main"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #vertical-mix>
|
||||
<div class="layout-sider h-full w-8px"></div>
|
||||
<div class="layout-sider h-full w-16px"></div>
|
||||
<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"></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>
|
||||
</template>
|
||||
<template #horizontal>
|
||||
<div class="layout-header"></div>
|
||||
<div class="layout-header !bg-primary"></div>
|
||||
<div class="horizontal-wrapper">
|
||||
<div class="layout-main"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #horizontal-mix>
|
||||
<div class="layout-header"></div>
|
||||
<template #top-hybrid-sidebar-first>
|
||||
<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="layout-sider w-18px"></div>
|
||||
<div class="layout-main"></div>
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
.layout-header {
|
||||
--uno: h-16px bg-primary rd-4px;
|
||||
--uno: h-16px rd-4px;
|
||||
}
|
||||
|
||||
.layout-sider {
|
@@ -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>
|
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { resetCacheStrategyOptions, 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="0" :label="$t('theme.layout.resetCacheStrategy.title')">
|
||||
<NSelect
|
||||
v-model:value="themeStore.resetCacheStrategy"
|
||||
:options="translateOptions(resetCacheStrategyOptions)"
|
||||
size="small"
|
||||
class="w-120px"
|
||||
/>
|
||||
</SettingItem>
|
||||
<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>
|
@@ -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>
|
15
src/layouts/modules/theme-drawer/modules/preset/index.vue
Normal file
15
src/layouts/modules/theme-drawer/modules/preset/index.vue
Normal 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>
|
@@ -0,0 +1,148 @@
|
||||
<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'
|
||||
| 'resetCacheStrategy'
|
||||
| '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>
|
@@ -58,97 +58,159 @@ const local: App.I18n.Schema = {
|
||||
tokenExpired: 'The requested token has expired'
|
||||
},
|
||||
theme: {
|
||||
themeSchema: {
|
||||
title: 'Theme Schema',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
auto: 'Follow System'
|
||||
themeDrawerTitle: 'Theme Configuration',
|
||||
tabs: {
|
||||
appearance: 'Appearance',
|
||||
layout: 'Layout',
|
||||
general: 'General',
|
||||
preset: 'Preset'
|
||||
},
|
||||
grayscale: 'Grayscale',
|
||||
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: {
|
||||
title: 'Theme Color',
|
||||
primary: 'Primary',
|
||||
info: 'Info',
|
||||
success: 'Success',
|
||||
warning: 'Warning',
|
||||
error: 'Error',
|
||||
followPrimary: 'Follow Primary'
|
||||
},
|
||||
scrollMode: {
|
||||
title: 'Scroll Mode',
|
||||
wrapper: 'Wrapper',
|
||||
content: 'Content'
|
||||
},
|
||||
page: {
|
||||
animate: 'Page Animate',
|
||||
mode: {
|
||||
title: 'Page Animate Mode',
|
||||
fade: 'Fade',
|
||||
'fade-slide': 'Slide',
|
||||
'fade-bottom': 'Fade Zoom',
|
||||
'fade-scale': 'Fade Scale',
|
||||
'zoom-fade': 'Zoom Fade',
|
||||
'zoom-out': 'Zoom Out',
|
||||
none: 'None'
|
||||
appearance: {
|
||||
themeSchema: {
|
||||
title: 'Theme Schema',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
auto: 'Follow System'
|
||||
},
|
||||
grayscale: 'Grayscale',
|
||||
colourWeakness: 'Colour Weakness',
|
||||
themeColor: {
|
||||
title: 'Theme Color',
|
||||
primary: 'Primary',
|
||||
info: 'Info',
|
||||
success: 'Success',
|
||||
warning: 'Warning',
|
||||
error: 'Error',
|
||||
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'
|
||||
}
|
||||
}
|
||||
},
|
||||
fixedHeaderAndTab: 'Fixed Header And Tab',
|
||||
header: {
|
||||
height: 'Header Height',
|
||||
breadcrumb: {
|
||||
visible: 'Breadcrumb Visible',
|
||||
showIcon: 'Breadcrumb Icon Visible'
|
||||
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',
|
||||
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: {
|
||||
title: 'Scroll Mode',
|
||||
tip: 'The theme scroll only scrolls the main part, the outer scroll can carry the header and footer together',
|
||||
wrapper: 'Wrapper',
|
||||
content: 'Content'
|
||||
},
|
||||
page: {
|
||||
animate: 'Page Animate',
|
||||
mode: {
|
||||
title: 'Page Animate Mode',
|
||||
fade: 'Fade',
|
||||
'fade-slide': 'Slide',
|
||||
'fade-bottom': 'Fade Zoom',
|
||||
'fade-scale': 'Fade Scale',
|
||||
'zoom-fade': 'Zoom Fade',
|
||||
'zoom-out': 'Zoom Out',
|
||||
none: 'None'
|
||||
}
|
||||
},
|
||||
fixedHeaderAndTab: 'Fixed Header And Tab'
|
||||
},
|
||||
resetCacheStrategy: {
|
||||
title: 'Reset Cache Strategy',
|
||||
close: 'Close Page',
|
||||
refresh: 'Refresh Page'
|
||||
}
|
||||
},
|
||||
general: {
|
||||
title: 'General Settings',
|
||||
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: {
|
||||
title: 'Multilingual Settings',
|
||||
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: {
|
||||
copyConfig: 'Copy Config',
|
||||
copySuccessMsg: 'Copy Success, Please replace the variable "themeSettings" in "src/theme/settings.ts"',
|
||||
@@ -166,6 +228,7 @@ const local: App.I18n.Schema = {
|
||||
document: 'Document',
|
||||
document_project: 'Project Document',
|
||||
'document_project-link': 'Project Document(External Link)',
|
||||
document_video: 'Video Tutorial',
|
||||
document_vue: 'Vue Document',
|
||||
document_vite: 'Vite Document',
|
||||
document_unocss: 'UnoCSS Document',
|
||||
@@ -178,7 +241,6 @@ const local: App.I18n.Schema = {
|
||||
function: 'System Function',
|
||||
alova: 'Alova Example',
|
||||
alova_request: 'Alova Request',
|
||||
alova_user: 'User List',
|
||||
alova_scenes: 'Scenario Request',
|
||||
'pro-naive': 'Pro Naive Example',
|
||||
'pro-naive_form': 'Form',
|
||||
@@ -286,7 +348,7 @@ const local: App.I18n.Schema = {
|
||||
},
|
||||
about: {
|
||||
title: 'About',
|
||||
introduction: `SoybeanAdmin is an elegant 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.`,
|
||||
introduction: `SoybeanAdmin is an elegant 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.`,
|
||||
projectInfo: {
|
||||
title: 'Project Info',
|
||||
version: 'Version',
|
||||
|
@@ -58,97 +58,156 @@ const local: App.I18n.Schema = {
|
||||
tokenExpired: 'token已过期'
|
||||
},
|
||||
theme: {
|
||||
themeSchema: {
|
||||
title: '主题模式',
|
||||
light: '亮色模式',
|
||||
dark: '暗黑模式',
|
||||
auto: '跟随系统'
|
||||
themeDrawerTitle: '主题配置',
|
||||
tabs: {
|
||||
appearance: '外观',
|
||||
layout: '布局',
|
||||
general: '通用',
|
||||
preset: '预设'
|
||||
},
|
||||
grayscale: '灰色模式',
|
||||
colourWeakness: '色弱模式',
|
||||
layoutMode: {
|
||||
title: '布局模式',
|
||||
vertical: '左侧菜单模式',
|
||||
'vertical-mix': '左侧菜单混合模式',
|
||||
horizontal: '顶部菜单模式',
|
||||
'horizontal-mix': '顶部菜单混合模式',
|
||||
reverseHorizontalMix: '一级菜单与子级菜单位置反转'
|
||||
},
|
||||
recommendColor: '应用推荐算法的颜色',
|
||||
recommendColorDesc: '推荐颜色的算法参照',
|
||||
themeColor: {
|
||||
title: '主题颜色',
|
||||
primary: '主色',
|
||||
info: '信息色',
|
||||
success: '成功色',
|
||||
warning: '警告色',
|
||||
error: '错误色',
|
||||
followPrimary: '跟随主色'
|
||||
},
|
||||
scrollMode: {
|
||||
title: '滚动模式',
|
||||
wrapper: '外层滚动',
|
||||
content: '主体滚动'
|
||||
},
|
||||
page: {
|
||||
animate: '页面切换动画',
|
||||
mode: {
|
||||
title: '页面切换动画类型',
|
||||
'fade-slide': '滑动',
|
||||
fade: '淡入淡出',
|
||||
'fade-bottom': '底部消退',
|
||||
'fade-scale': '缩放消退',
|
||||
'zoom-fade': '渐变',
|
||||
'zoom-out': '闪现',
|
||||
none: '无'
|
||||
appearance: {
|
||||
themeSchema: {
|
||||
title: '主题模式',
|
||||
light: '亮色模式',
|
||||
dark: '暗黑模式',
|
||||
auto: '跟随系统'
|
||||
},
|
||||
grayscale: '灰色模式',
|
||||
colourWeakness: '色弱模式',
|
||||
themeColor: {
|
||||
title: '主题颜色',
|
||||
primary: '主色',
|
||||
info: '信息色',
|
||||
success: '成功色',
|
||||
warning: '警告色',
|
||||
error: '错误色',
|
||||
followPrimary: '跟随主色'
|
||||
},
|
||||
recommendColor: '应用推荐算法的颜色',
|
||||
recommendColorDesc: '推荐颜色的算法参照',
|
||||
preset: {
|
||||
title: '主题预设',
|
||||
apply: '应用',
|
||||
applySuccess: '预设应用成功',
|
||||
default: {
|
||||
name: '默认预设',
|
||||
desc: 'Soybean 默认主题预设'
|
||||
},
|
||||
dark: {
|
||||
name: '暗色预设',
|
||||
desc: '适用于夜间使用的暗色主题预设'
|
||||
},
|
||||
compact: {
|
||||
name: '紧凑型',
|
||||
desc: '适用于小屏幕的紧凑布局预设'
|
||||
},
|
||||
azir: {
|
||||
name: 'Azir的预设',
|
||||
desc: '是 Azir 比较喜欢的莫兰迪色系冷淡风'
|
||||
}
|
||||
}
|
||||
},
|
||||
fixedHeaderAndTab: '固定头部和标签栏',
|
||||
header: {
|
||||
height: '头部高度',
|
||||
breadcrumb: {
|
||||
visible: '显示面包屑',
|
||||
showIcon: '显示面包屑图标'
|
||||
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: '标签栏风格',
|
||||
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: {
|
||||
title: '滚动模式',
|
||||
tip: '主题滚动仅 main 部分滚动,外层滚动可携带头部底部一起滚动',
|
||||
wrapper: '外层滚动',
|
||||
content: '主体滚动'
|
||||
},
|
||||
page: {
|
||||
animate: '页面切换动画',
|
||||
mode: {
|
||||
title: '页面切换动画类型',
|
||||
'fade-slide': '滑动',
|
||||
fade: '淡入淡出',
|
||||
'fade-bottom': '底部消退',
|
||||
'fade-scale': '缩放消退',
|
||||
'zoom-fade': '渐变',
|
||||
'zoom-out': '闪现',
|
||||
none: '无'
|
||||
}
|
||||
},
|
||||
fixedHeaderAndTab: '固定头部和标签栏'
|
||||
},
|
||||
resetCacheStrategy: {
|
||||
title: '重置缓存策略',
|
||||
close: '关闭页面',
|
||||
refresh: '刷新页面'
|
||||
}
|
||||
},
|
||||
general: {
|
||||
title: '通用设置',
|
||||
watermark: {
|
||||
title: '水印设置',
|
||||
visible: '显示全屏水印',
|
||||
text: '自定义水印文本',
|
||||
enableUserName: '启用用户名水印',
|
||||
enableTime: '显示当前时间',
|
||||
timeFormat: '时间格式'
|
||||
},
|
||||
multilingual: {
|
||||
title: '多语言设置',
|
||||
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: {
|
||||
copyConfig: '复制配置',
|
||||
copySuccessMsg: '复制成功,请替换 src/theme/settings.ts 中的变量 themeSettings',
|
||||
@@ -166,6 +225,7 @@ const local: App.I18n.Schema = {
|
||||
document: '文档',
|
||||
document_project: '项目文档',
|
||||
'document_project-link': '项目文档(外链)',
|
||||
document_video: '视频教程',
|
||||
document_vue: 'Vue文档',
|
||||
document_vite: 'Vite文档',
|
||||
document_unocss: 'UnoCSS文档',
|
||||
@@ -178,7 +238,6 @@ const local: App.I18n.Schema = {
|
||||
function: '系统功能',
|
||||
alova: 'alova示例',
|
||||
alova_request: 'alova请求',
|
||||
alova_user: '用户列表',
|
||||
alova_scenes: '场景化请求',
|
||||
'pro-naive': 'Pro Naive UI 示例',
|
||||
'pro-naive_form': '表单',
|
||||
@@ -286,7 +345,7 @@ const local: App.I18n.Schema = {
|
||||
},
|
||||
about: {
|
||||
title: '关于',
|
||||
introduction: `SoybeanAdmin 是一个优雅且功能强大的后台管理模板,基于最新的前端技术栈,包括 Vue3, Vite5, TypeScript, Pinia 和 UnoCSS。它内置了丰富的主题配置和组件,代码规范严谨,实现了自动化的文件路由系统。此外,它还采用了基于 ApiFox 的在线Mock数据方案。SoybeanAdmin 为您提供了一站式的后台管理解决方案,无需额外配置,开箱即用。同样是一个快速学习前沿技术的最佳实践。`,
|
||||
introduction: `SoybeanAdmin 是一个优雅且功能强大的后台管理模板,基于最新的前端技术栈,包括 Vue3, Vite7, TypeScript, Pinia 和 UnoCSS。它内置了丰富的主题配置和组件,代码规范严谨,实现了自动化的文件路由系统。此外,它还采用了基于 ApiFox 的在线Mock数据方案。SoybeanAdmin 为您提供了一站式的后台管理解决方案,无需额外配置,开箱即用。同样是一个快速学习前沿技术的最佳实践。`,
|
||||
projectInfo: {
|
||||
title: '项目信息',
|
||||
version: '版本',
|
||||
|
@@ -25,8 +25,8 @@ export function setupAppVersionNotification() {
|
||||
|
||||
const buildTime = await getHtmlBuildTime();
|
||||
|
||||
// If build time hasn't changed, no update is needed
|
||||
if (buildTime === BUILD_TIME) {
|
||||
// If failed to get build time or build time hasn't changed, no update is needed.
|
||||
if (!buildTime || buildTime === BUILD_TIME) {
|
||||
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 res = await fetch(`${baseUrl}index.html?time=${Date.now()}`);
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}index.html?time=${Date.now()}`);
|
||||
|
||||
const html = await res.text();
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = html.match(/<meta name="buildTime" content="(.*)">/);
|
||||
|
||||
const buildTime = match?.[1] || '';
|
||||
|
||||
return buildTime;
|
||||
const html = await res.text();
|
||||
const match = html.match(/<meta name="buildTime" content="(.*)">/);
|
||||
return match?.[1] || null;
|
||||
} catch (error) {
|
||||
window.console.error('getHtmlBuildTime error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { addAPIProvider, disableCache } from '@iconify/vue';
|
||||
import { addAPIProvider } from '@iconify/vue';
|
||||
|
||||
/** Setup the iconify offline */
|
||||
export function setupIconifyOffline() {
|
||||
@@ -6,7 +6,5 @@ export function setupIconifyOffline() {
|
||||
|
||||
if (VITE_ICONIFY_URL) {
|
||||
addAPIProvider('', { resources: [VITE_ICONIFY_URL] });
|
||||
|
||||
disableCache('all');
|
||||
}
|
||||
}
|
||||
|
@@ -23,7 +23,6 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
about: () => import("@/views/about/index.vue"),
|
||||
alova_request: () => import("@/views/alova/request/index.vue"),
|
||||
alova_scenes: () => import("@/views/alova/scenes/index.vue"),
|
||||
alova_user: () => import("@/views/alova/user/index.vue"),
|
||||
"function_hide-child_one": () => import("@/views/function/hide-child/one/index.vue"),
|
||||
"function_hide-child_three": () => import("@/views/function/hide-child/three/index.vue"),
|
||||
"function_hide-child_two": () => import("@/views/function/hide-child/two/index.vue"),
|
||||
|
@@ -81,17 +81,6 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
icon: 'cbi:scene-dynamic',
|
||||
order: 3
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'alova_user',
|
||||
path: '/alova/user',
|
||||
component: 'view.alova_user',
|
||||
meta: {
|
||||
title: 'alova_user',
|
||||
i18nKey: 'route.alova_user',
|
||||
icon: 'carbon:user-multiple',
|
||||
order: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@@ -170,6 +170,7 @@ const routeMap: RouteMap = {
|
||||
"document": "/document",
|
||||
"document_project": "/document/project",
|
||||
"document_project-link": "/document/project-link",
|
||||
"document_video": "/document/video",
|
||||
"document_vue": "/document/vue",
|
||||
"document_vite": "/document/vite",
|
||||
"document_unocss": "/document/unocss",
|
||||
@@ -184,7 +185,6 @@ const routeMap: RouteMap = {
|
||||
"alova": "/alova",
|
||||
"alova_request": "/alova/request",
|
||||
"alova_scenes": "/alova/scenes",
|
||||
"alova_user": "/alova/user",
|
||||
"function": "/function",
|
||||
"function_hide-child": "/function/hide-child",
|
||||
"function_hide-child_one": "/function/hide-child/one",
|
||||
|
@@ -145,6 +145,18 @@ const customRoutes: CustomRoute[] = [
|
||||
href: 'https://docs.soybeanjs.cn/zh'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'document_video',
|
||||
path: '/document/video',
|
||||
component: 'view.iframe-page',
|
||||
meta: {
|
||||
title: 'document_video',
|
||||
i18nKey: 'route.document_video',
|
||||
order: 2,
|
||||
localIcon: 'logo',
|
||||
href: 'https://www.bilibili.com/video/BV1YKdRYXELC'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'document_unocss',
|
||||
path: '/document/unocss',
|
||||
|
@@ -2,8 +2,8 @@ import { createAlovaRequest } from '@sa/alova';
|
||||
import { createAlovaMockAdapter } from '@sa/alova/mock';
|
||||
import adapterFetch from '@sa/alova/fetch';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { $t } from '@/locales';
|
||||
import { getServiceBaseURL } from '@/utils/service';
|
||||
import { $t } from '@/locales';
|
||||
import featureUsers20241014 from '../mocks/feature-users-20241014';
|
||||
import { getAuthorization, handleRefreshToken, showErrorMsg } from './shared';
|
||||
import type { RequestInstanceState } from './type';
|
||||
|
@@ -10,7 +10,7 @@ import type { RequestInstanceState } from './type';
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
||||
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
||||
|
||||
export const request = createFlatRequest<App.Service.Response, RequestInstanceState>(
|
||||
export const request = createFlatRequest(
|
||||
{
|
||||
baseURL,
|
||||
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) {
|
||||
const Authorization = getAuthorization();
|
||||
Object.assign(config.headers, { Authorization });
|
||||
@@ -91,9 +98,6 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
|
||||
|
||||
return null;
|
||||
},
|
||||
transformBackendResponse(response) {
|
||||
return response.data.data;
|
||||
},
|
||||
onError(error) {
|
||||
// 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
|
||||
},
|
||||
{
|
||||
transform(response: AxiosResponse<App.Service.DemoResponse>) {
|
||||
return response.data.result;
|
||||
},
|
||||
async onRequest(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
|
||||
// for example: the token is expired, refresh token and retry request
|
||||
},
|
||||
transformBackendResponse(response) {
|
||||
return response.data.result;
|
||||
},
|
||||
onError(error) {
|
||||
// when the request is fail, you can show error message
|
||||
|
||||
|
@@ -28,14 +28,14 @@ async function handleRefreshToken() {
|
||||
}
|
||||
|
||||
export async function handleExpiredRequest(state: RequestInstanceState) {
|
||||
if (!state.refreshTokenFn) {
|
||||
state.refreshTokenFn = handleRefreshToken();
|
||||
if (!state.refreshTokenPromise) {
|
||||
state.refreshTokenPromise = handleRefreshToken();
|
||||
}
|
||||
|
||||
const success = await state.refreshTokenFn;
|
||||
const success = await state.refreshTokenPromise;
|
||||
|
||||
setTimeout(() => {
|
||||
state.refreshTokenFn = null;
|
||||
state.refreshTokenPromise = null;
|
||||
}, 1000);
|
||||
|
||||
return success;
|
||||
|
@@ -1,6 +1,7 @@
|
||||
export interface RequestInstanceState {
|
||||
/** whether the request is refreshing token */
|
||||
refreshTokenFn: Promise<boolean> | null;
|
||||
/** the promise of refreshing token */
|
||||
refreshTokenPromise: Promise<boolean> | null;
|
||||
/** the request error message stack */
|
||||
errMsgStack: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
@@ -13,6 +13,7 @@ import { clearAuthStorage, getToken } from './shared';
|
||||
|
||||
export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const routeStore = useRouteStore();
|
||||
const tabStore = useTabStore();
|
||||
const { toLogin, redirectFromLogin } = useRouterPush(false);
|
||||
@@ -39,7 +40,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
|
||||
/** Reset auth store */
|
||||
async function resetStore() {
|
||||
const authStore = useAuthStore();
|
||||
recordUserId();
|
||||
|
||||
clearAuthStorage();
|
||||
|
||||
@@ -53,6 +54,41 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
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
|
||||
*
|
||||
@@ -69,7 +105,15 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
const pass = await loginByToken(loginToken);
|
||||
|
||||
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({
|
||||
title: $t('page.login.common.loginSuccess'),
|
||||
|
@@ -318,7 +318,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
}
|
||||
|
||||
async function onRouteSwitchWhenLoggedIn() {
|
||||
await authStore.initUserInfo();
|
||||
// some global init logic when logged in and switch route
|
||||
}
|
||||
|
||||
async function onRouteSwitchWhenNotLoggedIn() {
|
||||
|
@@ -98,13 +98,24 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
|
||||
const removeTabIndex = tabs.value.findIndex(tab => tab.id === tabId);
|
||||
if (removeTabIndex === -1) return;
|
||||
|
||||
const removedTabRouteKey = tabs.value[removeTabIndex].routeKey;
|
||||
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);
|
||||
|
||||
// if current tab is removed, then switch to next tab
|
||||
if (isRemoveActiveTab && nextTab) {
|
||||
await switchRouteByTab(nextTab);
|
||||
}
|
||||
|
||||
// reset route cache if cache strategy is close
|
||||
if (themeStore.resetCacheStrategy === 'close') {
|
||||
routeStore.resetRouteCache(removedTabRouteKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** remove active tab */
|
||||
@@ -131,9 +142,26 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
|
||||
*/
|
||||
async function clearTabs(excludes: string[] = []) {
|
||||
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[] = [];
|
||||
|
||||
if (themeStore.resetCacheStrategy === 'close') {
|
||||
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);
|
||||
// filterTabsByIds returns tabs NOT in removedTabsIds, so these are the tabs that will remain
|
||||
const updatedTabs = filterTabsByIds(removedTabsIds, tabs.value);
|
||||
|
||||
function update() {
|
||||
@@ -142,13 +170,21 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
|
||||
|
||||
if (!isRemoveActiveTab) {
|
||||
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;
|
||||
|
||||
await switchRouteByTab(activeTab);
|
||||
update();
|
||||
// After tabs are updated and route potentially switched, reset cache for removed tabs
|
||||
for (const routeKey of routeKeysToReset) {
|
||||
routeStore.resetRouteCache(routeKey);
|
||||
}
|
||||
}
|
||||
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
@@ -1,10 +1,11 @@
|
||||
import { computed, effectScope, onScopeDispose, ref, toRefs, watch } 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 { getPaletteColorByNumber } from '@sa/color';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
import { useAuthStore } from '../auth';
|
||||
import {
|
||||
addThemeVarsToGlobal,
|
||||
createThemeToken,
|
||||
@@ -18,10 +19,14 @@ import {
|
||||
export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
|
||||
const scope = effectScope();
|
||||
const osTheme = usePreferredColorScheme();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
/** Theme settings */
|
||||
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 */
|
||||
const darkMode = computed(() => {
|
||||
if (settings.value.themeScheme === 'auto') {
|
||||
@@ -57,6 +62,28 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
|
||||
*/
|
||||
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 */
|
||||
function resetStore() {
|
||||
const themeStore = useThemeStore();
|
||||
@@ -144,13 +171,43 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
|
||||
);
|
||||
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) {
|
||||
settings.value.layout.reverseHorizontalMix = reverse;
|
||||
function setWatermarkEnableUserName(enable: boolean) {
|
||||
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 */
|
||||
@@ -196,6 +253,15 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// watch watermark settings to control timer
|
||||
watch(
|
||||
() => [settings.value.watermark.visible, settings.value.watermark.enableTime],
|
||||
() => {
|
||||
updateWatermarkTimer();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
});
|
||||
|
||||
/** On scope dispose */
|
||||
@@ -209,6 +275,7 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
|
||||
themeColors,
|
||||
naiveTheme,
|
||||
settingsJson,
|
||||
watermarkContent,
|
||||
setGrayscale,
|
||||
setColourWeakness,
|
||||
resetStore,
|
||||
@@ -216,6 +283,7 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
|
||||
toggleThemeScheme,
|
||||
updateThemeColors,
|
||||
setThemeLayout,
|
||||
setLayoutReverseHorizontalMix
|
||||
setWatermarkEnableUserName,
|
||||
setWatermarkEnableTime
|
||||
};
|
||||
});
|
||||
|
@@ -10,4 +10,5 @@ body,
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
color: rgb(var(--base-text-color));
|
||||
}
|
||||
|
90
src/theme/preset/azir.json
Normal file
90
src/theme/preset/azir.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"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,
|
||||
"resetCacheStrategy": "refresh",
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
90
src/theme/preset/compact.json
Normal file
90
src/theme/preset/compact.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"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,
|
||||
"resetCacheStrategy": "close",
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
90
src/theme/preset/dark.json
Normal file
90
src/theme/preset/dark.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"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,
|
||||
"resetCacheStrategy": "close",
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
90
src/theme/preset/default.json
Normal file
90
src/theme/preset/default.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"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,
|
||||
"resetCacheStrategy": "close",
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -12,11 +12,10 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
||||
error: '#f5222d'
|
||||
},
|
||||
isInfoFollowPrimary: true,
|
||||
resetCacheStrategy: 'close',
|
||||
resetCacheStrategy: 'refresh',
|
||||
layout: {
|
||||
mode: 'vertical',
|
||||
scrollMode: 'content',
|
||||
reverseHorizontalMix: false
|
||||
scrollMode: 'content'
|
||||
},
|
||||
page: {
|
||||
animate: true,
|
||||
@@ -30,6 +29,9 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
||||
},
|
||||
multilingual: {
|
||||
visible: true
|
||||
},
|
||||
globalSearch: {
|
||||
visible: true
|
||||
}
|
||||
},
|
||||
tab: {
|
||||
@@ -55,7 +57,10 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
||||
},
|
||||
watermark: {
|
||||
visible: false,
|
||||
text: 'SoybeanAdmin'
|
||||
text: 'SoybeanAdmin',
|
||||
enableUserName: false,
|
||||
enableTime: false,
|
||||
timeFormat: 'YYYY-MM-DD HH:mm'
|
||||
},
|
||||
tokens: {
|
||||
light: {
|
||||
|
20
src/typings/api/auth.d.ts
vendored
Normal file
20
src/typings/api/auth.d.ts
vendored
Normal 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[];
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user