init
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
34
frontend/Dockerfile
Normal file
34
frontend/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
# Dockerfile
|
||||
|
||||
# ---- Stage 1: Build ----
|
||||
# 使用一个包含 Node.js 的镜像来构建前端应用
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 package.json 和 package-lock.json 并安装依赖
|
||||
# 这样可以利用 Docker 的层缓存
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# 复制所有源代码
|
||||
COPY . .
|
||||
|
||||
# 执行构建命令
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 2: Production ----
|
||||
# 使用一个轻量的 Nginx 镜像来托管构建好的静态文件
|
||||
FROM nginx:alpine
|
||||
|
||||
# 从构建阶段复制编译好的文件到 Nginx 的网站根目录
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# 复制自定义的 Nginx 配置文件
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 暴露 80 端口
|
||||
EXPOSE 80
|
||||
|
||||
# 启动 Nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
frontend/nginx.conf
Normal file
34
frontend/nginx.conf
Normal file
@@ -0,0 +1,34 @@
|
||||
# nginx.conf
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 网站根目录
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
# API 反向代理配置
|
||||
# 所有 /api/ 的请求都转发给名为 'backend' 的服务(在 docker-compose.yml 中定义)的 8000 端口
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:5000/; # 这里的 skit-panel-backend 必须与 docker-compose 中的服务名一致
|
||||
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;
|
||||
}
|
||||
|
||||
# 播放器反向代理 (可选,但推荐,以解决跨域问题)
|
||||
# 假设你的 Node.js 播放器服务在 docker-compose 中名为 skit-panel-player,端口为 3001
|
||||
# location /player/ {
|
||||
# proxy_pass http://skit-panel-player:3001/;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection "upgrade";
|
||||
# }
|
||||
|
||||
# 处理 Vue Router (SPA) 的路由
|
||||
# 如果请求的文件或目录不存在,则返回 index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
1956
frontend/package-lock.json
generated
Normal file
1956
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "skit-panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.11.0",
|
||||
"element-plus": "^2.11.2",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
3
frontend/src/App.vue
Normal file
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
154
frontend/src/api/index.js
Normal file
154
frontend/src/api/index.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import axios from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
// true: 使用模拟数据, false: 请求真实后端
|
||||
const isMock = false;
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api', // Nginx 将会把这个前缀代理到后端服务
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => response.data,
|
||||
error => {
|
||||
ElMessage.error(error.message || '网络请求失败');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// ---- 模拟数据生成器 (用于分页列表) ----
|
||||
const createMockListData = (page, pageSize, total, itemGenerator) => {
|
||||
const items = [];
|
||||
const count = Math.min(pageSize, total - (page - 1) * pageSize);
|
||||
for (let i = 0; i < count; i++) {
|
||||
items.push(itemGenerator(i + (page - 1) * pageSize));
|
||||
}
|
||||
// 模拟网络延迟
|
||||
return new Promise(resolve => setTimeout(() => {
|
||||
resolve({
|
||||
total: total,
|
||||
list: items,
|
||||
});
|
||||
}, 500));
|
||||
};
|
||||
|
||||
// ---- API 接口定义 ----
|
||||
|
||||
/** 系统配置 */
|
||||
export const getSystemConfig = () => isMock
|
||||
? Promise.resolve({
|
||||
douban: { cookie: 'douban_cookie_test_value_xxxxxx' },
|
||||
skit_paths: ['/skits', '/downloads/completed'], // 现在是数组
|
||||
ptskit: {
|
||||
url: 'https://ptskit.com',
|
||||
cookie: 'ptskit_cookie_test_value_xxxxxx',
|
||||
user_agent: ''
|
||||
},
|
||||
qbittorrent: {
|
||||
host: 'localhost',
|
||||
port: 8080,
|
||||
username: 'admin',
|
||||
password: 'adminadmin'
|
||||
},
|
||||
transmission: {
|
||||
host: 'localhost',
|
||||
port: 9091,
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
emby: {
|
||||
host: 'localhost',
|
||||
port: 8096,
|
||||
use_ssl: false,
|
||||
api_key: 'emby_api_key_test'
|
||||
},
|
||||
plex: {
|
||||
host: 'localhost',
|
||||
port: 32400,
|
||||
use_ssl: false,
|
||||
token: 'plex_token_test'
|
||||
},
|
||||
fnos: {
|
||||
host: 'localhost',
|
||||
port: 1999,
|
||||
use_ssl: false,
|
||||
authorization: 'fnos_auth_token_test'
|
||||
},
|
||||
})
|
||||
: api.get('/system/config');
|
||||
export const saveSystemConfig = (data) => isMock ? Promise.resolve({ message: '配置保存成功!' }) : api.post('/system/config', data);
|
||||
export const testConnection = (data) => isMock
|
||||
? new Promise(resolve => setTimeout(() => resolve({ success: true, message: `[Mock] ${data.type} 连接成功!` }), 1000))
|
||||
: api.post('/system/test_connection', data);
|
||||
|
||||
|
||||
/** 首页 / 仪表盘 */
|
||||
export const getInitialCheck = () => isMock
|
||||
? Promise.resolve({ is_configured: true }) // <-- 修改为 false 来测试顶部的“请配置”警告条
|
||||
: api.get('/dashboard/initial_check');
|
||||
|
||||
export const getDashboardStats = () => isMock
|
||||
? new Promise(resolve => setTimeout(() => resolve({
|
||||
local_skits: 2,
|
||||
fnos_items: 2,
|
||||
emby_items: 2,
|
||||
plex_items: 0,
|
||||
qb_total: 47,
|
||||
tr_total: 4,
|
||||
}), 800)) // 模拟网络延迟,让 loading 效果更明显
|
||||
: api.get('/dashboard/stats');
|
||||
|
||||
export const testAllComponents = () => isMock
|
||||
? new Promise(resolve => setTimeout(() => resolve({
|
||||
douban_cookie: { status: 'success', message: 'Cookie已配置' },
|
||||
ptskit_cookie: { status: 'success', message: 'Cookie已配置' },
|
||||
local_path: { status: 'success', message: '已挂载' },
|
||||
qbittorrent: { status: 'success', message: '连接成功 v4.5.2' },
|
||||
transmission: { status: 'error', message: '连接失败: 认证错误' },
|
||||
fnos: { status: 'unconfigured', message: '未配置' },
|
||||
emby: { status: 'success', message: '连接成功' },
|
||||
plex: { status: 'unconfigured', message: '未配置' },
|
||||
}), 1500)) // 模拟一个较长的测试时间
|
||||
: api.get('/dashboard/component_statuses');
|
||||
|
||||
|
||||
/** 本地媒体 */
|
||||
export const getLocalMedia = (params) => isMock
|
||||
? createMockListData(params.page, params.pageSize, 88, i => ({
|
||||
id: i + 1,
|
||||
title: `短剧-本地风云-${i + 1}`,
|
||||
path: `/media/短剧-本地风云-${i + 1}`,
|
||||
poster: `https://picsum.photos/seed/${i}/200/300`,
|
||||
scraped: i % 3 === 0, // 模拟刮削状态
|
||||
}))
|
||||
: api.get('/local-media', { params });
|
||||
|
||||
/** PTSKIT 种子 */
|
||||
export const getPtskitTorrents = (params) => isMock
|
||||
? createMockListData(params.page, params.pageSize, 123, i => ({
|
||||
id: `torrent-${i}`,
|
||||
title: `[PTSKIT] 超好看的短剧 S01 全集 ${i + 1}GB`,
|
||||
size: `${(Math.random() * 10).toFixed(2)} GB`,
|
||||
seeders: Math.floor(Math.random() * 100),
|
||||
leechers: Math.floor(Math.random() * 20),
|
||||
download_url: `https://ptskit.com/download.php?id=${i}`
|
||||
}))
|
||||
: api.get('/ptskit/torrents', { params });
|
||||
|
||||
/** 下载 */
|
||||
export const addDownloadTask = (data) => isMock ? Promise.resolve({ message: '任务添加成功' }) : api.post('/downloader/add', data);
|
||||
|
||||
/** 刮削 */
|
||||
export const scrapeMedia = (data) => isMock ? Promise.resolve({ message: '开始刮削' }) : api.post('/scrape', data);
|
||||
|
||||
/** 媒体服务器 */
|
||||
export const getMediaServerLibrary = (serverType, params) => isMock
|
||||
? createMockListData(params.page, params.pageSize, 50, i => ({
|
||||
id: `${serverType}-${i}`,
|
||||
title: `[${serverType.toUpperCase()}] 服务器上的短剧 ${i + 1}`,
|
||||
year: 2023,
|
||||
poster: `https://picsum.photos/seed/${serverType}${i}/200/300`,
|
||||
}))
|
||||
: api.get(`/${serverType}/library`, { params });
|
||||
15
frontend/src/assets/main.css
Normal file
15
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,15 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
60
frontend/src/components/AppLayout.vue
Normal file
60
frontend/src/components/AppLayout.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<el-container style="height: 100vh;">
|
||||
<el-aside width="200px" style="background-color: #545c64;">
|
||||
<div class="logo">Skit-Panel</div>
|
||||
<el-menu
|
||||
active-text-color="#ffd04b"
|
||||
background-color="#545c64"
|
||||
class="el-menu-vertical-demo"
|
||||
:default-active="$route.path"
|
||||
text-color="#fff"
|
||||
router
|
||||
>
|
||||
<el-menu-item v-for="route in menuRoutes" :key="route.path" :index="'/' + route.path">
|
||||
<el-icon><component :is="route.meta.icon" /></el-icon>
|
||||
<span>{{ route.meta.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main :class="{ 'no-padding': isPlayerPage }">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :exclude="['PlayerIframe']">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
// 判断当前页面是否是嵌入播放器的页面
|
||||
const isPlayerPage = computed(() => route.name === 'PlayerIframe');
|
||||
const menuRoutes = computed(() =>
|
||||
router.options.routes.find(r => r.path === '/').children.filter(r => r.meta && r.meta.icon)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.el-main.no-padding {
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
<style scoped>
|
||||
.logo {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.el-menu {
|
||||
border-right: none;
|
||||
}
|
||||
</style>
|
||||
17
frontend/src/main.js
Normal file
17
frontend/src/main.js
Normal file
@@ -0,0 +1,17 @@
|
||||
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'
|
||||
import './assets/main.css'
|
||||
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')
|
||||
76
frontend/src/router/index.js
Normal file
76
frontend/src/router/index.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import AppLayout from '../components/AppLayout.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
redirect: '/home',
|
||||
children: [
|
||||
{
|
||||
path: 'home',
|
||||
name: 'Home',
|
||||
component: () => import('../views/Home.vue'),
|
||||
meta: { title: '首页', icon: 'House' }
|
||||
},
|
||||
{
|
||||
path: 'ptskit',
|
||||
name: 'Ptskit',
|
||||
component: () => import('../views/PtskitList.vue'),
|
||||
meta: { title: '站点种子', icon: 'Memo' }
|
||||
},
|
||||
{
|
||||
path: 'local-media',
|
||||
name: 'LocalMedia',
|
||||
component: () => import('../views/LocalMedia.vue'),
|
||||
meta: { title: '本地资源', icon: 'Folder' }
|
||||
},
|
||||
{
|
||||
path: 'player-iframe',
|
||||
name: 'PlayerIframe',
|
||||
component: () => import('../views/PlayerIframeWrapper.vue'),
|
||||
meta: { title: '在线观看', icon: 'VideoPlay' }
|
||||
},
|
||||
{
|
||||
path: 'fnos',
|
||||
name: 'Fnos',
|
||||
component: () => import('../views/MediaServerView.vue'),
|
||||
meta: { title: '飞牛影视', icon: 'Film', serverType: 'fnos' }
|
||||
},
|
||||
{
|
||||
path: 'emby',
|
||||
name: 'Emby',
|
||||
component: () => import('../views/MediaServerView.vue'),
|
||||
meta: { title: 'EMBY 媒体库', icon: 'VideoCamera', serverType: 'emby' }
|
||||
},
|
||||
{
|
||||
path: 'plex',
|
||||
name: 'Plex',
|
||||
component: () => import('../views/MediaServerView.vue'),
|
||||
meta: { title: 'Plex 媒体库', icon: 'Monitor', serverType: 'plex' }
|
||||
},
|
||||
{
|
||||
path: 'config',
|
||||
name: 'Config',
|
||||
component: () => import('../views/SystemConfig.vue'),
|
||||
meta: { title: '系统配置', icon: 'Setting' }
|
||||
}
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
const MAIN_TITLE = '短剧管理';
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.meta.title) {
|
||||
document.title = `${MAIN_TITLE} - ${to.meta.title}`;
|
||||
} else {
|
||||
document.title = MAIN_TITLE;
|
||||
}
|
||||
next();
|
||||
});
|
||||
export default router;
|
||||
357
frontend/src/views/Home.vue
Normal file
357
frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="header">
|
||||
<h1>SkitPanel 短剧管理面板</h1>
|
||||
<p class="subtitle">一站式短剧资源管理与刮削工具</p>
|
||||
</div>
|
||||
|
||||
<!-- 配置提示 -->
|
||||
<el-alert
|
||||
v-if="showConfigAlert"
|
||||
title="请先前往 系统配置 页面完成必要设置,以确保所有功能正常使用。"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="config-alert"
|
||||
>
|
||||
<router-link to="/config" class="config-link">前往配置</router-link>
|
||||
</el-alert>
|
||||
|
||||
<!-- 资源统计 -->
|
||||
<el-card class="box-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>资源统计</span>
|
||||
<el-button type="primary" :icon="'Refresh'" :loading="loading.stats" @click="fetchStats">
|
||||
刷新统计
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="12" :md="8" :lg="4" v-for="item in statItems" :key="item.key" class="stat-col">
|
||||
<div :class="['stat-card', item.colorClass]">
|
||||
<div class="stat-content">
|
||||
<span class="stat-title">{{ item.title }}</span>
|
||||
<span class="stat-value">{{ stats[item.key] ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<el-icon class="stat-icon" :size="28"><component :is="item.icon" /></el-icon>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 系统组件状态 -->
|
||||
<el-card class="box-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>系统组件状态</span>
|
||||
<el-button type="primary" :icon="'Connection'" :loading="loading.components" @click="runAllTests">
|
||||
测试所有组件
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :md="24" :lg="12" v-for="item in statusItems" :key="item.id" class="status-col">
|
||||
<div class="status-item">
|
||||
<div class="status-label">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<div class="status-indicator">
|
||||
<el-icon v-if="item.status === 'pending'" class="is-loading"><Loading /></el-icon>
|
||||
<el-tag v-else :type="getStatusType(item.status)">
|
||||
<el-icon style="margin-right: 4px;"><component :is="getStatusIcon(item.status)" /></el-icon>
|
||||
{{ item.message }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import * as api from '../api';
|
||||
// 引入所有 Element Plus 图标
|
||||
import { Folder, VideoPlay, Download, Search, Link, SetUp, QuestionFilled, Check, Close, Loading } from '@element-plus/icons-vue';
|
||||
|
||||
// --- 响应式状态 ---
|
||||
|
||||
// 加载状态
|
||||
const loading = reactive({
|
||||
stats: false,
|
||||
components: false,
|
||||
});
|
||||
|
||||
// 是否显示配置警告
|
||||
const showConfigAlert = ref(false);
|
||||
|
||||
// 资源统计数据
|
||||
const stats = ref({
|
||||
local_skits: 0,
|
||||
fnos_items: 0,
|
||||
emby_items: 0,
|
||||
plex_items: 0,
|
||||
qb_total: 0,
|
||||
tr_total: 0,
|
||||
});
|
||||
|
||||
// 统计卡片的静态配置
|
||||
const statItems = [
|
||||
{ key: 'local_skits', title: '本地资源数量', icon: Folder, colorClass: 'bg-blue' },
|
||||
{ key: 'fnos_items', title: '飞牛媒体库数量', icon: VideoPlay, colorClass: 'bg-green' },
|
||||
{ key: 'emby_items', title: 'Emby媒体库数量', icon: VideoPlay, colorClass: 'bg-purple' },
|
||||
{ key: 'plex_items', title: 'Plex媒体库数量', icon: VideoPlay, colorClass: 'bg-cyan' },
|
||||
{ key: 'qb_total', title: 'QB总任务数', icon: Download, colorClass: 'bg-orange' },
|
||||
{ key: 'tr_total', title: 'TR总任务数', icon: Download, colorClass: 'bg-red' },
|
||||
];
|
||||
|
||||
// 系统组件状态
|
||||
const statusItems = ref([
|
||||
{ id: 'douban_cookie', label: '豆瓣 Cookie', icon: Search, status: 'untested', message: '未测试' },
|
||||
{ id: 'ptskit_cookie', label: '站点 Cookie', icon: SetUp, status: 'untested', message: '未测试' },
|
||||
{ id: 'local_path', label: '本地资源映射', icon: Folder, status: 'untested', message: '未测试' },
|
||||
{ id: 'qbittorrent', label: 'qBittorrent连通性', icon: Download, status: 'untested', message: '未测试' },
|
||||
{ id: 'transmission', label: 'Transmission连通性', icon: Download, status: 'untested', message: '未测试' },
|
||||
{ id: 'fnos', label: '飞牛媒体库连通性', icon: Link, status: 'untested', message: '未测试' },
|
||||
{ id: 'emby', label: 'Emby服务器连通性', icon: Link, status: 'untested', message: '未测试' },
|
||||
{ id: 'plex', label: 'Plex服务器连通性', icon: Link, status: 'untested', message: '未测试' },
|
||||
]);
|
||||
|
||||
// --- 方法 ---
|
||||
|
||||
// 检查初始配置
|
||||
const checkInitialConfig = async () => {
|
||||
try {
|
||||
const res = await api.getInitialCheck(); // 假设的API
|
||||
showConfigAlert.value = !res.is_configured;
|
||||
} catch (error) {
|
||||
console.error("检查初始配置失败:", error);
|
||||
showConfigAlert.value = true; // 出错时也提示配置
|
||||
}
|
||||
};
|
||||
|
||||
// 获取资源统计
|
||||
const fetchStats = async () => {
|
||||
loading.stats = true;
|
||||
try {
|
||||
const data = await api.getDashboardStats(); // 假设的API
|
||||
stats.value = data;
|
||||
ElMessage.success('统计信息已更新!');
|
||||
} catch (error) {
|
||||
console.error("获取统计信息失败:", error);
|
||||
ElMessage.error('获取统计信息失败。');
|
||||
} finally {
|
||||
loading.stats = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 运行所有组件测试
|
||||
const runAllTests = async () => {
|
||||
loading.components = true;
|
||||
// 立即将所有状态设置为'pending'
|
||||
statusItems.value.forEach(item => {
|
||||
item.status = 'pending';
|
||||
item.message = '测试中...';
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await api.testAllComponents(); // 假设的API
|
||||
// 更新状态
|
||||
statusItems.value.forEach(item => {
|
||||
if (results[item.id]) {
|
||||
item.status = results[item.id].status;
|
||||
item.message = results[item.id].message;
|
||||
} else {
|
||||
item.status = 'unconfigured';
|
||||
item.message = '未配置';
|
||||
}
|
||||
});
|
||||
ElMessage.success('所有组件测试完成!');
|
||||
} catch (error) {
|
||||
console.error("测试组件失败:", error);
|
||||
ElMessage.error('测试组件时发生错误。');
|
||||
// 失败时重置状态
|
||||
statusItems.value.forEach(item => {
|
||||
item.status = 'error';
|
||||
item.message = '测试失败';
|
||||
});
|
||||
} finally {
|
||||
loading.components = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 根据状态返回 Element Plus 的 Tag 类型
|
||||
const getStatusType = (status) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
case 'configured':
|
||||
return 'success';
|
||||
case 'error':
|
||||
return 'danger';
|
||||
case 'unconfigured':
|
||||
return 'info';
|
||||
case 'untested':
|
||||
default:
|
||||
return 'warning';
|
||||
}
|
||||
};
|
||||
|
||||
// 根据状态返回图标
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
case 'configured':
|
||||
return Check;
|
||||
case 'error':
|
||||
return Close;
|
||||
case 'pending':
|
||||
return Loading;
|
||||
case 'unconfigured':
|
||||
case 'untested':
|
||||
default:
|
||||
return QuestionFilled;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 生命周期钩子 ---
|
||||
onMounted(() => {
|
||||
checkInitialConfig();
|
||||
fetchStats();
|
||||
// 可选择在页面加载时自动运行一次测试
|
||||
// runAllTests();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #888;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.config-alert {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.config-link {
|
||||
margin-left: 10px;
|
||||
font-weight: bold;
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.box-card {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-col {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.stat-col {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.stat-col {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 背景色 */
|
||||
.bg-blue { background: linear-gradient(135deg, #409EFF, #79bbff); }
|
||||
.bg-green { background: linear-gradient(135deg, #67C23A, #95d475); }
|
||||
.bg-purple { background: linear-gradient(135deg, #9b59b6, #b37acb); }
|
||||
.bg-cyan { background: linear-gradient(135deg, #16a085, #3ccdae); }
|
||||
.bg-orange { background: linear-gradient(135deg, #E6A23C, #eebe77); }
|
||||
.bg-red { background: linear-gradient(135deg, #F56C6C, #f89898); }
|
||||
|
||||
.status-col {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 15px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.status-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
.status-label .el-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.status-indicator .el-tag {
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
232
frontend/src/views/LocalMedia.vue
Normal file
232
frontend/src/views/LocalMedia.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 页面标题 -->
|
||||
<h1>本地短剧资源管理</h1>
|
||||
|
||||
<!-- 头部操作区域:包含搜索框和刷新按钮 -->
|
||||
<div class="table-header">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索标题或路径..."
|
||||
@keyup.enter="fetchData(1)"
|
||||
style="width: 300px;"
|
||||
clearable
|
||||
:prefix-icon="'Search'"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="'Refresh'"
|
||||
@click="fetchData(1)"
|
||||
:loading="loading"
|
||||
>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
|
||||
<!-- 封面列 -->
|
||||
<el-table-column label="封面" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
style="width: 80px; height: 120px; border-radius: 4px;"
|
||||
:src="row.poster"
|
||||
fit="cover"
|
||||
lazy
|
||||
preview-teleported
|
||||
:preview-src-list="row.poster ? [row.poster] : []"
|
||||
>
|
||||
<!-- 图片加载失败时的占位内容 -->
|
||||
<template #error>
|
||||
<div class="image-slot">
|
||||
<span>无封面</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 标题列 -->
|
||||
<el-table-column prop="title" label="标题" min-width="200" sortable />
|
||||
|
||||
<!-- 路径列 -->
|
||||
<el-table-column prop="path" label="路径" min-width="250" />
|
||||
|
||||
<!-- 刮削状态列 -->
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.scraped ? 'success' : 'info'">
|
||||
{{ row.scraped ? '已刮削' : '未刮削' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<el-table-column label="操作" width="120" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:icon="'MagicStick'"
|
||||
@click="handleScrape(row)"
|
||||
>
|
||||
刮削
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页组件 -->
|
||||
<el-pagination
|
||||
style="margin-top: 20px; justify-content: flex-end;"
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="pagination.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
background
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import * as api from '../api'; // 引入封装好的 API 请求
|
||||
|
||||
// --- 响应式状态定义 ---
|
||||
|
||||
// 表格加载状态
|
||||
const loading = ref(false);
|
||||
// 搜索查询关键词
|
||||
const searchQuery = ref('');
|
||||
// 表格数据
|
||||
const tableData = ref([]);
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// --- 方法定义 ---
|
||||
|
||||
/**
|
||||
* @description 从后端获取短剧列表数据
|
||||
* @param {number} page - 要获取的页码,默认为当前页
|
||||
*/
|
||||
const fetchData = async (page = pagination.currentPage) => {
|
||||
loading.value = true;
|
||||
pagination.currentPage = page;
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.currentPage,
|
||||
pageSize: pagination.pageSize,
|
||||
query: searchQuery.value.trim() // 传递搜索词,并去除前后空格
|
||||
};
|
||||
const res = await api.getLocalMedia(params);
|
||||
tableData.value = res.list;
|
||||
pagination.total = res.total;
|
||||
} catch (error) {
|
||||
console.error("获取本地媒体列表失败:", error);
|
||||
ElMessage.error("数据加载失败,请检查网络或联系管理员。");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 处理刮削操作
|
||||
* @param {object} row - 当前行的数据对象
|
||||
*/
|
||||
const handleScrape = (row) => {
|
||||
// 弹出输入框,让用户可以手动输入关键词或豆瓣链接
|
||||
ElMessageBox.prompt('请输入豆瓣链接或关键词进行刮削', '手动刮削', {
|
||||
confirmButtonText: '开始刮削',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '留空则默认使用标题进行搜索',
|
||||
inputValue: row.title // 将当前标题作为默认值填入输入框
|
||||
}).then(async ({ value }) => {
|
||||
// 显示加载状态
|
||||
const scrapeLoading = ElMessage({
|
||||
message: '正在提交刮削任务...',
|
||||
type: 'info',
|
||||
duration: 0 // 持续显示,直到手动关闭
|
||||
});
|
||||
try {
|
||||
// 调用后端刮削接口
|
||||
await api.scrapeMedia({
|
||||
media_id: row.id, // 传递媒体的唯一标识
|
||||
query: value || row.title // 如果用户没输入,就用原始标题
|
||||
});
|
||||
scrapeLoading.close(); // 关闭加载提示
|
||||
ElMessage.success('刮削任务已成功提交!后台正在处理...');
|
||||
|
||||
// 2秒后自动刷新当前页数据,以便用户看到更新后的状态(如封面、刮削状态标签)
|
||||
setTimeout(() => {
|
||||
fetchData();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
scrapeLoading.close(); // 关闭加载提示
|
||||
console.error('刮削失败:', error);
|
||||
// ElMessage.error("刮削任务提交失败!"); // API拦截器中已有通用错误提示
|
||||
}
|
||||
}).catch(() => {
|
||||
// 用户点击取消按钮
|
||||
ElMessage.info('已取消刮削操作');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 处理每页显示数量变化
|
||||
* @param {number} size - 新的每页显示数量
|
||||
*/
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.pageSize = size;
|
||||
fetchData(1); // 更改每页数量时,回到第一页
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 处理当前页码变化
|
||||
* @param {number} page - 新的页码
|
||||
*/
|
||||
const handleCurrentChange = (page) => {
|
||||
fetchData(page);
|
||||
};
|
||||
|
||||
// --- 生命周期钩子 ---
|
||||
|
||||
// 组件挂载完成后,立即获取第一页数据
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 页面容器通用内边距 */
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 表格头部样式,使用 flex 布局让搜索框和按钮分布在两端 */
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 封面图片加载失败时的占位符样式 */
|
||||
.image-slot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
97
frontend/src/views/MediaServerView.vue
Normal file
97
frontend/src/views/MediaServerView.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<div class="table-header">
|
||||
<el-input v-model="searchQuery" placeholder="搜索标题..." @keyup.enter="fetchData(1)" style="width: 300px;"></el-input>
|
||||
<el-button type="primary" :icon="'Refresh'" @click="fetchData(1)" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="tableData" v-loading="loading" border>
|
||||
<el-table-column label="封面" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-image style="width: 80px; height: 120px" :src="row.poster" fit="cover" lazy>
|
||||
<template #error><div class="image-slot">无封面</div></template>
|
||||
</el-image>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="300"></el-table-column>
|
||||
<el-table-column prop="year" label="年份" width="120"></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
style="margin-top: 20px; justify-content: flex-end;"
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="pagination.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import * as api from '../api';
|
||||
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const tableData = ref([]);
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// 根据路由元信息动态设置页面标题和服务器类型
|
||||
const pageTitle = computed(() => route.meta.title);
|
||||
const serverType = computed(() => route.meta.serverType);
|
||||
|
||||
const fetchData = async (page = pagination.currentPage) => {
|
||||
loading.value = true;
|
||||
pagination.currentPage = page;
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.currentPage,
|
||||
pageSize: pagination.pageSize,
|
||||
query: searchQuery.value
|
||||
};
|
||||
const res = await api.getMediaServerLibrary(serverType.value, params);
|
||||
tableData.value = res.list;
|
||||
pagination.total = res.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.pageSize = size;
|
||||
fetchData(1);
|
||||
};
|
||||
|
||||
const handleCurrentChange = (page) => {
|
||||
fetchData(page);
|
||||
};
|
||||
|
||||
// 监听路由变化,当从Emby切换到Plex时重新加载数据
|
||||
watch(serverType, () => {
|
||||
searchQuery.value = '';
|
||||
fetchData(1);
|
||||
});
|
||||
|
||||
onMounted(() => fetchData());
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-slot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
36
frontend/src/views/PlayerIframeWrapper.vue
Normal file
36
frontend/src/views/PlayerIframeWrapper.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<!-- src/views/PlayerIframeWrapper.vue -->
|
||||
<template>
|
||||
<div class="iframe-container">
|
||||
<iframe :src="playerAppUrl" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
// **核心配置**
|
||||
// 在这里填入您 Node.js 播放器应用的实际访问地址
|
||||
// 在开发环境中,可能是 localhost 地址
|
||||
// 在生产环境中,应该是一个可以通过 Docker 网络访问的地址或 Nginx 代理后的地址
|
||||
// const playerAppUrl = ref('http://localhost:8101');
|
||||
|
||||
|
||||
const playerAppUrl = ref(import.meta.env.VITE_PLAYER_URL || 'http://localhost:8101');
|
||||
// 然后在 .env 文件中定义 VITE_PLAYER_URL
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.iframe-container {
|
||||
/* 确保容器占满父元素 el-main 的所有可用空间 */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden; /* 防止 iframe 出现不必要的滚动条 */
|
||||
}
|
||||
|
||||
iframe {
|
||||
/* 让 iframe 自身也占满容器 */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none; /* 确保没有边框 */
|
||||
}
|
||||
</style>
|
||||
88
frontend/src/views/PtskitList.vue
Normal file
88
frontend/src/views/PtskitList.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h1>Ptskit 种子列表</h1>
|
||||
<div class="table-header">
|
||||
<el-input v-model="searchQuery" placeholder="搜索种子标题..." @keyup.enter="fetchData(1)" style="width: 300px;"></el-input>
|
||||
<el-button type="primary" :icon="'Refresh'" @click="fetchData(1)" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="tableData" v-loading="loading" border>
|
||||
<el-table-column prop="title" label="标题" min-width="300"></el-table-column>
|
||||
<el-table-column prop="size" label="大小" width="120"></el-table-column>
|
||||
<el-table-column prop="seeders" label="做种" width="100"></el-table-column>
|
||||
<el-table-column prop="leechers" label="下载" width="100"></el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" :icon="'Download'" @click="handleDownload(row)">下载</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
style="margin-top: 20px; justify-content: flex-end;"
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="pagination.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import * as api from '../api';
|
||||
|
||||
const loading = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const tableData = ref([]);
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
});
|
||||
|
||||
const fetchData = async (page = pagination.currentPage) => {
|
||||
loading.value = true;
|
||||
pagination.currentPage = page;
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.currentPage,
|
||||
pageSize: pagination.pageSize,
|
||||
query: searchQuery.value
|
||||
};
|
||||
const res = await api.getPtskitTorrents(params);
|
||||
tableData.value = res.list;
|
||||
pagination.total = res.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (row) => {
|
||||
ElMessageBox.confirm(`确定要下载 "${row.title}" 吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info',
|
||||
}).then(async () => {
|
||||
try {
|
||||
await api.addDownloadTask({ url: row.download_url });
|
||||
ElMessage.success('已发送下载指令!');
|
||||
} catch (error) {
|
||||
console.error("下载失败", error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.pageSize = size;
|
||||
fetchData(1);
|
||||
};
|
||||
|
||||
const handleCurrentChange = (page) => {
|
||||
fetchData(page);
|
||||
};
|
||||
|
||||
onMounted(() => fetchData());
|
||||
</script>
|
||||
336
frontend/src/views/SystemConfig.vue
Normal file
336
frontend/src/views/SystemConfig.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h1>系统配置</h1>
|
||||
<el-alert
|
||||
title="所有配置项默认折叠,请点击标题展开。修改后,请务必点击页面底部的“保存所有配置”按钮以生效。"
|
||||
type-info
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 20px;"
|
||||
/>
|
||||
|
||||
<!-- 使用 el-form 组件包裹所有配置项 -->
|
||||
<el-form :model="configForm" label-width="120px" label-position="right">
|
||||
<!-- 使用 el-collapse 实现折叠面板效果 -->
|
||||
<el-collapse v-model="activeCollapseNames">
|
||||
|
||||
<!-- 基础配置 -->
|
||||
<el-collapse-item name="1">
|
||||
<template #title>
|
||||
<span class="collapse-title">基础配置</span>
|
||||
</template>
|
||||
<el-form-item label="豆瓣 Cookie">
|
||||
<el-input
|
||||
v-model="configForm.douban.cookie"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="请输入从豆瓣网站获取的完整 Cookie,用于刮削影视信息"
|
||||
/>
|
||||
</el-form-item>
|
||||
<!-- 动态增减的本地短剧路径 -->
|
||||
<el-form-item
|
||||
v-for="(pathItem, index) in configForm.skit_paths"
|
||||
:key="pathItem.id"
|
||||
:label="index === 0 ? '本地短剧路径' : ''"
|
||||
:class="{ 'extra-path-item': index > 0 }"
|
||||
>
|
||||
<div class="path-input-row">
|
||||
<el-input v-model="pathItem.path" placeholder="例如: /skits" />
|
||||
<el-button
|
||||
@click="removePath(index)"
|
||||
:icon="'Delete'"
|
||||
type="danger"
|
||||
circle
|
||||
v-if="configForm.skit_paths.length > 1"
|
||||
class="remove-button"
|
||||
/>
|
||||
</div>
|
||||
<el-text v-if="index === 0" class="input-tip" type="info">
|
||||
重要:此处应填写 Docker 容器内的路径,例如 /skits,而不是宿主机的路径。
|
||||
</el-text>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="addPath" type="primary" :icon="'Plus'" text>
|
||||
添加更多路径
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
|
||||
<!-- 站点配置 -->
|
||||
<el-collapse-item name="2">
|
||||
<template #title>
|
||||
<span class="collapse-title">PTSKIT 站点配置</span>
|
||||
</template>
|
||||
<el-form-item label="站点 URL"><el-input v-model="configForm.ptskit.url" placeholder="例如: https://pt.skit.com" /></el-form-item>
|
||||
<el-form-item label="站点 Cookie"><el-input v-model="configForm.ptskit.cookie" type="textarea" :rows="5" placeholder="请输入登录 PTSKIT 站点后获取的完整 Cookie" /></el-form-item>
|
||||
<el-form-item label="User-Agent">
|
||||
<el-input v-model="configForm.ptskit.user_agent" placeholder="可选,留空则使用默认值" />
|
||||
<el-text class="input-tip" type="info">模拟浏览器访问的 User-Agent,一般无需填写。</el-text>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="testLoading.ptskit" @click="handleTestConnection('ptskit')">测试连接</el-button>
|
||||
</el-collapse-item>
|
||||
|
||||
<!-- 下载器配置 -->
|
||||
<el-collapse-item name="3">
|
||||
<template #title>
|
||||
<span class="collapse-title">下载器配置</span>
|
||||
</template>
|
||||
<el-tabs v-model="activeDownloaderTab" style="margin-top: -10px;">
|
||||
<!-- qBittorrent 配置 -->
|
||||
<el-tab-pane label="qBittorrent" name="qb">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12"><el-form-item label="IP / 主机名"><el-input v-model="configForm.qbittorrent.host" placeholder="例如: 192.168.1.10" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="端口"><el-input v-model.number="configForm.qbittorrent.port" placeholder="例如: 8080" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="用户名"><el-input v-model="configForm.qbittorrent.username" placeholder="请输入用户名" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="密码"><el-input v-model="configForm.qbittorrent.password" type="password" placeholder="请输入密码" show-password /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-button type="primary" :loading="testLoading.qb" @click="handleTestConnection('qb')">测试连接</el-button>
|
||||
</el-tab-pane>
|
||||
<!-- Transmission 配置 -->
|
||||
<el-tab-pane label="Transmission" name="tr">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12"><el-form-item label="IP / 主机名"><el-input v-model="configForm.transmission.host" placeholder="例如: 192.168.1.11" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="端口"><el-input v-model.number="configForm.transmission.port" placeholder="例如: 9091" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="用户名"><el-input v-model="configForm.transmission.username" placeholder="可选" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="密码"><el-input v-model="configForm.transmission.password" type="password" placeholder="可选" show-password /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-button type="primary" :loading="testLoading.tr" @click="handleTestConnection('tr')">测试连接</el-button>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-collapse-item>
|
||||
|
||||
<!-- 媒体服务器配置 -->
|
||||
<el-collapse-item name="4">
|
||||
<template #title>
|
||||
<span class="collapse-title">媒体服务器配置</span>
|
||||
</template>
|
||||
<el-tabs v-model="activeMediaServerTab" style="margin-top: -10px;">
|
||||
<!-- Emby 配置 -->
|
||||
<el-tab-pane label="Emby" name="emby">
|
||||
<el-form-item label="IP / 主机名"><el-input v-model="configForm.emby.host" placeholder="例如: emby.local" /></el-form-item>
|
||||
<el-form-item label="端口"><el-input v-model.number="configForm.emby.port" placeholder="例如: 8096" /></el-form-item>
|
||||
<el-form-item label="SSL"><el-switch v-model="configForm.emby.use_ssl" active-text="https" inactive-text="http" /></el-form-item>
|
||||
<el-form-item label="API 密钥"><el-input v-model="configForm.emby.api_key" type="password" placeholder="请输入 Emby API Key" show-password /></el-form-item>
|
||||
<el-button type="primary" :loading="testLoading.emby" @click="handleTestConnection('emby')">测试连接</el-button>
|
||||
</el-tab-pane>
|
||||
<!-- Plex 配置 -->
|
||||
<el-tab-pane label="Plex" name="plex">
|
||||
<el-form-item label="IP / 主机名"><el-input v-model="configForm.plex.host" placeholder="例如: 192.168.1.12" /></el-form-item>
|
||||
<el-form-item label="端口"><el-input v-model.number="configForm.plex.port" placeholder="例如: 32400" /></el-form-item>
|
||||
<el-form-item label="SSL"><el-switch v-model="configForm.plex.use_ssl" active-text="https" inactive-text="http" /></el-form-item>
|
||||
<el-form-item label="X-Plex-Token"><el-input v-model="configForm.plex.token" type="password" placeholder="请输入 Plex Token" show-password /></el-form-item>
|
||||
<el-button type="primary" :loading="testLoading.plex" @click="handleTestConnection('plex')">测试连接</el-button>
|
||||
</el-tab-pane>
|
||||
<!-- 飞牛影视 (Fnos) 配置 -->
|
||||
<el-tab-pane label="飞牛影视" name="fnos">
|
||||
<el-form-item label="IP / 主机名"><el-input v-model="configForm.fnos.host" placeholder="请输入 Fnos 服务地址" /></el-form-item>
|
||||
<el-form-item label="端口"><el-input v-model.number="configForm.fnos.port" placeholder="请输入 Fnos 服务端口" /></el-form-item>
|
||||
<el-form-item label="SSL"><el-switch v-model="configForm.fnos.use_ssl" active-text="https" inactive-text="http" /></el-form-item>
|
||||
<el-form-item label="Authorization"><el-input v-model="configForm.fnos.authorization" type="password" placeholder="请输入 Authorization" show-password /></el-form-item>
|
||||
<el-button type="primary" :loading="testLoading.fnos" @click="handleTestConnection('fnos')">测试连接</el-button>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-collapse-item>
|
||||
|
||||
</el-collapse>
|
||||
|
||||
<!-- 页面底部全局操作 -->
|
||||
<div class="footer-actions">
|
||||
<el-button type="success" :icon="'Select'" size="large" :loading="saveLoading" @click="handleSaveConfig">
|
||||
保存所有配置
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import * as api from '../api';
|
||||
|
||||
// --- 响应式状态定义 ---
|
||||
|
||||
// 控制哪个折叠面板是展开的,空数组表示默认全部折叠
|
||||
const activeCollapseNames = ref([]);
|
||||
const activeDownloaderTab = ref('qb');
|
||||
const activeMediaServerTab = ref('emby');
|
||||
|
||||
// 表单数据模型
|
||||
const configForm = ref({
|
||||
douban: { cookie: '' },
|
||||
// skit_paths 现在是一个对象数组,以便 v-for 绑定 key
|
||||
skit_paths: [{ id: Date.now(), path: '' }],
|
||||
ptskit: { url: '', cookie: '', user_agent: '' },
|
||||
qbittorrent: { host: '', port: null, username: '', password: '' },
|
||||
transmission: { host: '', port: null, username: '', password: '' },
|
||||
emby: { host: '', port: null, use_ssl: false, api_key: '' },
|
||||
plex: { host: '', port: null, use_ssl: false, token: '' },
|
||||
fnos: { host: '', port: null, use_ssl: false, authorization: '' },
|
||||
});
|
||||
|
||||
// 加载状态
|
||||
const saveLoading = ref(false);
|
||||
const testLoading = reactive({
|
||||
ptskit: false, qb: false, tr: false, emby: false, plex: false, fnos: false
|
||||
});
|
||||
|
||||
// --- 方法定义 ---
|
||||
|
||||
/**
|
||||
* @description 添加一个新的本地短剧路径输入框
|
||||
*/
|
||||
const addPath = () => {
|
||||
configForm.value.skit_paths.push({ id: Date.now(), path: '' });
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 移除指定索引的本地短剧路径
|
||||
* @param {number} index - 要移除的路径在数组中的索引
|
||||
*/
|
||||
const removePath = (index) => {
|
||||
if (configForm.value.skit_paths.length > 1) {
|
||||
configForm.value.skit_paths.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 从后端加载配置,并适配前端数据结构
|
||||
*/
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await api.getSystemConfig();
|
||||
if (!res) return;
|
||||
|
||||
// 深层合并加载的配置,防止后端未返回某些字段时出错
|
||||
Object.keys(configForm.value).forEach(key => {
|
||||
if (key !== 'skit_paths' && res[key] !== undefined) {
|
||||
configForm.value[key] = { ...configForm.value[key], ...res[key] };
|
||||
}
|
||||
});
|
||||
|
||||
// 特殊处理 skit_paths,将其从字符串数组转换为对象数组
|
||||
if (res.skit_paths && Array.isArray(res.skit_paths) && res.skit_paths.length > 0) {
|
||||
configForm.value.skit_paths = res.skit_paths.map(p => ({ id: Date.now() + Math.random(), path: p }));
|
||||
} else {
|
||||
// 如果没有路径,确保至少有一个空的输入框
|
||||
configForm.value.skit_paths = [{ id: Date.now(), path: '' }];
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("加载配置失败:", error);
|
||||
ElMessage.error("配置加载失败,请刷新页面重试。");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 保存所有配置,发送前处理数据格式
|
||||
*/
|
||||
const handleSaveConfig = async () => {
|
||||
saveLoading.value = true;
|
||||
try {
|
||||
// 创建一个 payload 的深拷贝以进行修改
|
||||
const payload = JSON.parse(JSON.stringify(configForm.value));
|
||||
|
||||
// 将 skit_paths 从对象数组转换回字符串数组,并过滤掉空值
|
||||
payload.skit_paths = payload.skit_paths
|
||||
.map(item => item.path.trim())
|
||||
.filter(path => path !== '');
|
||||
|
||||
await api.saveSystemConfig(payload);
|
||||
ElMessage.success('配置已成功保存!');
|
||||
} catch(error) {
|
||||
console.error("保存配置失败:", error);
|
||||
} finally {
|
||||
saveLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleTestConnection = async (serviceType) => {
|
||||
if (!testLoading.hasOwnProperty(serviceType)) return;
|
||||
|
||||
testLoading[serviceType] = true;
|
||||
try {
|
||||
let serviceConfig = {};
|
||||
if (serviceType === 'qb') serviceConfig = configForm.value.qbittorrent;
|
||||
else if (serviceType === 'tr') serviceConfig = configForm.value.transmission;
|
||||
else serviceConfig = configForm.value[serviceType];
|
||||
|
||||
const res = await api.testConnection({ type: serviceType, config: serviceConfig });
|
||||
|
||||
if (res.success) {
|
||||
ElMessage.success(`${res.message || '连接成功!'}`);
|
||||
} else {
|
||||
ElMessage.error(`${res.message || '连接失败,请检查配置。'}`);
|
||||
}
|
||||
} finally {
|
||||
testLoading[serviceType] = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.collapse-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-collapse {
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 优化折叠面板样式 */
|
||||
:deep(.el-collapse-item__header) {
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
:deep(.el-collapse-item__wrap) {
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
:deep(.el-collapse-item__content) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
/* 动态路径行的样式 */
|
||||
.path-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
/* 让第二个及以后的路径输入框,其 label 区域为空,从而实现对齐 */
|
||||
.extra-path-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.extra-path-item :deep(.el-form-item__label) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.input-tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
24
frontend/vite.config.js
Normal file
24
frontend/vite.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
// 新增或修改 server 部分
|
||||
proxy: {
|
||||
// 关键配置:将 /api 前缀的请求代理到后端服务
|
||||
'/api': {
|
||||
// 你的 Python Flask 后端地址
|
||||
target: 'http://localhost:5000',
|
||||
|
||||
// 允许跨域
|
||||
changeOrigin: true,
|
||||
|
||||
// 可选:如果你不希望路径中保留 /api,可以重写。
|
||||
// 但根据我们的后端设计,/api 是需要的,所以不需要下面这行。
|
||||
// rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user