This commit is contained in:
DengDai
2025-12-08 14:45:14 +08:00
commit 519589f8f5
60 changed files with 8191 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View File

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

34
frontend/Dockerfile Normal file
View 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
View 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
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

34
frontend/nginx.conf Normal file
View 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

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

154
frontend/src/api/index.js Normal file
View 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 });

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

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

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

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

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

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

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

View 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
View 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/, ''),
},
}
}
})