feat: Architecture initialization
This commit is contained in:
commit
6f37264047
|
|
@ -0,0 +1,15 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{less,css}]
|
||||||
|
indent_size = 2
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# 公共环境变量 (所有模式共享)
|
||||||
|
VITE_APP_TITLE = CPMS 运营平台
|
||||||
|
VITE_BASE_URL = /
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
# 开发环境
|
||||||
|
VITE_PORT = 5173
|
||||||
|
VITE_API_BASE_URL = http://localhost:3000
|
||||||
|
VITE_API_PREFIX = /api
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# 生产环境
|
||||||
|
VITE_API_BASE_URL = https://api.cpms.example.com
|
||||||
|
VITE_API_PREFIX = /api
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# 测试环境
|
||||||
|
VITE_API_BASE_URL = https://test-api.cpms.example.com
|
||||||
|
VITE_API_PREFIX = /api
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
public
|
||||||
|
*.config.js
|
||||||
|
*.config.ts
|
||||||
|
start-debug.js
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/* eslint-env node */
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
node: true,
|
||||||
|
es2021: true,
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:vue/vue3-recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'prettier',
|
||||||
|
'plugin:prettier/recommended',
|
||||||
|
],
|
||||||
|
parser: 'vue-eslint-parser',
|
||||||
|
parserOptions: {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
plugins: ['vue', '@typescript-eslint', 'prettier'],
|
||||||
|
rules: {
|
||||||
|
'prettier/prettier': 'warn',
|
||||||
|
'vue/multi-word-component-names': 'off',
|
||||||
|
'vue/no-v-html': 'off',
|
||||||
|
'vue/require-default-prop': 'off',
|
||||||
|
'vue/attribute-hyphenation': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
|
'@typescript-eslint/ban-ts-comment': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||||
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
defineProps: 'readonly',
|
||||||
|
defineEmits: 'readonly',
|
||||||
|
defineExpose: 'readonly',
|
||||||
|
withDefaults: 'readonly',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Env local overrides
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.cache
|
||||||
|
.temp
|
||||||
|
.tmp
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npx --no -- commitlint --edit "$1"
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npx lint-staged
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
public
|
||||||
|
*.svg
|
||||||
|
*.ico
|
||||||
|
CHANGELOG.md
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"vueIndentScriptAndStyle": false,
|
||||||
|
"htmlWhitespaceSensitivity": "ignore"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"antfu.unocss",
|
||||||
|
"lokalise.i18n-ally"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"eslint.validate": ["javascript", "typescript", "vue"],
|
||||||
|
"[vue]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[less]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"files.associations": {
|
||||||
|
"*.vue": "vue"
|
||||||
|
},
|
||||||
|
"search.exclude": {
|
||||||
|
"**/node_modules": true,
|
||||||
|
"**/dist": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
# CPMS 运营平台 (cpms_web_admin_pc)
|
||||||
|
|
||||||
|
基于 **Vue 3 + Vite 5 + TypeScript + Ant Design Vue 4** 搭建的中后台管理项目骨架,采用 **TSX 写法**(不使用 `.vue` 单文件组件)。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 技术 | 版本 | 说明 |
|
||||||
|
| ---------------------- | ------ | ------------------------------- |
|
||||||
|
| Vue | ^3.3.0 | 渐进式前端框架 |
|
||||||
|
| Vite | ^5.0.0 | 下一代构建工具 |
|
||||||
|
| TypeScript | ^5.3.0 | 类型系统 |
|
||||||
|
| Vue Router | ^4.2.0 | 官方路由 |
|
||||||
|
| Ant Design Vue | ^4.0.0 | UI 组件库 |
|
||||||
|
| Less | ^4.6.4 | CSS 预处理器 |
|
||||||
|
| @vitejs/plugin-vue-jsx | ^4.0.0 | TSX/JSX 编译插件 |
|
||||||
|
| CSS Modules | - | 组件级样式隔离(`*.module.less`) |
|
||||||
|
| ESLint + Prettier | - | 代码规范与格式化 |
|
||||||
|
| Husky + Commitlint | - | Git 提交规范 |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
cpms_operation_platform/
|
||||||
|
├── public/ # 静态资源(不参与编译)
|
||||||
|
│ └── favicon.svg
|
||||||
|
├── src/
|
||||||
|
│ ├── api/ # 接口请求层
|
||||||
|
│ ├── assets/ # 静态资源(参与编译)
|
||||||
|
│ │ └── styles/ # 全局样式
|
||||||
|
│ ├── components/ # 公共组件 (.tsx)
|
||||||
|
│ ├── layouts/ # 布局组件 (.tsx + .module.less)
|
||||||
|
│ ├── router/ # 路由配置
|
||||||
|
│ ├── types/ # 全局类型定义
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ ├── views/ # 页面视图 (.tsx + .module.less)
|
||||||
|
│ ├── App.tsx # 根组件
|
||||||
|
│ ├── main.ts # 应用入口
|
||||||
|
│ └── env.d.ts # 环境变量类型声明
|
||||||
|
├── .env # 公共环境变量
|
||||||
|
├── .env.development # 开发环境
|
||||||
|
├── .env.test # 测试环境
|
||||||
|
├── .env.production # 生产环境
|
||||||
|
├── .eslintrc.cjs # ESLint 配置
|
||||||
|
├── .prettierrc # Prettier 配置
|
||||||
|
├── commitlint.config.js # 提交规范配置
|
||||||
|
├── index.html # HTML 模板
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json # TypeScript 配置
|
||||||
|
├── vite.config.ts # Vite 配置
|
||||||
|
└── start-debug.js # 本地调试服务(express)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
# 或
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # 启动开发服务器
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:dev # 开发环境构建
|
||||||
|
npm run build:test # 测试环境构建
|
||||||
|
npm run build:prod # 生产环境构建
|
||||||
|
```
|
||||||
|
|
||||||
|
### 预览构建产物
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
### 本地调试(基于 express)
|
||||||
|
|
||||||
|
先构建产物,再用 express 托管:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:dev && npm run local
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码检查与格式化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint # ESLint 检查
|
||||||
|
npm run lint:fix # ESLint 自动修复
|
||||||
|
npm run format # Prettier 格式化
|
||||||
|
npm run type-check # TypeScript 类型检查
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git 提交规范
|
||||||
|
|
||||||
|
本项目使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范,由 commitlint 强制校验。
|
||||||
|
|
||||||
|
提交格式:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
```
|
||||||
|
|
||||||
|
常用 type:
|
||||||
|
|
||||||
|
| type | 说明 |
|
||||||
|
| -------- | -------------------- |
|
||||||
|
| feat | 新功能 |
|
||||||
|
| fix | 修复 Bug |
|
||||||
|
| docs | 文档变更 |
|
||||||
|
| style | 代码格式(不影响功能) |
|
||||||
|
| refactor | 重构 |
|
||||||
|
| perf | 性能优化 |
|
||||||
|
| test | 测试 |
|
||||||
|
| build | 构建系统/依赖变更 |
|
||||||
|
| ci | CI 配置 |
|
||||||
|
| chore | 杂项 |
|
||||||
|
| revert | 回滚 |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(dashboard): 新增工作台数据统计卡片"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
通过 `.env` 系列文件管理环境配置,Vite 中以 `VITE_` 开头的变量会暴露到 `import.meta.env`。
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
| ----------------- | -------------- |
|
||||||
|
| VITE_APP_TITLE | 应用标题 |
|
||||||
|
| VITE_BASE_URL | 部署基础路径 |
|
||||||
|
| VITE_PORT | 开发服务器端口 |
|
||||||
|
| VITE_API_BASE_URL | 接口基础地址 |
|
||||||
|
| VITE_API_PREFIX | 接口路径前缀 |
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
module.exports = {
|
||||||
|
extends: ['@commitlint/config-conventional'],
|
||||||
|
rules: {
|
||||||
|
'type-enum': [
|
||||||
|
2,
|
||||||
|
'always',
|
||||||
|
[
|
||||||
|
'feat', // 新功能
|
||||||
|
'fix', // 修复 bug
|
||||||
|
'docs', // 文档变更
|
||||||
|
'style', // 代码格式(不影响功能)
|
||||||
|
'refactor', // 重构
|
||||||
|
'perf', // 性能优化
|
||||||
|
'test', // 测试
|
||||||
|
'build', // 构建系统或外部依赖变更
|
||||||
|
'ci', // CI 配置
|
||||||
|
'chore', // 杂项/构建工具
|
||||||
|
'revert', // 回滚
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'type-case': [2, 'always', 'lower-case'],
|
||||||
|
'type-empty': [2, 'never'],
|
||||||
|
'scope-case': [2, 'always', 'lower-case'],
|
||||||
|
'subject-empty': [2, 'never'],
|
||||||
|
'subject-full-stop': [2, 'never', '.'],
|
||||||
|
'header-max-length': [2, 'always', 100],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="CPMS 运营平台" />
|
||||||
|
<title>CPMS 运营平台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,64 @@
|
||||||
|
{
|
||||||
|
"name": "cpms_operation_platform",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "CPMS 运营平台前端 PC 端",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"serve": "vite",
|
||||||
|
"build:dev": "vue-tsc --noEmit && vite build --mode development",
|
||||||
|
"build:test": "vue-tsc --noEmit && vite build --mode test",
|
||||||
|
"build:prod": "vue-tsc --noEmit && vite build --mode production",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"type-check": "vue-tsc --noEmit",
|
||||||
|
"local": "node start-debug.js",
|
||||||
|
"lint": "eslint src --ext .js,.ts,.tsx",
|
||||||
|
"lint:fix": "eslint src --ext .js,.ts,.tsx --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.{js,ts,tsx,json,less,css,md}\"",
|
||||||
|
"prepare": "husky install"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
|
"@yp-component/root": "^0.0.46",
|
||||||
|
"ant-design-vue": "^4.0.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"qs": "^6.15.0",
|
||||||
|
"vue": "^3.3.0",
|
||||||
|
"vue-router": "^4.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^20.4.3",
|
||||||
|
"@commitlint/config-conventional": "^20.4.3",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/qs": "^6.15.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"@vitejs/plugin-vue": "^4.5.0",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^4.0.0",
|
||||||
|
"eslint": "^8.0.0",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
|
"eslint-plugin-vue": "^9.0.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"less": "^4.6.4",
|
||||||
|
"lint-staged": "^15.0.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vue-tsc": "^2.0.0"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,ts,tsx}": [
|
||||||
|
"eslint --fix",
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"*.{json,less,css,md}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#1677ff" />
|
||||||
|
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="28" font-weight="700" fill="#fff" font-family="Arial, sans-serif">CP</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 285 B |
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import { ConfigProvider } from 'ant-design-vue';
|
||||||
|
import zhCN from 'ant-design-vue/es/locale/zh_CN';
|
||||||
|
import { RouterView } from 'vue-router';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根组件
|
||||||
|
* 通过 ConfigProvider 注入 ant-design-vue 全局配置(语言、主题色)
|
||||||
|
*/
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'App',
|
||||||
|
setup() {
|
||||||
|
const theme = {
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#1677ff',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<ConfigProvider locale={zhCN} theme={theme}>
|
||||||
|
<RouterView />
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/**
|
||||||
|
* 接口模块示例
|
||||||
|
* 实际项目中按业务域拆分:如 api/user.ts、api/order.ts 等
|
||||||
|
*/
|
||||||
|
import { get, post } from '@/utils/request';
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户信息(示例接口) */
|
||||||
|
export function getCurrentUser(): Promise<UserInfo> {
|
||||||
|
return get('/user/current');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户列表(示例接口) */
|
||||||
|
export function getUserList(params: {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}): Promise<{ list: UserInfo[]; total: number }> {
|
||||||
|
return get('/user/list', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建用户(示例接口) */
|
||||||
|
export function createUser(data: Omit<UserInfo, 'id'>): Promise<UserInfo> {
|
||||||
|
return post('/user', data);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
// ===== 全局样式入口 =====
|
||||||
|
|
||||||
|
// CSS Reset 已由 ant-design-vue 的 reset.css 提供,这里补充全局变量与基础样式
|
||||||
|
|
||||||
|
// 全局 less 变量
|
||||||
|
@primary-color: #1677ff;
|
||||||
|
@text-color: rgba(0, 0, 0, 0.88);
|
||||||
|
@border-color: #f0f0f0;
|
||||||
|
@bg-color: #f5f5f5;
|
||||||
|
|
||||||
|
// 基础重置
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC',
|
||||||
|
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: @text-color;
|
||||||
|
background-color: @bg-color;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: @primary-color;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动条美化
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
// ===== 主题变量 =====
|
||||||
|
// 可在组件中通过 @import 引入复用
|
||||||
|
|
||||||
|
@primary-color: #1677ff;
|
||||||
|
@success-color: #52c41a;
|
||||||
|
@warning-color: #faad14;
|
||||||
|
@error-color: #ff4d4f;
|
||||||
|
|
||||||
|
@text-color: rgba(0, 0, 0, 0.88);
|
||||||
|
@text-color-secondary: rgba(0, 0, 0, 0.65);
|
||||||
|
@text-color-disabled: rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
@border-radius-base: 6px;
|
||||||
|
@border-color: #f0f0f0;
|
||||||
|
@bg-color: #f5f5f5;
|
||||||
|
|
||||||
|
@layout-header-height: 48px;
|
||||||
|
@layout-sider-width: 200px;
|
||||||
|
@layout-sider-width-collapsed: 80px;
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
code {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import styles from './HelloWorld.module.less';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公共组件示例
|
||||||
|
*/
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'HelloWorld',
|
||||||
|
props: {
|
||||||
|
msg: {
|
||||||
|
type: String,
|
||||||
|
default: 'Hello World',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => (
|
||||||
|
<div class={styles.container}>
|
||||||
|
<h3 class={styles.title}>{props.msg}</h3>
|
||||||
|
<p class={styles.desc}>
|
||||||
|
这是一个公共组件示例,采用 TSX 写法。你可以在
|
||||||
|
<code>src/components</code>
|
||||||
|
下继续扩展。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
/**
|
||||||
|
* 公共组件统一导出
|
||||||
|
* 在业务中可通过 `import { HelloWorld } from '@/components'` 使用
|
||||||
|
*/
|
||||||
|
export { default as HelloWorld } from './HelloWorld';
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_APP_TITLE: string;
|
||||||
|
readonly VITE_BASE_URL: string;
|
||||||
|
readonly VITE_PORT: string;
|
||||||
|
readonly VITE_API_BASE_URL: string;
|
||||||
|
readonly VITE_API_PREFIX: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
export * from './useEffect';
|
||||||
|
export * from './useState';
|
||||||
|
export * from './usePagination';
|
||||||
|
export * from './useRequest';
|
||||||
|
export * from './useDebounce';
|
||||||
|
export * from './useAuth';
|
||||||
|
export * from './useWebSocket';
|
||||||
|
export * from './useBasicLayout';
|
||||||
|
export * from './useBreadcrumb';
|
||||||
|
export * from './useContext';
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
// src/hooks/useAuth.ts
|
||||||
|
import { useState } from './useState';
|
||||||
|
import { watch } from 'vue';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
|
||||||
|
|
||||||
|
function createAuth() {
|
||||||
|
const [token, setToken] = useState<string | null>(window.localStorage.getItem(TOKEN_KEY));
|
||||||
|
|
||||||
|
watch(token, (newToken) => {
|
||||||
|
if (newToken) {
|
||||||
|
window.localStorage.setItem(TOKEN_KEY, newToken);
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const login = (newToken: string) => {
|
||||||
|
setToken(newToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setToken(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
isLoggedIn: () => !!token.value,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth = createAuth();
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { h } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { TrophyOutlined, WalletOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { useBreadcrumb } from './useBreadcrumb';
|
||||||
|
|
||||||
|
export function useBasicLayout() {
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const { breadcrumbItems } = useBreadcrumb();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
key: '/events',
|
||||||
|
icon: h(TrophyOutlined),
|
||||||
|
label: '我的赛事',
|
||||||
|
},
|
||||||
|
// { TODO 本期先做隐藏
|
||||||
|
// key: '/wallet',
|
||||||
|
// icon: h(WalletOutlined),
|
||||||
|
// label: '钱包管理',
|
||||||
|
// },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getActiveMenuKey = () => {
|
||||||
|
const matchedRoutes = route.matched;
|
||||||
|
for (const r of matchedRoutes) {
|
||||||
|
if (r.meta?.activeMenu) {
|
||||||
|
return r.meta.activeMenu as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (route.path.startsWith('/events')) return '/events';
|
||||||
|
if (route.path.startsWith('/wallet')) return '/wallet';
|
||||||
|
return route.path;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuClick = (info: any) => {
|
||||||
|
router.push(String(info.key));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
breadcrumbItems,
|
||||||
|
menuItems,
|
||||||
|
getActiveMenuKey,
|
||||||
|
handleMenuClick,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { computed, h } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
export function useBreadcrumb() {
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据当前路径和记录路径,计算拼接后的路径
|
||||||
|
*/
|
||||||
|
const buildPath = (currentPath: string, recordPath: string): string => {
|
||||||
|
if (recordPath.startsWith('/')) {
|
||||||
|
return recordPath;
|
||||||
|
}
|
||||||
|
return currentPath ? `${currentPath}/${recordPath}`.replace(/\/+/g, '/') : `/${recordPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
const matched = route.matched;
|
||||||
|
const items: Array<{ title: string; path?: string }> = [];
|
||||||
|
let currentPath = '';
|
||||||
|
|
||||||
|
for (const record of matched) {
|
||||||
|
currentPath = buildPath(currentPath, record.path);
|
||||||
|
if (record.meta && record.meta.title) {
|
||||||
|
items.push({
|
||||||
|
title: record.meta.title as string,
|
||||||
|
path: currentPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
const breadcrumbItems = computed(() => {
|
||||||
|
return breadcrumbs.value.map((item, index) => ({
|
||||||
|
key: index,
|
||||||
|
title:
|
||||||
|
item.path && index !== breadcrumbs.value.length - 1
|
||||||
|
? h(
|
||||||
|
'a',
|
||||||
|
{
|
||||||
|
onClick: (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// 最保守方案:只有当前路径包含 /bracket/ 且目标路径是 /events/bracket 时才保留 query
|
||||||
|
// 也就是只有:/events/bracket/xxx → /events/bracket 时才保留
|
||||||
|
const targetPath = item.path || '/';
|
||||||
|
const isBracketChildPage =
|
||||||
|
route.path.includes('/bracket/') && targetPath === '/events/bracket';
|
||||||
|
router.push({
|
||||||
|
path: targetPath,
|
||||||
|
query: isBracketChildPage ? route.query : {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
item.title,
|
||||||
|
)
|
||||||
|
: item.title,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
breadcrumbs,
|
||||||
|
breadcrumbItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { reactive, provide, inject, type InjectionKey } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深合并:将 source 的属性递归合并到 target 中
|
||||||
|
* - 对于原始值和数组,直接赋值(数组整体替换,不做元素级 diff)
|
||||||
|
* - 对于普通对象,递归合并,保留 target 中已有的响应式 Proxy
|
||||||
|
* - 跳过值为 undefined 的属性
|
||||||
|
*/
|
||||||
|
function deepSyncState<T extends Record<string, any>>(target: T, source: Partial<T>): void {
|
||||||
|
const keys = Object.keys(source) as (keyof T)[];
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const key = keys[i];
|
||||||
|
if (!(key in target) || source[key] === undefined) continue;
|
||||||
|
|
||||||
|
const sourceVal = source[key];
|
||||||
|
const targetVal = target[key];
|
||||||
|
|
||||||
|
// 两边都是普通对象 → 递归合并,保留响应式
|
||||||
|
if (
|
||||||
|
isPlainObject(sourceVal) &&
|
||||||
|
isPlainObject(targetVal) &&
|
||||||
|
!Array.isArray(sourceVal) &&
|
||||||
|
!Array.isArray(targetVal)
|
||||||
|
) {
|
||||||
|
deepSyncState(targetVal as Record<string, any>, sourceVal as Record<string, any>);
|
||||||
|
} else {
|
||||||
|
// 原始值、数组、或类型不匹配 → 直接赋值
|
||||||
|
(target as any)[key] = sourceVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为普通对象(Plain Object)
|
||||||
|
* 排除 Array、Date、RegExp、Map、Set 等内置类型
|
||||||
|
*/
|
||||||
|
function isPlainObject(val: unknown): val is Record<string, any> {
|
||||||
|
if (val === null || typeof val !== 'object') return false;
|
||||||
|
const proto = Object.getPrototypeOf(val);
|
||||||
|
// Object.create(null) 的 proto 为 null,也视为普通对象
|
||||||
|
return proto === null || proto === Object.prototype;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Context<T extends Record<string, any>> {
|
||||||
|
/**
|
||||||
|
* 在当前组件及所有子组件中提供 Context 数据
|
||||||
|
*
|
||||||
|
* @param value - 可选的部分更新,会深合并到初始值中
|
||||||
|
* @returns dispose 清理函数,调用后恢复初始值
|
||||||
|
*/
|
||||||
|
provide: (value?: Partial<T>) => () => void;
|
||||||
|
/** 在子组件中消费 Context 数据,返回响应式对象 */
|
||||||
|
useContext: () => T;
|
||||||
|
/** 底层 InjectionKey,用于自定义 provide/inject 场景 */
|
||||||
|
readonly key: InjectionKey<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个 Context 实例
|
||||||
|
*
|
||||||
|
* @param initialValue - 初始状态对象,所有属性将具备响应式能力
|
||||||
|
* @param options.displayName - 可选,用于 DevTools 调试时的标识
|
||||||
|
*/
|
||||||
|
export function createContext<T extends Record<string, any>>(
|
||||||
|
initialValue: T,
|
||||||
|
options?: { displayName?: string },
|
||||||
|
): Context<T> {
|
||||||
|
const name = options?.displayName || 'Context';
|
||||||
|
const key: InjectionKey<T> = Symbol(name) as InjectionKey<T>;
|
||||||
|
|
||||||
|
// 深拷贝初始值,确保源数据不被污染,同时作为后续恢复的快照
|
||||||
|
const snapshot = deepClone(initialValue);
|
||||||
|
// 创建全局响应式容器
|
||||||
|
const initialReactive = reactive(snapshot) as T;
|
||||||
|
|
||||||
|
// 记录当前是否已被 dispose,防止重复调用
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在当前组件树中提供 Context
|
||||||
|
* 调用后,所有子组件可通过 useContext() 获取到响应式状态
|
||||||
|
*
|
||||||
|
* @param value - 可选的部分更新,会深合并到初始值中
|
||||||
|
* @returns 清理函数,调用后将状态恢复为 initialValue
|
||||||
|
*/
|
||||||
|
provide(value?: Partial<T>): () => void {
|
||||||
|
disposed = false;
|
||||||
|
|
||||||
|
// 如果传入了 value,深合并到全局 reactive 对象
|
||||||
|
if (value) {
|
||||||
|
deepSyncState(initialReactive, value);
|
||||||
|
}
|
||||||
|
// 通过 Vue 原生 provide 向下注入
|
||||||
|
provide(key, initialReactive);
|
||||||
|
|
||||||
|
// 返回清理函数
|
||||||
|
return () => {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
// 恢复初始值
|
||||||
|
deepSyncState(initialReactive, initialValue);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在子组件中消费 Context
|
||||||
|
* 必须在 Provider 所在组件的子级 setup 中调用
|
||||||
|
*/
|
||||||
|
useContext(): T {
|
||||||
|
const injected = inject(key);
|
||||||
|
if (!injected) {
|
||||||
|
// 降级返回初始值,保证组件不会崩溃
|
||||||
|
return initialReactive;
|
||||||
|
}
|
||||||
|
return injected;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深拷贝普通对象,保留嵌套结构
|
||||||
|
* 仅用于创建初始快照,不处理特殊对象(Date、RegExp 等保持引用)
|
||||||
|
*/
|
||||||
|
function deepClone<T>(obj: T): T {
|
||||||
|
if (obj === null || typeof obj !== 'object') return obj;
|
||||||
|
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as T;
|
||||||
|
const result = {} as T;
|
||||||
|
const keys = Object.keys(obj) as (keyof T)[];
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const k = keys[i];
|
||||||
|
result[k] = deepClone(obj[k]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
// src/hooks/useDebounce.ts
|
||||||
|
import { watch, onUnmounted } from 'vue';
|
||||||
|
import { useState } from './useState';
|
||||||
|
|
||||||
|
export interface UseDebounceOptions {
|
||||||
|
delay?: number;
|
||||||
|
immediate?: boolean;
|
||||||
|
maxWait?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDebounce<T>(
|
||||||
|
source: any, // Ref<T>
|
||||||
|
options: number | UseDebounceOptions = {},
|
||||||
|
) {
|
||||||
|
const config = typeof options === 'number' ? { delay: options } : options;
|
||||||
|
const { delay = 300, immediate = false, maxWait } = config;
|
||||||
|
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(source.value);
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
|
||||||
|
let timer: any = null;
|
||||||
|
let maxWaitTimer: any = null;
|
||||||
|
let isFirstCall = true; // 标记是否为首次调用,用于 immediate 模式
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
const clearTimers = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
if (maxWaitTimer) {
|
||||||
|
clearTimeout(maxWaitTimer);
|
||||||
|
maxWaitTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新
|
||||||
|
const updateValue = () => {
|
||||||
|
setDebouncedValue(source.value);
|
||||||
|
setIsPending(false);
|
||||||
|
clearTimers();
|
||||||
|
// 安静期结束后重置 isFirstCall,使下次触发能立即执行(immediate 模式)
|
||||||
|
isFirstCall = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(source, () => {
|
||||||
|
setIsPending(true);
|
||||||
|
|
||||||
|
// 处理 maxWait
|
||||||
|
if (maxWait && !maxWaitTimer) {
|
||||||
|
maxWaitTimer = setTimeout(() => {
|
||||||
|
updateValue();
|
||||||
|
}, maxWait);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 delay
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
|
||||||
|
if (immediate && isFirstCall) {
|
||||||
|
isFirstCall = false;
|
||||||
|
updateValue();
|
||||||
|
} else {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
updateValue();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 卸载
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
debouncedValue,
|
||||||
|
isPending,
|
||||||
|
refresh: updateValue,
|
||||||
|
cancel: clearTimers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { watchEffect, watch, WatchSource, isRef, isReactive } from 'vue';
|
||||||
|
|
||||||
|
export function useEffect(effect: () => void | (() => void), deps?: WatchSource<any>[]) {
|
||||||
|
if (!deps) {
|
||||||
|
return watchEffect((onCleanup) => {
|
||||||
|
const cleanup = effect();
|
||||||
|
if (cleanup) onCleanup(cleanup);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validDeps = deps.map((dep) => {
|
||||||
|
if (dep === null || dep === undefined) {
|
||||||
|
return () => null;
|
||||||
|
}
|
||||||
|
if (isRef(dep) || isReactive(dep) || typeof dep === 'function') {
|
||||||
|
return dep;
|
||||||
|
}
|
||||||
|
return () => dep;
|
||||||
|
});
|
||||||
|
|
||||||
|
return watch(
|
||||||
|
validDeps,
|
||||||
|
() => {
|
||||||
|
const cleanup = effect();
|
||||||
|
return cleanup;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
flush: 'post',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||||
|
// src/hooks/usePagination.ts
|
||||||
|
import { computed, ref, watch, Ref, triggerRef } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页配置项
|
||||||
|
*/
|
||||||
|
export interface UsePaginationOptions {
|
||||||
|
/** 当前页码 (支持 v-model 双向绑定) */
|
||||||
|
current?: number;
|
||||||
|
/** 每页条数 (支持 v-model 双向绑定) */
|
||||||
|
pageSize?: number;
|
||||||
|
/** 数据总条数 (支持 v-model 双向绑定) */
|
||||||
|
total?: number;
|
||||||
|
/** 默认页码 (非受控模式下使用) */
|
||||||
|
defaultCurrent?: number;
|
||||||
|
/** 默认每页条数 (非受控模式下使用) */
|
||||||
|
defaultPageSize?: number;
|
||||||
|
/** 每页条数切换选项 (用于生成下拉菜单) */
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
/** 是否显示快速跳转 (可选功能) */
|
||||||
|
showQuickJumper?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页操作 API
|
||||||
|
*/
|
||||||
|
export interface UsePaginationActions {
|
||||||
|
/** 设置当前页 */
|
||||||
|
setCurrent: (current: number) => void;
|
||||||
|
/** 设置每页条数 (会自动重置页码为 1) */
|
||||||
|
setPageSize: (size: number) => void;
|
||||||
|
/** 设置总条数 */
|
||||||
|
setTotal: (total: number) => void;
|
||||||
|
/** 下一页 */
|
||||||
|
next: () => void;
|
||||||
|
/** 上一页 */
|
||||||
|
prev: () => void;
|
||||||
|
/** 跳转到指定页 */
|
||||||
|
jump: (page: number) => void;
|
||||||
|
/** 重置到初始状态 */
|
||||||
|
reset: () => void;
|
||||||
|
/** 强制刷新 (用于某些极端响应式场景) */
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页计算属性
|
||||||
|
*/
|
||||||
|
export interface UsePaginationComputed {
|
||||||
|
/** 响应式请求参数对象 (给 API 用的) */
|
||||||
|
params: Ref<{ page: number; pageSize: number }>;
|
||||||
|
/** 总页数 */
|
||||||
|
totalPages: Ref<number>;
|
||||||
|
/** 是否有上一页 */
|
||||||
|
hasPrev: Ref<boolean>;
|
||||||
|
/** 是否有下一页 */
|
||||||
|
hasNext: Ref<boolean>;
|
||||||
|
/** 当前页起始索引 (用于显示 "显示 1-10 条") */
|
||||||
|
startIndex: Ref<number>;
|
||||||
|
/** 当前页结束索引 */
|
||||||
|
endIndex: Ref<number>;
|
||||||
|
/** 是否为第一页 */
|
||||||
|
isFirstPage: Ref<boolean>;
|
||||||
|
/** 是否为最后一页 */
|
||||||
|
isLastPage: Ref<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsePaginationReturn = UsePaginationOptions &
|
||||||
|
UsePaginationActions &
|
||||||
|
UsePaginationComputed;
|
||||||
|
|
||||||
|
export function usePagination(options: UsePaginationOptions = {}): UsePaginationReturn {
|
||||||
|
const { defaultCurrent = 1, defaultPageSize = 10, pageSizeOptions = [10, 20, 50, 100] } = options;
|
||||||
|
|
||||||
|
const currentRef = ref(options.current ?? defaultCurrent);
|
||||||
|
const pageSizeRef = ref(options.pageSize ?? defaultPageSize);
|
||||||
|
const totalRef = ref(options.total ?? 0);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => options.current,
|
||||||
|
(val) => {
|
||||||
|
if (val !== undefined) currentRef.value = val;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => options.pageSize,
|
||||||
|
(val) => {
|
||||||
|
if (val !== undefined) pageSizeRef.value = val;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => options.total,
|
||||||
|
(val) => {
|
||||||
|
if (val !== undefined) totalRef.value = val;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
const total = totalRef.value;
|
||||||
|
const size = pageSizeRef.value;
|
||||||
|
return size === 0 ? 0 : Math.ceil(total / size);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasPrev = computed(() => currentRef.value > 1);
|
||||||
|
const hasNext = computed(() => currentRef.value < totalPages.value);
|
||||||
|
const isFirstPage = computed(() => currentRef.value === 1);
|
||||||
|
const isLastPage = computed(() => currentRef.value === totalPages.value);
|
||||||
|
|
||||||
|
const startIndex = computed(() =>
|
||||||
|
totalPages.value === 0 ? 0 : (currentRef.value - 1) * pageSizeRef.value + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const endIndex = computed(() => Math.min(currentRef.value * pageSizeRef.value, totalRef.value));
|
||||||
|
|
||||||
|
const params = computed(() => ({
|
||||||
|
page: currentRef.value,
|
||||||
|
pageSize: pageSizeRef.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const setCurrent = (page: number) => {
|
||||||
|
const safePage = Math.max(1, Math.min(page, totalPages.value || 1));
|
||||||
|
if (currentRef.value !== safePage) {
|
||||||
|
currentRef.value = safePage;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPageSize = (size: number) => {
|
||||||
|
if (pageSizeRef.value !== size) {
|
||||||
|
pageSizeRef.value = size;
|
||||||
|
currentRef.value = 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTotal = (total: number) => {
|
||||||
|
totalRef.value = total;
|
||||||
|
const maxPage = Math.max(1, Math.ceil(total / pageSizeRef.value));
|
||||||
|
if (currentRef.value > maxPage) {
|
||||||
|
currentRef.value = maxPage;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => setCurrent(currentRef.value + 1);
|
||||||
|
const prev = () => setCurrent(currentRef.value - 1);
|
||||||
|
|
||||||
|
const jump = (page: number) => {
|
||||||
|
if (!Number.isInteger(page) || page < 1) return;
|
||||||
|
setCurrent(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
currentRef.value = defaultCurrent;
|
||||||
|
pageSizeRef.value = defaultPageSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
triggerRef(currentRef);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// @ts-ignore
|
||||||
|
current: currentRef,
|
||||||
|
// @ts-ignore
|
||||||
|
pageSize: pageSizeRef,
|
||||||
|
// @ts-ignore
|
||||||
|
total: totalRef,
|
||||||
|
pageSizeOptions,
|
||||||
|
params,
|
||||||
|
totalPages,
|
||||||
|
hasPrev,
|
||||||
|
hasNext,
|
||||||
|
isFirstPage,
|
||||||
|
isLastPage,
|
||||||
|
startIndex,
|
||||||
|
endIndex,
|
||||||
|
setCurrent,
|
||||||
|
setPageSize,
|
||||||
|
setTotal,
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
jump,
|
||||||
|
reset,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||||
|
import { ref, Ref, watch, WatchSource, onUnmounted, computed } from 'vue';
|
||||||
|
|
||||||
|
export interface UseRequestReturn<T> {
|
||||||
|
/** 纯净的数据,不包含后端外层壳 */
|
||||||
|
data: Ref<T | undefined>;
|
||||||
|
/** 加载状态 */
|
||||||
|
loading: Ref<boolean>;
|
||||||
|
/** 错误对象 */
|
||||||
|
error: Ref<Error | null>;
|
||||||
|
/** 手动触发请求 */
|
||||||
|
run: (...args: any[]) => Promise<T | undefined>;
|
||||||
|
/** 取消请求 */
|
||||||
|
cancel: () => void;
|
||||||
|
/** 重置数据 */
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRequest<T>(
|
||||||
|
fetcher: (...args: any[]) => Promise<any>,
|
||||||
|
options: {
|
||||||
|
/** 是否手动触发,默认 false (即自动触发) */
|
||||||
|
manual?: boolean;
|
||||||
|
/** 初始数据 */
|
||||||
|
initialData?: T;
|
||||||
|
/** 依赖数组,变化时重新请求 */
|
||||||
|
refreshDeps?: WatchSource[];
|
||||||
|
/**
|
||||||
|
* 数据格式化函数
|
||||||
|
* 默认逻辑:如果返回是对象且有 data 属性,则返回 res.data,否则返回原数据
|
||||||
|
*/
|
||||||
|
formatResult?: (res: any) => T;
|
||||||
|
/** 是否准备好可以发起请求,默认 true */
|
||||||
|
ready?: Ref<boolean>;
|
||||||
|
} = {},
|
||||||
|
): UseRequestReturn<T> & {
|
||||||
|
/** 获取当前 AbortController 的 signal,可传递给 fetcher */
|
||||||
|
getSignal: () => AbortSignal | undefined;
|
||||||
|
} {
|
||||||
|
const {
|
||||||
|
manual = false,
|
||||||
|
initialData,
|
||||||
|
refreshDeps,
|
||||||
|
formatResult = (res) => (res && typeof res === 'object' && 'data' in res ? res.data : res),
|
||||||
|
ready = ref(true),
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const data = ref<T | undefined>(initialData);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<Error | null>(null);
|
||||||
|
|
||||||
|
let abortController: AbortController | null = null;
|
||||||
|
let isUnmounted = false;
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
isUnmounted = true;
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = async (...args: any[]): Promise<T | undefined> => {
|
||||||
|
if (isUnmounted) return undefined;
|
||||||
|
|
||||||
|
// 取消之前的请求
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的 AbortController
|
||||||
|
abortController = new AbortController();
|
||||||
|
const currentSignal = abortController.signal;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 将 signal 作为最后一个参数传递给 fetcher
|
||||||
|
// 调用方可以通过最后一个参数获取 signal: async (...args, { signal }) => {...}
|
||||||
|
const res = await fetcher(...args, { signal: currentSignal });
|
||||||
|
|
||||||
|
if (currentSignal.aborted || isUnmounted) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedData = formatResult(res);
|
||||||
|
|
||||||
|
if (!isUnmounted) {
|
||||||
|
data.value = formattedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formattedData;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.name === 'AbortError' || e.message?.includes('aborted')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isUnmounted) {
|
||||||
|
error.value = e;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
// 只有当前请求未被取消时才重置 loading
|
||||||
|
if (!currentSignal.aborted && !isUnmounted) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
// 如果这是当前活动的 controller,清理引用
|
||||||
|
if (abortController?.signal === currentSignal) {
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort();
|
||||||
|
loading.value = false;
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
cancel();
|
||||||
|
data.value = initialData;
|
||||||
|
error.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 shouldAutoRun 统一管理自动请求,避免 refreshDeps 和 immediate watch 重复触发 run
|
||||||
|
const shouldAutoRun = computed(() => !manual && ready.value);
|
||||||
|
let hasAutoRun = false; // 标记是否已自动执行过,防止重复触发
|
||||||
|
|
||||||
|
watch(
|
||||||
|
shouldAutoRun,
|
||||||
|
(val) => {
|
||||||
|
if (val && !hasAutoRun) {
|
||||||
|
hasAutoRun = true;
|
||||||
|
run();
|
||||||
|
} else if (!val) {
|
||||||
|
// 当条件不满足时重置 hasAutoRun,使下次满足条件时能再次自动执行
|
||||||
|
hasAutoRun = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// refreshDeps 变化时重新请求(初始化时不再重复触发)
|
||||||
|
if (refreshDeps && refreshDeps.length > 0) {
|
||||||
|
watch(
|
||||||
|
refreshDeps,
|
||||||
|
() => {
|
||||||
|
if (shouldAutoRun.value) {
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSignal = () => abortController?.signal;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// @ts-ignore
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
run,
|
||||||
|
cancel,
|
||||||
|
reset,
|
||||||
|
getSignal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { ref, Ref } from 'vue';
|
||||||
|
/**
|
||||||
|
* 返回一个 [state, setState] 元组
|
||||||
|
*/
|
||||||
|
export function useState<T>(initialValue: T): [Ref<T>, (newVal: T) => void] {
|
||||||
|
const state: any = ref<T>(initialValue);
|
||||||
|
|
||||||
|
const setState = (newVal: T) => {
|
||||||
|
if (typeof newVal === 'function') {
|
||||||
|
state.value = (newVal as (prev: T) => T)(state.value);
|
||||||
|
} else {
|
||||||
|
state.value = newVal;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return [state, setState];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { ref, watch, onBeforeUnmount, toValue } from 'vue';
|
||||||
|
import type { Ref, MaybeRefOrGetter } from 'vue';
|
||||||
|
|
||||||
|
// --- 类型定义 ---
|
||||||
|
export type WebSocketStatus = 'connecting' | 'open' | 'closed' | 'error';
|
||||||
|
|
||||||
|
export interface UseWebSocketOptions {
|
||||||
|
// 是否自动连接
|
||||||
|
autoConnect?: boolean;
|
||||||
|
// 子协议
|
||||||
|
protocols?: string[];
|
||||||
|
|
||||||
|
// --- 重连配置 ---
|
||||||
|
reconnectLimit?: number; // 最大重连次数,默认 -1 (无限)
|
||||||
|
reconnectInterval?: number; // 初始重连间隔 ms
|
||||||
|
reconnectIncrement?: number; // 每次重连增加的间隔 ms (用于指数退避)
|
||||||
|
|
||||||
|
// --- 心跳配置 ---
|
||||||
|
heartbeat?: boolean; // 是否开启心跳
|
||||||
|
heartbeatInterval?: number; // 心跳间隔 ms
|
||||||
|
heartbeatMessage?: any; // 心跳发送的数据
|
||||||
|
|
||||||
|
// --- 回调钩子 ---
|
||||||
|
onOpen?: (event: Event) => void;
|
||||||
|
onClose?: (event: CloseEvent) => void;
|
||||||
|
onMessage?: (data: any, event: MessageEvent) => void;
|
||||||
|
onError?: (event: Event) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 返回值接口 ---
|
||||||
|
export interface UseWebSocketReturn {
|
||||||
|
status: Ref<WebSocketStatus>;
|
||||||
|
latestData: Ref<any>; // 最新接收到的数据
|
||||||
|
send: (data: any) => void; // 发送方法
|
||||||
|
connect: () => void; // 手动连接
|
||||||
|
disconnect: () => void; // 手动断开
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useWebSocket Hook
|
||||||
|
* 仿照 ahooks 设计,支持动态配置、指数退避重连、心跳检测
|
||||||
|
*/
|
||||||
|
// 模块级变量,替代 window 全局挂载,避免全局污染和内存泄漏
|
||||||
|
const webSocketList: WebSocket[] = [];
|
||||||
|
|
||||||
|
export function useWebSocket(
|
||||||
|
url: MaybeRefOrGetter<string>,
|
||||||
|
options: UseWebSocketOptions = {},
|
||||||
|
): UseWebSocketReturn {
|
||||||
|
const {
|
||||||
|
autoConnect = true,
|
||||||
|
protocols = [],
|
||||||
|
reconnectLimit = -1,
|
||||||
|
reconnectInterval = 1000,
|
||||||
|
reconnectIncrement = 1000,
|
||||||
|
heartbeat = true,
|
||||||
|
heartbeatInterval = 30000,
|
||||||
|
heartbeatMessage = 'ping',
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
onMessage,
|
||||||
|
onError,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// --- 响应式状态 ---
|
||||||
|
const status = ref<WebSocketStatus>('closed');
|
||||||
|
const latestData = ref<any>(null);
|
||||||
|
const wsRef = ref<WebSocket | null>(null);
|
||||||
|
|
||||||
|
// --- 内部变量 ---
|
||||||
|
let reconnectCount = 0;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// --- 辅助函数 ---
|
||||||
|
|
||||||
|
// 清除所有定时器
|
||||||
|
const clearTimers = () => {
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启动心跳
|
||||||
|
const startHeartbeat = () => {
|
||||||
|
if (!heartbeat) return;
|
||||||
|
stopHeartbeat(); // 防止重复启动
|
||||||
|
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
if (wsRef.value && wsRef.value.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.value.send(
|
||||||
|
typeof heartbeatMessage === 'string'
|
||||||
|
? heartbeatMessage
|
||||||
|
: JSON.stringify(heartbeatMessage),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, heartbeatInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 停止心跳
|
||||||
|
const stopHeartbeat = () => {
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearInterval(heartbeatTimer);
|
||||||
|
heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 建立连接的核心逻辑
|
||||||
|
const connect = () => {
|
||||||
|
// 如果已经有连接且是开启状态,不重复创建
|
||||||
|
if (wsRef.value?.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
// 关闭旧连接(如果有)
|
||||||
|
if (wsRef.value) {
|
||||||
|
wsRef.value.close();
|
||||||
|
wsRef.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.value = 'connecting';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用 toValue 支持响应式 URL
|
||||||
|
const wsUrl = toValue(url);
|
||||||
|
wsRef.value = new WebSocket(wsUrl, protocols);
|
||||||
|
|
||||||
|
// 追踪所有 WebSocket 实例用于调试和清理(使用模块级变量,避免全局污染)
|
||||||
|
webSocketList.push(wsRef.value);
|
||||||
|
|
||||||
|
wsRef.value.onopen = (event) => {
|
||||||
|
status.value = 'open';
|
||||||
|
reconnectCount = 0; // 重置重连计数
|
||||||
|
startHeartbeat(); // 启动心跳
|
||||||
|
onOpen?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.value.onmessage = (event) => {
|
||||||
|
// 简单的心跳回显处理:如果收到的消息等于发送的心跳消息,则忽略(或者根据业务协议处理 pong)
|
||||||
|
// 这里假设服务端会原样返回 ping 或者返回特定的 pong
|
||||||
|
// 实际项目中建议检查 event.data
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
latestData.value = data;
|
||||||
|
onMessage?.(data, event);
|
||||||
|
} catch (e) {
|
||||||
|
latestData.value = event.data;
|
||||||
|
onMessage?.(event.data, event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.value.onerror = (event) => {
|
||||||
|
status.value = 'error';
|
||||||
|
onError?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.value.onclose = (event) => {
|
||||||
|
status.value = 'closed';
|
||||||
|
stopHeartbeat();
|
||||||
|
onClose?.(event);
|
||||||
|
|
||||||
|
// 触发重连逻辑
|
||||||
|
handleReconnect();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
status.value = 'error';
|
||||||
|
onError?.(err as Event);
|
||||||
|
handleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理重连(指数退避策略)
|
||||||
|
const handleReconnect = () => {
|
||||||
|
// 如果是用户手动调用的 disconnect,不应该重连(可以通过标记位控制,这里简化处理:只要没达到限制就重连)
|
||||||
|
// 如果需要彻底断开,请调用 disconnect()
|
||||||
|
|
||||||
|
if (reconnectLimit !== -1 && reconnectCount >= reconnectLimit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算延迟时间:线性增长 1s, 2s, 3s, 4s... (最大 10s)
|
||||||
|
const delay = Math.min(10000, reconnectInterval + reconnectCount * reconnectIncrement);
|
||||||
|
|
||||||
|
reconnectCount++;
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
connect();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
const send = (data: any) => {
|
||||||
|
if (wsRef.value && wsRef.value.readyState === WebSocket.OPEN) {
|
||||||
|
const strData = typeof data === 'string' ? data : JSON.stringify(data);
|
||||||
|
wsRef.value.send(strData);
|
||||||
|
} else {
|
||||||
|
console.warn('WebSocket 未连接,无法发送消息');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 手动断开(通常意味着不再自动重连,除非再次调用 connect)
|
||||||
|
const disconnect = () => {
|
||||||
|
reconnectCount = reconnectLimit; // 设置计数为最大值,阻止后续重连逻辑
|
||||||
|
clearTimers();
|
||||||
|
if (wsRef.value) {
|
||||||
|
// 从模块级列表中移除这个实例
|
||||||
|
const index = webSocketList.indexOf(wsRef.value);
|
||||||
|
if (index > -1) {
|
||||||
|
webSocketList.splice(index, 1);
|
||||||
|
}
|
||||||
|
wsRef.value.close();
|
||||||
|
wsRef.value = null;
|
||||||
|
}
|
||||||
|
status.value = 'closed';
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 监听与副作用 ---
|
||||||
|
|
||||||
|
// 监听 URL 变化,如果变了则重新连接
|
||||||
|
watch(
|
||||||
|
() => toValue(url),
|
||||||
|
() => {
|
||||||
|
if (status.value !== 'closed') {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 组件卸载时清理资源
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化:如果不是手动模式,则自动连接
|
||||||
|
if (autoConnect) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
latestData,
|
||||||
|
send,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider {
|
||||||
|
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoText {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.3s;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { computed, defineComponent, ref } from 'vue';
|
||||||
|
import { useRouter, useRoute, RouterView } from 'vue-router';
|
||||||
|
import { Layout, Menu } from 'ant-design-vue';
|
||||||
|
import type { MenuProps } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import styles from './BasicLayout.module.less';
|
||||||
|
|
||||||
|
const { Header, Sider, Content, Footer } = Layout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础布局:左侧导航 + 顶部栏 + 内容区 + 页脚
|
||||||
|
*/
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'BasicLayout',
|
||||||
|
setup() {
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const collapsed = ref(false);
|
||||||
|
|
||||||
|
const selectedKeys = computed<string[]>(() => [route.path]);
|
||||||
|
|
||||||
|
const menuItems: MenuProps['items'] = [
|
||||||
|
{ key: '/dashboard', icon: () => <DashboardOutlined />, label: '工作台' },
|
||||||
|
{ key: '/about', icon: () => <InfoCircleOutlined />, label: '关于' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||||
|
router.push(key as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
collapsed.value = !collapsed.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<Layout class={styles.container}>
|
||||||
|
<Sider class={styles.sider} collapsed={collapsed.value} trigger={null} collapsible>
|
||||||
|
<div class={styles.logo}>
|
||||||
|
<span class={styles.logoText}>{collapsed.value ? 'CPMS' : 'CPMS 运营平台'}</span>
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={selectedKeys.value}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
<Header class={styles.header}>
|
||||||
|
<span class={styles.trigger} onClick={toggleCollapsed}>
|
||||||
|
{collapsed.value ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
</span>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Content class={styles.content}>
|
||||||
|
<RouterView />
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
<Footer class={styles.footer}>CPMS 运营平台 ©{new Date().getFullYear()}</Footer>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
import Antd from 'ant-design-vue';
|
||||||
|
import 'ant-design-vue/dist/reset.css';
|
||||||
|
|
||||||
|
import App from './App';
|
||||||
|
import router from './router';
|
||||||
|
import './assets/styles/index.less';
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
app.use(router);
|
||||||
|
app.use(Antd);
|
||||||
|
|
||||||
|
app.mount('#app');
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 400px;
|
||||||
|
max-width: 90vw;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
|
:global(.ant-card-head-title) {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { defineComponent, reactive, ref } from 'vue';
|
||||||
|
import { Button, Card, Form, FormItem, Input, message } from 'ant-design-vue';
|
||||||
|
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { auth } from '@/hooks/useAuth';
|
||||||
|
import styles from './index.module.less';
|
||||||
|
|
||||||
|
interface LoginForm {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录页
|
||||||
|
*/
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'LoginPage',
|
||||||
|
setup() {
|
||||||
|
const router = useRouter();
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const form = reactive<LoginForm>({
|
||||||
|
username: 'admin',
|
||||||
|
password: '123456',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
username: [{ required: true, message: '请输入用户名' }],
|
||||||
|
password: [{ required: true, message: '请输入密码' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.username || !form.password) {
|
||||||
|
message.warning('请输入用户名和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
// TODO: 替换为真实登录接口
|
||||||
|
// const res = await post<LoginResult>('/login', { ...form });
|
||||||
|
// auth.login(res.token);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
auth.login('mock_token_' + Date.now());
|
||||||
|
message.success('登录成功');
|
||||||
|
loading.value = false;
|
||||||
|
router.push('/dashboard');
|
||||||
|
}, 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class={styles.container}>
|
||||||
|
<Card class={styles.card} title="CPMS 运营平台">
|
||||||
|
<Form model={form} rules={rules} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
<FormItem name="username">
|
||||||
|
<Input
|
||||||
|
v-model={[form.username, 'value']}
|
||||||
|
placeholder="用户名"
|
||||||
|
size="large"
|
||||||
|
v-slots={{ prefix: () => <UserOutlined /> }}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem name="password">
|
||||||
|
<Input
|
||||||
|
v-model={[form.password, 'value']}
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
size="large"
|
||||||
|
v-slots={{ prefix: () => <LockOutlined /> }}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem>
|
||||||
|
<Button type="primary" html-type="submit" size="large" block loading={loading.value}>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</FormItem>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { routes } from './routes';
|
||||||
|
import { auth } from '@/hooks/useAuth';
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.VITE_BASE_URL || '/'),
|
||||||
|
routes,
|
||||||
|
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 路由守卫:登录鉴权 + 页面标题
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
const isLoggedIn = auth.isLoggedIn();
|
||||||
|
|
||||||
|
// 设置页面标题
|
||||||
|
const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台';
|
||||||
|
document.title = to.meta.title ? `${to.meta.title} - ${appTitle}` : appTitle;
|
||||||
|
|
||||||
|
// 如果访问的是登录页
|
||||||
|
if (to.path === '/login') {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
// 已登录则重定向到首页
|
||||||
|
next('/dashboard');
|
||||||
|
} else {
|
||||||
|
// 未登录则允许访问登录页
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 访问其他页面
|
||||||
|
if (isLoggedIn) {
|
||||||
|
// 已登录则允许访问
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
// 未登录则重定向到登录页
|
||||||
|
next('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
import { createRoute } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ====== 业务路由模块 ======
|
||||||
|
* 实际项目中按模块拆分,如 eventsRoutes、walletRoutes 等,
|
||||||
|
* 然后在下方 routes 数组的布局 children 中引入。
|
||||||
|
* 参考示例(嵌套路由):
|
||||||
|
*
|
||||||
|
* const eventsRoutes = {
|
||||||
|
* path: '/events',
|
||||||
|
* name: 'EventsModule',
|
||||||
|
* redirect: '/events/list',
|
||||||
|
* meta: { title: '我的赛事', activeMenu: '/events' },
|
||||||
|
* children: [
|
||||||
|
* createRoute('list', () => import('@/pages/events/list'), { title: '赛事列表' }),
|
||||||
|
* {
|
||||||
|
* path: 'bracket',
|
||||||
|
* name: 'BracketPage',
|
||||||
|
* component: () => import('@/pages/events/bracket'),
|
||||||
|
* meta: { title: '赛事详情' },
|
||||||
|
* children: [
|
||||||
|
* createRoute('player-management', () => import('@/pages/events/bracket/player-management'), { title: '选手管理' }),
|
||||||
|
* createRoute('match-result', () => import('@/pages/events/bracket/match-result'), { title: '比赛结果' }),
|
||||||
|
* ],
|
||||||
|
* },
|
||||||
|
* ],
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const routes: RouteRecordRaw[] = [
|
||||||
|
// ── 登录页(不经过布局)──
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('@/pages/login'),
|
||||||
|
meta: { title: '登录' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 主布局(需要登录)──
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/layouts/BasicLayout'),
|
||||||
|
children: [
|
||||||
|
// 业务模块路由在此添加,例如:eventsRoutes, walletRoutes
|
||||||
|
|
||||||
|
createRoute('/dashboard', () => import('@/views/Dashboard'), {
|
||||||
|
title: '工作台',
|
||||||
|
icon: 'DashboardOutlined',
|
||||||
|
}),
|
||||||
|
createRoute('/about', () => import('@/views/About'), {
|
||||||
|
title: '关于',
|
||||||
|
icon: 'InfoCircleOutlined',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 默认重定向
|
||||||
|
{ path: '/', redirect: '/dashboard' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 404 ──
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'NotFound',
|
||||||
|
component: () => import('@/views/NotFound'),
|
||||||
|
meta: { title: '页面不存在' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
|
||||||
|
/** 路由 meta 类型 */
|
||||||
|
export type AppMeta = {
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
hideInMenu?: boolean;
|
||||||
|
activeMenu?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速创建路由记录
|
||||||
|
* 根据路径自动生成 PascalCase 路由 name
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* createRoute('list', () => import('@/pages/home'), { title: '赛事列表' })
|
||||||
|
* // => { path: 'list', name: 'List', meta: {...}, component: ... }
|
||||||
|
*/
|
||||||
|
export function createRoute(
|
||||||
|
path: string,
|
||||||
|
component: RouteRecordRaw['component'],
|
||||||
|
meta: AppMeta,
|
||||||
|
children: RouteRecordRaw[] = [],
|
||||||
|
): RouteRecordRaw {
|
||||||
|
const cleanPath = path.replace(/[:/]/g, '-');
|
||||||
|
|
||||||
|
const name = cleanPath
|
||||||
|
.split('-')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
name: name || undefined,
|
||||||
|
meta,
|
||||||
|
component,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* 全局类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 分页查询参数 */
|
||||||
|
export interface PageParams {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页返回结果 */
|
||||||
|
export interface PageResult<T> {
|
||||||
|
list: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用下拉选项 */
|
||||||
|
export interface SelectOption {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 路由 meta 扩展(与 router/utils.ts 的 AppMeta 保持一致) */
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
title?: string;
|
||||||
|
icon?: string;
|
||||||
|
/** 是否需要登录 */
|
||||||
|
requiresAuth?: boolean;
|
||||||
|
/** 是否在菜单中隐藏 */
|
||||||
|
hideInMenu?: boolean;
|
||||||
|
/** 旧字段,等价于 hideInMenu */
|
||||||
|
hidden?: boolean;
|
||||||
|
/** 高亮的菜单路径(用于详情页高亮列表项) */
|
||||||
|
activeMenu?: string;
|
||||||
|
/** 权限标识 */
|
||||||
|
permission?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
/**
|
||||||
|
* 通用工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一 ID
|
||||||
|
*/
|
||||||
|
export function uniqueId(prefix = ''): string {
|
||||||
|
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||||
|
return prefix ? `${prefix}_${id}` : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简易深拷贝(基于 JSON,适用于纯数据对象)
|
||||||
|
*/
|
||||||
|
export function deepClone<T>(value: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防抖
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: any[]) => any>(fn: T, wait = 300) {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const debounced = (...args: Parameters<T>) => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => fn(...args), wait);
|
||||||
|
};
|
||||||
|
debounced.cancel = () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
};
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节流
|
||||||
|
*/
|
||||||
|
export function throttle<T extends (...args: any[]) => any>(fn: T, wait = 300) {
|
||||||
|
let lastTime = 0;
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTime >= wait) {
|
||||||
|
lastTime = now;
|
||||||
|
fn(...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期
|
||||||
|
*/
|
||||||
|
export function formatDate(date: Date | string | number, format = 'YYYY-MM-DD HH:mm:ss'): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
YYYY: String(d.getFullYear()),
|
||||||
|
MM: pad(d.getMonth() + 1),
|
||||||
|
DD: pad(d.getDate()),
|
||||||
|
HH: pad(d.getHours()),
|
||||||
|
mm: pad(d.getMinutes()),
|
||||||
|
ss: pad(d.getSeconds()),
|
||||||
|
};
|
||||||
|
|
||||||
|
return format.replace(/YYYY|MM|DD|HH|mm|ss/g, (match) => map[match]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 树形数据扁平化
|
||||||
|
*/
|
||||||
|
export interface TreeNode {
|
||||||
|
id: string | number;
|
||||||
|
children?: TreeNode[];
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenTree<T extends TreeNode>(tree: T[]): T[] {
|
||||||
|
const result: T[] = [];
|
||||||
|
const stack = [...tree].reverse();
|
||||||
|
while (stack.length) {
|
||||||
|
const node = stack.pop() as T;
|
||||||
|
result.push(node);
|
||||||
|
if (node.children?.length) {
|
||||||
|
stack.push(...(node.children as T[]).reverse());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { auth } from '@/hooks/';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
const baseUrl = import.meta.env?.VITE_API_BASE_URL || '';
|
||||||
|
const DEFAULT_TIMEOUT = 10000;
|
||||||
|
|
||||||
|
const activeControllers = new Set<AbortController>();
|
||||||
|
|
||||||
|
/** 统一处理 401 未授权逻辑 */
|
||||||
|
const handleUnauthorized = (msg?: string) => {
|
||||||
|
auth.logout();
|
||||||
|
message.error(msg || '登录状态已过期,请重新登录');
|
||||||
|
window.location.href = '/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = (
|
||||||
|
url: string,
|
||||||
|
options: RequestInit & {
|
||||||
|
query?: Record<string, any>;
|
||||||
|
responseType?: 'json' | 'blob';
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
let finalUrl = baseUrl + url;
|
||||||
|
|
||||||
|
if (options.query) {
|
||||||
|
finalUrl += '?' + new URLSearchParams(options.query).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = auth.token.value;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (!(options.body instanceof FormData)) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先使用外部传入的 signal(来自 useRequest 取消机制);
|
||||||
|
// 无外部 signal 时自建 controller 用于超时取消
|
||||||
|
const externalSignal = (options as RequestInit & { signal?: AbortSignal }).signal;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
activeControllers.add(controller);
|
||||||
|
|
||||||
|
// 合并外部 signal 与内部 controller:任一 abort 都会取消请求
|
||||||
|
const onExternalAbort = () => controller.abort();
|
||||||
|
if (externalSignal) {
|
||||||
|
if (externalSignal.aborted) {
|
||||||
|
controller.abort();
|
||||||
|
} else {
|
||||||
|
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const init: RequestInit = {
|
||||||
|
...options,
|
||||||
|
headers: { ...headers, ...(options.headers as Record<string, string>) },
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
controller.abort(); // 超时触发 abort
|
||||||
|
}, DEFAULT_TIMEOUT);
|
||||||
|
|
||||||
|
const cleanupSignal = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (externalSignal) externalSignal.removeEventListener('abort', onExternalAbort);
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(finalUrl, init)
|
||||||
|
.then(async (res) => {
|
||||||
|
cleanupSignal();
|
||||||
|
activeControllers.delete(controller);
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
handleUnauthorized();
|
||||||
|
return Promise.reject(new Error('Unauthorized'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 500) {
|
||||||
|
message.error('服务器开小差了,请稍后再试');
|
||||||
|
const errorData = await res.json().catch(() => ({}));
|
||||||
|
return Promise.reject(new Error(errorData.message || '服务器内部错误'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({}));
|
||||||
|
message.error(errorData.message || '请求失败');
|
||||||
|
return Promise.reject(new Error(errorData.message || '请求失败'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.responseType === 'blob') {
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.code === 401) {
|
||||||
|
handleUnauthorized(data.msg);
|
||||||
|
return Promise.reject(new Error(data.msg || 'Unauthorized'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
cleanupSignal();
|
||||||
|
|
||||||
|
activeControllers.delete(controller);
|
||||||
|
|
||||||
|
if (err.name === 'AbortError' || err.message === 'The user aborted a request.') {
|
||||||
|
// 使用特殊标记对象区分“请求被取消”和“请求成功但返回 null”
|
||||||
|
return Promise.resolve({ __aborted: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.message === 'Failed to fetch') {
|
||||||
|
message.error('网络连接失败,请检查网络');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cancelAllRequests = () => {
|
||||||
|
activeControllers.forEach((controller) => {
|
||||||
|
controller.abort();
|
||||||
|
});
|
||||||
|
activeControllers.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
export { request };
|
||||||
|
|
||||||
|
export const get = (url: string, query?: Record<string, any>, options?: { signal?: AbortSignal }) =>
|
||||||
|
request(url, { method: 'GET', query, signal: options?.signal });
|
||||||
|
|
||||||
|
export const post = (
|
||||||
|
url: string,
|
||||||
|
body?: any,
|
||||||
|
query?: Record<string, any>,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
) =>
|
||||||
|
request(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
query,
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const put = (url: string, body?: any, query?: Record<string, any>) =>
|
||||||
|
request(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const del = (url: string, query?: Record<string, any>) =>
|
||||||
|
request(url, { method: 'DELETE', query });
|
||||||
|
|
||||||
|
export default {
|
||||||
|
get,
|
||||||
|
post,
|
||||||
|
put,
|
||||||
|
delete: del,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
.container {
|
||||||
|
// 容器
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import { Card } from 'ant-design-vue';
|
||||||
|
import styles from './About.module.less';
|
||||||
|
|
||||||
|
interface TechItem {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关于页面
|
||||||
|
*/
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'About',
|
||||||
|
setup() {
|
||||||
|
const techStack: TechItem[] = [
|
||||||
|
{ name: 'Vue', version: '^3.3.0' },
|
||||||
|
{ name: 'Vite', version: '^5.0.0' },
|
||||||
|
{ name: 'TypeScript', version: '^5.3.0' },
|
||||||
|
{ name: 'Ant Design Vue', version: '^4.0.0' },
|
||||||
|
{ name: 'Vue Router', version: '^4.2.0' },
|
||||||
|
{ name: 'Less', version: '^4.6.4' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class={styles.container}>
|
||||||
|
<h2 class={styles.title}>关于</h2>
|
||||||
|
<Card>
|
||||||
|
<p>CPMS 运营平台前端 PC 端。</p>
|
||||||
|
<h3 class={styles.subtitle}>技术栈</h3>
|
||||||
|
<ul class={styles.list}>
|
||||||
|
{techStack.map((item) => (
|
||||||
|
<li key={item.name}>
|
||||||
|
{item.name} — {item.version}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
.container {
|
||||||
|
// 容器
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
margin-top: 16px;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import { Card, Col, Row, Statistic } from 'ant-design-vue';
|
||||||
|
import styles from './Dashboard.module.less';
|
||||||
|
|
||||||
|
interface StatItem {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
suffix: string;
|
||||||
|
precision?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作台 / 仪表盘
|
||||||
|
*/
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'Dashboard',
|
||||||
|
setup() {
|
||||||
|
const stats: StatItem[] = [
|
||||||
|
{ title: '今日访问', value: 1280, suffix: '次' },
|
||||||
|
{ title: '活跃用户', value: 368, suffix: '人' },
|
||||||
|
{ title: '订单总数', value: 9920, suffix: '单' },
|
||||||
|
{ title: '处理率', value: 96.8, suffix: '%', precision: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class={styles.container}>
|
||||||
|
<h2 class={styles.title}>工作台</h2>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{stats.map((item) => (
|
||||||
|
<Col key={item.title} xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={item.title}
|
||||||
|
value={item.value}
|
||||||
|
suffix={item.suffix}
|
||||||
|
precision={item.precision}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card class={styles.panel} title="项目说明">
|
||||||
|
<p>
|
||||||
|
欢迎使用 CPMS 运营平台。这是一个基于 Vue 3 + Vite + TypeScript + Ant Design Vue
|
||||||
|
搭建的中后台管理项目骨架,采用 TSX 写法,你可以在此基础上继续开发业务功能。
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
开发命令:<code>npm run dev</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
构建(测试环境):<code>npm run build:test</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
构建(生产环境):<code>npm run build:prod</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
代码检查:<code>npm run lint</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { Button, Result } from 'ant-design-vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 404 页面
|
||||||
|
*/
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'NotFound',
|
||||||
|
setup() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const goHome = () => router.push('/');
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<Result
|
||||||
|
status="404"
|
||||||
|
title="404"
|
||||||
|
subTitle="抱歉,你访问的页面不存在。"
|
||||||
|
style={{ paddingTop: '120px' }}
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
extra: () => (
|
||||||
|
<Button type="primary" onClick={goHome}>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
</Result>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
function loadEnv(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return {};
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const env = {};
|
||||||
|
content.split('\n').forEach((line) => {
|
||||||
|
const match = line.match(/^([^=:#]+?)=(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
env[match[1].trim()] = match[2].trim().replace(/^['"](.*)['"]$/, '$1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getComponentLibs(env) {
|
||||||
|
const libs = [];
|
||||||
|
|
||||||
|
libs.push({
|
||||||
|
name: 'cpms_web_component (默认)',
|
||||||
|
path: path.resolve(__dirname, '../cpms_web_component'),
|
||||||
|
useKey: null,
|
||||||
|
usePath: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const key in env) {
|
||||||
|
if (key.startsWith('COMPONENT_LIB_') && key !== 'COMPONENT_LIB_PATH') {
|
||||||
|
libs.push({
|
||||||
|
name: key.replace('COMPONENT_LIB_', ''),
|
||||||
|
path: path.resolve(__dirname, env[key]),
|
||||||
|
useKey: key.replace('COMPONENT_LIB_', ''),
|
||||||
|
usePath: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.COMPONENT_LIB_PATH) {
|
||||||
|
libs.push({
|
||||||
|
name: '自定义路径',
|
||||||
|
path: path.resolve(__dirname, env.COMPONENT_LIB_PATH),
|
||||||
|
useKey: null,
|
||||||
|
usePath: env.COMPONENT_LIB_PATH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return libs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectLib(libs) {
|
||||||
|
if (libs.length === 1) return libs[0];
|
||||||
|
|
||||||
|
console.log('📦 请选择要使用的组件库:');
|
||||||
|
libs.forEach((lib, i) => {
|
||||||
|
const exists = fs.existsSync(lib.path) ? '✅' : '❌';
|
||||||
|
console.log(` ${i + 1}. ${lib.name} ${exists}`);
|
||||||
|
console.log(` ${lib.path}`);
|
||||||
|
});
|
||||||
|
console.log(' 0. 使用 npm 版本');
|
||||||
|
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(`\n请输入选项 (0-${libs.length},回车默认): `, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
if (answer === '0') resolve(null);
|
||||||
|
else if (answer && /^\d+$/.test(answer)) {
|
||||||
|
const idx = parseInt(answer) - 1;
|
||||||
|
resolve(idx >= 0 && idx < libs.length ? libs[idx] : libs[0]);
|
||||||
|
} else {
|
||||||
|
resolve(libs[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const localEnv = loadEnv(path.resolve(__dirname, '.env.local'));
|
||||||
|
const libs = getComponentLibs(localEnv);
|
||||||
|
|
||||||
|
console.log('🚀 启动本地调试模式...\n');
|
||||||
|
const selected = await selectLib(libs);
|
||||||
|
|
||||||
|
const mergedEnv = { ...process.env, ...localEnv, LOCAL_DEBUG: 'true' };
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
console.log('\n📦 使用 npm 版本');
|
||||||
|
delete mergedEnv.COMPONENT_LIB;
|
||||||
|
delete mergedEnv.COMPONENT_LIB_PATH;
|
||||||
|
} else {
|
||||||
|
const exists = fs.existsSync(selected.path);
|
||||||
|
if (!exists) {
|
||||||
|
console.warn(`\n⚠️ 组件库不存在: ${selected.name}`);
|
||||||
|
console.warn(` ${selected.path}`);
|
||||||
|
console.warn(' 将使用 npm 版本');
|
||||||
|
delete mergedEnv.COMPONENT_LIB;
|
||||||
|
delete mergedEnv.COMPONENT_LIB_PATH;
|
||||||
|
} else {
|
||||||
|
console.log(`\n📦 ${selected.name}`);
|
||||||
|
console.log(`📍 ${selected.path}`);
|
||||||
|
if (selected.useKey) {
|
||||||
|
mergedEnv.COMPONENT_LIB = selected.useKey;
|
||||||
|
delete mergedEnv.COMPONENT_LIB_PATH;
|
||||||
|
} else if (selected.usePath) {
|
||||||
|
mergedEnv.COMPONENT_LIB_PATH = selected.usePath;
|
||||||
|
delete mergedEnv.COMPONENT_LIB;
|
||||||
|
} else {
|
||||||
|
delete mergedEnv.COMPONENT_LIB;
|
||||||
|
delete mergedEnv.COMPONENT_LIB_PATH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n⏳ 启动 Vite...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync('npx vite --port 3000 --open --strictPort false', {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: mergedEnv,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 启动失败:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"jsxImportSource": "vue",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"types": ["node", "vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules", "dist", "src/router copy"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import vueJsx from '@vitejs/plugin-vue-jsx';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
base: env.VITE_BASE_URL || '/',
|
||||||
|
plugins: [vue(), vueJsx()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
modules: {
|
||||||
|
localsConvention: 'camelCaseOnly',
|
||||||
|
},
|
||||||
|
preprocessorOptions: {
|
||||||
|
less: {
|
||||||
|
javascriptEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: Number(env.VITE_PORT) || 5173,
|
||||||
|
open: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: env.VITE_API_BASE_URL || 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: mode !== 'production',
|
||||||
|
chunkSizeWarningLimit: 1500,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
vue: ['vue', 'vue-router'],
|
||||||
|
antd: ['ant-design-vue'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue