chore: init read-star monorepo
11
.editorconfig
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Editor configuration, see http://editorconfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
13
.gitattributes
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"*.vue" eol=lf
|
||||||
|
"*.js" eol=lf
|
||||||
|
"*.ts" eol=lf
|
||||||
|
"*.jsx" eol=lf
|
||||||
|
"*.tsx" eol=lf
|
||||||
|
"*.mjs" eol=lf
|
||||||
|
"*.json" eol=lf
|
||||||
|
"*.html" eol=lf
|
||||||
|
"*.css" eol=lf
|
||||||
|
"*.scss" eol=lf
|
||||||
|
"*.md" eol=lf
|
||||||
|
"*.yaml" eol=lf
|
||||||
|
"*.yml" eol=lf
|
||||||
90
.github/ISSUE_TEMPLATE/bug-report_cn.yaml
vendored
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
name: 🐞 Bug提交
|
||||||
|
description: 在使用软件或功能的过程中遇到了错误
|
||||||
|
title: '[Bug]: '
|
||||||
|
labels: [bug?]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## 请按照以下要求进行提交
|
||||||
|
### 1. 提交后需要指定标签和截止时间。
|
||||||
|
---
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## 环境信息
|
||||||
|
请根据实际使用环境修改以下信息。
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: env-program-ver
|
||||||
|
attributes:
|
||||||
|
label: 软件版本
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: env-vm-ver
|
||||||
|
attributes:
|
||||||
|
label: 运行环境
|
||||||
|
description: 选择运行软件的系统版本
|
||||||
|
options:
|
||||||
|
- Windows (64)
|
||||||
|
- Windows (32/x84)
|
||||||
|
- MacOS
|
||||||
|
- Linux
|
||||||
|
- Ubuntu
|
||||||
|
- CentOS
|
||||||
|
- ArchLinux
|
||||||
|
- UNIX (Android)
|
||||||
|
- 其它(请在下方说明)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: env-vm-arch
|
||||||
|
attributes:
|
||||||
|
label: 运行架构
|
||||||
|
description: (可选) 选择运行软件的系统架构
|
||||||
|
options:
|
||||||
|
- AMD64
|
||||||
|
- x86
|
||||||
|
- ARM [32] (别名:AArch32 / ARMv7)
|
||||||
|
- ARM [64] (别名:AArch64 / ARMv8)
|
||||||
|
- 其它
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduce-steps
|
||||||
|
attributes:
|
||||||
|
label: 重现步骤
|
||||||
|
description: |
|
||||||
|
我们需要执行哪些操作才能让 bug 出现?
|
||||||
|
简洁清晰的重现步骤能够帮助我们更迅速地定位问题所在。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: 期望的结果是什么?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: 实际的结果是什么?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logging
|
||||||
|
attributes:
|
||||||
|
label: 日志记录(可选)
|
||||||
|
render: golang
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra-desc
|
||||||
|
attributes:
|
||||||
|
label: 补充说明(可选)
|
||||||
90
.github/ISSUE_TEMPLATE/bug-report_en.yaml
vendored
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
name: 🐞 Bug Report
|
||||||
|
description: Encountered an error while using the software or feature
|
||||||
|
title: '[Bug]: '
|
||||||
|
labels: [bug?]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Please submit according to the following requirements
|
||||||
|
### 1. After submission, you need to specify the label and deadline.
|
||||||
|
---
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Environment Information
|
||||||
|
Please modify the following information according to the actual usage environment.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: env-program-ver
|
||||||
|
attributes:
|
||||||
|
label: Software Version
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: env-vm-ver
|
||||||
|
attributes:
|
||||||
|
label: Operating Environment
|
||||||
|
description: Select the system version on which the software is running
|
||||||
|
options:
|
||||||
|
- Windows (64)
|
||||||
|
- Windows (32/x84)
|
||||||
|
- MacOS
|
||||||
|
- Linux
|
||||||
|
- Ubuntu
|
||||||
|
- CentOS
|
||||||
|
- ArchLinux
|
||||||
|
- UNIX (Android)
|
||||||
|
- Other (please specify below)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: env-vm-arch
|
||||||
|
attributes:
|
||||||
|
label: Operating Architecture
|
||||||
|
description: (Optional) Select the system architecture on which the software is running
|
||||||
|
options:
|
||||||
|
- AMD64
|
||||||
|
- x86
|
||||||
|
- ARM [32] (Alias:AArch32 / ARMv7)
|
||||||
|
- ARM [64] (Alias:AArch64 / ARMv8)
|
||||||
|
- Other
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduce-steps
|
||||||
|
attributes:
|
||||||
|
label: Reproduce Steps
|
||||||
|
description: |
|
||||||
|
What operations do we need to perform to make the bug appear?
|
||||||
|
The concise and clear reproduction steps can help us locate the problem more quickly.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: What is the expected result?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: What is the actual result?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logging
|
||||||
|
attributes:
|
||||||
|
label: Logging (Optional)
|
||||||
|
render: golang
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra-desc
|
||||||
|
attributes:
|
||||||
|
label: Additional Description (Optional)
|
||||||
48
.github/ISSUE_TEMPLATE/feature_request_cn.yaml
vendored
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
name: 🚀 功能请求
|
||||||
|
description: 提出一个想法以帮助我们改进W&B
|
||||||
|
title: '[功能]: '
|
||||||
|
labels:
|
||||||
|
- 功能请求
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
**感谢 :heart: 您花时间填写此功能请求报告!**
|
||||||
|
我们恳请您搜索看看您的功能是否[已经存在](https://github.com/reader-star/reader-star-admin/issues?q=is%3Aissue+sort%3Acreated-desc+)。
|
||||||
|
|
||||||
|
我们也很乐意接受用户的贡献。有关更多详细信息,请参阅[此处](https://github.com/reader-star/reader-star-admin/blob/main/README.zh_CN.md#%E5%A6%82%E4%BD%95%E8%B4%A1%E7%8C%AE)。
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: 描述
|
||||||
|
description: |
|
||||||
|
对您感兴趣的功能的清晰简洁的描述。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: 建议的解决方案
|
||||||
|
description: |
|
||||||
|
描述您想要的解决方案。对您希望发生的事情的清晰简洁的描述。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: 替代方案
|
||||||
|
description: |
|
||||||
|
描述您考虑过的替代方案。
|
||||||
|
对您考虑过的任何替代解决方案或功能的清晰简洁的描述。
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: 额外的上下文
|
||||||
|
description: |
|
||||||
|
在此处添加有关问题的其他上下文。
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
48
.github/ISSUE_TEMPLATE/feature_request_en.yaml
vendored
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
name: 🚀 Feature Request
|
||||||
|
description: Suggest an idea to help us improve W&B
|
||||||
|
title: '[Feature]: '
|
||||||
|
labels:
|
||||||
|
- feature_request
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
**Thanks :heart: for taking the time to fill out this feature request report!**
|
||||||
|
We kindly ask that you search to see if an issue [already exists](https://github.com/reader-star/reader-star-admin/issues?q=is%3Aissue+sort%3Acreated-desc+) for your feature.
|
||||||
|
|
||||||
|
We are also happy to accept contributions from our users. For more details see [here](https://github.com/reader-star/reader-star-admin/blob/main/README.md#how-to-contribute).
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: |
|
||||||
|
A clear and concise description of the feature you're interested in.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Suggested Solution
|
||||||
|
description: |
|
||||||
|
Describe the solution you'd like. A clear and concise description of what you want to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Alternatives
|
||||||
|
description: |
|
||||||
|
Describe alternatives you've considered.
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: |
|
||||||
|
Add any other context about the problem here.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
50
.github/PULL_REQUEST_TEMPLATE/pr_cn.md
vendored
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
首先,感谢你的贡献! 😄
|
||||||
|
|
||||||
|
新特性请提交至 feature 分支,其余可提交至 main 分支。在一个维护者审核通过后合并。请确保填写以下 pull request 的信息,谢谢!~
|
||||||
|
|
||||||
|
[[English Template / 英文模板](./pr_en.md)]
|
||||||
|
|
||||||
|
### 这个变动的性质是
|
||||||
|
|
||||||
|
- [ ] 新特性提交
|
||||||
|
- [ ] 日常 bug 修复
|
||||||
|
- [ ] 站点、文档改进
|
||||||
|
- [ ] 组件样式改进
|
||||||
|
- [ ] TypeScript 定义更新
|
||||||
|
- [ ] 重构
|
||||||
|
- [ ] 代码风格优化
|
||||||
|
- [ ] 分支合并
|
||||||
|
- [ ] 其他改动(是关于什么的改动?)
|
||||||
|
|
||||||
|
### 需求背景
|
||||||
|
|
||||||
|
> 1. 描述相关需求的来源。
|
||||||
|
> 2. 要解决的问题。
|
||||||
|
> 3. 相关的 issue 讨论链接。
|
||||||
|
|
||||||
|
### 实现方案和 API(非新功能可选)
|
||||||
|
|
||||||
|
> 1. 基本的解决思路和其他可选方案。
|
||||||
|
> 2. 列出最终的 API 实现和用法。
|
||||||
|
> 3. 涉及 UI/交互变动需要有截图或 GIF。
|
||||||
|
|
||||||
|
### 对用户的影响和可能的风险(非新功能可选)
|
||||||
|
|
||||||
|
> 1. 这个改动对用户端是否有影响?影响的方面有哪些?
|
||||||
|
> 2. 是否有可能隐含的 break change 和其他风险?
|
||||||
|
|
||||||
|
### Changelog 描述(非新功能可选)
|
||||||
|
|
||||||
|
> 1. 英文描述
|
||||||
|
> 2. 中文描述(可选)
|
||||||
|
|
||||||
|
### 请求合并前的自查清单
|
||||||
|
|
||||||
|
- [ ] 文档已补充或无须补充
|
||||||
|
- [ ] 代码演示已提供或无须提供
|
||||||
|
- [ ] TypeScript 定义已补充或无须补充
|
||||||
|
- [ ] Changelog 已提供或无须提供
|
||||||
|
|
||||||
|
### 后续计划(非新功能可选)
|
||||||
|
|
||||||
|
> 如果这个提交后面还有相关的其他提交和跟进信息,可以写在这里。
|
||||||
51
.github/PULL_REQUEST_TEMPLATE/pr_en.md
vendored
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
First of all, thank you for your contribution! 😄
|
||||||
|
|
||||||
|
New feature please send pull request to feature branch, and rest to main branch. Pull request will be merged after one of collaborators approve. Please makes sure that these form are filled before submitting your pull request, thank you!
|
||||||
|
|
||||||
|
[[中文版模板 / Chinese template](./pr_cn.md)]
|
||||||
|
|
||||||
|
### This is a ...
|
||||||
|
|
||||||
|
- [ ] New feature
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] Site / document update
|
||||||
|
- [ ] Component style update
|
||||||
|
- [ ] TypeScript definition update
|
||||||
|
- [ ] Refactoring
|
||||||
|
- [ ] Code style optimization
|
||||||
|
- [ ] Branch merge
|
||||||
|
- [ ] Other (about what?)
|
||||||
|
|
||||||
|
### What's the background?
|
||||||
|
|
||||||
|
> 1. Describe the source of requirement.
|
||||||
|
> 2. Resolve what problem.
|
||||||
|
> 3. Related issue link.
|
||||||
|
|
||||||
|
### API Realization (Optional if not new feature)
|
||||||
|
|
||||||
|
> 1. Basic thought of solution and other optional proposal.
|
||||||
|
> 2. List final API realization and usage sample.
|
||||||
|
> 3. GIF or snapshot should be provided if includes UI/interactive modification.
|
||||||
|
|
||||||
|
### What's the effect? (Optional if not new feature)
|
||||||
|
|
||||||
|
> 1. Does this PR affect user? Which part will be affected?
|
||||||
|
> 2. What will say in changelog?
|
||||||
|
> 3. Does this PR contains potential break change or other risk?
|
||||||
|
|
||||||
|
### Changelog description (Optional if not new feature)
|
||||||
|
|
||||||
|
> 1. English description
|
||||||
|
> 2. Chinese description (optional)
|
||||||
|
|
||||||
|
### Self Check before Merge
|
||||||
|
|
||||||
|
- [ ] Doc is updated/provided or not needed
|
||||||
|
- [ ] Demo is updated/provided or not needed
|
||||||
|
- [ ] TypeScript definition is updated/provided or not needed
|
||||||
|
- [ ] Changelog is provided or not needed
|
||||||
|
|
||||||
|
### Additional Plan? (Optional if not new feature)
|
||||||
|
|
||||||
|
> If this PR related with other PR or following info. You can type here.
|
||||||
30
.github/workflows/linter.yml
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: Lint Code
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint All Code
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Lint Code Base
|
||||||
|
uses: github/super-linter@v4
|
||||||
|
env:
|
||||||
|
VALIDATE_ALL_CODEBASE: false
|
||||||
|
DEFAULT_BRANCH: main
|
||||||
|
# To change branch master or main
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
FILTER_REGEX_EXCLUDE: (docs|.github)
|
||||||
|
VALIDATE_MARKDOWN: false
|
||||||
25
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18.x
|
||||||
|
|
||||||
|
- run: npx githublogen
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||||
53
.gitignore
vendored
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# --- 依赖与构建输出 ---
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
# 忽略所有子应用的构建输出
|
||||||
|
apps/*/dist
|
||||||
|
packages/*/dist
|
||||||
|
|
||||||
|
# --- 日志文件 ---
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# --- 系统与编辑器 ---
|
||||||
|
.DS_Store
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# --- 测试与覆盖率 ---
|
||||||
|
coverage
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
.VSCodeCounter
|
||||||
|
|
||||||
|
# --- Lock 文件 ---
|
||||||
|
# 在 pnpm 工作区中,只需保留根目录的 pnpm-lock.yaml
|
||||||
|
# 如果有残留的 lock 文件建议忽略或删除
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# --- 环境变量 ---
|
||||||
|
# 保护敏感信息,只允许提交 .env.example
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
.env.test
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# --- 特色插件生成文件 ---
|
||||||
|
# 如果你希望路由自动生成文件不进入版本管理(可选)
|
||||||
|
# apps/*/src/typings/elegant-router.d.ts
|
||||||
4
.npmrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
registry=https://registry.npmmirror.com/
|
||||||
|
shamefully-hoist=true
|
||||||
|
ignore-workspace-root-check=true
|
||||||
|
link-workspace-packages=true
|
||||||
19
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"afzalsayed96.icones",
|
||||||
|
"antfu.iconify",
|
||||||
|
"antfu.unocss",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"editorconfig.editorconfig",
|
||||||
|
"lokalise.i18n-ally",
|
||||||
|
"mhutchie.git-graph",
|
||||||
|
"mikestead.dotenv",
|
||||||
|
"naumovs.color-highlight",
|
||||||
|
"pkief.material-icon-theme",
|
||||||
|
"sdras.vue-vscode-snippets",
|
||||||
|
"vue.volar",
|
||||||
|
"whtouche.vscode-js-console-utils",
|
||||||
|
"zhuangtongfa.material-theme",
|
||||||
|
"tu6ge.naive-ui-intelligence"
|
||||||
|
]
|
||||||
|
}
|
||||||
22
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Vue Debugger",
|
||||||
|
"url": "http://localhost:9527",
|
||||||
|
"webRoot": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "TS Debugger",
|
||||||
|
"runtimeExecutable": "tsx",
|
||||||
|
"skipFiles": ["<node_internals>/**", "${workspaceFolder}/node_modules/**"],
|
||||||
|
"program": "${file}",
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"internalConsoleOptions": "neverOpen"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
39
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
// 1. 禁用原生格式化,避免冲突
|
||||||
|
"editor.formatOnSave": false,
|
||||||
|
|
||||||
|
// 2. 核心配置:保存时触发 ESLint 修复动作
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit",
|
||||||
|
"source.organizeImports": "never"
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. 必须:强制所有相关文件使用 ESLint 插件作为格式化器
|
||||||
|
"[vue]": {
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4. 必须:显式声明需要校验的文件类型
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"typescript",
|
||||||
|
"vue",
|
||||||
|
"html",
|
||||||
|
"json",
|
||||||
|
"jsonc"
|
||||||
|
],
|
||||||
|
|
||||||
|
// 5. 必须:指定工作区目录(让 Windsurf 知道去哪里加载每个包的配置)
|
||||||
|
"eslint.workingDirectories": [
|
||||||
|
{ "mode": "auto" },
|
||||||
|
"./apps/admin",
|
||||||
|
"./apps/user",
|
||||||
|
"./packages/eslint-config"
|
||||||
|
]
|
||||||
|
}
|
||||||
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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
156
README.md
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
## 简介
|
||||||
|
|
||||||
|
一个清新优雅、高颜值且功能强大的后台管理模板,基于最新的前端技术栈,包括 Vue3, Vite7, TypeScript, Pinia 和 UnoCSS。它内置了丰富的主题配置和组件,代码规范严谨,实现了自动化的文件路由系统.
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- **前沿技术应用**:采用 Vue3, Vite7, TypeScript, Pinia 和 UnoCSS 等最新流行的技术栈。
|
||||||
|
- **清晰的项目架构**:采用 pnpm monorepo 架构,结构清晰,优雅易懂。
|
||||||
|
- **严格的代码规范**:遵循 prettier 和 simple-git-hooks,保证代码的规范性。
|
||||||
|
- **TypeScript**: 支持严格的类型检查,提高代码的可维护性。
|
||||||
|
- **丰富的主题配置**:内置多样的主题配置,与 UnoCSS 完美结合。
|
||||||
|
- **内置国际化方案**:轻松实现多语言支持。
|
||||||
|
- **自动化文件路由系统**:自动生成路由导入、声明和类型。更多细节请查看 [Elegant Router](https://github.com/reader-star/elegant-router)。
|
||||||
|
- **灵活的权限路由**:同时支持前端静态路由和后端动态路由。
|
||||||
|
- **丰富的页面组件**:内置多样页面和组件,包括403、404、500页面,以及布局组件、标签组件、主题配置组件等。
|
||||||
|
- **命令行工具**:内置高效的命令行工具,git提交、删除文件、发布等。
|
||||||
|
- **移动端适配**:完美支持移动端,实现自适应布局。
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
**环境准备**
|
||||||
|
|
||||||
|
确保你的环境满足以下要求:
|
||||||
|
|
||||||
|
- **git**: 你需要git来克隆和管理项目版本。
|
||||||
|
- **NodeJS**: >=20.19.0,推荐 20.19.0 或更高。
|
||||||
|
- **pnpm**: >= 10.5.0,推荐 10.5.0 或更高。
|
||||||
|
|
||||||
|
**克隆项目**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# github
|
||||||
|
git clone http://8.137.159.94:3000/ranjl/reader-star.git
|
||||||
|
```
|
||||||
|
|
||||||
|
**安装依赖**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm i
|
||||||
|
```
|
||||||
|
|
||||||
|
> 由于本项目采用了 pnpm monorepo 的管理方式,因此请不要使用 npm 或 yarn 来安装依赖。
|
||||||
|
|
||||||
|
**启动项目**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**构建项目**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git 提交规范
|
||||||
|
|
||||||
|
本项目已内置 `commit` 命令,您可以通过执行 `pnpm commit` 来生成符合 [Conventional Commits](<[conventionalcommits](https://www.conventionalcommits.org/)>) 规范的提交信息。在提交PR时,请务必使用 `commit` 命令来创建提交信息,以确保信息的规范性。
|
||||||
|
|
||||||
|
## 项目层级
|
||||||
|
|
||||||
|
- `apps/admin`: 后台管理
|
||||||
|
- `apps/user`: 用户端
|
||||||
|
- `packages/eslint-config`: eslint 配置
|
||||||
|
- `packages/shared`: 公共组件
|
||||||
|
|
||||||
|
## 目录说明
|
||||||
|
|
||||||
|
```
|
||||||
|
read-star
|
||||||
|
├── .vscode // VSCode/Windsurf 项目级配置
|
||||||
|
│ ├── extensions.json // 推荐安装的插件
|
||||||
|
│ ├── launch.json // 调试配置文件 (Debug Vue/TS)
|
||||||
|
│ ├── settings.json // 项目专属设置 (ESLint 校验、格式化规则)
|
||||||
|
│ └── vue3.code-snippets // Vue3 代码片段
|
||||||
|
├── apps // 业务应用目录
|
||||||
|
│ ├── admin // 管理端应用 (原 soybean-admin 业务主体)
|
||||||
|
│ │ ├── build // Vite 构建相关配置和插件
|
||||||
|
│ │ ├── src // 源代码
|
||||||
|
│ │ │ ├── assets // 静态资源 (图片、svg图标)
|
||||||
|
│ │ │ ├── components // 全局组件
|
||||||
|
│ │ │ ├── constants // 常量 (正则、业务常量)
|
||||||
|
│ │ │ ├── hooks // 组合式函数 hooks
|
||||||
|
│ │ │ ├── layouts // 布局组件
|
||||||
|
│ │ │ ├── locales // 国际化配置
|
||||||
|
│ │ │ ├── router // 路由配置 (含 elegant-router 生成文件)
|
||||||
|
│ │ │ ├── service // 网络请求接口
|
||||||
|
│ │ │ ├── store // Pinia 状态管理
|
||||||
|
│ │ │ ├── theme // 主题配置
|
||||||
|
│ │ │ ├── typings // TS 类型声明 (*.d.ts)
|
||||||
|
│ │ │ ├── views // 页面视图
|
||||||
|
│ │ │ ├── App.vue // 根组件
|
||||||
|
│ │ │ └── main.ts // 入口文件
|
||||||
|
│ │ ├── .env // 环境配置
|
||||||
|
│ │ ├── index.html // 入口 HTML
|
||||||
|
│ │ ├── package.json // 应用依赖描述
|
||||||
|
│ │ └── vite.config.ts // Vite 配置文件
|
||||||
|
│ └── user // 用户端应用 (镜像拆分后的第二个端)
|
||||||
|
│ └── ... // 结构与 admin 一致
|
||||||
|
├── packages // 公共共享包 (内部依赖库)
|
||||||
|
│ ├── alova // Alova 请求封装
|
||||||
|
│ ├── axios // Axios 请求封装
|
||||||
|
│ ├── color // 颜色处理工具
|
||||||
|
│ ├── eslint-config // 自定义本地 ESLint 规范包 (私有化配置)
|
||||||
|
│ ├── hooks // 跨应用复用的组合式 hooks
|
||||||
|
│ ├── materials // 组件物料库
|
||||||
|
│ ├── shared // 共享类型声明、常量和通用函数
|
||||||
|
│ ├── uno-preset // UnoCSS 预设配置
|
||||||
|
│ └── utils // 全局通用的纯工具函数
|
||||||
|
├── .editorconfig // 统一编辑器编码规范
|
||||||
|
├── .gitattributes // Git 属性配置
|
||||||
|
├── .gitignore // Git 忽略文件配置
|
||||||
|
├── .npmrc // npm/pnpm 运行参数配置
|
||||||
|
├── eslint.config.js // 根目录 ESLint 平铺配置文件 (引用自 packages)
|
||||||
|
├── package.json // Monorepo 根依赖及脚本管理
|
||||||
|
├── pnpm-lock.yaml // pnpm 依赖锁定文件
|
||||||
|
├── pnpm-workspace.yaml // pnpm 工作区定义文件
|
||||||
|
├── README.md // 项目英文介绍
|
||||||
|
├── tsconfig.base.json // TS 基础共享配置
|
||||||
|
├── tsconfig.json // TS 引用管理配置 (Project References)
|
||||||
|
├── uno.config.ts // 原子 CSS 框架 UnoCSS 全局配置
|
||||||
|
└── vite.config.ts // (可选) 根目录 Vite 公共配置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 命令行工具
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{
|
||||||
|
// 构建打包(prod环境)
|
||||||
|
"build": "vite build --mode prod",
|
||||||
|
// 构建打包(test环境)
|
||||||
|
"build:test": "vite build --mode test",
|
||||||
|
// 删除主项目及子项目的 node_modules, dist, pnpm-lock.yaml
|
||||||
|
"cleanup": "sa cleanup",
|
||||||
|
// 提交代码 (生成符合 Conventional Commits standard 的提交信息)
|
||||||
|
"commit": "sa git-commit",
|
||||||
|
// 本地运行(test环境)
|
||||||
|
"dev": "vite --mode test",
|
||||||
|
// 本地运行(prod环境)
|
||||||
|
"dev:prod": "vite --mode prod",
|
||||||
|
// 生成路由
|
||||||
|
"gen-route": "sa gen-route",
|
||||||
|
// eslint检查并自动修复
|
||||||
|
"lint": "eslint . --fix",
|
||||||
|
// 初始化 simple-git-hooks
|
||||||
|
"prepare": "simple-git-hooks",
|
||||||
|
// 本地环境预览构建后的dist
|
||||||
|
"preview": "vite preview",
|
||||||
|
// 发布
|
||||||
|
"release": "sa release",
|
||||||
|
// vue文件的ts检查
|
||||||
|
"typecheck": "vue-tsc --noEmit --skipLibCheck",
|
||||||
|
// 更新依赖包
|
||||||
|
"update-pkg": "sa update-pkg"
|
||||||
|
}
|
||||||
|
```
|
||||||
7
apps/admin/.env.prod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# backend service base url, prod environment
|
||||||
|
VITE_SERVICE_BASE_URL=https://mock.apifox.cn/m1/3109515-0-default
|
||||||
|
|
||||||
|
# other backend service base url, prod environment
|
||||||
|
VITE_OTHER_SERVICE_BASE_URL= `{
|
||||||
|
"demo": "http://localhost:9529"
|
||||||
|
}`
|
||||||
2
apps/admin/build/config/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './proxy'
|
||||||
|
export * from './time'
|
||||||
58
apps/admin/build/config/proxy.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { ProxyOptions } from 'vite'
|
||||||
|
import { consola } from 'consola'
|
||||||
|
import { bgRed, bgYellow, green, lightBlue } from 'kolorist'
|
||||||
|
import { createServiceConfig } from '../../src/utils/service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set http proxy
|
||||||
|
*
|
||||||
|
* @param env - The current env
|
||||||
|
* @param enable - If enable http proxy
|
||||||
|
*/
|
||||||
|
export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
|
||||||
|
const isEnableHttpProxy = enable && env.VITE_HTTP_PROXY === 'Y'
|
||||||
|
|
||||||
|
if (!isEnableHttpProxy)
|
||||||
|
return undefined
|
||||||
|
|
||||||
|
const isEnableProxyLog = env.VITE_PROXY_LOG === 'Y'
|
||||||
|
|
||||||
|
const { baseURL, proxyPattern, other } = createServiceConfig(env)
|
||||||
|
|
||||||
|
const proxy: Record<string, ProxyOptions> = createProxyItem({ baseURL, proxyPattern }, isEnableProxyLog)
|
||||||
|
|
||||||
|
other.forEach((item) => {
|
||||||
|
Object.assign(proxy, createProxyItem(item, isEnableProxyLog))
|
||||||
|
})
|
||||||
|
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProxyItem(item: App.Service.ServiceConfigItem, enableLog: boolean) {
|
||||||
|
const proxy: Record<string, ProxyOptions> = {}
|
||||||
|
|
||||||
|
proxy[item.proxyPattern] = {
|
||||||
|
target: item.baseURL,
|
||||||
|
changeOrigin: true,
|
||||||
|
configure: (_proxy, options) => {
|
||||||
|
_proxy.on('proxyReq', (_proxyReq, req, _res) => {
|
||||||
|
if (!enableLog)
|
||||||
|
return
|
||||||
|
|
||||||
|
const requestUrl = `${lightBlue('[proxy url]')}: ${bgYellow(` ${req.method} `)} ${green(`${item.proxyPattern}${req.url}`)}`
|
||||||
|
|
||||||
|
const proxyUrl = `${lightBlue('[real request url]')}: ${green(`${options.target}${req.url}`)}`
|
||||||
|
|
||||||
|
consola.log(`${requestUrl}\n${proxyUrl}`)
|
||||||
|
})
|
||||||
|
_proxy.on('error', (_err, req, _res) => {
|
||||||
|
if (!enableLog)
|
||||||
|
return
|
||||||
|
consola.log(bgRed(`Error: ${req.method} `), green(`${options.target}${req.url}`))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
rewrite: path => path.replace(new RegExp(`^${item.proxyPattern}`), ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
12
apps/admin/build/config/time.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import dayjs from 'dayjs'
|
||||||
|
import timezone from 'dayjs/plugin/timezone'
|
||||||
|
import utc from 'dayjs/plugin/utc'
|
||||||
|
|
||||||
|
export function getBuildTime() {
|
||||||
|
dayjs.extend(utc)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
|
||||||
|
const buildTime = dayjs.tz(Date.now(), 'Asia/Shanghai').format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
|
||||||
|
return buildTime
|
||||||
|
}
|
||||||
9
apps/admin/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,
|
||||||
|
})
|
||||||
|
}
|
||||||
13
apps/admin/build/plugins/html.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
|
export function setupHtmlPlugin(buildTime: string) {
|
||||||
|
const plugin: Plugin = {
|
||||||
|
name: 'html-plugin',
|
||||||
|
apply: 'build',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replace('<head>', `<head>\n <meta name="buildTime" content="${buildTime}">`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugin
|
||||||
|
}
|
||||||
24
apps/admin/build/plugins/index.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { PluginOption } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||||
|
import progress from 'vite-plugin-progress'
|
||||||
|
import { setupDevtoolsPlugin } from './devtools'
|
||||||
|
import { setupHtmlPlugin } from './html'
|
||||||
|
import { setupElegantRouter } from './router'
|
||||||
|
import { setupUnocss } from './unocss'
|
||||||
|
import { setupUnplugin } from './unplugin'
|
||||||
|
|
||||||
|
export function setupVitePlugins(viteEnv: Env.ImportMeta, buildTime: string) {
|
||||||
|
const plugins: PluginOption = [
|
||||||
|
vue(),
|
||||||
|
vueJsx(),
|
||||||
|
setupDevtoolsPlugin(viteEnv),
|
||||||
|
setupElegantRouter(),
|
||||||
|
setupUnocss(viteEnv),
|
||||||
|
...setupUnplugin(viteEnv),
|
||||||
|
progress(),
|
||||||
|
setupHtmlPlugin(buildTime),
|
||||||
|
]
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
}
|
||||||
41
apps/admin/build/plugins/router.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import type { RouteKey } from '@elegant-router/types'
|
||||||
|
import type { RouteMeta } from 'vue-router'
|
||||||
|
import ElegantVueRouter from '@elegant-router/vue/vite'
|
||||||
|
|
||||||
|
export function setupElegantRouter() {
|
||||||
|
return ElegantVueRouter({
|
||||||
|
layouts: {
|
||||||
|
base: 'src/layouts/base-layout/index.vue',
|
||||||
|
blank: 'src/layouts/blank-layout/index.vue',
|
||||||
|
},
|
||||||
|
routePathTransformer(routeName, routePath) {
|
||||||
|
const key = routeName as RouteKey
|
||||||
|
|
||||||
|
if (key === 'login') {
|
||||||
|
const modules: UnionKey.LoginModule[] = ['pwd-login', 'code-login', 'register', 'reset-pwd', 'bind-wechat']
|
||||||
|
|
||||||
|
const moduleReg = modules.join('|')
|
||||||
|
|
||||||
|
return `/login/:module(${moduleReg})?`
|
||||||
|
}
|
||||||
|
|
||||||
|
return routePath
|
||||||
|
},
|
||||||
|
onRouteMetaGen(routeName) {
|
||||||
|
const key = routeName as RouteKey
|
||||||
|
|
||||||
|
const constantRoutes: RouteKey[] = ['login', '403', '404', '500']
|
||||||
|
|
||||||
|
const meta: Partial<RouteMeta> = {
|
||||||
|
title: key,
|
||||||
|
i18nKey: `route.${key}` as App.I18n.I18nKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (constantRoutes.includes(key)) {
|
||||||
|
meta.constant = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
31
apps/admin/build/plugins/unocss.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
import { FileSystemIconLoader } from '@iconify/utils/lib/loader/node-loaders'
|
||||||
|
import presetIcons from '@unocss/preset-icons'
|
||||||
|
import unocss from '@unocss/vite'
|
||||||
|
|
||||||
|
export function setupUnocss(viteEnv: Env.ImportMeta) {
|
||||||
|
const { VITE_ICON_PREFIX, VITE_ICON_LOCAL_PREFIX } = viteEnv
|
||||||
|
|
||||||
|
const localIconPath = path.join(process.cwd(), 'src/assets/svg-icon')
|
||||||
|
|
||||||
|
/** The name of the local icon collection */
|
||||||
|
const collectionName = VITE_ICON_LOCAL_PREFIX.replace(`${VITE_ICON_PREFIX}-`, '')
|
||||||
|
|
||||||
|
return unocss({
|
||||||
|
presets: [
|
||||||
|
presetIcons({
|
||||||
|
prefix: `${VITE_ICON_PREFIX}-`,
|
||||||
|
scale: 1,
|
||||||
|
extraProperties: {
|
||||||
|
display: 'inline-block',
|
||||||
|
},
|
||||||
|
collections: {
|
||||||
|
[collectionName]: FileSystemIconLoader(localIconPath, svg =>
|
||||||
|
svg.replace(/^<svg\s/, '<svg width="1em" height="1em" ')),
|
||||||
|
},
|
||||||
|
warn: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
46
apps/admin/build/plugins/unplugin.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import type { PluginOption } from 'vite'
|
||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
||||||
|
import IconsResolver from 'unplugin-icons/resolver'
|
||||||
|
import Icons from 'unplugin-icons/vite'
|
||||||
|
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
||||||
|
|
||||||
|
export function setupUnplugin(viteEnv: Env.ImportMeta) {
|
||||||
|
const { VITE_ICON_PREFIX, VITE_ICON_LOCAL_PREFIX } = viteEnv
|
||||||
|
|
||||||
|
const localIconPath = path.join(process.cwd(), 'src/assets/svg-icon')
|
||||||
|
|
||||||
|
/** The name of the local icon collection */
|
||||||
|
const collectionName = VITE_ICON_LOCAL_PREFIX.replace(`${VITE_ICON_PREFIX}-`, '')
|
||||||
|
|
||||||
|
const plugins: PluginOption[] = [
|
||||||
|
Icons({
|
||||||
|
compiler: 'vue3',
|
||||||
|
customCollections: {
|
||||||
|
[collectionName]: FileSystemIconLoader(localIconPath, svg =>
|
||||||
|
svg.replace(/^<svg\s/, '<svg width="1em" height="1em" ')),
|
||||||
|
},
|
||||||
|
scale: 1,
|
||||||
|
defaultClass: 'inline-block',
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
dts: 'src/typings/components.d.ts',
|
||||||
|
types: [{ from: 'vue-router', names: ['RouterLink', 'RouterView'] }],
|
||||||
|
resolvers: [
|
||||||
|
NaiveUiResolver(),
|
||||||
|
IconsResolver({ customCollections: [collectionName], componentPrefix: VITE_ICON_PREFIX }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createSvgIconsPlugin({
|
||||||
|
iconDirs: [localIconPath],
|
||||||
|
symbolId: `${VITE_ICON_LOCAL_PREFIX}-[dir]-[name]`,
|
||||||
|
inject: 'body-last',
|
||||||
|
customDomId: '__SVG_ICON_LOCAL__',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
}
|
||||||
14
apps/admin/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-cmn-Hans">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<title>%VITE_APP_TITLE%</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
85
apps/admin/package.json
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"name": "@reader-star/admin",
|
||||||
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"homepage": "http://8.137.159.94:3000/ranjl/reader-star.git",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0",
|
||||||
|
"pnpm": ">=10.5.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build --mode prod",
|
||||||
|
"build:test": "vite build --mode test",
|
||||||
|
"cleanup": "sa cleanup",
|
||||||
|
"commit": "sa git-commit",
|
||||||
|
"commit:zh": "sa git-commit -l=zh-cn",
|
||||||
|
"dev": "vite --mode test",
|
||||||
|
"dev:prod": "vite --mode prod",
|
||||||
|
"gen-route": "sa gen-route",
|
||||||
|
"lint": "eslint . --fix",
|
||||||
|
"prepare": "simple-git-hooks",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"release": "sa release",
|
||||||
|
"typecheck": "vue-tsc --noEmit --skipLibCheck",
|
||||||
|
"update-pkg": "sa update-pkg"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@better-scroll/core": "2.5.1",
|
||||||
|
"@iconify/vue": "5.0.0",
|
||||||
|
"@sa/axios": "workspace:*",
|
||||||
|
"@sa/color": "workspace:*",
|
||||||
|
"@sa/hooks": "workspace:*",
|
||||||
|
"@sa/materials": "workspace:*",
|
||||||
|
"@sa/utils": "workspace:*",
|
||||||
|
"@vueuse/core": "14.1.0",
|
||||||
|
"clipboard": "2.0.11",
|
||||||
|
"dayjs": "1.11.19",
|
||||||
|
"defu": "6.1.4",
|
||||||
|
"echarts": "6.0.0",
|
||||||
|
"json5": "2.2.3",
|
||||||
|
"naive-ui": "2.43.2",
|
||||||
|
"nprogress": "0.2.0",
|
||||||
|
"pinia": "3.0.4",
|
||||||
|
"tailwind-merge": "3.4.0",
|
||||||
|
"vue": "3.5.26",
|
||||||
|
"vue-draggable-plus": "0.6.0",
|
||||||
|
"vue-i18n": "11.2.7",
|
||||||
|
"vue-router": "4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@elegant-router/vue": "0.3.8",
|
||||||
|
"@iconify/json": "2.2.417",
|
||||||
|
"@sa/scripts": "workspace:*",
|
||||||
|
"@sa/uno-preset": "workspace:*",
|
||||||
|
"@types/node": "25.0.3",
|
||||||
|
"@types/nprogress": "0.2.3",
|
||||||
|
"@unocss/eslint-config": "66.5.10",
|
||||||
|
"@unocss/preset-icons": "66.5.10",
|
||||||
|
"@unocss/preset-uno": "66.5.10",
|
||||||
|
"@unocss/transformer-directives": "66.5.10",
|
||||||
|
"@unocss/transformer-variant-group": "66.5.10",
|
||||||
|
"@unocss/vite": "66.5.10",
|
||||||
|
"@vitejs/plugin-vue": "6.0.3",
|
||||||
|
"@vitejs/plugin-vue-jsx": "5.1.3",
|
||||||
|
"consola": "3.4.2",
|
||||||
|
"eslint": "9.39.2",
|
||||||
|
"eslint-plugin-vue": "10.6.2",
|
||||||
|
"kolorist": "1.8.0",
|
||||||
|
"sass": "1.97.1",
|
||||||
|
"simple-git-hooks": "2.13.1",
|
||||||
|
"tsx": "4.21.0",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"unplugin-icons": "22.5.0",
|
||||||
|
"unplugin-vue-components": "30.0.0",
|
||||||
|
"vite": "7.3.0",
|
||||||
|
"vite-plugin-progress": "0.0.7",
|
||||||
|
"vite-plugin-svg-icons": "2.0.1",
|
||||||
|
"vite-plugin-vue-devtools": "8.0.5",
|
||||||
|
"vue-eslint-parser": "10.2.0",
|
||||||
|
"vue-tsc": "3.2.1"
|
||||||
|
},
|
||||||
|
"simple-git-hooks": {
|
||||||
|
"commit-msg": "pnpm sa git-commit-verify",
|
||||||
|
"pre-commit": "pnpm typecheck && pnpm lint && git diff --exit-code"
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/admin/public/favicon.svg
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<svg width="100%" height="100%" version="1.1" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M 200,866 C 100,866 50,779.4 100,692.8 L 200,519.6 C 220,485 240,490 265,499.6 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
|
||||||
|
fill="url(#LinearGradient)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||||
|
<path
|
||||||
|
d="M 420,866 C 455,861 478,846 500,827 C 614,696 615,597 500,517 C 394,444 333,374 380,207.82 L 260,415.67 C 240.22,450 254.37,465.1 275.28,481.79 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
|
||||||
|
fill="url(#LinearGradient_2)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||||
|
<path d="M 500,517 C 394,444 333,374 380,207.82 L 400,173.2 C 367,295 421,350 603,428 C 572,440 524,474 500,517"
|
||||||
|
fill="url(#LinearGradient_3)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||||
|
<path d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 572,440 524,474 500,517 C 615,597 614,696 500,827"
|
||||||
|
fill="url(#LinearGradient_4)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||||
|
<path d="M 400,173.2 C 367,295 421,350 603,428 C 690,389, 750,445 788,500 L 600,173.2 C 550,86.6 450,86.6 400,173.2"
|
||||||
|
fill="url(#LinearGradient_5)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||||
|
<path
|
||||||
|
d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 690,389, 750,445 788,500 C 816,554 797,606 750,640 L 500,827"
|
||||||
|
fill="url(#LinearGradient_6)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||||
|
<path
|
||||||
|
d="M 788,500 C 816,554 797,606 750,640 L 500,827 C 497,851 513,862 540,866 L 800,866 C 900,866 950,779.4 900,692.8 L 788,500"
|
||||||
|
fill="url(#LinearGradient_7)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient gradientTransform="matrix(104.391 -73.3432 73.3432 104.391 277.441 710.122)"
|
||||||
|
gradientUnits="userSpaceOnUse" id="LinearGradient" x1="0" x2="1" y1="0" y2="0">
|
||||||
|
<stop offset="0" stop-color="#373ebf" />
|
||||||
|
<stop offset="1" stop-color="#5058e6" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient gradientTransform="matrix(-173.747 557.324 -557.324 -173.747 508.829 258.172)"
|
||||||
|
gradientUnits="userSpaceOnUse" id="LinearGradient_2" x1="0" x2="1" y1="0" y2="0">
|
||||||
|
<stop offset="0" stop-color="#c2d6ff" />
|
||||||
|
<stop offset="1" stop-color="#646cff" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient gradientTransform="matrix(157.951 295.666 -295.666 157.951 382.944 193.642)"
|
||||||
|
gradientUnits="userSpaceOnUse" id="LinearGradient_3" x1="0" x2="1" y1="0" y2="0">
|
||||||
|
<stop offset="0" stop-color="#5058e6" />
|
||||||
|
<stop offset="1" stop-color="#373ebf" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient gradientTransform="matrix(-44.3023 219.578 -219.578 -44.3023 619.69 469.652)"
|
||||||
|
gradientUnits="userSpaceOnUse" id="LinearGradient_4" x1="0" x2="1" y1="0" y2="0">
|
||||||
|
<stop offset="0" stop-color="#91a7ff" />
|
||||||
|
<stop offset="1" stop-color="#5058e6" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient gradientTransform="matrix(125.52 334.256 -334.256 125.52 539.723 235.139)"
|
||||||
|
gradientUnits="userSpaceOnUse" id="LinearGradient_5" x1="0" x2="1" y1="0" y2="0">
|
||||||
|
<stop offset="0" stop-color="#646cff" />
|
||||||
|
<stop offset="1" stop-color="#c2d6ff" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient gradientTransform="matrix(-241.23 357.206 -357.206 -241.23 754.054 449.312)"
|
||||||
|
gradientUnits="userSpaceOnUse" id="LinearGradient_6" x1="0" x2="1" y1="0" y2="0">
|
||||||
|
<stop offset="0" stop-color="#c2d6ff" />
|
||||||
|
<stop offset="1" stop-color="#646cff" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient gradientTransform="matrix(125.978 210.065 -210.065 125.978 596.433 613.665)"
|
||||||
|
gradientUnits="userSpaceOnUse" id="LinearGradient_7" x1="0" x2="1" y1="0" y2="0">
|
||||||
|
<stop offset="0" stop-color="#373ebf" />
|
||||||
|
<stop offset="1" stop-color="#5058e6" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
58
apps/admin/src/App.vue
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { WatermarkProps } from 'naive-ui'
|
||||||
|
import { darkTheme, NConfigProvider } from 'naive-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { naiveDateLocales, naiveLocales } from './locales/naive'
|
||||||
|
import { useAppStore } from './store/modules/app'
|
||||||
|
import { useThemeStore } from './store/modules/theme'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'App',
|
||||||
|
})
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
const naiveDarkTheme = computed(() => (themeStore.darkMode ? darkTheme : undefined))
|
||||||
|
|
||||||
|
const naiveLocale = computed(() => {
|
||||||
|
return naiveLocales[appStore.locale]
|
||||||
|
})
|
||||||
|
|
||||||
|
const naiveDateLocale = computed(() => {
|
||||||
|
return naiveDateLocales[appStore.locale]
|
||||||
|
})
|
||||||
|
|
||||||
|
const watermarkProps = computed<WatermarkProps>(() => {
|
||||||
|
return {
|
||||||
|
content: themeStore.watermarkContent,
|
||||||
|
cross: true,
|
||||||
|
fullscreen: true,
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 16,
|
||||||
|
width: 384,
|
||||||
|
height: 384,
|
||||||
|
xOffset: 12,
|
||||||
|
yOffset: 60,
|
||||||
|
rotate: -15,
|
||||||
|
zIndex: 9999,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NConfigProvider
|
||||||
|
:theme="naiveDarkTheme"
|
||||||
|
:theme-overrides="themeStore.naiveTheme"
|
||||||
|
:locale="naiveLocale"
|
||||||
|
:date-locale="naiveDateLocale"
|
||||||
|
class="h-full"
|
||||||
|
>
|
||||||
|
<AppProvider>
|
||||||
|
<RouterView class="bg-layout" />
|
||||||
|
<NWatermark v-if="themeStore.watermark.visible" v-bind="watermarkProps" />
|
||||||
|
</AppProvider>
|
||||||
|
</NConfigProvider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
BIN
apps/admin/src/assets/imgs/reader-star.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
1
apps/admin/src/assets/svg-icon/activity.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||||
|
After Width: | Height: | Size: 202 B |
1
apps/admin/src/assets/svg-icon/at-sign.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="prefix__prefix__feather prefix__prefix__feather-at-sign"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 006 0v-1a10 10 0 10-3.92 7.94"/></svg>
|
||||||
|
After Width: | Height: | Size: 315 B |
1
apps/admin/src/assets/svg-icon/avatar.svg
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
1
apps/admin/src/assets/svg-icon/banner.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
1
apps/admin/src/assets/svg-icon/cast.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="prefix__prefix__feather prefix__prefix__feather-cast"><path d="M2 16.1A5 5 0 015.9 20M2 12.05A9 9 0 019.95 20M2 8V6a2 2 0 012-2h16a2 2 0 012 2v12a2 2 0 01-2 2h-6M2 20h.01"/></svg>
|
||||||
|
After Width: | Height: | Size: 345 B |
1
apps/admin/src/assets/svg-icon/chrome.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/><path d="M21.17 8H12M3.95 6.06L8.54 14m2.34 7.94L15.46 14"/></svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
1
apps/admin/src/assets/svg-icon/copy.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
1
apps/admin/src/assets/svg-icon/custom-icon.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24"><path fill="currentColor" d="M19 10c0 1.38-2.12 2.5-3.5 2.5s-2.75-1.12-2.75-2.5h-1.5c0 1.38-1.37 2.5-2.75 2.5S5 11.38 5 10h-.75c-.16.64-.25 1.31-.25 2a8 8 0 008 8 8 8 0 008-8c0-.69-.09-1.36-.25-2H19m-7-6C9.04 4 6.45 5.61 5.07 8h13.86C17.55 5.61 14.96 4 12 4m10 8a10 10 0 01-10 10A10 10 0 012 12 10 10 0 0112 2a10 10 0 0110 10m-10 5.23c-1.75 0-3.29-.73-4.19-1.81L9.23 14c.45.72 1.52 1.23 2.77 1.23s2.32-.51 2.77-1.23l1.42 1.42c-.9 1.08-2.44 1.81-4.19 1.81z"/></svg>
|
||||||
|
After Width: | Height: | Size: 544 B |
1
apps/admin/src/assets/svg-icon/empty-data.svg
Normal file
|
After Width: | Height: | Size: 77 KiB |
1
apps/admin/src/assets/svg-icon/expectation.svg
Normal file
|
After Width: | Height: | Size: 70 KiB |
1
apps/admin/src/assets/svg-icon/heart.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
|
After Width: | Height: | Size: 309 B |
1
apps/admin/src/assets/svg-icon/logo.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg"><path d="M81.28 55.9c-.1-11.67-2.93-22.55-9.37-32.38-1-1.5-2.14-2.86-2.5-4.71a8.1 8.1 0 014-8.61 7.89 7.89 0 019.3 1.23 35.999 35.999 0 015.9 8.83 75.18 75.18 0 018.44 28.58 83.211 83.211 0 01-5.23 36.74 102.983 102.983 0 01-3 7.28 1.2 1.2 0 000 1.41c9.58 13.3 21.76 23 37.85 27.24a54.37 54.37 0 0019.68 1.57 7.72 7.72 0 018.36 6.9 7.903 7.903 0 01-6.7 9 64.744 64.744 0 01-23-1.33 77.68 77.68 0 01-36.93-19.88 93.628 93.628 0 01-11.91-13.71 2.18 2.18 0 00-2.3-1.06 72.744 72.744 0 00-27.38 7.55c-11.6 6-20.67 14.58-26.4 26.45a10.134 10.134 0 01-3.7 4.7 8 8 0 01-9.19-.7 7.86 7.86 0 01-2.36-9.28 60.324 60.324 0 018.72-14.52c12.2-15.43 28.21-24.59 47.32-28.57A85.085 85.085 0 0173.07 87c.524.015 1-.307 1.18-.8a76.06 76.06 0 006.53-22.3c.351-2.652.518-5.325.5-8z" fill="currentColor"/><path d="M136.26 108.34a44.742 44.742 0 01-11.13-2.87 46.108 46.108 0 01-19.66-13.76 8 8 0 015.72-13.22 7.93 7.93 0 016.54 2.93 33.27 33.27 0 0018.87 10.75c1.546.155 3.058.553 4.48 1.18a8.08 8.08 0 013.84 9.21c-.92 3.52-4.13 5.81-8.66 5.78zm-80.6-75.02a7.61 7.61 0 016.64 5 49.139 49.139 0 013.64 17 46.33 46.33 0 01-2.46 17.28c-2 5.77-8.24 7.79-12.89 4.15a8.1 8.1 0 01-2.39-9 31.679 31.679 0 001.68-12.36 35.77 35.77 0 00-2.43-11c-2.1-5.45 1.75-11.07 8.21-11.07zm22.26 93.25a8 8 0 01-6.68 7.86 32.88 32.88 0 00-19.7 12.19 8.13 8.13 0 01-11.21 1.62 8 8 0 01-1.41-11.58A51.043 51.043 0 0154 123.81a45.842 45.842 0 0114-5.1c5.35-1.04 9.91 2.56 9.92 7.86z" fill="currentColor"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
1
apps/admin/src/assets/svg-icon/network-error.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
1
apps/admin/src/assets/svg-icon/no-icon.svg
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
1
apps/admin/src/assets/svg-icon/no-permission.svg
Normal file
|
After Width: | Height: | Size: 50 KiB |
1
apps/admin/src/assets/svg-icon/not-found.svg
Normal file
|
After Width: | Height: | Size: 33 KiB |
1
apps/admin/src/assets/svg-icon/service-error.svg
Normal file
|
After Width: | Height: | Size: 74 KiB |
1
apps/admin/src/assets/svg-icon/wind.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-wind"><path d="M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2"></path></svg>
|
||||||
|
After Width: | Height: | Size: 327 B |
45
apps/admin/src/components/advanced/table-column-setting.vue
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts" generic="T extends Record<string, unknown>, K = never">
|
||||||
|
import { VueDraggable } from 'vue-draggable-plus'
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'TableColumnSetting',
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NPopover placement="bottom-end" trigger="click">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton size="small">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ant-design-setting-outlined class="text-icon" />
|
||||||
|
</template>
|
||||||
|
{{ $t('common.columnSetting') }}
|
||||||
|
</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)"
|
||||||
|
: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'">
|
||||||
|
<component :is="item.title" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ item.title }}
|
||||||
|
</template>
|
||||||
|
</NCheckbox>
|
||||||
|
</div>
|
||||||
|
</VueDraggable>
|
||||||
|
</NPopover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'TableHeaderOperation',
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
itemAlign?: NaiveUI.Align
|
||||||
|
disabledDelete?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'add'): void
|
||||||
|
(e: 'delete'): void
|
||||||
|
(e: 'refresh'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
|
||||||
|
default: () => [],
|
||||||
|
})
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
emit('add')
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchDelete() {
|
||||||
|
emit('delete')
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
emit('refresh')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NSpace :align="itemAlign" wrap justify="end" class="lt-sm:w-200px">
|
||||||
|
<slot name="prefix" />
|
||||||
|
<slot name="default">
|
||||||
|
<NButton size="small" ghost type="primary" @click="add">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-plus class="text-icon" />
|
||||||
|
</template>
|
||||||
|
{{ $t('common.add') }}
|
||||||
|
</NButton>
|
||||||
|
<NPopconfirm @positive-click="batchDelete">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton size="small" ghost type="error" :disabled="disabledDelete">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-delete class="text-icon" />
|
||||||
|
</template>
|
||||||
|
{{ $t('common.batchDelete') }}
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
{{ $t('common.confirmDelete') }}
|
||||||
|
</NPopconfirm>
|
||||||
|
</slot>
|
||||||
|
<NButton size="small" @click="refresh">
|
||||||
|
<template #icon>
|
||||||
|
<icon-mdi-refresh class="text-icon" :class="{ 'animate-spin': loading }" />
|
||||||
|
</template>
|
||||||
|
{{ $t('common.refresh') }}
|
||||||
|
</NButton>
|
||||||
|
<TableColumnSetting v-model:columns="columns" />
|
||||||
|
<slot name="suffix" />
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
39
apps/admin/src/components/common/app-provider.vue
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useDialog, useLoadingBar, useMessage, useNotification } from 'naive-ui'
|
||||||
|
import { createTextVNode, defineComponent } from 'vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'AppProvider',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ContextHolder = defineComponent({
|
||||||
|
name: 'ContextHolder',
|
||||||
|
setup() {
|
||||||
|
function register() {
|
||||||
|
window.$loadingBar = useLoadingBar()
|
||||||
|
window.$dialog = useDialog()
|
||||||
|
window.$message = useMessage()
|
||||||
|
window.$notification = useNotification()
|
||||||
|
}
|
||||||
|
|
||||||
|
register()
|
||||||
|
|
||||||
|
return () => createTextVNode()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NLoadingBarProvider>
|
||||||
|
<NDialogProvider>
|
||||||
|
<NNotificationProvider>
|
||||||
|
<NMessageProvider>
|
||||||
|
<ContextHolder />
|
||||||
|
<slot />
|
||||||
|
</NMessageProvider>
|
||||||
|
</NNotificationProvider>
|
||||||
|
</NDialogProvider>
|
||||||
|
</NLoadingBarProvider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
17
apps/admin/src/components/common/dark-mode-container.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ name: 'DarkModeContainer' })
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
inverted?: boolean
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-container text-base-text transition-300" :class="{ 'bg-inverted text-#1f1f1f': inverted }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
45
apps/admin/src/components/common/exception-base.vue
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
defineOptions({ name: 'ExceptionBase' })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
type ExceptionType = '403' | '404' | '500'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Exception type
|
||||||
|
*
|
||||||
|
* - 403: no permission
|
||||||
|
* - 404: not found
|
||||||
|
* - 500: service error
|
||||||
|
*/
|
||||||
|
type: ExceptionType
|
||||||
|
}
|
||||||
|
|
||||||
|
const { routerPushByKey } = useRouterPush()
|
||||||
|
|
||||||
|
const iconMap: Record<ExceptionType, string> = {
|
||||||
|
403: 'no-permission',
|
||||||
|
404: 'not-found',
|
||||||
|
500: 'service-error',
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = computed(() => iconMap[props.type])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="size-full min-h-520px flex-col-center gap-24px overflow-hidden">
|
||||||
|
<div class="flex text-400px text-primary">
|
||||||
|
<SvgIcon :local-icon="icon" />
|
||||||
|
</div>
|
||||||
|
<NButton type="primary" @click="routerPushByKey('root')">
|
||||||
|
{{ $t('common.backToHome') }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
22
apps/admin/src/components/common/full-screen.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'FullScreen',
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
full?: boolean
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ButtonIcon :key="String(full)" :tooltip-content="full ? $t('icon.fullscreenExit') : $t('icon.fullscreen')">
|
||||||
|
<icon-gridicons-fullscreen-exit v-if="full" />
|
||||||
|
<icon-gridicons-fullscreen v-else />
|
||||||
|
</ButtonIcon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
42
apps/admin/src/components/common/icon-tooltip.vue
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { PopoverPlacement } from 'naive-ui'
|
||||||
|
import { computed, useSlots } from 'vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'IconTooltip' })
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
icon: 'mdi-help-circle',
|
||||||
|
localIcon: '',
|
||||||
|
desc: '',
|
||||||
|
placement: 'top',
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon?: string
|
||||||
|
localIcon?: string
|
||||||
|
desc?: string
|
||||||
|
placement?: PopoverPlacement
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
62
apps/admin/src/components/common/lang-switch.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'LangSwitch',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showTooltip: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Current language */
|
||||||
|
lang: App.I18n.LangType
|
||||||
|
/** Language options */
|
||||||
|
langOptions: App.I18n.LangOption[]
|
||||||
|
/** Show tooltip */
|
||||||
|
showTooltip?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'changeLang', lang: App.I18n.LangType): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipContent = computed(() => {
|
||||||
|
if (!props.showTooltip)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
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="dropdownOptions" trigger="hover" @select="changeLang">
|
||||||
|
<div>
|
||||||
|
<ButtonIcon :tooltip-content="tooltipContent" tooltip-placement="left">
|
||||||
|
<SvgIcon icon="heroicons:language" />
|
||||||
|
</ButtonIcon>
|
||||||
|
</div>
|
||||||
|
</NDropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
53
apps/admin/src/components/common/menu-toggler.vue
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
defineOptions({ name: 'MenuToggler' })
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
arrowIcon: false,
|
||||||
|
zIndex: 98,
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Show collapsed icon */
|
||||||
|
collapsed?: boolean
|
||||||
|
/** Arrow style icon */
|
||||||
|
arrowIcon?: boolean
|
||||||
|
zIndex?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type NumberBool = 0 | 1
|
||||||
|
|
||||||
|
const icon = computed(() => {
|
||||||
|
const icons: Record<NumberBool, Record<NumberBool, string>> = {
|
||||||
|
0: {
|
||||||
|
0: 'line-md:menu-fold-left',
|
||||||
|
1: 'line-md:menu-fold-right',
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
0: 'ph-caret-double-left-bold',
|
||||||
|
1: 'ph-caret-double-right-bold',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrowIcon = Number(props.arrowIcon || false) as NumberBool
|
||||||
|
|
||||||
|
const collapsed = Number(props.collapsed || false) as NumberBool
|
||||||
|
|
||||||
|
return icons[arrowIcon][collapsed]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ButtonIcon
|
||||||
|
:key="String(collapsed)"
|
||||||
|
:tooltip-content="collapsed ? $t('icon.expand') : $t('icon.collapse')"
|
||||||
|
tooltip-placement="bottom-start"
|
||||||
|
:z-index="zIndex"
|
||||||
|
>
|
||||||
|
<SvgIcon :icon="icon" />
|
||||||
|
</ButtonIcon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
26
apps/admin/src/components/common/pin-toggler.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
defineOptions({ name: 'PinToggler' })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = computed(() => (props.pin ? 'mdi-pin-off' : 'mdi-pin'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ButtonIcon
|
||||||
|
:tooltip-content="pin ? $t('icon.unpin') : $t('icon.pin')"
|
||||||
|
tooltip-placement="bottom-start"
|
||||||
|
:z-index="100"
|
||||||
|
>
|
||||||
|
<SvgIcon :icon="icon" />
|
||||||
|
</ButtonIcon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
21
apps/admin/src/components/common/reload-button.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ReloadButton',
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ButtonIcon :tooltip-content="$t('icon.reload')">
|
||||||
|
<icon-ant-design-reload-outlined :class="{ 'animate-spin animate-duration-750': loading }" />
|
||||||
|
</ButtonIcon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
160
apps/admin/src/components/common/system-logo.vue
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-logo">
|
||||||
|
<svg
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
version="1.1"
|
||||||
|
viewBox="0 0 1000 1000"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
>
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M 200,866 C 100,866 50,779.4 100,692.8 L 200,519.6 C 220,485 240,490 265,499.6 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
|
||||||
|
fill="url(#LinearGradient)"
|
||||||
|
fill-rule="nonzero"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 420,866 C 455,861 478,846 500,827 C 614,696 615,597 500,517 C 394,444 333,374 380,207.82 L 260,415.67 C 240.22,450 254.37,465.1 275.28,481.79 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
|
||||||
|
fill="url(#LinearGradient_2)"
|
||||||
|
fill-rule="nonzero"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 500,517 C 394,444 333,374 380,207.82 L 400,173.2 C 367,295 421,350 603,428 C 572,440 524,474 500,517"
|
||||||
|
fill="url(#LinearGradient_3)"
|
||||||
|
fill-rule="nonzero"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 572,440 524,474 500,517 C 615,597 614,696 500,827"
|
||||||
|
fill="url(#LinearGradient_4)"
|
||||||
|
fill-rule="nonzero"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 400,173.2 C 367,295 421,350 603,428 C 690,389, 750,445 788,500 L 600,173.2 C 550,86.6 450,86.6 400,173.2"
|
||||||
|
fill="url(#LinearGradient_5)"
|
||||||
|
fill-rule="nonzero"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 690,389, 750,445 788,500 C 816,554 797,606 750,640 L 500,827"
|
||||||
|
fill="url(#LinearGradient_6)"
|
||||||
|
fill-rule="nonzero"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 788,500 C 816,554 797,606 750,640 L 500,827 C 497,851 513,862 540,866 L 800,866 C 900,866 950,779.4 900,692.8 L 788,500"
|
||||||
|
fill="url(#LinearGradient_7)"
|
||||||
|
fill-rule="nonzero"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="LinearGradient"
|
||||||
|
gradientTransform="matrix(104.391 -73.3432 73.3432 104.391 277.441 710.122)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="0"
|
||||||
|
x2="1"
|
||||||
|
y1="0"
|
||||||
|
y2="0"
|
||||||
|
>
|
||||||
|
<stop offset="0" stop-color="var(--logo-color-700)" />
|
||||||
|
<stop offset="1" stop-color="var(--logo-color-600)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="LinearGradient_2"
|
||||||
|
gradientTransform="matrix(-173.747 557.324 -557.324 -173.747 508.829 258.172)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="0"
|
||||||
|
x2="1"
|
||||||
|
y1="0"
|
||||||
|
y2="0"
|
||||||
|
>
|
||||||
|
<stop offset="0" stop-color="var(--logo-color-300)" />
|
||||||
|
<stop offset="1" stop-color="var(--logo-color-500)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="LinearGradient_3"
|
||||||
|
gradientTransform="matrix(157.951 295.666 -295.666 157.951 382.944 193.642)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="0"
|
||||||
|
x2="1"
|
||||||
|
y1="0"
|
||||||
|
y2="0"
|
||||||
|
>
|
||||||
|
<stop offset="0" stop-color="var(--logo-color-600)" />
|
||||||
|
<stop offset="1" stop-color="var(--logo-color-700)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="LinearGradient_4"
|
||||||
|
gradientTransform="matrix(-44.3023 219.578 -219.578 -44.3023 619.69 469.652)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="0"
|
||||||
|
x2="1"
|
||||||
|
y1="0"
|
||||||
|
y2="0"
|
||||||
|
>
|
||||||
|
<stop offset="0" stop-color="var(--logo-color-400)" />
|
||||||
|
<stop offset="1" stop-color="var(--logo-color-600)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="LinearGradient_5"
|
||||||
|
gradientTransform="matrix(125.52 334.256 -334.256 125.52 539.723 235.139)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="0"
|
||||||
|
x2="1"
|
||||||
|
y1="0"
|
||||||
|
y2="0"
|
||||||
|
>
|
||||||
|
<stop offset="0" stop-color="var(--logo-color-500)" />
|
||||||
|
<stop offset="1" stop-color="var(--logo-color-300)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="LinearGradient_6"
|
||||||
|
gradientTransform="matrix(-241.23 357.206 -357.206 -241.23 754.054 449.312)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="0"
|
||||||
|
x2="1"
|
||||||
|
y1="0"
|
||||||
|
y2="0"
|
||||||
|
>
|
||||||
|
<stop offset="0" stop-color="var(--logo-color-300)" />
|
||||||
|
<stop offset="1" stop-color="var(--logo-color-500)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="LinearGradient_7"
|
||||||
|
gradientTransform="matrix(125.978 210.065 -210.065 125.978 596.433 613.665)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="0"
|
||||||
|
x2="1"
|
||||||
|
y1="0"
|
||||||
|
y2="0"
|
||||||
|
>
|
||||||
|
<stop offset="0" stop-color="var(--logo-color-700)" />
|
||||||
|
<stop offset="1" stop-color="var(--logo-color-600)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-logo {
|
||||||
|
--logo-color-300: rgb(var(--primary-300-color));
|
||||||
|
--logo-color-400: rgb(var(--primary-400-color));
|
||||||
|
--logo-color-500: rgb(var(--primary-500-color));
|
||||||
|
--logo-color-600: rgb(var(--primary-600-color));
|
||||||
|
--logo-color-700: rgb(var(--primary-700-color));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
57
apps/admin/src/components/common/theme-schema-switch.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PopoverPlacement } from 'naive-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
defineOptions({ name: 'ThemeSchemaSwitch' })
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showTooltip: true,
|
||||||
|
tooltipPlacement: 'bottom',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Theme schema */
|
||||||
|
themeSchema: UnionKey.ThemeScheme
|
||||||
|
/** Show tooltip */
|
||||||
|
showTooltip?: boolean
|
||||||
|
/** Tooltip placement */
|
||||||
|
tooltipPlacement?: PopoverPlacement
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'switch'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwitch() {
|
||||||
|
emit('switch')
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons: Record<UnionKey.ThemeScheme, string> = {
|
||||||
|
light: 'material-symbols:sunny',
|
||||||
|
dark: 'material-symbols:nightlight-rounded',
|
||||||
|
auto: 'material-symbols:hdr-auto',
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = computed(() => icons[props.themeSchema])
|
||||||
|
|
||||||
|
const tooltipContent = computed(() => {
|
||||||
|
if (!props.showTooltip)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
return $t('icon.themeSchema')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ButtonIcon
|
||||||
|
:icon="icon"
|
||||||
|
:tooltip-content="tooltipContent"
|
||||||
|
:tooltip-placement="tooltipPlacement"
|
||||||
|
@click="handleSwitch"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
54
apps/admin/src/components/custom/better-scroll.vue
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Options } from '@better-scroll/core'
|
||||||
|
import BScroll from '@better-scroll/core'
|
||||||
|
import { useElementSize } from '@vueuse/core'
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'BetterScroll' })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* BetterScroll options
|
||||||
|
*
|
||||||
|
* @link https://better-scroll.github.io/docs/zh-CN/guide/base-scroll-options.html
|
||||||
|
*/
|
||||||
|
options: Options
|
||||||
|
}
|
||||||
|
|
||||||
|
const bsWrapper = ref<HTMLElement>()
|
||||||
|
const bsContent = ref<HTMLElement>()
|
||||||
|
const { width: wrapWidth } = useElementSize(bsWrapper)
|
||||||
|
const { width, height } = useElementSize(bsContent)
|
||||||
|
|
||||||
|
const instance = ref<BScroll>()
|
||||||
|
const isScrollY = computed(() => Boolean(props.options.scrollY))
|
||||||
|
|
||||||
|
function initBetterScroll() {
|
||||||
|
if (!bsWrapper.value)
|
||||||
|
return
|
||||||
|
instance.value = new BScroll(bsWrapper.value, props.options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh BS when scroll element size changed
|
||||||
|
watch([() => wrapWidth.value, () => width.value, () => height.value], () => {
|
||||||
|
instance.value?.refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initBetterScroll()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ instance })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="bsWrapper" class="h-full text-left">
|
||||||
|
<div ref="bsContent" class="inline-block" :class="{ 'h-full': !isScrollY }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
48
apps/admin/src/components/custom/button-icon.vue
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PopoverPlacement } from 'naive-ui'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ButtonIcon',
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
class: '',
|
||||||
|
icon: '',
|
||||||
|
tooltipContent: '',
|
||||||
|
tooltipPlacement: 'bottom',
|
||||||
|
zIndex: 98,
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Button class */
|
||||||
|
class?: string
|
||||||
|
/** Iconify icon name */
|
||||||
|
icon?: string
|
||||||
|
/** Tooltip content */
|
||||||
|
tooltipContent?: string
|
||||||
|
/** Tooltip placement */
|
||||||
|
tooltipPlacement?: PopoverPlacement
|
||||||
|
zIndex?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CLASS = 'h-[36px] text-icon'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NTooltip :placement="tooltipPlacement" :z-index="zIndex" :disabled="!tooltipContent">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton quaternary :class="twMerge(DEFAULT_CLASS, props.class)" v-bind="$attrs">
|
||||||
|
<div class="flex-center gap-8px">
|
||||||
|
<slot>
|
||||||
|
<SvgIcon :icon="icon" />
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
{{ tooltipContent }}
|
||||||
|
</NTooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
88
apps/admin/src/components/custom/count-to.vue
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { TransitionPresets, useTransition } from '@vueuse/core'
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'CountTo',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
startValue: 0,
|
||||||
|
endValue: 2021,
|
||||||
|
duration: 1500,
|
||||||
|
autoplay: true,
|
||||||
|
decimals: 0,
|
||||||
|
prefix: '',
|
||||||
|
suffix: '',
|
||||||
|
separator: ',',
|
||||||
|
decimal: '.',
|
||||||
|
useEasing: true,
|
||||||
|
transition: 'linear',
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
startValue?: number
|
||||||
|
endValue?: number
|
||||||
|
duration?: number
|
||||||
|
autoplay?: boolean
|
||||||
|
decimals?: number
|
||||||
|
prefix?: string
|
||||||
|
suffix?: string
|
||||||
|
separator?: string
|
||||||
|
decimal?: string
|
||||||
|
useEasing?: boolean
|
||||||
|
transition?: keyof typeof TransitionPresets
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = ref(props.startValue)
|
||||||
|
|
||||||
|
const transition = computed(() => (props.useEasing ? TransitionPresets[props.transition] : undefined))
|
||||||
|
|
||||||
|
const outputValue = useTransition(source, {
|
||||||
|
disabled: false,
|
||||||
|
duration: props.duration,
|
||||||
|
transition: transition.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
const value = computed(() => formatValue(outputValue.value))
|
||||||
|
|
||||||
|
function formatValue(num: number) {
|
||||||
|
const { decimals, decimal, separator, suffix, prefix } = props
|
||||||
|
|
||||||
|
let number = num.toFixed(decimals)
|
||||||
|
number = String(number)
|
||||||
|
|
||||||
|
const x = number.split('.')
|
||||||
|
let x1 = x[0]
|
||||||
|
const x2 = x.length > 1 ? decimal + x[1] : ''
|
||||||
|
const rgx = /(\d+)(\d{3})/
|
||||||
|
if (separator) {
|
||||||
|
while (rgx.test(x1)) {
|
||||||
|
x1 = x1.replace(rgx, `$1${separator}$2`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix + x1 + x2 + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
await nextTick()
|
||||||
|
source.value = props.endValue
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.startValue, () => props.endValue],
|
||||||
|
() => {
|
||||||
|
if (props.autoplay) {
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span>{{ value }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
22
apps/admin/src/components/custom/look-forward.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'LookForward',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="size-full min-h-520px flex-col-center gap-24px overflow-hidden">
|
||||||
|
<div class="flex text-400px text-primary">
|
||||||
|
<SvgIcon local-icon="expectation" />
|
||||||
|
</div>
|
||||||
|
<slot>
|
||||||
|
<h3 class="text-28px text-primary font-500">
|
||||||
|
{{ $t('common.lookForward') }}
|
||||||
|
</h3>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
13
apps/admin/src/components/custom/soybean-avatar.vue
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({
|
||||||
|
name: 'ReaderStarAvatar',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="size-72px overflow-hidden rd-1/2">
|
||||||
|
<img src="@/assets/imgs/reader-star.jpg" class="size-full">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
54
apps/admin/src/components/custom/svg-icon.vue
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { computed, useAttrs } from 'vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'SvgIcon', inheritAttrs: false })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props
|
||||||
|
*
|
||||||
|
* - Support iconify and local svg icon
|
||||||
|
* - If icon and localIcon are passed at the same time, localIcon will be rendered first
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
/** Iconify icon name */
|
||||||
|
icon?: string
|
||||||
|
/** Local svg icon name */
|
||||||
|
localIcon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
|
||||||
|
const bindAttrs = computed<{ class: string, style: string }>(() => ({
|
||||||
|
class: (attrs.class as string) || '',
|
||||||
|
style: (attrs.style as string) || '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const symbolId = computed(() => {
|
||||||
|
const { VITE_ICON_LOCAL_PREFIX: prefix } = import.meta.env
|
||||||
|
|
||||||
|
const defaultLocalIcon = 'no-icon'
|
||||||
|
|
||||||
|
const icon = props.localIcon || defaultLocalIcon
|
||||||
|
|
||||||
|
return `#${prefix}-${icon}`
|
||||||
|
})
|
||||||
|
|
||||||
|
/** If localIcon is passed, render localIcon first */
|
||||||
|
const renderLocalIcon = computed(() => props.localIcon || !props.icon)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-if="renderLocalIcon">
|
||||||
|
<svg aria-hidden="true" width="1em" height="1em" v-bind="bindAttrs">
|
||||||
|
<use :xlink:href="symbolId" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
61
apps/admin/src/components/custom/wave-bg.vue
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { getPaletteColorByNumber } from '@sa/color'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'WaveBg' })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Theme color */
|
||||||
|
themeColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const lightColor = computed(() => getPaletteColorByNumber(props.themeColor, 200))
|
||||||
|
const darkColor = computed(() => getPaletteColorByNumber(props.themeColor, 500))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="absolute-lt z-1 size-full overflow-hidden">
|
||||||
|
<div class="absolute -right-300px -top-900px lt-sm:(-right-100px -top-1170px)">
|
||||||
|
<svg height="1337" width="1337">
|
||||||
|
<defs>
|
||||||
|
<path
|
||||||
|
id="path-1"
|
||||||
|
opacity="1"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M1337,668.5 C1337,1037.455193874239 1037.455193874239,1337 668.5,1337 C523.6725684305388,1337 337,1236 370.50000000000006,1094 C434.03835568300906,824.6732385973953 6.906089672974592e-14,892.6277623047779 0,668.5000000000001 C0,299.5448061257611 299.5448061257609,1.1368683772161603e-13 668.4999999999999,0 C1037.455193874239,0 1337,299.544806125761 1337,668.5Z"
|
||||||
|
/>
|
||||||
|
<linearGradient id="linearGradient-2" x1="0.79" y1="0.62" x2="0.21" y2="0.86">
|
||||||
|
<stop offset="0" :stop-color="lightColor" stop-opacity="1" />
|
||||||
|
<stop offset="1" :stop-color="darkColor" stop-opacity="1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g opacity="1">
|
||||||
|
<use xlink:href="#path-1" fill="url(#linearGradient-2)" fill-opacity="1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="absolute -bottom-400px -left-200px lt-sm:(-bottom-760px -left-100px)">
|
||||||
|
<svg height="896" width="967.8852157128662">
|
||||||
|
<defs>
|
||||||
|
<path
|
||||||
|
id="path-2"
|
||||||
|
opacity="1"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M896,448 C1142.6325445712241,465.5747656464056 695.2579309733121,896 448,896 C200.74206902668806,896 5.684341886080802e-14,695.2579309733121 0,448.0000000000001 C0,200.74206902668806 200.74206902668791,5.684341886080802e-14 447.99999999999994,0 C695.2579309733121,0 475,418 896,448Z"
|
||||||
|
/>
|
||||||
|
<linearGradient id="linearGradient-3" x1="0.5" y1="0" x2="0.5" y2="1">
|
||||||
|
<stop offset="0" :stop-color="darkColor" stop-opacity="1" />
|
||||||
|
<stop offset="1" :stop-color="lightColor" stop-opacity="1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g opacity="1">
|
||||||
|
<use xlink:href="#path-2" fill="url(#linearGradient-3)" fill-opacity="1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
71
apps/admin/src/constants/app.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { transformRecordToOption } from '@/utils/common'
|
||||||
|
|
||||||
|
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.appearance.themeSchema.light',
|
||||||
|
dark: 'theme.appearance.themeSchema.dark',
|
||||||
|
auto: 'theme.appearance.themeSchema.auto',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeSchemaOptions = transformRecordToOption(themeSchemaRecord)
|
||||||
|
|
||||||
|
export const loginModuleRecord: Record<UnionKey.LoginModule, App.I18n.I18nKey> = {
|
||||||
|
'pwd-login': 'page.login.pwdLogin.title',
|
||||||
|
'code-login': 'page.login.codeLogin.title',
|
||||||
|
'register': 'page.login.register.title',
|
||||||
|
'reset-pwd': 'page.login.resetPwd.title',
|
||||||
|
'bind-wechat': 'page.login.bindWeChat.title',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeLayoutModeRecord: Record<UnionKey.ThemeLayoutMode, App.I18n.I18nKey> = {
|
||||||
|
'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.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.layout.tab.mode.chrome',
|
||||||
|
button: 'theme.layout.tab.mode.button',
|
||||||
|
slider: 'theme.layout.tab.mode.slider',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord)
|
||||||
|
|
||||||
|
export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = {
|
||||||
|
'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 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' },
|
||||||
|
]
|
||||||
8
apps/admin/src/constants/common.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { transformRecordToOption } from '@/utils/common'
|
||||||
|
|
||||||
|
export const yesOrNoRecord: Record<CommonType.YesOrNo, App.I18n.I18nKey> = {
|
||||||
|
Y: 'common.yesOrNo.yes',
|
||||||
|
N: 'common.yesOrNo.no',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const yesOrNoOptions = transformRecordToOption(yesOrNoRecord)
|
||||||
25
apps/admin/src/constants/reg.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export const REG_USER_NAME = /^[\u4E00-\u9FA5\w-]{4,16}$/
|
||||||
|
|
||||||
|
/** Phone reg */
|
||||||
|
export const REG_PHONE
|
||||||
|
= /^1((3\d)|(4[014-9])|(5[0-35-9])|(6[2567])|(7[0-8])|(8\d)|(9[0-35-9]))\d{8}$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password reg
|
||||||
|
*
|
||||||
|
* 6-18 characters, including letters, numbers, and underscores
|
||||||
|
*/
|
||||||
|
export const REG_PWD = /^\w{6,18}$/
|
||||||
|
|
||||||
|
/** Email reg */
|
||||||
|
export const REG_EMAIL = /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/
|
||||||
|
|
||||||
|
/** Six digit code reg */
|
||||||
|
export const REG_CODE_SIX = /^\d{6}$/
|
||||||
|
|
||||||
|
/** Four digit code reg */
|
||||||
|
export const REG_CODE_FOUR = /^\d{4}$/
|
||||||
|
|
||||||
|
/** Url reg */
|
||||||
|
export const REG_URL
|
||||||
|
= /(((^https?:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-]*)?\??[-+=&;%@.\w]*(?:#\w*)?)?)$/
|
||||||
7
apps/admin/src/enum/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export enum SetupStoreId {
|
||||||
|
App = 'app-store',
|
||||||
|
Theme = 'theme-store',
|
||||||
|
Auth = 'auth-store',
|
||||||
|
Route = 'route-store',
|
||||||
|
Tab = 'tab-store',
|
||||||
|
}
|
||||||
21
apps/admin/src/hooks/business/auth.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
function hasAuth(codes: string | string[]) {
|
||||||
|
if (!authStore.isLogin) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof codes === 'string') {
|
||||||
|
return authStore.userInfo.buttons.includes(codes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes.some(code => authStore.userInfo.buttons.includes(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasAuth,
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/admin/src/hooks/business/captcha.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { useCountDown, useLoading } from '@sa/hooks'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { REG_PHONE } from '@/constants/reg'
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
export function useCaptcha() {
|
||||||
|
const { loading, startLoading, endLoading } = useLoading()
|
||||||
|
const { count, start, stop, isCounting } = useCountDown(10)
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
let text = $t('page.login.codeLogin.getCode')
|
||||||
|
|
||||||
|
const countingLabel = $t('page.login.codeLogin.reGetCode', { time: count.value })
|
||||||
|
|
||||||
|
if (loading.value) {
|
||||||
|
text = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCounting.value) {
|
||||||
|
text = countingLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
})
|
||||||
|
|
||||||
|
function isPhoneValid(phone: string) {
|
||||||
|
if (phone.trim() === '') {
|
||||||
|
window.$message?.error?.($t('form.phone.required'))
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!REG_PHONE.test(phone)) {
|
||||||
|
window.$message?.error?.($t('form.phone.invalid'))
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCaptcha(phone: string) {
|
||||||
|
const valid = isPhoneValid(phone)
|
||||||
|
|
||||||
|
if (!valid || loading.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startLoading()
|
||||||
|
|
||||||
|
// request
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
window.$message?.success?.($t('page.login.codeLogin.sendCodeSuccess'))
|
||||||
|
|
||||||
|
start()
|
||||||
|
|
||||||
|
endLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
isCounting,
|
||||||
|
loading,
|
||||||
|
getCaptcha,
|
||||||
|
}
|
||||||
|
}
|
||||||
233
apps/admin/src/hooks/common/echarts.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import type {
|
||||||
|
BarSeriesOption,
|
||||||
|
GaugeSeriesOption,
|
||||||
|
LineSeriesOption,
|
||||||
|
PictorialBarSeriesOption,
|
||||||
|
PieSeriesOption,
|
||||||
|
RadarSeriesOption,
|
||||||
|
ScatterSeriesOption,
|
||||||
|
} from 'echarts/charts'
|
||||||
|
import type {
|
||||||
|
DatasetComponentOption,
|
||||||
|
GridComponentOption,
|
||||||
|
LegendComponentOption,
|
||||||
|
TitleComponentOption,
|
||||||
|
ToolboxComponentOption,
|
||||||
|
TooltipComponentOption,
|
||||||
|
} from 'echarts/components'
|
||||||
|
import { useElementSize } from '@vueuse/core'
|
||||||
|
import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts'
|
||||||
|
import {
|
||||||
|
DatasetComponent,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TitleComponent,
|
||||||
|
ToolboxComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
TransformComponent,
|
||||||
|
} from 'echarts/components'
|
||||||
|
import * as echarts from 'echarts/core'
|
||||||
|
import { LabelLayout, UniversalTransition } from 'echarts/features'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
import { computed, effectScope, nextTick, onScopeDispose, shallowRef, watch } from 'vue'
|
||||||
|
import { useThemeStore } from '@/store/modules/theme'
|
||||||
|
|
||||||
|
export type ECOption = echarts.ComposeOption<
|
||||||
|
| BarSeriesOption
|
||||||
|
| LineSeriesOption
|
||||||
|
| PieSeriesOption
|
||||||
|
| ScatterSeriesOption
|
||||||
|
| PictorialBarSeriesOption
|
||||||
|
| RadarSeriesOption
|
||||||
|
| GaugeSeriesOption
|
||||||
|
| TitleComponentOption
|
||||||
|
| LegendComponentOption
|
||||||
|
| TooltipComponentOption
|
||||||
|
| GridComponentOption
|
||||||
|
| ToolboxComponentOption
|
||||||
|
| DatasetComponentOption
|
||||||
|
>
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
TitleComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
GridComponent,
|
||||||
|
DatasetComponent,
|
||||||
|
TransformComponent,
|
||||||
|
ToolboxComponent,
|
||||||
|
BarChart,
|
||||||
|
LineChart,
|
||||||
|
PieChart,
|
||||||
|
ScatterChart,
|
||||||
|
PictorialBarChart,
|
||||||
|
RadarChart,
|
||||||
|
GaugeChart,
|
||||||
|
LabelLayout,
|
||||||
|
UniversalTransition,
|
||||||
|
CanvasRenderer,
|
||||||
|
])
|
||||||
|
|
||||||
|
interface ChartHooks {
|
||||||
|
onRender?: (chart: echarts.ECharts) => void | Promise<void>
|
||||||
|
onUpdated?: (chart: echarts.ECharts) => void | Promise<void>
|
||||||
|
onDestroy?: (chart: echarts.ECharts) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* use echarts
|
||||||
|
*
|
||||||
|
* @param optionsFactory echarts options factory function
|
||||||
|
* @param darkMode dark mode
|
||||||
|
*/
|
||||||
|
export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: ChartHooks = {}) {
|
||||||
|
const scope = effectScope()
|
||||||
|
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const darkMode = computed(() => themeStore.darkMode)
|
||||||
|
|
||||||
|
const domRef = shallowRef<HTMLElement | null>(null)
|
||||||
|
const initialSize = { width: 0, height: 0 }
|
||||||
|
const { width, height } = useElementSize(domRef, initialSize)
|
||||||
|
|
||||||
|
const chart = shallowRef<echarts.ECharts | null>(null)
|
||||||
|
const chartOptions: T = optionsFactory()
|
||||||
|
|
||||||
|
const {
|
||||||
|
onRender = (instance) => {
|
||||||
|
const textColor = darkMode.value ? 'rgb(224, 224, 224)' : 'rgb(31, 31, 31)'
|
||||||
|
const maskColor = darkMode.value ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.8)'
|
||||||
|
|
||||||
|
instance.showLoading({
|
||||||
|
color: themeStore.themeColor,
|
||||||
|
textColor,
|
||||||
|
fontSize: 14,
|
||||||
|
maskColor,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onUpdated = (instance) => {
|
||||||
|
instance.hideLoading()
|
||||||
|
},
|
||||||
|
onDestroy,
|
||||||
|
} = hooks
|
||||||
|
|
||||||
|
/** is chart rendered */
|
||||||
|
function isRendered() {
|
||||||
|
return Boolean(domRef.value && chart.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* update chart options
|
||||||
|
*
|
||||||
|
* @param callback callback function
|
||||||
|
*/
|
||||||
|
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
|
||||||
|
const updatedOpts = callback(chartOptions, optionsFactory)
|
||||||
|
|
||||||
|
Object.assign(chartOptions, updatedOpts)
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
if (!isRendered())
|
||||||
|
return
|
||||||
|
|
||||||
|
if (isRendered()) {
|
||||||
|
chart.value?.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.value?.setOption({ ...updatedOpts, backgroundColor: 'transparent' })
|
||||||
|
|
||||||
|
await onUpdated?.(chart.value!)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOptions(options: T) {
|
||||||
|
chart.value?.setOption(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** render chart */
|
||||||
|
async function render() {
|
||||||
|
if (isRendered())
|
||||||
|
return
|
||||||
|
|
||||||
|
const chartTheme = darkMode.value ? 'dark' : 'light'
|
||||||
|
|
||||||
|
chart.value = echarts.init(domRef.value, chartTheme)
|
||||||
|
|
||||||
|
chart.value?.setOption({ ...chartOptions, backgroundColor: 'transparent' })
|
||||||
|
|
||||||
|
await onRender?.(chart.value!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** resize chart */
|
||||||
|
function resize() {
|
||||||
|
chart.value?.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** destroy chart */
|
||||||
|
async function destroy() {
|
||||||
|
if (!chart.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
await onDestroy?.(chart.value)
|
||||||
|
chart.value?.dispose()
|
||||||
|
chart.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** change chart theme */
|
||||||
|
async function changeTheme() {
|
||||||
|
await destroy()
|
||||||
|
await render()
|
||||||
|
await onUpdated?.(chart.value!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* render chart by size
|
||||||
|
*
|
||||||
|
* @param w width
|
||||||
|
* @param h height
|
||||||
|
*/
|
||||||
|
async function renderChartBySize(w: number, h: number) {
|
||||||
|
initialSize.width = w
|
||||||
|
initialSize.height = h
|
||||||
|
|
||||||
|
// resize chart
|
||||||
|
if (isRendered()) {
|
||||||
|
resize()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// render chart
|
||||||
|
await render()
|
||||||
|
|
||||||
|
if (chart.value) {
|
||||||
|
await onUpdated?.(chart.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
watch(
|
||||||
|
[width, height],
|
||||||
|
([newWidth, newHeight]) => {
|
||||||
|
renderChartBySize(newWidth, newHeight)
|
||||||
|
},
|
||||||
|
{ flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(darkMode, () => {
|
||||||
|
changeTheme()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onScopeDispose(() => {
|
||||||
|
destroy()
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
domRef,
|
||||||
|
chart,
|
||||||
|
updateOptions,
|
||||||
|
setOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
97
apps/admin/src/hooks/common/form.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import type { FormInst } from 'naive-ui'
|
||||||
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
|
import { ref, toValue } from 'vue'
|
||||||
|
import { REG_CODE_SIX, REG_EMAIL, REG_PHONE, REG_PWD, REG_USER_NAME } from '@/constants/reg'
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
export function useFormRules() {
|
||||||
|
const patternRules = {
|
||||||
|
userName: {
|
||||||
|
pattern: REG_USER_NAME,
|
||||||
|
message: $t('form.userName.invalid'),
|
||||||
|
trigger: 'change',
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
pattern: REG_PHONE,
|
||||||
|
message: $t('form.phone.invalid'),
|
||||||
|
trigger: 'change',
|
||||||
|
},
|
||||||
|
pwd: {
|
||||||
|
pattern: REG_PWD,
|
||||||
|
message: $t('form.pwd.invalid'),
|
||||||
|
trigger: 'change',
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
pattern: REG_CODE_SIX,
|
||||||
|
message: $t('form.code.invalid'),
|
||||||
|
trigger: 'change',
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
pattern: REG_EMAIL,
|
||||||
|
message: $t('form.email.invalid'),
|
||||||
|
trigger: 'change',
|
||||||
|
},
|
||||||
|
} satisfies Record<string, App.Global.FormRule>
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
userName: [createRequiredRule($t('form.userName.required')), patternRules.userName],
|
||||||
|
phone: [createRequiredRule($t('form.phone.required')), patternRules.phone],
|
||||||
|
pwd: [createRequiredRule($t('form.pwd.required')), patternRules.pwd],
|
||||||
|
code: [createRequiredRule($t('form.code.required')), patternRules.code],
|
||||||
|
email: [createRequiredRule($t('form.email.required')), patternRules.email],
|
||||||
|
} satisfies Record<string, App.Global.FormRule[]>
|
||||||
|
|
||||||
|
/** the default required rule */
|
||||||
|
const defaultRequiredRule = createRequiredRule($t('form.required'))
|
||||||
|
|
||||||
|
function createRequiredRule(message: string): App.Global.FormRule {
|
||||||
|
return {
|
||||||
|
required: true,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** create a rule for confirming the password */
|
||||||
|
function createConfirmPwdRule(pwd: string | Ref<string> | ComputedRef<string>) {
|
||||||
|
const confirmPwdRule: App.Global.FormRule[] = [
|
||||||
|
{ required: true, message: $t('form.confirmPwd.required') },
|
||||||
|
{
|
||||||
|
asyncValidator: (rule, value) => {
|
||||||
|
if (value.trim() !== '' && value !== toValue(pwd)) {
|
||||||
|
return Promise.reject(rule.message)
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
message: $t('form.confirmPwd.invalid'),
|
||||||
|
trigger: 'input',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return confirmPwdRule
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
patternRules,
|
||||||
|
formRules,
|
||||||
|
defaultRequiredRule,
|
||||||
|
createRequiredRule,
|
||||||
|
createConfirmPwdRule,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNaiveForm() {
|
||||||
|
const formRef = ref<FormInst | null>(null)
|
||||||
|
|
||||||
|
async function validate() {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreValidation() {
|
||||||
|
formRef.value?.restoreValidation()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
formRef,
|
||||||
|
validate,
|
||||||
|
restoreValidation,
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/admin/src/hooks/common/icon.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useSvgIconRender } from '@sa/hooks'
|
||||||
|
import SvgIcon from '@/components/custom/svg-icon.vue'
|
||||||
|
|
||||||
|
export function useSvgIcon() {
|
||||||
|
const { SvgIconVNode } = useSvgIconRender(SvgIcon)
|
||||||
|
|
||||||
|
return {
|
||||||
|
SvgIconVNode,
|
||||||
|
}
|
||||||
|
}
|
||||||
116
apps/admin/src/hooks/common/router.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import type { RouteKey } from '@elegant-router/types'
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { router as globalRouter } from '@/router'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router push
|
||||||
|
*
|
||||||
|
* Jump to the specified route, it can replace function router.push
|
||||||
|
*
|
||||||
|
* @param inSetup Whether is in vue script setup
|
||||||
|
*/
|
||||||
|
export function useRouterPush(inSetup = true) {
|
||||||
|
const router = inSetup ? useRouter() : globalRouter
|
||||||
|
const route = globalRouter.currentRoute
|
||||||
|
|
||||||
|
const routerPush = router.push
|
||||||
|
|
||||||
|
const routerBack = router.back
|
||||||
|
|
||||||
|
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions) {
|
||||||
|
const { query, params } = options || {}
|
||||||
|
|
||||||
|
const routeLocation: RouteLocationRaw = {
|
||||||
|
name: key,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(query || {}).length) {
|
||||||
|
routeLocation.query = query
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(params || {}).length) {
|
||||||
|
routeLocation.params = params
|
||||||
|
}
|
||||||
|
|
||||||
|
return routerPush(routeLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
function routerPushByKeyWithMetaQuery(key: RouteKey) {
|
||||||
|
const allRoutes = router.getRoutes()
|
||||||
|
const meta = allRoutes.find(item => item.name === key)?.meta || null
|
||||||
|
|
||||||
|
const query: Record<string, string> = {}
|
||||||
|
|
||||||
|
meta?.query?.forEach((item) => {
|
||||||
|
query[item.key] = item.value
|
||||||
|
})
|
||||||
|
|
||||||
|
return routerPushByKey(key, { query })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toHome() {
|
||||||
|
return routerPushByKey('root')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to login page
|
||||||
|
*
|
||||||
|
* @param loginModule The login module
|
||||||
|
* @param redirectUrl The redirect url, if not specified, it will be the current route fullPath
|
||||||
|
*/
|
||||||
|
async function toLogin(loginModule?: UnionKey.LoginModule, redirectUrl?: string) {
|
||||||
|
const module = loginModule || 'pwd-login'
|
||||||
|
|
||||||
|
const options: App.Global.RouterPushOptions = {
|
||||||
|
params: {
|
||||||
|
module,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirect = redirectUrl || route.value.fullPath
|
||||||
|
|
||||||
|
options.query = {
|
||||||
|
redirect,
|
||||||
|
}
|
||||||
|
|
||||||
|
return routerPushByKey('login', options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle login module
|
||||||
|
*
|
||||||
|
* @param module
|
||||||
|
*/
|
||||||
|
async function toggleLoginModule(module: UnionKey.LoginModule) {
|
||||||
|
const query = route.value.query as Record<string, string>
|
||||||
|
|
||||||
|
return routerPushByKey('login', { query, params: { module } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect from login
|
||||||
|
*
|
||||||
|
* @param [needRedirect] Whether to redirect after login. Default is `true`
|
||||||
|
*/
|
||||||
|
async function redirectFromLogin(needRedirect = true) {
|
||||||
|
const redirect = route.value.query?.redirect as string
|
||||||
|
|
||||||
|
if (needRedirect && redirect) {
|
||||||
|
await routerPush(redirect)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await toHome()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
routerPush,
|
||||||
|
routerBack,
|
||||||
|
routerPushByKey,
|
||||||
|
routerPushByKeyWithMetaQuery,
|
||||||
|
toLogin,
|
||||||
|
toggleLoginModule,
|
||||||
|
redirectFromLogin,
|
||||||
|
}
|
||||||
|
}
|
||||||
315
apps/admin/src/hooks/common/table.ts
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
import type { FlatResponseData } from '@sa/axios'
|
||||||
|
import type { PaginationData, TableColumnCheck, UseTableOptions } from '@sa/hooks'
|
||||||
|
import type { PaginationProps } from 'naive-ui'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { useBoolean, useTable } from '@sa/hooks'
|
||||||
|
import { jsonClone } from '@sa/utils'
|
||||||
|
import { computed, effectScope, onScopeDispose, reactive, shallowRef, watch } from 'vue'
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
|
||||||
|
...options,
|
||||||
|
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
|
||||||
|
getColumns,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
watch(
|
||||||
|
() => appStore.locale,
|
||||||
|
() => {
|
||||||
|
result.reloadColumns()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
onScopeDispose(() => {
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
scrollX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
pagination.pageSize = data.pageSize
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = shallowRef<NaiveUI.TableOperateType>('add')
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
operateType.value = 'add'
|
||||||
|
openDrawer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the editing row data */
|
||||||
|
const editingData = shallowRef<TableData | null>(null)
|
||||||
|
|
||||||
|
function handleEdit(id: TableData[keyof TableData]) {
|
||||||
|
operateType.value = 'edit'
|
||||||
|
const findItem = data.value.find(item => item[idKey] === id) || null
|
||||||
|
editingData.value = jsonClone(findItem)
|
||||||
|
|
||||||
|
openDrawer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the checked row keys of table */
|
||||||
|
const checkedRowKeys = shallowRef<string[]>([])
|
||||||
|
|
||||||
|
/** the hook after the batch delete operation is completed */
|
||||||
|
async function onBatchDeleted() {
|
||||||
|
window.$message?.success($t('common.deleteSuccess'))
|
||||||
|
|
||||||
|
checkedRowKeys.value = []
|
||||||
|
|
||||||
|
await getData()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the hook after the delete operation is completed */
|
||||||
|
async function onDeleted() {
|
||||||
|
window.$message?.success($t('common.deleteSuccess'))
|
||||||
|
|
||||||
|
await getData()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
drawerVisible,
|
||||||
|
openDrawer,
|
||||||
|
closeDrawer,
|
||||||
|
operateType,
|
||||||
|
handleAdd,
|
||||||
|
editingData,
|
||||||
|
handleEdit,
|
||||||
|
checkedRowKeys,
|
||||||
|
onBatchDeleted,
|
||||||
|
onDeleted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
162
apps/admin/src/layouts/base-layout/index.vue
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { LayoutMode } from '@sa/materials'
|
||||||
|
import { AdminLayout, LAYOUT_SCROLL_EL_ID } from '@sa/materials'
|
||||||
|
import { computed, defineAsyncComponent } from 'vue'
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
import { useThemeStore } from '@/store/modules/theme'
|
||||||
|
import GlobalContent from '../modules/global-content/index.vue'
|
||||||
|
import GlobalFooter from '../modules/global-footer/index.vue'
|
||||||
|
import GlobalHeader from '../modules/global-header/index.vue'
|
||||||
|
import { provideMixMenuContext } from '../modules/global-menu/context'
|
||||||
|
import GlobalSider from '../modules/global-sider/index.vue'
|
||||||
|
import GlobalTab from '../modules/global-tab/index.vue'
|
||||||
|
import ThemeDrawer from '../modules/theme-drawer/index.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'BaseLayout',
|
||||||
|
})
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const { secondLevelMenus, childLevelMenus, isActiveFirstLevelMenuHasChildren } = provideMixMenuContext()
|
||||||
|
|
||||||
|
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'))
|
||||||
|
|
||||||
|
const layoutMode = computed(() => {
|
||||||
|
const vertical: LayoutMode = 'vertical'
|
||||||
|
const horizontal: LayoutMode = 'horizontal'
|
||||||
|
return themeStore.layout.mode.includes(vertical) ? vertical : horizontal
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerProps = computed(() => {
|
||||||
|
const { mode } = themeStore.layout
|
||||||
|
|
||||||
|
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
|
||||||
|
'vertical': {
|
||||||
|
showLogo: false,
|
||||||
|
showMenu: false,
|
||||||
|
showMenuToggler: true,
|
||||||
|
},
|
||||||
|
'vertical-mix': {
|
||||||
|
showLogo: false,
|
||||||
|
showMenu: false,
|
||||||
|
showMenuToggler: false,
|
||||||
|
},
|
||||||
|
'vertical-hybrid-header-first': {
|
||||||
|
showLogo: !isActiveFirstLevelMenuHasChildren.value,
|
||||||
|
showMenu: true,
|
||||||
|
showMenuToggler: false,
|
||||||
|
},
|
||||||
|
'horizontal': {
|
||||||
|
showLogo: true,
|
||||||
|
showMenu: true,
|
||||||
|
showMenuToggler: false,
|
||||||
|
},
|
||||||
|
'top-hybrid-sidebar-first': {
|
||||||
|
showLogo: true,
|
||||||
|
showMenu: true,
|
||||||
|
showMenuToggler: false,
|
||||||
|
},
|
||||||
|
'top-hybrid-header-first': {
|
||||||
|
showLogo: true,
|
||||||
|
showMenu: true,
|
||||||
|
showMenuToggler: isActiveFirstLevelMenuHasChildren.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return headerPropsConfig[mode]
|
||||||
|
})
|
||||||
|
|
||||||
|
const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal')
|
||||||
|
|
||||||
|
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-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(() => getSiderAndCollapsedWidth(false))
|
||||||
|
|
||||||
|
const siderCollapsedWidth = computed(() => getSiderAndCollapsedWidth(true))
|
||||||
|
|
||||||
|
function getSiderAndCollapsedWidth(isCollapsed: boolean) {
|
||||||
|
const {
|
||||||
|
mixChildMenuWidth,
|
||||||
|
collapsedWidth,
|
||||||
|
width: themeWidth,
|
||||||
|
mixCollapsedWidth,
|
||||||
|
mixWidth: themeMixWidth,
|
||||||
|
} = themeStore.sider
|
||||||
|
|
||||||
|
const width = isCollapsed ? collapsedWidth : themeWidth
|
||||||
|
const mixWidth = isCollapsed ? mixCollapsedWidth : themeMixWidth
|
||||||
|
|
||||||
|
if (isTopHybridHeaderFirst.value) {
|
||||||
|
return isActiveFirstLevelMenuHasChildren.value ? width : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVerticalHybridHeaderFirst.value && !isActiveFirstLevelMenuHasChildren.value) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMixMode = isVerticalMix.value || isTopHybridSidebarFirst.value || isVerticalHybridHeaderFirst.value
|
||||||
|
let finalWidth = isMixMode ? mixWidth : width
|
||||||
|
|
||||||
|
if (isVerticalMix.value && appStore.mixSiderFixed && secondLevelMenus.value.length) {
|
||||||
|
finalWidth += mixChildMenuWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVerticalHybridHeaderFirst.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
||||||
|
finalWidth += mixChildMenuWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalWidth
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AdminLayout
|
||||||
|
v-model:sider-collapse="appStore.siderCollapse"
|
||||||
|
:mode="layoutMode"
|
||||||
|
:scroll-el-id="LAYOUT_SCROLL_EL_ID"
|
||||||
|
:scroll-mode="themeStore.layout.scrollMode"
|
||||||
|
:is-mobile="appStore.isMobile"
|
||||||
|
:full-content="appStore.fullContent"
|
||||||
|
:fixed-top="themeStore.fixedHeaderAndTab"
|
||||||
|
:header-height="themeStore.header.height"
|
||||||
|
:tab-visible="themeStore.tab.visible"
|
||||||
|
:tab-height="themeStore.tab.height"
|
||||||
|
:content-class="appStore.contentXScrollable ? 'overflow-x-hidden' : ''"
|
||||||
|
:sider-visible="siderVisible"
|
||||||
|
:sider-width="siderWidth"
|
||||||
|
:sider-collapsed-width="siderCollapsedWidth"
|
||||||
|
:footer-visible="themeStore.footer.visible"
|
||||||
|
:footer-height="themeStore.footer.height"
|
||||||
|
:fixed-footer="themeStore.footer.fixed"
|
||||||
|
:right-footer="themeStore.footer.right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<GlobalHeader v-bind="headerProps" />
|
||||||
|
</template>
|
||||||
|
<template #tab>
|
||||||
|
<GlobalTab />
|
||||||
|
</template>
|
||||||
|
<template #sider>
|
||||||
|
<GlobalSider />
|
||||||
|
</template>
|
||||||
|
<GlobalMenu />
|
||||||
|
<GlobalContent />
|
||||||
|
<ThemeDrawer />
|
||||||
|
<template #footer>
|
||||||
|
<GlobalFooter />
|
||||||
|
</template>
|
||||||
|
</AdminLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
#__SCROLL_EL_ID__ {
|
||||||
|
@include scrollbar();
|
||||||
|
}
|
||||||
|
</style>
|
||||||
13
apps/admin/src/layouts/blank-layout/index.vue
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import GlobalContent from '../modules/global-content/index.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'BlankLayout',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<GlobalContent :show-padding="false" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
47
apps/admin/src/layouts/modules/global-breadcrumb/index.vue
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteKey } from '@elegant-router/types'
|
||||||
|
import { createReusableTemplate } from '@vueuse/core'
|
||||||
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { useRouteStore } from '@/store/modules/route'
|
||||||
|
import { useThemeStore } from '@/store/modules/theme'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'GlobalBreadcrumb',
|
||||||
|
})
|
||||||
|
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const routeStore = useRouteStore()
|
||||||
|
const { routerPushByKey } = useRouterPush()
|
||||||
|
|
||||||
|
interface BreadcrumbContentProps {
|
||||||
|
breadcrumb: App.Global.Menu
|
||||||
|
}
|
||||||
|
|
||||||
|
const [DefineBreadcrumbContent, BreadcrumbContent] = createReusableTemplate<BreadcrumbContentProps>()
|
||||||
|
|
||||||
|
function handleClickMenu(key: RouteKey) {
|
||||||
|
routerPushByKey(key)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NBreadcrumb v-if="themeStore.header.breadcrumb.visible">
|
||||||
|
<!-- define component start: BreadcrumbContent -->
|
||||||
|
<DefineBreadcrumbContent v-slot="{ breadcrumb }">
|
||||||
|
<div class="i-flex-y-center align-middle">
|
||||||
|
<component :is="breadcrumb.icon" v-if="themeStore.header.breadcrumb.showIcon" class="mr-4px text-icon" />
|
||||||
|
{{ breadcrumb.label }}
|
||||||
|
</div>
|
||||||
|
</DefineBreadcrumbContent>
|
||||||
|
<!-- define component end: BreadcrumbContent -->
|
||||||
|
|
||||||
|
<NBreadcrumbItem v-for="item in routeStore.breadcrumbs" :key="item.key">
|
||||||
|
<NDropdown v-if="item.options?.length" :options="item.options" @select="handleClickMenu">
|
||||||
|
<BreadcrumbContent :breadcrumb="item" />
|
||||||
|
</NDropdown>
|
||||||
|
<BreadcrumbContent v-else :breadcrumb="item" />
|
||||||
|
</NBreadcrumbItem>
|
||||||
|
</NBreadcrumb>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
58
apps/admin/src/layouts/modules/global-content/index.vue
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
import { useRouteStore } from '@/store/modules/route'
|
||||||
|
import { useTabStore } from '@/store/modules/tab'
|
||||||
|
import { useThemeStore } from '@/store/modules/theme'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'GlobalContent',
|
||||||
|
})
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
showPadding: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Show padding for content */
|
||||||
|
showPadding?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const routeStore = useRouteStore()
|
||||||
|
const tabStore = useTabStore()
|
||||||
|
|
||||||
|
const transitionName = computed(() => (themeStore.page.animate ? themeStore.page.animateMode : ''))
|
||||||
|
|
||||||
|
function resetScroll() {
|
||||||
|
const el = document.querySelector(`#${LAYOUT_SCROLL_EL_ID}`)
|
||||||
|
|
||||||
|
el?.scrollTo({ left: 0, top: 0 })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView v-slot="{ Component, route }">
|
||||||
|
<Transition
|
||||||
|
:name="transitionName"
|
||||||
|
mode="out-in"
|
||||||
|
@before-leave="appStore.setContentXScrollable(true)"
|
||||||
|
@after-leave="resetScroll"
|
||||||
|
@after-enter="appStore.setContentXScrollable(false)"
|
||||||
|
>
|
||||||
|
<KeepAlive :include="routeStore.cacheRoutes" :exclude="routeStore.excludeCacheRoutes">
|
||||||
|
<component
|
||||||
|
:is="Component"
|
||||||
|
v-if="appStore.reloadFlag"
|
||||||
|
:key="tabStore.getTabIdByRoute(route)"
|
||||||
|
:class="{ 'p-16px': showPadding }"
|
||||||
|
class="flex-grow bg-layout transition-300"
|
||||||
|
/>
|
||||||
|
</KeepAlive>
|
||||||
|
</Transition>
|
||||||
|
</RouterView>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style></style>
|
||||||
15
apps/admin/src/layouts/modules/global-footer/index.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({
|
||||||
|
name: 'GlobalFooter',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DarkModeContainer class="h-full flex-center">
|
||||||
|
<a target="_blank" rel="noopener noreferrer">
|
||||||
|
Copyright MIT © 2021 reader-star
|
||||||
|
</a>
|
||||||
|
</DarkModeContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ThemeButton',
|
||||||
|
})
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ButtonIcon
|
||||||
|
icon="majesticons:color-swatch-line"
|
||||||
|
:tooltip-content="$t('icon.themeConfig')"
|
||||||
|
@click="appStore.openThemeDrawer"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { VNode } from 'vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useSvgIcon } from '@/hooks/common/icon'
|
||||||
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'UserAvatar',
|
||||||
|
})
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const { routerPushByKey, toLogin } = useRouterPush()
|
||||||
|
const { SvgIconVNode } = useSvgIcon()
|
||||||
|
|
||||||
|
function loginOrRegister() {
|
||||||
|
toLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
type DropdownKey = 'logout'
|
||||||
|
|
||||||
|
type DropdownOption =
|
||||||
|
| {
|
||||||
|
key: DropdownKey
|
||||||
|
label: string
|
||||||
|
icon?: () => VNode
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'divider'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = computed(() => {
|
||||||
|
const opts: DropdownOption[] = [
|
||||||
|
{
|
||||||
|
label: $t('common.logout'),
|
||||||
|
key: 'logout',
|
||||||
|
icon: SvgIconVNode({ icon: 'ph:sign-out', fontSize: 18 }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return opts
|
||||||
|
})
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
window.$dialog?.info({
|
||||||
|
title: $t('common.tip'),
|
||||||
|
content: $t('common.logoutConfirm'),
|
||||||
|
positiveText: $t('common.confirm'),
|
||||||
|
negativeText: $t('common.cancel'),
|
||||||
|
onPositiveClick: () => {
|
||||||
|
authStore.resetStore()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDropdown(key: DropdownKey) {
|
||||||
|
if (key === 'logout') {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// If your other options are jumps from other routes, they will be directly supported here
|
||||||
|
routerPushByKey(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NButton v-if="!authStore.isLogin" quaternary @click="loginOrRegister">
|
||||||
|
{{ $t('page.login.common.loginOrRegister') }}
|
||||||
|
</NButton>
|
||||||
|
<NDropdown v-else placement="bottom" trigger="click" :options="options" @select="handleDropdown">
|
||||||
|
<div>
|
||||||
|
<ButtonIcon>
|
||||||
|
<SvgIcon icon="ph:user-circle" class="text-icon-large" />
|
||||||
|
<span class="text-16px font-medium">{{ authStore.userInfo.userName }}</span>
|
||||||
|
</ButtonIcon>
|
||||||
|
</div>
|
||||||
|
</NDropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
60
apps/admin/src/layouts/modules/global-header/index.vue
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useFullscreen } from '@vueuse/core'
|
||||||
|
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app'
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
import { useThemeStore } from '@/store/modules/theme'
|
||||||
|
import GlobalBreadcrumb from '../global-breadcrumb/index.vue'
|
||||||
|
import GlobalLogo from '../global-logo/index.vue'
|
||||||
|
import GlobalSearch from '../global-search/index.vue'
|
||||||
|
import ThemeButton from './components/theme-button.vue'
|
||||||
|
import UserAvatar from './components/user-avatar.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'GlobalHeader',
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Whether to show the logo */
|
||||||
|
showLogo?: App.Global.HeaderProps['showLogo']
|
||||||
|
/** Whether to show the menu toggler */
|
||||||
|
showMenuToggler?: App.Global.HeaderProps['showMenuToggler']
|
||||||
|
/** Whether to show the menu */
|
||||||
|
showMenu?: App.Global.HeaderProps['showMenu']
|
||||||
|
}
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const { isFullscreen, toggle } = useFullscreen()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DarkModeContainer class="h-full flex-y-center px-12px shadow-header">
|
||||||
|
<GlobalLogo v-if="showLogo" class="h-full" :style="{ width: `${themeStore.sider.width}px` }" />
|
||||||
|
<MenuToggler v-if="showMenuToggler" :collapsed="appStore.siderCollapse" @click="appStore.toggleSiderCollapse" />
|
||||||
|
<div v-if="showMenu" :id="GLOBAL_HEADER_MENU_ID" class="h-full flex-y-center flex-1-hidden" />
|
||||||
|
<div v-else class="h-full flex-y-center flex-1-hidden">
|
||||||
|
<GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" />
|
||||||
|
</div>
|
||||||
|
<div class="h-full flex-y-center justify-end">
|
||||||
|
<GlobalSearch v-if="themeStore.header.globalSearch.visible" />
|
||||||
|
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
|
||||||
|
<LangSwitch
|
||||||
|
v-if="themeStore.header.multilingual.visible"
|
||||||
|
:lang="appStore.locale"
|
||||||
|
:lang-options="appStore.localeOptions"
|
||||||
|
@change-lang="appStore.changeLocale"
|
||||||
|
/>
|
||||||
|
<ThemeSchemaSwitch
|
||||||
|
:theme-schema="themeStore.themeScheme"
|
||||||
|
:is-dark="themeStore.darkMode"
|
||||||
|
@switch="themeStore.toggleThemeScheme"
|
||||||
|
/>
|
||||||
|
<ThemeButton />
|
||||||
|
<UserAvatar />
|
||||||
|
</div>
|
||||||
|
</DarkModeContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
27
apps/admin/src/layouts/modules/global-logo/index.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'GlobalLogo',
|
||||||
|
})
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
showTitle: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Whether to show the title */
|
||||||
|
showTitle?: boolean
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterLink to="/" class="w-full flex-center nowrap-hidden">
|
||||||
|
<SystemLogo class="size-32px" />
|
||||||
|
<h2 v-show="showTitle" class="pl-8px text-16px text-primary font-bold transition duration-300 ease-in-out">
|
||||||
|
{{ $t('system.title') }}
|
||||||
|
</h2>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteKey } from '@elegant-router/types'
|
||||||
|
import { transformColorWithOpacity } from '@sa/color'
|
||||||
|
import { SimpleScrollbar } from '@sa/materials'
|
||||||
|
import { createReusableTemplate } from '@vueuse/core'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'FirstLevelMenu',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
menus: App.Global.Menu[]
|
||||||
|
activeMenuKey?: string
|
||||||
|
inverted?: boolean
|
||||||
|
siderCollapse?: boolean
|
||||||
|
darkMode?: boolean
|
||||||
|
themeColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'select', menuKey: RouteKey): boolean
|
||||||
|
(e: 'toggleSiderCollapse'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MixMenuItemProps {
|
||||||
|
/** Menu item label */
|
||||||
|
label: App.Global.Menu['label']
|
||||||
|
/** Menu item icon */
|
||||||
|
icon: App.Global.Menu['icon']
|
||||||
|
/** Active menu item */
|
||||||
|
active: boolean
|
||||||
|
/** Mini size */
|
||||||
|
isMini?: boolean
|
||||||
|
}
|
||||||
|
const [DefineMixMenuItem, MixMenuItem] = createReusableTemplate<MixMenuItemProps>()
|
||||||
|
|
||||||
|
const selectedBgColor = computed(() => {
|
||||||
|
const { darkMode, themeColor } = props
|
||||||
|
|
||||||
|
const light = transformColorWithOpacity(themeColor, 0.1, '#ffffff')
|
||||||
|
const dark = transformColorWithOpacity(themeColor, 0.3, '#000000')
|
||||||
|
|
||||||
|
return darkMode ? dark : light
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleClickMixMenu(menuKey: RouteKey) {
|
||||||
|
emit('select', menuKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSiderCollapse() {
|
||||||
|
emit('toggleSiderCollapse')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- define component: MixMenuItem -->
|
||||||
|
<DefineMixMenuItem v-slot="{ label, icon, active, isMini }">
|
||||||
|
<div
|
||||||
|
class="mx-4px mb-6px flex-col-center cursor-pointer rounded-8px bg-transparent px-4px py-8px transition-300 hover:bg-[rgb(0,0,0,0.08)]"
|
||||||
|
:class="{
|
||||||
|
'text-primary selected-mix-menu': active,
|
||||||
|
'text-white:65 hover:text-white': inverted,
|
||||||
|
'!text-white !bg-primary': active && inverted,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<component :is="icon" :class="[isMini ? 'text-icon-small' : 'text-icon-large']" />
|
||||||
|
<p
|
||||||
|
class="w-full ellipsis-text text-center text-12px transition-height-300"
|
||||||
|
:class="[isMini ? 'h-0 pt-0' : 'h-20px pt-4px']"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DefineMixMenuItem>
|
||||||
|
<!-- define component end: MixMenuItem -->
|
||||||
|
|
||||||
|
<div class="h-full flex-col-stretch flex-1-hidden">
|
||||||
|
<slot />
|
||||||
|
<SimpleScrollbar>
|
||||||
|
<MixMenuItem
|
||||||
|
v-for="menu in menus"
|
||||||
|
:key="menu.key"
|
||||||
|
:label="menu.label"
|
||||||
|
:icon="menu.icon"
|
||||||
|
:active="menu.key === activeMenuKey"
|
||||||
|
:is-mini="siderCollapse"
|
||||||
|
@click="handleClickMixMenu(menu.routeKey)"
|
||||||
|
/>
|
||||||
|
</SimpleScrollbar>
|
||||||
|
<MenuToggler
|
||||||
|
arrow-icon
|
||||||
|
:collapsed="siderCollapse"
|
||||||
|
:z-index="99"
|
||||||
|
:class="{ 'text-white:88 !hover:text-white': inverted }"
|
||||||
|
@click="toggleSiderCollapse"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selected-mix-menu {
|
||||||
|
background-color: v-bind(selectedBgColor);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
185
apps/admin/src/layouts/modules/global-menu/context/index.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import type { RouteKey } from '@elegant-router/types'
|
||||||
|
import { useContext } from '@sa/hooks'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { useRouteStore } from '@/store/modules/route'
|
||||||
|
import { useThemeStore } from '@/store/modules/theme'
|
||||||
|
|
||||||
|
export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', useMixMenu)
|
||||||
|
|
||||||
|
function useMixMenu() {
|
||||||
|
const route = useRoute()
|
||||||
|
const routeStore = useRouteStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
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 || [],
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasChildLevelMenus = computed(() => childLevelMenus.value.length > 0)
|
||||||
|
|
||||||
|
function getDeepestLevelMenuKey(): RouteKey | null {
|
||||||
|
if (!secondLevelMenus.value.length || !themeStore.sider.autoSelectFirstMenu) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondLevelFirstMenu = secondLevelMenus.value[0]
|
||||||
|
|
||||||
|
if (!secondLevelFirstMenu) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDeepest(menu: App.Global.Menu): RouteKey {
|
||||||
|
if (!menu.children?.length) {
|
||||||
|
return menu.routeKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return findDeepest(menu.children[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return findDeepest(secondLevelFirstMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeDeepestLevelMenuKey() {
|
||||||
|
const deepestLevelMenuKey = getDeepestLevelMenuKey()
|
||||||
|
if (!deepestLevelMenuKey)
|
||||||
|
return
|
||||||
|
|
||||||
|
// select the deepest second level menu
|
||||||
|
handleSelectSecondLevelMenu(deepestLevelMenuKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.name,
|
||||||
|
() => {
|
||||||
|
getActiveFirstLevelMenuKey()
|
||||||
|
// if there are child level menus, get the active second level menu key
|
||||||
|
if (hasChildLevelMenus.value) {
|
||||||
|
getActiveSecondLevelMenuKey()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
firstLevelMenus,
|
||||||
|
activeFirstLevelMenuKey,
|
||||||
|
setActiveFirstLevelMenuKey,
|
||||||
|
isActiveFirstLevelMenuHasChildren,
|
||||||
|
handleSelectFirstLevelMenu,
|
||||||
|
getActiveFirstLevelMenuKey,
|
||||||
|
secondLevelMenus,
|
||||||
|
activeSecondLevelMenuKey,
|
||||||
|
setActiveSecondLevelMenuKey,
|
||||||
|
isActiveSecondLevelMenuHasChildren,
|
||||||
|
handleSelectSecondLevelMenu,
|
||||||
|
getActiveSecondLevelMenuKey,
|
||||||
|
childLevelMenus,
|
||||||
|
hasChildLevelMenus,
|
||||||
|
getDeepestLevelMenuKey,
|
||||||
|
activeDeepestLevelMenuKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/admin/src/layouts/modules/global-menu/index.vue
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
import { useThemeStore } from '@/store/modules/theme'
|
||||||
|
import HorizontalMenu from './modules/horizontal-menu.vue'
|
||||||
|
import TopHybridHeaderFirst from './modules/top-hybrid-header-first.vue'
|
||||||
|
import TopHybridSidebarFirst from './modules/top-hybrid-sidebar-first.vue'
|
||||||
|
import VerticalHybridHeaderFirst from './modules/vertical-hybrid-header-first.vue'
|
||||||
|
import VerticalMenu from './modules/vertical-menu.vue'
|
||||||
|
import VerticalMixMenu from './modules/vertical-mix-menu.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'GlobalMenu',
|
||||||
|
})
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
const activeMenu = computed(() => {
|
||||||
|
const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = {
|
||||||
|
'vertical': VerticalMenu,
|
||||||
|
'vertical-mix': VerticalMixMenu,
|
||||||
|
'vertical-hybrid-header-first': VerticalHybridHeaderFirst,
|
||||||
|
'horizontal': HorizontalMenu,
|
||||||
|
'top-hybrid-sidebar-first': TopHybridSidebarFirst,
|
||||||
|
'top-hybrid-header-first': TopHybridHeaderFirst,
|
||||||
|
}
|
||||||
|
|
||||||
|
return menuMap[themeStore.layout.mode]
|
||||||
|
})
|
||||||
|
|
||||||
|
const reRenderVertical = computed(() => themeStore.layout.mode === 'vertical' && appStore.isMobile)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component :is="activeMenu" :key="reRenderVertical" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app'
|
||||||
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { useRouteStore } from '@/store/modules/route'
|
||||||
|
import { useMenu } from '../context'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'HorizontalMenu',
|
||||||
|
})
|
||||||
|
|
||||||
|
const routeStore = useRouteStore()
|
||||||
|
const { routerPushByKeyWithMetaQuery } = useRouterPush()
|
||||||
|
const { selectedKey } = useMenu()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||||
|
<NMenu
|
||||||
|
mode="horizontal"
|
||||||
|
:value="selectedKey"
|
||||||
|
:options="routeStore.menus"
|
||||||
|
:indent="18"
|
||||||
|
responsive
|
||||||
|
@update:value="routerPushByKeyWithMetaQuery"
|
||||||
|
/>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteKey } from '@elegant-router/types'
|
||||||
|
import { SimpleScrollbar } from '@sa/materials'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'
|
||||||
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
import { useRouteStore } from '@/store/modules/route'
|
||||||
|
import { useThemeStore } from '@/store/modules/theme'
|
||||||
|
import { useMenu, useMixMenuContext } from '../context'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'TopHybridHeaderFirst',
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const routeStore = useRouteStore()
|
||||||
|
const { routerPushByKeyWithMetaQuery } = useRouterPush()
|
||||||
|
const {
|
||||||
|
firstLevelMenus,
|
||||||
|
secondLevelMenus,
|
||||||
|
activeFirstLevelMenuKey,
|
||||||
|
handleSelectFirstLevelMenu,
|
||||||
|
activeDeepestLevelMenuKey,
|
||||||
|
} = useMixMenuContext('TopHybridHeaderFirst')
|
||||||
|
const { selectedKey } = useMenu()
|
||||||
|
|
||||||
|
const expandedKeys = ref<string[]>([])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle first level menu select
|
||||||
|
* @param key RouteKey
|
||||||
|
*/
|
||||||
|
function handleSelectMenu(key: RouteKey) {
|
||||||
|
handleSelectFirstLevelMenu(key)
|
||||||
|
|
||||||
|
// if there are second level menus, select the deepest one by default
|
||||||
|
activeDeepestLevelMenuKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`">
|
||||||
|
<SimpleScrollbar>
|
||||||
|
<NMenu
|
||||||
|
v-model:expanded-keys="expandedKeys"
|
||||||
|
mode="vertical"
|
||||||
|
:value="selectedKey"
|
||||||
|
:collapsed="appStore.siderCollapse"
|
||||||
|
:collapsed-width="themeStore.sider.collapsedWidth"
|
||||||
|
:collapsed-icon-size="22"
|
||||||
|
:options="secondLevelMenus"
|
||||||
|
:indent="18"
|
||||||
|
@update:value="routerPushByKeyWithMetaQuery"
|
||||||
|
/>
|
||||||
|
</SimpleScrollbar>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteKey } from '@elegant-router/types'
|
||||||
|
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'
|
||||||
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
import { useThemeStore } from '@/store/modules/theme'
|
||||||
|
import FirstLevelMenu from '../components/first-level-menu.vue'
|
||||||
|
import { useMenu, useMixMenuContext } from '../context'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'TopHybridSidebarFirst',
|
||||||
|
})
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const { routerPushByKeyWithMetaQuery } = useRouterPush()
|
||||||
|
const {
|
||||||
|
firstLevelMenus,
|
||||||
|
secondLevelMenus,
|
||||||
|
activeFirstLevelMenuKey,
|
||||||
|
handleSelectFirstLevelMenu,
|
||||||
|
activeDeepestLevelMenuKey,
|
||||||
|
} = useMixMenuContext('TopHybridSidebarFirst')
|
||||||
|
const { selectedKey } = useMenu()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle first level menu select
|
||||||
|
* @param key RouteKey
|
||||||
|
*/
|
||||||
|
function handleSelectMenu(key: RouteKey) {
|
||||||
|
handleSelectFirstLevelMenu(key)
|
||||||
|
|
||||||
|
// if there are second level menus, select the deepest one by default
|
||||||
|
activeDeepestLevelMenuKey()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||||
|
<NMenu
|
||||||
|
mode="horizontal"
|
||||||
|
:value="selectedKey"
|
||||||
|
:options="secondLevelMenus"
|
||||||
|
:indent="18"
|
||||||
|
responsive
|
||||||
|
@update:value="routerPushByKeyWithMetaQuery"
|
||||||
|
/>
|
||||||
|
</Teleport>
|
||||||
|
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||||
|
<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="handleSelectMenu"
|
||||||
|
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,175 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteKey } from '@elegant-router/types'
|
||||||
|
import { useBoolean } from '@sa/hooks'
|
||||||
|
import { SimpleScrollbar } from '@sa/materials'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'
|
||||||
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
import { useRouteStore } from '@/store/modules/route'
|
||||||
|
import { useThemeStore } from '@/store/modules/theme'
|
||||||
|
import GlobalLogo from '../../global-logo/index.vue'
|
||||||
|
import FirstLevelMenu from '../components/first-level-menu.vue'
|
||||||
|
import { useMenu, useMixMenuContext } from '../context'
|
||||||
|
|
||||||
|
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,
|
||||||
|
hasChildLevelMenus,
|
||||||
|
activeDeepestLevelMenuKey,
|
||||||
|
} = useMixMenuContext('VerticalHybridHeaderFirst')
|
||||||
|
const { selectedKey } = useMenu()
|
||||||
|
|
||||||
|
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted)
|
||||||
|
|
||||||
|
const showDrawer = computed(() => hasChildLevelMenus.value && (drawerVisible.value || appStore.mixSiderFixed))
|
||||||
|
|
||||||
|
function handleSelectMixMenu(key: RouteKey) {
|
||||||
|
handleSelectSecondLevelMenu(key)
|
||||||
|
|
||||||
|
if (isActiveSecondLevelMenuHasChildren.value) {
|
||||||
|
setDrawerVisible(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle second level menu selection based on autoSelectFirstMenu setting:
|
||||||
|
* - When disabled: Activate first second-level menu for display only, expand third-level menu if exists
|
||||||
|
* - When enabled: Navigate to the deepest menu automatically
|
||||||
|
*/
|
||||||
|
function handleSelectMenu(key: RouteKey) {
|
||||||
|
handleSelectFirstLevelMenu(key)
|
||||||
|
|
||||||
|
if (secondLevelMenus.value.length === 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
const secondFirstMenuKey = secondLevelMenus.value[0].routeKey
|
||||||
|
|
||||||
|
// Case 1: autoSelectFirstMenu disabled - only activate menu for display
|
||||||
|
if (!themeStore.sider.autoSelectFirstMenu) {
|
||||||
|
// Check if there are third-level menus
|
||||||
|
const hasChildren = secondLevelMenus.value.find(menu => menu.key === secondFirstMenuKey)?.children?.length
|
||||||
|
|
||||||
|
// If there are third-level menus, expand them
|
||||||
|
if (hasChildren) {
|
||||||
|
handleSelectMixMenu(secondFirstMenuKey)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: autoSelectFirstMenu enabled - navigate to deepest menu
|
||||||
|
activeDeepestLevelMenuKey()
|
||||||
|
setDrawerVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && hasChildLevelMenus ? `${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>
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SimpleScrollbar } from '@sa/materials'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app'
|
||||||
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
import { useRouteStore } from '@/store/modules/route'
|
||||||
|
import { useThemeStore } from '@/store/modules/theme'
|
||||||
|
import { useMenu } from '../context'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'VerticalMenu',
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const routeStore = useRouteStore()
|
||||||
|
const { routerPushByKeyWithMetaQuery } = useRouterPush()
|
||||||
|
const { selectedKey } = useMenu()
|
||||||
|
|
||||||
|
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted)
|
||||||
|
|
||||||
|
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_SIDER_MENU_ID}`">
|
||||||
|
<SimpleScrollbar>
|
||||||
|
<NMenu
|
||||||
|
v-model:expanded-keys="expandedKeys"
|
||||||
|
mode="vertical"
|
||||||
|
:value="selectedKey"
|
||||||
|
:collapsed="appStore.siderCollapse"
|
||||||
|
:collapsed-width="themeStore.sider.collapsedWidth"
|
||||||
|
:collapsed-icon-size="22"
|
||||||
|
:options="routeStore.menus"
|
||||||
|
:inverted="inverted"
|
||||||
|
:indent="18"
|
||||||
|
@update:value="routerPushByKeyWithMetaQuery"
|
||||||
|
/>
|
||||||
|
</SimpleScrollbar>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteKey } from '@elegant-router/types'
|
||||||
|
import { useBoolean } from '@sa/hooks'
|
||||||
|
import { SimpleScrollbar } from '@sa/materials'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app'
|
||||||
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
import { useRouteStore } from '@/store/modules/route'
|
||||||
|
import { useThemeStore } from '@/store/modules/theme'
|
||||||
|
import GlobalLogo from '../../global-logo/index.vue'
|
||||||
|
import FirstLevelMenu from '../components/first-level-menu.vue'
|
||||||
|
import { useMenu, useMixMenuContext } from '../context'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'VerticalMixMenu',
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const routeStore = useRouteStore()
|
||||||
|
const { routerPushByKeyWithMetaQuery } = useRouterPush()
|
||||||
|
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean()
|
||||||
|
const {
|
||||||
|
firstLevelMenus,
|
||||||
|
secondLevelMenus,
|
||||||
|
activeFirstLevelMenuKey,
|
||||||
|
isActiveFirstLevelMenuHasChildren,
|
||||||
|
getActiveFirstLevelMenuKey,
|
||||||
|
handleSelectFirstLevelMenu,
|
||||||
|
} = useMixMenuContext('VerticalMixMenu')
|
||||||
|
const { selectedKey } = useMenu()
|
||||||
|
|
||||||
|
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted)
|
||||||
|
|
||||||
|
const hasChildMenus = computed(() => secondLevelMenus.value.length > 0)
|
||||||
|
|
||||||
|
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed))
|
||||||
|
|
||||||
|
function handleSelectMenu(key: RouteKey) {
|
||||||
|
handleSelectFirstLevelMenu(key)
|
||||||
|
|
||||||
|
if (isActiveFirstLevelMenuHasChildren.value) {
|
||||||
|
setDrawerVisible(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResetActiveMenu() {
|
||||||
|
setDrawerVisible(false)
|
||||||
|
|
||||||
|
if (!appStore.mixSiderFixed) {
|
||||||
|
getActiveFirstLevelMenuKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_SIDER_MENU_ID}`">
|
||||||
|
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
|
||||||
|
<FirstLevelMenu
|
||||||
|
:menus="firstLevelMenus"
|
||||||
|
:active-menu-key="activeFirstLevelMenuKey"
|
||||||
|
:inverted="inverted"
|
||||||
|
:sider-collapse="appStore.siderCollapse"
|
||||||
|
:dark-mode="themeStore.darkMode"
|
||||||
|
:theme-color="themeStore.themeColor"
|
||||||
|
@select="handleSelectMenu"
|
||||||
|
@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="secondLevelMenus"
|
||||||
|
:inverted="inverted"
|
||||||
|
:indent="18"
|
||||||
|
@update:value="routerPushByKeyWithMetaQuery"
|
||||||
|
/>
|
||||||
|
</SimpleScrollbar>
|
||||||
|
</DarkModeContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
|
defineOptions({ name: 'SearchFooter' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-44px flex-y-center gap-14px px-24px">
|
||||||
|
<span class="flex-y-center">
|
||||||
|
<icon-mdi-keyboard-return class="operate-shadow operate-item" />
|
||||||
|
<span>{{ $t('common.confirm') }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex-y-center">
|
||||||
|
<icon-mdi-arrow-up-thin class="operate-shadow operate-item" />
|
||||||
|
<icon-mdi-arrow-down-thin class="operate-shadow operate-item" />
|
||||||
|
<span>{{ $t('common.switch') }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex-y-center">
|
||||||
|
<icon-mdi-keyboard-esc class="operate-shadow operate-item" />
|
||||||
|
<span>{{ $t('common.close') }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.operate-shadow {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 -2px #cdcde6,
|
||||||
|
inset 0 0 1px 1px #fff,
|
||||||
|
0 1px 2px 1px #1e235a66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operate-item {
|
||||||
|
--uno: mr-6px p-2px text-20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||