代码初始化
|
@ -0,0 +1,8 @@
|
||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
|
@ -0,0 +1,7 @@
|
||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<ScalaCodeStyleSettings>
|
||||||
|
<option name="MULTILINE_STRING_CLOSING_QUOTES_ON_NEW_LINE" value="true" />
|
||||||
|
</ScalaCodeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||||
|
</state>
|
||||||
|
</component>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/mentalHealth.iml" filepath="$PROJECT_DIR$/.idea/mentalHealth.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
|
@ -0,0 +1,22 @@
|
||||||
|
# 项目中文名称
|
||||||
|
VITE_APP_TITLE=["云舒_心理健康云平台","云舒_心理健康云平台","云舒_心理健康云平台"]
|
||||||
|
# 项目英文名称
|
||||||
|
VITE_APP_TITLE_EN=["Mental_Health"]
|
||||||
|
# 项目副标题
|
||||||
|
VITE_APP_SUBTITLE=[""]
|
||||||
|
# 项目识别码
|
||||||
|
VITE_APP_KEY=["x-mental-health"]
|
||||||
|
# 项目 STORE 识别码
|
||||||
|
VITE_APP_STORE_KEY=["platform","client","portal"]
|
||||||
|
# 盐
|
||||||
|
VITE_APP_SALT=["f96a62c83906df59"]
|
||||||
|
# 主页地址
|
||||||
|
VITE_BASE_PATH=["/"]
|
||||||
|
# 登录地址
|
||||||
|
VITE_AUTH_PATH=["/login"]
|
||||||
|
# 注册地址
|
||||||
|
VITE_REGISTER_PATH=["/register"]
|
||||||
|
# 首页地址
|
||||||
|
VITE_HOME_PATH=["/tenant","/subUser","/home"]
|
||||||
|
# 大屏布局目录
|
||||||
|
VITE_LAYOUT_SCREEN=["/screen"]
|
|
@ -0,0 +1,15 @@
|
||||||
|
# 运行环境
|
||||||
|
VITE_MODE="development"
|
||||||
|
# 项目标题后缀
|
||||||
|
VITE_APP_TITLE_SUFFIX=["_开发"]
|
||||||
|
# 公共基础路径
|
||||||
|
VITE_PUBLIC_PATH=["/","/","/"]
|
||||||
|
# 项目接口地址后缀
|
||||||
|
VITE_API_URL_SUFFIX=["https://api.ysmental.com/mental-health-api/api"]
|
||||||
|
# VITE_API_URL_SUFFIX=["http://127.0.0.1:9030/api"]
|
||||||
|
# 文件上传地址
|
||||||
|
VITE_FILE_UPLOAD=["/upload/file/annex","/tenant/subUserManagement/batchImportSubUser","/questionsInfo/importRecord"]
|
||||||
|
# 文件下载地址
|
||||||
|
VITE_FILE_DOWNLOAD=["https://api.ysmental.com/upload"]
|
||||||
|
# 跨域代理配置
|
||||||
|
VITE_PROXY=[["/mental-health-api/api","https://api.ysmental.com/mental-health-api/api"],["/upload","https://api.ysmental.com/upload"]]
|
|
@ -0,0 +1,15 @@
|
||||||
|
# 运行环境
|
||||||
|
VITE_MODE="production"
|
||||||
|
# 项目标题后缀
|
||||||
|
VITE_APP_TITLE_SUFFIX=[""]
|
||||||
|
# 公共基础路径
|
||||||
|
VITE_PUBLIC_PATH=["/","/","/"]
|
||||||
|
# 项目接口地址后缀
|
||||||
|
VITE_API_URL_SUFFIX=["https://api.ysmental.com/mental-health-api/api"]
|
||||||
|
# VITE_API_URL_SUFFIX=["http://127.0.0.1:9030/api"]
|
||||||
|
# 文件上传地址
|
||||||
|
VITE_FILE_UPLOAD=["/upload/file/annex","/tenant/subUserManagement/batchImportSubUser","/questionsInfo/importRecord"]
|
||||||
|
# 文件下载地址
|
||||||
|
VITE_FILE_DOWNLOAD=["https://api.ysmental.com/upload"]
|
||||||
|
# 跨域代理配置
|
||||||
|
VITE_PROXY=[["/mental-health-api/api","https://api.ysmental.com/mental-health-api/api"],["/upload","https://api.ysmental.com/upload"]]
|
|
@ -0,0 +1,12 @@
|
||||||
|
node_modules
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
public
|
||||||
|
|
||||||
|
*.html
|
||||||
|
*.md
|
||||||
|
*.json
|
||||||
|
*.min.{js,mjs,cjs,ts,mts,cts}
|
||||||
|
|
||||||
|
auto-imports.d.ts
|
||||||
|
.eslintrc-auto-imports.*.json
|
|
@ -0,0 +1,50 @@
|
||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"extends": ["everqin", "everqin/typescript", "turbo"],
|
||||||
|
"plugins": ["react-refresh"],
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "detect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["apps/client/**"],
|
||||||
|
"extends": [".eslintrc-auto-imports.client.json"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["apps/platform/**"],
|
||||||
|
"extends": [".eslintrc-auto-imports.platform.json"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["apps/portal/**"],
|
||||||
|
"extends": [".eslintrc-auto-imports.portal.json"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["apps/**/**"],
|
||||||
|
"extends": ["everqin", "everqin/react", "everqin/typescript", "plugin:react-hooks/recommended"],
|
||||||
|
"parserOptions": {
|
||||||
|
"project": "./apps/**/tsconfig.json"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"complexity": ["off"],
|
||||||
|
"max-params": ["off"],
|
||||||
|
"no-undef": ["off"],
|
||||||
|
// react
|
||||||
|
"react-refresh/only-export-components": "warn",
|
||||||
|
"react/no-unknown-property": ["off"],
|
||||||
|
"react/no-unstable-nested-components": ["off"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["packages/**/**"],
|
||||||
|
"extends": ["everqin", "everqin/react", "everqin/typescript", "plugin:react-hooks/recommended"],
|
||||||
|
"parserOptions": {
|
||||||
|
"project": "./packages/**/tsconfig.json"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-undef": ["off"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
.idea
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# output
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# turbo
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# local
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# auto import
|
||||||
|
auto-imports.d.ts
|
||||||
|
.eslintrc-auto-imports.*.json
|
|
@ -0,0 +1,6 @@
|
||||||
|
# registry=https://registry.npmjs.org/
|
||||||
|
registry=https://registry.npmmirror.com/
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
auto-install-peers=true
|
||||||
|
strict-peer-dependencies=false
|
|
@ -0,0 +1,12 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
public
|
||||||
|
|
||||||
|
*.svg
|
||||||
|
*.min.{js,mjs,cjs,ts,mts,cts}
|
||||||
|
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
auto-imports.d.ts
|
||||||
|
.eslintrc-auto-imports.*.json
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"singleAttributePerLine": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"vueIndentScriptAndStyle": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"singleAttributePerLine": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.json5"],
|
||||||
|
"options": {
|
||||||
|
"singleQuote": false,
|
||||||
|
"quoteProps": "preserve"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.yml"],
|
||||||
|
"options": {
|
||||||
|
"singleQuote": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.zod.ts", "**/zod/**/*.ts", "*.type.d.ts"],
|
||||||
|
"options": {
|
||||||
|
"printWidth": 360
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
public
|
||||||
|
|
||||||
|
*.min.css
|
|
@ -0,0 +1,58 @@
|
||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"extends": ["stylelint-config-standard", "stylelint-config-property-sort-order-smacss"],
|
||||||
|
"rules": {
|
||||||
|
"declaration-property-value-no-unknown": true,
|
||||||
|
"at-rule-no-unknown": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ignoreAtRules": [
|
||||||
|
"tailwind",
|
||||||
|
"apply",
|
||||||
|
"variants",
|
||||||
|
"responsive",
|
||||||
|
"screen",
|
||||||
|
"function",
|
||||||
|
"if",
|
||||||
|
"each",
|
||||||
|
"include",
|
||||||
|
"mixin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"font-family-no-missing-generic-family-keyword": null,
|
||||||
|
"function-no-unknown": null,
|
||||||
|
"named-grid-areas-no-invalid": null,
|
||||||
|
"no-empty-source": null,
|
||||||
|
"no-descending-specificity": null,
|
||||||
|
"selector-class-pattern": "^(?:(?:o|c|u|t|s|is|has|_|js|qa)-)?[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*(?:__[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*)?(?:--[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*)?(?:\\[.+\\])?$",
|
||||||
|
"selector-pseudo-class-no-unknown": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ignorePseudoClasses": ["global"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unit-no-unknown": [true, { "ignoreUnits": ["rpx"] }]
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["**/*.{html,vue}"],
|
||||||
|
"customSyntax": "postcss-html",
|
||||||
|
"rules": {
|
||||||
|
"keyframes-name-pattern": null,
|
||||||
|
"selector-pseudo-class-no-unknown": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ignorePseudoClasses": ["deep", "global"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"selector-pseudo-element-no-unknown": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ignorePseudoElements": ["v-deep", "v-global", "v-slotted"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"aaron-bond.better-comments",
|
||||||
|
"antfu.iconify",
|
||||||
|
"antfu.unocss",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"irongeek.vscode-env",
|
||||||
|
"lokalise.i18n-ally",
|
||||||
|
"streetsidesoftware.code-spell-checker",
|
||||||
|
"stylelint.vscode-stylelint",
|
||||||
|
"vunguyentuan.vscode-postcss"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,111 @@
|
||||||
|
{
|
||||||
|
"files.eol": "\n",
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "never",
|
||||||
|
"source.fixAll.eslint": "explicit",
|
||||||
|
"source.fixAll.stylelint": "explicit",
|
||||||
|
"source.organizeImports": "never"
|
||||||
|
},
|
||||||
|
"material-icon-theme.folders.associations": {
|
||||||
|
"enums": "typescript",
|
||||||
|
"store": "context"
|
||||||
|
},
|
||||||
|
"files.associations": {
|
||||||
|
"*.css": "postcss"
|
||||||
|
},
|
||||||
|
"editor.quickSuggestions": {
|
||||||
|
"strings": "on"
|
||||||
|
},
|
||||||
|
"prettier.tabWidth": 2,
|
||||||
|
"prettier.printWidth": 120,
|
||||||
|
"prettier.singleAttributePerLine": true,
|
||||||
|
"prettier.singleQuote": true,
|
||||||
|
"prettier.trailingComma": "all",
|
||||||
|
"prettier.vueIndentScriptAndStyle": true,
|
||||||
|
"tsImportSorter.configuration.emptyLinesBetweenGroups": 0,
|
||||||
|
"tsImportSorter.configuration.wrappingStyle": "prettier",
|
||||||
|
"tsImportSorter.configuration.excludeGlob": ["auto-imports.d.ts"],
|
||||||
|
"better-comments.tags": [
|
||||||
|
{
|
||||||
|
"tag": "! ",
|
||||||
|
"color": "#FF2D00",
|
||||||
|
"strikethrough": false,
|
||||||
|
"underline": false,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"bold": false,
|
||||||
|
"italic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "? ",
|
||||||
|
"color": "#60a5fa",
|
||||||
|
"strikethrough": false,
|
||||||
|
"underline": false,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"bold": false,
|
||||||
|
"italic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "& ",
|
||||||
|
"color": "#FF8C00",
|
||||||
|
"strikethrough": false,
|
||||||
|
"underline": false,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"bold": false,
|
||||||
|
"italic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "* ",
|
||||||
|
"color": "#84cc16",
|
||||||
|
"strikethrough": false,
|
||||||
|
"underline": false,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"bold": false,
|
||||||
|
"italic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "~ ",
|
||||||
|
"color": "#22d3ee",
|
||||||
|
"strikethrough": false,
|
||||||
|
"underline": false,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"bold": false,
|
||||||
|
"italic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "@ ",
|
||||||
|
"color": "#34d399",
|
||||||
|
"strikethrough": false,
|
||||||
|
"underline": false,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"bold": false,
|
||||||
|
"italic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "$ ",
|
||||||
|
"color": "#a78bfa",
|
||||||
|
"strikethrough": false,
|
||||||
|
"underline": false,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"bold": false,
|
||||||
|
"italic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "% ",
|
||||||
|
"color": "#e879f9",
|
||||||
|
"strikethrough": false,
|
||||||
|
"underline": false,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"bold": false,
|
||||||
|
"italic": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"markdownlint.config": {
|
||||||
|
"default": true,
|
||||||
|
"MD033": false
|
||||||
|
},
|
||||||
|
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||||
|
"unocss.root": ["apps/platform", "apps/client", "apps/portal"],
|
||||||
|
"eslint.workingDirectories": ["apps/**", "packages/**"]
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
# <center>Mental Health
|
||||||
|
|
||||||
|
## 介绍
|
||||||
|
|
||||||
|
心理测评
|
||||||
|
|
||||||
|
## 软件架构
|
||||||
|
|
||||||
|
Vite + React + TypeScript + Foca + Antd + Zod
|
||||||
|
|
||||||
|
## 开发环境
|
||||||
|
|
||||||
|
1. `node` 版本 >= `16`
|
||||||
|
2. `pnpm` 版本 >= `8`
|
||||||
|
|
||||||
|
## 安装教程
|
||||||
|
|
||||||
|
1. 克隆项目
|
||||||
|
2. 运行 `pnpm install`
|
||||||
|
3. 运行 `pnpm dev`
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
- ### 包管理器
|
||||||
|
|
||||||
|
1. 请使用 `pnpm` 安装运行项目,禁止使用 npm、yarn。
|
||||||
|
2. 如果是初次安装 pnpm@7 记得运行 `pnpm setup` 后,重新启动编辑器,其他版本 `pnpm` 则不需要执行此操作。
|
||||||
|
|
||||||
|
- ### JavaScript/TypeScript
|
||||||
|
|
||||||
|
- 使用 JavaScript 开发时,请使用 `js|jsx` 文件拓展名。
|
||||||
|
- 使用 TypeScript 开发时,请使用 `ts|tsx` 文件拓展名。
|
||||||
|
- 文件名关系到项目的运行和编译,请仔细确认。
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title><%- HTML_TITLE %></title>
|
||||||
|
<link type="image/png" href="/favicon-light.png" rel="shortcut icon" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>This app works best with JavaScript enabled.</noscript>
|
||||||
|
<div id="root" class="wh-full"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"author": "handpear<handpear@outlook.com>",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --mode development --open --port 6173",
|
||||||
|
"dev:production": "vite --mode production --open --port 6174",
|
||||||
|
"preview": "vite preview --open --port 6175",
|
||||||
|
"build": "vite build --mode production",
|
||||||
|
"build:development": "vite build --mode development",
|
||||||
|
"type:check": "tsc --noEmit --skipLibCheck"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite';
|
||||||
|
import * as Preset from '../../../share/plugins.json';
|
||||||
|
import { name } from '../package.json';
|
||||||
|
|
||||||
|
export const configAutoImport = () => {
|
||||||
|
return AutoImport({
|
||||||
|
include: [/\.[cm]?[tj]sx?$/, /\.vue$/, /\.vue\?vue/, /\.md$/],
|
||||||
|
imports: [
|
||||||
|
'react',
|
||||||
|
'react-router-dom',
|
||||||
|
'ahooks',
|
||||||
|
{
|
||||||
|
antd: Preset.antd,
|
||||||
|
foca: Preset.foca,
|
||||||
|
zod: Preset.zod,
|
||||||
|
'framer-motion': Preset['framer-motion'],
|
||||||
|
'@ant-design/pro-components': [['*', 'P']],
|
||||||
|
'@handpear/zod': [['*', 'Z'], 'Req', 'Res'],
|
||||||
|
'@handpear/enums': [['*', 'E']],
|
||||||
|
'@handpear/hooks': [['*', 'H']],
|
||||||
|
'@handpear/utils': [['*', 'U'], 'EnumKeys', 'defineApis', 'getTableSize', 'getValueRange'],
|
||||||
|
'@handpear/ui': [['*', 'Ui'], 'message', 'modal', 'notification', 'Icon', 'Scrollbar'],
|
||||||
|
'~/modules/dayjs': ['dayjs'],
|
||||||
|
'~/modules/store': ['localForage'],
|
||||||
|
'~/service': ['request', 'controller'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'antd',
|
||||||
|
imports: [['*', 'Antd']],
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: '@ant-design/pro-components',
|
||||||
|
imports: [['*', 'Pro']],
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: '@handpear/enums',
|
||||||
|
imports: ['COLOR_SCHEMA', 'EDIT_TYPE'],
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dirs: [
|
||||||
|
'src/components/**/*',
|
||||||
|
'src/composable/**/*',
|
||||||
|
'src/store/**/*',
|
||||||
|
'src/enums/**/*',
|
||||||
|
'src/hooks/**/*',
|
||||||
|
'src/utils/**/*',
|
||||||
|
'src/zod/**/*',
|
||||||
|
'src/**/model.{js,ts}',
|
||||||
|
'src/**/*.zod.{js,ts}',
|
||||||
|
],
|
||||||
|
dts: true,
|
||||||
|
eslintrc: { enabled: true, filepath: `../../.eslintrc-auto-imports.${name}.json` },
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { createHtmlPlugin } from 'vite-plugin-html';
|
||||||
|
|
||||||
|
export const configHtml = (env: ImportMetaEnv, isBuild: boolean) => {
|
||||||
|
return createHtmlPlugin({
|
||||||
|
minify: isBuild,
|
||||||
|
inject: {
|
||||||
|
data: {
|
||||||
|
HTML_TITLE: `${env.VITE_APP_TITLE[1]}${env.VITE_APP_TITLE_SUFFIX[0]}`,
|
||||||
|
},
|
||||||
|
// 嵌入配置文件
|
||||||
|
tags: [
|
||||||
|
// {
|
||||||
|
// tag: 'link',
|
||||||
|
// attrs: {
|
||||||
|
// rel: 'stylesheet',
|
||||||
|
// href: 'http://example.css',
|
||||||
|
// },
|
||||||
|
// injectTo: 'head',
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,31 @@
|
||||||
|
import viteImagemin from 'vite-plugin-imagemin';
|
||||||
|
|
||||||
|
export const configImagemin = () => {
|
||||||
|
return viteImagemin({
|
||||||
|
gifsicle: {
|
||||||
|
optimizationLevel: 7,
|
||||||
|
interlaced: false,
|
||||||
|
},
|
||||||
|
optipng: {
|
||||||
|
optimizationLevel: 7,
|
||||||
|
},
|
||||||
|
mozjpeg: {
|
||||||
|
quality: 20,
|
||||||
|
},
|
||||||
|
pngquant: {
|
||||||
|
quality: [0.8, 0.9],
|
||||||
|
speed: 4,
|
||||||
|
},
|
||||||
|
svgo: {
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: 'removeViewBox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'removeEmptyAttrs',
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,7 @@
|
||||||
|
import legacy from '@vitejs/plugin-legacy';
|
||||||
|
|
||||||
|
export const configLegacy = () => {
|
||||||
|
return legacy({
|
||||||
|
targets: ['defaults', 'not IE 11', 'chrome > 86'],
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,10 @@
|
||||||
|
import Pages from 'vite-plugin-pages';
|
||||||
|
|
||||||
|
export const configPages = () => {
|
||||||
|
return Pages({
|
||||||
|
caseSensitive: true,
|
||||||
|
dirs: 'src/views',
|
||||||
|
exclude: ['**/components/*', '**/_*/*', '**/_*.*'],
|
||||||
|
extensions: ['jsx', 'tsx', 'vue'],
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,5 @@
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export const configReact = () => {
|
||||||
|
return react();
|
||||||
|
};
|
|
@ -0,0 +1,5 @@
|
||||||
|
import Unocss from 'unocss/vite';
|
||||||
|
|
||||||
|
export const configUnocss = () => {
|
||||||
|
return Unocss();
|
||||||
|
};
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { PluginOption } from 'vite';
|
||||||
|
import { configAutoImport } from './AutoImport';
|
||||||
|
import { configHtml } from './Html';
|
||||||
|
import { configImagemin } from './Imagemin';
|
||||||
|
import { configLegacy } from './Legacy';
|
||||||
|
import { configPages } from './Pages';
|
||||||
|
import { configReact } from './React';
|
||||||
|
import { configUnocss } from './Unocss';
|
||||||
|
|
||||||
|
export const loadPlugins = (env: ImportMetaEnv, isBuild: boolean) => {
|
||||||
|
/**
|
||||||
|
* vite 插件列表
|
||||||
|
* @description (!)代表必要插件,(?)代表可选插件,(*)代表推荐插件
|
||||||
|
*/
|
||||||
|
const vitePlugins: (PluginOption | PluginOption[])[] = [
|
||||||
|
// ! 原子化 css 引擎
|
||||||
|
configUnocss(),
|
||||||
|
// ! 用于 React 项目的一体化 Vite 插件
|
||||||
|
configReact(),
|
||||||
|
// ! 文件路由系统
|
||||||
|
configPages(),
|
||||||
|
// ! 自动导入 API
|
||||||
|
configAutoImport(),
|
||||||
|
// * 为 index.html 提供压缩和基于 ejs 模板功能的 vite 插件
|
||||||
|
configHtml(env, isBuild),
|
||||||
|
// ? 图片压缩插件
|
||||||
|
configImagemin(),
|
||||||
|
// ? 为支持传统浏览器的语言提供兼容性支持
|
||||||
|
configLegacy(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return vitePlugins;
|
||||||
|
};
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { ProxyOptions } from 'vite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理运行环境配置项,使其拥有正确的类型
|
||||||
|
*
|
||||||
|
* @template E
|
||||||
|
* @param {(Record<keyof E, string> | E)} importMetaEnv 导入的环境变量
|
||||||
|
* @returns {E}
|
||||||
|
*/
|
||||||
|
export const resolveEnv = <E extends ImportMetaEnv>(importMetaEnv: Record<keyof E, string> | E): E => {
|
||||||
|
const ret = {} as unknown as E;
|
||||||
|
const envNameList = Object.keys(importMetaEnv) as (keyof E)[];
|
||||||
|
|
||||||
|
for (const envName of envNameList) {
|
||||||
|
let value = typeof importMetaEnv[envName] === 'string' && importMetaEnv[envName].replace(/\\n/g, '\n');
|
||||||
|
value = value === 'true' ? true : value === 'false' ? false : value;
|
||||||
|
|
||||||
|
if (typeof value === 'string' && value.startsWith('[') && value.endsWith(']')) {
|
||||||
|
try {
|
||||||
|
value = JSON.parse(value.replace(/'/g, '"'));
|
||||||
|
} catch (error) {
|
||||||
|
value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ret[envName] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发服务器代理配置
|
||||||
|
*
|
||||||
|
* @template L extends [string, ...string[]][]
|
||||||
|
* @param {L} list 代理配置项
|
||||||
|
*/
|
||||||
|
export const resolveProxy = <L extends [string, ...string[]][]>(list: L) => {
|
||||||
|
const httpsRE = /^https:\/\//;
|
||||||
|
const ret: Record<string, ProxyOptions> = {};
|
||||||
|
|
||||||
|
if (typeof list === 'object') {
|
||||||
|
for (const [prefix, target] of list) {
|
||||||
|
const isHttps = httpsRE.test(target!);
|
||||||
|
|
||||||
|
// https://github.com/http-party/node-http-proxy#options
|
||||||
|
ret[prefix] = {
|
||||||
|
target: target,
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
rewrite: (path) => path.replace(new RegExp(`^${prefix}`), ''),
|
||||||
|
// https 需要开启 secure = false
|
||||||
|
...(isHttps ? { secure: false } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
};
|
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 8.6 KiB |
|
@ -0,0 +1,22 @@
|
||||||
|
import { App as AppProvider } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import { Router } from './Router';
|
||||||
|
|
||||||
|
export const App = () => {
|
||||||
|
const themeAlgorithm = useAlgorithm();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={themeAlgorithm}
|
||||||
|
>
|
||||||
|
<AppProvider>
|
||||||
|
<PreviewModal />
|
||||||
|
<Ui.Compatibility />
|
||||||
|
<Ui.StaticFunction />
|
||||||
|
<Ui.GlobalScrollbar />
|
||||||
|
<Router />
|
||||||
|
</AppProvider>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import Layouts from './layouts';
|
||||||
|
import { SignIn } from './layouts/sign/SignIn';
|
||||||
|
|
||||||
|
export const Router = () => {
|
||||||
|
let isRedirect = true;
|
||||||
|
const computed_isSigned = useComputed(systemModel.computed_isSigned);
|
||||||
|
const URLParams = new URLSearchParams(window.location.search);
|
||||||
|
const tenantNo = URLParams.get('tenantNo');
|
||||||
|
|
||||||
|
if (tenantNo && tenantNo !== 'null' && tenantNo !== 'undefined') {
|
||||||
|
isRedirect = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter basename={ENV.VITE_PUBLIC_PATH[1]!}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path={ENV.VITE_BASE_PATH[0]}
|
||||||
|
element={
|
||||||
|
<Navigate
|
||||||
|
replace
|
||||||
|
to={ENV.VITE_AUTH_PATH[0]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ENV.VITE_AUTH_PATH[0]}
|
||||||
|
element={
|
||||||
|
computed_isSigned && isRedirect ? (
|
||||||
|
<Navigate
|
||||||
|
replace
|
||||||
|
to={ENV.VITE_HOME_PATH[0]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SignIn />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
computed_isSigned ? (
|
||||||
|
<Layouts />
|
||||||
|
) : (
|
||||||
|
<Navigate
|
||||||
|
replace
|
||||||
|
to={ENV.VITE_AUTH_PATH[0]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,3 @@
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
此文件夹下存放了项目中所有的资源文件
|
After Width: | Height: | Size: 4.0 MiB |
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 2.7 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 27 KiB |
After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 27 KiB |
After Width: | Height: | Size: 27 KiB |
After Width: | Height: | Size: 3.6 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 62 KiB |
After Width: | Height: | Size: 136 KiB |
After Width: | Height: | Size: 73 KiB |
|
@ -0,0 +1,30 @@
|
||||||
|
import type { DividerProps } from 'antd';
|
||||||
|
|
||||||
|
interface P_ContentHeader {
|
||||||
|
title?: string;
|
||||||
|
dividerProps?: DividerProps;
|
||||||
|
operation?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContentHeader: React.FC<P_ContentHeader> = ({
|
||||||
|
title,
|
||||||
|
dividerProps = { orientation: 'left' },
|
||||||
|
operation,
|
||||||
|
}) => {
|
||||||
|
const { className = '' } = dividerProps;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row className="pb-6 justify-between">
|
||||||
|
<Col flex="auto">
|
||||||
|
<Divider
|
||||||
|
{...dividerProps}
|
||||||
|
className={className}
|
||||||
|
un-before="xxl:!w-48 xl:!w-36 !w-24"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Divider>
|
||||||
|
</Col>
|
||||||
|
<Space>{operation}</Space>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,103 @@
|
||||||
|
export const FileUpload: React.FC<Files.UploadProps> = (props) => {
|
||||||
|
const {
|
||||||
|
accept = EnumKeys(E.MIME).map((t) => E.MIME[t]),
|
||||||
|
headers,
|
||||||
|
dragger = false,
|
||||||
|
fileList = [],
|
||||||
|
setFileList,
|
||||||
|
allowMaxSize,
|
||||||
|
uploadButton,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
...restProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
useMount(() => {
|
||||||
|
setFileList?.(fileList);
|
||||||
|
});
|
||||||
|
|
||||||
|
return dragger ? (
|
||||||
|
<Upload.Dragger
|
||||||
|
accept={`${accept}` as string}
|
||||||
|
headers={headers ?? { [KEYS.TOKEN]: systemModel.state.token, 'x-mental-health-type': '1' }}
|
||||||
|
fileList={fileList}
|
||||||
|
beforeUpload={(file) => filesModel.beforeUpload(file, accept, allowMaxSize)}
|
||||||
|
onPreview={filesModel.onUploadPreview}
|
||||||
|
onChange={(info) => filesModel.onUploadChange({ info, setFileList, onSuccess, onError })}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Upload
|
||||||
|
accept={`${accept}` as string}
|
||||||
|
headers={headers ?? { [KEYS.TOKEN]: systemModel.state.token, 'x-mental-health-type': '1' }}
|
||||||
|
fileList={fileList}
|
||||||
|
beforeUpload={(file) => filesModel.beforeUpload(file, accept, allowMaxSize)}
|
||||||
|
onPreview={filesModel.onUploadPreview}
|
||||||
|
onChange={(info) => filesModel.onUploadChange({ info, setFileList, onSuccess, onError })}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{restProps.disabled || (restProps.maxCount && fileList.length >= restProps.maxCount)
|
||||||
|
? null
|
||||||
|
: uploadButton || <UploadButton listType={restProps.listType || 'text'} />}
|
||||||
|
</Upload>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 上传按钮 */
|
||||||
|
const UploadButton: React.FC<Pick<Files.UploadProps, 'listType'>> = ({ listType }) => {
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
className="cursor-pointer flex-center"
|
||||||
|
un-flex={listType === 'picture-card' ? 'col' : ''}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="fluent:folder-arrow-up-24-filled"
|
||||||
|
className="text-theme text-2xl text-neutral-500"
|
||||||
|
/>
|
||||||
|
<Col className="mt-0.5 px-1">上传附件</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 预览窗口 */
|
||||||
|
export const PreviewModal = () => {
|
||||||
|
const [name, path] = useModel(filesModel, (filesModel) => [
|
||||||
|
decodeURIComponent(filesModel.previewFile?.name || ''),
|
||||||
|
filesModel.previewFile?.path,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const renderRecordPreview = () => {
|
||||||
|
if (!path || !name) return null;
|
||||||
|
const extension = name.split('.').pop()?.toLowerCase() as keyof typeof E.MIME;
|
||||||
|
const isImage = [E.MIME.jpg, E.MIME.jpeg, E.MIME.png, E.MIME.gif].includes(E.MIME[extension]);
|
||||||
|
|
||||||
|
return isImage ? (
|
||||||
|
<img src={path} />
|
||||||
|
) : (
|
||||||
|
<Row className="flex-col flex-center">
|
||||||
|
<Col className="py-3">{name}</Col>
|
||||||
|
<Col>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => filesModel.downloadFile({ method: 'GET', url: path, fileName: name })}
|
||||||
|
>
|
||||||
|
下载文件
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
width="80%"
|
||||||
|
title={name}
|
||||||
|
open={!!path}
|
||||||
|
footer={null}
|
||||||
|
onCancel={filesModel.onPreviewModalCancel}
|
||||||
|
zIndex={1050}
|
||||||
|
>
|
||||||
|
<Row className="flex-center wh-full">{renderRecordPreview()}</Row>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,32 @@
|
||||||
|
interface P_MotionLayout extends React.HTMLAttributes<unknown> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MotionLayout: React.FC<P_MotionLayout> = ({ children, className = '', ...rest }) => {
|
||||||
|
const [isMotion, transition, height] = useModel(basicLayoutModel, (basicLayoutModel) => [
|
||||||
|
basicLayoutModel.motions.isMotion,
|
||||||
|
basicLayoutModel.motions.transition,
|
||||||
|
basicLayoutModel.layout.mainLayoutSize.height || '100%',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-theme-layout"
|
||||||
|
initial={isMotion ? { x: -240, opacity: 0 } : {}}
|
||||||
|
animate={isMotion ? { x: 0, opacity: 1 } : {}}
|
||||||
|
exit={isMotion ? { x: 240, opacity: 0 } : {}}
|
||||||
|
transition={transition}
|
||||||
|
>
|
||||||
|
<Scrollbar style={{ height }}>
|
||||||
|
<Row
|
||||||
|
gutter={[0, 24]}
|
||||||
|
className={className}
|
||||||
|
un-children="bg-theme-container text-theme p-6 rounded-md"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Row>
|
||||||
|
</Scrollbar>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,155 @@
|
||||||
|
import type { ColProps } from 'antd/es/grid/col';
|
||||||
|
|
||||||
|
/** 页面布局 - 搜索类型 */
|
||||||
|
type SearchType = 'simple' | 'advance';
|
||||||
|
|
||||||
|
interface P_SearchForm<T extends SearchType = 'simple'> {
|
||||||
|
/** 样式 */
|
||||||
|
className?: string;
|
||||||
|
/** 标签布局样式 */
|
||||||
|
labelCol?: ColProps;
|
||||||
|
/** 输入控件布局样式 */
|
||||||
|
wrapperCol?: ColProps;
|
||||||
|
/** 简单搜索初始选项 */
|
||||||
|
defaultSimpleValue?: unknown;
|
||||||
|
/**
|
||||||
|
* 搜索类型
|
||||||
|
* @default "simple"
|
||||||
|
*/
|
||||||
|
searchType?: SearchType;
|
||||||
|
|
||||||
|
showChangeSearchTypeButton?: boolean;
|
||||||
|
/** 简单搜索表单项 */
|
||||||
|
simpleFormItems?: React.ReactNode;
|
||||||
|
/** 高级搜索表单项 */
|
||||||
|
advanceFormItems?: React.ReactNode;
|
||||||
|
onFinish: <V>(value: T extends 'simple' ? { [key: Key]: unknown } : V) => void;
|
||||||
|
onReset: (...args: any[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchForm: React.FC<P_SearchForm> = (props) => {
|
||||||
|
const {
|
||||||
|
searchType = 'simple',
|
||||||
|
defaultSimpleValue,
|
||||||
|
simpleFormItems,
|
||||||
|
advanceFormItems,
|
||||||
|
showChangeSearchTypeButton = true,
|
||||||
|
onFinish,
|
||||||
|
onReset,
|
||||||
|
...restProps
|
||||||
|
} = props;
|
||||||
|
const [form] = Form.useForm<typeof searchType extends 'simple' ? { key: string; value: string } : Recordable>();
|
||||||
|
const [formSearchType, setFormSearchType, getFormSearchType] = useGetState<SearchType>(searchType);
|
||||||
|
|
||||||
|
const { run: onDebounceReset } = useDebounceFn(onReset);
|
||||||
|
const { run: onDebounceFinish } = useDebounceFn((value: Parameters<P_SearchForm['onFinish']>[0]) => onFinish(value));
|
||||||
|
|
||||||
|
useMount(() => {
|
||||||
|
setFormSearchType(searchType);
|
||||||
|
form.resetFields();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 切换搜索类型 */
|
||||||
|
const onSearchTypeChange = () => {
|
||||||
|
const formSearchType = getFormSearchType();
|
||||||
|
setFormSearchType(formSearchType === 'simple' ? 'advance' : 'simple');
|
||||||
|
form.resetFields();
|
||||||
|
};
|
||||||
|
const onSimpleSearch = () => {
|
||||||
|
form.validateFields().then(({ key, value }) => {
|
||||||
|
onDebounceFinish({ [`${key}`]: value });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onSearchFormReset = () => {
|
||||||
|
form.resetFields();
|
||||||
|
onDebounceReset();
|
||||||
|
};
|
||||||
|
/** 简单搜索表单 */
|
||||||
|
const simpleSearchForm = useCreation(
|
||||||
|
() => (
|
||||||
|
<Space size="middle">
|
||||||
|
<Form.Item noStyle>
|
||||||
|
<Space.Compact>
|
||||||
|
<Form.Item
|
||||||
|
name="key"
|
||||||
|
initialValue={defaultSimpleValue}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
{simpleFormItems}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="value"
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="请输入"
|
||||||
|
onSearch={onSimpleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space.Compact>
|
||||||
|
</Form.Item>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
onClick={onSearchFormReset}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
onClick={onSearchTypeChange}
|
||||||
|
>
|
||||||
|
高级搜索
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
[simpleFormItems],
|
||||||
|
);
|
||||||
|
/** 高级搜索表单 */
|
||||||
|
const advanceSearchForm = useCreation(
|
||||||
|
() => (
|
||||||
|
<Row className="w-full">
|
||||||
|
<Col span={24}>{advanceFormItems}</Col>
|
||||||
|
<Col
|
||||||
|
span={24}
|
||||||
|
className="flex justify-end"
|
||||||
|
>
|
||||||
|
<Space size="middle">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
onClick={onSearchFormReset}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
{showChangeSearchTypeButton ? (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
onClick={onSearchTypeChange}
|
||||||
|
>
|
||||||
|
简单搜索
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
[advanceFormItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
className="flex-x-center"
|
||||||
|
onFinish={onFinish}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{formSearchType === 'simple' ? simpleSearchForm : advanceSearchForm}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,4 @@
|
||||||
|
export function isWebLink(path: string | null | undefined): boolean {
|
||||||
|
if (path) return path.startsWith('http');
|
||||||
|
else return false;
|
||||||
|
}
|
|
@ -0,0 +1,137 @@
|
||||||
|
import type { MappingAlgorithm } from 'antd/es/config-provider/context';
|
||||||
|
import type { AliasToken, MapToken } from 'antd/es/theme/interface';
|
||||||
|
|
||||||
|
const screenBreakPoints: Partial<Recordable<`screen${keyof typeof E.SCREEN_BREAK_POINTS}`, number>> = {};
|
||||||
|
|
||||||
|
U.ObjectEntries(E.SCREEN_BREAK_POINTS).forEach(([k, v]) => {
|
||||||
|
screenBreakPoints[`screen${k}`] = v;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 算法模式 */
|
||||||
|
export function useAlgorithm() {
|
||||||
|
const media = useCreation(() => globalThis.matchMedia('(prefers-color-scheme: dark)'), []);
|
||||||
|
const responsive = useResponsive();
|
||||||
|
const computed_isSigned = useComputed(systemModel.computed_isSigned);
|
||||||
|
const [algorithm, setAlgorithm] = useState<MappingAlgorithm[]>([]);
|
||||||
|
const [token, setToken] = useState<Partial<AliasToken>>({});
|
||||||
|
const [isCompact, setIsCompact] = useState<boolean>(false);
|
||||||
|
const [colorSchema, colorPrimary, isFollowSystem] = useModel(basicLayoutModel, (basicLayoutModel) => [
|
||||||
|
basicLayoutModel.theme.colorSchema,
|
||||||
|
basicLayoutModel.theme.colorPrimary,
|
||||||
|
basicLayoutModel.theme.isFollowSystem,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 监听颜色模式变化,将颜色模式算法传递给应用 */
|
||||||
|
const onMediaMatchesChange = useMemoizedFn(() => {
|
||||||
|
// 设置算法模式
|
||||||
|
basicLayoutModel.matchMediaColorSchema().then((schema: typeof colorSchema) => {
|
||||||
|
const algorithmList: typeof algorithm = [];
|
||||||
|
const isCompact = !responsive['xl'];
|
||||||
|
schema === 'light' && algorithmList.push(theme.defaultAlgorithm);
|
||||||
|
schema === 'dark' && algorithmList.push(theme.darkAlgorithm);
|
||||||
|
isCompact && algorithmList.push(theme.compactAlgorithm);
|
||||||
|
setAlgorithm(algorithmList);
|
||||||
|
setIsCompact(isCompact);
|
||||||
|
});
|
||||||
|
// const algorithmList: typeof algorithm = [];
|
||||||
|
// const isCompact = !responsive['xl'];
|
||||||
|
// algorithmList.push(theme.defaultAlgorithm);
|
||||||
|
// isCompact && algorithmList.push(theme.compactAlgorithm);
|
||||||
|
// setAlgorithm(algorithmList);
|
||||||
|
// setIsCompact(isCompact);
|
||||||
|
});
|
||||||
|
|
||||||
|
useMount(() => media.addEventListener('change', onMediaMatchesChange));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { defaultAlgorithm, darkAlgorithm, defaultSeed } = theme;
|
||||||
|
const lightMapToken = defaultAlgorithm({ ...defaultSeed, colorPrimary });
|
||||||
|
const darkMapToken = darkAlgorithm({ ...defaultSeed, colorPrimary });
|
||||||
|
const getStyleMaps = (lightMapToken: MapToken, darkMapToken: MapToken) => {
|
||||||
|
const isLight = colorSchema === 'light';
|
||||||
|
const mapToken = isLight ? lightMapToken : darkMapToken;
|
||||||
|
const invertMapToken = isLight ? darkMapToken : lightMapToken;
|
||||||
|
|
||||||
|
const styleMaps = [
|
||||||
|
[`--color-text`, mapToken.colorText],
|
||||||
|
[`--color-text-invert`, invertMapToken.colorText],
|
||||||
|
[`--color-text-base`, mapToken.colorTextBase],
|
||||||
|
[`--color-text-base-invert`, invertMapToken.colorTextBase],
|
||||||
|
[`--color-text-disabled`, isLight ? 'rgba(0, 0, 0, 0.25)' : 'rgba(255, 255, 255, 0.25)'],
|
||||||
|
[`--color-text-disabled-invert`, isLight ? 'rgba(255, 255, 255, 0.25)' : 'rgba(0, 0, 0, 0.25)'],
|
||||||
|
[`--color-bg-base`, mapToken.colorBgBase],
|
||||||
|
[`--color-bg-base-invert`, invertMapToken.colorBgBase],
|
||||||
|
[`--color-bg-container`, mapToken.colorBgContainer],
|
||||||
|
[`--color-bg-container-invert`, invertMapToken.colorBgContainer],
|
||||||
|
[`--color-bg-elevated`, mapToken.colorBgElevated],
|
||||||
|
[`--color-bg-elevated-invert`, invertMapToken.colorBgElevated],
|
||||||
|
[`--color-bg-layout`, mapToken.colorBgLayout],
|
||||||
|
[`--color-bg-layout-invert`, invertMapToken.colorBgLayout],
|
||||||
|
[`--color-bg-mask`, mapToken.colorBgMask],
|
||||||
|
[`--color-bg-mask-invert`, invertMapToken.colorBgMask],
|
||||||
|
[`--color-bg-spotlight`, mapToken.colorBgSpotlight],
|
||||||
|
[`--color-bg-spotlight-invert`, invertMapToken.colorBgSpotlight],
|
||||||
|
[`--color-border`, mapToken.colorBorder],
|
||||||
|
[`--color-border-invert`, invertMapToken.colorBorder],
|
||||||
|
[`--color-border-secondary`, mapToken.colorBorderSecondary],
|
||||||
|
[`--color-border-secondary-invert`, invertMapToken.colorBorderSecondary],
|
||||||
|
[`--color-border-disabled`, isLight ? 'rgba(0, 0, 0, 0.25)' : 'rgba(255, 255, 255, 0.25)'],
|
||||||
|
[`--color-border-disabled-invert`, isLight ? 'rgba(255, 255, 255, 0.25)' : 'rgba(0, 0, 0, 0.25)'],
|
||||||
|
|
||||||
|
[`--color-primary`, mapToken.colorPrimary],
|
||||||
|
[`--color-primary-hover`, mapToken.colorPrimaryHover],
|
||||||
|
[`--color-primary-active`, mapToken.colorPrimaryActive],
|
||||||
|
[`--color-primary-bg`, mapToken.colorPrimaryBg],
|
||||||
|
[`--color-primary-bg-hover`, mapToken.colorPrimaryBgHover],
|
||||||
|
[`--color-info`, mapToken.colorInfo],
|
||||||
|
[`--color-info-hover`, mapToken.colorInfoHover],
|
||||||
|
[`--color-info-active`, mapToken.colorInfoActive],
|
||||||
|
[`--color-info-bg`, mapToken.colorInfoBg],
|
||||||
|
[`--color-info-bg-hover`, mapToken.colorInfoBgHover],
|
||||||
|
[`--color-success`, mapToken.colorSuccess],
|
||||||
|
[`--color-success-hover`, mapToken.colorSuccessHover],
|
||||||
|
[`--color-success-active`, mapToken.colorSuccessActive],
|
||||||
|
[`--color-success-bg`, mapToken.colorSuccessBg],
|
||||||
|
[`--color-success-bg-hover`, mapToken.colorSuccessBgHover],
|
||||||
|
[`--color-warning`, mapToken.colorWarning],
|
||||||
|
[`--color-warning-hover`, mapToken.colorWarningHover],
|
||||||
|
[`--color-warning-active`, mapToken.colorWarningActive],
|
||||||
|
[`--color-warning-bg`, mapToken.colorWarningBg],
|
||||||
|
[`--color-warning-bg-hover`, mapToken.colorWarningBgHover],
|
||||||
|
[`--color-error`, mapToken.colorError],
|
||||||
|
[`--color-error-hover`, mapToken.colorErrorHover],
|
||||||
|
[`--color-error-active`, mapToken.colorErrorActive],
|
||||||
|
[`--color-error-bg`, mapToken.colorErrorBg],
|
||||||
|
[`--color-error-bg-hover`, mapToken.colorErrorBgHover],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return styleMaps;
|
||||||
|
};
|
||||||
|
|
||||||
|
[...getStyleMaps(lightMapToken, darkMapToken)].forEach(([key, value]) => {
|
||||||
|
document.documentElement.style.setProperty(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
setToken({
|
||||||
|
...screenBreakPoints,
|
||||||
|
colorPrimary,
|
||||||
|
colorLink: colorPrimary,
|
||||||
|
colorLinkActive: (colorSchema === 'light' ? lightMapToken : darkMapToken).colorPrimaryActive,
|
||||||
|
colorLinkHover: (colorSchema === 'light' ? lightMapToken : darkMapToken).colorPrimaryHover,
|
||||||
|
});
|
||||||
|
}, [colorSchema, colorPrimary]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onMediaMatchesChange();
|
||||||
|
}, [computed_isSigned, isFollowSystem]);
|
||||||
|
|
||||||
|
useUpdateEffect(() => {
|
||||||
|
computed_isSigned && !basicLayoutModel.state.theme.isFollowSystem && onMediaMatchesChange();
|
||||||
|
}, [computed_isSigned, colorSchema]);
|
||||||
|
|
||||||
|
useUpdateEffect(() => {
|
||||||
|
onMediaMatchesChange();
|
||||||
|
}, [responsive]);
|
||||||
|
|
||||||
|
return { algorithm, token, isCompact };
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
declare global {
|
||||||
|
interface Navigator {
|
||||||
|
getBattery: () => Promise<BatteryManager>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface BatteryManager {
|
||||||
|
/** 电池是否充电中 */
|
||||||
|
readonly charging: boolean;
|
||||||
|
/** 电池充满所需时间(秒),为 0 则充电完毕 */
|
||||||
|
readonly chargingTime: number;
|
||||||
|
/** 电池剩余可用时间(秒) */
|
||||||
|
readonly dischargingTime: number;
|
||||||
|
/** 剩余电量,0.0-1.0 */
|
||||||
|
readonly level: number;
|
||||||
|
/** 电池充电状态改变时触发该监听函数 */
|
||||||
|
onchargingchange: null | ((event: { target: BatteryManager }) => void);
|
||||||
|
/** 电池充满所需时间改变时触发该监听函数 */
|
||||||
|
onchargingtimechange: null | ((event: { target: BatteryManager }) => void);
|
||||||
|
/** 电池剩余可用时间改变时触发该监听函数 */
|
||||||
|
ondischargingtimechange: null | ((event: { target: BatteryManager }) => void);
|
||||||
|
/** 电池电量改变时触发该监听函数 */
|
||||||
|
onlevelchange: null | ((event: { target: BatteryManager }) => void);
|
||||||
|
}
|
||||||
|
/** 电池状态 */
|
||||||
|
interface Battery extends Pick<BatteryManager, 'charging' | 'chargingTime' | 'dischargingTime' | 'level'> {
|
||||||
|
apiSupport: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 电池管理 */
|
||||||
|
export function useBattery() {
|
||||||
|
const [battery, setBattery] = useSetState<Battery>({
|
||||||
|
apiSupport: false,
|
||||||
|
charging: true,
|
||||||
|
chargingTime: Infinity,
|
||||||
|
dischargingTime: Infinity,
|
||||||
|
level: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 更新电池使用状态 */
|
||||||
|
const updateBattery = ({ charging, chargingTime, dischargingTime, level }: BatteryManager) => {
|
||||||
|
setBattery({ apiSupport: true, charging, chargingTime, dischargingTime, level });
|
||||||
|
};
|
||||||
|
/** 计算电池充电状态 */
|
||||||
|
const calcBatteryStatus = () => {
|
||||||
|
if (battery.charging && battery.level >= 1) return '已充满';
|
||||||
|
else if (battery.charging) return '充电中';
|
||||||
|
else return '已断开电源';
|
||||||
|
};
|
||||||
|
/** 计算电池剩余可用时间 */
|
||||||
|
const calcDischargingTime = () => {
|
||||||
|
const hour = battery.dischargingTime / 3600;
|
||||||
|
const minute = (battery.dischargingTime / 60) % 60;
|
||||||
|
return battery.charging
|
||||||
|
? '电源已接通'
|
||||||
|
: battery.dischargingTime === Infinity
|
||||||
|
? '计算中...'
|
||||||
|
: `${~~hour}小时${~~minute}分钟`;
|
||||||
|
};
|
||||||
|
/** 计算电池充满剩余时间 */
|
||||||
|
const calcChargingTime = () => {
|
||||||
|
const hour = battery.chargingTime / 3600;
|
||||||
|
const minute = (battery.chargingTime / 60) % 60;
|
||||||
|
return `${~~hour}小时${~~minute}分钟`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
const BatteryManager = await globalThis.navigator?.getBattery?.();
|
||||||
|
|
||||||
|
if (BatteryManager) {
|
||||||
|
updateBattery(BatteryManager);
|
||||||
|
|
||||||
|
// 电池充电状态改变时触发该监听函数
|
||||||
|
BatteryManager.onchargingchange = ({ target }) => {
|
||||||
|
updateBattery(target);
|
||||||
|
};
|
||||||
|
// 电池充满所需时间改变时触发该监听函数
|
||||||
|
BatteryManager.onchargingtimechange = ({ target }) => {
|
||||||
|
updateBattery(target);
|
||||||
|
};
|
||||||
|
// 电池剩余可用时间改变时触发该监听函数
|
||||||
|
BatteryManager.ondischargingtimechange = ({ target }) => {
|
||||||
|
updateBattery(target);
|
||||||
|
};
|
||||||
|
// 电池电量改变时触发该监听函数
|
||||||
|
BatteryManager.onlevelchange = ({ target }) => {
|
||||||
|
updateBattery(target);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { battery, calcDischargingTime, calcChargingTime, calcBatteryStatus };
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { TablePaginationConfig } from 'antd';
|
||||||
|
|
||||||
|
export function useTablePagination(
|
||||||
|
params: { [k in keyof TablePaginationConfig]: TablePaginationConfig[k] | undefined } = {},
|
||||||
|
) {
|
||||||
|
const tablePagination = {
|
||||||
|
pageSize: 10,
|
||||||
|
responsive: true,
|
||||||
|
showTotal: (total: number) => `当前共 ${total} 条数据`,
|
||||||
|
hideOnSinglePage: true,
|
||||||
|
size: 'default',
|
||||||
|
...params,
|
||||||
|
} as unknown as TablePaginationConfig;
|
||||||
|
|
||||||
|
return { tablePagination };
|
||||||
|
}
|
|
@ -0,0 +1,77 @@
|
||||||
|
export const BatteryManager = () => {
|
||||||
|
const [isReady, setIsReady] = useGetState<boolean>(false);
|
||||||
|
const { battery, calcBatteryStatus, calcDischargingTime } = useBattery();
|
||||||
|
const [isHeaderDark, colorSchema] = useModel(basicLayoutModel, (basicLayoutModel) => [
|
||||||
|
basicLayoutModel.nav.navTheme === E.NAV_THEME.暗色顶栏,
|
||||||
|
basicLayoutModel.theme.colorSchema,
|
||||||
|
]);
|
||||||
|
const currentBatteryLevel = useCreation(() => `${(battery.level * 100).toFixed(0)}%`, [battery.level]);
|
||||||
|
|
||||||
|
const createBatteryNotification = () => {
|
||||||
|
notification.open({
|
||||||
|
message: calcBatteryStatus(),
|
||||||
|
description: `当前电量:${currentBatteryLevel}`,
|
||||||
|
icon: (
|
||||||
|
<Icon icon={battery.charging ? 'fluent:battery-checkmark-24-filled' : 'fluent:battery-warning-24-filled'} />
|
||||||
|
),
|
||||||
|
placement: 'bottomRight',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useUpdateEffect(() => {
|
||||||
|
if (isReady) createBatteryNotification();
|
||||||
|
else setIsReady(true);
|
||||||
|
}, [battery.charging]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
arrow
|
||||||
|
placement="bottom"
|
||||||
|
menu={{
|
||||||
|
theme: colorSchema,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: (
|
||||||
|
<Row className="items-center py-1">
|
||||||
|
<Icon
|
||||||
|
icon={battery.charging ? 'fluent:battery-checkmark-20-regular' : 'fluent:battery-warning-20-regular'}
|
||||||
|
/>
|
||||||
|
<span className="pl-3">充电状态:{calcBatteryStatus()}</span>
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: (
|
||||||
|
<Row className="items-center py-1">
|
||||||
|
<Icon icon="ion:battery-low" />
|
||||||
|
<span className="pl-3">当前电量:{currentBatteryLevel}</span>
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '3',
|
||||||
|
label: (
|
||||||
|
<Row className="items-center py-1">
|
||||||
|
<Icon icon="ion:battery-half" />
|
||||||
|
<span className="pl-3">剩余可用:{calcDischargingTime()}</span>
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Col
|
||||||
|
un-hover="light:bg-dark/10 dark:bg-white/30"
|
||||||
|
un-display={battery.apiSupport ? '' : 'none'}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={battery.charging ? 'fluent:battery-charge-24-filled' : 'fluent:battery-charge-24-regular'}
|
||||||
|
className="text-6"
|
||||||
|
un-text={isHeaderDark && colorSchema !== 'dark' ? '!theme-invert' : 'theme'}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { ItemType } from 'antd/es/breadcrumb/Breadcrumb';
|
||||||
|
|
||||||
|
export const Breadcrumbs = () => {
|
||||||
|
const [breadcrumbIcon, breadcrumbArray, isBreadcrumb, isBreadcrumbIcon] = useModel(
|
||||||
|
basicLayoutModel,
|
||||||
|
(basicLayoutModel) => [
|
||||||
|
basicLayoutModel.nav.breadcrumbIcon,
|
||||||
|
basicLayoutModel.nav.breadcrumbArray,
|
||||||
|
basicLayoutModel.layout.isBreadcrumb,
|
||||||
|
basicLayoutModel.layout.isBreadcrumbIcon,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const items: ItemType[] = breadcrumbArray.map((item) => ({ title: item.title }));
|
||||||
|
|
||||||
|
if (isBreadcrumbIcon && breadcrumbIcon) {
|
||||||
|
items.unshift({
|
||||||
|
title: <Icon icon={breadcrumbIcon} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return isBreadcrumb ? <Breadcrumb items={items} /> : null;
|
||||||
|
};
|
|
@ -0,0 +1,12 @@
|
||||||
|
export const FootLayout = () => {
|
||||||
|
const [isFooter] = useModel(basicLayoutModel, (basicLayoutModel) => [basicLayoutModel.layout.isFooter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.Footer
|
||||||
|
className="text-center !text-zinc"
|
||||||
|
un-display={isFooter ? 'block' : 'none'}
|
||||||
|
>
|
||||||
|
{`${ENV.VITE_APP_TITLE_EN} ©${new Date().getFullYear()} Created by Handpear for Technical Team`}
|
||||||
|
</Layout.Footer>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,258 @@
|
||||||
|
import { BatteryManager } from './BatteryManager';
|
||||||
|
import { Breadcrumbs } from './Breadcrumbs';
|
||||||
|
import { StyleSetting } from './StyleSetting';
|
||||||
|
import { UserAvatar } from './UserAvatar';
|
||||||
|
import { UserSetting } from './UserSetting';
|
||||||
|
|
||||||
|
export const HeadLayout = () => {
|
||||||
|
// @ts-ignore
|
||||||
|
const [isHeaderDark, colorSchema, isHeadFixed] = useModel(basicLayoutModel, (basicLayoutModel) => [
|
||||||
|
basicLayoutModel.nav.navTheme === E.NAV_THEME.暗色顶栏,
|
||||||
|
basicLayoutModel.theme.colorSchema,
|
||||||
|
basicLayoutModel.layout.isHeadFixed,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{ algorithm: colorSchema === 'dark' || isHeaderDark ? theme.darkAlgorithm : theme.defaultAlgorithm }}
|
||||||
|
>
|
||||||
|
<Layout.Header
|
||||||
|
className={`w-full z-1035 !px-6 ${isHeaderDark ? 'dark' : ''}`}
|
||||||
|
un-pos={isHeadFixed ? 'fixed' : 'relative'}
|
||||||
|
un-bg={colorSchema === 'dark' ? '!#141414' : isHeaderDark ? '' : '!white'}
|
||||||
|
>
|
||||||
|
<ProjectLogo />
|
||||||
|
<Row
|
||||||
|
className="flex-y-center flex-nowrap h-full float-left"
|
||||||
|
un-children="px-3 flex-y-center h-full cursor-pointer"
|
||||||
|
>
|
||||||
|
<ButtonGroup />
|
||||||
|
<Breadcrumbs />
|
||||||
|
</Row>
|
||||||
|
<HeaderMenu />
|
||||||
|
</Layout.Header>
|
||||||
|
</ConfigProvider>
|
||||||
|
<SettingGroup />
|
||||||
|
<StyleSetting />
|
||||||
|
<StyleSetting />
|
||||||
|
<UserSetting />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 头部 - 项目信息 */
|
||||||
|
const ProjectLogo = () => {
|
||||||
|
const [isHeaderDark, navMode, colorSchema, tenant] = useModel(
|
||||||
|
basicLayoutModel,
|
||||||
|
systemModel,
|
||||||
|
(basicLayoutModel, systemModel) => [
|
||||||
|
basicLayoutModel.nav.navTheme === E.NAV_THEME.暗色顶栏,
|
||||||
|
basicLayoutModel.nav.navMode,
|
||||||
|
basicLayoutModel.theme.colorSchema,
|
||||||
|
systemModel.tenant,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
className="flex-center h-full pr-6 float-left"
|
||||||
|
un-display={navMode === E.NAV_MODE.顶部菜单模式 ? '' : 'none'}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
size={36}
|
||||||
|
src={
|
||||||
|
tenant?.logoUrl
|
||||||
|
? `${ENV.VITE_FILE_DOWNLOAD[0]}${tenant.logoUrl}`
|
||||||
|
: resolveAssetPath('/app/images/logo-project.png')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="ml-3"
|
||||||
|
un-text={isHeaderDark && colorSchema !== 'dark' ? '!theme-invert' : 'theme'}
|
||||||
|
>
|
||||||
|
{tenant?.tenantName || ENV.VITE_APP_TITLE[1]}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 头部 - 按钮组 */
|
||||||
|
const ButtonGroup = () => {
|
||||||
|
const [isHeaderDark, navMode, isNavCollapsed, colorSchema] = useModel(basicLayoutModel, (basicLayoutModel) => [
|
||||||
|
basicLayoutModel.nav.navTheme === E.NAV_THEME.暗色顶栏,
|
||||||
|
basicLayoutModel.nav.navMode,
|
||||||
|
basicLayoutModel.nav.isNavCollapsed,
|
||||||
|
basicLayoutModel.theme.colorSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip title="展开/收缩导航栏">
|
||||||
|
<Col
|
||||||
|
onClick={basicLayoutModel.sideCollapseToggle}
|
||||||
|
un-hover="bg-white/30"
|
||||||
|
un-display={navMode === E.NAV_MODE.顶部菜单模式 ? 'none' : ''}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="uis:flip-v"
|
||||||
|
className="text-theme text-4 xl:text-5 transform transition"
|
||||||
|
un-rotate={isNavCollapsed ? '0' : '180'}
|
||||||
|
un-text={isHeaderDark && colorSchema !== 'dark' ? '!theme-invert' : 'theme'}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="清空标签页">
|
||||||
|
<Col
|
||||||
|
un-hover="bg-white/30"
|
||||||
|
onClick={basicLayoutModel.cleanNavTabs}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="fluent:calendar-sync-24-filled"
|
||||||
|
un-text={isHeaderDark && colorSchema !== 'dark' ? '!theme-invert' : 'theme'}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 头部 - 顶栏菜单 */
|
||||||
|
const HeaderMenu = () => {
|
||||||
|
const [navUserTree, navMode, navTheme, colorSchema] = useModel(
|
||||||
|
systemModel,
|
||||||
|
basicLayoutModel,
|
||||||
|
(systemModel, basicLayoutModel) => [
|
||||||
|
systemModel.navUserTree,
|
||||||
|
basicLayoutModel.nav.navMode,
|
||||||
|
basicLayoutModel.nav.navTheme,
|
||||||
|
basicLayoutModel.theme.colorSchema,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const menuTheme =
|
||||||
|
colorSchema === 'dark'
|
||||||
|
? (undefined as unknown as typeof colorSchema)
|
||||||
|
: navTheme === E.NAV_THEME.暗色顶栏
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
/** 渲染导航名称 */
|
||||||
|
const renderMenuLabel = (title: string, path: string, children: System.API.NavRow[] | undefined) => {
|
||||||
|
const isParent = !!children?.length;
|
||||||
|
const isWebsite = isWebLink(path);
|
||||||
|
const isScreen = path.startsWith(ENV.VITE_LAYOUT_SCREEN[0]);
|
||||||
|
switch (true) {
|
||||||
|
case isParent:
|
||||||
|
return title;
|
||||||
|
case isWebsite || isScreen:
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={isWebsite ? path : `${ENV.VITE_PUBLIC_PATH[1]!}${path.slice(1)}`}
|
||||||
|
target="_blank"
|
||||||
|
className="select-none"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={path}
|
||||||
|
className="select-none"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/** 顶栏导航 */
|
||||||
|
const headMenuItems = U.treeMap(navUserTree, ({ title, path, icon, children, showIcon }) => ({
|
||||||
|
key: path,
|
||||||
|
label: renderMenuLabel(title, path, children),
|
||||||
|
icon: showIcon && icon ? <Icon icon={icon} /> : null,
|
||||||
|
children: navMode === E.NAV_MODE.混合菜单模式 || !children?.length ? undefined : children,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
mode="horizontal"
|
||||||
|
theme={menuTheme}
|
||||||
|
items={headMenuItems}
|
||||||
|
onClick={basicLayoutModel.onHeadMenuClick}
|
||||||
|
onOpenChange={basicLayoutModel.onNavOpenChange}
|
||||||
|
className="h-full w-[calc(100%-44rem-24px)] border-b-0 children:!flex-y-center"
|
||||||
|
un-display={navMode === E.NAV_MODE.左侧菜单模式 ? 'none' : ''}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 头部 - 设置组 */
|
||||||
|
const SettingGroup = () => {
|
||||||
|
const [isHeaderDark, colorSchema, isHeadFixed, isTabs] = useModel(basicLayoutModel, (basicLayoutModel) => [
|
||||||
|
basicLayoutModel.nav.navTheme === E.NAV_THEME.暗色顶栏,
|
||||||
|
basicLayoutModel.theme.colorSchema,
|
||||||
|
basicLayoutModel.layout.isHeadFixed,
|
||||||
|
basicLayoutModel.layout.isTabs,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
className={`h-14 xl:h-16 top-0 right-6 z-1036 ${isHeaderDark ? 'dark' : ''}`}
|
||||||
|
un-children="px-3 flex-y-center h-full cursor-pointer"
|
||||||
|
un-pos={isHeadFixed ? 'fixed' : 'absolute'}
|
||||||
|
un-bg={colorSchema === 'dark' ? '!#141414' : isHeaderDark ? '' : '!white'}
|
||||||
|
>
|
||||||
|
<UserAvatar />
|
||||||
|
<BatteryManager />
|
||||||
|
<Tooltip title="内容区全屏">
|
||||||
|
<Col
|
||||||
|
un-hover="light:bg-dark/10 dark:bg-white/30"
|
||||||
|
onClick={() => basicLayoutModel.onSwitchChange('isMainLayoutFullscreen')}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="fluent:screenshot-24-filled"
|
||||||
|
className="text-5.5"
|
||||||
|
un-text={isHeaderDark && colorSchema !== 'dark' ? '!theme-invert' : 'theme'}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="切换显示标签页">
|
||||||
|
<Col
|
||||||
|
un-hover="light:bg-dark/10 dark:bg-white/30"
|
||||||
|
onClick={() => basicLayoutModel.onSwitchChange('isTabs')}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={isTabs ? 'fluent:calendar-cancel-24-filled' : 'fluent:calendar-checkmark-24-filled'}
|
||||||
|
className="text-5"
|
||||||
|
un-text={isHeaderDark && colorSchema !== 'dark' ? '!theme-invert' : 'theme'}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="重新加载页面">
|
||||||
|
<Col
|
||||||
|
un-hover="bg-white/30"
|
||||||
|
onClick={basicLayoutModel.reloadCurrentNav}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="fluent:arrow-clockwise-16-filled"
|
||||||
|
un-text={isHeaderDark && colorSchema !== 'dark' ? '!theme-invert' : 'theme'}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
title="风格设置"
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
<Col
|
||||||
|
un-hover="light:bg-dark/10 dark:bg-white/30"
|
||||||
|
onClick={() => basicLayoutModel.onSwitchChange('isSetting')}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="fluent:settings-24-filled"
|
||||||
|
className="text-5"
|
||||||
|
un-text={isHeaderDark && colorSchema !== 'dark' ? '!theme-invert' : 'theme'}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Tooltip>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { cloneElement, Suspense } from 'react';
|
||||||
|
import Home from '~/views/tenant';
|
||||||
|
|
||||||
|
import routes from '~react-pages';
|
||||||
|
|
||||||
|
export const MainLayout = () => {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const size = useSize(ref);
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [isFullscreen, { enterFullscreen, exitFullscreen }] = useFullscreen(ref);
|
||||||
|
const [navMode, isMainLayoutFullscreen, navUserRows] = useModel(
|
||||||
|
basicLayoutModel,
|
||||||
|
systemModel,
|
||||||
|
(basicLayoutModel, systemModel) => [
|
||||||
|
basicLayoutModel.nav.navMode,
|
||||||
|
basicLayoutModel.layout.isMainLayoutFullscreen,
|
||||||
|
systemModel.navUserRows,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const userRoutes = routes.map((route) => resolveRoute({ navRows: navUserRows, route, element: <Home /> }));
|
||||||
|
|
||||||
|
useDebounceEffect(() => {
|
||||||
|
size && basicLayoutModel.onMainLayoutSizeChange(size);
|
||||||
|
}, [size]);
|
||||||
|
|
||||||
|
useUpdateEffect(() => {
|
||||||
|
!isFullscreen ? enterFullscreen() : exitFullscreen();
|
||||||
|
}, [isMainLayoutFullscreen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.Content
|
||||||
|
ref={ref}
|
||||||
|
un-overflow="hidden"
|
||||||
|
un-pos="relative"
|
||||||
|
className={navMode === E.NAV_MODE.顶部菜单模式 || isFullscreen ? 'bg-theme-layout' : 'ml-6'}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Row className="bg-theme-layout flex-center wh-full">
|
||||||
|
<Spin />
|
||||||
|
</Row>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{cloneElement(useRoutes(userRoutes) ?? <Home />, { key: pathname })}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Suspense>
|
||||||
|
</Layout.Content>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,140 @@
|
||||||
|
export const SideLayout = () => {
|
||||||
|
const [navWidth, navTheme, isNavCollapsed, isSideFixed, colorSchema] = useModel(
|
||||||
|
basicLayoutModel,
|
||||||
|
(basicLayoutModel) => [
|
||||||
|
basicLayoutModel.nav.navWidth,
|
||||||
|
basicLayoutModel.nav.navTheme,
|
||||||
|
basicLayoutModel.nav.isNavCollapsed,
|
||||||
|
basicLayoutModel.layout.isSideFixed,
|
||||||
|
basicLayoutModel.theme.colorSchema,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row style={{ width: isSideFixed ? (isNavCollapsed ? 80 : navWidth) : 'auto' }}>
|
||||||
|
<Layout.Sider
|
||||||
|
width={navWidth}
|
||||||
|
collapsed={isNavCollapsed}
|
||||||
|
theme={colorSchema === 'light' && navTheme === E.NAV_THEME.亮色侧边栏 ? 'light' : 'dark'}
|
||||||
|
className="h-full z-2 dark:bg-#141414"
|
||||||
|
un-pos={isSideFixed ? '!fixed' : 'relative'}
|
||||||
|
>
|
||||||
|
<ProjectLogo />
|
||||||
|
<Scrollbar
|
||||||
|
un-p="t-3"
|
||||||
|
un-h={isSideFixed ? '[calc(100%-9rem-2rem)]' : 'auto'}
|
||||||
|
>
|
||||||
|
<SideMenu />
|
||||||
|
</Scrollbar>
|
||||||
|
</Layout.Sider>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 侧边栏 - 项目信息 */
|
||||||
|
const ProjectLogo = () => {
|
||||||
|
const [navTheme, isNavCollapsed, tenant] = useModel(
|
||||||
|
basicLayoutModel,
|
||||||
|
systemModel,
|
||||||
|
(basicLayoutModel, systemModel) => [
|
||||||
|
basicLayoutModel.nav.navTheme,
|
||||||
|
basicLayoutModel.nav.isNavCollapsed,
|
||||||
|
systemModel.tenant,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row className="flex-center flex-col h-36">
|
||||||
|
<Avatar
|
||||||
|
size={48}
|
||||||
|
src={
|
||||||
|
tenant?.logoUrl
|
||||||
|
? `${ENV.VITE_FILE_DOWNLOAD[0]}${tenant.logoUrl}`
|
||||||
|
: resolveAssetPath('/app/images/logo-project.png')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
className="mt-6 truncate dark:text-white/85"
|
||||||
|
un-text={navTheme === E.NAV_THEME.亮色侧边栏 ? '' : 'white/85'}
|
||||||
|
un-display={isNavCollapsed ? 'none' : ''}
|
||||||
|
>
|
||||||
|
{tenant?.tenantName || ENV.VITE_APP_TITLE[1]}
|
||||||
|
</Row>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 侧边导航 */
|
||||||
|
const SideMenu = () => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [navUserTree, mixNavigation, navMode, navTheme, isVertical, openKeys, colorSchema] = useModel(
|
||||||
|
systemModel,
|
||||||
|
basicLayoutModel,
|
||||||
|
(systemModel, basicLayoutModel) => [
|
||||||
|
systemModel.navUserTree,
|
||||||
|
systemModel.navUserMixed,
|
||||||
|
basicLayoutModel.nav.navMode,
|
||||||
|
basicLayoutModel.nav.navTheme,
|
||||||
|
basicLayoutModel.nav.isVertical,
|
||||||
|
basicLayoutModel.nav.openKeys,
|
||||||
|
basicLayoutModel.theme.colorSchema,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const menuTheme =
|
||||||
|
colorSchema === 'dark'
|
||||||
|
? (undefined as unknown as typeof colorSchema)
|
||||||
|
: navTheme === E.NAV_THEME.亮色侧边栏
|
||||||
|
? 'light'
|
||||||
|
: 'dark';
|
||||||
|
/** 渲染导航名称 */
|
||||||
|
const renderMenuLabel = (title: string, path: string, children: System.API.NavRow[] | undefined) => {
|
||||||
|
const isParent = !!children?.length;
|
||||||
|
const isWebsite = isWebLink(path);
|
||||||
|
const isLayoutScreen = path.startsWith(ENV.VITE_LAYOUT_SCREEN[0]);
|
||||||
|
switch (true) {
|
||||||
|
case isParent:
|
||||||
|
return title;
|
||||||
|
case isWebsite || isLayoutScreen:
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={isWebsite ? path : `${ENV.VITE_PUBLIC_PATH[1]!}${path.slice(1)}`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <Link to={path}>{title}</Link>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/** 侧边栏导航 */
|
||||||
|
const sideMenuItems = U.treeMap(
|
||||||
|
navMode === E.NAV_MODE.混合菜单模式 ? mixNavigation : navUserTree,
|
||||||
|
({ title, path = '/404', icon, children, showIcon }) => ({
|
||||||
|
key: path,
|
||||||
|
label: renderMenuLabel(title, path, children),
|
||||||
|
icon:
|
||||||
|
showIcon && icon ? (
|
||||||
|
<Icon
|
||||||
|
icon={icon}
|
||||||
|
className="text-theme !text-4 xl:!text-5"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="h-5 w-5" />
|
||||||
|
),
|
||||||
|
children: navMode === E.NAV_MODE.顶部菜单模式 || !children?.length ? undefined : children,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
mode={isVertical ? 'vertical' : 'inline'}
|
||||||
|
theme={menuTheme}
|
||||||
|
items={sideMenuItems}
|
||||||
|
selectedKeys={[pathname]}
|
||||||
|
openKeys={openKeys}
|
||||||
|
onOpenChange={basicLayoutModel.onNavOpenChange}
|
||||||
|
style={{ borderInlineEnd: 0 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,398 @@
|
||||||
|
export const StyleSetting = () => {
|
||||||
|
const [isSetting] = useModel(basicLayoutModel, (basicLayoutModel) => [basicLayoutModel.layout.isSetting]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title="风格设置"
|
||||||
|
placement="right"
|
||||||
|
width={296}
|
||||||
|
zIndex={1040}
|
||||||
|
closable={false}
|
||||||
|
open={isSetting}
|
||||||
|
bodyStyle={{ paddingRight: 4 }}
|
||||||
|
onClose={() => basicLayoutModel.onSwitchChange('isSetting')}
|
||||||
|
>
|
||||||
|
<Scrollbar className="h-full pr-3 xl:pr-5">
|
||||||
|
<Row gutter={[0, 12]}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Divider className="!mt-0">颜色模式</Divider>
|
||||||
|
<ColorSchema />
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Divider>品牌主色</Divider>
|
||||||
|
<ColorPrimary />
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Divider>导航栏模式</Divider>
|
||||||
|
<NavMode />
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Divider>导航栏风格</Divider>
|
||||||
|
<NavTheme />
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Divider>导航栏风格</Divider>
|
||||||
|
<NavStyle />
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Divider>界面风格</Divider>
|
||||||
|
<LayoutStyle />
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Divider>界面布局</Divider>
|
||||||
|
<LayoutDisplay />
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Divider>布局动画</Divider>
|
||||||
|
<LayoutAnimation />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Scrollbar>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 颜色模式 */
|
||||||
|
const ColorSchema = () => {
|
||||||
|
const [isFollowSystem, colorSchema] = useModel(basicLayoutModel, (basicLayoutModel) => [
|
||||||
|
basicLayoutModel.theme.isFollowSystem,
|
||||||
|
basicLayoutModel.theme.colorSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row className="flex-y-center py-2 justify-between">
|
||||||
|
<span>暗色模式</span>
|
||||||
|
<Switch
|
||||||
|
disabled={isFollowSystem}
|
||||||
|
checkedChildren={
|
||||||
|
<Icon
|
||||||
|
icon="ic:baseline-dark-mode"
|
||||||
|
className="text-white xl:text-base relative xl:top-0.8"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
unCheckedChildren={
|
||||||
|
<Icon
|
||||||
|
icon="ic:outline-light-mode"
|
||||||
|
className="text-theme xl:text-base relative xl:top-0.8"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
checked={colorSchema === 'dark'}
|
||||||
|
onChange={() => basicLayoutModel.onSwitchChange('colorSchema')}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row className="flex-y-center py-2 justify-between">
|
||||||
|
<span>跟随系统</span>
|
||||||
|
<Switch
|
||||||
|
checkedChildren={
|
||||||
|
<Icon
|
||||||
|
icon="ic-round-hdr-auto"
|
||||||
|
className="text-white xl:text-base relative xl:top-0.8"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
unCheckedChildren={
|
||||||
|
<Icon
|
||||||
|
icon="ic-baseline-do-not-disturb"
|
||||||
|
className="text-theme xl:text-base relative xl:top-0.8"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
checked={isFollowSystem}
|
||||||
|
onChange={() => basicLayoutModel.onSwitchChange('isFollowSystem')}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 品牌主色 */
|
||||||
|
const ColorPrimary = () => {
|
||||||
|
const [colorPrimary] = useModel(basicLayoutModel, (basicLayoutModel) => [basicLayoutModel.theme.colorPrimary]);
|
||||||
|
|
||||||
|
const { run: onColorPrimaryChange } = useDebounceFn(
|
||||||
|
(_: unknown, hex: string) => {
|
||||||
|
basicLayoutModel.onPrimaryColorChange(hex as RGB_HEX);
|
||||||
|
},
|
||||||
|
{ wait: 500, leading: true },
|
||||||
|
);
|
||||||
|
const { run: createRandomHexColor } = useDebounceFn(
|
||||||
|
() => {
|
||||||
|
const color = U.generateHexColor();
|
||||||
|
basicLayoutModel.onPrimaryColorChange(color);
|
||||||
|
},
|
||||||
|
{ wait: 500, leading: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row>
|
||||||
|
<Col flex="44px">
|
||||||
|
<ColorPicker
|
||||||
|
value={colorPrimary}
|
||||||
|
presets={[
|
||||||
|
{ label: 'presets', colors: E.themePreset.colorList },
|
||||||
|
{ label: 'cyberpunk', colors: E.cyberpunk },
|
||||||
|
// ...traditionColors.map((c) => ({ label: c.range, colors: c.data.map((d) => d.color) })),
|
||||||
|
]}
|
||||||
|
onChange={onColorPrimaryChange}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col flex="auto">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={createRandomHexColor}
|
||||||
|
>
|
||||||
|
随机颜色
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 导航栏模式 */
|
||||||
|
const NavMode = () => {
|
||||||
|
const [navMode, currentNav] = useModel(basicLayoutModel, (basicLayoutModel) => [
|
||||||
|
basicLayoutModel.nav.navMode,
|
||||||
|
basicLayoutModel.nav.currentNav,
|
||||||
|
]);
|
||||||
|
const onNavModeClick = (n: keyof typeof E.NAV_MODE) => {
|
||||||
|
const parentPath = `/${currentNav?.path.split('/')[1]}`;
|
||||||
|
basicLayoutModel.onNavModeChange(E.NAV_MODE[n]);
|
||||||
|
if (E.NAV_MODE[n] === E.NAV_MODE.混合菜单模式) {
|
||||||
|
basicLayoutModel.onHeadMenuClick({
|
||||||
|
key: currentNav?.parentId === 0 ? currentNav.path : parentPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row className="justify-between children:(cursor-pointer w-12) ">
|
||||||
|
{EnumKeys(E.NAV_MODE).map((n) => (
|
||||||
|
<Tooltip
|
||||||
|
key={n}
|
||||||
|
title={n}
|
||||||
|
>
|
||||||
|
<Col onClick={() => onNavModeClick(n)}>
|
||||||
|
<img
|
||||||
|
className="shadow-md mb-2"
|
||||||
|
src={resolveAssetPath(`/app/images/nav-mode-${E.NAV_MODE[n]}.png`)}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<Row justify="center">
|
||||||
|
<Row
|
||||||
|
className="bg-green-500 dot"
|
||||||
|
un-display={navMode === E.NAV_MODE[n] ? 'flex' : 'none'}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 导航栏风格 */
|
||||||
|
const NavTheme = () => {
|
||||||
|
const [navTheme] = useModel(basicLayoutModel, (basicLayoutModel) => [basicLayoutModel.nav.navTheme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row className="justify-between children:(cursor-pointer w-12) ">
|
||||||
|
{EnumKeys(E.NAV_THEME).map((n) => (
|
||||||
|
<Tooltip
|
||||||
|
key={n}
|
||||||
|
title={n}
|
||||||
|
>
|
||||||
|
<Col
|
||||||
|
onClick={() => {
|
||||||
|
basicLayoutModel.updateState({ nav: { ...basicLayoutModel.state.nav, navTheme: E.NAV_THEME[n] } });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={resolveAssetPath(`/app/images/nav-theme-${E.NAV_THEME[n]}.png`)}
|
||||||
|
className="shadow-md mb-2"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<Row justify="center">
|
||||||
|
<Row
|
||||||
|
className="bg-green-500 dot"
|
||||||
|
un-display={navTheme === E.NAV_THEME[n] ? 'flex' : 'none'}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 导航栏风格 */
|
||||||
|
const NavStyle = () => {
|
||||||
|
const [isAccordion, isVertical] = useModel(basicLayoutModel, (basicLayoutModel) => [
|
||||||
|
basicLayoutModel.nav.isAccordion,
|
||||||
|
basicLayoutModel.nav.isVertical,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row className="flex-y-center py-2 justify-between">
|
||||||
|
<span>手风琴导航栏</span>
|
||||||
|
<Switch
|
||||||
|
checked={isAccordion}
|
||||||
|
onChange={() => basicLayoutModel.onSwitchChange('isAccordion')}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row className="flex-y-center py-2 justify-between">
|
||||||
|
<span>垂直导航栏</span>
|
||||||
|
<Switch
|
||||||
|
checked={isVertical}
|
||||||
|
onChange={() => basicLayoutModel.onSwitchChange('isVertical')}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 界面风格 */
|
||||||
|
const LayoutStyle = () => {
|
||||||
|
const [isHeadFixed, isSideFixed] = useModel(basicLayoutModel, (basicLayoutModel) => [
|
||||||
|
basicLayoutModel.layout.isHeadFixed,
|
||||||
|
basicLayoutModel.layout.isSideFixed,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row className="flex-y-center py-2 justify-between">
|
||||||
|
<span>固定顶栏</span>
|
||||||
|
<Switch
|
||||||
|
checked={isHeadFixed}
|
||||||
|
onChange={() => basicLayoutModel.onSwitchChange('isHeadFixed')}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row className="flex-y-center py-2 justify-between">
|
||||||
|
<span>固定侧边栏</span>
|
||||||
|
<Switch
|
||||||
|
checked={isSideFixed}
|
||||||
|
onChange={() => basicLayoutModel.onSwitchChange('isSideFixed')}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 界面布局 */
|
||||||
|
const LayoutDisplay = () => {
|
||||||
|
const [navWidth, isTabs, isBreadcrumb, isBreadcrumbIcon, isFooter] = useModel(
|
||||||
|
basicLayoutModel,
|
||||||
|
(basicLayoutModel) => [
|
||||||
|
basicLayoutModel.nav.navWidth,
|
||||||
|
basicLayoutModel.layout.isTabs,
|
||||||
|
basicLayoutModel.layout.isBreadcrumb,
|
||||||
|
basicLayoutModel.layout.isBreadcrumbIcon,
|
||||||
|
basicLayoutModel.layout.isFooter,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
/** 侧边栏宽度滑动输入 */
|
||||||
|
const { run: onNavSliderChange } = useDebounceFn((value: number) => {
|
||||||
|
basicLayoutModel.updateState({ nav: { ...basicLayoutModel.state.nav, navWidth: value } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span>侧边栏宽度</span>
|
||||||
|
<Slider
|
||||||
|
step={20}
|
||||||
|
min={200}
|
||||||
|
max={280}
|
||||||
|
defaultValue={navWidth}
|
||||||
|
onChange={onNavSliderChange}
|
||||||
|
className="mx-3 !dark:children:after:bg-primary"
|
||||||
|
/>
|
||||||
|
<Row className="flex-y-center py-2 justify-between">
|
||||||
|
<span>显示标签页</span>
|
||||||
|
<Switch
|
||||||
|
checked={isTabs}
|
||||||
|
onChange={() => basicLayoutModel.onSwitchChange('isTabs')}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row className="flex-y-center py-2 justify-between">
|
||||||
|
<span>显示面包屑导航</span>
|
||||||
|
<Switch
|
||||||
|
checked={isBreadcrumb}
|
||||||
|
onChange={() => basicLayoutModel.onSwitchChange('isBreadcrumb')}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row className="flex-y-center py-2 justify-between">
|
||||||
|
<span>显示面包屑图标</span>
|
||||||
|
<Switch
|
||||||
|
checked={isBreadcrumbIcon}
|
||||||
|
onChange={() => basicLayoutModel.onSwitchChange('isBreadcrumbIcon')}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row className="flex-y-center py-2 justify-between">
|
||||||
|
<span>显示底栏</span>
|
||||||
|
<Switch
|
||||||
|
checked={isFooter}
|
||||||
|
onChange={() => basicLayoutModel.onSwitchChange('isFooter')}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 布局动画 */
|
||||||
|
const LayoutAnimation = () => {
|
||||||
|
const [duration, ease, isMotion] = useModel(basicLayoutModel, (basicLayoutModel) => [
|
||||||
|
basicLayoutModel.motions.transition.duration,
|
||||||
|
basicLayoutModel.motions.transition.ease,
|
||||||
|
basicLayoutModel.motions.isMotion,
|
||||||
|
]);
|
||||||
|
/** 动画持续时间滑动输入 */
|
||||||
|
const { run: onDurationSliderChange } = useDebounceFn((value: number) => {
|
||||||
|
basicLayoutModel.updateState({
|
||||||
|
motions: {
|
||||||
|
...basicLayoutModel.state.motions,
|
||||||
|
transition: { ...basicLayoutModel.state.motions.transition, duration: value },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 设置 ease */
|
||||||
|
const onEaseChange = (value: keyof typeof E.EASING) => {
|
||||||
|
basicLayoutModel.updateState({
|
||||||
|
motions: {
|
||||||
|
...basicLayoutModel.state.motions,
|
||||||
|
transition: { ...basicLayoutModel.state.motions.transition, ease: value },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span>动画持续时间</span>
|
||||||
|
<Slider
|
||||||
|
step={0.1}
|
||||||
|
min={0}
|
||||||
|
max={1.5}
|
||||||
|
tooltip={{ formatter: (value) => `${value} 秒` }}
|
||||||
|
defaultValue={duration}
|
||||||
|
onChange={onDurationSliderChange}
|
||||||
|
className="mx-3 !dark:children:after:bg-primary"
|
||||||
|
/>
|
||||||
|
<Row className="flex-y-center py-2 justify-between">
|
||||||
|
<span>启用布局动画</span>
|
||||||
|
<Switch
|
||||||
|
checked={isMotion}
|
||||||
|
onChange={() => basicLayoutModel.onSwitchChange('isMotion')}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row className="flex-y-center py-2 justify-between">
|
||||||
|
<span>过渡曲线</span>
|
||||||
|
<Select<keyof typeof E.EASING>
|
||||||
|
value={ease}
|
||||||
|
onChange={onEaseChange}
|
||||||
|
options={EnumKeys(E.EASING).map((e) => ({ label: e, value: e }))}
|
||||||
|
className="w-30"
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,66 @@
|
||||||
|
export const TabsLayout = () => {
|
||||||
|
const [isHeadFixed] = useModel(basicLayoutModel, (basicLayoutModel) => [basicLayoutModel.layout.isHeadFixed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
className="mt-16 px-6 pt-3"
|
||||||
|
un-m={isHeadFixed ? '' : 't-0'}
|
||||||
|
>
|
||||||
|
<Scrollbar
|
||||||
|
trackStyle={() => ({ height: 0 })}
|
||||||
|
thumbStyle={() => ({ height: 6, bottom: 0 })}
|
||||||
|
>
|
||||||
|
<TabsSpace />
|
||||||
|
</Scrollbar>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TabsSpace = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [navTabsArray, currentNav, isTabs] = useModel(basicLayoutModel, (basicLayoutModel) => [
|
||||||
|
basicLayoutModel.nav.navTabsArray,
|
||||||
|
basicLayoutModel.nav.currentNav,
|
||||||
|
basicLayoutModel.layout.isTabs,
|
||||||
|
]);
|
||||||
|
const onTabsChange = (path: string | number) => {
|
||||||
|
typeof path === 'string' && navigate(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Segmented
|
||||||
|
options={navTabsArray
|
||||||
|
.filter(({ path }) => !path.startsWith(ENV.VITE_LAYOUT_SCREEN[0]))
|
||||||
|
.map((nav) => ({
|
||||||
|
value: nav.path,
|
||||||
|
label: <TabsLabel nav={nav} />,
|
||||||
|
}))}
|
||||||
|
value={currentNav?.path || ''}
|
||||||
|
onChange={onTabsChange}
|
||||||
|
className="p-0 !children:children:(rounded-b-0 min-w-32 mr-2 overflow-hidden after:rounded-b-0 children:px-0) ant-tabs-segmented"
|
||||||
|
un-display={isTabs ? '' : 'none'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TabsLabel: React.FC<{ nav: System.API.NavRow }> = ({ nav }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const onTabsDelete = (e: React.MouseEvent<SVGSVGElement, MouseEvent>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
basicLayoutModel.deleteTabs(nav.id, navigate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
className="flex-x-center relative px-2 py-1 z-1"
|
||||||
|
un-hover="children:right-2"
|
||||||
|
>
|
||||||
|
{nav.title}
|
||||||
|
<Icon
|
||||||
|
icon="ion:ios-close-circle"
|
||||||
|
className="text-theme text-lg rounded-1/2 absolute -right-10 top-2 transition-all"
|
||||||
|
onClick={onTabsDelete}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,176 @@
|
||||||
|
|
||||||
|
export const UserAvatar = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const computed_homePath = useComputed(systemModel.computed_homePath);
|
||||||
|
const [personName, roleNames, tenant] = useModel(systemModel, (systemModel) => [
|
||||||
|
systemModel.user?.personName,
|
||||||
|
systemModel.user?.roleNames,
|
||||||
|
systemModel.tenant,
|
||||||
|
]);
|
||||||
|
const isDev = ENV.VITE_MODE === 'development';
|
||||||
|
const _url=`https://wx.ysmental.com/pages/index/user/userBind/userBind?tenantNo=${tenant?.tenantNo}`;
|
||||||
|
|
||||||
|
const downloadQRCode = () => {
|
||||||
|
const canvas = document.getElementById('myqrcode')?.querySelector<HTMLCanvasElement>('canvas');
|
||||||
|
if (canvas) {
|
||||||
|
const url = canvas.toDataURL("image/png");
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.download = 'QRCode.png';
|
||||||
|
a.href = url;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onShareUrl = (type: 0 | 1) => {
|
||||||
|
let url: string; // 拿到想要复制的值
|
||||||
|
// const isDev = ENV.VITE_MODE === 'development';
|
||||||
|
|
||||||
|
if (type === 0) {
|
||||||
|
url = `${isDev ? 'http://localhost:7173' : 'https://www.ysmental.com'}/login?tenantNo=${tenant?.tenantNo}`;
|
||||||
|
} else {
|
||||||
|
url = `${isDev ? 'http://localhost:6173' : 'https://tenant.ysmental.com'}/login?tenantNo=${tenant?.tenantNo}`;
|
||||||
|
}
|
||||||
|
const copyInput = document.createElement('input'); // 创建input元素
|
||||||
|
document.body.appendChild(copyInput); // 向页面底部追加输入框
|
||||||
|
copyInput.setAttribute('value', url); // 添加属性,将url赋值给input元素的value属性
|
||||||
|
copyInput.select(); // 选择input元素
|
||||||
|
document.execCommand('Copy'); // 执行复制命令
|
||||||
|
message.success('分享链接已复制到剪切板!'); // 弹出提示信息,不同组件可能存在写法不同
|
||||||
|
// 复制之后再删除元素,否则无法成功赋值
|
||||||
|
copyInput.remove(); // 删除动态创建的节点
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 角色快捷操作弹框 */
|
||||||
|
const DropdownRender = useCallback(
|
||||||
|
() => (
|
||||||
|
<Row className="bg-theme-container shadow p-2 justify-center">
|
||||||
|
<Row className="flex-col flex-center relative">
|
||||||
|
<Col className="font-bold">{personName ?? '姓名'}</Col>
|
||||||
|
{roleNames?.map((name) => (
|
||||||
|
<Col
|
||||||
|
key={name}
|
||||||
|
className="bg-primary my-1 px-3 text-white"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
<Divider className="my-3" />
|
||||||
|
<Menu
|
||||||
|
className="w-full bg-theme-container !shadow-none children:!py-2"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: '-1',
|
||||||
|
label: (
|
||||||
|
<div className="flex-y-center">
|
||||||
|
<Icon icon="fluent:share-24-regular" />
|
||||||
|
<span className="pl-3">分享门户</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onClick: () => {
|
||||||
|
setOpen(true);},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '0',
|
||||||
|
label: (
|
||||||
|
<div className="flex-y-center">
|
||||||
|
<Icon icon="fluent:share-24-regular" />
|
||||||
|
<span className="pl-3">分享后台</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onClick: () => onShareUrl(1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: (
|
||||||
|
<Link
|
||||||
|
to="/account"
|
||||||
|
className="flex-y-center"
|
||||||
|
>
|
||||||
|
<Icon icon="ant-design:user-outlined" />
|
||||||
|
<span className="pl-3">用户主页</span>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: (
|
||||||
|
<a
|
||||||
|
className="flex-y-center"
|
||||||
|
onClick={basicLayoutModel.editPasswordModalToggle}
|
||||||
|
>
|
||||||
|
<Icon icon="ri:lock-password-line" />
|
||||||
|
<span className="pl-3">修改密码</span>
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '4',
|
||||||
|
label: (
|
||||||
|
<a
|
||||||
|
className="flex-y-center"
|
||||||
|
onClick={systemModel.signOut}
|
||||||
|
>
|
||||||
|
<Icon icon="ant-design:logout-outlined" />
|
||||||
|
<span className="pl-3">退出登录</span>
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
[computed_homePath, personName, roleNames],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dropdown
|
||||||
|
placement="bottom"
|
||||||
|
dropdownRender={DropdownRender}
|
||||||
|
>
|
||||||
|
<Col>
|
||||||
|
<Avatar
|
||||||
|
size={36}
|
||||||
|
src={
|
||||||
|
tenant?.logoUrl
|
||||||
|
? `${ENV.VITE_FILE_DOWNLOAD[0]}${tenant.logoUrl}`
|
||||||
|
: resolveAssetPath('/app/images/logo-project.png')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Dropdown>
|
||||||
|
<Modal
|
||||||
|
title="分享门户链接"
|
||||||
|
centered
|
||||||
|
open={open}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
width={300}
|
||||||
|
footer={
|
||||||
|
[
|
||||||
|
<Button key="back" onClick={downloadQRCode}>
|
||||||
|
下载二维码
|
||||||
|
</Button>
|
||||||
|
]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
<div id="myqrcode">
|
||||||
|
<QRCode
|
||||||
|
errorLevel="H"
|
||||||
|
size={260}
|
||||||
|
value = {_url}
|
||||||
|
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
icon = {
|
||||||
|
tenant?.logoUrl
|
||||||
|
? `${ENV.VITE_FILE_DOWNLOAD[0]}${tenant.logoUrl}`
|
||||||
|
: resolveAssetPath('/app/images/logo-project.png')
|
||||||
|
} */
|
||||||
|
};
|
|
@ -0,0 +1,76 @@
|
||||||
|
export const UserSetting = () => {
|
||||||
|
const [form] = Form.useForm<System.API.Params.Password>();
|
||||||
|
const [isUserSetting] = useModel(basicLayoutModel, (basicLayoutModel) => [basicLayoutModel.layout.isUserSetting]);
|
||||||
|
const { run: SET_MY_PASSWORD } = useRequest(systemModel.SET_MY_PASSWORD, {
|
||||||
|
manual: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.resetFields();
|
||||||
|
basicLayoutModel.editPasswordModalToggle();
|
||||||
|
systemModel.signOut();
|
||||||
|
message.success({ content: '修改密码成功,请重新登陆', duration: 10 });
|
||||||
|
},
|
||||||
|
// onError: () => {
|
||||||
|
// message.error('修改用户密码失败');
|
||||||
|
// },
|
||||||
|
});
|
||||||
|
const onFinishEditPassword = () => {
|
||||||
|
form.validateFields().then((values) => {
|
||||||
|
SET_MY_PASSWORD(values);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
labelCol={E.COL_FORM_LABEL}
|
||||||
|
wrapperCol={E.COL_FORM_WRAPPER}
|
||||||
|
component={false}
|
||||||
|
>
|
||||||
|
<Modal
|
||||||
|
title="修改密码"
|
||||||
|
open={isUserSetting}
|
||||||
|
closable={false}
|
||||||
|
getContainer={false}
|
||||||
|
destroyOnClose={true}
|
||||||
|
bodyStyle={{ paddingTop: 12 }}
|
||||||
|
onOk={onFinishEditPassword}
|
||||||
|
onCancel={basicLayoutModel.editPasswordModalToggle}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="旧密码"
|
||||||
|
name="oldPwd"
|
||||||
|
rules={[
|
||||||
|
{ required: true },
|
||||||
|
// { validator: (_, value) => Z.validateZod(value, z.string().min(6, { message: '至少需要 6 位字符' })) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="请输入旧密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="新密码"
|
||||||
|
name="newPwd"
|
||||||
|
rules={[{ required: true }, { validator: (_, value) => Z.validateZod(value, Z.Password) }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="请输入新密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="确认密码"
|
||||||
|
name="confirm"
|
||||||
|
dependencies={['newPwd']}
|
||||||
|
hasFeedback
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请再次输入新密码' },
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator(_, value) {
|
||||||
|
if (!value || getFieldValue('newPwd') === value) return Promise.resolve();
|
||||||
|
return Promise.reject(new Error('您两次输入的密码不匹配!'));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="请输入新密码" />
|
||||||
|
</Form.Item>
|
||||||
|
</Modal>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { FootLayout } from './components/FootLayout';
|
||||||
|
import { HeadLayout } from './components/HeadLayout';
|
||||||
|
import { MainLayout } from './components/MainLayout';
|
||||||
|
import { SideLayout } from './components/SideLayout';
|
||||||
|
import { TabsLayout } from './components/TabsLayout';
|
||||||
|
|
||||||
|
const Index = () => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [isTabs, navMode, title] = useModel(basicLayoutModel, (basicLayoutModel) => [
|
||||||
|
basicLayoutModel.layout.isTabs,
|
||||||
|
basicLayoutModel.nav.navMode,
|
||||||
|
basicLayoutModel.nav.currentNav?.title,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useTitle(`${title ? `${title}_` : ''}${ENV.VITE_APP_TITLE[0]}${ENV.VITE_APP_TITLE_SUFFIX[0]}`);
|
||||||
|
useEffect(() => {
|
||||||
|
basicLayoutModel.onPathnameChange(pathname);
|
||||||
|
}, [pathname, isTabs, navMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout className="min-h-screen">
|
||||||
|
{navMode !== E.NAV_MODE.顶部菜单模式 && <SideLayout />}
|
||||||
|
<Layout className="relative">
|
||||||
|
<HeadLayout />
|
||||||
|
<TabsLayout />
|
||||||
|
<MainLayout />
|
||||||
|
<FootLayout />
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Index;
|
|
@ -0,0 +1,277 @@
|
||||||
|
import { clone } from 'lodash-es';
|
||||||
|
import type { NavigateFunction } from 'react-router-dom';
|
||||||
|
import { NAV_MODE, NAV_THEME } from '@handpear/enums';
|
||||||
|
|
||||||
|
const initialState: BasicLayout.State = {
|
||||||
|
theme: {
|
||||||
|
isFollowSystem: true,
|
||||||
|
colorSchema: E.COLOR_SCHEMA.亮色模式,
|
||||||
|
colorPrimary: '#1677ff',
|
||||||
|
isShowTraditionColor: false,
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
navMode: NAV_MODE.左侧菜单模式,
|
||||||
|
navTheme: NAV_THEME.暗色侧边栏,
|
||||||
|
navWidth: 200,
|
||||||
|
isNavCollapsed: false,
|
||||||
|
openKeys: [],
|
||||||
|
navTabsArray: [],
|
||||||
|
breadcrumbArray: [],
|
||||||
|
breadcrumbIcon: undefined,
|
||||||
|
currentNav: undefined,
|
||||||
|
isAccordion: true,
|
||||||
|
isVertical: false,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
isSetting: false,
|
||||||
|
isTabs: true,
|
||||||
|
isBreadcrumb: true,
|
||||||
|
isBreadcrumbIcon: false,
|
||||||
|
isFooter: false,
|
||||||
|
isHeadFixed: true,
|
||||||
|
isSideFixed: true,
|
||||||
|
isFootFixed: false,
|
||||||
|
isMainLayoutFullscreen: false,
|
||||||
|
isUserSetting: false,
|
||||||
|
mainLayoutSize: { width: 0, height: 0 },
|
||||||
|
},
|
||||||
|
motions: {
|
||||||
|
isMotion: true,
|
||||||
|
transition: {
|
||||||
|
delay: 0,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pollingInterval: 30000,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const basicLayoutModel = defineModel('basicLayout', {
|
||||||
|
initialState,
|
||||||
|
skipRefresh: true,
|
||||||
|
reducers: {
|
||||||
|
/** 清空标签页 */
|
||||||
|
cleanNavTabs(state) {
|
||||||
|
systemModel.computed_isSigned.value && message.success({ content: '已清空标签页', key: 'cleanNavTabsArray' });
|
||||||
|
if (!state.nav.navTabsArray.length) return;
|
||||||
|
state.nav.navTabsArray = [];
|
||||||
|
},
|
||||||
|
/** 清空展开导航 */
|
||||||
|
cleanOpenKeys(state) {
|
||||||
|
state.nav.openKeys = [];
|
||||||
|
},
|
||||||
|
/** 导航栏模式切换 */
|
||||||
|
onNavModeChange(state, mode: NAV_MODE) {
|
||||||
|
state.nav.openKeys = [];
|
||||||
|
state.nav.navMode = mode;
|
||||||
|
},
|
||||||
|
/** 开关切换 */
|
||||||
|
onSwitchChange(
|
||||||
|
state,
|
||||||
|
key:
|
||||||
|
| keyof Omit<BasicLayout.Layout, 'mainLayoutSize'>
|
||||||
|
| 'colorSchema'
|
||||||
|
| 'isFollowSystem'
|
||||||
|
| 'isAccordion'
|
||||||
|
| 'isVertical'
|
||||||
|
| 'isMotion'
|
||||||
|
| 'isShowTraditionColor',
|
||||||
|
) {
|
||||||
|
switch (key) {
|
||||||
|
case 'colorSchema':
|
||||||
|
state.theme.colorSchema =
|
||||||
|
state.theme.colorSchema === 'dark' ? E.COLOR_SCHEMA.亮色模式 : E.COLOR_SCHEMA.暗色模式;
|
||||||
|
break;
|
||||||
|
case 'isFollowSystem':
|
||||||
|
state.theme.isFollowSystem = !state.theme.isFollowSystem;
|
||||||
|
break;
|
||||||
|
case 'isShowTraditionColor':
|
||||||
|
state.theme.isShowTraditionColor = !state.theme.isShowTraditionColor;
|
||||||
|
break;
|
||||||
|
case 'isAccordion':
|
||||||
|
state.nav.isAccordion = !state.nav.isAccordion;
|
||||||
|
state.nav.openKeys = [state.nav.openKeys.at(-1) || ''];
|
||||||
|
break;
|
||||||
|
case 'isVertical':
|
||||||
|
state.nav.isVertical = !state.nav.isVertical;
|
||||||
|
state.nav.isVertical && (state.nav.openKeys = []);
|
||||||
|
break;
|
||||||
|
case 'isMotion':
|
||||||
|
state.motions.isMotion = !state.motions.isMotion;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
state.layout[key] = !state.layout[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** 主内容区布局变化回调 */
|
||||||
|
onMainLayoutSizeChange(state, size: BasicLayout.Layout['mainLayoutSize']) {
|
||||||
|
state.layout.mainLayoutSize = size;
|
||||||
|
},
|
||||||
|
/** 导航展开/关闭的回调 */
|
||||||
|
onNavOpenChange(state, keys: string[]) {
|
||||||
|
if (state.nav.isAccordion) {
|
||||||
|
const newSet = new Set(state.nav.openKeys);
|
||||||
|
keys.at(-1) && newSet.add(keys.at(-1)!);
|
||||||
|
// 当 openKeys 长度大于 ChangeOpenKeys 时,则期望收起当前点击的栏目
|
||||||
|
if (state.nav.openKeys?.length > keys.length) {
|
||||||
|
// 求 openKeys 与 ChangeOpenKeys 的差集
|
||||||
|
const diff = [...newSet].filter((item) => !new Set(keys).has(item));
|
||||||
|
diff[0] && newSet.delete(diff[0]);
|
||||||
|
}
|
||||||
|
state.nav.openKeys = [...newSet];
|
||||||
|
} else state.nav.openKeys = keys.length ? [keys.at(-1)!] : [];
|
||||||
|
},
|
||||||
|
/** 切换显示密码编辑窗口 */
|
||||||
|
editPasswordModalToggle(state) {
|
||||||
|
state.layout.isUserSetting = !state.layout.isUserSetting;
|
||||||
|
},
|
||||||
|
/** 重置模块状态 */
|
||||||
|
reset() {
|
||||||
|
return this.initialState;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/** 更新 state */
|
||||||
|
async updateState<S extends typeof initialState>(state: Partial<S>) {
|
||||||
|
return this.setState(state as Pick<S, keyof S>);
|
||||||
|
},
|
||||||
|
/** 重新加载 MainLayout */
|
||||||
|
reloadCurrentNav() {
|
||||||
|
const { pathname } = location;
|
||||||
|
const index = systemModel.state.navUserRows.findIndex((r) => r.path === pathname);
|
||||||
|
if (index > -1) {
|
||||||
|
basicLayoutModel.updateState({ isLoading: true });
|
||||||
|
const route = clone(systemModel.state.navUserRows[index]!);
|
||||||
|
systemModel.removeUserNav(index);
|
||||||
|
setTimeout(() => {
|
||||||
|
systemModel.addUserNav(route);
|
||||||
|
basicLayoutModel.updateState({ isLoading: false });
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** 设置展开的 SubMenu 菜单项 key 数组以及面包屑 */
|
||||||
|
onPathnameChange(path: string) {
|
||||||
|
if (path.startsWith(ENV.VITE_LAYOUT_SCREEN[0])) return;
|
||||||
|
const pathname = path.replace(ENV.VITE_PUBLIC_PATH[1]!, '/');
|
||||||
|
if (!systemModel.state.navUserRows.length || this.state.nav.isVertical) return;
|
||||||
|
|
||||||
|
this.onHeadMenuClick({ key: pathname });
|
||||||
|
const navTabsArray = clone(this.state.nav.navTabsArray);
|
||||||
|
const openKeys = clone(this.state.nav.openKeys);
|
||||||
|
const currentNav = systemModel.state.navUserRows.find((item) => item.path === pathname);
|
||||||
|
const newOpenKeys: string[] = [];
|
||||||
|
let breadcrumbArray: System.API.NavRow[] = [];
|
||||||
|
let breadcrumbIcon = currentNav?.icon;
|
||||||
|
|
||||||
|
if (currentNav && pathname !== ENV.VITE_AUTH_PATH[0]) {
|
||||||
|
/** 获取当前导航对应的所有祖先导航 */
|
||||||
|
const findAllParentNav = (NavParams: System.API.NavRow): void => {
|
||||||
|
// 获取父级导航参数
|
||||||
|
const parentNav = systemModel.state.navUserRows.find((item) => item.id === NavParams.parentId);
|
||||||
|
if (parentNav) {
|
||||||
|
newOpenKeys.push(parentNav.path);
|
||||||
|
breadcrumbArray.push(parentNav);
|
||||||
|
// 父级导航为顶级导航时停止
|
||||||
|
parentNav.parentId !== 0 ? findAllParentNav(parentNav) : (breadcrumbIcon = parentNav.icon);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 当前栏目非顶级导航时执行
|
||||||
|
currentNav.parentId !== 0 && findAllParentNav(currentNav);
|
||||||
|
breadcrumbArray = [...new Set([currentNav, ...breadcrumbArray])].reverse();
|
||||||
|
if (this.state.layout.isTabs) {
|
||||||
|
const index = navTabsArray.findIndex((nav) => nav.id === currentNav.id);
|
||||||
|
index > -1 ? navTabsArray.splice(index, 1, currentNav) : navTabsArray.push(currentNav);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setState((state) => {
|
||||||
|
state.nav.openKeys = !state.nav.isNavCollapsed
|
||||||
|
? state.nav.isAccordion
|
||||||
|
? [...new Set([...openKeys, ...newOpenKeys])]
|
||||||
|
: [...new Set(newOpenKeys)]
|
||||||
|
: [];
|
||||||
|
state.nav.breadcrumbArray = breadcrumbArray;
|
||||||
|
state.nav.currentNav = currentNav;
|
||||||
|
state.nav.navTabsArray = navTabsArray;
|
||||||
|
state.nav.breadcrumbIcon = breadcrumbIcon;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 侧边栏展开/收起 */
|
||||||
|
sideCollapseToggle() {
|
||||||
|
this.setState((state) => {
|
||||||
|
state.nav.isNavCollapsed = !state.nav.isNavCollapsed;
|
||||||
|
state.nav.openKeys = [];
|
||||||
|
});
|
||||||
|
this.onPathnameChange(document.location.pathname);
|
||||||
|
},
|
||||||
|
/** 主题颜色变化回调 */
|
||||||
|
onPrimaryColorChange(colorPrimary: RGB_HEX) {
|
||||||
|
this.setState((state) => {
|
||||||
|
state.theme.colorPrimary = colorPrimary;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 混合菜单模式 拆分菜单 */
|
||||||
|
onHeadMenuClick(info: { key: string }) {
|
||||||
|
if (this.state.nav.navMode === NAV_MODE.混合菜单模式) {
|
||||||
|
const path = `/${info.key.split('/')[1] || ''}`;
|
||||||
|
const minNavData = systemModel.state.navUserTree.find((n) => n.path === path)!;
|
||||||
|
const mixNavigation = minNavData?.children?.reduce<System.API.NavTreeRow[]>((prev, curr) => {
|
||||||
|
return [...prev, { ...curr, parentId: 0 }];
|
||||||
|
}, []);
|
||||||
|
systemModel.updateState({ navUserMixed: mixNavigation ?? [] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 删除标签页
|
||||||
|
*
|
||||||
|
* @param {number} id 导航 ID
|
||||||
|
* @param {NavigateFunction} navigate 导航函数
|
||||||
|
*/
|
||||||
|
deleteTabs(id: number, navigate: NavigateFunction) {
|
||||||
|
this.setState((state) => {
|
||||||
|
const index = state.nav.navTabsArray.findIndex((item) => item.id === id);
|
||||||
|
index > -1 && state.nav.navTabsArray.splice(index, 1);
|
||||||
|
if (state.nav.currentNav?.id === id) {
|
||||||
|
navigate(state.nav.navTabsArray.at(-1)?.path || systemModel.computed_homePath.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 匹配系统颜色模式 */
|
||||||
|
async matchMediaColorSchema() {
|
||||||
|
let value: BasicLayout.Theme['colorSchema'];
|
||||||
|
const { isFollowSystem, colorSchema } = this.state.theme;
|
||||||
|
const classList = document.documentElement.classList;
|
||||||
|
const isDark = classList.contains('dark');
|
||||||
|
const isLight = classList.contains('light');
|
||||||
|
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
||||||
|
const prefersColor = prefersDark ? E.COLOR_SCHEMA.暗色模式 : E.COLOR_SCHEMA.亮色模式;
|
||||||
|
|
||||||
|
if (systemModel.computed_isSigned.value) value = isFollowSystem ? prefersColor : colorSchema;
|
||||||
|
else value = prefersColor;
|
||||||
|
|
||||||
|
if (value === 'dark') {
|
||||||
|
isLight && classList.remove('light');
|
||||||
|
!isDark && classList.add('dark');
|
||||||
|
} else {
|
||||||
|
isDark && classList.remove('dark');
|
||||||
|
!isLight && classList.add('light');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState((state) => {
|
||||||
|
state.theme.colorSchema = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
/** 设置滚动条轨道样式 */
|
||||||
|
setTrackStyle(horizontal: boolean | undefined) {
|
||||||
|
return horizontal ? { height: 0 } : { width: 0 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
onInit() {
|
||||||
|
systemModel.computed_isSigned.value && this.cleanOpenKeys();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,92 @@
|
||||||
|
import type { NAV_MODE, NAV_THEME } from '@handpear/enums';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
/** 基础布局 */
|
||||||
|
namespace BasicLayout {
|
||||||
|
/** 系统主题 */
|
||||||
|
interface Theme {
|
||||||
|
/** 主题模式是否跟随系统 */
|
||||||
|
isFollowSystem: boolean;
|
||||||
|
/** 颜色模式 */
|
||||||
|
colorSchema: COLOR_SCHEMA;
|
||||||
|
/** 品牌主色 */
|
||||||
|
colorPrimary: RGB_HEX;
|
||||||
|
/** 是否显示传统色 */
|
||||||
|
isShowTraditionColor: boolean;
|
||||||
|
}
|
||||||
|
/** 导航栏 */
|
||||||
|
interface Nav {
|
||||||
|
/** 导航栏主题模式 */
|
||||||
|
navMode: NAV_MODE;
|
||||||
|
/** 导航栏主题颜色 */
|
||||||
|
navTheme: NAV_THEME;
|
||||||
|
/** 导航栏宽度 */
|
||||||
|
navWidth: number;
|
||||||
|
/** 是否收起 */
|
||||||
|
isNavCollapsed: boolean;
|
||||||
|
/** 当前展开的 SubMenu 菜单项 key 数组 */
|
||||||
|
openKeys: string[];
|
||||||
|
/** 标签页导航列表 */
|
||||||
|
navTabsArray: System.API.NavRow[];
|
||||||
|
/** 面包屑列表 */
|
||||||
|
breadcrumbArray: System.API.NavRow[];
|
||||||
|
/** 面包屑图标 */
|
||||||
|
breadcrumbIcon: string | null | undefined;
|
||||||
|
/** 当前导航 */
|
||||||
|
currentNav: System.API.NavRow | undefined;
|
||||||
|
/** 是否开启手风琴效果 */
|
||||||
|
isAccordion: boolean;
|
||||||
|
/** 是否开启垂直模式 */
|
||||||
|
isVertical: boolean;
|
||||||
|
}
|
||||||
|
/** 界面功能 */
|
||||||
|
interface Layout {
|
||||||
|
/** 是否显示风格设置 */
|
||||||
|
isSetting: boolean;
|
||||||
|
/** 是否显示面包屑导航 */
|
||||||
|
isBreadcrumb: boolean;
|
||||||
|
/** 是否显示面包屑图标 */
|
||||||
|
isBreadcrumbIcon: boolean;
|
||||||
|
/** 是否显示标签页 */
|
||||||
|
isTabs: boolean;
|
||||||
|
/** 是否显示底栏 */
|
||||||
|
isFooter: boolean;
|
||||||
|
/** 是否固定顶栏 */
|
||||||
|
isHeadFixed: boolean;
|
||||||
|
/** 是否固定侧边栏 */
|
||||||
|
isSideFixed: boolean;
|
||||||
|
/** 是否固定底栏 */
|
||||||
|
isFootFixed: boolean;
|
||||||
|
/** 是否内容区全屏 */
|
||||||
|
isMainLayoutFullscreen: boolean;
|
||||||
|
/** 是否正在设置用户信息 */
|
||||||
|
isUserSetting: boolean;
|
||||||
|
/** 主内容区布局宽高 */
|
||||||
|
mainLayoutSize: { width: number; height: number };
|
||||||
|
}
|
||||||
|
/** 动画布局 */
|
||||||
|
interface Motion {
|
||||||
|
isMotion: boolean;
|
||||||
|
transition: {
|
||||||
|
delay: number;
|
||||||
|
duration: number;
|
||||||
|
ease: keyof typeof E.EASING;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** 模块状态 */
|
||||||
|
interface State {
|
||||||
|
/** 主题相关 */
|
||||||
|
theme: Theme;
|
||||||
|
/** 导航相关 */
|
||||||
|
nav: Nav;
|
||||||
|
/** 布局相关 */
|
||||||
|
layout: Layout;
|
||||||
|
/** 动画相关 */
|
||||||
|
motions: Motion;
|
||||||
|
/** 轮询间隔 */
|
||||||
|
pollingInterval: number;
|
||||||
|
/** 是否加载中 */
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
const Index = () => {
|
||||||
|
const [isLoading] = useModel(basicLayoutModel, (basicLayoutModel) => [basicLayoutModel.isLoading]);
|
||||||
|
const computed_isSigned = useComputed(systemModel.computed_isSigned);
|
||||||
|
const computed_homePath = useComputed(systemModel.computed_homePath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionLayout>
|
||||||
|
<Skeleton
|
||||||
|
active
|
||||||
|
paragraph={{ rows: 18 }}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<Result
|
||||||
|
title="403"
|
||||||
|
status="403"
|
||||||
|
className="w-full py-12 px-6"
|
||||||
|
subTitle={
|
||||||
|
<>
|
||||||
|
<div>Sorry, you are not authorized to access this page.</div>
|
||||||
|
<div>抱歉,您无权访问此页面。</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Link to={computed_isSigned ? computed_homePath : ENV.VITE_AUTH_PATH[0]}>
|
||||||
|
<Button type="primary">返回主页</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Skeleton>
|
||||||
|
</MotionLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Index;
|
|
@ -0,0 +1,34 @@
|
||||||
|
const Index = () => {
|
||||||
|
const [isLoading] = useModel(basicLayoutModel, (basicLayoutModel) => [basicLayoutModel.isLoading]);
|
||||||
|
const computed_isSigned = useComputed(systemModel.computed_isSigned);
|
||||||
|
const computed_homePath = useComputed(systemModel.computed_homePath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionLayout>
|
||||||
|
<Skeleton
|
||||||
|
active
|
||||||
|
paragraph={{ rows: 18 }}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<Result
|
||||||
|
title="404"
|
||||||
|
status="404"
|
||||||
|
className="w-full py-12 px-6"
|
||||||
|
subTitle={
|
||||||
|
<>
|
||||||
|
<div>Sorry, the page you visited does not exist.</div>
|
||||||
|
<div>抱歉,您访问的页面不存在。</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Link to={computed_isSigned ? computed_homePath : ENV.VITE_AUTH_PATH[0]}>
|
||||||
|
<Button type="primary">返回主页</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Skeleton>
|
||||||
|
</MotionLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Index;
|
|
@ -0,0 +1,34 @@
|
||||||
|
const Index = () => {
|
||||||
|
const [isLoading] = useModel(basicLayoutModel, (basicLayoutModel) => [basicLayoutModel.isLoading]);
|
||||||
|
const computed_isSigned = useComputed(systemModel.computed_isSigned);
|
||||||
|
const computed_homePath = useComputed(systemModel.computed_homePath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionLayout>
|
||||||
|
<Skeleton
|
||||||
|
active
|
||||||
|
paragraph={{ rows: 18 }}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<Result
|
||||||
|
title="500"
|
||||||
|
status="500"
|
||||||
|
className="w-full py-12 px-6"
|
||||||
|
subTitle={
|
||||||
|
<>
|
||||||
|
<div>Sorry, something went wrong.</div>
|
||||||
|
<div>抱歉,出了点问题。</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Link to={computed_isSigned ? computed_homePath : ENV.VITE_AUTH_PATH[0]}>
|
||||||
|
<Button type="primary">返回主页</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Skeleton>
|
||||||
|
</MotionLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Index;
|
|
@ -0,0 +1,10 @@
|
||||||
|
import BasicLayout from './basic';
|
||||||
|
import ScreenLayout from './screen';
|
||||||
|
|
||||||
|
const Index = () => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
return pathname.startsWith(ENV.VITE_LAYOUT_SCREEN[0]) ? <ScreenLayout /> : <BasicLayout />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Index;
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import NotPermission from '~/layouts/exception/403';
|
||||||
|
import NotFound from '~/layouts/exception/404';
|
||||||
|
import routes from '~react-pages';
|
||||||
|
|
||||||
|
const Index = () => {
|
||||||
|
const [navUserRows] = useModel(systemModel, (systemModel) => [systemModel.navUserRows]);
|
||||||
|
|
||||||
|
const userRoutes = routes.map((route) => resolveRoute({ navRows: navUserRows, route, element: <NotPermission /> }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Row className="bg-theme-layout flex-center wh-full">
|
||||||
|
<Spin />
|
||||||
|
</Row>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navUserRows.length ? useRoutes(userRoutes) : <NotFound />}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Index;
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { AuthForm } from './components/AuthForm';
|
||||||
|
|
||||||
|
export const SignIn = () => {
|
||||||
|
const [colorSchema] = useModel(basicLayoutModel, (basicLayoutModel) => [basicLayoutModel.theme.colorSchema]);
|
||||||
|
|
||||||
|
useTitle('欢迎使用 - 请登录');
|
||||||
|
useMount(() => notification.destroy());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="flex-center wh-v-full relative overflow-hidden"
|
||||||
|
un-bg="cover no-repeat fixed"
|
||||||
|
style={{ backgroundImage: `url(${resolveAssetPath('/app/images/login-bg.jpg')})` }}
|
||||||
|
>
|
||||||
|
<div className="bg-[#1377f5]/48 rounded-1/2 shadow-2xl shadow-blue-700 absolute wh-480" />
|
||||||
|
<div className="bg-[#1f8afa]/36 rounded-1/2 shadow-2xl shadow-blue-600 absolute wh-360" />
|
||||||
|
<div className="bg-[#2b93fd]/24 rounded-1/2 shadow-2xl shadow-blue-500 absolute wh-240" />
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<header className="flex-y-center justify-between px-6 py-3">
|
||||||
|
<img
|
||||||
|
src={resolveAssetPath(`/app/images/logo-project.png`)}
|
||||||
|
className="rounded-1/2 mr-3 p-2 w-16 xl:w-16"
|
||||||
|
un-bg={colorSchema === 'light' ? 'blue-100' : '[var(--color-primary)]'}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<span className="bg-clip-text bg-gradient-to-r font-bold from-[var(--color-primary)] to-blue-300 text-transparent text-5 xl:text-6 truncate">
|
||||||
|
{`${ENV.VITE_APP_TITLE[1]}${ENV.VITE_APP_TITLE_SUFFIX[0]}`}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
}
|
||||||
|
className="rounded-tl-none rounded-rb-none rounded-2xl w-96 xl:w-120"
|
||||||
|
>
|
||||||
|
<AuthForm />
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,7 @@
|
||||||
|
export const SignUp = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>SignUp</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,131 @@
|
||||||
|
import {JSON} from "ts-toolbelt/out/Misc/_api";
|
||||||
|
|
||||||
|
export const AuthForm = () => {
|
||||||
|
const [form] = Form.useForm<System.Form.Values & { tenantNo?: string }>();
|
||||||
|
const [captcha, setCaptcha] = useState<System.SignIn.Captcha>({ rd: '', vc: '' });
|
||||||
|
|
||||||
|
// @ 获取验证码
|
||||||
|
const { run: GET_CAPTCHA } = useRequest(systemModel.GET_CAPTCHA, {
|
||||||
|
debounceWait: 300,
|
||||||
|
onSuccess: (response) => setCaptcha(response.data),
|
||||||
|
onError: () => setCaptcha({ rd: '', vc: '' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const URLParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
const tenantNo = URLParams.get('tenantNo');
|
||||||
|
if (tenantNo && tenantNo !== 'null' && tenantNo !== 'undefined') {
|
||||||
|
form.setFieldsValue({ tenantNo });
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setFieldsValue({ remember: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 表单预验证成功 */
|
||||||
|
const onFinish = (values: Omit<System.Form.Values, 'rd'>) => {
|
||||||
|
systemModel.signIn({ ...values, rd: captcha.rd }).catch((err) => {
|
||||||
|
message.error(err.msg);
|
||||||
|
systemModel.signOut();
|
||||||
|
GET_CAPTCHA();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography.Title
|
||||||
|
level={5}
|
||||||
|
className="!text-primary !mb-6"
|
||||||
|
>
|
||||||
|
账号登陆
|
||||||
|
</Typography.Title>
|
||||||
|
<Form
|
||||||
|
size="large"
|
||||||
|
form={form}
|
||||||
|
onFinish={onFinish}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: '请输入账号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入账号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
placeholder="请输入密码"
|
||||||
|
autoComplete="on"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="tenantNo"
|
||||||
|
rules={[{ required: true, message: '请输入所属租户编号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入所属租户编号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Row>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="vc"
|
||||||
|
rules={[{ required: true, message: '请输入验证码' }]}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入验证码" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col
|
||||||
|
span={12}
|
||||||
|
className="!flex-y-center justify-between pl-3 h-34px xl:h-38px"
|
||||||
|
>
|
||||||
|
<Tooltip title="刷新验证码">
|
||||||
|
<div>
|
||||||
|
<Icon
|
||||||
|
icon="mdi:reload"
|
||||||
|
className="text-theme text-6 cursor-pointer"
|
||||||
|
onClick={GET_CAPTCHA}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<div
|
||||||
|
className="wh-full flex-center bg-#141414 ml-3 cursor-pointer relative overflow-hidden"
|
||||||
|
un-border="~ solid #424242 rounded-2"
|
||||||
|
onClick={GET_CAPTCHA}
|
||||||
|
>
|
||||||
|
{captcha.vc ? (
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${captcha.vc}`}
|
||||||
|
className="wh-full"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon icon="ant-design:file-image-filled" />
|
||||||
|
<span className="ml-2">请点击重试</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="remember"
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Checkbox>记住页面设置</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
className="!rounded-3xl !w-full"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { FocaProvider } from 'foca';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
// 安装自定义用户模块系统
|
||||||
|
Object.values<Recordable<'install', () => void>>(import.meta.glob('./modules/*.ts', { eager: true })).forEach((i) => {
|
||||||
|
i.install?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<FocaProvider>
|
||||||
|
<App />
|
||||||
|
</FocaProvider>,
|
||||||
|
);
|
|
@ -0,0 +1,9 @@
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
自定义用户模块系统。用下面的模板添加 `.ts` 文件,系统将自动运行模块
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const install = () => {
|
||||||
|
// do something
|
||||||
|
};
|
||||||
|
```
|
|
@ -0,0 +1,6 @@
|
||||||
|
import 'uno.css';
|
||||||
|
import 'antd/dist/reset.css';
|
||||||
|
import 'mac-scrollbar/dist/mac-scrollbar.css';
|
||||||
|
import '@handpear/style/global.css';
|
||||||
|
import '@handpear/style/antd.overwrite.css';
|
||||||
|
import '@handpear/style/antd.pro.overwrite.css';
|
|
@ -0,0 +1,13 @@
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||||
|
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||||
|
import dayjs, { extend, locale } from 'dayjs';
|
||||||
|
|
||||||
|
export { dayjs };
|
||||||
|
export const install = () => {
|
||||||
|
// 加载插件
|
||||||
|
extend(isSameOrBefore);
|
||||||
|
extend(isSameOrAfter);
|
||||||
|
// 将 antd 组件的默认文案修改为中文
|
||||||
|
locale('zh-cn');
|
||||||
|
};
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { configResponsive } from 'ahooks';
|
||||||
|
|
||||||
|
export const install = () => {
|
||||||
|
// 自定义屏幕响应断点
|
||||||
|
const { XS, SM, MD, LG, XL, XXL } = E.SCREEN_BREAK_POINTS;
|
||||||
|
configResponsive({ xs: XS, sm: SM, md: MD, lg: LG, xl: XL, xxl: XXL });
|
||||||
|
};
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { store } from 'foca';
|
||||||
|
import localForage from 'localforage';
|
||||||
|
import { createLogger } from 'redux-logger';
|
||||||
|
|
||||||
|
export { localForage };
|
||||||
|
export const install = () => {
|
||||||
|
/**
|
||||||
|
* 模型日志配置项
|
||||||
|
*
|
||||||
|
* @link https://github.com/LogRocket/redux-logger#readme
|
||||||
|
*/
|
||||||
|
const loggerOptions = {
|
||||||
|
/** 是否折叠日志信息 */
|
||||||
|
collapsed: true,
|
||||||
|
/**
|
||||||
|
* 是否显示 state 之间的差异
|
||||||
|
* @warning 当 store 中存储的 state 对象(如地图 map 对象)过于复杂时可能会影响性能
|
||||||
|
*/
|
||||||
|
diff: true,
|
||||||
|
/** 是否显示 action 耗时 */
|
||||||
|
duration: true,
|
||||||
|
/** 是否显示 action 时间戳 */
|
||||||
|
timestamp: false,
|
||||||
|
/** 是否捕获、记录并重新抛出错误 */
|
||||||
|
logErrors: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.init({
|
||||||
|
compose: 'redux-devtools',
|
||||||
|
middleware: ENV.VITE_MODE === 'development' ? [createLogger(loggerOptions)] : [],
|
||||||
|
persist: [
|
||||||
|
{
|
||||||
|
key: `${ENV.VITE_APP_KEY[0]}-${ENV.VITE_APP_STORE_KEY[1]!}-${ENV.VITE_MODE}`,
|
||||||
|
version: 0,
|
||||||
|
engine: localForage,
|
||||||
|
models: [systemModel, basicLayoutModel],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (import.meta.hot) import.meta.hot.accept(() => console.log('Hot updated: store'));
|
||||||
|
};
|
|
@ -0,0 +1,49 @@
|
||||||
|
interface RequestCodeMap {
|
||||||
|
[type: string]: { key?: string; message?: string; description: string; callback?: () => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const httpCodeMap = {
|
||||||
|
zh: {
|
||||||
|
'200': '服务器成功返回请求的数据。',
|
||||||
|
'201': '新建或修改数据成功。',
|
||||||
|
'202': '一个请求已经进入后台排队(异步任务)。',
|
||||||
|
'204': '删除数据成功。',
|
||||||
|
'400': '发出的请求有错误,服务器没有进行新建或修改数据的操作。',
|
||||||
|
'401': '用户没有权限(令牌、用户名、密码错误)。',
|
||||||
|
'403': '用户得到授权,但是访问是被禁止的。',
|
||||||
|
'404': '发出的请求针对的是不存在的记录,服务器没有进行操作。',
|
||||||
|
'405': '请求方法不被允许。',
|
||||||
|
'406': '请求的格式不可得。',
|
||||||
|
'410': '请求的资源被永久删除,且不会再得到的。',
|
||||||
|
'422': '当创建一个对象时,发生一个验证错误。',
|
||||||
|
'500': '服务器发生错误,请检查服务器。',
|
||||||
|
'502': '网关错误。',
|
||||||
|
'503': '服务不可用,服务器暂时过载或维护。',
|
||||||
|
'504': '网关超时。',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
'200': 'The server successfully returned the requested data. ',
|
||||||
|
'201': 'New or modified data is successful. ',
|
||||||
|
'202': 'A request has entered the background queue (asynchronous task). ',
|
||||||
|
'204': 'Data deleted successfully. ',
|
||||||
|
'400': 'There was an error in the request sent, and the server did not create or modify data. ',
|
||||||
|
'401': 'The user does not have permission (token, username, password error). ',
|
||||||
|
'403': 'The user is authorized, but access is forbidden. ',
|
||||||
|
'404': 'The request sent was for a record that did not exist. ',
|
||||||
|
'405': 'The request method is not allowed. ',
|
||||||
|
'406': 'The requested format is not available. ',
|
||||||
|
'410': 'The requested resource is permanently deleted and will no longer be available. ',
|
||||||
|
'422': 'When creating an object, a validation error occurred. ',
|
||||||
|
'500': 'An error occurred on the server, please check the server. ',
|
||||||
|
'502': 'Gateway error. ',
|
||||||
|
'503': 'The service is unavailable. ',
|
||||||
|
'504': 'The gateway timed out. ',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requestCodeMap: RequestCodeMap = {
|
||||||
|
Timeout: {
|
||||||
|
message: '请求异常',
|
||||||
|
description: '网络连接超时,服务器无响应。',
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { TimelineItemProps } from 'antd';
|
||||||
|
import type { ResponseError } from 'umi-request';
|
||||||
|
|
||||||
|
interface ZodErrorProps {
|
||||||
|
error: ResponseError<number>;
|
||||||
|
errorMessage: Zod.ZodError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ZodErrorDescription: React.FC<ZodErrorProps> = ({ error, errorMessage }) => {
|
||||||
|
const items: TimelineItemProps[] = [
|
||||||
|
{
|
||||||
|
color: 'green',
|
||||||
|
children: (
|
||||||
|
<Row>
|
||||||
|
<Col flex="48px">接口:</Col>
|
||||||
|
<Col flex="1">{error.request.options['url'] || error.request.url}</Col>
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...errorMessage.issues.map((issue) => ({
|
||||||
|
color: 'red',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Row>
|
||||||
|
<Col flex="48px">信息:</Col>
|
||||||
|
<Col flex="1">{issue.message}</Col>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Col flex="48px">路径:</Col>
|
||||||
|
<Col flex="1">{issue.path.join(' > ')}</Col>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
items={items}
|
||||||
|
className="mt-6 children-[.ant-timeline-item]:pb-3 children-[.ant-timeline-item-last]:!pb-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,54 @@
|
||||||
|
import type { ResponseError } from 'umi-request';
|
||||||
|
import { httpCodeMap, requestCodeMap } from './error.codeMap';
|
||||||
|
|
||||||
|
export const resolveRequestError = (error: ResponseError<number>) => {
|
||||||
|
const { type, name, request } = error;
|
||||||
|
|
||||||
|
if (request.options.isGlobalError === true) {
|
||||||
|
switch (name) {
|
||||||
|
case 'HttpError':
|
||||||
|
notification.error({
|
||||||
|
key: name,
|
||||||
|
message: `网络请求失败 ${name}`,
|
||||||
|
description: httpCodeMap.zh[name as keyof typeof httpCodeMap.zh] || error.message,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'SystemError':
|
||||||
|
notification.error({
|
||||||
|
key: name,
|
||||||
|
message: `系统错误 ${name}`,
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'RequestError':
|
||||||
|
notification.error({
|
||||||
|
key: name,
|
||||||
|
message: requestCodeMap[type]?.message || `请求异常 ${type}`,
|
||||||
|
description: requestCodeMap[type]?.description || '网络异常,无法连接服务器。',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ZodError':
|
||||||
|
// notification.warning({
|
||||||
|
// key: `${request.options['url'] || request.url}`,
|
||||||
|
// style: { width: 480 },
|
||||||
|
// duration: 0,
|
||||||
|
// message: '请求异常,数据校验失败',
|
||||||
|
// description: ZodErrorDescription({ error, errorMessage: JSON.parse(error.message) as Zod.ZodError }),
|
||||||
|
// });
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 请求初始化时出错或者没有响应返回的异常
|
||||||
|
error.message !== 'ZodError' &&
|
||||||
|
notification.error({
|
||||||
|
key: 'defaultError',
|
||||||
|
message: '请求异常',
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
};
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { extend, type RequestOptionsInit } from 'umi-request';
|
||||||
|
import { resolveRequestError } from './error';
|
||||||
|
|
||||||
|
declare module 'umi-request' {
|
||||||
|
interface RequestOptionsInit {
|
||||||
|
/**
|
||||||
|
* 是否进行全局错误处理
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
isGlobalError?: boolean;
|
||||||
|
/** 是否开启 zod 校验 */
|
||||||
|
zod?: {
|
||||||
|
/** 请求参数校验 */
|
||||||
|
req?: { params: unknown; target: Zod.ZodTypeAny };
|
||||||
|
/** 响应结果校验 */
|
||||||
|
res?: ReturnType<typeof Res.base> | ReturnType<typeof Res.page>;
|
||||||
|
/**
|
||||||
|
* 校验失败时是否直接抛出错误而不执行错误捕获
|
||||||
|
* @default { req: true, res: false }
|
||||||
|
*/
|
||||||
|
panic?: boolean | { req?: boolean; res?: boolean };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局控制器,可以终止所有请求 */
|
||||||
|
export const controller = new AbortController();
|
||||||
|
// 返回一个 AbortSignal 对象实例,它可以用来 with/abort 一个 DOM 请求
|
||||||
|
const { signal } = controller;
|
||||||
|
|
||||||
|
const requestOptions: RequestOptionsInit = {
|
||||||
|
prefix: `${ENV.VITE_API_URL_SUFFIX[0]}`,
|
||||||
|
suffix: '',
|
||||||
|
signal,
|
||||||
|
isGlobalError: true,
|
||||||
|
errorHandler: resolveRequestError,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 请求实例 */
|
||||||
|
export const request = extend(requestOptions);
|
||||||
|
|
||||||
|
// 中间件 - zod 校验
|
||||||
|
request.use(async (ctx, next) => {
|
||||||
|
const { req } = ctx;
|
||||||
|
const { zod } = req.options;
|
||||||
|
if (zod) {
|
||||||
|
const { panic = { req: true, res: false } } = zod;
|
||||||
|
|
||||||
|
if (zod.req) {
|
||||||
|
const zodResult = zod.req.target.safeParse(zod.req.params);
|
||||||
|
if (!zodResult.success) {
|
||||||
|
resolveRequestError({
|
||||||
|
name: 'ZodError',
|
||||||
|
data: 26,
|
||||||
|
response: undefined as unknown as Response,
|
||||||
|
request: req,
|
||||||
|
type: zodResult.error.name,
|
||||||
|
message: JSON.stringify(zodResult.error),
|
||||||
|
});
|
||||||
|
if (typeof panic === 'boolean') {
|
||||||
|
if (panic) throw new Error(zodResult.error.name);
|
||||||
|
} else if (panic.req) throw new Error(zodResult.error.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
|
||||||
|
const { res } = ctx;
|
||||||
|
|
||||||
|
if (zod.res) {
|
||||||
|
const zodResult = zod.res.safeParse(res);
|
||||||
|
if (!zodResult.success) {
|
||||||
|
resolveRequestError({
|
||||||
|
name: 'ZodError',
|
||||||
|
data: 26,
|
||||||
|
response: res,
|
||||||
|
request: req,
|
||||||
|
type: zodResult.error.name,
|
||||||
|
message: JSON.stringify(zodResult.error),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof panic === 'boolean') {
|
||||||
|
if (panic) throw new Error(zodResult.error.name);
|
||||||
|
} else if (panic.res) throw new Error(zodResult.error.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 请求 拦截器
|
||||||
|
request.interceptors.request.use((url, options) => {
|
||||||
|
(options.headers as Recordable)[KEYS.TOKEN] = systemModel.state.token || '';
|
||||||
|
(options.headers as Recordable)['x-mental-health-type'] = 1;
|
||||||
|
return { url, options };
|
||||||
|
});
|
||||||
|
// 响应 拦截器
|
||||||
|
request.interceptors.response.use(async (response, options) => {
|
||||||
|
if (options.responseType === 'blob') {
|
||||||
|
return Promise.resolve(response);
|
||||||
|
} else {
|
||||||
|
const res = await response.clone().json();
|
||||||
|
|
||||||
|
if (response.status < 200 || response.status > 200) {
|
||||||
|
res.name = 'HttpError';
|
||||||
|
res.type = `${response.status}`;
|
||||||
|
res.message = response.statusText;
|
||||||
|
|
||||||
|
return Promise.reject(res);
|
||||||
|
} else {
|
||||||
|
if (res.code !== 0) {
|
||||||
|
res.name = 'ServiceError';
|
||||||
|
res.type = `${res.code}`;
|
||||||
|
res.message = res.msg;
|
||||||
|
|
||||||
|
return Promise.reject(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve(response);
|
||||||
|
});
|
|
@ -0,0 +1 @@
|
||||||
|
export {};
|
|
@ -0,0 +1,7 @@
|
||||||
|
declare namespace Account {
|
||||||
|
namespace API {
|
||||||
|
interface Base extends Zod.infer<typeof Z.Account.Base> {}
|
||||||
|
interface Row extends Zod.infer<typeof Z.Account.Row> {}
|
||||||
|
interface Account extends Zod.infer<typeof Z.Account.Ac> {}
|
||||||
|
}
|
||||||
|
}
|