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