This commit is contained in:
2025-07-09 20:36:32 +08:00
commit af33978d28
43 changed files with 9434 additions and 0 deletions

6
.env.development Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

156
README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

15
tsconfig.app.json Normal file
View 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
View 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
View 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
View 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
}
})