完成基础开发
This commit is contained in:
32
frontend/Dockerfile
Normal file
32
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
40
frontend/nginx.conf
Normal 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
3155
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal 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
23
frontend/src/App.vue
Normal 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>
|
31
frontend/src/api/clients.js
Normal file
31
frontend/src/api/clients.js
Normal 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
39
frontend/src/api/index.js
Normal 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
|
87
frontend/src/api/torrents.js
Normal file
87
frontend/src/api/torrents.js
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
260
frontend/src/components/AddTorrentDialog.vue
Normal file
260
frontend/src/components/AddTorrentDialog.vue
Normal 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>
|
258
frontend/src/components/ClientManager.vue
Normal file
258
frontend/src/components/ClientManager.vue
Normal 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>
|
156
frontend/src/components/GlobalStats.vue
Normal file
156
frontend/src/components/GlobalStats.vue
Normal 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>
|
542
frontend/src/components/TorrentList.vue
Normal file
542
frontend/src/components/TorrentList.vue
Normal 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
21
frontend/src/main.js
Normal 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')
|
17
frontend/src/router/index.js
Normal file
17
frontend/src/router/index.js
Normal 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
|
67
frontend/src/stores/app.js
Normal file
67
frontend/src/stores/app.js
Normal 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
|
||||
}
|
||||
})
|
128
frontend/src/stores/clients.js
Normal file
128
frontend/src/stores/clients.js
Normal 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
|
||||
}
|
||||
})
|
227
frontend/src/stores/torrents.js
Normal file
227
frontend/src/stores/torrents.js
Normal 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
|
||||
}
|
||||
})
|
160
frontend/src/views/Dashboard.vue
Normal file
160
frontend/src/views/Dashboard.vue
Normal 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
21
frontend/vite.config.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
Reference in New Issue
Block a user