feat: Architecture initialization

This commit is contained in:
Disciple_C 2026-07-14 10:31:17 +08:00
commit 6f37264047
59 changed files with 8752 additions and 0 deletions

15
.editorconfig Normal file
View File

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

3
.env Normal file
View File

@ -0,0 +1,3 @@
# 公共环境变量 (所有模式共享)
VITE_APP_TITLE = CPMS 运营平台
VITE_BASE_URL = /

4
.env.development Normal file
View File

@ -0,0 +1,4 @@
# 开发环境
VITE_PORT = 5173
VITE_API_BASE_URL = http://localhost:3000
VITE_API_PREFIX = /api

3
.env.production Normal file
View File

@ -0,0 +1,3 @@
# 生产环境
VITE_API_BASE_URL = https://api.cpms.example.com
VITE_API_PREFIX = /api

3
.env.test Normal file
View File

@ -0,0 +1,3 @@
# 测试环境
VITE_API_BASE_URL = https://test-api.cpms.example.com
VITE_API_PREFIX = /api

6
.eslintignore Normal file
View File

@ -0,0 +1,6 @@
dist
node_modules
public
*.config.js
*.config.ts
start-debug.js

45
.eslintrc.cjs Normal file
View File

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

42
.gitignore vendored Normal file
View File

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

4
.husky/commit-msg Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit "$1"

4
.husky/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

9
.prettierignore Normal file
View File

@ -0,0 +1,9 @@
dist
node_modules
public
*.svg
*.ico
CHANGELOG.md
pnpm-lock.yaml
package-lock.json
yarn.lock

14
.prettierrc Normal file
View File

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

10
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"EditorConfig.EditorConfig",
"antfu.unocss",
"lokalise.i18n-ally"
]
}

24
.vscode/settings.json vendored Normal file
View File

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

142
README.md Normal file
View File

@ -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 | 接口路径前缀 |

28
commitlint.config.js Normal file
View File

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

14
index.html Normal file
View File

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

5999
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

64
package.json Normal file
View File

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

4
public/favicon.svg Normal file
View File

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

25
src/App.tsx Normal file
View File

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

29
src/api/index.ts Normal file
View File

@ -0,0 +1,29 @@
/**
*
* 实际项目中按业务域拆分: api/user.tsapi/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);
}

View File

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

View File

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

View File

@ -0,0 +1,16 @@
.container {
text-align: center;
padding: 16px;
}
.title {
color: #1677ff;
}
.desc {
code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 4px;
}
}

View File

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

5
src/components/index.ts Normal file
View File

@ -0,0 +1,5 @@
/**
*
* `import { HelloWorld } from '@/components'` 使
*/
export { default as HelloWorld } from './HelloWorld';

13
src/env.d.ts vendored Normal file
View File

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

10
src/hooks/index.ts Normal file
View File

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

34
src/hooks/useAuth.ts Normal file
View File

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

View File

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

View File

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

137
src/hooks/useContext.ts Normal file
View File

@ -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
* ArrayDateRegExpMapSet
*/
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;
},
};
}
/**
*
* DateRegExp
*/
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;
}

80
src/hooks/useDebounce.ts Normal file
View File

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

32
src/hooks/useEffect.ts Normal file
View File

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

185
src/hooks/usePagination.ts Normal file
View File

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

170
src/hooks/useRequest.ts Normal file
View File

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

17
src/hooks/useState.ts Normal file
View File

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

245
src/hooks/useWebSocket.ts Normal file
View File

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

View File

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

View File

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

14
src/main.ts Normal file
View File

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

View File

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

83
src/pages/login/index.tsx Normal file
View File

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

40
src/router/index.ts Normal file
View File

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

68
src/router/routes.ts Normal file
View File

@ -0,0 +1,68 @@
import type { RouteRecordRaw } from 'vue-router';
import { createRoute } from './utils';
/**
* ====== ======
* , eventsRouteswalletRoutes ,
* 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: '页面不存在' },
},
];

40
src/router/utils.ts Normal file
View File

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

42
src/types/index.ts Normal file
View File

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

90
src/utils/index.ts Normal file
View File

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

164
src/utils/request.ts Normal file
View File

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

View File

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

42
src/views/About.tsx Normal file
View File

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

View File

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

67
src/views/Dashboard.tsx Normal file
View File

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

32
src/views/NotFound.tsx Normal file
View File

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

131
start-debug.js Normal file
View File

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

27
tsconfig.json Normal file
View File

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

12
tsconfig.node.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

54
vite.config.ts Normal file
View File

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