代码初始化
|
@ -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> {}
|
||||
}
|
||||
}
|