代码初始化

This commit is contained in:
userName 2024-02-18 14:52:01 +08:00
commit ef3b0f6332
2332 changed files with 534137 additions and 0 deletions

8
.idea/.gitignore vendored Normal file
View File

@ -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

View File

@ -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>

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View File

@ -0,0 +1,5 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
</profile>
</component>

9
.idea/mentalHealth.iml Normal file
View File

@ -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>

6
.idea/misc.xml Normal file
View File

@ -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>

8
.idea/modules.xml Normal file
View File

@ -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>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

17
01-Web/.editorconfig Normal file
View File

@ -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

22
01-Web/.env Normal file
View File

@ -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"]

15
01-Web/.env.development Normal file
View File

@ -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"]]

15
01-Web/.env.production Normal file
View File

@ -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"]]

12
01-Web/.eslintignore Normal file
View File

@ -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

50
01-Web/.eslintrc.json Normal file
View File

@ -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"]
}
}
]
}

36
01-Web/.gitignore vendored Normal file
View File

@ -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

6
01-Web/.npmrc Normal file
View File

@ -0,0 +1,6 @@
# registry=https://registry.npmjs.org/
registry=https://registry.npmmirror.com/
# pnpm
auto-install-peers=true
strict-peer-dependencies=false

12
01-Web/.prettierignore Normal file
View File

@ -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

33
01-Web/.prettierrc.json Normal file
View File

@ -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
}
}
]
}

6
01-Web/.stylelintignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
build
dist
public
*.min.css

58
01-Web/.stylelintrc.json Normal file
View File

@ -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"]
}
]
}
}
]
}

14
01-Web/.vscode/extensions.json vendored Normal file
View File

@ -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"
]
}

111
01-Web/.vscode/settings.json vendored Normal file
View File

@ -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/**"]
}

33
01-Web/README.md Normal file
View File

@ -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` 文件拓展名。
- 文件名关系到项目的运行和编译,请仔细确认。

View File

@ -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>

View File

@ -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"
}
}

View File

@ -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` },
});
};

View File

@ -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',
// },
],
},
});
};

View File

@ -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,
},
],
},
});
};

View File

@ -0,0 +1,7 @@
import legacy from '@vitejs/plugin-legacy';
export const configLegacy = () => {
return legacy({
targets: ['defaults', 'not IE 11', 'chrome > 86'],
});
};

View File

@ -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'],
});
};

View File

@ -0,0 +1,5 @@
import react from '@vitejs/plugin-react';
export const configReact = () => {
return react();
};

View File

@ -0,0 +1,5 @@
import Unocss from 'unocss/vite';
export const configUnocss = () => {
return Unocss();
};

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

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

View File

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

View File

@ -0,0 +1,3 @@
## Assets
此文件夹下存放了项目中所有的资源文件

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export function isWebLink(path: string | null | undefined): boolean {
if (path) return path.startsWith('http');
else return false;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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')
} */
};

View File

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

View File

@ -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;

View File

@ -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();
},
},
});

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -0,0 +1,7 @@
export const SignUp = () => {
return (
<div>
<div>SignUp</div>
</div>
);
};

View File

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

View File

@ -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>,
);

View File

@ -0,0 +1,9 @@
## Modules
自定义用户模块系统。用下面的模板添加 `.ts` 文件,系统将自动运行模块
```ts
export const install = () => {
// do something
};
```

View File

@ -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';

View File

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

View File

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

View File

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

View File

@ -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: '网络连接超时,服务器无响应。',
},
};

View File

@ -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"
/>
);
};

View File

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

View File

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

View File

@ -0,0 +1 @@
export {};

View File

@ -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> {}
}
}

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