完成基础开发

This commit is contained in:
2025-07-18 15:46:07 +08:00
parent 99f30bd1eb
commit cff16ef2af
42 changed files with 7290 additions and 0 deletions

32
frontend/Dockerfile Normal file
View File

@ -0,0 +1,32 @@
# 前端 Dockerfile - 多阶段构建
# 第一阶段:构建
FROM node:18-alpine AS builder
WORKDIR /app
# 复制 package 文件
COPY package*.json ./
# 安装依赖
RUN npm ci --only=production
# 复制源代码
COPY . .
# 构建应用
RUN npm run build
# 第二阶段:运行
FROM nginx:alpine
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制 nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 暴露端口
EXPOSE 80
# 启动 nginx
CMD ["nginx", "-g", "daemon off;"]

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<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>qBittorrent 管理平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

40
frontend/nginx.conf Normal file
View File

@ -0,0 +1,40 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 启用 gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# 静态文件缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API 请求代理到后端
location /api/ {
proxy_pass http://backend:8888;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# Vue Router 历史模式支持
location / {
try_files $uri $uri/ /index.html;
}
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

3155
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "qbit-manager-frontend",
"version": "0.1.0",
"description": "多客户端 qBittorrent 集中管理平台前端",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"element-plus": "^2.4.4",
"axios": "^1.6.2",
"@element-plus/icons-vue": "^2.3.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"vite": "^5.0.8",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.19.2",
"@vue/eslint-config-prettier": "^9.0.0",
"prettier": "^3.1.1"
}
}

23
frontend/src/App.vue Normal file
View File

@ -0,0 +1,23 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script setup>
// 应用根组件
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
}
body {
margin: 0;
padding: 0;
}
</style>

View File

@ -0,0 +1,31 @@
/**
* 客户端管理 API
*/
import api from './index'
export const clientsApi = {
// 获取所有客户端
getClients() {
return api.get('/clients')
},
// 添加客户端
addClient(clientData) {
return api.post('/clients', clientData)
},
// 更新客户端
updateClient(clientId, clientData) {
return api.put(`/clients/${clientId}`, clientData)
},
// 删除客户端
deleteClient(clientId) {
return api.delete(`/clients/${clientId}`)
},
// 测试客户端连接
testConnection(clientId) {
return api.post(`/clients/${clientId}/test`)
}
}

39
frontend/src/api/index.js Normal file
View File

@ -0,0 +1,39 @@
/**
* API 接口配置
*/
import axios from 'axios'
// 创建 axios 实例
const api = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
api.interceptors.request.use(
config => {
// 可以在这里添加认证 token
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
response => {
return response.data
},
error => {
// 统一错误处理
const message = error.response?.data?.error || error.message || '请求失败'
console.error('API Error:', message)
return Promise.reject(new Error(message))
}
)
export default api

View File

@ -0,0 +1,87 @@
/**
* 种子管理 API
*/
import api from './index'
export const torrentsApi = {
// 获取种子列表
getTorrents(clientIds = []) {
const params = clientIds.length > 0 ? { client_ids: clientIds } : {}
return api.get('/torrents', { params })
},
// 获取主要数据(聚合接口)
getMainData(clientIds = []) {
const params = clientIds.length > 0 ? { client_ids: clientIds } : {}
return api.get('/torrents/maindata', { params })
},
// 暂停种子
pauseTorrents(hashes, clientId = null) {
return api.post('/torrents/pause', {
hashes,
client_id: clientId
})
},
// 恢复种子
resumeTorrents(hashes, clientId = null) {
return api.post('/torrents/resume', {
hashes,
client_id: clientId
})
},
// 删除种子
deleteTorrents(hashes, deleteFiles = false, clientId = null) {
return api.post('/torrents/delete', {
hashes,
delete_files: deleteFiles,
client_id: clientId
})
},
// 获取种子详细信息
getTorrentDetails(torrentHash, clientId) {
return api.get(`/torrents/${torrentHash}/details`, {
params: { client_id: clientId }
})
},
// 添加种子文件
addTorrentFile(clientId, file, options = {}) {
const formData = new FormData()
formData.append('client_id', clientId)
formData.append('torrent_file', file)
// 添加可选参数
if (options.category) formData.append('category', options.category)
if (options.tags) formData.append('tags', options.tags)
if (options.savePath) formData.append('save_path', options.savePath)
if (options.paused) formData.append('paused', options.paused.toString())
return api.post('/torrents/add', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 添加磁力链接
addMagnetLink(clientId, magnetLink, options = {}) {
return api.post('/torrents/add', {
client_id: clientId,
magnet_link: magnetLink,
options
})
},
// 添加种子URL
addTorrentUrl(clientId, torrentUrl, options = {}) {
return api.post('/torrents/add', {
client_id: clientId,
torrent_url: torrentUrl,
options
})
}
}

View File

@ -0,0 +1,260 @@
<template>
<el-dialog
title="添加种子"
v-model="visible"
width="600px"
@close="resetForm"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="客户端" prop="clientId">
<el-select v-model="form.clientId" placeholder="选择客户端" style="width: 100%">
<el-option
v-for="client in connectedClients"
:key="client.id"
:label="client.name"
:value="client.id"
/>
</el-select>
</el-form-item>
<el-form-item label="添加方式">
<el-radio-group v-model="addMethod">
<el-radio value="file">种子文件</el-radio>
<el-radio value="magnet">磁力链接</el-radio>
<el-radio value="url">种子URL</el-radio>
</el-radio-group>
</el-form-item>
<!-- 种子文件上传 -->
<el-form-item v-if="addMethod === 'file'" label="种子文件" prop="torrentFile">
<el-upload
ref="uploadRef"
:auto-upload="false"
:show-file-list="true"
:limit="1"
accept=".torrent"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
>
<el-button type="primary">选择文件</el-button>
<template #tip>
<div class="el-upload__tip">
只能上传 .torrent 文件
</div>
</template>
</el-upload>
</el-form-item>
<!-- 磁力链接 -->
<el-form-item v-if="addMethod === 'magnet'" label="磁力链接" prop="magnetLink">
<el-input
v-model="form.magnetLink"
type="textarea"
:rows="3"
placeholder="magnet:?xt=urn:btih:..."
/>
</el-form-item>
<!-- 种子URL -->
<el-form-item v-if="addMethod === 'url'" label="种子URL" prop="torrentUrl">
<el-input
v-model="form.torrentUrl"
placeholder="https://example.com/torrent.torrent"
/>
</el-form-item>
<!-- 可选设置 -->
<el-divider content-position="left">可选设置</el-divider>
<el-form-item label="分类">
<el-input v-model="form.category" placeholder="种子分类" />
</el-form-item>
<el-form-item label="标签">
<el-input v-model="form.tags" placeholder="用逗号分隔多个标签" />
</el-form-item>
<el-form-item label="保存路径">
<el-input v-model="form.savePath" placeholder="自定义保存路径" />
</el-form-item>
<el-form-item label="添加后暂停">
<el-switch v-model="form.paused" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">
添加种子
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useClientsStore } from '@/stores/clients'
import { torrentsApi } from '@/api/torrents'
import { ElMessage } from 'element-plus'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'success'])
// Stores
const clientsStore = useClientsStore()
// 响应式数据
const visible = ref(false)
const addMethod = ref('file')
const submitting = ref(false)
const formRef = ref()
const uploadRef = ref()
const form = ref({
clientId: '',
torrentFile: null,
magnetLink: '',
torrentUrl: '',
category: '',
tags: '',
savePath: '',
paused: false
})
const rules = {
clientId: [
{ required: true, message: '请选择客户端', trigger: 'change' }
],
torrentFile: [
{ required: true, message: '请选择种子文件', trigger: 'change' }
],
magnetLink: [
{ required: true, message: '请输入磁力链接', trigger: 'blur' },
{ pattern: /^magnet:/, message: '请输入有效的磁力链接', trigger: 'blur' }
],
torrentUrl: [
{ required: true, message: '请输入种子URL', trigger: 'blur' },
{ type: 'url', message: '请输入有效的URL', trigger: 'blur' }
]
}
// 计算属性
const connectedClients = computed(() => clientsStore.connectedClients)
// 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
})
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 监听添加方式变化,重置验证
watch(addMethod, () => {
if (formRef.value) {
formRef.value.clearValidate()
}
})
// 方法
const handleFileChange = (file) => {
form.value.torrentFile = file.raw
}
const handleFileRemove = () => {
form.value.torrentFile = null
}
const submitForm = async () => {
if (!formRef.value) return
try {
// 根据添加方式验证不同字段
const fieldsToValidate = ['clientId']
if (addMethod.value === 'file') fieldsToValidate.push('torrentFile')
if (addMethod.value === 'magnet') fieldsToValidate.push('magnetLink')
if (addMethod.value === 'url') fieldsToValidate.push('torrentUrl')
await formRef.value.validateField(fieldsToValidate)
submitting.value = true
const options = {
category: form.value.category,
tags: form.value.tags,
savePath: form.value.savePath,
paused: form.value.paused
}
let response
if (addMethod.value === 'file') {
response = await torrentsApi.addTorrentFile(form.value.clientId, form.value.torrentFile, options)
} else if (addMethod.value === 'magnet') {
response = await torrentsApi.addMagnetLink(form.value.clientId, form.value.magnetLink, options)
} else if (addMethod.value === 'url') {
response = await torrentsApi.addTorrentUrl(form.value.clientId, form.value.torrentUrl, options)
}
if (response.success) {
ElMessage.success(response.message || '种子添加成功')
visible.value = false
emit('success')
} else {
ElMessage.error(response.error || '添加失败')
}
} catch (error) {
ElMessage.error(`添加种子失败: ${error.message}`)
} finally {
submitting.value = false
}
}
const resetForm = () => {
form.value = {
clientId: '',
torrentFile: null,
magnetLink: '',
torrentUrl: '',
category: '',
tags: '',
savePath: '',
paused: false
}
addMethod.value = 'file'
if (formRef.value) {
formRef.value.resetFields()
}
if (uploadRef.value) {
uploadRef.value.clearFiles()
}
}
</script>
<style scoped>
.el-divider {
margin: 20px 0;
}
.el-upload__tip {
color: #909399;
font-size: 12px;
margin-top: 5px;
}
</style>

View File

@ -0,0 +1,258 @@
<template>
<div class="client-manager">
<div class="header">
<h3>客户端管理</h3>
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon>
添加客户端
</el-button>
</div>
<el-table
:data="clients"
v-loading="loading"
style="width: 100%"
empty-text="暂无客户端"
>
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="host" label="主机" min-width="120" />
<el-table-column prop="port" label="端口" width="80" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.connected ? 'success' : 'danger'">
{{ row.connected ? '已连接' : '未连接' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" min-width="100">
<template #default="{ row }">
<span v-if="row.version">{{ row.version }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="testConnection(row.id)">
测试
</el-button>
<el-button size="small" type="primary" @click="editClient(row)">
编辑
</el-button>
<el-button size="small" type="danger" @click="deleteClient(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 添加/编辑客户端对话框 -->
<el-dialog
:title="editingClient ? '编辑客户端' : '添加客户端'"
v-model="showAddDialog"
width="500px"
@close="resetForm"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="80px"
>
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入客户端名称" />
</el-form-item>
<el-form-item label="主机" prop="host">
<el-input v-model="form.host" placeholder="例如: 192.168.1.100" />
</el-form-item>
<el-form-item label="端口" prop="port">
<el-input-number
v-model="form.port"
:min="1"
:max="65535"
placeholder="8080"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="qBittorrent 用户名" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="qBittorrent 密码"
show-password
/>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">
{{ editingClient ? '更新' : '添加' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useClientsStore } from '@/stores/clients'
import { ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
const clientsStore = useClientsStore()
// 响应式数据
const showAddDialog = ref(false)
const editingClient = ref(null)
const submitting = ref(false)
const formRef = ref()
const form = ref({
name: '',
host: '',
port: 8080,
username: 'admin',
password: '',
enabled: true
})
const rules = {
name: [
{ required: true, message: '请输入客户端名称', trigger: 'blur' }
],
host: [
{ required: true, message: '请输入主机地址', trigger: 'blur' }
],
port: [
{ required: true, message: '请输入端口号', trigger: 'blur' },
{ type: 'number', min: 1, max: 65535, message: '端口号必须在1-65535之间', trigger: 'blur' }
]
}
// 计算属性
const clients = computed(() => clientsStore.clients)
const loading = computed(() => clientsStore.loading)
// 方法
const fetchClients = () => {
clientsStore.fetchClients()
}
const testConnection = async (clientId) => {
await clientsStore.testConnection(clientId)
}
const editClient = (client) => {
editingClient.value = client
form.value = {
name: client.name,
host: client.host,
port: client.port,
username: client.username || 'admin',
password: '', // 不显示原密码
enabled: client.enabled !== false
}
showAddDialog.value = true
}
const deleteClient = async (client) => {
try {
await ElMessageBox.confirm(
`确定要删除客户端 "${client.name}" 吗?`,
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
await clientsStore.deleteClient(client.id)
} catch (error) {
// 用户取消删除
}
}
const submitForm = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
submitting.value = true
if (editingClient.value) {
await clientsStore.updateClient(editingClient.value.id, form.value)
} else {
await clientsStore.addClient(form.value)
}
showAddDialog.value = false
resetForm()
} catch (error) {
// 表单验证失败或提交失败
} finally {
submitting.value = false
}
}
const resetForm = () => {
editingClient.value = null
form.value = {
name: '',
host: '',
port: 8080,
username: 'admin',
password: '',
enabled: true
}
if (formRef.value) {
formRef.value.resetFields()
}
}
// 生命周期
onMounted(() => {
fetchClients()
})
</script>
<style scoped>
.client-manager {
background: white;
border-radius: 8px;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header h3 {
margin: 0;
color: #303133;
}
.text-muted {
color: #909399;
}
@media (max-width: 768px) {
.client-manager {
padding: 15px;
}
.header {
flex-direction: column;
align-items: stretch;
gap: 15px;
}
}
</style>

View File

@ -0,0 +1,156 @@
<template>
<div class="global-stats">
<el-row :gutter="20">
<el-col :xs="12" :sm="6" :md="6" :lg="6">
<el-card class="stats-card">
<div class="stats-item">
<div class="stats-icon download">
<el-icon><Download /></el-icon>
</div>
<div class="stats-content">
<div class="stats-value">{{ formatSpeed(downloadSpeed) }}</div>
<div class="stats-label">下载速度</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6" :md="6" :lg="6">
<el-card class="stats-card">
<div class="stats-item">
<div class="stats-icon upload">
<el-icon><Upload /></el-icon>
</div>
<div class="stats-content">
<div class="stats-value">{{ formatSpeed(uploadSpeed) }}</div>
<div class="stats-label">上传速度</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6" :md="6" :lg="6">
<el-card class="stats-card">
<div class="stats-item">
<div class="stats-icon total">
<el-icon><Document /></el-icon>
</div>
<div class="stats-content">
<div class="stats-value">{{ totalTorrents }}</div>
<div class="stats-label">总种子数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6" :md="6" :lg="6">
<el-card class="stats-card">
<div class="stats-item">
<div class="stats-icon active">
<el-icon><VideoPlay /></el-icon>
</div>
<div class="stats-content">
<div class="stats-value">{{ activeTorrents }}</div>
<div class="stats-label">活跃种子</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useTorrentsStore } from '@/stores/torrents'
import { Download, Upload, Document, VideoPlay } from '@element-plus/icons-vue'
const torrentsStore = useTorrentsStore()
const downloadSpeed = computed(() => torrentsStore.downloadSpeed)
const uploadSpeed = computed(() => torrentsStore.uploadSpeed)
const totalTorrents = computed(() => torrentsStore.totalTorrents)
const activeTorrents = computed(() => torrentsStore.globalStats.active_torrents)
const formatSpeed = (speed) => torrentsStore.formatSpeed(speed)
</script>
<style scoped>
.global-stats {
margin-bottom: 20px;
}
.stats-card {
border-radius: 8px;
transition: all 0.3s ease;
}
.stats-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stats-item {
display: flex;
align-items: center;
padding: 10px 0;
}
.stats-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
font-size: 20px;
color: white;
}
.stats-icon.download {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.stats-icon.upload {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.stats-icon.total {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stats-icon.active {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
.stats-content {
flex: 1;
}
.stats-value {
font-size: 24px;
font-weight: bold;
color: #303133;
line-height: 1;
margin-bottom: 5px;
}
.stats-label {
font-size: 14px;
color: #909399;
}
@media (max-width: 768px) {
.stats-value {
font-size: 20px;
}
.stats-icon {
width: 40px;
height: 40px;
font-size: 18px;
margin-right: 10px;
}
}
</style>

View File

@ -0,0 +1,542 @@
<template>
<div class="torrent-list">
<div class="toolbar">
<div class="filters">
<el-select v-model="filterStatus" placeholder="状态筛选" style="width: 120px">
<el-option label="全部" value="all" />
<el-option label="下载中" value="downloading" />
<el-option label="做种中" value="seeding" />
<el-option label="已暂停" value="paused" />
<el-option label="已完成" value="completed" />
</el-select>
<el-select
v-model="filterClients"
multiple
placeholder="客户端筛选"
style="width: 200px"
collapse-tags
>
<el-option
v-for="client in connectedClients"
:key="client.id"
:label="client.name"
:value="client.id"
/>
</el-select>
</div>
<div class="actions">
<el-button
size="small"
:disabled="selectedTorrents.length === 0"
@click="resumeSelected"
>
<el-icon><VideoPlay /></el-icon>
恢复
</el-button>
<el-button
size="small"
:disabled="selectedTorrents.length === 0"
@click="pauseSelected"
>
<el-icon><VideoPause /></el-icon>
暂停
</el-button>
<el-button
size="small"
type="danger"
:disabled="selectedTorrents.length === 0"
@click="deleteSelected"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
<el-button
size="small"
@click="refreshData"
:loading="refreshing"
>
<el-icon><Refresh /></el-icon>
{{ refreshing ? '刷新中...' : '刷新' }}
</el-button>
<el-button
size="small"
type="success"
@click="showAddDialog = true"
>
<el-icon><Plus /></el-icon>
添加种子
</el-button>
</div>
</div>
<el-table
:data="filteredTorrents"
v-loading="loading"
:element-loading-text="loadingText"
@selection-change="handleSelectionChange"
style="width: 100%"
empty-text="暂无种子"
max-height="600"
>
<el-table-column type="selection" width="55" />
<el-table-column label="名称" min-width="250">
<template #default="{ row }">
<div class="torrent-name">
<div class="name">{{ row.name }}</div>
<div class="torrent-meta">
<el-tag size="small" type="info">{{ row.client_name }}</el-tag>
<el-tag v-if="row.category" size="small" type="warning">{{ row.category }}</el-tag>
<el-tag v-for="tag in getTags(row.tags)" :key="tag" size="small" type="success">{{ tag }}</el-tag>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="大小" width="100">
<template #default="{ row }">
{{ formatSize(row.size) }}
</template>
</el-table-column>
<el-table-column label="进度" width="120">
<template #default="{ row }">
<el-progress
:percentage="Math.round(row.progress * 100)"
:stroke-width="6"
:show-text="false"
/>
<div class="progress-text">{{ Math.round(row.progress * 100) }}%</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.state)" size="small">
{{ getStatusText(row.state) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="做种/下载" width="100">
<template #default="{ row }">
<div class="peers-info">
<span class="seeds">{{ row.num_seeds || 0 }}</span>
<span class="separator">/</span>
<span class="leeches">{{ row.num_leechs || 0 }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="分享率" width="80">
<template #default="{ row }">
<span :class="getRatioClass(row.ratio)">
{{ formatRatio(row.ratio) }}
</span>
</template>
</el-table-column>
<el-table-column label="下载速度" width="100">
<template #default="{ row }">
{{ formatSpeed(row.dlspeed) }}
</template>
</el-table-column>
<el-table-column label="上传速度" width="100">
<template #default="{ row }">
{{ formatSpeed(row.upspeed) }}
</template>
</el-table-column>
<el-table-column label="添加时间" width="120">
<template #default="{ row }">
{{ formatDate(row.added_on) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button
size="small"
v-if="row.state.includes('paused')"
@click="resumeTorrent(row)"
>
恢复
</el-button>
<el-button
size="small"
v-else
@click="pauseTorrent(row)"
>
暂停
</el-button>
<el-button
size="small"
type="danger"
@click="deleteTorrent(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 状态栏 -->
<div class="status-bar">
<div class="status-info">
<span v-if="lastUpdateTime" class="last-update">
最后更新: {{ formatTime(lastUpdateTime) }}
</span>
<span v-if="operationLoading" class="operation-status">
<el-icon class="is-loading"><Loading /></el-icon>
操作执行中...
</span>
</div>
<div class="torrent-count">
显示 {{ filteredTorrents.length }} / {{ torrentsStore.totalTorrents }} 个种子
</div>
</div>
<!-- 添加种子对话框 -->
<AddTorrentDialog
v-model="showAddDialog"
@success="handleAddSuccess"
/>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useTorrentsStore } from '@/stores/torrents'
import { useClientsStore } from '@/stores/clients'
import { ElMessageBox } from 'element-plus'
import { VideoPlay, VideoPause, Delete, Refresh, Loading, Plus } from '@element-plus/icons-vue'
import AddTorrentDialog from './AddTorrentDialog.vue'
const torrentsStore = useTorrentsStore()
const clientsStore = useClientsStore()
// 响应式数据
const selectedTorrents = ref([])
const filterStatus = ref('all')
const filterClients = ref([])
const showAddDialog = ref(false)
// 计算属性
const filteredTorrents = computed(() => torrentsStore.filteredTorrents)
const loading = computed(() => torrentsStore.loading)
const refreshing = computed(() => torrentsStore.refreshing)
const operationLoading = computed(() => torrentsStore.operationLoading)
const connectedClients = computed(() => clientsStore.connectedClients)
const lastUpdateTime = computed(() => torrentsStore.lastUpdateTime)
// 加载文本
const loadingText = computed(() => {
if (refreshing.value) return '刷新数据中...'
if (operationLoading.value) return '操作执行中...'
return '加载中...'
})
// 监听筛选条件变化
watch([filterStatus, filterClients], () => {
torrentsStore.setFilter({
status: filterStatus.value,
clientIds: filterClients.value
})
})
// 方法
const handleSelectionChange = (selection) => {
selectedTorrents.value = selection
torrentsStore.setSelectedTorrents(selection)
}
const refreshData = () => {
torrentsStore.fetchTorrents(filterClients.value, true) // 传入 isRefresh = true
}
const handleAddSuccess = () => {
// 添加种子成功后刷新数据
refreshData()
}
const resumeSelected = async () => {
const hashes = selectedTorrents.value.map(t => t.hash)
await torrentsStore.resumeTorrents(hashes)
}
const pauseSelected = async () => {
const hashes = selectedTorrents.value.map(t => t.hash)
await torrentsStore.pauseTorrents(hashes)
}
const deleteSelected = async () => {
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedTorrents.value.length} 个种子吗?`,
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
const hashes = selectedTorrents.value.map(t => t.hash)
await torrentsStore.deleteTorrents(hashes, false)
} catch (error) {
// 用户取消删除
}
}
const resumeTorrent = async (torrent) => {
await torrentsStore.resumeTorrents([torrent.hash], torrent.client_id)
}
const pauseTorrent = async (torrent) => {
await torrentsStore.pauseTorrents([torrent.hash], torrent.client_id)
}
const deleteTorrent = async (torrent) => {
try {
await ElMessageBox.confirm(
`确定要删除种子 "${torrent.name}" 吗?`,
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
await torrentsStore.deleteTorrents([torrent.hash], false, torrent.client_id)
} catch (error) {
// 用户取消删除
}
}
const formatSize = (size) => torrentsStore.formatSize(size)
const formatSpeed = (speed) => torrentsStore.formatSpeed(speed)
const formatTime = (time) => {
if (!time) return ''
const now = new Date()
const diff = now - time
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
return time.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const formatDate = (timestamp) => {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000) // Unix 时间戳转换
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
const formatRatio = (ratio) => {
if (ratio === undefined || ratio === null) return '-'
if (ratio === -1) return '∞'
return ratio.toFixed(2)
}
const getRatioClass = (ratio) => {
if (ratio === undefined || ratio === null) return ''
if (ratio >= 2) return 'ratio-excellent'
if (ratio >= 1) return 'ratio-good'
if (ratio >= 0.5) return 'ratio-normal'
return 'ratio-low'
}
const getTags = (tags) => {
if (!tags) return []
return tags.split(',').filter(tag => tag.trim()).slice(0, 3) // 最多显示3个标签
}
const getStatusType = (state) => {
if (state.includes('paused')) return 'info'
if (['downloading', 'stalledDL', 'metaDL'].includes(state)) return 'primary'
if (['uploading', 'stalledUP'].includes(state)) return 'success'
if (state === 'error') return 'danger'
return 'info'
}
const getStatusText = (state) => {
const statusMap = {
'downloading': '下载中',
'uploading': '上传中',
'stalledDL': '等待下载',
'stalledUP': '等待上传',
'pausedDL': '已暂停',
'pausedUP': '已暂停',
'queuedDL': '排队下载',
'queuedUP': '排队上传',
'checkingDL': '检查中',
'checkingUP': '检查中',
'error': '错误',
'missingFiles': '文件丢失',
'allocating': '分配空间'
}
return statusMap[state] || state
}
// 生命周期
onMounted(() => {
refreshData()
})
</script>
<style scoped>
.torrent-list {
background: white;
border-radius: 8px;
padding: 20px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 15px;
}
.filters {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.torrent-name {
display: flex;
flex-direction: column;
gap: 5px;
}
.name {
font-weight: 500;
color: #303133;
word-break: break-all;
line-height: 1.4;
}
.torrent-meta {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.peers-info {
display: flex;
align-items: center;
font-size: 12px;
}
.seeds {
color: #67c23a;
font-weight: 500;
}
.leeches {
color: #e6a23c;
font-weight: 500;
}
.separator {
color: #909399;
margin: 0 2px;
}
.ratio-excellent {
color: #67c23a;
font-weight: 600;
}
.ratio-good {
color: #409eff;
font-weight: 500;
}
.ratio-normal {
color: #e6a23c;
}
.ratio-low {
color: #f56c6c;
}
.progress-text {
text-align: center;
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-top: 1px solid #ebeef5;
margin-top: 10px;
font-size: 12px;
color: #909399;
}
.status-info {
display: flex;
align-items: center;
gap: 15px;
}
.operation-status {
display: flex;
align-items: center;
gap: 5px;
color: #409eff;
}
.last-update {
color: #606266;
}
.torrent-count {
font-weight: 500;
}
@media (max-width: 768px) {
.torrent-list {
padding: 15px;
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.filters,
.actions {
justify-content: center;
}
}
</style>

21
frontend/src/main.js Normal file
View File

@ -0,0 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@ -0,0 +1,17 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '@/views/Dashboard.vue'
const routes = [
{
path: '/',
name: 'Dashboard',
component: Dashboard
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View File

@ -0,0 +1,67 @@
/**
* 应用全局状态管理
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
// 状态
const theme = ref('light')
const sidebarCollapsed = ref(false)
const refreshInterval = ref(5000) // 5秒刷新间隔
const autoRefresh = ref(true)
// 操作方法
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
// 保存到本地存储
localStorage.setItem('theme', theme.value)
}
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
const setRefreshInterval = (interval) => {
refreshInterval.value = interval
localStorage.setItem('refreshInterval', interval.toString())
}
const toggleAutoRefresh = () => {
autoRefresh.value = !autoRefresh.value
localStorage.setItem('autoRefresh', autoRefresh.value.toString())
}
const initializeSettings = () => {
// 从本地存储恢复设置
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
theme.value = savedTheme
}
const savedInterval = localStorage.getItem('refreshInterval')
if (savedInterval) {
refreshInterval.value = parseInt(savedInterval)
}
const savedAutoRefresh = localStorage.getItem('autoRefresh')
if (savedAutoRefresh) {
autoRefresh.value = savedAutoRefresh === 'true'
}
}
return {
// 状态
theme,
sidebarCollapsed,
refreshInterval,
autoRefresh,
// 方法
toggleTheme,
toggleSidebar,
setRefreshInterval,
toggleAutoRefresh,
initializeSettings
}
})

View File

@ -0,0 +1,128 @@
/**
* 客户端状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { clientsApi } from '@/api/clients'
import { ElMessage } from 'element-plus'
export const useClientsStore = defineStore('clients', () => {
// 状态
const clients = ref([])
const loading = ref(false)
// 计算属性
const enabledClients = computed(() =>
clients.value.filter(client => client.enabled)
)
const connectedClients = computed(() =>
clients.value.filter(client => client.connected)
)
const clientsCount = computed(() => clients.value.length)
// 操作方法
const fetchClients = async () => {
loading.value = true
try {
const response = await clientsApi.getClients()
if (response.success) {
clients.value = response.data
}
} catch (error) {
ElMessage.error(`获取客户端列表失败: ${error.message}`)
} finally {
loading.value = false
}
}
const addClient = async (clientData) => {
try {
const response = await clientsApi.addClient(clientData)
if (response.success) {
clients.value.push(response.data)
ElMessage.success('客户端添加成功')
return response.data
}
} catch (error) {
ElMessage.error(`添加客户端失败: ${error.message}`)
throw error
}
}
const updateClient = async (clientId, clientData) => {
try {
const response = await clientsApi.updateClient(clientId, clientData)
if (response.success) {
const index = clients.value.findIndex(c => c.id === clientId)
if (index !== -1) {
clients.value[index] = response.data
}
ElMessage.success('客户端更新成功')
return response.data
}
} catch (error) {
ElMessage.error(`更新客户端失败: ${error.message}`)
throw error
}
}
const deleteClient = async (clientId) => {
try {
const response = await clientsApi.deleteClient(clientId)
if (response.success) {
clients.value = clients.value.filter(c => c.id !== clientId)
ElMessage.success('客户端删除成功')
}
} catch (error) {
ElMessage.error(`删除客户端失败: ${error.message}`)
throw error
}
}
const testConnection = async (clientId) => {
try {
const response = await clientsApi.testConnection(clientId)
if (response.success) {
ElMessage.success('连接测试成功')
// 更新客户端连接状态
const client = clients.value.find(c => c.id === clientId)
if (client) {
client.connected = true
client.version = response.version
client.web_api_version = response.web_api_version
}
} else {
ElMessage.error(`连接测试失败: ${response.error}`)
}
return response
} catch (error) {
ElMessage.error(`连接测试失败: ${error.message}`)
throw error
}
}
const getClientById = (clientId) => {
return clients.value.find(c => c.id === clientId)
}
return {
// 状态
clients,
loading,
// 计算属性
enabledClients,
connectedClients,
clientsCount,
// 方法
fetchClients,
addClient,
updateClient,
deleteClient,
testConnection,
getClientById
}
})

View File

@ -0,0 +1,227 @@
/**
* 种子状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { torrentsApi } from '@/api/torrents'
import { ElMessage } from 'element-plus'
export const useTorrentsStore = defineStore('torrents', () => {
// 状态
const torrents = ref([])
const globalStats = ref({
download_speed: 0,
upload_speed: 0,
total_torrents: 0,
active_torrents: 0,
downloading: 0,
seeding: 0,
paused: 0
})
const clientsStatus = ref([])
const loading = ref(false)
const refreshing = ref(false) // 区分初始加载和刷新
const operationLoading = ref(false) // 操作加载状态
const selectedTorrents = ref([])
const filterOptions = ref({
status: 'all',
clientIds: []
})
const lastUpdateTime = ref(null)
// 计算属性
const filteredTorrents = computed(() => {
let filtered = torrents.value
// 按状态过滤
if (filterOptions.value.status !== 'all') {
filtered = filtered.filter(torrent => {
switch (filterOptions.value.status) {
case 'downloading':
return ['downloading', 'stalledDL', 'metaDL'].includes(torrent.state)
case 'seeding':
return ['uploading', 'stalledUP'].includes(torrent.state)
case 'paused':
return torrent.state.toLowerCase().includes('paused')
case 'completed':
return torrent.progress === 1
default:
return true
}
})
}
// 按客户端过滤
if (filterOptions.value.clientIds.length > 0) {
filtered = filtered.filter(torrent =>
filterOptions.value.clientIds.includes(torrent.client_id)
)
}
return filtered
})
const downloadSpeed = computed(() => globalStats.value.download_speed)
const uploadSpeed = computed(() => globalStats.value.upload_speed)
const totalTorrents = computed(() => globalStats.value.total_torrents)
// 操作方法
const fetchTorrents = async (clientIds = [], isRefresh = false) => {
const loadingRef = isRefresh ? refreshing : loading
loadingRef.value = true
try {
const response = await torrentsApi.getMainData(clientIds)
if (response.success) {
torrents.value = response.data.torrents
globalStats.value = response.data.global_stats
clientsStatus.value = response.data.clients_status
lastUpdateTime.value = new Date()
}
} catch (error) {
ElMessage.error(`获取种子列表失败: ${error.message}`)
} finally {
loadingRef.value = false
}
}
// 静默刷新数据(不显示加载状态)
const silentRefresh = async (clientIds = []) => {
try {
const response = await torrentsApi.getMainData(clientIds)
if (response.success) {
torrents.value = response.data.torrents
globalStats.value = response.data.global_stats
clientsStatus.value = response.data.clients_status
lastUpdateTime.value = new Date()
}
} catch (error) {
// 静默失败,不显示错误消息
console.error('Silent refresh failed:', error)
}
}
const pauseTorrents = async (hashes, clientId = null) => {
operationLoading.value = true
try {
const response = await torrentsApi.pauseTorrents(hashes, clientId)
if (response.success) {
ElMessage.success('种子暂停成功')
// 静默刷新数据,不显示加载状态
await silentRefresh(filterOptions.value.clientIds)
}
} catch (error) {
ElMessage.error(`暂停种子失败: ${error.message}`)
throw error
} finally {
operationLoading.value = false
}
}
const resumeTorrents = async (hashes, clientId = null) => {
operationLoading.value = true
try {
const response = await torrentsApi.resumeTorrents(hashes, clientId)
if (response.success) {
ElMessage.success('种子恢复成功')
// 静默刷新数据,不显示加载状态
await silentRefresh(filterOptions.value.clientIds)
}
} catch (error) {
ElMessage.error(`恢复种子失败: ${error.message}`)
throw error
} finally {
operationLoading.value = false
}
}
const deleteTorrents = async (hashes, deleteFiles = false, clientId = null) => {
operationLoading.value = true
try {
const response = await torrentsApi.deleteTorrents(hashes, deleteFiles, clientId)
if (response.success) {
ElMessage.success('种子删除成功')
// 静默刷新数据,不显示加载状态
await silentRefresh(filterOptions.value.clientIds)
}
} catch (error) {
ElMessage.error(`删除种子失败: ${error.message}`)
throw error
} finally {
operationLoading.value = false
}
}
const getTorrentDetails = async (torrentHash, clientId) => {
try {
const response = await torrentsApi.getTorrentDetails(torrentHash, clientId)
if (response.success) {
return response.data
}
} catch (error) {
ElMessage.error(`获取种子详情失败: ${error.message}`)
throw error
}
}
const setSelectedTorrents = (torrents) => {
selectedTorrents.value = torrents
}
const setFilter = (filter) => {
filterOptions.value = { ...filterOptions.value, ...filter }
}
const formatSpeed = (speed) => {
if (speed === 0) return '0 B/s'
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']
let unitIndex = 0
while (speed >= 1024 && unitIndex < units.length - 1) {
speed /= 1024
unitIndex++
}
return `${speed.toFixed(1)} ${units[unitIndex]}`
}
const formatSize = (size) => {
if (size === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
return {
// 状态
torrents,
globalStats,
clientsStatus,
loading,
refreshing,
operationLoading,
selectedTorrents,
filterOptions,
lastUpdateTime,
// 计算属性
filteredTorrents,
downloadSpeed,
uploadSpeed,
totalTorrents,
// 方法
fetchTorrents,
silentRefresh,
pauseTorrents,
resumeTorrents,
deleteTorrents,
getTorrentDetails,
setSelectedTorrents,
setFilter,
formatSpeed,
formatSize
}
})

View File

@ -0,0 +1,160 @@
<template>
<div class="dashboard">
<div class="header">
<h1>qBittorrent 管理平台</h1>
<div class="header-actions">
<el-button @click="toggleAutoRefresh">
<el-icon><Timer /></el-icon>
{{ autoRefresh ? '停止自动刷新' : '开启自动刷新' }}
</el-button>
<el-button @click="showClientManager = !showClientManager">
<el-icon><Setting /></el-icon>
{{ showClientManager ? '隐藏客户端管理' : '显示客户端管理' }}
</el-button>
</div>
</div>
<!-- 全局统计 -->
<GlobalStats />
<!-- 客户端管理 -->
<div v-if="showClientManager" class="section">
<ClientManager />
</div>
<!-- 种子列表 -->
<div class="section">
<TorrentList />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useAppStore } from '@/stores/app'
import { useTorrentsStore } from '@/stores/torrents'
import { useClientsStore } from '@/stores/clients'
import GlobalStats from '@/components/GlobalStats.vue'
import ClientManager from '@/components/ClientManager.vue'
import TorrentList from '@/components/TorrentList.vue'
import { Timer, Setting } from '@element-plus/icons-vue'
const appStore = useAppStore()
const torrentsStore = useTorrentsStore()
const clientsStore = useClientsStore()
// 响应式数据
const showClientManager = ref(false)
let refreshTimer = null
// 计算属性
const autoRefresh = computed(() => appStore.autoRefresh)
const refreshInterval = computed(() => appStore.refreshInterval)
// 方法
const toggleAutoRefresh = () => {
appStore.toggleAutoRefresh()
if (appStore.autoRefresh) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
}
const startAutoRefresh = () => {
if (refreshTimer) {
clearInterval(refreshTimer)
}
refreshTimer = setInterval(() => {
// 使用静默刷新,不显示加载状态
torrentsStore.silentRefresh()
}, refreshInterval.value)
}
const stopAutoRefresh = () => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
const initializeData = async () => {
// 初始化应用设置
appStore.initializeSettings()
// 获取客户端列表
await clientsStore.fetchClients()
// 获取种子数据
await torrentsStore.fetchTorrents()
// 开启自动刷新
if (appStore.autoRefresh) {
startAutoRefresh()
}
}
// 生命周期
onMounted(() => {
initializeData()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<style scoped>
.dashboard {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
flex-wrap: wrap;
gap: 20px;
}
.header h1 {
margin: 0;
color: #303133;
font-size: 28px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.section {
margin-bottom: 30px;
}
@media (max-width: 768px) {
.dashboard {
padding: 15px;
}
.header {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.header h1 {
font-size: 24px;
}
.header-actions {
justify-content: center;
}
}
</style>

21
frontend/vite.config.js Normal file
View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8888',
changeOrigin: true
}
}
}
})