awake
This commit is contained in:
6
.env.development
Normal file
6
.env.development
Normal file
@ -0,0 +1,6 @@
|
||||
ENV = 'production'
|
||||
VUE_APP_MODE = 'production'
|
||||
VITE_BASE_URL = 'https://wly-demo.wlyun.co'
|
||||
VITE_ENVURL='https://wlyun-public.oss-cn-hangzhou.aliyuncs.com/shuangxi/hdr/'
|
||||
VITE_MODELURL='https://wlyun-public.oss-cn-hangzhou.aliyuncs.com/shuangxi/model/'
|
||||
VITE_PUBLIC_URL=/
|
6
.env.production
Normal file
6
.env.production
Normal file
@ -0,0 +1,6 @@
|
||||
ENV = 'production'
|
||||
VUE_APP_MODE = 'production'
|
||||
VITE_BASE_URL = 'https://wly-demo.wlyun.co'
|
||||
VITE_ENVURL='https://wlyun-public.oss-cn-hangzhou.aliyuncs.com/shuangxi/hdr/'
|
||||
VITE_MODELURL='https://wlyun-public.oss-cn-hangzhou.aliyuncs.com/shuangxi/model/'
|
||||
VITE_PUBLIC_URL=./
|
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
156
README.md
Normal file
156
README.md
Normal file
@ -0,0 +1,156 @@
|
||||
# Vite + Vue 3 + TypeScript 脚手架
|
||||
|
||||
一个基于 Vite 构建的现代化 Vue 3 + TypeScript 前端脚手架,集成了 Pinia 状态管理、Vue Router 动态路由、持久化存储工具和环境变量配置。
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- ⚡ **Vite 4** - 极速的开发体验
|
||||
- 🎯 **Vue 3** - 组合式 API,性能更强
|
||||
- 🔷 **TypeScript** - 类型安全
|
||||
- 🍍 **Pinia** - 现代化状态管理
|
||||
- 🛣️ **Vue Router 4** - 路由管理与权限控制
|
||||
- 💾 **持久化存储** - localStorage/sessionStorage 封装
|
||||
- 🎨 **主题切换** - 明亮/暗黑主题支持
|
||||
- 📱 **响应式设计** - 移动端适配
|
||||
- 🔧 **环境变量配置** - 开发/生产环境分离
|
||||
- 📦 **自动代码分割** - 优化打包体积
|
||||
|
||||
## 🏗️ 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── assets/ # 静态资源
|
||||
├── components/ # 公共组件
|
||||
├── config/ # 配置文件
|
||||
│ └── env.ts # 环境变量配置
|
||||
├── router/ # 路由配置
|
||||
│ └── index.ts # 路由定义与权限控制
|
||||
├── stores/ # 状态管理
|
||||
│ ├── index.ts # Pinia 主文件
|
||||
│ └── modules/ # 状态模块
|
||||
│ ├── app.ts # 应用状态
|
||||
│ └── user.ts # 用户状态
|
||||
├── utils/ # 工具函数
|
||||
│ └── storage.ts # 存储工具
|
||||
├── views/ # 页面组件
|
||||
│ ├── Home.vue # 首页
|
||||
│ ├── About.vue # 关于页面
|
||||
│ ├── Login.vue # 登录页面
|
||||
│ ├── Dashboard.vue # 仪表板
|
||||
│ ├── Profile.vue # 个人中心
|
||||
│ ├── Admin.vue # 管理页面
|
||||
│ └── 404.vue # 404 页面
|
||||
├── App.vue # 根组件
|
||||
└── main.ts # 入口文件
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 生产构建
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 预览构建结果
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 🔧 主要功能
|
||||
|
||||
### 状态管理 (Pinia)
|
||||
|
||||
- **用户状态管理**: 登录、用户信息、权限控制
|
||||
- **应用状态管理**: 主题、语言、应用配置
|
||||
- **持久化支持**: 自动同步到本地存储
|
||||
|
||||
### 路由管理 (Vue Router)
|
||||
|
||||
- **路由守卫**: 登录验证、权限检查
|
||||
- **动态路由**: 根据用户权限动态加载
|
||||
- **页面缓存**: 支持 keep-alive
|
||||
|
||||
### 持久化存储
|
||||
|
||||
```typescript
|
||||
import { storage } from '@/utils/storage'
|
||||
|
||||
// 本地存储
|
||||
storage.local.set('key', value, 3600) // 设置过期时间(秒)
|
||||
storage.local.get('key')
|
||||
storage.local.remove('key')
|
||||
|
||||
// 会话存储
|
||||
storage.session.set('key', value)
|
||||
storage.session.get('key')
|
||||
```
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
在 `src/config/env.ts` 中配置环境变量:
|
||||
|
||||
```typescript
|
||||
export const envConfig = {
|
||||
VITE_APP_TITLE: 'Vite应用',
|
||||
VITE_APP_PORT: 8080,
|
||||
VITE_APP_BASE_API: '/api',
|
||||
VITE_APP_ENV: 'development'
|
||||
}
|
||||
```
|
||||
|
||||
### 主题切换
|
||||
|
||||
```typescript
|
||||
import { useAppStore } from '@/stores/modules/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
appStore.toggleTheme() // 切换主题
|
||||
appStore.setTheme('dark') // 设置暗色主题
|
||||
```
|
||||
|
||||
## 📝 开发指南
|
||||
|
||||
### 添加新页面
|
||||
|
||||
1. 在 `src/views/` 下创建页面组件
|
||||
2. 在 `src/router/index.ts` 中添加路由配置
|
||||
3. 如需权限控制,设置 `meta.requireAuth: true`
|
||||
|
||||
### 添加新的状态模块
|
||||
|
||||
1. 在 `src/stores/modules/` 下创建新模块
|
||||
2. 在 `src/stores/index.ts` 中导出模块
|
||||
|
||||
### 自定义样式
|
||||
|
||||
- 全局样式在 `src/App.vue` 中定义
|
||||
- 支持 CSS 变量进行主题切换
|
||||
- 响应式断点已预设
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **前端框架**: Vue 3
|
||||
- **构建工具**: Vite 4
|
||||
- **编程语言**: TypeScript
|
||||
- **状态管理**: Pinia
|
||||
- **路由管理**: Vue Router 4
|
||||
- **HTTP 客户端**: Axios
|
||||
- **样式**: CSS3 + CSS Variables
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
4561
package-lock.json
generated
Normal file
4561
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.10.0",
|
||||
"element-plus": "^2.10.3",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.12",
|
||||
"@vitejs/plugin-legacy": "^6.1.1",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"rollup-plugin-visualizer": "^6.0.3",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
}
|
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
70
src/App.vue
Normal file
70
src/App.vue
Normal file
@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
// 应用入口组件
|
||||
import NavHeader from './components/NavHeader.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<NavHeader />
|
||||
<el-container>
|
||||
<!-- 导航头部 -->
|
||||
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<el-main class="main-content">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 全局样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #2d3748;
|
||||
background-color: #ffffff;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.el-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 暗色主题 */
|
||||
[data-theme="dark"] body {
|
||||
color: #e2e8f0;
|
||||
background-color: #1a202c;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .main-content {
|
||||
background-color: #1a202c;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
288
src/api/README.md
Normal file
288
src/api/README.md
Normal file
@ -0,0 +1,288 @@
|
||||
# API 架构重构说明
|
||||
|
||||
## 概述
|
||||
|
||||
本次重构将原有的网络请求代码进行了全面的重新设计和组织,提供了更加模块化、类型安全和易于扩展的API架构。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/api/
|
||||
├── types/ # 类型定义
|
||||
│ └── index.ts
|
||||
├── config/ # 配置文件
|
||||
│ └── index.ts
|
||||
├── utils/ # 工具函数
|
||||
│ └── index.ts
|
||||
├── core/ # 核心模块
|
||||
│ ├── request.ts # HTTP请求客户端
|
||||
│ ├── upload.ts # 文件上传模块
|
||||
│ └── download.ts # 文件下载模块
|
||||
├── modules/ # 业务API模块
|
||||
│ ├── editor.ts # 编辑器相关API
|
||||
│ └── resource.ts # 资源管理API
|
||||
├── examples/ # 使用示例
|
||||
│ └── usage.ts
|
||||
├── index.ts # 统一入口
|
||||
└── README.md # 说明文档
|
||||
```
|
||||
|
||||
## 主要特性
|
||||
|
||||
### 1. 类型安全
|
||||
- 完整的TypeScript类型定义
|
||||
- 泛型支持,确保请求和响应数据的类型安全
|
||||
- 严格的类型检查
|
||||
|
||||
### 2. 模块化设计
|
||||
- 核心功能与业务逻辑分离
|
||||
- 可插拔的模块设计
|
||||
- 易于扩展和维护
|
||||
|
||||
### 3. 文件上传功能
|
||||
- 普通文件上传
|
||||
- 图片上传(支持压缩)
|
||||
- 大文件分片上传
|
||||
- 批量文件上传
|
||||
- Base64上传
|
||||
- 上传进度监控
|
||||
|
||||
### 4. 文件下载功能
|
||||
- 普通文件下载
|
||||
- 分片下载(大文件)
|
||||
- 批量下载
|
||||
- 下载为不同格式(Blob、Base64、ArrayBuffer、Text)
|
||||
- 下载进度监控
|
||||
|
||||
### 5. 错误处理
|
||||
- 统一的错误处理机制
|
||||
- 自动重试功能
|
||||
- 详细的错误信息
|
||||
|
||||
### 6. 请求管理
|
||||
- 请求拦截器
|
||||
- 响应拦截器
|
||||
- 请求取消功能
|
||||
- 认证token管理
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 基本使用
|
||||
|
||||
```typescript
|
||||
import { editorApi, resourceApi, ApiManager } from '@/api'
|
||||
|
||||
// 设置认证token
|
||||
ApiManager.setAuthToken('your-token')
|
||||
|
||||
// 创建订单
|
||||
const order = await editorApi.createOrder({
|
||||
projectId: 'project-123',
|
||||
type: 'render'
|
||||
})
|
||||
|
||||
// 上传资源
|
||||
const resource = await resourceApi.uploadResource(file, {
|
||||
type: 'model',
|
||||
tags: ['建筑', '现代']
|
||||
})
|
||||
```
|
||||
|
||||
### 文件上传
|
||||
|
||||
```typescript
|
||||
import { fileUploader } from '@/api'
|
||||
|
||||
// 普通文件上传
|
||||
const result = await fileUploader.uploadFile({
|
||||
file,
|
||||
onProgress: (progress) => {
|
||||
console.log(`上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
|
||||
// 图片上传(带压缩)
|
||||
const imageResult = await fileUploader.uploadImage(file, {
|
||||
compress: true,
|
||||
quality: 0.8,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080
|
||||
})
|
||||
|
||||
// 大文件分片上传
|
||||
const largeFileResult = await fileUploader.uploadLargeFile(file, {
|
||||
chunkSize: 2 * 1024 * 1024, // 2MB per chunk
|
||||
onProgress: (progress) => {
|
||||
console.log(`上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 文件下载
|
||||
|
||||
```typescript
|
||||
import { fileDownloader } from '@/api'
|
||||
|
||||
// 下载并保存文件
|
||||
await fileDownloader.downloadAndSave({
|
||||
url: 'https://example.com/file.zip',
|
||||
filename: 'download.zip'
|
||||
})
|
||||
|
||||
// 下载为Base64
|
||||
const base64 = await fileDownloader.downloadAsBase64({
|
||||
url: 'https://example.com/image.jpg'
|
||||
})
|
||||
|
||||
// 批量下载
|
||||
const result = await fileDownloader.downloadBatch([
|
||||
'https://example.com/file1.zip',
|
||||
'https://example.com/file2.zip'
|
||||
], {
|
||||
concurrent: 3,
|
||||
onProgress: (progress) => {
|
||||
console.log(`批量下载进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## API 模块
|
||||
|
||||
### 编辑器API (EditorApi)
|
||||
|
||||
提供编辑器相关的功能:
|
||||
|
||||
- `createOrder(params)` - 创建订单
|
||||
- `saveProject(projectData)` - 保存项目
|
||||
- `loadProject(projectId)` - 加载项目
|
||||
- `deleteProject(projectId)` - 删除项目
|
||||
- `getProjectList(params)` - 获取项目列表
|
||||
- `uploadThumbnail(file, projectId)` - 上传缩略图
|
||||
- `exportProject(projectId, format)` - 导出项目
|
||||
- `importProject(file)` - 导入项目
|
||||
|
||||
### 资源API (ResourceApi)
|
||||
|
||||
提供资源管理功能:
|
||||
|
||||
- `getResourceList(params)` - 获取资源列表
|
||||
- `getResourceDetail(resourceId)` - 获取资源详情
|
||||
- `uploadResource(file, metadata)` - 上传资源
|
||||
- `uploadMultipleResources(files, metadata)` - 批量上传资源
|
||||
- `deleteResource(resourceId)` - 删除资源
|
||||
- `downloadResource(resource)` - 下载资源
|
||||
- `searchResources(params)` - 搜索资源
|
||||
|
||||
## 配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# .env.development
|
||||
VITE_BASE_URL=http://localhost:3000/api
|
||||
|
||||
# .env.production
|
||||
VITE_BASE_URL=https://api.example.com
|
||||
```
|
||||
|
||||
### API配置
|
||||
|
||||
```typescript
|
||||
// src/api/config/index.ts
|
||||
export const API_CONFIG = {
|
||||
BASE_URL: import.meta.env.VITE_BASE_URL,
|
||||
TIMEOUT: 15000,
|
||||
RETRY_COUNT: 3,
|
||||
RETRY_DELAY: 1000,
|
||||
// ...其他配置
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
API会自动处理常见的错误情况:
|
||||
|
||||
- 网络连接失败
|
||||
- 请求超时
|
||||
- 认证失败
|
||||
- 权限不足
|
||||
- 服务器错误
|
||||
|
||||
所有API方法都返回统一的响应格式:
|
||||
|
||||
```typescript
|
||||
interface ApiResponse<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
success: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## 扩展性
|
||||
|
||||
### 添加新的API模块
|
||||
|
||||
1. 在 `src/api/modules/` 目录下创建新的模块文件
|
||||
2. 定义相关的类型接口
|
||||
3. 实现API类
|
||||
4. 在 `src/api/index.ts` 中导出
|
||||
|
||||
### 添加新的请求拦截器
|
||||
|
||||
```typescript
|
||||
import { httpClient } from '@/api'
|
||||
|
||||
// 添加请求拦截器
|
||||
httpClient.instance.interceptors.request.use(
|
||||
(config) => {
|
||||
// 自定义请求处理
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从旧API迁移
|
||||
|
||||
旧的API调用:
|
||||
```typescript
|
||||
// 旧方式
|
||||
const request = new Request()
|
||||
request.post(new ParamsReq('/api/endpoint', data))
|
||||
```
|
||||
|
||||
新的API调用:
|
||||
```typescript
|
||||
// 新方式
|
||||
import { httpClient } from '@/api'
|
||||
const response = await httpClient.post('/api/endpoint', data)
|
||||
```
|
||||
|
||||
### 兼容性
|
||||
|
||||
新的API架构与旧代码完全分离,可以逐步迁移,不会影响现有功能。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用TypeScript类型**:充分利用类型定义,确保代码的类型安全
|
||||
2. **错误处理**:始终处理API调用可能出现的错误
|
||||
3. **进度监控**:对于文件上传下载,使用进度回调提升用户体验
|
||||
4. **请求取消**:在组件卸载时取消未完成的请求
|
||||
5. **缓存策略**:对于不经常变化的数据,考虑实现缓存机制
|
||||
|
||||
## 性能优化
|
||||
|
||||
1. **并发控制**:批量操作时控制并发数量
|
||||
2. **分片上传**:大文件使用分片上传
|
||||
3. **压缩优化**:图片上传时自动压缩
|
||||
4. **请求去重**:避免重复请求
|
||||
5. **缓存机制**:合理使用缓存减少网络请求
|
||||
|
||||
## 示例代码
|
||||
|
||||
详细的使用示例请参考 `src/api/examples/usage.ts` 文件。
|
83
src/api/config/index.ts
Normal file
83
src/api/config/index.ts
Normal file
@ -0,0 +1,83 @@
|
||||
// API 基础配置
|
||||
export const API_CONFIG = {
|
||||
// 基础URL
|
||||
BASE_URL: import.meta.env.VITE_BASE_URL || 'https://script.jiaweijia.cn/script',
|
||||
|
||||
// 超时时间
|
||||
TIMEOUT: 15000,
|
||||
|
||||
// 重试次数
|
||||
RETRY_COUNT: 3,
|
||||
|
||||
// 重试延迟(毫秒)
|
||||
RETRY_DELAY: 1000,
|
||||
|
||||
// 默认请求头
|
||||
DEFAULT_HEADERS: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
|
||||
// 文件上传请求头
|
||||
UPLOAD_HEADERS: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
|
||||
// 响应状态码
|
||||
STATUS_CODES: {
|
||||
SUCCESS: 200,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
SERVER_ERROR: 500,
|
||||
},
|
||||
|
||||
// 业务状态码
|
||||
BUSINESS_CODES: {
|
||||
SUCCESS: 0,
|
||||
FAIL: -1,
|
||||
TOKEN_EXPIRED: 401,
|
||||
PERMISSION_DENIED: 403,
|
||||
}
|
||||
}
|
||||
|
||||
// 环境配置
|
||||
export const ENV_CONFIG = {
|
||||
isDev: import.meta.env.DEV,
|
||||
isProd: import.meta.env.PROD,
|
||||
mode: import.meta.env.MODE,
|
||||
}
|
||||
|
||||
// API 端点配置
|
||||
export const API_ENDPOINTS = {
|
||||
// 编辑器相关
|
||||
EDITOR: {
|
||||
CREATE_ORDER: '/cpq/3d/create/order',
|
||||
SAVE_PROJECT: '/editor/project/save',
|
||||
LOAD_PROJECT: '/editor/project/load',
|
||||
DELETE_PROJECT: '/editor/project/delete',
|
||||
},
|
||||
|
||||
// 文件相关
|
||||
FILE: {
|
||||
UPLOAD: '/file/upload',
|
||||
DOWNLOAD: '/file/download',
|
||||
DELETE: '/file/delete',
|
||||
LIST: '/file/list',
|
||||
},
|
||||
|
||||
// 用户相关
|
||||
USER: {
|
||||
LOGIN: '/user/login',
|
||||
LOGOUT: '/user/logout',
|
||||
INFO: '/user/info',
|
||||
UPDATE: '/user/update',
|
||||
},
|
||||
|
||||
// 资源相关
|
||||
RESOURCE: {
|
||||
LIST: '/resource/list',
|
||||
UPLOAD: '/resource/upload',
|
||||
DELETE: '/resource/delete',
|
||||
}
|
||||
}
|
377
src/api/core/download.ts
Normal file
377
src/api/core/download.ts
Normal file
@ -0,0 +1,377 @@
|
||||
import { httpClient } from './request'
|
||||
import { DownloadConfig, ApiResponse } from '../types'
|
||||
import { API_ENDPOINTS } from '../config'
|
||||
import { downloadFile, generateId } from '../utils'
|
||||
|
||||
/**
|
||||
* 下载响应类型
|
||||
*/
|
||||
export interface DownloadResponse {
|
||||
blob: Blob
|
||||
filename: string
|
||||
size: number
|
||||
type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载响应类型
|
||||
*/
|
||||
export interface BatchDownloadResponse {
|
||||
success: DownloadResponse[]
|
||||
failed: Array<{
|
||||
url: string
|
||||
error: string
|
||||
}>
|
||||
total: number
|
||||
successCount: number
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件下载类
|
||||
*/
|
||||
export class FileDownloader {
|
||||
private downloadQueue: Map<string, AbortController> = new Map()
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
public async downloadFile(config: DownloadConfig): Promise<DownloadResponse> {
|
||||
const {
|
||||
url,
|
||||
filename,
|
||||
params,
|
||||
onProgress
|
||||
} = config
|
||||
|
||||
// 生成下载ID
|
||||
const downloadId = generateId()
|
||||
const controller = new AbortController()
|
||||
this.downloadQueue.set(downloadId, controller)
|
||||
|
||||
try {
|
||||
const response = await httpClient.request<Blob>({
|
||||
url,
|
||||
method: 'GET',
|
||||
params,
|
||||
responseType: 'blob',
|
||||
timeout: 0, // 下载不设置超时
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message)
|
||||
}
|
||||
|
||||
const blob = response.data
|
||||
const contentType = blob.type || 'application/octet-stream'
|
||||
|
||||
// 从响应头或URL中获取文件名
|
||||
const finalFilename = filename || this.extractFilenameFromUrl(url) || 'download'
|
||||
|
||||
return {
|
||||
blob,
|
||||
filename: finalFilename,
|
||||
size: blob.size,
|
||||
type: contentType
|
||||
}
|
||||
} finally {
|
||||
this.downloadQueue.delete(downloadId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并保存文件
|
||||
*/
|
||||
public async downloadAndSave(config: DownloadConfig): Promise<void> {
|
||||
const result = await this.downloadFile(config)
|
||||
downloadFile(result.blob, result.filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件为Base64
|
||||
*/
|
||||
public async downloadAsBase64(config: DownloadConfig): Promise<string> {
|
||||
const result = await this.downloadFile(config)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const base64 = reader.result as string
|
||||
resolve(base64.split(',')[1]) // 移除data:xxx;base64,前缀
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(result.blob)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件为ArrayBuffer
|
||||
*/
|
||||
public async downloadAsArrayBuffer(config: DownloadConfig): Promise<ArrayBuffer> {
|
||||
const result = await this.downloadFile(config)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = reject
|
||||
reader.readAsArrayBuffer(result.blob)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件为文本
|
||||
*/
|
||||
public async downloadAsText(config: DownloadConfig): Promise<string> {
|
||||
const result = await this.downloadFile(config)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsText(result.blob)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 分片下载(大文件)
|
||||
*/
|
||||
public async downloadLargeFile(
|
||||
config: DownloadConfig & {
|
||||
chunkSize?: number
|
||||
onChunkProgress?: (chunkIndex: number, chunkProgress: number) => void
|
||||
}
|
||||
): Promise<DownloadResponse> {
|
||||
const {
|
||||
url,
|
||||
filename,
|
||||
params,
|
||||
chunkSize = 2 * 1024 * 1024, // 2MB per chunk
|
||||
onProgress,
|
||||
onChunkProgress
|
||||
} = config
|
||||
|
||||
try {
|
||||
// 获取文件信息
|
||||
const headResponse = await httpClient.request({
|
||||
url,
|
||||
method: 'HEAD',
|
||||
params
|
||||
})
|
||||
|
||||
if (!headResponse.success) {
|
||||
throw new Error('无法获取文件信息')
|
||||
}
|
||||
|
||||
// 这里需要根据实际的响应头来获取文件大小
|
||||
// const fileSize = parseInt(headResponse.headers['content-length'] || '0')
|
||||
const fileSize = 0 // 临时设置,实际应该从响应头获取
|
||||
|
||||
if (fileSize === 0) {
|
||||
// 如果无法获取文件大小,回退到普通下载
|
||||
return this.downloadFile(config)
|
||||
}
|
||||
|
||||
const totalChunks = Math.ceil(fileSize / chunkSize)
|
||||
const chunks: Blob[] = []
|
||||
|
||||
// 下载分片
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize
|
||||
const end = Math.min(start + chunkSize - 1, fileSize - 1)
|
||||
|
||||
const chunkResponse = await httpClient.request<Blob>({
|
||||
url,
|
||||
method: 'GET',
|
||||
params,
|
||||
headers: {
|
||||
'Range': `bytes=${start}-${end}`
|
||||
},
|
||||
responseType: 'blob'
|
||||
})
|
||||
|
||||
if (chunkResponse.success) {
|
||||
chunks.push(chunkResponse.data)
|
||||
|
||||
// 分片进度回调
|
||||
onChunkProgress?.(i, 100)
|
||||
|
||||
// 总进度回调
|
||||
const progress = ((i + 1) / totalChunks) * 100
|
||||
onProgress?.(progress)
|
||||
} else {
|
||||
throw new Error(`分片 ${i} 下载失败: ${chunkResponse.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 合并分片
|
||||
const blob = new Blob(chunks)
|
||||
const finalFilename = filename || this.extractFilenameFromUrl(url) || 'download'
|
||||
|
||||
return {
|
||||
blob,
|
||||
filename: finalFilename,
|
||||
size: blob.size,
|
||||
type: blob.type || 'application/octet-stream'
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`分片下载失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载
|
||||
*/
|
||||
public async downloadBatch(
|
||||
urls: string[],
|
||||
options: {
|
||||
concurrent?: number
|
||||
onProgress?: (progress: number) => void
|
||||
onFileProgress?: (url: string, progress: number) => void
|
||||
onFileComplete?: (url: string, result: DownloadResponse | Error) => void
|
||||
} = {}
|
||||
): Promise<BatchDownloadResponse> {
|
||||
const {
|
||||
concurrent = 3,
|
||||
onProgress,
|
||||
onFileProgress,
|
||||
onFileComplete
|
||||
} = options
|
||||
|
||||
const results: BatchDownloadResponse = {
|
||||
success: [],
|
||||
failed: [],
|
||||
total: urls.length,
|
||||
successCount: 0,
|
||||
failedCount: 0
|
||||
}
|
||||
|
||||
const downloadUrl = async (url: string): Promise<void> => {
|
||||
try {
|
||||
const response = await this.downloadFile({
|
||||
url,
|
||||
onProgress: (progress) => onFileProgress?.(url, progress)
|
||||
})
|
||||
|
||||
results.success.push(response)
|
||||
results.successCount++
|
||||
onFileComplete?.(url, response)
|
||||
} catch (error) {
|
||||
results.failed.push({
|
||||
url,
|
||||
error: error instanceof Error ? error.message : '下载失败'
|
||||
})
|
||||
results.failedCount++
|
||||
onFileComplete?.(url, error as Error)
|
||||
}
|
||||
|
||||
// 更新总进度
|
||||
const completed = results.successCount + results.failedCount
|
||||
const progress = (completed / results.total) * 100
|
||||
onProgress?.(progress)
|
||||
}
|
||||
|
||||
// 并发下载
|
||||
const chunks = []
|
||||
for (let i = 0; i < urls.length; i += concurrent) {
|
||||
chunks.push(urls.slice(i, i + concurrent))
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await Promise.all(chunk.map(downloadUrl))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载并打包为ZIP
|
||||
*/
|
||||
public async downloadAsZip(
|
||||
files: Array<{
|
||||
url: string
|
||||
filename?: string
|
||||
}>,
|
||||
zipFilename: string = 'download.zip'
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 下载所有文件
|
||||
const downloadPromises = files.map(async (file) => {
|
||||
const result = await this.downloadFile({
|
||||
url: file.url,
|
||||
filename: file.filename
|
||||
})
|
||||
return {
|
||||
filename: result.filename,
|
||||
blob: result.blob
|
||||
}
|
||||
})
|
||||
|
||||
const downloadedFiles = await Promise.all(downloadPromises)
|
||||
|
||||
// 这里需要使用JSZip库来创建ZIP文件
|
||||
// 由于没有导入JSZip,这里只是示例代码
|
||||
console.log('需要集成JSZip库来实现ZIP打包功能')
|
||||
console.log('下载的文件:', downloadedFiles)
|
||||
|
||||
// 示例:如果有JSZip
|
||||
// const JSZip = (await import('jszip')).default
|
||||
// const zip = new JSZip()
|
||||
//
|
||||
// downloadedFiles.forEach(file => {
|
||||
// zip.file(file.filename, file.blob)
|
||||
// })
|
||||
//
|
||||
// const zipBlob = await zip.generateAsync({ type: 'blob' })
|
||||
// downloadFile(zipBlob, zipFilename)
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`ZIP下载失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL中提取文件名
|
||||
*/
|
||||
private extractFilenameFromUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathname = urlObj.pathname
|
||||
const filename = pathname.split('/').pop()
|
||||
return filename || 'download'
|
||||
} catch {
|
||||
return 'download'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消下载
|
||||
*/
|
||||
public cancelDownload(downloadId: string): void {
|
||||
const controller = this.downloadQueue.get(downloadId)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
this.downloadQueue.delete(downloadId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有下载
|
||||
*/
|
||||
public cancelAllDownloads(): void {
|
||||
this.downloadQueue.forEach((controller) => {
|
||||
controller.abort()
|
||||
})
|
||||
this.downloadQueue.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下载进度
|
||||
*/
|
||||
public getDownloadProgress(downloadId: string): number {
|
||||
// 这里可以实现获取具体下载进度的逻辑
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认实例
|
||||
export const fileDownloader = new FileDownloader()
|
344
src/api/core/request.ts
Normal file
344
src/api/core/request.ts
Normal file
@ -0,0 +1,344 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
|
||||
import { ApiResponse, RequestConfig, ApiError } from '../types'
|
||||
import { API_CONFIG, ENV_CONFIG } from '../config'
|
||||
import { createApiError, retry } from '../utils'
|
||||
|
||||
/**
|
||||
* HTTP请求客户端类
|
||||
*/
|
||||
export class HttpClient {
|
||||
private instance: AxiosInstance
|
||||
private requestQueue: Map<string, AbortController> = new Map()
|
||||
|
||||
constructor() {
|
||||
this.instance = this.createInstance()
|
||||
this.setupInterceptors()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建axios实例
|
||||
*/
|
||||
private createInstance(): AxiosInstance {
|
||||
return axios.create({
|
||||
baseURL: API_CONFIG.BASE_URL,
|
||||
timeout: API_CONFIG.TIMEOUT,
|
||||
headers: API_CONFIG.DEFAULT_HEADERS,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置拦截器
|
||||
*/
|
||||
private setupInterceptors(): void {
|
||||
// 请求拦截器
|
||||
this.instance.interceptors.request.use(
|
||||
(config) => {
|
||||
// 添加请求ID用于取消请求
|
||||
const requestId = this.generateRequestId(config)
|
||||
config.metadata = { requestId }
|
||||
|
||||
// 添加认证token
|
||||
const token = this.getAuthToken()
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 开发环境下打印请求信息
|
||||
if (ENV_CONFIG.isDev) {
|
||||
console.log('🚀 Request:', {
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(this.handleError(error))
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
this.instance.interceptors.response.use(
|
||||
(response) => {
|
||||
// 清理请求队列
|
||||
const requestId = response.config.metadata?.requestId
|
||||
if (requestId) {
|
||||
this.requestQueue.delete(requestId)
|
||||
}
|
||||
|
||||
// 开发环境下打印响应信息
|
||||
if (ENV_CONFIG.isDev) {
|
||||
console.log('✅ Response:', {
|
||||
url: response.config.url,
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
})
|
||||
}
|
||||
|
||||
// 直接返回response,在具体的请求方法中处理数据格式
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
// 清理请求队列
|
||||
const requestId = error.config?.metadata?.requestId
|
||||
if (requestId) {
|
||||
this.requestQueue.delete(requestId)
|
||||
}
|
||||
|
||||
return Promise.reject(this.handleError(error))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理响应数据
|
||||
*/
|
||||
private handleResponse<T>(response: AxiosResponse): ApiResponse<T> {
|
||||
const { data } = response
|
||||
|
||||
// 如果后端返回的数据已经是标准格式
|
||||
if (data && typeof data.code !== 'undefined') {
|
||||
return {
|
||||
code: data.code,
|
||||
message: data.message || 'Success',
|
||||
data: data.data,
|
||||
success: data.code === API_CONFIG.BUSINESS_CODES.SUCCESS,
|
||||
}
|
||||
}
|
||||
|
||||
// 否则包装成标准格式
|
||||
return {
|
||||
code: API_CONFIG.BUSINESS_CODES.SUCCESS,
|
||||
message: 'Success',
|
||||
data: data,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理错误
|
||||
*/
|
||||
private handleError(error: any): ApiError {
|
||||
if (ENV_CONFIG.isDev) {
|
||||
console.error('❌ Request Error:', error)
|
||||
}
|
||||
|
||||
// 请求被取消
|
||||
if (axios.isCancel(error)) {
|
||||
return createApiError(-1, '请求已取消')
|
||||
}
|
||||
|
||||
// 网络错误
|
||||
if (!error.response) {
|
||||
return createApiError(-1, '网络连接失败,请检查网络设置')
|
||||
}
|
||||
|
||||
const { status, data } = error.response as AxiosResponse
|
||||
|
||||
// 根据HTTP状态码处理
|
||||
switch (status) {
|
||||
case API_CONFIG.STATUS_CODES.UNAUTHORIZED:
|
||||
this.handleUnauthorized()
|
||||
return createApiError(status, '登录已过期,请重新登录')
|
||||
|
||||
case API_CONFIG.STATUS_CODES.FORBIDDEN:
|
||||
return createApiError(status, '没有权限访问该资源')
|
||||
|
||||
case API_CONFIG.STATUS_CODES.NOT_FOUND:
|
||||
return createApiError(status, '请求的资源不存在')
|
||||
|
||||
case API_CONFIG.STATUS_CODES.SERVER_ERROR:
|
||||
return createApiError(status, '服务器内部错误')
|
||||
|
||||
default:
|
||||
return createApiError(
|
||||
status,
|
||||
data?.message || `请求失败 (${status})`,
|
||||
data
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理未授权
|
||||
*/
|
||||
private handleUnauthorized(): void {
|
||||
// 清除token
|
||||
this.clearAuthToken()
|
||||
|
||||
// 可以在这里添加跳转到登录页的逻辑
|
||||
// router.push('/login')
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成请求ID
|
||||
*/
|
||||
private generateRequestId(config: AxiosRequestConfig): string {
|
||||
return `${config.method}_${config.url}_${Date.now()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认证token
|
||||
*/
|
||||
private getAuthToken(): string | null {
|
||||
return localStorage.getItem('auth_token')
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除认证token
|
||||
*/
|
||||
private clearAuthToken(): void {
|
||||
localStorage.removeItem('auth_token')
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置认证token
|
||||
*/
|
||||
public setAuthToken(token: string): void {
|
||||
localStorage.setItem('auth_token', token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用请求方法
|
||||
*/
|
||||
public async request<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
|
||||
const axiosConfig: AxiosRequestConfig = {
|
||||
url: config.url,
|
||||
method: config.method || 'GET',
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
headers: config.headers,
|
||||
timeout: config.timeout,
|
||||
responseType: config.responseType || 'json',
|
||||
}
|
||||
|
||||
const response = await this.instance.request(axiosConfig)
|
||||
return this.handleResponse<T>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试的请求方法
|
||||
*/
|
||||
public async requestWithRetry<T = any>(
|
||||
config: RequestConfig,
|
||||
retries?: number
|
||||
): Promise<ApiResponse<T>> {
|
||||
return retry(() => this.request<T>(config), retries)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET请求
|
||||
*/
|
||||
public get<T = any>(
|
||||
url: string,
|
||||
params?: Record<string, any>,
|
||||
config?: Partial<RequestConfig>
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({
|
||||
url,
|
||||
method: 'GET',
|
||||
params,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST请求
|
||||
*/
|
||||
public post<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: Partial<RequestConfig>
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({
|
||||
url,
|
||||
method: 'POST',
|
||||
data,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT请求
|
||||
*/
|
||||
public put<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: Partial<RequestConfig>
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({
|
||||
url,
|
||||
method: 'PUT',
|
||||
data,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE请求
|
||||
*/
|
||||
public delete<T = any>(
|
||||
url: string,
|
||||
params?: Record<string, any>,
|
||||
config?: Partial<RequestConfig>
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({
|
||||
url,
|
||||
method: 'DELETE',
|
||||
params,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH请求
|
||||
*/
|
||||
public patch<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: Partial<RequestConfig>
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({
|
||||
url,
|
||||
method: 'PATCH',
|
||||
data,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消请求
|
||||
*/
|
||||
public cancelRequest(requestId: string): void {
|
||||
const controller = this.requestQueue.get(requestId)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
this.requestQueue.delete(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有请求
|
||||
*/
|
||||
public cancelAllRequests(): void {
|
||||
this.requestQueue.forEach((controller) => {
|
||||
controller.abort()
|
||||
})
|
||||
this.requestQueue.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认实例
|
||||
export const httpClient = new HttpClient()
|
||||
|
||||
// 扩展axios配置类型
|
||||
declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
metadata?: {
|
||||
requestId: string
|
||||
}
|
||||
}
|
||||
}
|
362
src/api/core/upload.ts
Normal file
362
src/api/core/upload.ts
Normal file
@ -0,0 +1,362 @@
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
import { httpClient } from './request'
|
||||
import { UploadConfig, ApiResponse } from '../types'
|
||||
import { API_CONFIG, API_ENDPOINTS } from '../config'
|
||||
import {
|
||||
validateFileType,
|
||||
formatFileSize,
|
||||
compressImage,
|
||||
readFileAsBase64,
|
||||
generateId
|
||||
} from '../utils'
|
||||
|
||||
/**
|
||||
* 文件上传响应类型
|
||||
*/
|
||||
export interface UploadResponse {
|
||||
fileId: string
|
||||
filename: string
|
||||
url: string
|
||||
size: number
|
||||
type: string
|
||||
uploadTime: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传响应类型
|
||||
*/
|
||||
export interface BatchUploadResponse {
|
||||
success: UploadResponse[]
|
||||
failed: Array<{
|
||||
file: File
|
||||
error: string
|
||||
}>
|
||||
total: number
|
||||
successCount: number
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传类
|
||||
*/
|
||||
export class FileUploader {
|
||||
private uploadQueue: Map<string, AbortController> = new Map()
|
||||
|
||||
/**
|
||||
* 单文件上传
|
||||
*/
|
||||
public async uploadFile(config: UploadConfig): Promise<ApiResponse<UploadResponse>> {
|
||||
const {
|
||||
file,
|
||||
filename = file instanceof File ? file.name : 'blob',
|
||||
fieldName = 'file',
|
||||
data = {},
|
||||
onProgress
|
||||
} = config
|
||||
|
||||
// 创建FormData
|
||||
const formData = new FormData()
|
||||
formData.append(fieldName, file, filename)
|
||||
|
||||
// 添加额外数据
|
||||
Object.keys(data).forEach(key => {
|
||||
formData.append(key, data[key])
|
||||
})
|
||||
|
||||
// 生成上传ID
|
||||
const uploadId = generateId()
|
||||
const controller = new AbortController()
|
||||
this.uploadQueue.set(uploadId, controller)
|
||||
|
||||
try {
|
||||
const response = await httpClient.request<UploadResponse>({
|
||||
url: API_ENDPOINTS.FILE.UPLOAD,
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
timeout: 0, // 上传不设置超时
|
||||
responseType: 'json',
|
||||
})
|
||||
|
||||
return response
|
||||
} finally {
|
||||
this.uploadQueue.delete(uploadId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片上传(带压缩)
|
||||
*/
|
||||
public async uploadImage(
|
||||
file: File,
|
||||
options: {
|
||||
compress?: boolean
|
||||
quality?: number
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
allowedTypes?: string[]
|
||||
maxSize?: number
|
||||
onProgress?: (progress: number) => void
|
||||
} = {}
|
||||
): Promise<ApiResponse<UploadResponse>> {
|
||||
const {
|
||||
compress = true,
|
||||
quality = 0.8,
|
||||
maxWidth = 1920,
|
||||
maxHeight = 1080,
|
||||
allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'],
|
||||
maxSize = 10 * 1024 * 1024, // 10MB
|
||||
onProgress
|
||||
} = options
|
||||
|
||||
// 验证文件类型
|
||||
if (!validateFileType(file, allowedTypes)) {
|
||||
throw new Error(`不支持的文件类型,仅支持: ${allowedTypes.join(', ')}`)
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
if (file.size > maxSize) {
|
||||
throw new Error(`文件大小超出限制,最大支持: ${formatFileSize(maxSize)}`)
|
||||
}
|
||||
|
||||
let uploadFile = file
|
||||
|
||||
// 压缩图片
|
||||
if (compress && file.type.startsWith('image/')) {
|
||||
try {
|
||||
const compressedBlob = await compressImage(file, quality, maxWidth, maxHeight)
|
||||
uploadFile = new File([compressedBlob], file.name, { type: file.type })
|
||||
} catch (error) {
|
||||
console.warn('图片压缩失败,使用原文件上传:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return this.uploadFile({
|
||||
file: uploadFile,
|
||||
onProgress
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64上传
|
||||
*/
|
||||
public async uploadBase64(
|
||||
file: File,
|
||||
options: {
|
||||
onProgress?: (progress: number) => void
|
||||
} = {}
|
||||
): Promise<ApiResponse<UploadResponse>> {
|
||||
const { onProgress } = options
|
||||
|
||||
try {
|
||||
const base64 = await readFileAsBase64(file)
|
||||
|
||||
const response = await httpClient.post<UploadResponse>(
|
||||
API_ENDPOINTS.FILE.UPLOAD,
|
||||
{
|
||||
filename: file.name,
|
||||
content: base64,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
encoding: 'base64'
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw new Error(`Base64上传失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分片上传(大文件)
|
||||
*/
|
||||
public async uploadLargeFile(
|
||||
file: File,
|
||||
options: {
|
||||
chunkSize?: number
|
||||
onProgress?: (progress: number) => void
|
||||
onChunkProgress?: (chunkIndex: number, chunkProgress: number) => void
|
||||
} = {}
|
||||
): Promise<ApiResponse<UploadResponse>> {
|
||||
const {
|
||||
chunkSize = 2 * 1024 * 1024, // 2MB per chunk
|
||||
onProgress,
|
||||
onChunkProgress
|
||||
} = options
|
||||
|
||||
const totalChunks = Math.ceil(file.size / chunkSize)
|
||||
const uploadId = generateId()
|
||||
const uploadedChunks: string[] = []
|
||||
|
||||
try {
|
||||
// 初始化分片上传
|
||||
const initResponse = await httpClient.post('/file/upload/init', {
|
||||
filename: file.name,
|
||||
fileSize: file.size,
|
||||
totalChunks,
|
||||
uploadId
|
||||
})
|
||||
|
||||
if (!initResponse.success) {
|
||||
throw new Error(initResponse.message)
|
||||
}
|
||||
|
||||
// 上传分片
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize
|
||||
const end = Math.min(start + chunkSize, file.size)
|
||||
const chunk = file.slice(start, end)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('chunk', chunk)
|
||||
formData.append('chunkIndex', i.toString())
|
||||
formData.append('uploadId', uploadId)
|
||||
|
||||
const chunkResponse = await httpClient.post('/file/upload/chunk', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
}
|
||||
})
|
||||
|
||||
if (chunkResponse.success) {
|
||||
uploadedChunks.push(chunkResponse.data.chunkId)
|
||||
|
||||
// 分片进度回调
|
||||
onChunkProgress?.(i, 100)
|
||||
|
||||
// 总进度回调
|
||||
const progress = ((i + 1) / totalChunks) * 100
|
||||
onProgress?.(progress)
|
||||
} else {
|
||||
throw new Error(`分片 ${i} 上传失败: ${chunkResponse.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 合并分片
|
||||
const mergeResponse = await httpClient.post<UploadResponse>('/file/upload/merge', {
|
||||
uploadId,
|
||||
filename: file.name,
|
||||
chunks: uploadedChunks
|
||||
})
|
||||
|
||||
return mergeResponse
|
||||
} catch (error) {
|
||||
// 清理失败的上传
|
||||
try {
|
||||
await httpClient.post('/file/upload/cleanup', { uploadId })
|
||||
} catch (cleanupError) {
|
||||
console.warn('清理上传失败:', cleanupError)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传
|
||||
*/
|
||||
public async uploadBatch(
|
||||
files: File[],
|
||||
options: {
|
||||
concurrent?: number
|
||||
onProgress?: (progress: number) => void
|
||||
onFileProgress?: (file: File, progress: number) => void
|
||||
onFileComplete?: (file: File, result: UploadResponse | Error) => void
|
||||
} = {}
|
||||
): Promise<BatchUploadResponse> {
|
||||
const {
|
||||
concurrent = 3,
|
||||
onProgress,
|
||||
onFileProgress,
|
||||
onFileComplete
|
||||
} = options
|
||||
|
||||
const results: BatchUploadResponse = {
|
||||
success: [],
|
||||
failed: [],
|
||||
total: files.length,
|
||||
successCount: 0,
|
||||
failedCount: 0
|
||||
}
|
||||
|
||||
const uploadFile = async (file: File): Promise<void> => {
|
||||
try {
|
||||
const response = await this.uploadFile({
|
||||
file,
|
||||
onProgress: (progress) => onFileProgress?.(file, progress)
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
results.success.push(response.data)
|
||||
results.successCount++
|
||||
} else {
|
||||
results.failed.push({
|
||||
file,
|
||||
error: response.message
|
||||
})
|
||||
results.failedCount++
|
||||
}
|
||||
|
||||
onFileComplete?.(file, response.data)
|
||||
} catch (error) {
|
||||
results.failed.push({
|
||||
file,
|
||||
error: error instanceof Error ? error.message : '上传失败'
|
||||
})
|
||||
results.failedCount++
|
||||
onFileComplete?.(file, error as Error)
|
||||
}
|
||||
|
||||
// 更新总进度
|
||||
const completed = results.successCount + results.failedCount
|
||||
const progress = (completed / results.total) * 100
|
||||
onProgress?.(progress)
|
||||
}
|
||||
|
||||
// 并发上传
|
||||
const chunks = []
|
||||
for (let i = 0; i < files.length; i += concurrent) {
|
||||
chunks.push(files.slice(i, i + concurrent))
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await Promise.all(chunk.map(uploadFile))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消上传
|
||||
*/
|
||||
public cancelUpload(uploadId: string): void {
|
||||
const controller = this.uploadQueue.get(uploadId)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
this.uploadQueue.delete(uploadId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有上传
|
||||
*/
|
||||
public cancelAllUploads(): void {
|
||||
this.uploadQueue.forEach((controller) => {
|
||||
controller.abort()
|
||||
})
|
||||
this.uploadQueue.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上传进度
|
||||
*/
|
||||
public getUploadProgress(uploadId: string): number {
|
||||
// 这里可以实现获取具体上传进度的逻辑
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认实例
|
||||
export const fileUploader = new FileUploader()
|
116
src/api/index.ts
Normal file
116
src/api/index.ts
Normal file
@ -0,0 +1,116 @@
|
||||
// 核心模块
|
||||
export { httpClient, HttpClient } from './core/request'
|
||||
export { fileUploader, FileUploader } from './core/upload'
|
||||
export { fileDownloader, FileDownloader } from './core/download'
|
||||
|
||||
// 业务API模块
|
||||
export { editorApi, EditorApi } from './modules/editor'
|
||||
export { resourceApi, ResourceApi } from './modules/resource'
|
||||
|
||||
// 类型定义
|
||||
export type {
|
||||
ApiResponse,
|
||||
RequestConfig,
|
||||
UploadConfig,
|
||||
DownloadConfig,
|
||||
PaginationParams,
|
||||
PaginationResponse,
|
||||
ApiError
|
||||
} from './types'
|
||||
|
||||
// 配置
|
||||
export { API_CONFIG, API_ENDPOINTS, ENV_CONFIG } from './config'
|
||||
|
||||
// 工具函数
|
||||
export {
|
||||
delay,
|
||||
generateId,
|
||||
formatFileSize,
|
||||
getFileExtension,
|
||||
validateFileType,
|
||||
createApiError,
|
||||
isApiError,
|
||||
retry,
|
||||
downloadFile,
|
||||
readFileAsBase64,
|
||||
readFileAsArrayBuffer,
|
||||
compressImage
|
||||
} from './utils'
|
||||
|
||||
// 业务类型
|
||||
export type {
|
||||
ProjectData,
|
||||
OrderData
|
||||
} from './modules/editor'
|
||||
|
||||
export type {
|
||||
ResourceData,
|
||||
ResourceCategory,
|
||||
ResourceSearchParams
|
||||
} from './modules/resource'
|
||||
|
||||
// 上传下载相关类型
|
||||
export type {
|
||||
UploadResponse,
|
||||
BatchUploadResponse
|
||||
} from './core/upload'
|
||||
|
||||
export type {
|
||||
DownloadResponse,
|
||||
BatchDownloadResponse
|
||||
} from './core/download'
|
||||
|
||||
// 导入实例用于ApiManager
|
||||
import { httpClient } from './core/request'
|
||||
import { fileUploader } from './core/upload'
|
||||
import { fileDownloader } from './core/download'
|
||||
import { API_CONFIG, ENV_CONFIG } from './config'
|
||||
|
||||
/**
|
||||
* API管理器类
|
||||
* 提供统一的API管理和配置
|
||||
*/
|
||||
export class ApiManager {
|
||||
/**
|
||||
* 设置认证token
|
||||
*/
|
||||
public static setAuthToken(token: string): void {
|
||||
httpClient.setAuthToken(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有请求
|
||||
*/
|
||||
public static cancelAllRequests(): void {
|
||||
httpClient.cancelAllRequests()
|
||||
fileUploader.cancelAllUploads()
|
||||
fileDownloader.cancelAllDownloads()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取API配置
|
||||
*/
|
||||
public static getConfig() {
|
||||
return {
|
||||
baseUrl: API_CONFIG.BASE_URL,
|
||||
timeout: API_CONFIG.TIMEOUT,
|
||||
retryCount: API_CONFIG.RETRY_COUNT,
|
||||
env: ENV_CONFIG
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查网络连接
|
||||
*/
|
||||
public static async checkConnection(): Promise<boolean> {
|
||||
try {
|
||||
const response = await httpClient.get('/health')
|
||||
return response.success
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认导出API管理器
|
||||
export default ApiManager
|
143
src/api/modules/editor.ts
Normal file
143
src/api/modules/editor.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { httpClient } from '../core/request'
|
||||
import { fileUploader } from '../core/upload'
|
||||
import { fileDownloader } from '../core/download'
|
||||
import { ApiResponse, PaginationParams, PaginationResponse } from '../types'
|
||||
import { API_ENDPOINTS } from '../config'
|
||||
|
||||
/**
|
||||
* 项目数据类型
|
||||
*/
|
||||
export interface ProjectData {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
thumbnail?: string
|
||||
sceneData: any
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单数据类型
|
||||
*/
|
||||
export interface OrderData {
|
||||
id: string
|
||||
projectId: string
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
result?: any
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器API类
|
||||
*/
|
||||
export class EditorApi {
|
||||
/**
|
||||
* 创建订单
|
||||
*/
|
||||
public async createOrder(params: any): Promise<ApiResponse<OrderData>> {
|
||||
return httpClient.post<OrderData>(API_ENDPOINTS.EDITOR.CREATE_ORDER, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存项目
|
||||
*/
|
||||
public async saveProject(projectData: Partial<ProjectData>): Promise<ApiResponse<ProjectData>> {
|
||||
return httpClient.post<ProjectData>(API_ENDPOINTS.EDITOR.SAVE_PROJECT, projectData)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载项目
|
||||
*/
|
||||
public async loadProject(projectId: string): Promise<ApiResponse<ProjectData>> {
|
||||
return httpClient.get<ProjectData>(`${API_ENDPOINTS.EDITOR.LOAD_PROJECT}/${projectId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目
|
||||
*/
|
||||
public async deleteProject(projectId: string): Promise<ApiResponse<void>> {
|
||||
return httpClient.delete<void>(`${API_ENDPOINTS.EDITOR.DELETE_PROJECT}/${projectId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目列表
|
||||
*/
|
||||
public async getProjectList(params: PaginationParams): Promise<ApiResponse<PaginationResponse<ProjectData>>> {
|
||||
return httpClient.get<PaginationResponse<ProjectData>>(API_ENDPOINTS.EDITOR.LOAD_PROJECT, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传项目缩略图
|
||||
*/
|
||||
public async uploadThumbnail(file: File, projectId: string): Promise<ApiResponse<{ url: string }>> {
|
||||
return fileUploader.uploadImage(file, {
|
||||
compress: true,
|
||||
quality: 0.8,
|
||||
maxWidth: 400,
|
||||
maxHeight: 300,
|
||||
onProgress: (progress) => {
|
||||
console.log(`缩略图上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出项目
|
||||
*/
|
||||
public async exportProject(projectId: string, format: 'json' | 'glb' | 'fbx' = 'json'): Promise<void> {
|
||||
await fileDownloader.downloadAndSave({
|
||||
url: `/editor/project/export/${projectId}`,
|
||||
filename: `project_${projectId}.${format}`,
|
||||
params: { format }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入项目
|
||||
*/
|
||||
public async importProject(file: File): Promise<ApiResponse<ProjectData>> {
|
||||
const uploadResponse = await fileUploader.uploadFile({
|
||||
file,
|
||||
fieldName: 'projectFile',
|
||||
data: {
|
||||
action: 'import'
|
||||
}
|
||||
})
|
||||
|
||||
if (!uploadResponse.success) {
|
||||
throw new Error(uploadResponse.message)
|
||||
}
|
||||
|
||||
// 上传成功后,调用导入API
|
||||
return httpClient.post<ProjectData>('/editor/project/import', {
|
||||
fileUrl: uploadResponse.data.url
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导出项目
|
||||
*/
|
||||
public async exportMultipleProjects(projectIds: string[], format: 'json' | 'glb' | 'fbx' = 'json'): Promise<void> {
|
||||
const downloadUrls = projectIds.map(id => `/editor/project/export/${id}?format=${format}`)
|
||||
|
||||
await fileDownloader.downloadBatch(downloadUrls, {
|
||||
concurrent: 2,
|
||||
onProgress: (progress) => {
|
||||
console.log(`批量导出进度: ${progress}%`)
|
||||
},
|
||||
onFileComplete: (url, result) => {
|
||||
if (result instanceof Error) {
|
||||
console.error(`导出失败: ${url}`, result)
|
||||
} else {
|
||||
console.log(`导出完成: ${url}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认实例
|
||||
export const editorApi = new EditorApi()
|
281
src/api/modules/resource.ts
Normal file
281
src/api/modules/resource.ts
Normal file
@ -0,0 +1,281 @@
|
||||
import { httpClient } from '../core/request'
|
||||
import { fileUploader } from '../core/upload'
|
||||
import { fileDownloader } from '../core/download'
|
||||
import { ApiResponse, PaginationParams, PaginationResponse } from '../types'
|
||||
import { API_ENDPOINTS } from '../config'
|
||||
|
||||
/**
|
||||
* 资源数据类型
|
||||
*/
|
||||
export interface ResourceData {
|
||||
id: string
|
||||
name: string
|
||||
type: 'model' | 'texture' | 'material' | 'audio' | 'video' | 'other'
|
||||
url: string
|
||||
thumbnailUrl?: string
|
||||
size: number
|
||||
format: string
|
||||
tags: string[]
|
||||
description?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源分类类型
|
||||
*/
|
||||
export interface ResourceCategory {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
parentId?: string
|
||||
children?: ResourceCategory[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源搜索参数
|
||||
*/
|
||||
export interface ResourceSearchParams extends PaginationParams {
|
||||
keyword?: string
|
||||
type?: ResourceData['type']
|
||||
tags?: string[]
|
||||
categoryId?: string
|
||||
format?: string
|
||||
sizeMin?: number
|
||||
sizeMax?: number
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源API类
|
||||
*/
|
||||
export class ResourceApi {
|
||||
/**
|
||||
* 获取资源列表
|
||||
*/
|
||||
public async getResourceList(params: ResourceSearchParams): Promise<ApiResponse<PaginationResponse<ResourceData>>> {
|
||||
return httpClient.get<PaginationResponse<ResourceData>>(API_ENDPOINTS.RESOURCE.LIST, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源详情
|
||||
*/
|
||||
public async getResourceDetail(resourceId: string): Promise<ApiResponse<ResourceData>> {
|
||||
return httpClient.get<ResourceData>(`${API_ENDPOINTS.RESOURCE.LIST}/${resourceId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传资源
|
||||
*/
|
||||
public async uploadResource(
|
||||
file: File,
|
||||
metadata: {
|
||||
name?: string
|
||||
type: ResourceData['type']
|
||||
tags?: string[]
|
||||
description?: string
|
||||
categoryId?: string
|
||||
}
|
||||
): Promise<ApiResponse<ResourceData>> {
|
||||
// 根据文件类型选择上传方式
|
||||
let uploadResponse
|
||||
|
||||
if (metadata.type === 'texture' && file.type.startsWith('image/')) {
|
||||
// 图片资源,使用图片上传(带压缩)
|
||||
uploadResponse = await fileUploader.uploadImage(file, {
|
||||
compress: true,
|
||||
quality: 0.9,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
onProgress: (progress) => {
|
||||
console.log(`资源上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
} else if (file.size > 50 * 1024 * 1024) {
|
||||
// 大文件,使用分片上传
|
||||
uploadResponse = await fileUploader.uploadLargeFile(file, {
|
||||
onProgress: (progress) => {
|
||||
console.log(`大文件上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 普通文件上传
|
||||
uploadResponse = await fileUploader.uploadFile({
|
||||
file,
|
||||
data: metadata,
|
||||
onProgress: (progress) => {
|
||||
console.log(`文件上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!uploadResponse.success) {
|
||||
throw new Error(uploadResponse.message)
|
||||
}
|
||||
|
||||
// 创建资源记录
|
||||
return httpClient.post<ResourceData>(API_ENDPOINTS.RESOURCE.UPLOAD, {
|
||||
...metadata,
|
||||
name: metadata.name || file.name,
|
||||
url: uploadResponse.data.url,
|
||||
size: file.size,
|
||||
format: file.name.split('.').pop()?.toLowerCase() || 'unknown'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传资源
|
||||
*/
|
||||
public async uploadMultipleResources(
|
||||
files: File[],
|
||||
metadata: {
|
||||
type: ResourceData['type']
|
||||
tags?: string[]
|
||||
categoryId?: string
|
||||
}
|
||||
): Promise<ApiResponse<ResourceData[]>> {
|
||||
const results = await fileUploader.uploadBatch(files, {
|
||||
concurrent: 3,
|
||||
onProgress: (progress) => {
|
||||
console.log(`批量上传进度: ${progress}%`)
|
||||
},
|
||||
onFileComplete: (file, result) => {
|
||||
if (result instanceof Error) {
|
||||
console.error(`文件上传失败: ${file.name}`, result)
|
||||
} else {
|
||||
console.log(`文件上传完成: ${file.name}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 创建资源记录
|
||||
const resourcePromises = results.success.map(uploadResult =>
|
||||
httpClient.post<ResourceData>(API_ENDPOINTS.RESOURCE.UPLOAD, {
|
||||
...metadata,
|
||||
name: uploadResult.filename,
|
||||
url: uploadResult.url,
|
||||
size: uploadResult.size,
|
||||
format: uploadResult.filename.split('.').pop()?.toLowerCase() || 'unknown'
|
||||
})
|
||||
)
|
||||
|
||||
const resourceResults = await Promise.all(resourcePromises)
|
||||
const successResources = resourceResults
|
||||
.filter(result => result.success)
|
||||
.map(result => result.data)
|
||||
|
||||
return {
|
||||
code: 0,
|
||||
message: 'Success',
|
||||
data: successResources,
|
||||
success: true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除资源
|
||||
*/
|
||||
public async deleteResource(resourceId: string): Promise<ApiResponse<void>> {
|
||||
return httpClient.delete<void>(`${API_ENDPOINTS.RESOURCE.DELETE}/${resourceId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除资源
|
||||
*/
|
||||
public async deleteMultipleResources(resourceIds: string[]): Promise<ApiResponse<void>> {
|
||||
return httpClient.post<void>('/resource/batch-delete', { resourceIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新资源信息
|
||||
*/
|
||||
public async updateResource(
|
||||
resourceId: string,
|
||||
data: Partial<Pick<ResourceData, 'name' | 'tags' | 'description'>>
|
||||
): Promise<ApiResponse<ResourceData>> {
|
||||
return httpClient.put<ResourceData>(`/resource/${resourceId}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载资源
|
||||
*/
|
||||
public async downloadResource(resource: ResourceData): Promise<void> {
|
||||
await fileDownloader.downloadAndSave({
|
||||
url: resource.url,
|
||||
filename: resource.name
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载资源
|
||||
*/
|
||||
public async downloadMultipleResources(resources: ResourceData[]): Promise<void> {
|
||||
const downloadFiles = resources.map(resource => ({
|
||||
url: resource.url,
|
||||
filename: resource.name
|
||||
}))
|
||||
|
||||
await fileDownloader.downloadAsZip(downloadFiles, 'resources.zip')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源分类
|
||||
*/
|
||||
public async getResourceCategories(): Promise<ApiResponse<ResourceCategory[]>> {
|
||||
return httpClient.get<ResourceCategory[]>('/resource/categories')
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建资源分类
|
||||
*/
|
||||
public async createResourceCategory(
|
||||
data: Pick<ResourceCategory, 'name' | 'description' | 'parentId'>
|
||||
): Promise<ApiResponse<ResourceCategory>> {
|
||||
return httpClient.post<ResourceCategory>('/resource/categories', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索资源
|
||||
*/
|
||||
public async searchResources(params: ResourceSearchParams): Promise<ApiResponse<PaginationResponse<ResourceData>>> {
|
||||
return httpClient.get<PaginationResponse<ResourceData>>('/resource/search', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门标签
|
||||
*/
|
||||
public async getPopularTags(limit: number = 20): Promise<ApiResponse<string[]>> {
|
||||
return httpClient.get<string[]>('/resource/tags/popular', { limit })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源统计信息
|
||||
*/
|
||||
public async getResourceStats(): Promise<ApiResponse<{
|
||||
total: number
|
||||
byType: Record<ResourceData['type'], number>
|
||||
totalSize: number
|
||||
recentUploads: number
|
||||
}>> {
|
||||
return httpClient.get('/resource/stats')
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览资源(获取预览URL)
|
||||
*/
|
||||
public async getResourcePreview(resourceId: string): Promise<ApiResponse<{ previewUrl: string }>> {
|
||||
return httpClient.get<{ previewUrl: string }>(`/resource/${resourceId}/preview`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成资源缩略图
|
||||
*/
|
||||
public async generateThumbnail(resourceId: string): Promise<ApiResponse<{ thumbnailUrl: string }>> {
|
||||
return httpClient.post<{ thumbnailUrl: string }>(`/resource/${resourceId}/thumbnail`)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认实例
|
||||
export const resourceApi = new ResourceApi()
|
58
src/api/types/index.ts
Normal file
58
src/api/types/index.ts
Normal file
@ -0,0 +1,58 @@
|
||||
// 通用响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
success: boolean
|
||||
}
|
||||
|
||||
// 请求配置类型
|
||||
export interface RequestConfig {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'
|
||||
params?: Record<string, any>
|
||||
data?: any
|
||||
headers?: Record<string, string>
|
||||
timeout?: number
|
||||
responseType?: 'json' | 'blob' | 'text' | 'arraybuffer'
|
||||
}
|
||||
|
||||
// 文件上传配置
|
||||
export interface UploadConfig {
|
||||
file: File | Blob
|
||||
filename?: string
|
||||
fieldName?: string
|
||||
data?: Record<string, any>
|
||||
onProgress?: (progress: number) => void
|
||||
}
|
||||
|
||||
// 文件下载配置
|
||||
export interface DownloadConfig {
|
||||
url: string
|
||||
filename?: string
|
||||
params?: Record<string, any>
|
||||
onProgress?: (progress: number) => void
|
||||
}
|
||||
|
||||
// 分页请求参数
|
||||
export interface PaginationParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// 分页响应数据
|
||||
export interface PaginationResponse<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
// 错误类型
|
||||
export interface ApiError {
|
||||
code: number
|
||||
message: string
|
||||
details?: any
|
||||
}
|
184
src/api/utils/index.ts
Normal file
184
src/api/utils/index.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import { ApiError } from '../types'
|
||||
import { API_CONFIG } from '../config'
|
||||
|
||||
/**
|
||||
* 延迟函数
|
||||
*/
|
||||
export const delay = (ms: number): Promise<void> => {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
*/
|
||||
export const generateId = (): string => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
*/
|
||||
export const getFileExtension = (filename: string): string => {
|
||||
return filename.slice((filename.lastIndexOf('.') - 1 >>> 0) + 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文件类型
|
||||
*/
|
||||
export const validateFileType = (file: File, allowedTypes: string[]): boolean => {
|
||||
const fileType = file.type
|
||||
const fileExtension = getFileExtension(file.name).toLowerCase()
|
||||
|
||||
return allowedTypes.some(type => {
|
||||
if (type.includes('/')) {
|
||||
return fileType === type
|
||||
}
|
||||
return fileExtension === type.toLowerCase()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建API错误对象
|
||||
*/
|
||||
export const createApiError = (
|
||||
code: number,
|
||||
message: string,
|
||||
details?: any
|
||||
): ApiError => {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
details
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为API错误
|
||||
*/
|
||||
export const isApiError = (error: any): error is ApiError => {
|
||||
return error && typeof error.code === 'number' && typeof error.message === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试函数
|
||||
*/
|
||||
export const retry = async <T>(
|
||||
fn: () => Promise<T>,
|
||||
retries: number = API_CONFIG.RETRY_COUNT,
|
||||
delayMs: number = API_CONFIG.RETRY_DELAY
|
||||
): Promise<T> => {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
if (retries > 0) {
|
||||
await delay(delayMs)
|
||||
return retry(fn, retries - 1, delayMs * 2) // 指数退避
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
export const downloadFile = (blob: Blob, filename: string): void => {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件为Base64
|
||||
*/
|
||||
export const readFileAsBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
resolve(result.split(',')[1]) // 移除data:xxx;base64,前缀
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件为ArrayBuffer
|
||||
*/
|
||||
export const readFileAsArrayBuffer = (file: File): Promise<ArrayBuffer> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = reject
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩图片
|
||||
*/
|
||||
export const compressImage = (
|
||||
file: File,
|
||||
quality: number = 0.8,
|
||||
maxWidth: number = 1920,
|
||||
maxHeight: number = 1080
|
||||
): Promise<Blob> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
// 计算压缩后的尺寸
|
||||
let { width, height } = img
|
||||
|
||||
if (width > maxWidth) {
|
||||
height = (height * maxWidth) / width
|
||||
width = maxWidth
|
||||
}
|
||||
|
||||
if (height > maxHeight) {
|
||||
width = (width * maxHeight) / height
|
||||
height = maxHeight
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
// 绘制并压缩
|
||||
ctx?.drawImage(img, 0, 0, width, height)
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('图片压缩失败'))
|
||||
}
|
||||
},
|
||||
file.type,
|
||||
quality
|
||||
)
|
||||
}
|
||||
|
||||
img.onerror = reject
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
}
|
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
After Width: | Height: | Size: 496 B |
41
src/components/HelloWorld.vue
Normal file
41
src/components/HelloWorld.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
188
src/components/NavHeader.vue
Normal file
188
src/components/NavHeader.vue
Normal file
@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<el-header class="nav-header">
|
||||
<div class="nav-container">
|
||||
<!-- 左侧Logo和菜单 -->
|
||||
<div class="nav-left">
|
||||
<div class="logo">
|
||||
<router-link to="/" class="logo-link">
|
||||
{{ appStore.title }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 主菜单 -->
|
||||
<el-menu
|
||||
:default-active="activeIndex"
|
||||
mode="horizontal"
|
||||
@select="handleSelect"
|
||||
class="nav-menu"
|
||||
:ellipsis="false"
|
||||
>
|
||||
<el-menu-item index="/">
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
<span>首页</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/about">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>关于</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="userStore.isLoggedIn" index="/dashboard">
|
||||
<el-icon><DataBoard /></el-icon>
|
||||
<span>仪表板</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="userStore.isLoggedIn" index="/profile">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>个人资料</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="userStore.isLoggedIn && userStore.hasRole('admin')" index="/admin">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>管理</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作区 -->
|
||||
<div class="nav-right">
|
||||
<!-- 主题切换 -->
|
||||
<el-button
|
||||
@click="appStore.toggleTheme"
|
||||
:icon="appStore.theme === 'dark' ? 'Sunny' : 'Moon'"
|
||||
circle
|
||||
class="theme-btn"
|
||||
/>
|
||||
|
||||
<!-- 用户区域 -->
|
||||
<div v-if="userStore.isLoggedIn" class="user-area">
|
||||
<el-dropdown>
|
||||
<span class="user-dropdown">
|
||||
<el-avatar :size="32" :src="userStore.userInfo?.avatar">
|
||||
<el-icon><User /></el-icon>
|
||||
</el-avatar>
|
||||
<span class="username">{{ userStore.userInfo?.nickname }}</span>
|
||||
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="$router.push('/profile')">
|
||||
<el-icon><User /></el-icon>
|
||||
个人资料
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="userStore.logout" divided>
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<el-button
|
||||
v-else
|
||||
type="primary"
|
||||
@click="$router.push('/login')"
|
||||
class="login-btn"
|
||||
>
|
||||
<el-icon><User /></el-icon>
|
||||
登录
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '../stores/modules/app'
|
||||
import { useUserStore } from '../stores/modules/user'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 当前激活的菜单项
|
||||
const activeIndex = computed(() => route.path)
|
||||
|
||||
// 菜单选择处理
|
||||
const handleSelect = (key: string) => {
|
||||
router.push(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-header {
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
padding: 0;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.logo-link {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.user-area {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
/* 暗黑主题适配 */
|
||||
[data-theme="dark"] .nav-header {
|
||||
background-color: #1f2937;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .logo-link {
|
||||
color: #60a5fa;
|
||||
}
|
||||
</style>
|
50
src/config/env.ts
Normal file
50
src/config/env.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 环境变量配置
|
||||
*/
|
||||
|
||||
// 获取环境变量
|
||||
export const getEnv = (): string => {
|
||||
return import.meta.env.MODE
|
||||
}
|
||||
|
||||
// 获取环境变量前缀
|
||||
export const getEnvConfig = (): Partial<ViteEnv> => {
|
||||
const envConfig = import.meta.env as Record<string, any>
|
||||
const ret: any = {}
|
||||
|
||||
for (const envName of Object.keys(envConfig)) {
|
||||
if (envName.startsWith('VITE_')) {
|
||||
let realName = envConfig[envName]
|
||||
if (typeof realName === 'string') {
|
||||
realName = realName.replace(/\\n/g, '\n')
|
||||
realName = realName === 'true' ? true : realName === 'false' ? false : realName
|
||||
}
|
||||
ret[envName] = realName
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// 环境变量类型定义
|
||||
export interface ViteEnv {
|
||||
VITE_APP_TITLE: string
|
||||
VITE_APP_PORT: number
|
||||
VITE_APP_BASE_API: string
|
||||
VITE_APP_ENV: string
|
||||
VITE_APP_BASE_URL: string
|
||||
}
|
||||
|
||||
// 默认环境配置
|
||||
export const defaultEnvConfig: ViteEnv = {
|
||||
VITE_APP_TITLE: 'Vite应用',
|
||||
VITE_APP_PORT: 8080,
|
||||
VITE_APP_BASE_API: '/api',
|
||||
VITE_APP_ENV: 'development',
|
||||
VITE_APP_BASE_URL: '/'
|
||||
}
|
||||
|
||||
// 获取当前环境配置
|
||||
export const envConfig = {
|
||||
...defaultEnvConfig,
|
||||
...getEnvConfig()
|
||||
}
|
36
src/main.ts
Normal file
36
src/main.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
// 导入Element Plus
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
// 导入路由
|
||||
import router from './router'
|
||||
|
||||
// 导入状态管理
|
||||
import pinia from './stores'
|
||||
|
||||
// 导入应用store进行初始化
|
||||
import { useAppStore } from './stores/modules/app'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 安装插件
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(ElementPlus)
|
||||
|
||||
// 注册Element Plus图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
// 挂载应用
|
||||
app.mount('#app')
|
||||
|
||||
// 初始化应用
|
||||
const appStore = useAppStore()
|
||||
appStore.initApp()
|
160
src/router/index.ts
Normal file
160
src/router/index.ts
Normal file
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Vue Router路由配置
|
||||
*/
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useUserStore } from '../stores/modules/user'
|
||||
|
||||
// 路由元信息接口
|
||||
export interface RouteMeta {
|
||||
title?: string
|
||||
icon?: string
|
||||
hidden?: boolean
|
||||
roles?: string[]
|
||||
requireAuth?: boolean
|
||||
keepAlive?: boolean
|
||||
}
|
||||
|
||||
// 扩展RouteRecordRaw类型
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
icon?: string
|
||||
hidden?: boolean
|
||||
roles?: string[]
|
||||
requireAuth?: boolean
|
||||
keepAlive?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// 静态路由
|
||||
export const staticRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('../views/Home.vue'),
|
||||
meta: {
|
||||
title: '首页',
|
||||
icon: 'home'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'About',
|
||||
component: () => import('../views/About.vue'),
|
||||
meta: {
|
||||
title: '关于',
|
||||
icon: 'info'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue'),
|
||||
meta: {
|
||||
title: '登录',
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: 'NotFound',
|
||||
component: () => import('../views/404.vue'),
|
||||
meta: {
|
||||
title: '页面未找到',
|
||||
hidden: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 需要登录的路由
|
||||
export const authRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue'),
|
||||
meta: {
|
||||
title: '仪表板',
|
||||
icon: 'dashboard',
|
||||
requireAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'Admin',
|
||||
component: () => import('../views/Admin.vue'),
|
||||
meta: {
|
||||
title: '管理中心',
|
||||
icon: 'admin',
|
||||
requireAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
component: () => import('../views/Profile.vue'),
|
||||
meta: {
|
||||
title: '个人中心',
|
||||
icon: 'user',
|
||||
requireAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
...staticRoutes,
|
||||
...authRoutes,
|
||||
// 404通配符路由
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404'
|
||||
}
|
||||
],
|
||||
scrollBehavior(_, __, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
return { top: 0 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, _, next) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 检查是否需要认证
|
||||
if (to.meta?.requireAuth) {
|
||||
if (!userStore.isLoggedIn) {
|
||||
// 未登录,跳转到登录页
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是登录页且已登录,跳转到首页
|
||||
if (to.path === '/login' && userStore.isLoggedIn) {
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
// 路由错误处理
|
||||
router.onError((error) => {
|
||||
console.error('路由错误:', error)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
// 工具函数 - 获取所有路由(用于菜单生成)
|
||||
export const getAllRoutes = (): RouteRecordRaw[] => {
|
||||
const allRoutes = [...staticRoutes, ...authRoutes]
|
||||
return allRoutes.filter(route => !route.meta?.hidden)
|
||||
}
|
30
src/stores/index.ts
Normal file
30
src/stores/index.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Pinia状态管理
|
||||
*/
|
||||
import { createPinia } from 'pinia'
|
||||
import { storage } from '../utils/storage'
|
||||
|
||||
// 创建pinia实例
|
||||
const pinia = createPinia()
|
||||
|
||||
// 添加持久化插件
|
||||
pinia.use(({ store }) => {
|
||||
// 从存储中恢复状态
|
||||
const storageKey = `store_${store.$id}`
|
||||
const savedState = storage.local.get(storageKey)
|
||||
|
||||
if (savedState) {
|
||||
store.$patch(savedState)
|
||||
}
|
||||
|
||||
// 监听状态变化并持久化
|
||||
store.$subscribe((_, state) => {
|
||||
storage.local.set(storageKey, state)
|
||||
})
|
||||
})
|
||||
|
||||
export default pinia
|
||||
|
||||
// 导出所有store
|
||||
export * from './modules/user'
|
||||
export * from './modules/app'
|
129
src/stores/modules/app.ts
Normal file
129
src/stores/modules/app.ts
Normal file
@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 应用状态管理
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'auto'
|
||||
export type Language = 'zh-CN' | 'en-US'
|
||||
|
||||
export interface AppConfig {
|
||||
theme: Theme
|
||||
language: Language
|
||||
collapsed: boolean
|
||||
loading: boolean
|
||||
title: string
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
// 应用配置
|
||||
const config = ref<AppConfig>({
|
||||
theme: 'light',
|
||||
language: 'zh-CN',
|
||||
collapsed: false,
|
||||
loading: false,
|
||||
title: 'Vite应用'
|
||||
})
|
||||
|
||||
// 侧边栏折叠状态
|
||||
const collapsed = computed(() => config.value.collapsed)
|
||||
|
||||
// 主题
|
||||
const theme = computed(() => config.value.theme)
|
||||
|
||||
// 语言
|
||||
const language = computed(() => config.value.language)
|
||||
|
||||
// 加载状态
|
||||
const loading = computed(() => config.value.loading)
|
||||
|
||||
// 应用标题
|
||||
const title = computed(() => config.value.title)
|
||||
|
||||
// 设置主题
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
config.value.theme = newTheme
|
||||
// 应用主题到文档
|
||||
document.documentElement.setAttribute('data-theme', newTheme)
|
||||
}
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme.value === 'light' ? 'dark' : 'light'
|
||||
setTheme(newTheme)
|
||||
}
|
||||
|
||||
// 设置语言
|
||||
const setLanguage = (newLanguage: Language) => {
|
||||
config.value.language = newLanguage
|
||||
}
|
||||
|
||||
// 切换侧边栏折叠状态
|
||||
const toggleCollapsed = () => {
|
||||
config.value.collapsed = !config.value.collapsed
|
||||
}
|
||||
|
||||
// 设置侧边栏折叠状态
|
||||
const setCollapsed = (newCollapsed: boolean) => {
|
||||
config.value.collapsed = newCollapsed
|
||||
}
|
||||
|
||||
// 设置加载状态
|
||||
const setLoading = (newLoading: boolean) => {
|
||||
config.value.loading = newLoading
|
||||
}
|
||||
|
||||
// 设置应用标题
|
||||
const setTitle = (newTitle: string) => {
|
||||
config.value.title = newTitle
|
||||
document.title = newTitle
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
const updateConfig = (updates: Partial<AppConfig>) => {
|
||||
config.value = { ...config.value, ...updates }
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
const resetConfig = () => {
|
||||
config.value = {
|
||||
theme: 'light',
|
||||
language: 'zh-CN',
|
||||
collapsed: false,
|
||||
loading: false,
|
||||
title: 'Vite应用'
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化应用
|
||||
const initApp = () => {
|
||||
// 应用主题
|
||||
setTheme(theme.value)
|
||||
// 设置标题
|
||||
setTitle(title.value)
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
config,
|
||||
|
||||
// 计算属性
|
||||
collapsed,
|
||||
theme,
|
||||
language,
|
||||
loading,
|
||||
title,
|
||||
|
||||
// 方法
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
setLanguage,
|
||||
toggleCollapsed,
|
||||
setCollapsed,
|
||||
setLoading,
|
||||
setTitle,
|
||||
updateConfig,
|
||||
resetConfig,
|
||||
initApp
|
||||
}
|
||||
})
|
119
src/stores/modules/user.ts
Normal file
119
src/stores/modules/user.ts
Normal file
@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 用户状态管理
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface UserInfo {
|
||||
id: number | string
|
||||
username: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 用户信息
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
|
||||
// 访问令牌
|
||||
const accessToken = ref<string>('')
|
||||
|
||||
// 刷新令牌
|
||||
const refreshToken = ref<string>('')
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => !!accessToken.value && !!userInfo.value)
|
||||
const username = computed(() => userInfo.value?.username || '')
|
||||
const nickname = computed(() => userInfo.value?.nickname || '')
|
||||
const roles = computed(() => userInfo.value?.roles || [])
|
||||
|
||||
// 设置用户信息
|
||||
const setUserInfo = (info: UserInfo) => {
|
||||
userInfo.value = info
|
||||
}
|
||||
|
||||
// 设置访问令牌
|
||||
const setAccessToken = (token: string) => {
|
||||
accessToken.value = token
|
||||
}
|
||||
|
||||
// 设置刷新令牌
|
||||
const setRefreshToken = (token: string) => {
|
||||
refreshToken.value = token
|
||||
}
|
||||
|
||||
// 登录
|
||||
const login = async (loginData: { username: string; password: string }) => {
|
||||
try {
|
||||
// 这里调用登录API
|
||||
// const response = await loginApi(loginData)
|
||||
|
||||
// 模拟登录成功
|
||||
const mockUserInfo: UserInfo = {
|
||||
id: 1,
|
||||
username: loginData.username,
|
||||
nickname: loginData.username,
|
||||
roles: ['user']
|
||||
}
|
||||
|
||||
setUserInfo(mockUserInfo)
|
||||
setAccessToken('mock-access-token')
|
||||
setRefreshToken('mock-refresh-token')
|
||||
|
||||
return { success: true, data: mockUserInfo }
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
return { success: false, error: '登录失败' }
|
||||
}
|
||||
}
|
||||
|
||||
// 登出
|
||||
const logout = () => {
|
||||
userInfo.value = null
|
||||
accessToken.value = ''
|
||||
refreshToken.value = ''
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
const updateUserInfo = (updates: Partial<UserInfo>) => {
|
||||
if (userInfo.value) {
|
||||
userInfo.value = { ...userInfo.value, ...updates }
|
||||
}
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
const hasRole = (role: string) => {
|
||||
return roles.value.includes(role)
|
||||
}
|
||||
|
||||
// 检查多个权限
|
||||
const hasAnyRole = (roleList: string[]) => {
|
||||
return roleList.some(role => hasRole(role))
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
userInfo,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
username,
|
||||
nickname,
|
||||
roles,
|
||||
|
||||
// 方法
|
||||
setUserInfo,
|
||||
setAccessToken,
|
||||
setRefreshToken,
|
||||
login,
|
||||
logout,
|
||||
updateUserInfo,
|
||||
hasRole,
|
||||
hasAnyRole
|
||||
}
|
||||
})
|
79
src/style.css
Normal file
79
src/style.css
Normal file
@ -0,0 +1,79 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
116
src/utils/storage.ts
Normal file
116
src/utils/storage.ts
Normal file
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 持久化存储工具
|
||||
*/
|
||||
|
||||
export interface StorageOptions {
|
||||
prefix?: string
|
||||
storage?: Storage
|
||||
expire?: number
|
||||
}
|
||||
|
||||
export class StorageUtil {
|
||||
private prefix: string
|
||||
private storage: Storage
|
||||
|
||||
constructor(options: StorageOptions = {}) {
|
||||
this.prefix = options.prefix || 'app_'
|
||||
this.storage = options.storage || window.localStorage
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成存储key
|
||||
*/
|
||||
private getKey(key: string): string {
|
||||
return `${this.prefix}${key}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置存储
|
||||
*/
|
||||
set<T = any>(key: string, value: T, expire?: number): void {
|
||||
const storageData = {
|
||||
value,
|
||||
expire: expire ? Date.now() + expire * 1000 : null
|
||||
}
|
||||
this.storage.setItem(this.getKey(key), JSON.stringify(storageData))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取存储
|
||||
*/
|
||||
get<T = any>(key: string): T | null {
|
||||
const item = this.storage.getItem(this.getKey(key))
|
||||
if (!item) return null
|
||||
|
||||
try {
|
||||
const storageData = JSON.parse(item)
|
||||
|
||||
// 检查是否过期
|
||||
if (storageData.expire && Date.now() > storageData.expire) {
|
||||
this.remove(key)
|
||||
return null
|
||||
}
|
||||
|
||||
return storageData.value
|
||||
} catch (error) {
|
||||
console.error('Storage parse error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除存储
|
||||
*/
|
||||
remove(key: string): void {
|
||||
this.storage.removeItem(this.getKey(key))
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有存储
|
||||
*/
|
||||
clear(): void {
|
||||
const keys = Object.keys(this.storage)
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith(this.prefix)) {
|
||||
this.storage.removeItem(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有存储的keys
|
||||
*/
|
||||
keys(): string[] {
|
||||
const keys = Object.keys(this.storage)
|
||||
return keys
|
||||
.filter(key => key.startsWith(this.prefix))
|
||||
.map(key => key.replace(this.prefix, ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否存在
|
||||
*/
|
||||
has(key: string): boolean {
|
||||
return this.get(key) !== null
|
||||
}
|
||||
}
|
||||
|
||||
// 默认实例
|
||||
export const localStorage = new StorageUtil({
|
||||
prefix: 'local_',
|
||||
storage: window.localStorage
|
||||
})
|
||||
|
||||
export const sessionStorage = new StorageUtil({
|
||||
prefix: 'session_',
|
||||
storage: window.sessionStorage
|
||||
})
|
||||
|
||||
// 便捷方法
|
||||
export const storage = {
|
||||
local: localStorage,
|
||||
session: sessionStorage,
|
||||
|
||||
// 创建自定义存储实例
|
||||
create: (options: StorageOptions) => new StorageUtil(options)
|
||||
}
|
109
src/views/404.vue
Normal file
109
src/views/404.vue
Normal file
@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<div class="content">
|
||||
<h1>404</h1>
|
||||
<h2>页面未找到</h2>
|
||||
<p>抱歉,您访问的页面不存在。</p>
|
||||
<div class="actions">
|
||||
<router-link to="/" class="btn">返回首页</router-link>
|
||||
<button @click="goBack" class="btn btn-secondary">返回上页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.go(-1)
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 6rem;
|
||||
margin: 0;
|
||||
color: #3182ce;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
font-size: 2rem;
|
||||
margin: 1rem 0;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.content p {
|
||||
font-size: 1.2rem;
|
||||
color: #4a5568;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2c5aa0;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e2e8f0;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #cbd5e0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .content h2 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .content p {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .btn-secondary {
|
||||
background: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .btn-secondary:hover {
|
||||
background: #2d3748;
|
||||
}
|
||||
</style>
|
241
src/views/About.vue
Normal file
241
src/views/About.vue
Normal file
@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<el-row justify="center">
|
||||
<el-col :span="20" :md="16" :lg="14">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">关于我们</h1>
|
||||
<p class="page-desc">了解项目技术架构和功能特性</p>
|
||||
</div>
|
||||
|
||||
<!-- 技术栈部分 -->
|
||||
<el-card class="section-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Monitor /></el-icon>
|
||||
<span class="header-title">技术栈</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-space direction="vertical" size="large" style="width: 100%">
|
||||
<div v-for="tech in techStack" :key="tech.name" class="tech-item">
|
||||
<div class="tech-header">
|
||||
<el-tag :type="tech.type" size="large">{{ tech.name }}</el-tag>
|
||||
</div>
|
||||
<p class="tech-desc">{{ tech.description }}</p>
|
||||
</div>
|
||||
</el-space>
|
||||
</el-card>
|
||||
|
||||
<!-- 功能特性部分 -->
|
||||
<el-card class="section-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Star /></el-icon>
|
||||
<span class="header-title">功能特性</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12" :sm="8" :md="6" v-for="feature in features" :key="feature.title">
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">{{ feature.icon }}</div>
|
||||
<div class="feature-text">{{ feature.title }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="$router.push('/')"
|
||||
icon="HomeFilled"
|
||||
>
|
||||
返回首页
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="success"
|
||||
size="large"
|
||||
@click="$router.push('/dashboard')"
|
||||
icon="DataBoard"
|
||||
>
|
||||
前往控制台
|
||||
</el-button>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 技术栈数据
|
||||
const techStack = [
|
||||
{
|
||||
name: 'Vite',
|
||||
type: 'primary',
|
||||
description: '极速的前端构建工具'
|
||||
},
|
||||
{
|
||||
name: 'Vue 3',
|
||||
type: 'success',
|
||||
description: '渐进式JavaScript框架'
|
||||
},
|
||||
{
|
||||
name: 'TypeScript',
|
||||
type: 'info',
|
||||
description: 'JavaScript的超集'
|
||||
},
|
||||
{
|
||||
name: 'Axios',
|
||||
type: 'warning',
|
||||
description: '基于Promise的HTTP客户端,用于处理API请求和响应'
|
||||
},
|
||||
{
|
||||
name: 'Pinia',
|
||||
type: 'danger',
|
||||
description: 'Vue.js的官方状态管理库'
|
||||
},
|
||||
{
|
||||
name: 'Vue Router',
|
||||
type: 'primary',
|
||||
description: 'Vue.js的官方路由管理器'
|
||||
}
|
||||
]
|
||||
|
||||
// 功能特性数据
|
||||
const features = [
|
||||
{ icon: '🎨', title: '主题切换' },
|
||||
{ icon: '🔐', title: '用户认证' },
|
||||
{ icon: '📱', title: '响应式设计' },
|
||||
{ icon: '💾', title: '数据存储' },
|
||||
{ icon: '🛣️', title: '动态路由' },
|
||||
{ icon: '⚡', title: '热模块替换' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.about {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 页面头部 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--el-color-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 1.2rem;
|
||||
color: var(--el-text-color-regular);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.section-card {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 1.2rem;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 技术栈项目 */
|
||||
.tech-item {
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.tech-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tech-header {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tech-desc {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 功能特性项目 */
|
||||
.feature-item {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--el-bg-color-page);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-item:hover {
|
||||
background: var(--el-color-primary-light-9);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.feature-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.actions {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.feature-text {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
34
src/views/Admin.vue
Normal file
34
src/views/Admin.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="admin">
|
||||
<h1>管理中心</h1>
|
||||
<p>这是一个简单的管理页面示例</p>
|
||||
|
||||
<div class="actions">
|
||||
<router-link to="/" class="btn">返回首页</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2c5aa0;
|
||||
}
|
||||
</style>
|
360
src/views/Dashboard.vue
Normal file
360
src/views/Dashboard.vue
Normal file
@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 欢迎区域 -->
|
||||
<el-row class="welcome-section">
|
||||
<el-col :span="24">
|
||||
<el-card class="welcome-card" shadow="never">
|
||||
<div class="welcome-content">
|
||||
<h1 class="welcome-title">
|
||||
<el-icon class="welcome-icon"><DataBoard /></el-icon>
|
||||
仪表板
|
||||
</h1>
|
||||
<p class="welcome-text">欢迎回来,{{ userStore.userInfo?.nickname || '用户' }}!</p>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="24" class="stats-section">
|
||||
<el-col :xs="12" :sm="12" :md="6" v-for="stat in statsData" :key="stat.title">
|
||||
<el-card class="stat-card" shadow="hover">
|
||||
<div class="stat-header">
|
||||
<el-icon :class="['stat-icon', stat.iconClass]" :size="28">
|
||||
<component :is="stat.icon" />
|
||||
</el-icon>
|
||||
<span class="stat-title">{{ stat.title }}</span>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
<div class="stat-desc">{{ stat.description }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 功能区域 -->
|
||||
<el-row :gutter="24" class="bottom-section">
|
||||
<!-- 快速操作 -->
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="action-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Operation /></el-icon>
|
||||
<span class="header-title">快速操作</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="action-buttons">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="appStore.toggleTheme"
|
||||
:icon="appStore.theme === 'dark' ? 'Sunny' : 'Moon'"
|
||||
class="action-button"
|
||||
>
|
||||
切换主题 ({{ appStore.theme === 'dark' ? '暗黑' : '明亮' }})
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="success"
|
||||
size="large"
|
||||
@click="$router.push('/profile')"
|
||||
icon="User"
|
||||
class="action-button"
|
||||
>
|
||||
个人中心
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="userStore.hasRole('admin')"
|
||||
type="warning"
|
||||
size="large"
|
||||
@click="$router.push('/admin')"
|
||||
icon="Setting"
|
||||
class="action-button"
|
||||
>
|
||||
管理中心
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 系统信息 -->
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="info-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Monitor /></el-icon>
|
||||
<span class="header-title">系统信息</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-descriptions :column="1" size="default" border>
|
||||
<el-descriptions-item label="应用标题">
|
||||
{{ appStore.title }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="当前主题">
|
||||
<el-tag :type="appStore.theme === 'dark' ? 'info' : 'success'" size="small">
|
||||
{{ appStore.theme === 'dark' ? '暗黑模式' : '明亮模式' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="语言设置">
|
||||
{{ appStore.language }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="本地存储">
|
||||
{{ getStorageInfo().localStorage }} 项
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="会话存储">
|
||||
{{ getStorageInfo().sessionStorage }} 项
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '../stores/modules/user'
|
||||
import { useAppStore } from '../stores/modules/app'
|
||||
import { storage } from '../utils/storage'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const getStorageInfo = () => {
|
||||
return {
|
||||
localStorage: storage.local.keys().length,
|
||||
sessionStorage: storage.session.keys().length
|
||||
}
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
const statsData = computed(() => [
|
||||
{
|
||||
title: '用户信息',
|
||||
icon: 'User',
|
||||
iconClass: 'user-icon',
|
||||
value: userStore.userInfo?.username || '未登录',
|
||||
description: `角色: ${userStore.roles.join(', ') || '无'}`
|
||||
},
|
||||
{
|
||||
title: '在线状态',
|
||||
icon: 'Connection',
|
||||
iconClass: 'status-icon',
|
||||
value: '在线',
|
||||
description: '连接正常'
|
||||
},
|
||||
{
|
||||
title: '存储使用',
|
||||
icon: 'FolderOpened',
|
||||
iconClass: 'storage-icon',
|
||||
value: `${getStorageInfo().localStorage + getStorageInfo().sessionStorage}`,
|
||||
description: '总存储项数'
|
||||
},
|
||||
{
|
||||
title: '应用版本',
|
||||
icon: 'Trophy',
|
||||
iconClass: 'version-icon',
|
||||
value: 'v1.0.0',
|
||||
description: '最新版本'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 欢迎区域 */
|
||||
.welcome-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
background: linear-gradient(135deg, var(--el-color-primary-light-7) 0%, var(--el-color-primary-light-9) 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--el-color-primary);
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.welcome-icon {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
font-size: 1.1rem;
|
||||
color: var(--el-text-color-regular);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 统计卡片区域 */
|
||||
.stats-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
height: 150px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-icon.user-icon {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.stat-icon.status-icon {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.stat-icon.storage-icon {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.stat-icon.version-icon {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 0.95rem;
|
||||
color: var(--el-text-color-regular);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: bold;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* 功能区域 */
|
||||
.bottom-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-card,
|
||||
.info-card {
|
||||
margin-bottom: 24px;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
/* 快速操作按钮 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 1.2rem;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.welcome-title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
height: 120px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.stat-desc {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑主题适配 */
|
||||
[data-theme="dark"] .welcome-card {
|
||||
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .welcome-title {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .welcome-text {
|
||||
color: #d1d5db;
|
||||
}
|
||||
</style>
|
195
src/views/Home.vue
Normal file
195
src/views/Home.vue
Normal file
@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<!-- 主标题区域 -->
|
||||
<el-row justify="center" class="hero-section">
|
||||
<el-col :span="20">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">{{ appStore.title }}</h1>
|
||||
<p class="hero-subtitle">欢迎使用Vite + Vue 3 + Pinia + Vue Router框架</p>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 技术特性卡片 -->
|
||||
<el-row justify="center">
|
||||
<el-col :span="20">
|
||||
<h2 class="section-title">技术特性</h2>
|
||||
<el-row :gutter="20" class="features-grid">
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="feature in features" :key="feature.name">
|
||||
<el-card class="feature-card" shadow="hover">
|
||||
<div class="feature-icon">{{ feature.icon }}</div>
|
||||
<h3 class="feature-name">{{ feature.name }}</h3>
|
||||
<p class="feature-desc">{{ feature.description }}</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 快速操作 -->
|
||||
<el-row justify="center" class="actions-section">
|
||||
<el-col :span="20">
|
||||
<el-space size="large" wrap>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="appStore.toggleTheme"
|
||||
:icon="appStore.theme === 'dark' ? 'Sunny' : 'Moon'"
|
||||
>
|
||||
切换主题 ({{ appStore.theme === 'dark' ? '暗黑' : '明亮' }})
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="success"
|
||||
size="large"
|
||||
@click="$router.push('/about')"
|
||||
icon="InfoFilled"
|
||||
>
|
||||
了解更多
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="userStore.isLoggedIn"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="$router.push('/dashboard')"
|
||||
icon="DataBoard"
|
||||
>
|
||||
控制台
|
||||
</el-button>
|
||||
</el-space>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '../stores/modules/app'
|
||||
import { useUserStore } from '../stores/modules/user'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 技术特性数据
|
||||
const features = [
|
||||
{
|
||||
name: 'Vite',
|
||||
icon: '✨',
|
||||
description: '极速的开发体验'
|
||||
},
|
||||
{
|
||||
name: 'Vue 3',
|
||||
icon: '🚀',
|
||||
description: '组合式API,性能更强'
|
||||
},
|
||||
{
|
||||
name: 'Axios',
|
||||
icon: '📡',
|
||||
description: 'HTTP客户端,API请求处理'
|
||||
},
|
||||
{
|
||||
name: 'Pinia',
|
||||
icon: '🍍',
|
||||
description: '现代化状态管理'
|
||||
},
|
||||
{
|
||||
name: 'Vue Router',
|
||||
icon: '🛣️',
|
||||
description: '动态路由配置'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 主标题区域 */
|
||||
.hero-section {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--el-color-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.2rem;
|
||||
color: var(--el-text-color-regular);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 技术特性区域 */
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
text-align: center;
|
||||
height: 180px;
|
||||
margin-bottom: 20px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.feature-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
color: var(--el-text-color-regular);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 操作按钮区域 */
|
||||
.actions-section {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
height: auto;
|
||||
min-height: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
186
src/views/Login.vue
Normal file
186
src/views/Login.vue
Normal file
@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="login">
|
||||
<div class="login-card">
|
||||
<h2>用户登录</h2>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="loginForm.username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="login-btn" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="tips">
|
||||
<p>提示:输入任意用户名和密码即可登录</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '../stores/modules/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!loginForm.value.username || !loginForm.value.password) {
|
||||
alert('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const result = await userStore.login(loginForm.value)
|
||||
|
||||
if (result.success) {
|
||||
// 登录成功,跳转到目标页面或首页
|
||||
const redirect = route.query.redirect as string || '/'
|
||||
await router.push(redirect)
|
||||
} else {
|
||||
alert(result.error || '登录失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录错误:', error)
|
||||
alert('登录失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #4a5568;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #3182ce;
|
||||
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.1);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.login-btn:hover:not(:disabled) {
|
||||
background: #2c5aa0;
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tips p {
|
||||
color: #718096;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .login-card {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .login-card h2 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-group label {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-group input {
|
||||
background: #4a5568;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tips p {
|
||||
color: #a0aec0;
|
||||
}
|
||||
</style>
|
135
src/views/Profile.vue
Normal file
135
src/views/Profile.vue
Normal file
@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="profile">
|
||||
<h1>个人中心</h1>
|
||||
|
||||
<div class="profile-card">
|
||||
<h2>用户信息</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>用户名:</label>
|
||||
<span>{{ userStore.username }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>昵称:</label>
|
||||
<span>{{ userStore.nickname }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>角色:</label>
|
||||
<span>{{ userStore.roles.join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="handleLogout" class="btn btn-danger">退出登录</button>
|
||||
<router-link to="/" class="btn">返回首页</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '../stores/modules/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const handleLogout = () => {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile {
|
||||
padding: 2rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.profile-card h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2c5aa0;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e53e3e;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c53030;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .profile-card {
|
||||
background: #2d3748;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .profile-card h2 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .info-item label {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .info-item span {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
</style>
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
15
tsconfig.app.json
Normal file
15
tsconfig.app.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": false,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["esnext", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"script/*": ["src/script/*"]
|
||||
},
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
54
vite.config.ts
Normal file
54
vite.config.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import legacy from '@vitejs/plugin-legacy'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: './', //打包后的相对路径
|
||||
define: {
|
||||
'process.env': process.env //public环境
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 8080, //vite项目启动时自定义端口
|
||||
|
||||
open: true,
|
||||
proxy: {
|
||||
// 正则表达式写法
|
||||
'^/api': {
|
||||
target: 'http://192.168.3.151:3000/api', // 后端服务实际地址
|
||||
changeOrigin: true, //开启代理
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
plugins: [
|
||||
vue(),
|
||||
legacy({
|
||||
targets: ['defaults', 'not IE 11']
|
||||
}),
|
||||
visualizer({
|
||||
open: true, // 构建完成后自动打开浏览器
|
||||
gzipSize: true,
|
||||
brotliSize: true
|
||||
})
|
||||
],
|
||||
|
||||
resolve: {
|
||||
//别名
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
components: resolve(__dirname, './src/components'),
|
||||
script: resolve(__dirname, './src/script'),
|
||||
utils: resolve(__dirname, './src/utils')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
assetsDir: 'static', //打包后的公共文件夹名
|
||||
target: 'es2015',
|
||||
cssTarget: ['chrome61'],
|
||||
chunkSizeWarningLimit: 5000
|
||||
}
|
||||
})
|
Reference in New Issue
Block a user