mirror of
https://github.com/Cp0204/quark-auto-save.git
synced 2026-01-12 15:20:44 +08:00
14474 lines
708 KiB
HTML
14474 lines
708 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>夸克自动转存</title>
|
||
<!-- CSS -->
|
||
<link rel="stylesheet" href="./static/css/bootstrap.min.css">
|
||
<link rel="stylesheet" href="./static/css/bootstrap-icons.min.css">
|
||
<link rel="stylesheet" href="./static/css/main.css">
|
||
<!-- Bootstrap JS -->
|
||
<script src="./static/js/jquery-3.5.1.slim.min.js"></script>
|
||
<script src="./static/js/bootstrap.bundle.min.js"></script>
|
||
<!-- Vue.js -->
|
||
<script src="./static/js/vue@2.js"></script>
|
||
<script src="./static/js/axios.min.js"></script>
|
||
<script src="./static/js/v-jsoneditor.min.js"></script>
|
||
<script src="./static/js/sort_file_by_name.js"></script>
|
||
<script src="./static/js/pinyin-pro.min.js"></script>
|
||
<script>
|
||
// 添加检测文本溢出的自定义指令
|
||
Vue.directive('check-overflow', {
|
||
inserted: function(el, binding, vnode) {
|
||
// 检查元素是否溢出
|
||
const isOverflowing = el.scrollWidth > el.clientWidth;
|
||
|
||
// 如果绑定了值,则绑定到该值对应的记录属性上
|
||
if (binding.value) {
|
||
const indexAndField = binding.value.split('|');
|
||
const index = parseInt(indexAndField[0]);
|
||
const field = indexAndField[1];
|
||
|
||
// 设置记录的_isOverflowing属性
|
||
// 获取记录数组
|
||
const records = vnode.context.filteredHistoryRecords;
|
||
if (records && records[index]) {
|
||
// 初始化_isOverflowing属性(如果不存在)
|
||
if (!records[index]._isOverflowing) {
|
||
vnode.context.$set(records[index], '_isOverflowing', {});
|
||
}
|
||
// 设置对应字段的溢出状态
|
||
vnode.context.$set(records[index]._isOverflowing, field, isOverflowing);
|
||
}
|
||
}
|
||
},
|
||
// 添加update钩子,在DOM更新时重新检测溢出状态
|
||
update: function(el, binding, vnode) {
|
||
// 检查元素是否溢出
|
||
const isOverflowing = el.scrollWidth > el.clientWidth;
|
||
|
||
// 如果绑定了值,则绑定到该值对应的记录属性上
|
||
if (binding.value) {
|
||
const indexAndField = binding.value.split('|');
|
||
const index = parseInt(indexAndField[0]);
|
||
const field = indexAndField[1];
|
||
|
||
// 设置记录的_isOverflowing属性
|
||
const records = vnode.context.filteredHistoryRecords;
|
||
if (records && records[index]) {
|
||
// 初始化_isOverflowing属性(如果不存在)
|
||
if (!records[index]._isOverflowing) {
|
||
vnode.context.$set(records[index], '_isOverflowing', {});
|
||
}
|
||
// 设置对应字段的溢出状态
|
||
vnode.context.$set(records[index]._isOverflowing, field, isOverflowing);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 根据文件扩展名获取对应的Bootstrap图标类名
|
||
function getFileIconClass(fileName, isDir = false) {
|
||
// 如果是文件夹,返回文件夹图标
|
||
if (isDir) {
|
||
return 'bi-folder-fill';
|
||
}
|
||
|
||
// 获取文件扩展名(转为小写)
|
||
const ext = fileName.toLowerCase().split('.').pop();
|
||
|
||
// 视频文件
|
||
const videoExts = ['mp4', 'mkv', 'avi', 'mov', 'rmvb', 'flv', 'wmv', 'm4v', 'ts', 'webm', '3gp', 'f4v'];
|
||
if (videoExts.includes(ext)) {
|
||
return 'bi-file-earmark-play';
|
||
}
|
||
|
||
// 音频文件
|
||
const audioExts = ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'wma', 'ape', 'ac3', 'dts'];
|
||
if (audioExts.includes(ext)) {
|
||
return 'bi-file-earmark-music';
|
||
}
|
||
|
||
// 图片文件
|
||
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'svg', 'ico', 'raw'];
|
||
if (imageExts.includes(ext)) {
|
||
return 'bi-file-earmark-image';
|
||
}
|
||
|
||
// 文本文件(包括歌词文件和字幕文件)
|
||
const textExts = ['txt', 'md', 'rtf', 'log', 'ini', 'cfg', 'conf', 'lrc', 'srt', 'ass', 'ssa', 'vtt', 'sup'];
|
||
if (textExts.includes(ext)) {
|
||
return 'bi-file-earmark-text';
|
||
}
|
||
|
||
// 富文本文件
|
||
const richtextExts = ['rtf', 'odt'];
|
||
if (richtextExts.includes(ext)) {
|
||
return 'bi-file-earmark-richtext';
|
||
}
|
||
|
||
// 压缩文件
|
||
const archiveExts = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'lzma', 'cab', 'iso'];
|
||
if (archiveExts.includes(ext)) {
|
||
return 'bi-file-earmark-zip';
|
||
}
|
||
|
||
// 字体文件
|
||
const fontExts = ['ttf', 'otf', 'woff', 'woff2', 'eot'];
|
||
if (fontExts.includes(ext)) {
|
||
return 'bi-file-earmark-font';
|
||
}
|
||
|
||
// 代码文件
|
||
const codeExts = ['js', 'html', 'css', 'py', 'java', 'c', 'cpp', 'php', 'go', 'json', 'xml', 'yml', 'yaml', 'sql', 'sh', 'bat', 'ps1', 'rb', 'swift', 'kt', 'ts', 'jsx', 'tsx', 'vue', 'scss', 'sass', 'less'];
|
||
if (codeExts.includes(ext)) {
|
||
return 'bi-file-earmark-code';
|
||
}
|
||
|
||
// PDF文件
|
||
if (ext === 'pdf') {
|
||
return 'bi-file-earmark-pdf';
|
||
}
|
||
|
||
// Word文档
|
||
const wordExts = ['doc', 'docx'];
|
||
if (wordExts.includes(ext)) {
|
||
return 'bi-file-earmark-word';
|
||
}
|
||
|
||
// Excel文档
|
||
const excelExts = ['xls', 'xlsx', 'csv'];
|
||
if (excelExts.includes(ext)) {
|
||
return 'bi-file-earmark-excel';
|
||
}
|
||
|
||
// PowerPoint文档
|
||
const pptExts = ['ppt', 'pptx'];
|
||
if (pptExts.includes(ext)) {
|
||
return 'bi-file-earmark-ppt';
|
||
}
|
||
|
||
// 医疗/健康相关文件
|
||
const medicalExts = ['dcm', 'dicom', 'hl7'];
|
||
if (medicalExts.includes(ext)) {
|
||
return 'bi-file-earmark-medical';
|
||
}
|
||
|
||
// 默认文件图标
|
||
return 'bi-file-earmark';
|
||
}
|
||
|
||
// 添加检测文件整理页面文件名溢出的自定义指令
|
||
Vue.directive('check-file-overflow', {
|
||
inserted: function(el, binding, vnode) {
|
||
// 检查元素是否溢出
|
||
const isOverflowing = el.scrollWidth > el.clientWidth;
|
||
|
||
// 如果绑定了值,则绑定到该值对应的文件属性上
|
||
if (binding.value) {
|
||
const indexAndField = binding.value.split('|');
|
||
const index = parseInt(indexAndField[0]);
|
||
const field = indexAndField[1];
|
||
|
||
// 设置文件的_isOverflowing属性
|
||
const files = vnode.context.fileManager.fileList;
|
||
if (files && files[index]) {
|
||
// 初始化_isOverflowing属性(如果不存在)
|
||
if (!files[index]._isOverflowing) {
|
||
vnode.context.$set(files[index], '_isOverflowing', {});
|
||
}
|
||
// 设置对应字段的溢出状态
|
||
vnode.context.$set(files[index]._isOverflowing, field, isOverflowing);
|
||
}
|
||
}
|
||
},
|
||
// 添加update钩子,在DOM更新时重新检测溢出状态
|
||
update: function(el, binding, vnode) {
|
||
// 检查元素是否溢出
|
||
const isOverflowing = el.scrollWidth > el.clientWidth;
|
||
|
||
// 如果绑定了值,则绑定到该值对应的文件属性上
|
||
if (binding.value) {
|
||
const indexAndField = binding.value.split('|');
|
||
const index = parseInt(indexAndField[0]);
|
||
const field = indexAndField[1];
|
||
|
||
// 设置文件的_isOverflowing属性
|
||
const files = vnode.context.fileManager.fileList;
|
||
if (files && files[index]) {
|
||
// 初始化_isOverflowing属性(如果不存在)
|
||
if (!files[index]._isOverflowing) {
|
||
vnode.context.$set(files[index], '_isOverflowing', {});
|
||
}
|
||
// 设置对应字段的溢出状态
|
||
vnode.context.$set(files[index]._isOverflowing, field, isOverflowing);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 添加检测模态框表格文本溢出的自定义指令
|
||
Vue.directive('check-modal-overflow', {
|
||
inserted: function(el, binding, vnode) {
|
||
// 检查元素是否溢出
|
||
const isOverflowing = el.scrollWidth > el.clientWidth;
|
||
|
||
// 如果绑定了值,则绑定到该值对应的文件属性上
|
||
if (binding.value) {
|
||
const indexAndField = binding.value.split('|');
|
||
const index = parseInt(indexAndField[0]);
|
||
const field = indexAndField[1];
|
||
|
||
// 设置文件的_isOverflowing属性
|
||
const files = vnode.context.fileSelect.fileList;
|
||
if (files && files[index]) {
|
||
// 初始化_isOverflowing属性(如果不存在)
|
||
if (!files[index]._isOverflowing) {
|
||
vnode.context.$set(files[index], '_isOverflowing', {});
|
||
}
|
||
// 设置对应字段的溢出状态
|
||
vnode.context.$set(files[index]._isOverflowing, field, isOverflowing);
|
||
}
|
||
}
|
||
},
|
||
// 添加update钩子,在DOM更新时重新检测溢出状态
|
||
update: function(el, binding, vnode) {
|
||
// 检查元素是否溢出
|
||
const isOverflowing = el.scrollWidth > el.clientWidth;
|
||
|
||
// 如果绑定了值,则绑定到该值对应的文件属性上
|
||
if (binding.value) {
|
||
const indexAndField = binding.value.split('|');
|
||
const index = parseInt(indexAndField[0]);
|
||
const field = indexAndField[1];
|
||
|
||
// 设置文件的_isOverflowing属性
|
||
const files = vnode.context.fileSelect.fileList;
|
||
if (files && files[index]) {
|
||
// 初始化_isOverflowing属性(如果不存在)
|
||
if (!files[index]._isOverflowing) {
|
||
vnode.context.$set(files[index], '_isOverflowing', {});
|
||
}
|
||
// 设置对应字段的溢出状态
|
||
vnode.context.$set(files[index]._isOverflowing, field, isOverflowing);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 添加中文数字转阿拉伯数字的函数
|
||
function chineseToArabic(chinese) {
|
||
if (!chinese) {
|
||
return null;
|
||
}
|
||
|
||
// 数字映射
|
||
const digitMap = {
|
||
'零': 0, '一': 1, '二': 2, '三': 3, '四': 4,
|
||
'五': 5, '六': 6, '七': 7, '八': 8, '九': 9,
|
||
'两': 2
|
||
};
|
||
|
||
// 单位映射
|
||
const unitMap = {
|
||
'十': 10,
|
||
'百': 100,
|
||
'千': 1000,
|
||
'万': 10000
|
||
};
|
||
|
||
// 如果是单个字符,直接返回对应数字
|
||
if (chinese.length === 1) {
|
||
if (chinese === '十') return 10;
|
||
return digitMap[chinese];
|
||
}
|
||
|
||
let result = 0;
|
||
let section = 0;
|
||
let number = 0;
|
||
|
||
// 从左向右处理
|
||
for (let i = 0; i < chinese.length; i++) {
|
||
const char = chinese[i];
|
||
|
||
if (char in digitMap) {
|
||
number = digitMap[char];
|
||
} else if (char in unitMap) {
|
||
const unit = unitMap[char];
|
||
// 如果前面没有数字,默认为1,例如"十"表示1*10=10
|
||
section += (number || 1) * unit;
|
||
number = 0;
|
||
|
||
// 如果是万级单位,累加到结果并重置section
|
||
if (unit === 10000) {
|
||
result += section;
|
||
section = 0;
|
||
}
|
||
} else {
|
||
// 非法字符
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 加上最后的数字和小节
|
||
result += section + number;
|
||
|
||
return result;
|
||
}
|
||
</script>
|
||
</head>
|
||
|
||
<body>
|
||
<div id="app">
|
||
<!-- 添加通知组件 -->
|
||
<div class="toast-container toast-container-center">
|
||
<div class="toast toast-custom" ref="toast" data-delay="3000">
|
||
<div class="toast-body toast-body-custom">
|
||
{{ toastMessage }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<nav class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0">
|
||
<a class="navbar-brand col-md-2 col-lg-2 mr-0 px-3" href="#" @click.prevent="window.innerWidth > 767.98 && toggleSidebar()" :class="{'sidebar-collapsed-navbar-brand': sidebarCollapsed}" :title="sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'">
|
||
<i class="bi bi-clouds"></i> <span class="navbar-title">夸克自动转存</span>
|
||
</a>
|
||
<!-- 顶部操作按钮组 -->
|
||
<div class="navbar-actions">
|
||
<button type="button" class="navbar-action-btn" title="保存(CTRL+S)" @click="saveConfig()">
|
||
<i class="bi bi-check2"></i>
|
||
</button>
|
||
<button type="button" class="navbar-action-btn" title="运行全部任务(CTRL+R)" @click="runScriptNow()">
|
||
<i class="bi bi-caret-right"></i>
|
||
</button>
|
||
<button type="button" class="navbar-action-btn" title="单击回到顶部/双击回到底部" @click="scrollToX(0)" @dblclick="scrollToX()">
|
||
<i class="bi bi-chevron-expand"></i>
|
||
</button>
|
||
<button type="button" class="navbar-action-btn d-none d-md-inline-block" :title="'页面宽度:' + (pageWidthMode === 'narrow' ? '窄' : pageWidthMode === 'medium' ? '中' : '宽')" @click="togglePageWidth()">
|
||
<i class="bi bi-arrows"></i>
|
||
</button>
|
||
</div>
|
||
<button class="navbar-toggler navbar-toggler-square position-absolute d-md-none collapsed" type="button" data-toggle="collapse" data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false">
|
||
<i class="bi bi-list"></i>
|
||
</button>
|
||
</nav>
|
||
|
||
<div class="container-fluid">
|
||
<nav id="sidebarMenu" class="col-md-2 col-lg-2 d-md-block bg-light sidebar collapse" :class="{'sidebar-collapsed': sidebarCollapsed}">
|
||
<div class="sidebar-sticky">
|
||
<ul class="nav flex-column">
|
||
<li class="nav-item">
|
||
<a class="nav-link" href="#" :class="{active: activeTab === 'tasklist'}" @click="changeTab('tasklist')">
|
||
<i class="bi bi-list-ul"></i> <span class="nav-text">任务列表</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" href="#" :class="{active: activeTab === 'history'}" @click="changeTab('history')">
|
||
<i class="bi bi-clock-history"></i> <span class="nav-text">转存记录</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" href="#" :class="{active: activeTab === 'filemanager'}" @click="changeTab('filemanager')">
|
||
<i class="bi bi-archive"></i> <span class="nav-text">文件整理</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" href="#" :class="{active: activeTab === 'discovery'}" @click="changeTab('discovery')">
|
||
<i class="bi bi-film"></i> <span class="nav-text">影视发现</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" href="#" :class="{active: activeTab === 'calendar'}" @click="changeTab('calendar')">
|
||
<i class="bi bi-calendar3-week"></i> <span class="nav-text">追剧日历</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" href="#" :class="{active: activeTab === 'config'}" @click="changeTab('config')">
|
||
<i class="bi bi-gear"></i> <span class="nav-text">系统配置</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" href="#" :class="{active: activeTab === 'runlogs'}" @click="changeTab('runlogs')">
|
||
<i class="bi bi-terminal"></i> <span class="nav-text">运行日志</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" href="/logout">
|
||
<i class="bi bi-power"></i> <span class="nav-text">退出</span>
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
<div class="bottom-links">
|
||
<ul class="nav flex-column">
|
||
<li class="nav-item">
|
||
<a class="nav-link" href="https://github.com/x1ao4/quark-auto-save-x/wiki" target="_blank">
|
||
<i class="bi bi-book"></i>
|
||
<span class="nav-text">Quark Auto Save X</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" href="https://github.com/x1ao4/quark-auto-save-x/releases" target="_blank">
|
||
<i class="bi bi-github"></i>
|
||
<span class="nav-text"><span v-html="versionTips"></span></span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" href="https://github.com/x1ao4/quark-auto-save-x/wiki/赞赏与交流群" target="_blank">
|
||
<i class="bi bi-wechat"></i>
|
||
<span class="nav-text">WeChat Group</span>
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<main class="col-md-10 col-lg-10 ml-sm-auto">
|
||
<form @submit.prevent="saveConfig" @keydown.enter.prevent>
|
||
|
||
<div v-if="activeTab === 'config'">
|
||
<div style="height: 0.5px;"></div>
|
||
<div class="row title">
|
||
<div class="col">
|
||
<h2 style="display: inline-block; font-size: 1.5rem;">账号设置</h2>
|
||
<span class="badge badge-pill badge-light">
|
||
<a href="#" title="夸克自动转存WebUI账号设置"><i class="bi bi-question-circle"></i></a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="row mb-2">
|
||
<div class="col webui-username-col">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">用户名</span>
|
||
</div>
|
||
<input type="text" v-model="formData.webui.username" class="form-control" placeholder="登录用户名">
|
||
</div>
|
||
</div>
|
||
<div class="col webui-password-col">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">密码</span>
|
||
</div>
|
||
<input :type="showWebuiPassword ? 'text' : 'password'" v-model="formData.webui.password" class="form-control" placeholder="登录密码">
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary" @click="toggleWebuiPassword">
|
||
<i :class="['bi', showWebuiPassword ? 'bi-eye' : 'bi-eye-slash']"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row title">
|
||
<div class="col">
|
||
<h2 style="display: inline-block; font-size: 1.5rem;">Cookie</h2>
|
||
<span class="badge badge-pill badge-light">
|
||
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/使用技巧集锦" target="_blank" title="夸克网盘账号的Cookie,用于转存、签到和文件整理,查阅Wiki了解详情"><i class="bi bi-question-circle"></i></a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div v-for="(value, index) in formData.cookie" :key="index" class="input-group mb-2">
|
||
<div class="input-group-prepend" v-if="userInfoList[index]">
|
||
<span class="input-group-text"
|
||
:style="userInfoList[index].nickname ? (userInfoList[index].is_active ? 'color: var(--dark-text-color);' : 'color: red;') : 'color: var(--dark-text-color);'"
|
||
:title="getCookieStatusTooltip(userInfoList[index])">
|
||
{{ userInfoList[index].nickname || (userInfoList[index].has_mparam ? '仅签到' : '未登录') }}
|
||
</span>
|
||
</div>
|
||
<div class="input-group-prepend" v-else>
|
||
<span class="input-group-text" style="color: var(--dark-text-color);" title="账号信息未验证,请获取Cookie后自动验证">未验证</span>
|
||
</div>
|
||
<input type="text" v-model="formData.cookie[index]" class="form-control" placeholder="打开 pan.quark.com 按 F12 抓取" @change="fetchUserInfo" title="所有账号都会进行签到(纯签到只需填写移动端参数),只有第一个账号会进行转存,请自行确认账号顺序,所有填写了Cookie的账号均支持文件整理,如需签到请在Cookie后方添加签到参数">
|
||
<div class="input-group-append">
|
||
<button v-if="index === formData.cookie.length - 1" type="button" class="btn btn-outline-primary" @click="addCookie()"><i class="bi bi-plus-lg"></i></button>
|
||
<button type="button" class="btn btn-outline-danger" @click="removeCookie(index)"><i class="bi bi-dash-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row title">
|
||
<div class="col">
|
||
<h2 style="display: inline-block; font-size: 1.5rem;">定时规则</h2>
|
||
<span class="badge badge-pill badge-light">
|
||
<a target="_blank" href="https://github.com/x1ao4/quark-auto-save-x/wiki/定时规则" title="设置任务的定时、延迟规则与执行周期模式,查阅Wiki了解详情"><i class="bi bi-question-circle"></i></a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="row mb-2 crontab-setting-row">
|
||
<div class="col-lg-4 col-md-6 col-12 mb-2 mb-lg-0">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">
|
||
<a href="https://tool.lu/crontab/" target="_blank" rel="noopener noreferrer" class="crontab-link" title="点击查看Crontab执行时间计算器">Crontab</a>
|
||
</span>
|
||
</div>
|
||
<input type="text" v-model="formData.crontab" class="form-control" placeholder="必填" title="支持标准Crontab表达式,例如 0 * * * * 表示在每个整点执行一次">
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-4 col-md-6 col-12 mb-2 mb-lg-0">
|
||
<div class="input-group" title="添加随机延迟时间:定时任务将在0到设定秒数之间随机延迟执行。建议值:0–3600,0表示不延迟">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">延迟执行</span>
|
||
</div>
|
||
<input type="text" v-model="formData.crontab_delay" class="form-control no-spinner" placeholder="0-3600" @input="validateNumberInput($event, 'crontab_delay', 3600)">
|
||
<div class="input-group-append">
|
||
<span class="input-group-text square-append">秒</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-4 col-md-6 col-12 mb-2 mb-lg-0">
|
||
<div class="input-group" title="执行周期判断方式:按自选周期执行(自选)使用任务的执行周期和截止日期判断;按任务进度执行(自动)根据任务进度是否100%判断,100%则跳过">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">执行周期</span>
|
||
</div>
|
||
<select v-model="formData.execution_mode" class="form-control" @change="onExecutionModeChange">
|
||
<option value="manual">按自选周期执行(自选)</option>
|
||
<option value="auto">按任务进度执行(自动)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row title" title="通知推送,支持多个渠道,查阅Wiki了解详情">
|
||
<div class="col">
|
||
<h2 style="display: inline-block; font-size: 1.5rem;">通知设置</h2>
|
||
<span class="badge badge-pill badge-light">
|
||
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/通知设置" target="_blank"><i class="bi bi-question-circle"></i></a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="input-group mb-2">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">推送通知</span>
|
||
</div>
|
||
<select v-model="formData.push_notify_type" class="form-control">
|
||
<option value="full">推送完整信息(转存成功、转存失败、资源失效)</option>
|
||
<option value="success_only">推送成功信息(转存成功)</option>
|
||
<option value="exclude_invalid">排除失效信息(转存成功、转存失败)</option>
|
||
</select>
|
||
</div>
|
||
<div v-for="(value, key, index) in formData.push_config" :key="key" class="input-group mb-2">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text" v-html="key"></span>
|
||
</div>
|
||
<div class="input-group-prepend" v-if="(key == 'DEER_KEY' || key == 'PUSH_KEY')">
|
||
<a type="button" class="btn btn-warning" target="_blank" href="https://sct.ftqq.com/r/13249" title="Server酱推荐计划"><i class="bi bi-award"></i></a>
|
||
</div>
|
||
<input type="text" v-model="formData.push_config[key]" class="form-control">
|
||
<div class="input-group-append">
|
||
<button v-if="index === Object.keys(formData.push_config).length - 1" type="button" class="btn btn-outline-primary" @click="addPush()"><i class="bi bi-plus-lg"></i></button>
|
||
<button type="button" class="btn btn-outline-danger" @click="removePush(key)"><i class="bi bi-dash-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row title" v-if="Object.keys(getAvailablePlugins(formData.plugins)).length" title="插件设置,具体键值由插件定义,查阅Wiki了解详情">
|
||
<div class="col">
|
||
<h2 style="display: inline-block; font-size: 1.5rem;">插件设置</h2>
|
||
<span class="badge badge-pill badge-light">
|
||
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/插件设置" target="_blank"><i class="bi bi-question-circle"></i></a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div v-for="(plugin, pluginName) in getAvailablePlugins(formData.plugins)" :key="pluginName" style="margin-bottom: -8px;" :style="pluginName === Object.keys(getAvailablePlugins(formData.plugins))[0] ? 'margin-top: -8px;' : ''">
|
||
<div class="form-group row mb-0" style="display:flex; align-items:center;">
|
||
<div data-toggle="collapse" :data-target="'#collapse_'+pluginName" aria-expanded="true" :aria-controls="'collapse_'+pluginName">
|
||
<div class="btn btn-block text-left">
|
||
<i class="bi bi-caret-right-fill"></i> <span v-html="getPluginDisplayName(pluginName)"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="collapse" :id="'collapse_'+pluginName" style="margin-left: 26px;">
|
||
<div v-for="(value, key, keyIndex) in plugin" :key="key" class="input-group mb-2" :style="{ marginBottom: keyIndex === Object.keys(plugin).length - 1 && pluginName === Object.keys(getAvailablePlugins(formData.plugins)).pop() ? '8.5px !important' : '' }">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text" v-html="key" :title="getPluginConfigHelp(pluginName, key)"></span>
|
||
</div>
|
||
<input type="text" v-model="formData.plugins[pluginName][key]" class="form-control"
|
||
:placeholder="getPluginConfigPlaceholder(pluginName, key)"
|
||
:title="getPluginConfigHelp(pluginName, key)">
|
||
</div>
|
||
<!-- 为特定插件添加全局配置选项 -->
|
||
<div v-if="['aria2', 'alist_strm_gen', 'emby'].includes(pluginName)" class="input-group mb-2">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text" :title="getPluginConfigModeHelp(pluginName)">插件配置模式</span>
|
||
</div>
|
||
<select class="form-control" v-model="formData.plugin_config_mode[pluginName]" @change="onPluginConfigModeChange(pluginName)" :title="getPluginConfigModeHelp(pluginName)">
|
||
<option value="independent">独立配置</option>
|
||
<option value="global">全局配置</option>
|
||
</select>
|
||
</div>
|
||
<!-- 全局插件配置选项 -->
|
||
<div v-if="['aria2', 'alist_strm_gen', 'emby'].includes(pluginName) && formData.plugin_config_mode[pluginName] === 'global'">
|
||
<div v-for="(taskConfig, taskKey) in getPluginTaskConfig(pluginName)" :key="taskKey" class="input-group mb-2">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text" v-html="taskKey" :title="getPluginTaskConfigHelp(pluginName, taskKey)"></span>
|
||
</div>
|
||
<select v-if="typeof formData.global_plugin_config[pluginName][taskKey] === 'boolean'" class="form-control" v-model="formData.global_plugin_config[pluginName][taskKey]" @change="onGlobalPluginConfigChange()" :title="getPluginTaskConfigHelp(pluginName, taskKey)">
|
||
<option :value="true">true</option>
|
||
<option :value="false">false</option>
|
||
</select>
|
||
<input v-else type="text" class="form-control" v-model="formData.global_plugin_config[pluginName][taskKey]"
|
||
:placeholder="getPluginTaskConfigPlaceholder(pluginName, taskKey)"
|
||
:title="getPluginTaskConfigHelp(pluginName, taskKey)"
|
||
@input="onGlobalPluginConfigChange()">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row title" title="资源搜索服务配置,用于任务名称智能搜索,查阅Wiki了解详情">
|
||
<div class="col">
|
||
<h2 style="display: inline-block; font-size: 1.5rem;">搜索来源</h2>
|
||
<span class="badge badge-pill badge-light">
|
||
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/资源搜索" target="_blank"><i class="bi bi-question-circle"></i></a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<!-- 按插件风格显示为可展开项 -->
|
||
<div style="margin-bottom: -8px;">
|
||
<!-- CloudSaver -->
|
||
<div style="margin-bottom: -8px; margin-top: -8px;">
|
||
<div class="form-group row mb-0" style="display:flex; align-items:center;">
|
||
<div data-toggle="collapse" data-target="#collapse_source_cloudsaver" aria-expanded="true" aria-controls="collapse_source_cloudsaver">
|
||
<div class="btn btn-block text-left">
|
||
<i class="bi bi-caret-right-fill"></i> CloudSaver
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="collapse" id="collapse_source_cloudsaver" style="margin-left: 26px;">
|
||
<div class="input-group mb-2">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">服务器</span>
|
||
</div>
|
||
<input type="text" v-model="formData.source.cloudsaver.server" class="form-control" placeholder="CloudSaver 服务器地址,如:http://192.168.1.100:8008">
|
||
</div>
|
||
<div class="input-group mb-2">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">用户名</span>
|
||
</div>
|
||
<input type="text" v-model="formData.source.cloudsaver.username" class="form-control" placeholder="CloudSaver 用户名">
|
||
</div>
|
||
<div class="input-group mb-2">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">密码</span>
|
||
</div>
|
||
<input :type="showCloudSaverPassword ? 'text' : 'password'" v-model="formData.source.cloudsaver.password" class="form-control" placeholder="CloudSaver 密码">
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary" @click="toggleCloudSaverPassword">
|
||
<i :class="['bi', showCloudSaverPassword ? 'bi-eye' : 'bi-eye-slash']"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PanSou -->
|
||
<div style="margin-bottom: -9.5px;">
|
||
<div class="form-group row mb-0" style="display:flex; align-items:center;">
|
||
<div data-toggle="collapse" data-target="#collapse_source_pansou" aria-expanded="false" aria-controls="collapse_source_pansou">
|
||
<div class="btn btn-block text-left">
|
||
<i class="bi bi-caret-right-fill"></i> PanSou
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="collapse" id="collapse_source_pansou" style="margin-left: 26px;">
|
||
<div class="input-group mb-2" style="margin-bottom: 10.5px !important;">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">服务器</span>
|
||
</div>
|
||
<input type="text" v-model="formData.source.pansou.server" class="form-control" placeholder="PanSou 服务器地址,如:http://192.168.1.100:80,默认地址 https://so.252035.xyz 为 PanSou 官方地址,建议自部署以获得更稳定的体验">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row title" title="预定义的正则匹配规则,在任务列表中可直接点击使用,查阅Wiki了解详情">
|
||
<div class="col">
|
||
<h2 style="display: inline-block; font-size: 1.5rem;">魔法匹配</h2>
|
||
<span class="badge badge-pill badge-light">
|
||
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/命名规则#21-魔法匹配" target="_blank"><i class="bi bi-question-circle"></i></a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div v-for="(value, key, index) in formData.magic_regex" :key="key" class="form-group mb-2">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">魔法名</span>
|
||
</div>
|
||
<input type="text" :data-oldkey="key" v-model="key" class="form-control" @change="updateMagicRegexKey($event.target.dataset.oldkey, $event.target.value)" placeholder="自定义名称">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">正则命名</span>
|
||
</div>
|
||
<input type="text" v-model="value.pattern" class="form-control" placeholder="匹配表达式">
|
||
<input type="text" v-model="value.replace" class="form-control" placeholder="替换表达式">
|
||
<div class="input-group-append">
|
||
<button v-if="index === Object.keys(formData.magic_regex).length - 1" type="button" class="btn btn-outline-primary" @click="addMagicRegex()"><i class="bi bi-plus-lg"></i></button>
|
||
<button type="button" class="btn btn-outline-danger" @click="removeMagicRegex(key)"><i class="bi bi-dash-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 剧集识别模块 -->
|
||
<div class="row title" title="识别文件名中的剧集编号,用于自动重命名,留空时使用内置默认规则,支持输入自定义规则作为补充,查阅Wiki了解详情">
|
||
<div class="col">
|
||
<h2 style="display: inline-block; font-size: 1.5rem;">剧集识别</h2>
|
||
<span class="badge badge-pill badge-light">
|
||
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/命名规则#25-剧集命名" target="_blank"><i class="bi bi-question-circle"></i></a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">集编号识别规则</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="episodePatternsText" placeholder="留空使用内置默认规则,或输入自定义正则表达式作为补充规则,多个表达式用竖线分隔,特殊符号需要转义">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 任务设置模块 -->
|
||
<div class="row title" title="配置影视发现页面创建任务时的智能填充规则,请使用你的网盘目录替换默认的目录前缀,否则将自动继承任务列表最后一个任务的保存路径,查阅Wiki了解详情">
|
||
<div class="col">
|
||
<h2 style="display: inline-block; font-size: 1.5rem;">任务设置</h2>
|
||
<span class="badge badge-pill badge-light">
|
||
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/影视发现任务设置" target="_blank"><i class="bi bi-question-circle"></i></a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">电影保存路径</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="formData.task_settings.movie_save_path" placeholder="电影目录前缀/片名 (年份)" title="设置电影类型内容的默认保存路径格式,在影视发现页面创建电影任务时自动填充。支持变量:片名、年份等">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">剧集保存路径</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="formData.task_settings.tv_save_path" placeholder="剧集目录前缀/剧名 (年份)/剧名 - S季数" title="设置电视剧类型内容的默认保存路径格式,在影视发现页面创建剧集任务时自动填充。支持变量:剧名、年份、季数等">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">动画保存路径</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="formData.task_settings.anime_save_path" placeholder="动画目录前缀/剧名 (年份)/剧名 - S季数" title="设置动画类型内容的默认保存路径格式,在影视发现页面创建动画任务时自动填充。支持变量:剧名、年份、季数等">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">综艺保存路径</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="formData.task_settings.variety_save_path" placeholder="综艺目录前缀/剧名 (年份)/剧名 - S季数" title="设置综艺类型内容的默认保存路径格式,在影视发现页面创建综艺任务时自动填充。支持变量:剧名、年份、季数等">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">纪录片保存路径</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="formData.task_settings.documentary_save_path" placeholder="纪录片目录前缀/剧名 (年份)/剧名 - S季数" title="设置纪录片类型内容的默认保存路径格式,在影视发现页面创建纪录片任务时自动填充。支持变量:剧名、年份、季数等">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">电影命名规则</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="formData.task_settings.movie_naming_pattern" placeholder="^(.*)\.([^.]+)" title="设置电影类型内容的默认文件命名规则的匹配表达式,在影视发现页面创建电影任务时自动填充">
|
||
<input type="text" class="form-control" v-model="formData.task_settings.movie_naming_replace" placeholder="片名 (年份).\2" title="设置电影类型内容的默认文件命名规则的替换表达式,在影视发现页面创建电影任务时自动填充。支持变量:片名、年份等">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">电视命名规则</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="formData.task_settings.tv_naming_rule" placeholder="剧名 - S季数E[]" title="设置电视类型内容的默认文件命名规则,在影视发现页面创建电视任务时自动填充。支持变量:剧名、季数、集数等,[]表示集数占位符">
|
||
<div class="input-group-append" title="保存时只比较文件名的部分,01.mp4和01.mkv视同为同一文件,不重复转存">
|
||
<div class="input-group-text">
|
||
<input type="checkbox" v-model="formData.task_settings.tv_ignore_extension"> 忽略后缀
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">字幕命名规则</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="formData.task_settings.subtitle_naming_rule" placeholder="zh" title="设置字幕文件的语言代码,在重命名字幕文件时会在文件名末尾添加此代码">
|
||
<div class="input-group-append" title="启用后,重命名字幕文件时会在文件名末尾添加语言代码后缀">
|
||
<div class="input-group-text">
|
||
<input type="checkbox" v-model="formData.task_settings.subtitle_add_language_code"> 添加语言代码
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">自动搜索资源</span>
|
||
</div>
|
||
<select class="form-control" v-model="formData.task_settings.auto_search_resources" title="启用后,在影视发现页面点击海报创建任务时,如果配置了有效的CloudSaver信息,将自动触发资源搜索功能">
|
||
<option value="disabled">禁用</option>
|
||
<option value="enabled">启用</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row title" title="设置任务列表、追剧日历等页面的任务按钮和相关信息的显示及排序方式,支持拖拽模块调整显示顺序,个别项目的禁用状态将被应用到所有页面,查阅Wiki了解详情">
|
||
<div class="col">
|
||
<h2 style="display: inline-block; font-size: 1.5rem;">显示设置</h2>
|
||
<span class="badge badge-pill badge-light">
|
||
<a target="_blank" href="https://github.com/x1ao4/quark-auto-save-x/wiki/显示设置"><i class="bi bi-question-circle"></i></a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 可拖拽显示设置(统一在一个容器中渲染,保持原样式,仅可拖动排序) -->
|
||
<div class="row mb-2 display-setting-row" id="display-setting-draggable">
|
||
<div class="col-lg-3 col-md-6 mb-2 draggable-item" v-for="key in formData.button_display_order" :key="key" draggable="true" @dragstart="onDisplayDragStart($event, key)" @dragend="onDisplayDragEnd($event)" @dragover.prevent="onDisplayDragOver($event)" @drop="onDisplayDrop($event, key)">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text" :title="getDisplayHelp(key)">{{ getDisplayLabel(key) }}</span>
|
||
</div>
|
||
<select v-if="key==='run_task' || key==='delete_task'" class="form-control" v-model="formData.button_display[key]" :title="getDisplayHelp(key)">
|
||
<option value="always">始终显示</option>
|
||
<option value="hover">悬停显示</option>
|
||
</select>
|
||
<select v-else class="form-control" v-model="formData.button_display[key]" :title="getDisplayHelp(key)">
|
||
<option value="always">始终显示</option>
|
||
<option value="hover">悬停显示</option>
|
||
<option value="disabled">禁用</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 海报语言设置(不支持拖拽,但样式与其他设置一致) -->
|
||
<div class="col-lg-3 col-md-6 mb-2">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text" title="任务列表与追剧日历的海报语言优先级,选择中文将优先使用zh-CN海报,选择原始语言将优先使用原始语言海报">海报语言</span>
|
||
</div>
|
||
<select class="form-control" v-model="formData.poster_language" title="任务列表与追剧日历的海报语言优先级,选择中文将优先使用zh-CN海报,选择原始语言将优先使用原始语言海报">
|
||
<option value="zh-CN">中文</option>
|
||
<option value="original">原始语言</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 性能设置 -->
|
||
<div class="row title" title="配置文件加载、数据缓存和自动刷新的关键参数,以兼顾性能与效率,查阅Wiki了解详情">
|
||
<div class="col">
|
||
<h2 style="display: inline-block; font-size: 1.5rem;">性能设置</h2>
|
||
<span class="badge badge-pill badge-light">
|
||
<a target="_blank" href="https://github.com/x1ao4/quark-auto-save-x/wiki/性能设置"><i class="bi bi-question-circle"></i></a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="row mb-2 performance-setting-row">
|
||
<div class="col-lg-3 col-md-6 mb-2">
|
||
<div class="input-group" title="每次请求夸克API时获取的文件数量,适当增大该数值可减少请求次数,提升大文件夹的加载效率。建议值:100–500">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">单次请求文件数量</span>
|
||
</div>
|
||
<input type="text" class="form-control no-spinner" v-model="formData.file_performance.api_page_size" placeholder="200">
|
||
<div class="input-group-append">
|
||
<span class="input-group-text square-append">个</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-3 col-md-6 mb-2">
|
||
<div class="input-group" title="文件列表在本地缓存的持续时间,过期后将自动清除。设置过短会增加API请求次数,设置过长可能无法及时反映最新变动。建议值:0-300,0表示不缓存">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">文件列表缓存时长</span>
|
||
</div>
|
||
<input type="text" class="form-control no-spinner" v-model="formData.file_performance.cache_expire_time" placeholder="30">
|
||
<div class="input-group-append">
|
||
<span class="input-group-text square-append">秒</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-3 col-md-6 mb-2">
|
||
<div class="input-group" title="影视发现页面每个榜单显示的项目数量。建议值:20-100">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">影视榜单项目数量</span>
|
||
</div>
|
||
<input type="text" class="form-control no-spinner" v-model="formData.file_performance.discovery_items_count" placeholder="30">
|
||
<div class="input-group-append">
|
||
<span class="input-group-text square-append">个</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-3 col-md-6 mb-2">
|
||
<div class="input-group" title="追剧日历最新季元数据自动刷新周期,设置为0或负数可关闭自动刷新。默认值:21600(6小时)">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">追剧日历刷新周期</span>
|
||
</div>
|
||
<input type="text" class="form-control no-spinner" v-model="formData.performance.calendar_refresh_interval_seconds" placeholder="21600">
|
||
<div class="input-group-append">
|
||
<span class="input-group-text square-append">秒</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-3 col-md-6 mb-2">
|
||
<div class="input-group" title="已播出集数(及任务进度)自动刷新时间,24小时制,格式:HH:MM。默认值:00:00(每天0点)">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">播出集数刷新时间</span>
|
||
</div>
|
||
<input type="text" class="form-control no-spinner" v-model="formData.performance.aired_refresh_time" placeholder="00:00">
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-3 col-md-6 mb-2">
|
||
<div class="input-group" title="运行日志页面显示的日志时间范围,默认值:3(最近72小时)">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">运行日志显示范围</span>
|
||
</div>
|
||
<input type="text" class="form-control no-spinner" v-model="formData.performance.runtime_log_display_days" placeholder="3">
|
||
<div class="input-group-append">
|
||
<span class="input-group-text square-append">天</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="row title" title="QASX API 支持第三方添加任务、开发插件及自动化操作,查阅Wiki了解详情">
|
||
<div class="col">
|
||
<h2 style="display: inline-block; font-size: 1.5rem;">QASX API</h2>
|
||
<span class="badge badge-pill badge-light">
|
||
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/API" target="_blank"><i class="bi bi-question-circle"></i></a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">Token</span>
|
||
</div>
|
||
<input type="text" v-model="formData.api_token" class="form-control token-display" disabled>
|
||
</div>
|
||
|
||
<div class="row title" title="TMDB API密钥,用于追剧日历功能获取电视节目信息和播出时间表,点击获取TMDB API密钥">
|
||
<div class="col">
|
||
<h2 style="display: inline-block; font-size: 1.5rem;">TMDB API</h2>
|
||
<span class="badge badge-pill badge-light">
|
||
<a href="https://www.themoviedb.org/settings/api" target="_blank" title="TMDB API密钥,用于获取电视节目信息和播出时间表,点击获取TMDB API密钥"><i class="bi bi-question-circle"></i></a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">API 密钥</span>
|
||
</div>
|
||
<input type="text" v-model="formData.tmdb_api_key" class="form-control" placeholder="请输入 TMDB API 密钥,用于追剧日历功能">
|
||
</div>
|
||
<p class="tmdb-attribution">部分功能基于 <a href="https://www.themoviedb.org/" target="_blank">TMDB</a> 提供的数据实现,本应用与 TMDB 无关。</p>
|
||
|
||
</div>
|
||
|
||
<div v-if="activeTab === 'runlogs'">
|
||
<div style="height: 20px;"></div>
|
||
<div class="row tasklist-filter-row" style="margin-bottom: 20px;">
|
||
<div class="col-xl-4 col-lg-4 col-md-6 mb-2 mb-lg-0">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">内容筛选</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="runtimeLogFilters.keyword" placeholder="日志内容关键词">
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary filter-btn-square" @click="clearRuntimeLogFilter('keyword')"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-4 col-lg-4 col-md-6 mb-2 mb-lg-0">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">任务筛选</span>
|
||
</div>
|
||
<div class="position-relative" style="flex: 1;">
|
||
<select class="form-control task-filter-select" v-model="runtimeLogFilters.task" style="padding-left: 8px !important; text-indent: 0 !important; display: flex !important; align-items: center !important; line-height: 1.5 !important; padding-right: 8px !important;">
|
||
<option value="">全部</option>
|
||
<option v-for="task in taskNames" :value="task" v-html="task"></option>
|
||
</select>
|
||
</div>
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary filter-btn-square" @click="clearRuntimeLogFilter('task')"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-4 col-lg-4 col-md-6">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">级别筛选</span>
|
||
</div>
|
||
<div class="position-relative" style="flex: 1;">
|
||
<select class="form-control task-filter-select"
|
||
v-model="runtimeLogFilters.level"
|
||
style="padding-left: 8px !important; text-indent: 0 !important; display: flex !important; align-items: center !important; line-height: 1.5 !important; padding-right: 8px !important;">
|
||
<option value="">全部</option>
|
||
<option v-for="level in runtimeLogLevels" :value="level">{{ level }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary filter-btn-square" @click="clearRuntimeLogFilter('level')"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="row" style="margin-bottom: 20px;">
|
||
<div class="col">
|
||
<div class="runlog-content" ref="runtimeLogViewport">
|
||
<div v-if="!runtimeLogLoading && filteredRuntimeLogs.length === 0" style="display: flex; align-items: center; justify-content: center; min-height: 200px; color: var(--light-text-color);">
|
||
暂无匹配的日志
|
||
</div>
|
||
<div v-else-if="filteredRuntimeLogs.length > 0" :style="{ visibility: runtimeLogInitialized ? 'visible' : 'hidden' }">
|
||
<div v-for="log in filteredRuntimeLogs"
|
||
:key="log.id"
|
||
class="runlog-line"
|
||
@click="handleRuntimeLogClick(log, $event)">
|
||
<span style="margin-right: 8px;">[{{ log.timestamp || '--' }}][<span class="log-level-clickable" @click="filterByLogLevel(log.level || 'INFO', $event)">{{ log.level || 'INFO' }}</span>]</span>
|
||
<span v-html="getRuntimeLogDisplayHtml(log)"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="activeTab === 'tasklist'">
|
||
<div style="height: 20px;"></div>
|
||
<div class="row tasklist-filter-row" style="margin-bottom: 8px;">
|
||
<div class="col-xl-4 col-lg-4 col-md-6 mb-2 mb-lg-0">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">名称筛选</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="taskNameFilter" placeholder="任务名称关键词">
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary filter-btn-square" @click="clearData('taskNameFilter')"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-4 col-lg-4 col-md-6 mb-2 mb-lg-0">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">任务筛选</span>
|
||
</div>
|
||
<div class="position-relative" style="flex: 1;">
|
||
<select class="form-control task-filter-select" v-model="taskDirSelected" style="padding-left: 8px !important; text-indent: 0 !important; display: flex !important; align-items: center !important; line-height: 1.5 !important; padding-right: 8px !important;">
|
||
<option value="">全部</option>
|
||
<option v-for="task in taskNames" :value="task" v-html="task"></option>
|
||
</select>
|
||
<!-- <i class="bi bi-chevron-down select-arrow" style="position: absolute; pointer-events: none; color: var(--dark-text-color);"></i> -->
|
||
</div>
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary filter-btn-square" @click="clearData('taskDirSelected')"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-4 col-lg-4 col-md-6">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">状态筛选</span>
|
||
</div>
|
||
<div class="position-relative" style="flex: 1;">
|
||
<select class="form-control task-filter-select"
|
||
v-model="taskStatusFilter"
|
||
style="padding-left: 8px !important; text-indent: 0 !important; display: flex !important; align-items: center !important; line-height: 1.5 !important; padding-right: 8px !important;">
|
||
<option value="">全部</option>
|
||
<option value="incomplete">未完成</option>
|
||
<option value="ongoing">进行中</option>
|
||
<option value="completed">已完成</option>
|
||
<option value="airing">播出中</option>
|
||
<option value="finale">本季终</option>
|
||
<option value="ended">已完结</option>
|
||
<option value="unmatched">未匹配</option>
|
||
</select>
|
||
</div>
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary filter-btn-square" @click="clearData('taskStatusFilter')"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 任务列表:类型筛选按钮和排序组件(复用追剧日历移动端样式) -->
|
||
<div class="row mb-3 tasklist-header-row">
|
||
<div class="col-lg-8 col-md-6">
|
||
<!-- 类型筛选按钮 -->
|
||
<div class="calendar-category-buttons tasklist-type-filter">
|
||
<button type="button"
|
||
class="btn btn-outline-secondary calendar-category-btn"
|
||
:class="{ active: tasklist.selectedType === 'all' }"
|
||
@click="selectTasklistType('all')">
|
||
全部
|
||
</button>
|
||
<button type="button"
|
||
class="btn btn-outline-secondary calendar-category-btn"
|
||
v-for="type in tasklist.contentTypes"
|
||
:key="'tasklist-'+type"
|
||
:class="{ active: tasklist.selectedType === type }"
|
||
@click="selectTasklistType(type)">
|
||
{{ getContentTypeDisplayName(type) }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-4 col-md-6">
|
||
<!-- 排序组件 -->
|
||
<div class="tasklist-sort-controls d-flex justify-content-end">
|
||
<div class="tasklist-sort-wrapper">
|
||
<span class="tasklist-sort-pill tasklist-sort-pill-icon" title="切换排序">按</span>
|
||
<select class="tasklist-sort-pill tasklist-sort-select task-filter-select" :value="tasklistSort.by" @change="changeTasklistSortBy($event.target.value)" title="选择排序方式">
|
||
<option value="index">任务编号</option>
|
||
<option value="name">任务名称</option>
|
||
<option v-if="formData.tmdb_api_key" value="progress">任务进度</option>
|
||
<option value="update_time">更新时间</option>
|
||
</select>
|
||
<span class="tasklist-sort-pill tasklist-sort-order" @click="toggleTasklistSortOrder" title="切换升降序">
|
||
{{ tasklistSort.order === 'asc' ? '升序排列' : '降序排列' }}
|
||
</span>
|
||
</div>
|
||
<!-- 任务数量显示 -->
|
||
<div
|
||
class="tasklist-count-indicator ml-2"
|
||
:class="{'tasklist-count-single-digit': tasklistVisibleCount < 10}"
|
||
:title="`共${tasklistVisibleCount}个任务`">
|
||
<span class="tasklist-count-number">{{ tasklistVisibleCount }}</span>
|
||
</div>
|
||
<!-- 视图切换按钮:列表视图 与 海报视图 -->
|
||
<div class="ml-2">
|
||
<button type="button"
|
||
class="btn btn-outline-secondary btn-sm tasklist-view-toggle-btn"
|
||
@click="toggleTasklistViewMode"
|
||
:title="tasklist.viewMode === 'list' ? '当前:列表视图,点击切换到海报视图' : '当前:海报视图,点击切换到列表视图'">
|
||
<i :class="tasklist.viewMode === 'list' ? 'bi bi-grid-3x3-gap' : 'bi bi-list-ol'"></i>
|
||
</button>
|
||
</div>
|
||
<!-- 创建任务按钮 -->
|
||
<div class="ml-2">
|
||
<button type="button"
|
||
class="btn btn-outline-secondary btn-sm tasklist-create-task-btn"
|
||
@click="openCreateTaskModal"
|
||
title="创建任务">
|
||
<i class="bi bi-plus-lg"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 任务列表:列表视图 -->
|
||
<template v-if="tasklist.viewMode === 'list'">
|
||
<div v-for="(task, index) in sortedTasklist" :key="'list-'+index" class="task mb-3" v-show="shouldShowTasklist">
|
||
<template v-if="(taskDirSelected == '' || task.taskname == taskDirSelected) && task.taskname.includes(taskNameFilter) && tasklistFilterByType(task) && tasklistFilterByStatus(task)">
|
||
<hr>
|
||
<div class="form-group row" style="align-items:center">
|
||
<div class="col pl-0" data-toggle="collapse" :data-target="'#collapse_'+index" aria-expanded="true" :aria-controls="'collapse_'+index">
|
||
<div class="btn btn-block text-left">
|
||
<i class="bi bi-caret-right-fill"></i> #<span v-html="`${String((task.__originalIndex !== undefined ? task.__originalIndex : index) + 1).padStart(2, '0')} ${task.taskname}`"></span>
|
||
<template v-for="key in formData.button_display_order">
|
||
<span v-if="key==='latest_transfer_file' && formData.button_display.latest_transfer_file !== 'disabled' && taskLatestFiles[task.taskname]"
|
||
class="task-latest-file"
|
||
:class="{'hover-only': formData.button_display.latest_transfer_file === 'hover'}">
|
||
· {{ taskLatestFiles[task.taskname] }}
|
||
</span>
|
||
<span v-else-if="key==='season_counts' && formData.button_display.season_counts !== 'disabled' && getTaskSeasonCounts(task.taskname)"
|
||
class="task-season-counts"
|
||
:class="{'hover-only': formData.button_display.season_counts === 'hover'}"
|
||
v-html="' · ' + formatSeasonCounts(getTaskSeasonCounts(task.taskname))">
|
||
</span>
|
||
<span v-else-if="key==='latest_update_date' && formData.button_display.latest_update_date !== 'disabled' && taskLatestRecords[task.taskname]"
|
||
class="task-latest-date"
|
||
:class="{'hover-only': formData.button_display.latest_update_date === 'hover'}">
|
||
· {{ getTaskLatestRecordDisplay(task.taskname) }}
|
||
</span>
|
||
<span v-else-if="key==='task_progress' && formData.button_display.task_progress !== 'disabled' && getTaskProgress(task.taskname) !== null"
|
||
class="task-progress"
|
||
:class="{'hover-only': formData.button_display.task_progress === 'hover'}">
|
||
· {{ getTaskProgress(task.taskname) }}%
|
||
</span>
|
||
<span v-else-if="key==='show_status' && formData.button_display.show_status !== 'disabled' && getTaskShowStatus(task.taskname)"
|
||
class="task-show-status"
|
||
:class="{'hover-only': formData.button_display.show_status === 'hover'}">
|
||
· {{ getTaskShowStatus(task.taskname) }}
|
||
</span>
|
||
</template>
|
||
<span v-if="isTaskUpdatedToday(task.taskname) && shouldShowTodayIndicator()"
|
||
class="task-today-indicator"
|
||
:class="getTodayIndicatorClass()">
|
||
<i class="bi bi-stars"></i>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-auto task-buttons">
|
||
<template v-for="key in formData.button_display_order">
|
||
<button v-if="key==='refresh_plex' && formData.plugins && formData.plugins.plex && formData.plugins.plex.url && formData.plugins.plex.token && formData.plugins.plex.quark_root_path && formData.button_display.refresh_plex !== 'disabled'" type="button" class="btn btn-outline-plex" :class="{'hover-only': formData.button_display.refresh_plex === 'hover'}" @click="refreshPlexLibrary(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="刷新Plex媒体库"><img src="./static/images/Plex.svg" class="plex-icon"></button>
|
||
<button v-else-if="key==='refresh_alist' && formData.plugins && formData.plugins.alist && formData.plugins.alist.url && formData.plugins.alist.token && formData.plugins.alist.storage_id && formData.button_display.refresh_alist !== 'disabled'" type="button" class="btn btn-outline-alist" :class="{'hover-only': formData.button_display.refresh_alist === 'hover'}" @click="refreshAlistDirectory(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="刷新AList目录"><img src="https://cdn.jsdelivr.net/gh/alist-org/logo@main/logo.svg" class="alist-icon"></button>
|
||
<template v-else-if="key==='run_task'">
|
||
<button v-if="!task.shareurl_ban" type="button" class="btn btn-outline-primary" :class="{'hover-only': formData.button_display.run_task === 'hover'}" @click="runScriptNow(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="运行此任务"><i class="bi bi-caret-right"></i></button>
|
||
<button v-else type="button" class="btn btn-warning" :title="formatShareUrlBanMessage(task.shareurl_ban) || task.shareurl_ban" disabled><i class="bi bi-exclamation-circle"></i></button>
|
||
</template>
|
||
<button v-else-if="key==='delete_task'" type="button" class="btn btn-outline-danger" :class="{'hover-only': formData.button_display.delete_task === 'hover'}" @click="removeTask(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="删除此任务"><i class="bi bi-trash3"></i></button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div class="collapse ml-3" :id="'collapse_'+index">
|
||
<div class="alert alert-warning" role="alert" v-if="task.shareurl_ban" v-html="formatShareUrlBanMessage(task.shareurl_ban) || task.shareurl_ban"></div>
|
||
<div class="form-group row">
|
||
<label class="col-sm-2 col-form-label">任务名称</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<input type="text" name="taskname[]" class="form-control" v-model="task.taskname" placeholder="必填" @focus="smart_param.showSuggestions=true;focusTaskname(task.__originalIndex !== undefined ? task.__originalIndex : index, task)" @input="changeTaskname(task.__originalIndex !== undefined ? task.__originalIndex : index, task)">
|
||
<div class="dropdown-menu show task-suggestions" v-if="smart_param.showSuggestions && smart_param.index === (task.__originalIndex !== undefined ? task.__originalIndex : index) && (smart_param.taskSuggestions.success || smart_param.isSearching)">
|
||
<div class="dropdown-item text-muted" v-if="smart_param.isSearching" style="font-size:14px; padding: 0 8px; text-align: left;">
|
||
<span v-if="smart_param.validating">
|
||
<i class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></i>
|
||
正在验证链接有效性...({{ smart_param.validateProgress.current }}/{{ smart_param.validateProgress.total }})<span v-if="smart_param.validateProgress.valid > 0">已找到 {{ smart_param.validateProgress.valid }} 个有效链接</span>
|
||
</span>
|
||
<span v-else>正在搜索资源...</span>
|
||
</div>
|
||
<div class="dropdown-item text-muted" v-else style="font-size:14px; padding-left: 8px; text-align: left;">
|
||
{{ smart_param.taskSuggestions.message ? smart_param.taskSuggestions.message : smart_param.taskSuggestions.data && smart_param.taskSuggestions.data.length ? `以下资源由 ${(smart_param.taskSuggestions.source || '').replace(/,\s*/g, '、')} 搜索提供(仅显示有效链接,共 ${(smart_param.taskSuggestions.data || []).length} 个),如有侵权请联系资源发布方` : "未搜索到有效资源" }}
|
||
</div>
|
||
<div v-for="suggestion in smart_param.taskSuggestions.data || []" :key="(suggestion.shareurl || '') + '_' + (suggestion.taskname || '') + '_' + (suggestion.publish_date || '')" class="dropdown-item cursor-pointer" @click.prevent="selectSuggestion(task.__originalIndex !== undefined ? task.__originalIndex : index, suggestion)" style="font-size: 14px;" :title="getSuggestionHoverTitle(suggestion)">
|
||
<span v-html="suggestion.verify ? '✅': ''"></span> {{ suggestion.taskname }}
|
||
<small class="text-muted">
|
||
<a :href="suggestion.shareurl" target="_blank" @click.stop> · {{ suggestion.shareurl.replace(/^https?:\/\/pan\.quark\.cn\/s\//, '') }}</a>
|
||
<template v-if="suggestion.source"><span class="source-badge" :class="suggestion.source.toLowerCase()" :data-publish-date="suggestion.publish_date ? ' · ' + formatPublishDate(suggestion.publish_date, suggestion.pansou_source, suggestion.source) : ''">{{ suggestion.source }}</span></template>
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<div class="input-group-append" title="资源搜索">
|
||
<button class="btn btn-primary" type="button" @click="searchSuggestions(task.__originalIndex !== undefined ? task.__originalIndex : index, task.taskname)">
|
||
<i v-if="smart_param.isSearching && smart_param.index === (task.__originalIndex !== undefined ? task.__originalIndex : index)" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></i>
|
||
<i v-else class="bi bi-search"></i>
|
||
</button>
|
||
<div class="input-group-text" title="谷歌搜索">
|
||
<a target="_blank" :href="`https://www.google.com/search?q=%22pan.quark.cn/s%22+${cleanTaskNameForSearch(task.taskname)}`"><i class="bi bi-google"></i></a>
|
||
</div>
|
||
<div class="input-group-text" title="TMDB搜索">
|
||
<a target="_blank" :href="`https://www.themoviedb.org/search?query=${cleanTaskNameForSearch(task.taskname)}`"><img src="./static/images/TMDB.svg" class="tmdb-icon"></a>
|
||
</div>
|
||
<div class="input-group-text" title="豆瓣搜索">
|
||
<a target="_blank" :href="`https://search.douban.com/movie/subject_search?search_text=${cleanTaskNameForSearch(task.taskname)}`"><img src="./static/images/Douban.svg" class="douban-icon"></a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group row" title="支持子目录链接,Web端打开分享点入目录,复制浏览器的URL即可;支持带提取码链接,查阅Wiki了解详情">
|
||
<label class="col-sm-2 col-form-label">分享链接</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<input type="text" name="shareurl[]" class="form-control" v-model="task.shareurl" placeholder="必填" @blur="changeShareurl(task)">
|
||
<div class="input-group-append" v-if="task.shareurl">
|
||
<button type="button" class="btn btn-outline-secondary" @click="fileSelect.selectDir=true;fileSelect.previewRegex=false;showShareSelect(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="选择需转存的文件夹"><i class="bi bi-folder"></i></button>
|
||
<div class="input-group-text">
|
||
<a target="_blank" :href="task.shareurl"><i class="bi bi-link-45deg"></i></a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group row">
|
||
<label class="col-sm-2 col-form-label">保存路径</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<input type="text" name="savepath[]" class="form-control" v-model="task.savepath" placeholder="必填" @focus="focusTaskname(task.__originalIndex !== undefined ? task.__originalIndex : index, task)" @input="onSavepathChange(task.__originalIndex !== undefined ? task.__originalIndex : index, task)">
|
||
<div class="input-group-append">
|
||
<button class="btn btn-outline-secondary" type="button" v-if="smart_param.savepath && smart_param.index == (task.__originalIndex !== undefined ? task.__originalIndex : index) && task.savepath != smart_param.origin_savepath" @click="task.savepath = smart_param.origin_savepath" title="恢复保存路径"><i class="bi bi-reply"></i></button>
|
||
<button class="btn btn-outline-secondary" type="button" @click="showSavepathSelect(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="选择保存到的文件夹"><i class="bi bi-folder"></i></button>
|
||
<button type="button" class="btn btn-outline-secondary" @click="resetFolderContent(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="重置文件夹:此操作将删除当前保存路径中的所有文件及相关转存记录,且不可恢复,请谨慎操作"><i class="bi bi-folder-x"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group row" :title="task.use_sequence_naming || task.use_episode_naming ? (task.use_sequence_naming ? '输入带{}占位符的重命名格式,如:剧名 - S01E{}、剧名.S03E{}等,{}将被替换为集序号(按文件名和修改日期智能排序),目录中的文件夹会被自动过滤' : '输入带[]占位符的重命名格式,如:剧名 - S01E[]、剧名.S03E[]等,[]将被替换为从文件名中提取的集编号,目录中的文件夹会被自动过滤') : '可用作筛选,只转存匹配到文件名的文件,留空则直接转存所有文件'">
|
||
<label class="col-sm-2 col-form-label">命名规则</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<button class="btn btn-outline-secondary" type="button" @click="fileSelect.selectDir=true;fileSelect.previewRegex=true;fileSelect.sortBy='file_name_re';fileSelect.sortOrder='desc';showShareSelect(task.__originalIndex !== undefined ? task.__originalIndex : index)" :title="task.use_sequence_naming ? '预览顺序命名效果' : (task.use_episode_naming ? '预览剧集命名效果' : '预览正则命名效果')">
|
||
{{ task.use_sequence_naming ? '顺序命名' : (task.use_episode_naming ? '剧集命名' : '正则命名') }}
|
||
</button>
|
||
</div>
|
||
<input type="text" name="pattern[]" class="form-control" v-model="task.pattern" :placeholder="task.use_sequence_naming ? '输入带{}占位符的重命名格式,如:剧名 - S01E{}' : (task.use_episode_naming ? '输入带[]占位符的重命名格式,如:剧名 - S01E[]' : '匹配表达式')" list="magicRegex" @input="detectNamingMode(task); onPatternChange(task.__originalIndex !== undefined ? task.__originalIndex : index, task)">
|
||
<input v-if="!task.use_sequence_naming && !task.use_episode_naming" type="text" name="replace[]" class="form-control" v-model="task.replace" placeholder="替换表达式">
|
||
<div class="input-group-append" title="保存时只比较文件名的部分,01.mp4和01.mkv视同为同一文件,不重复转存">
|
||
<div class="input-group-text">
|
||
<input type="checkbox" v-model="task.ignore_extension"> 忽略后缀
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<datalist id="magicRegex">
|
||
<option v-for="(value, key) in formData.magic_regex" :key="key" :value="`${key}`" v-html="`${value.pattern.replace('<', '<\u200B')} → ${value.replace}`"></option>
|
||
</datalist>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group row" title="名称包含过滤词汇的项目不会被转存,多个词用逗号分隔,支持通过文件名和扩展名过滤文件和文件夹,支持使用保留词|过滤词的格式实现高级过滤,查阅Wiki了解详情">
|
||
<label class="col-sm-2 col-form-label">过滤规则</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<input type="text" name="filterwords[]" class="form-control" v-model="task.filterwords" placeholder="可选,输入过滤词汇,用逗号分隔,如:纯享,txt,超前企划,名称包含过滤词汇的项目不会被转存,支持使用保留词|过滤词的格式实现高级过滤">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group row" title="只转存比选中文件更新的文件,请在符合筛选条件的文件中进行选择,在更换分享链接时非常有用">
|
||
<label class="col-sm-2 col-form-label">起始文件</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<input type="text" class="form-control" placeholder="可选,只转存比此文件更新的文件,请在符合筛选条件的文件中进行选择" name="startfid[]" v-model="task.startfid">
|
||
<div class="input-group-append" v-if="task.shareurl">
|
||
<button class="btn btn-outline-secondary" type="button" @click="fileSelect.selectDir=false;fileSelect.previewRegex=false;showShareSelect(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="选择起始文件"><i class="bi bi-folder"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group row" title="匹配成功的文件夹的所有嵌套目录都会被更新,并且会应用与根目录相同的正则命名和过滤规则。注意:原理是逐级索引,深层嵌套目录的场景下效率非常低,慎用.*">
|
||
<label class="col-sm-2 col-form-label">更新目录</label>
|
||
<div class="col-sm-10">
|
||
<input type="text" name="update_subdir[]" class="form-control" v-model="task.update_subdir" placeholder="可选,输入需要更新的子目录的文件夹名称(或正则表达式),多个项目用竖线分隔,如:4K|1080P">
|
||
</div>
|
||
</div>
|
||
<div class="form-group row">
|
||
<label class="col-sm-2 col-form-label">截止日期</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<input type="date" name="enddate[]" class="form-control date-input-no-icon" v-model="task.enddate" placeholder="可选" :ref="'enddate_' + index">
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary" @click="openDatePicker(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="选择截止日期">
|
||
<i class="bi bi-calendar3"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group row" title="只在勾选的星期时才运行,对周更的内容非常有用">
|
||
<label class="col-sm-2 col-form-label">执行周期</label>
|
||
<div class="col-sm-10 col-form-label">
|
||
<div class="form-check form-check-inline" title="也可用作任务总开关">
|
||
<input class="form-check-input" type="checkbox" :checked="task.runweek.length === 7" @change="toggleAllWeekdays(task)" :indeterminate.prop="task.runweek.length > 0 && task.runweek.length < 7" :disabled="(task.execution_mode || formData.execution_mode || 'manual') === 'auto'">
|
||
<label class="form-check-label" :class="{'text-muted': (task.execution_mode || formData.execution_mode || 'manual') === 'auto'}">全选</label>
|
||
</div>
|
||
<div class="form-check form-check-inline" v-for="(day, index) in weekdays" :key="index">
|
||
<input class="form-check-input" type="checkbox" v-model="task.runweek" :value="index+1" :disabled="(task.execution_mode || formData.execution_mode || 'manual') === 'auto'">
|
||
<label class="form-check-label" :class="{'text-muted': (task.execution_mode || formData.execution_mode || 'manual') === 'auto'}" v-html="day"></label>
|
||
</div>
|
||
<div class="form-check form-check-inline">
|
||
<input class="form-check-input" type="checkbox" :checked="(task.execution_mode || formData.execution_mode || 'manual') === 'auto'" @change="task.execution_mode = $event.target.checked ? 'auto' : 'manual'">
|
||
<label class="form-check-label">自动</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group row" v-if="Object.keys(getAvailablePlugins(formData.plugins)).length" :title="getPluginConfigTitle(task)">
|
||
<label class="col-sm-2 col-form-label">插件配置</label>
|
||
<div class="col-sm-10">
|
||
<v-jsoneditor v-model="task.addition" :options="{mode:'tree'}" :plus="false" height="162px" style="margin-bottom: -8px;" :disabled="isPluginConfigDisabled(task)"></v-jsoneditor>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<!-- 任务列表:海报视图 -->
|
||
<div v-else class="tasklist-poster-mode">
|
||
<!-- 复用管理视图的海报网格样式与自适应布局 -->
|
||
<div class="discovery-grid">
|
||
<div class="discovery-item"
|
||
v-for="(task, index) in sortedTasklist"
|
||
:key="'poster-'+(task.taskname || index)"
|
||
v-if="(taskDirSelected == '' || task.taskname == taskDirSelected) && task.taskname.includes(taskNameFilter) && tasklistFilterByType(task) && tasklistFilterByStatus(task)">
|
||
<div class="discovery-poster" @mouseenter="handleManagementPosterHover($event, getCalendarTaskByName(task.taskname) || {})">
|
||
<img :src="getEpisodePosterUrl(getTasklistPosterLikeEpisode(task))"
|
||
:alt="task.taskname"
|
||
referrerpolicy="no-referrer"
|
||
crossorigin="anonymous"
|
||
decoding="async"
|
||
loading="lazy"
|
||
:fetchpriority="index < 50 ? 'high' : 'auto'"
|
||
@load="tasklistPosterLoaded[task.taskname] = true"
|
||
@error="handleImageError($event)">
|
||
|
||
<!-- 按钮行容器:自动补位布局 -->
|
||
<div class="discovery-actions-row" style="top: 8px;" v-if="tasklistPosterLoaded[task.taskname]">
|
||
<!-- 运行此任务按钮(正常状态) -->
|
||
<div v-if="!task.shareurl_ban" class="discovery-refresh-metadata tasklist-run-btn" @click.stop="runScriptNow(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="运行此任务">
|
||
<i class="bi bi-caret-right"></i>
|
||
</div>
|
||
<!-- 资源失效警告按钮(失效状态) -->
|
||
<div v-else class="discovery-warning-btn tasklist-warning-btn" :title="formatShareUrlBanMessage(task.shareurl_ban) || task.shareurl_ban">
|
||
<i class="bi bi-exclamation-circle"></i>
|
||
</div>
|
||
<div class="discovery-edit-metadata tasklist-delete-btn" @click.stop="removeTask(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="删除此任务">
|
||
<i class="bi bi-trash3"></i>
|
||
</div>
|
||
<div class="discovery-edit-metadata tasklist-edit-task-btn" @click.stop="openEditTaskModal(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="编辑此任务">
|
||
<i class="bi bi-pencil"></i>
|
||
</div>
|
||
</div>
|
||
<div class="discovery-actions-row" style="top: 36px;" v-if="tasklistPosterLoaded[task.taskname]">
|
||
<div class="discovery-refresh-metadata" v-if="getCalendarTaskByName(task.taskname) && getCalendarTaskByName(task.taskname).match_tmdb_id" @click="refreshSeasonMetadata(getCalendarTaskByName(task.taskname))" title="刷新元数据">
|
||
<i class="bi bi-arrow-clockwise"></i>
|
||
</div>
|
||
<div class="discovery-edit-metadata tasklist-edit-metadata-btn" @click.stop="openEditMetadataModal(getCalendarTaskByName(task.taskname) || { task_name: task.taskname })" title="编辑元数据">
|
||
<i class="bi bi-tag"></i>
|
||
</div>
|
||
<template v-for="key in formData.button_display_order">
|
||
<div v-if="key==='refresh_plex' && formData.plugins && formData.plugins.plex && formData.plugins.plex.url && formData.plugins.plex.token && formData.plugins.plex.quark_root_path && formData.button_display.refresh_plex !== 'disabled'" class="discovery-edit-metadata tasklist-plex-btn" @click.stop="refreshPlexLibrary(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="刷新Plex媒体库">
|
||
<img src="./static/images/Plex.svg" class="plex-icon" style="filter: brightness(0) invert(1);">
|
||
</div>
|
||
<div v-else-if="key==='refresh_alist' && formData.plugins && formData.plugins.alist && formData.plugins.alist.url && formData.plugins.alist.token && formData.plugins.alist.storage_id && formData.button_display.refresh_alist !== 'disabled'" class="discovery-edit-metadata tasklist-alist-btn" @click.stop="refreshAlistDirectory(task.__originalIndex !== undefined ? task.__originalIndex : index)" title="刷新AList目录">
|
||
<img src="https://cdn.jsdelivr.net/gh/alist-org/logo@main/logo.svg" class="alist-icon" style="filter: brightness(0) invert(1);">
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 转存进度徽标(复用评分样式) -->
|
||
<div class="discovery-rating"
|
||
v-if="tasklistPosterLoaded[task.taskname] && getCalendarTaskByName(task.taskname) && getCalendarTaskByName(task.taskname).matched_show_name && getCalendarTaskByName(task.taskname).season_counts"
|
||
:class="getProgressBadgeClass(getCalendarTaskByName(task.taskname))"
|
||
:title="'已转存/已播出:' + getTaskTransferredCount(getCalendarTaskByName(task.taskname)) + '/' + getTaskAiredCount(getCalendarTaskByName(task.taskname))">
|
||
{{ getTransferProgress(getCalendarTaskByName(task.taskname)) }}%
|
||
</div>
|
||
<!-- 左上角任务编号徽标:按需求移除 -->
|
||
|
||
<!-- 海报悬停信息 -->
|
||
<div class="discovery-poster-overlay" v-if="tasklistPosterLoaded[task.taskname]">
|
||
<!-- 任务编号 -->
|
||
<div class="info-line">#{{ String((task.__originalIndex !== undefined ? task.__originalIndex : index) + 1).padStart(2, '0') }}</div>
|
||
<!-- 匹配的剧名 -->
|
||
<div class="info-line">
|
||
<template v-if="getCalendarTaskByName(task.taskname) && getCalendarTaskByName(task.taskname).matched_show_name">
|
||
{{ getCalendarTaskByName(task.taskname).matched_show_name }}
|
||
</template>
|
||
<template v-else>
|
||
未匹配
|
||
</template>
|
||
</div>
|
||
<!-- 季名称 -->
|
||
<div class="info-line" v-if="getCalendarTaskByName(task.taskname) && getCalendarTaskByName(task.taskname).latest_season_name && getCalendarTaskByName(task.taskname).latest_season_name.trim() !== ''">
|
||
{{ getCalendarTaskByName(task.taskname).latest_season_name }}
|
||
</div>
|
||
<!-- 最近转存文件 -->
|
||
<div class="info-line" v-if="taskLatestFiles[task.taskname]">{{ taskLatestFiles[task.taskname] }}</div>
|
||
<!-- 最近更新日期 -->
|
||
<div class="info-line" v-if="taskLatestRecords[task.taskname]">{{ getTaskLatestRecordDisplay(task.taskname) }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="discovery-info">
|
||
<div class="discovery-title"
|
||
:title="getTasklistPosterTitle(task, index)"
|
||
:style="{ cursor: getTaskTmdbId(task) ? 'pointer' : 'default' }"
|
||
@click="openTaskTmdbPage(task)">
|
||
{{ task.taskname }}
|
||
<span v-if="getTaskShowStatus(task.taskname)"> · {{ getTaskShowStatus(task.taskname) }}</span>
|
||
<span v-if="isTaskUpdatedToday(task.taskname) && shouldShowTodayIndicator()"
|
||
class="task-today-indicator"
|
||
:class="getTodayIndicatorClass()">
|
||
<i class="bi bi-stars"></i>
|
||
</span>
|
||
</div>
|
||
<div class="discovery-genre"
|
||
:title="(getCalendarTaskByName(task.taskname) && getCalendarTaskByName(task.taskname).matched_show_name && getCalendarTaskByName(task.taskname).season_counts)
|
||
? ('已转存集数 / 已播出集数 / 节目总集数:' + getTaskTransferredCount(getCalendarTaskByName(task.taskname)) + ' / ' + getTaskAiredCount(getCalendarTaskByName(task.taskname)) + ' / ' + getTaskTotalCount(getCalendarTaskByName(task.taskname)))
|
||
: ''">
|
||
<template v-if="getCalendarTaskByName(task.taskname) && getCalendarTaskByName(task.taskname).matched_show_name && getCalendarTaskByName(task.taskname).season_counts">
|
||
<span v-html="`${getTaskTransferredCount(getCalendarTaskByName(task.taskname))} <span class='count-slash'>/</span> ${getTaskAiredCount(getCalendarTaskByName(task.taskname))} <span class='count-slash'>/</span> ${getTaskTotalCount(getCalendarTaskByName(task.taskname))}`"></span>
|
||
</template>
|
||
<template v-else>
|
||
暂无数据
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<hr v-if="tasklist.viewMode === 'list' && formData.tasklist.length > 0" class="task-divider">
|
||
<!-- 海报视图下隐藏底部添加任务按钮(仅列表视图显示) -->
|
||
<div class="row" v-if="tasklist.viewMode === 'list'">
|
||
<div class="col-sm-12 d-flex justify-content-end">
|
||
<button type="button" class="btn btn-outline-primary" @click="addTask()" title="添加任务"><i class="bi bi-plus-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="activeTab === 'history'">
|
||
<div style="height: 20px;"></div>
|
||
<div class="row tasklist-filter-row" style="margin-bottom: 20px;">
|
||
<div class="col-xl-4 col-lg-4 col-md-6 mb-2 mb-lg-0">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">名称筛选</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="historyNameFilter" placeholder="任务或转存为名称关键词">
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary filter-btn-square" @click="clearData('historyNameFilter')"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-4 col-lg-4 col-md-6 mb-2 mb-lg-0">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">任务筛选</span>
|
||
</div>
|
||
<div class="position-relative" style="flex: 1;">
|
||
<select class="form-control task-filter-select" v-model="historyTaskSelected" style="padding-left: 8px !important; text-indent: 0 !important; display: flex !important; align-items: center !important; line-height: 1.5 !important; padding-right: 8px !important;">
|
||
<option value="">全部</option>
|
||
<option v-for="task in historyTasks" :value="task" v-html="task"></option>
|
||
</select>
|
||
</div>
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary filter-btn-square" @click="clearData('historyTaskSelected')"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-4 col-lg-4 col-md-6">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">状态筛选</span>
|
||
</div>
|
||
<div class="position-relative" style="flex: 1;">
|
||
<select class="form-control task-filter-select"
|
||
v-model="historyStatusFilter"
|
||
style="padding-left: 8px !important; text-indent: 0 !important; display: flex !important; align-items: center !important; line-height: 1.5 !important; padding-right: 8px !important;">
|
||
<option value="">全部</option>
|
||
<option value="incomplete">未完成</option>
|
||
<option value="ongoing">进行中</option>
|
||
<option value="completed">已完成</option>
|
||
<option value="airing">播出中</option>
|
||
<option value="finale">本季终</option>
|
||
<option value="ended">已完结</option>
|
||
<option value="unmatched">未匹配</option>
|
||
<option value="ended_task">已结束</option>
|
||
</select>
|
||
</div>
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary filter-btn-square" @click="clearData('historyStatusFilter')"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-responsive">
|
||
<table class="table table-hover selectable-records">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:10%" class="cursor-pointer" @click="sortHistory('transfer_time')">转存日期 <i v-if="historyParams.sortBy === 'transfer_time'" :class="historyParams.order === 'asc' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i></th>
|
||
<th style="width:18%" class="cursor-pointer" @click="sortHistory('task_name')">任务名称 <i v-if="historyParams.sortBy === 'task_name'" :class="historyParams.order === 'asc' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i></th>
|
||
<th style="width:25%" class="cursor-pointer" @click="sortHistory('original_name')">原文件 <i v-if="historyParams.sortBy === 'original_name'" :class="historyParams.order === 'asc' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i></th>
|
||
<th style="width:25%" class="cursor-pointer" @click="sortHistory('renamed_to')">转存为 <i v-if="historyParams.sortBy === 'renamed_to'" :class="historyParams.order === 'asc' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i></th>
|
||
<th style="width:7%; min-width: 60px;" class="cursor-pointer file-size-column" @click="sortHistory('file_size')">大小 <i v-if="historyParams.sortBy === 'file_size'" :class="historyParams.order === 'asc' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i></th>
|
||
<th style="width:10%" class="cursor-pointer" @click="sortHistory('modify_date')">修改日期 <i v-if="historyParams.sortBy === 'modify_date'" :class="historyParams.order === 'asc' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-if="history.hasLoaded && filteredHistoryRecords.length === 0">
|
||
<td colspan="6" class="text-center">暂无记录</td>
|
||
</tr>
|
||
<tr v-for="(record, index) in filteredHistoryRecords" :key="record.id"
|
||
:class="{'selected-record': selectedRecords.includes(record.id)}"
|
||
@click="selectRecord($event, record.id)">
|
||
<td>{{ record.transfer_time_readable }}</td>
|
||
<td class="position-relative">
|
||
<div v-if="!record._expandedFields || !record._expandedFields.includes('task_name')"
|
||
class="text-truncate"
|
||
:title="record.task_name"
|
||
v-check-overflow="index + '|task_name'"
|
||
:class="{'task-name-hover': true}"
|
||
@click.stop="filterByTaskName(record.task_name, $event)">
|
||
{{ record.task_name }}
|
||
</div>
|
||
<div class="expand-button" v-if="isTextTruncated(record.task_name, index, 'task_name')" @click.stop="toggleExpand(index, 'task_name', $event)">
|
||
<i :class="record._expandedFields && record._expandedFields.length > 0 ? 'bi bi-chevron-up' : 'bi bi-chevron-down'"></i>
|
||
</div>
|
||
<div class="expanded-text task-name-hover" v-if="record._expandedFields && record._expandedFields.includes('task_name')" @click.stop="filterByTaskName(record.task_name, $event)">
|
||
{{ record.task_name }}
|
||
</div>
|
||
</td>
|
||
<td class="position-relative">
|
||
<div v-if="!record._expandedFields || !record._expandedFields.includes('original_name')"
|
||
class="text-truncate"
|
||
:title="record.original_name"
|
||
v-check-overflow="index + '|original_name'">
|
||
{{ record.original_name }}
|
||
</div>
|
||
<div class="expand-button" v-if="isTextTruncated(record.original_name, index, 'original_name')" @click.stop="toggleExpand(index, 'original_name', $event)">
|
||
<i :class="record._expandedFields && record._expandedFields.length > 0 ? 'bi bi-chevron-up' : 'bi bi-chevron-down'"></i>
|
||
</div>
|
||
<div class="expanded-text" v-if="record._expandedFields && record._expandedFields.includes('original_name')">
|
||
{{ record.original_name }}
|
||
</div>
|
||
</td>
|
||
<td class="position-relative">
|
||
<div v-if="!record._expandedFields || !record._expandedFields.includes('renamed_to')"
|
||
class="text-truncate"
|
||
:title="record.renamed_to"
|
||
v-check-overflow="index + '|renamed_to'">
|
||
{{ record.renamed_to }}
|
||
<span v-if="isRecordUpdatedToday(record) && shouldShowTodayIndicator()"
|
||
class="task-today-indicator"
|
||
:class="getTodayIndicatorClass()">
|
||
<i class="bi bi-stars"></i>
|
||
</span>
|
||
</div>
|
||
<div class="expand-button" v-if="isTextTruncated(record.renamed_to, index, 'renamed_to')" @click.stop="toggleExpand(index, 'renamed_to', $event)">
|
||
<i :class="record._expandedFields && record._expandedFields.length > 0 ? 'bi bi-chevron-up' : 'bi bi-chevron-down'"></i>
|
||
</div>
|
||
<div class="expanded-text" v-if="record._expandedFields && record._expandedFields.includes('renamed_to')">
|
||
{{ record.renamed_to }}
|
||
<span v-if="isRecordUpdatedToday(record) && shouldShowTodayIndicator()"
|
||
class="task-today-indicator"
|
||
:class="getTodayIndicatorClass()">
|
||
<i class="bi bi-stars"></i>
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td class="file-size-cell">
|
||
<span class="file-size-value">{{ record.file_size_readable }}</span>
|
||
<span class="delete-record-btn" @click.stop="deleteRecord(record.id, record.original_name)" title="删除记录">
|
||
<i class="bi bi-trash3"></i>
|
||
</span>
|
||
</td>
|
||
<td>{{ record.modify_date_readable }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 分页控制 -->
|
||
<div class="pagination-container d-flex justify-content-between align-items-center mt-3">
|
||
<div class="page-info text-secondary">
|
||
显示 {{ history.pagination && history.pagination.total_records > 0 ? ((historyParams.page - 1) * historyParams.page_size + 1) + '-' + Math.min(historyParams.page * historyParams.page_size, history.pagination.total_records) : '0' }} 条,共 {{ history.pagination ? history.pagination.total_records : 0 }} 条记录{{ selectedRecords.length > 0 ? ',已选中 ' + selectedRecords.length + ' 条记录' : '' }}
|
||
</div>
|
||
<div class="pagination-controls d-flex align-items-center">
|
||
<button type="button" class="btn btn-outline-secondary btn-sm mx-1" :class="{ disabled: historyParams.page <= 1 }" @click="changePage(historyParams.page - 1)" :disabled="historyParams.page <= 1">
|
||
<i class="bi bi-chevron-left"></i>
|
||
</button>
|
||
|
||
<!-- 分页按钮 -->
|
||
<template v-if="totalPages > 0">
|
||
<!-- 第一页按钮 -->
|
||
<button type="button" class="btn btn-sm mx-1"
|
||
:class="historyParams.page === 1 ? 'btn-primary' : 'btn-outline-secondary'"
|
||
@click="changePage(1)">
|
||
1
|
||
</button>
|
||
|
||
<!-- 省略号(前) -->
|
||
<span class="mx-1" v-if="getVisiblePageNumbers().length > 0 && getVisiblePageNumbers()[0] > 2">...</span>
|
||
|
||
<!-- 中间页码 -->
|
||
<button v-for="page in getVisiblePageNumbers()" :key="page" type="button"
|
||
class="btn btn-sm mx-1"
|
||
:class="historyParams.page === page ? 'btn-primary' : 'btn-outline-secondary'"
|
||
@click="changePage(page)">
|
||
{{ page }}
|
||
</button>
|
||
|
||
<!-- 省略号(后) -->
|
||
<span class="mx-1" v-if="getVisiblePageNumbers().length > 0 && getVisiblePageNumbers()[getVisiblePageNumbers().length - 1] < totalPages - 1">...</span>
|
||
|
||
<!-- 最后一页按钮 -->
|
||
<button v-if="totalPages > 1" type="button" class="btn btn-sm mx-1"
|
||
:class="historyParams.page === totalPages ? 'btn-primary' : 'btn-outline-secondary'"
|
||
@click="changePage(totalPages)">
|
||
{{ totalPages }}
|
||
</button>
|
||
</template>
|
||
|
||
<button type="button" class="btn btn-outline-secondary btn-sm mx-1" :class="{ disabled: historyParams.page >= totalPages }" @click="changePage(historyParams.page + 1)" :disabled="historyParams.page >= totalPages">
|
||
<i class="bi bi-chevron-right"></i>
|
||
</button>
|
||
</div>
|
||
<div class="pagination-settings d-flex align-items-center">
|
||
<span class="text-secondary mr-1">跳到第</span>
|
||
<div class="input-group mx-1" style="width: 70px">
|
||
<input type="number" class="form-control form-control-sm" v-model.number="gotoPage" min="1" :max="totalPages">
|
||
</div>
|
||
<span class="text-secondary mr-2">页</span>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm mr-2" @click="changePage(gotoPage)">确定</button>
|
||
<div class="dropdown">
|
||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-toggle="dropdown" aria-expanded="false">
|
||
{{ historyParams.page_size === 99999 ? '全部记录' : historyParams.page_size + ' 条/页' }}
|
||
</button>
|
||
<div class="dropdown-menu">
|
||
<a class="dropdown-item" href="#" v-for="size in [15, 30, 50, 100, 500, 1000, 'all']" :key="size" @click.prevent="changePageSizeTo(size)">{{ size === 'all' ? '全部记录' : size + ' 条/页' }}</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="activeTab === 'filemanager'">
|
||
<div style="height: 20px;"></div>
|
||
<!-- 账号选择栏 -->
|
||
<div class="row file-manager-account-selector">
|
||
<div class="col-12">
|
||
<!-- 桌面端账号选择 -->
|
||
<div class="d-flex align-items-center file-manager-rule-bar">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">夸克账号</span>
|
||
</div>
|
||
<div class="position-relative" style="flex: 1;">
|
||
<select class="form-control task-filter-select file-manager-input file-manager-account-select" v-model="fileManager.selectedAccountIndex" @change="onAccountChange">
|
||
<option v-for="(account, index) in accountsDetail" :key="index" :value="index">{{ account.display_text }}</option>
|
||
</select>
|
||
</div>
|
||
<button type="button" class="btn btn-outline-plex batch-rename-btn" v-if="formData.plugins && formData.plugins.plex && formData.plugins.plex.url && formData.plugins.plex.token && formData.plugins.plex.quark_root_path && formData.button_display.refresh_plex !== 'disabled'" @click="refreshFileManagerPlexLibrary" title="刷新Plex媒体库"><img src="./static/images/Plex.svg" class="plex-icon"></button>
|
||
<button type="button" class="btn btn-outline-alist batch-rename-btn" v-if="formData.plugins && formData.plugins.alist && formData.plugins.alist.url && formData.plugins.alist.token && formData.plugins.alist.storage_id && formData.button_display.refresh_alist !== 'disabled'" @click="refreshFileManagerAlistDirectory" title="刷新AList目录"><img src="https://cdn.jsdelivr.net/gh/alist-org/logo@main/logo.svg" class="alist-icon"></button>
|
||
<button type="button" class="btn btn-outline-primary batch-rename-btn" @click="createNewFolder" title="新建文件夹">
|
||
<i class="bi bi-folder-plus"></i>
|
||
</button>
|
||
<button type="button" class="btn btn-outline-primary batch-rename-btn" @click="refreshCurrentFolderCache" title="刷新当前目录缓存">
|
||
<i class="bi bi-arrow-clockwise"></i>
|
||
</button>
|
||
</div>
|
||
<!-- 移动端账号选择 -->
|
||
<div class="file-manager-rule-bar-responsive">
|
||
<div class="d-flex align-items-center">
|
||
<div class="input-group" style="flex: 1;">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">夸克账号</span>
|
||
</div>
|
||
<select class="form-control task-filter-select file-manager-account-select" v-model="fileManager.selectedAccountIndex" @change="onAccountChange">
|
||
<option v-for="(account, index) in accountsDetail" :key="index" :value="index">{{ account.display_text }}</option>
|
||
</select>
|
||
</div>
|
||
<button type="button" class="btn btn-outline-plex batch-rename-btn" v-if="formData.plugins && formData.plugins.plex && formData.plugins.plex.url && formData.plugins.plex.token && formData.plugins.plex.quark_root_path && formData.button_display.refresh_plex !== 'disabled'" @click="refreshFileManagerPlexLibrary" title="刷新Plex媒体库"><img src="./static/images/Plex.svg" class="plex-icon"></button>
|
||
<button type="button" class="btn btn-outline-alist batch-rename-btn" v-if="formData.plugins && formData.plugins.alist && formData.plugins.alist.url && formData.plugins.alist.token && formData.plugins.alist.storage_id && formData.button_display.refresh_alist !== 'disabled'" @click="refreshFileManagerAlistDirectory" title="刷新AList目录"><img src="https://cdn.jsdelivr.net/gh/alist-org/logo@main/logo.svg" class="alist-icon"></button>
|
||
<button type="button" class="btn btn-outline-primary batch-rename-btn" @click="createNewFolder" title="新建文件夹">
|
||
<i class="bi bi-folder-plus"></i>
|
||
</button>
|
||
<button type="button" class="btn btn-outline-primary batch-rename-btn" @click="refreshCurrentFolderCache" title="刷新当前目录缓存">
|
||
<i class="bi bi-arrow-clockwise"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 重命名规则设置栏 -->
|
||
<div class="row mb-3">
|
||
<div class="col-12">
|
||
<!-- 桌面端结构(原有) -->
|
||
<div class="d-flex align-items-center file-manager-rule-bar">
|
||
<div class="input-group-prepend">
|
||
<button class="btn btn-outline-secondary" type="button" @click="previewAndRename"
|
||
:title="fileManager.use_sequence_naming ? '预览顺序命名效果' : (fileManager.use_episode_naming ? '预览剧集命名效果' : '预览正则命名效果')">
|
||
{{ fileManager.use_sequence_naming ? '顺序命名' : (fileManager.use_episode_naming ? '剧集命名' : '正则命名') }}
|
||
</button>
|
||
</div>
|
||
<input type="text" class="form-control file-manager-input" v-model="fileManager.pattern"
|
||
:placeholder="fileManager.use_sequence_naming ? '输入带{}占位符的重命名格式,如:剧名 - S01E{}' : (fileManager.use_episode_naming ? '输入带[]占位符的重命名格式,如:剧名 - S01E[]' : '匹配表达式')"
|
||
@input="detectFileManagerNamingMode"
|
||
:title="fileManager.use_sequence_naming ? '输入带{}占位符的重命名格式,如:剧名 - S01E{}、剧名.S03E{}等,{}将被替换为集序号(按文件名和修改日期智能排序)' : (fileManager.use_episode_naming ? '输入带[]占位符的重命名格式,如:剧名 - S01E[]、剧名.S03E[]等,[]将被替换为从文件名中提取的集编号' : '只重命名匹配到文件名的文件,留空不会进行重命名')">
|
||
<input v-if="!fileManager.use_sequence_naming && !fileManager.use_episode_naming" type="text" class="form-control file-manager-input" v-model="fileManager.replace" placeholder="替换表达式" title="替换表达式">
|
||
<input type="text" class="form-control file-manager-input" v-model="fileManager.filterwords" placeholder="可选,输入过滤词汇,用逗号分隔,如:纯享,txt,超前企划" title="名称包含过滤词汇的项目不会被重命名,多个词用逗号分隔,支持通过文件名和扩展名过滤文件和文件夹,支持使用保留词|过滤词的格式实现高级过滤,查阅Wiki了解详情">
|
||
<div class="input-group-append">
|
||
<div class="input-group-text" title="勾选后,重命名和过滤规则也将应用于文件夹">
|
||
<input type="checkbox" v-model="fileManager.include_folders"> 含文件夹
|
||
</div>
|
||
</div>
|
||
<button type="button" class="btn btn-outline-primary batch-rename-btn" @click="previewAndRename" title="预览并执行重命名">
|
||
<i class="bi bi-pencil"></i>
|
||
</button>
|
||
</div>
|
||
<!-- 移动端结构(还原为input-group风格,两行,样式与桌面端一致) -->
|
||
<div class="file-manager-rule-bar-responsive">
|
||
<!-- 第一行:命名按钮+表达式+替换表达式 -->
|
||
<div class="input-group mb-2">
|
||
<div class="input-group-prepend">
|
||
<button class="btn btn-outline-secondary" type="button" @click="previewAndRename"
|
||
:title="fileManager.use_sequence_naming ? '预览顺序命名效果' : (fileManager.use_episode_naming ? '预览剧集命名效果' : '预览正则命名效果')">
|
||
{{ fileManager.use_sequence_naming ? '顺序命名' : (fileManager.use_episode_naming ? '剧集命名' : '正则命名') }}
|
||
</button>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="fileManager.pattern"
|
||
:placeholder="fileManager.use_sequence_naming ? '输入带{}占位符的重命名格式,如:剧名 - S01E{}' : (fileManager.use_episode_naming ? '输入带[]占位符的重命名格式,如:剧名 - S01E[]' : '匹配表达式')"
|
||
@input="detectFileManagerNamingMode"
|
||
:title="fileManager.use_sequence_naming ? '输入带{}占位符的重命名格式,如:剧名 - S01E{}、剧名.S03E{}等,{}将被替换为集序号(按文件名和修改日期智能排序)' : (fileManager.use_episode_naming ? '输入带[]占位符的重命名格式,如:剧名 - S01E[]、剧名.S03E[]等,[]将被替换为从文件名中提取的集编号' : '只重命名匹配到文件名的文件,留空不会进行重命名')">
|
||
<input v-if="!fileManager.use_sequence_naming && !fileManager.use_episode_naming" type="text" class="form-control" v-model="fileManager.replace" placeholder="替换表达式" title="替换表达式">
|
||
</div>
|
||
<!-- 第二行:过滤规则+含文件夹+按钮,flex包裹,含文件夹有圆角,按钮有8px间距和圆角 -->
|
||
<div class="input-group d-flex align-items-center">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">过滤规则</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="fileManager.filterwords" placeholder="可选,输入过滤词汇,用逗号分隔,如:纯享,txt,超前企划" title="名称包含过滤词汇的项目不会被重命名,多个词用逗号分隔,支持通过文件名和扩展名过滤文件和文件夹,支持使用保留词|过滤词的格式实现高级过滤,查阅Wiki了解详情">
|
||
<span class="input-group-text file-folder-rounded">
|
||
<input type="checkbox" v-model="fileManager.include_folders"> 含文件夹
|
||
</span>
|
||
<button type="button" class="btn btn-outline-primary batch-rename-btn" @click="previewAndRename" title="预览并执行重命名">
|
||
<i class="bi bi-pencil"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 面包屑导航 -->
|
||
<nav aria-label="breadcrumb" class="file-manager-breadcrumb">
|
||
<ol class="breadcrumb">
|
||
<li class="breadcrumb-item cursor-pointer" @click="navigateToFolder('root')">全部文件</li>
|
||
<li v-for="(item, index) in fileManager.paths" :key="index" class="breadcrumb-item">
|
||
<a v-if="index != fileManager.paths.length - 1" href="#" @click.prevent="navigateToFolder(item.fid, item.name)">{{ item.name }}</a>
|
||
<span v-else class="text-muted">{{ item.name }}</span>
|
||
</li>
|
||
</ol>
|
||
</nav>
|
||
|
||
<!-- 文件表格 -->
|
||
<div class="table-responsive">
|
||
<table class="table table-hover selectable-files">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:80%" class="cursor-pointer" @click="sortFiles('file_name')">文件名 <i v-if="fileManager.sortBy === 'file_name'" :class="fileManager.sortOrder === 'asc' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i></th>
|
||
<th style="width:10%; min-width: 80px;" class="cursor-pointer" @click="sortFiles('file_size')">大小 <i v-if="fileManager.sortBy === 'file_size'" :class="fileManager.sortOrder === 'asc' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i></th>
|
||
<th style="width:10%" class="cursor-pointer" @click="sortFiles('updated_at')">修改日期 <i v-if="fileManager.sortBy === 'updated_at'" :class="fileManager.sortOrder === 'asc' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-if="fileManager.hasLoaded && fileManager.fileList.length === 0">
|
||
<td colspan="3" class="text-center">当前文件夹为空</td>
|
||
</tr>
|
||
<template v-else>
|
||
<tr v-for="(file, index) in fileManager.fileList" :key="file.fid"
|
||
@click="handleFileRowClick(file, $event)"
|
||
:class="{'selected-file': fileManager.selectedFiles.includes(file.fid)}"
|
||
class="cursor-pointer">
|
||
<td class="position-relative">
|
||
<!-- 编辑状态 -->
|
||
<div v-if="file._editing" class="d-flex align-items-center" @click.stop>
|
||
<i class="bi mr-2" :class="getFileIconClass(file.file_name, file.dir)"></i>
|
||
<input
|
||
type="text"
|
||
class="form-control form-control-sm rename-input"
|
||
v-model="file._editingName"
|
||
@keyup.enter="saveRenameFile(file)"
|
||
@keyup.escape="cancelRenameFile(file)"
|
||
@blur="saveRenameFile(file)"
|
||
@click.stop
|
||
:ref="'renameInput_' + file.fid">
|
||
</div>
|
||
<!-- 正常显示状态 -->
|
||
<div v-else-if="!file._expanded" class="d-flex align-items-center">
|
||
<i class="bi mr-2" :class="getFileIconClass(file.file_name, file.dir)"></i>
|
||
<div class="text-truncate"
|
||
:title="file.file_name"
|
||
v-check-file-overflow="index + '|file_name'">
|
||
{{ file.file_name }}
|
||
<span v-if="isFileUpdatedToday(file) && shouldShowTodayIndicator()"
|
||
class="task-today-indicator"
|
||
:class="getTodayIndicatorClass()">
|
||
<i class="bi bi-stars"></i>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div v-else style="white-space: normal; word-break: break-word; padding-right: 25px;">
|
||
<i class="bi mr-2" :class="getFileIconClass(file.file_name, file.dir)"></i>{{ file.file_name }}
|
||
<span v-if="isFileUpdatedToday(file) && shouldShowTodayIndicator()"
|
||
class="task-today-indicator"
|
||
:class="getTodayIndicatorClass()">
|
||
<i class="bi bi-stars"></i>
|
||
</span>
|
||
</div>
|
||
<div class="expand-button" v-if="!file._editing && isFilenameTruncated(file.file_name, index, 'file_name')" @click.stop="toggleExpandFilename(file)">
|
||
<i :class="file._expanded ? 'bi bi-chevron-up' : 'bi bi-chevron-down'"></i>
|
||
</div>
|
||
</td>
|
||
<td class="file-size-cell">
|
||
<span class="file-size-value">{{ file.dir ? (file.include_items + ' 项') : formatFileSize(file.size || file.file_size) }}</span>
|
||
<span class="rename-record-btn" @click.stop="startRenameFile(file)" title="重命名文件">
|
||
<i class="bi bi-pencil"></i>
|
||
</span>
|
||
<span class="move-record-btn" @click.stop="startMoveFile(file)" title="移动文件">
|
||
<i class="bi bi-arrow-up-right-circle"></i>
|
||
</span>
|
||
<span class="delete-record-btn" @click.stop="deleteSelectedFilesForManager(file.fid, file.file_name, file.dir)" title="删除文件">
|
||
<i class="bi bi-trash3"></i>
|
||
</span>
|
||
</td>
|
||
<td>{{ formatDate(file.updated_at) }}</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 分页区域 -->
|
||
<div class="pagination-container d-flex justify-content-between align-items-center mt-3">
|
||
<div class="page-info text-secondary">
|
||
显示 {{ fileManager.total > 0 ? ((fileManager.currentPage - 1) * fileManager.pageSize + 1) + '-' + Math.min(fileManager.currentPage * fileManager.pageSize, fileManager.total) : '0-0' }} 项,共 {{ fileManager.total }} 个项目{{ fileManager.selectedFiles.length > 0 ? ',已选中 ' + fileManager.selectedFiles.length + ' 项' : '' }}
|
||
</div>
|
||
<div class="pagination-controls d-flex align-items-center">
|
||
<button type="button" class="btn btn-outline-secondary btn-sm mx-1" :class="{ disabled: fileManager.currentPage <= 1 }" @click="changeFolderPage(fileManager.currentPage - 1)" :disabled="fileManager.currentPage <= 1">
|
||
<i class="bi bi-chevron-left"></i>
|
||
</button>
|
||
|
||
<template v-if="fileManager.totalPages > 0">
|
||
<button type="button" class="btn btn-sm mx-1"
|
||
:class="fileManager.currentPage === 1 ? 'btn-primary' : 'btn-outline-secondary'"
|
||
@click="changeFolderPage(1)">1</button>
|
||
|
||
<span class="mx-1" v-if="getVisibleFolderPageNumbers().length > 0 && getVisibleFolderPageNumbers()[0] > 2">...</span>
|
||
|
||
<button v-for="page in getVisibleFolderPageNumbers()" :key="page" type="button"
|
||
class="btn btn-sm mx-1"
|
||
:class="fileManager.currentPage === page ? 'btn-primary' : 'btn-outline-secondary'"
|
||
@click="changeFolderPage(page)">
|
||
{{ page }}
|
||
</button>
|
||
|
||
<span class="mx-1" v-if="getVisibleFolderPageNumbers().length > 0 && getVisibleFolderPageNumbers()[getVisibleFolderPageNumbers().length - 1] < fileManager.totalPages - 1">...</span>
|
||
|
||
<button v-if="fileManager.totalPages > 1" type="button" class="btn btn-sm mx-1"
|
||
:class="fileManager.currentPage === fileManager.totalPages ? 'btn-primary' : 'btn-outline-secondary'"
|
||
@click="changeFolderPage(fileManager.totalPages)">
|
||
{{ fileManager.totalPages }}
|
||
</button>
|
||
</template>
|
||
|
||
<button type="button" class="btn btn-outline-secondary btn-sm mx-1" :class="{ disabled: fileManager.currentPage >= fileManager.totalPages }" @click="changeFolderPage(fileManager.currentPage + 1)" :disabled="fileManager.currentPage >= fileManager.totalPages">
|
||
<i class="bi bi-chevron-right"></i>
|
||
</button>
|
||
</div>
|
||
<div class="pagination-settings d-flex align-items-center">
|
||
<span class="text-secondary mr-1">跳到第</span>
|
||
<div class="input-group mx-1" style="width: 70px">
|
||
<input type="number" class="form-control form-control-sm" v-model.number="fileManager.gotoPage" min="1" :max="fileManager.totalPages">
|
||
</div>
|
||
<span class="text-secondary mr-2">页</span>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm mr-2" @click="changeFolderPage(fileManager.gotoPage)">确定</button>
|
||
<div class="dropdown">
|
||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-toggle="dropdown" aria-expanded="false">
|
||
{{ fileManager.pageSize === 99999 ? '全部项目' : fileManager.pageSize + ' 项/页' }}
|
||
</button>
|
||
<div class="dropdown-menu">
|
||
<a class="dropdown-item" href="#" v-for="size in [15, 30, 50, 100, 500, 1000, 'all']" :key="size" @click.prevent="changeFileManagerPageSize(size)">{{ size === 'all' ? '全部项目' : size + ' 项/页' }}</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 影视发现页面 -->
|
||
<div v-if="activeTab === 'discovery'">
|
||
<div style="height: 20px;"></div>
|
||
<!-- 榜单切换按钮区域 -->
|
||
<div class="discovery-controls">
|
||
<!-- 第一排:主榜单按钮 -->
|
||
<div class="discovery-main-buttons">
|
||
<button type="button"
|
||
class="btn btn-outline-secondary discovery-btn"
|
||
v-for="category in discovery.mainCategories"
|
||
:key="category.key"
|
||
:class="{ active: discovery.selectedMainCategory === category.key }"
|
||
@click="selectMainCategory(category.key)">
|
||
{{ category.name }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 第二排:子榜单按钮 -->
|
||
<div class="discovery-sub-buttons">
|
||
<!-- 调试信息 -->
|
||
<div v-if="currentSubCategories.length === 0" style="color: red; font-size: 12px;">
|
||
调试:当前主分类 {{ discovery.selectedMainCategory }},子分类数量:{{ currentSubCategories.length }}
|
||
</div>
|
||
<button type="button"
|
||
class="btn btn-outline-secondary discovery-btn"
|
||
v-for="subCategory in currentSubCategories"
|
||
:key="subCategory.key"
|
||
:class="{ active: discovery.selectedSubCategory === subCategory.key }"
|
||
@click="selectSubCategory(subCategory.key)">
|
||
{{ subCategory.name }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 只在已初始化时显示内容,避免页面刷新时的闪烁 -->
|
||
<div v-if="discovery.isInitialized || discovery.hasLoaded">
|
||
<!-- 错误提示 -->
|
||
<div v-if="discovery.error" class="alert alert-warning" role="alert">
|
||
<i class="bi bi-exclamation-triangle"></i>
|
||
{{ discovery.error }}
|
||
</div>
|
||
|
||
<!-- 影片海报网格 -->
|
||
<div v-else-if="discovery.items.length > 0" class="discovery-grid">
|
||
<div class="discovery-item"
|
||
v-for="item in discovery.items"
|
||
:key="item.id">
|
||
<div class="discovery-poster" @mouseenter="handlePosterHover($event, item)">
|
||
<img :src="(item.pic && item.pic.normal) ? getProxiedImageUrl(item.pic.normal) : '/static/images/no-poster.svg'"
|
||
:alt="item.title"
|
||
referrerpolicy="no-referrer"
|
||
crossorigin="anonymous"
|
||
@error="handleImageError($event)">
|
||
<div class="discovery-rating" v-if="item.rating && item.rating.value">
|
||
{{ parseFloat(item.rating.value).toFixed(1) }}
|
||
</div>
|
||
<div class="discovery-create-task" @click="createTaskFromDiscovery(item)" title="创建任务">
|
||
<i class="bi bi-plus-lg"></i>
|
||
</div>
|
||
<div class="discovery-poster-overlay">
|
||
<div class="info-line" v-for="(detail, index) in getMovieDetails(item)" :key="index">
|
||
{{ detail }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="discovery-info">
|
||
<div class="discovery-title" :title="item.title" @click="openDoubanPage(item)">{{ item.title }}</div>
|
||
<div class="discovery-genre" :title="getMovieGenreText(item)" v-html="getMovieGenre(item)"></div>
|
||
<div class="discovery-year" v-if="item.year">{{ item.year }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 空状态 - 只有在加载完成且无数据且无错误时才显示 -->
|
||
<div v-else-if="discovery.hasLoaded" class="text-center py-5 text-muted">
|
||
<i class="bi bi-film" style="font-size: 3rem;"></i>
|
||
<div class="mt-2">暂无数据</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 追剧日历页面 -->
|
||
<div v-if="activeTab === 'calendar'" ref="calendarRoot">
|
||
<div style="height: 20px;"></div>
|
||
|
||
<!-- TMDB API未配置提示 -->
|
||
<div v-if="configHasLoaded && !formData.tmdb_api_key" class="alert alert-warning" role="alert">
|
||
追剧日历需要配置 TMDB API 密钥后才能使用,请在<a href="#" class="link-focus" @click.prevent="changeTab('config')">系统配置</a>页面的 TMDB API 部分进行配置
|
||
</div>
|
||
|
||
<!-- 追剧日历内容 -->
|
||
<div v-else>
|
||
<!-- 顶部筛选和导航 -->
|
||
<div class="row tasklist-filter-row calendar-filter-row">
|
||
<div class="col-xl-4 col-lg-4 col-md-6 mb-2 mb-lg-0">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">名称筛选</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="calendar.nameFilter" placeholder="任务或节目名称关键词">
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary filter-btn-square" @click="clearCalendarFilter('nameFilter')"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-4 col-lg-4 col-md-6 mb-2 mb-lg-0">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">任务筛选</span>
|
||
</div>
|
||
<div class="position-relative" style="flex: 1;">
|
||
<select class="form-control task-filter-select" v-model="calendar.taskFilter" style="padding-left: 8px !important; text-indent: 0 !important; display: flex !important; align-items: center !important; line-height: 1.5 !important; padding-right: 8px !important;">
|
||
<option value="">全部</option>
|
||
<option v-for="task in calendar.taskNames" :value="task" v-html="task"></option>
|
||
</select>
|
||
</div>
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary filter-btn-square" @click="clearCalendarFilter('taskFilter')"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-4 col-lg-4 col-md-6 mb-2 mb-lg-0">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text">状态筛选</span>
|
||
</div>
|
||
<div class="position-relative" style="flex: 1;">
|
||
<select class="form-control task-filter-select"
|
||
v-model="calendar.statusFilter"
|
||
style="padding-left: 8px !important; text-indent: 0 !important; display: flex !important; align-items: center !important; line-height: 1.5 !important; padding-right: 8px !important;">
|
||
<option value="">全部</option>
|
||
<option value="incomplete">未完成</option>
|
||
<option value="ongoing">进行中</option>
|
||
<option value="completed">已完成</option>
|
||
<option value="airing">播出中</option>
|
||
<option value="finale">本季终</option>
|
||
<option value="ended">已完结</option>
|
||
<option value="unmatched">未匹配</option>
|
||
</select>
|
||
</div>
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary filter-btn-square" @click="clearCalendarFilter('statusFilter')"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分类按钮和视图切换 -->
|
||
<div class="row mb-3 calendar-header-row">
|
||
<div class="col-lg-8 col-md-6">
|
||
<!-- 分类按钮 -->
|
||
<div class="calendar-category-buttons">
|
||
<button type="button"
|
||
class="btn btn-outline-secondary calendar-category-btn"
|
||
:class="{ active: calendar.selectedType === 'all' }"
|
||
@click="selectCalendarType('all')">
|
||
全部
|
||
</button>
|
||
<button type="button"
|
||
class="btn btn-outline-secondary calendar-category-btn"
|
||
v-for="type in filteredContentTypes"
|
||
:key="type"
|
||
:class="{ active: calendar.selectedType === type }"
|
||
@click="selectCalendarType(type)">
|
||
{{ getContentTypeDisplayName(type) }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-4 col-md-6">
|
||
<!-- 日期切换和视图切换 -->
|
||
<div class="calendar-controls d-flex justify-content-end">
|
||
<!-- 日期切换按钮 -->
|
||
<div class="btn-group" v-if="calendar.viewMode === 'poster'">
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="changeCalendarDate('prevWeek')" title="前一周">
|
||
<i class="bi bi-chevron-double-left"></i>
|
||
</button>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="changeCalendarDate('prevDay')" title="前一天">
|
||
<i class="bi bi-chevron-left"></i>
|
||
</button>
|
||
<button type="button" class="btn btn-primary btn-sm" @click="goToToday">今天</button>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="changeCalendarDate('nextDay')" title="后一天">
|
||
<i class="bi bi-chevron-right"></i>
|
||
</button>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="changeCalendarDate('nextWeek')" title="后一周">
|
||
<i class="bi bi-chevron-double-right"></i>
|
||
</button>
|
||
</div>
|
||
<div class="btn-group" v-else>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="changeCalendarDate('prevMonth')" title="前一月">
|
||
<i class="bi bi-chevron-double-left"></i>
|
||
</button>
|
||
<button type="button" class="btn btn-primary btn-sm" @click="goToToday">今天</button>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="changeCalendarDate('nextMonth')" title="后一月">
|
||
<i class="bi bi-chevron-double-right"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 视图切换按钮 -->
|
||
<button type="button"
|
||
class="btn btn-outline-secondary btn-sm"
|
||
@click="toggleCalendarViewMode"
|
||
:title="calendar.viewMode === 'poster' ? '当前:海报视图,点击切换到日历视图' : '当前:日历视图,点击切换到海报视图'">
|
||
<i :class="calendar.viewMode === 'poster' ? 'bi bi-calendar3' : 'bi bi-grid-3x3-gap'"></i>
|
||
</button>
|
||
|
||
<!-- 合并集切换按钮 -->
|
||
<button type="button"
|
||
class="btn btn-outline-secondary btn-sm"
|
||
@click="toggleCalendarMergeEpisodes"
|
||
:title="calendar.mergeEpisodes ? '当前:合并集模式,点击切换到拆分集模式' : '当前:拆分集模式,点击切换到合并集模式'">
|
||
<i :class="calendar.mergeEpisodes ? 'bi bi-file-break' : 'bi bi-file'"></i>
|
||
</button>
|
||
|
||
<!-- 刷新按钮 -->
|
||
<button type="button"
|
||
class="btn btn-outline-primary batch-rename-btn"
|
||
@click="refreshCalendarData"
|
||
title="刷新追剧日历数据">
|
||
<i class="bi bi-arrow-clockwise"></i>
|
||
</button>
|
||
|
||
<!-- 内容管理按钮 -->
|
||
<button type="button"
|
||
class="btn btn-outline-secondary btn-sm"
|
||
@click="toggleCalendarManageMode"
|
||
:title="calendar.manageMode ? '返回追剧日历' : '内容管理'">
|
||
<i :class="calendar.manageMode ? 'bi bi-reply' : 'bi bi-tags'"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 内容管理模式 -->
|
||
<div v-if="calendar.manageMode" style="margin-top: 20px;">
|
||
<!-- 完全复用影视发现页面的海报网格样式与自适应布局 -->
|
||
<div class="discovery-grid">
|
||
<div class="discovery-item"
|
||
v-for="task in managementTasksFiltered"
|
||
:key="task.task_name">
|
||
<div class="discovery-poster" @mouseenter="handleManagementPosterHover($event, task)">
|
||
<img :src="getEpisodePosterUrl(getTaskPosterLikeEpisode(task))"
|
||
:alt="task.show_name || task.task_name"
|
||
referrerpolicy="no-referrer"
|
||
crossorigin="anonymous"
|
||
@error="handleImageError($event)">
|
||
|
||
<!-- 刷新元数据按钮(左上角,悬停显示) -->
|
||
<div class="discovery-refresh-metadata"
|
||
v-if="task.match_tmdb_id"
|
||
@click="refreshSeasonMetadata(task)"
|
||
title="刷新元数据">
|
||
<i class="bi bi-arrow-clockwise"></i>
|
||
</div>
|
||
<!-- 编辑元数据按钮(已匹配:靠右;未匹配:靠左) -->
|
||
<div class="discovery-edit-metadata tasklist-edit-metadata-btn"
|
||
:style="{ left: task.match_tmdb_id ? '36px' : '8px' }"
|
||
@click.stop="openEditMetadataModal(task)"
|
||
title="编辑元数据">
|
||
<i class="bi bi-tag"></i>
|
||
</div>
|
||
|
||
<!-- 转存进度徽标(复用评分样式) -->
|
||
<div class="discovery-rating"
|
||
v-if="task.matched_show_name && task.season_counts"
|
||
:class="getProgressBadgeClass(task)"
|
||
:title="'已转存/已播出:' + getTaskTransferredCount(task) + '/' + getTaskAiredCount(task)">
|
||
{{ getTransferProgress(task) }}%
|
||
</div>
|
||
<div class="discovery-poster-overlay">
|
||
<div class="info-line">
|
||
<template v-if="task.matched_show_name">
|
||
{{ task.matched_show_name }}
|
||
</template>
|
||
<template v-else>
|
||
未匹配
|
||
</template>
|
||
</div>
|
||
<div class="info-line" v-if="task.matched_year && String(task.matched_year).trim() !== ''">{{ task.matched_year }}</div>
|
||
<div class="info-line" v-if="task.latest_season_name && task.latest_season_name.trim() !== ''">{{ task.latest_season_name }}</div>
|
||
<div class="info-line" v-if="task.matched_show_name && task.season_counts"
|
||
v-html="`${getTaskTransferredCount(task)} <span class='count-slash'>/</span> ${getTaskAiredCount(task)} <span class='count-slash'>/</span> ${getTaskTotalCount(task)}`">
|
||
</div>
|
||
<div class="info-line" v-if="task.matched_status && task.matched_status.trim() !== ''">{{ task.matched_status }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="discovery-info">
|
||
<div class="discovery-title" :title="getTaskShowStatusTooltip(task)" @click.stop="openTaskMatchedTmdbPage(task)" style="cursor: pointer;">
|
||
{{ task.task_name }}
|
||
<span v-if="getTaskShowStatus(task)"> · {{ getTaskShowStatus(task) }}</span>
|
||
<span v-if="isCalendarTaskUpdatedToday(task) && shouldShowTodayIndicator()"
|
||
class="task-today-indicator"
|
||
:class="getTodayIndicatorClass()">
|
||
<i class="bi bi-stars"></i>
|
||
</span>
|
||
</div>
|
||
<div class="discovery-genre" :title="getContentTypeCN(task.content_type)">{{ getContentTypeCN(task.content_type) }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 海报模式 -->
|
||
<div v-else-if="calendar.viewMode === 'poster'" class="calendar-poster-mode">
|
||
<!-- 日期导航 -->
|
||
<div class="calendar-date-navigation">
|
||
<div class="calendar-date-item"
|
||
v-for="(date, index) in calendar.weekDates"
|
||
:key="index"
|
||
:class="{ 'today': date.isToday }">
|
||
<div class="calendar-date-weekday">{{ date.weekday }}</div>
|
||
<div class="calendar-date-day">{{ date.day }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 海报网格 -->
|
||
<div class="calendar-poster-grid">
|
||
<div class="calendar-poster-column"
|
||
v-for="(date, dateIndex) in calendar.weekDates"
|
||
:key="dateIndex">
|
||
<div class="calendar-poster-item"
|
||
v-for="episode in getEpisodesByDate(date.date)"
|
||
:key="episode.id">
|
||
<div class="calendar-poster"
|
||
@mouseenter="handleCalendarPosterHover($event, episode)"
|
||
@mouseleave="hideCalendarPosterHover">
|
||
<img :src="getEpisodePosterUrl(episode)"
|
||
:alt="episode.show_name"
|
||
class="calendar-poster-image"
|
||
crossorigin="anonymous"
|
||
referrerpolicy="no-referrer"
|
||
@error="handleCalendarImageError">
|
||
|
||
<!-- 刷新元数据按钮(左上角,悬停显示) -->
|
||
<div class="calendar-refresh-metadata"
|
||
@click="refreshEpisodeMetadata(episode)"
|
||
title="刷新元数据">
|
||
<i class="bi bi-arrow-clockwise"></i>
|
||
</div>
|
||
|
||
<!-- 已转存标识(与影视发现评分标识风格一致) -->
|
||
<div class="discovery-rating calendar-transferred-badge" v-if="isEpisodeReachedProgress(episode)" title="已转存">
|
||
<i class="bi bi-check2"></i>
|
||
</div>
|
||
|
||
<!-- 海报悬停效果 -->
|
||
<div class="calendar-poster-overlay">
|
||
<div class="info-line">{{ episode.name || episode.show_name }}</div>
|
||
<div class="info-line overview" v-if="episode.overview" :title="episode.overview" :data-fulltext="episode.overview">{{ episode.overview }}</div>
|
||
</div>
|
||
|
||
|
||
</div>
|
||
|
||
<div class="calendar-episode-info">
|
||
<div class="calendar-show-name" :title="getEpisodeShowTitleWithStatus(episode)" @click="openShowTmdbPage(episode)">
|
||
{{ episode.show_name }}
|
||
<span v-if="getEpisodeFinaleStatus(episode)"> · {{ getEpisodeFinaleStatus(episode) }}</span>
|
||
<span v-if="isEpisodeUpdatedToday(episode) && shouldShowTodayIndicator()"
|
||
class="task-today-indicator"
|
||
:class="getTodayIndicatorClass()">
|
||
<i class="bi bi-stars"></i>
|
||
</span>
|
||
</div>
|
||
<div class="calendar-episode-number"
|
||
:class="{ 'episode-number-reached': isEpisodeReachedProgress(episode) }"
|
||
:title="getEpisodeTooltip(episode)"
|
||
@click="openEpisodeTmdbPage(episode)">
|
||
{{ getEpisodeDisplayNumber(episode) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 日历模式 -->
|
||
<div v-else class="calendar-month-mode">
|
||
<div class="calendar-month-table">
|
||
<div class="calendar-month-header">
|
||
<div class="calendar-month-header-cell" v-for="weekday in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']">
|
||
{{ weekday }}
|
||
</div>
|
||
</div>
|
||
<div class="calendar-month-body">
|
||
<div class="calendar-month-row"
|
||
v-for="week in calendar.monthWeeks"
|
||
:key="week.weekIndex">
|
||
<div class="calendar-month-cell"
|
||
v-for="day in week.days"
|
||
:key="day.date"
|
||
:class="{
|
||
'other-month': !day.isCurrentMonth,
|
||
'today': day.isToday,
|
||
'has-episodes': day.episodes && day.episodes.length > 0,
|
||
'selected': day.date === calendar.selectedDate
|
||
}"
|
||
@click="selectCalendarDate(day)">
|
||
<div class="calendar-month-date">
|
||
<span v-if="day.dayNumber === 1">
|
||
{{ day.isCurrentMonth ? (calendar.currentDate.getMonth() + 1) : (calendar.currentDate.getMonth() + 2) }}/1
|
||
</span>
|
||
<span v-else>
|
||
{{ day.dayNumber }}
|
||
</span>
|
||
</div>
|
||
<div class="calendar-month-episodes" v-if="day.episodes && day.episodes.length > 0">
|
||
<div class="calendar-month-episode"
|
||
v-for="episode in day.episodes"
|
||
:key="episode.id">
|
||
<span class="episode-title" :title="getEpisodeShowTitleWithStatus(episode)" @click.stop="openShowTmdbPage(episode)">
|
||
{{ episode.show_name }}
|
||
<span v-if="getEpisodeFinaleStatus(episode)"> · {{ getEpisodeFinaleStatus(episode) }}</span>
|
||
<span v-if="isEpisodeUpdatedToday(episode) && shouldShowTodayIndicator()"
|
||
class="task-today-indicator"
|
||
:class="getTodayIndicatorClass()">
|
||
<i class="bi bi-stars"></i>
|
||
</span>
|
||
</span>
|
||
<span :class="['episode-number', { 'episode-number-reached': isEpisodeReachedProgress(episode) }]"
|
||
:title="getEpisodeTooltip(episode)"
|
||
:data-full="getEpisodeDisplayNumber(episode)"
|
||
:data-episode-only="getEpisodeOnlyNumber(episode)"
|
||
:data-number-only="getNumberOnly(episode)"
|
||
@click.stop="openEpisodeTmdbPage(episode)">
|
||
{{ getEpisodeDisplayNumber(episode) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 超小模式:在日历表格下方显示选中日期的集卡片(仅小屏手机显示) -->
|
||
<div class="calendar-selected-episodes d-sm-none">
|
||
<template v-if="calendar.selectedDate">
|
||
<div class="calendar-month-episodes" v-if="getEpisodesByDate(calendar.selectedDate).length > 0">
|
||
<div class="calendar-month-episode"
|
||
v-for="episode in getEpisodesByDate(calendar.selectedDate)"
|
||
:key="episode.id">
|
||
<span class="episode-title" :title="getEpisodeShowTitleWithStatus(episode)" @click.stop="openShowTmdbPage(episode)">
|
||
{{ episode.show_name }}
|
||
<span v-if="getEpisodeFinaleStatus(episode)"> · {{ getEpisodeFinaleStatus(episode) }}</span>
|
||
<span v-if="isEpisodeUpdatedToday(episode) && shouldShowTodayIndicator()"
|
||
class="task-today-indicator"
|
||
:class="getTodayIndicatorClass()">
|
||
<i class="bi bi-stars"></i>
|
||
</span>
|
||
</span>
|
||
<span :class="['episode-number', { 'episode-number-reached': isEpisodeReachedProgress(episode) }]"
|
||
:title="getEpisodeTooltip(episode)"
|
||
:data-full="getEpisodeDisplayNumber(episode)"
|
||
:data-episode-only="getEpisodeOnlyNumber(episode)"
|
||
:data-number-only="getNumberOnly(episode)"
|
||
@click.stop="openEpisodeTmdbPage(episode)">
|
||
{{ getEpisodeDisplayNumber(episode) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</form>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- 模态框 运行日志 -->
|
||
<div class="modal" tabindex="-1" id="logModal">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title"><b>运行日志</b>
|
||
<div v-if="modalLoading" class="spinner-border spinner-border-sm m-1" role="status"></div>
|
||
</h5>
|
||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||
<i class="bi bi-x-lg"></i>
|
||
</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<pre v-html="run_log"></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 模态框 文件选择 -->
|
||
<div class="modal" tabindex="-1" id="fileSelectModal">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" style="font-weight: 600; font-family: inherit; letter-spacing: normal;">
|
||
<span v-if="fileSelect.previewRegex && fileSelect.index >= 0 && formData.tasklist[fileSelect.index]" style="font-weight: 600; font-family: inherit; letter-spacing: normal;">{{ formData.tasklist[fileSelect.index].use_sequence_naming ? '顺序命名预览' : (formData.tasklist[fileSelect.index].use_episode_naming ? '剧集命名预览' : '正则命名预览') }}</span>
|
||
<span v-else-if="fileSelect.previewRegex && fileSelect.index === -1 && document.getElementById('fileSelectModal').getAttribute('data-modal-type') === 'preview'" style="font-weight: 600; font-family: inherit; letter-spacing: normal;">{{ createTask.taskData.use_sequence_naming ? '顺序命名预览' : (createTask.taskData.use_episode_naming ? '剧集命名预览' : '正则命名预览') }}</span>
|
||
<span v-else-if="fileSelect.previewRegex && fileSelect.index === -1 && document.getElementById('fileSelectModal').getAttribute('data-modal-type') === 'preview-filemanager'" style="font-weight: 600; font-family: inherit; letter-spacing: normal;">{{ fileManager.use_sequence_naming ? '顺序命名预览' : (fileManager.use_episode_naming ? '剧集命名预览' : '正则命名预览') }}</span>
|
||
<span v-else-if="fileSelect.selectDir" style="font-weight: 600; font-family: inherit; letter-spacing: normal;">选择{{fileSelect.moveMode ? '移动到的' : (fileSelect.selectShare ? '需转存的' : '保存到的')}}文件夹</span>
|
||
<span v-else style="font-weight: 600; font-family: inherit; letter-spacing: normal;">选择起始文件</span>
|
||
<div v-if="modalLoading" class="spinner-border spinner-border-sm m-1" role="status"></div>
|
||
</h5>
|
||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||
<i class="bi bi-x-lg"></i>
|
||
</button>
|
||
</div>
|
||
<div class="modal-body small">
|
||
<div class="alert alert-warning" v-if="fileSelect.error" v-html="fileSelect.error"></div>
|
||
<div v-else>
|
||
<!-- 面包屑导航 -->
|
||
<nav aria-label="breadcrumb" v-if="fileSelect.selectDir">
|
||
<ol class="breadcrumb">
|
||
<li class="breadcrumb-item cursor-pointer" @click="navigateTo('0','/')"><i class="bi bi-house-door"></i></li>
|
||
<li v-for="(item, index) in fileSelect.paths" class="breadcrumb-item">
|
||
<a v-if="index != fileSelect.paths.length - 1" href="#" @click="navigateTo(item.fid, item.name)">{{ item.name }}</a>
|
||
<span v-else class="text-muted">{{ item.name }}</span>
|
||
</li>
|
||
</ol>
|
||
</nav>
|
||
<!-- 文件列表 -->
|
||
<div class="mb-3" v-if="fileSelect.previewRegex">
|
||
<!-- 任务配置的命名预览 -->
|
||
<div v-if="fileSelect.index >= 0 && formData.tasklist[fileSelect.index]">
|
||
<div v-if="formData.tasklist[fileSelect.index].use_sequence_naming">
|
||
<div style="margin-bottom: 1px; padding-left: 12px; display: flex; align-items: center;">
|
||
<span style="font-weight: 600; font-family: inherit; letter-spacing: normal;">顺序命名表达式:</span><span class="badge badge-info" v-html="formData.tasklist[fileSelect.index].pattern"></span>
|
||
</div>
|
||
<div style="padding-left: 12px; display: flex; align-items: center;">
|
||
<span class="text-muted" style="font-family: inherit; letter-spacing: normal;">预览结果仅供参考,新文件序号将基于网盘或转存记录中已有文件的最大序号递增。若分享链接文件不完整,实际结果可能有所不同。</span>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="formData.tasklist[fileSelect.index].use_episode_naming">
|
||
<div style="padding-left: 12px; display: flex; align-items: center;">
|
||
<span style="font-weight: 600; font-family: inherit; letter-spacing: normal;">剧集命名表达式:</span><span class="badge badge-info" v-html="formData.tasklist[fileSelect.index].pattern"></span>
|
||
</div>
|
||
</div>
|
||
<div v-else>
|
||
<div style="margin-bottom: 1px; padding-left: 12px; display: flex; align-items: center;">
|
||
<span style="font-weight: 600; font-family: inherit; letter-spacing: normal;">匹配表达式:</span><span class="badge badge-info" v-html="formData.tasklist[fileSelect.index].pattern"></span>
|
||
</div>
|
||
<div style="padding-left: 12px; display: flex; align-items: center;">
|
||
<span style="font-weight: 600; font-family: inherit; letter-spacing: normal;">替换表达式:</span><span class="badge badge-info" v-html="formData.tasklist[fileSelect.index].replace"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 文件管理器的命名预览 -->
|
||
<!-- 创建任务模态框的命名预览 -->
|
||
<div v-else-if="fileSelect.index === -1 && document.getElementById('fileSelectModal').getAttribute('data-modal-type') === 'preview'">
|
||
<div v-if="createTask.taskData.use_sequence_naming">
|
||
<div style="margin-bottom: 1px; padding-left: 12px; display: flex; align-items: center;">
|
||
<span style="font-weight: 600; font-family: inherit; letter-spacing: normal;">顺序命名表达式:</span><span class="badge badge-info" v-html="createTask.taskData.pattern"></span>
|
||
</div>
|
||
<div style="padding-left: 12px; display: flex; align-items: center;">
|
||
<span class="text-muted" style="font-family: inherit; letter-spacing: normal;">预览结果仅供参考,新文件序号将基于网盘或转存记录中已有文件的最大序号递增。若分享链接文件不完整,实际结果可能有所不同。</span>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="createTask.taskData.use_episode_naming">
|
||
<div style="padding-left: 12px; display: flex; align-items: center;">
|
||
<span style="font-weight: 600; font-family: inherit; letter-spacing: normal;">剧集命名表达式:</span><span class="badge badge-info" v-html="createTask.taskData.pattern"></span>
|
||
</div>
|
||
</div>
|
||
<div v-else>
|
||
<div style="margin-bottom: 1px; padding-left: 12px; display: flex; align-items: center;">
|
||
<span style="font-weight: 600; font-family: inherit; letter-spacing: normal;">匹配表达式:</span><span class="badge badge-info" v-html="createTask.taskData.pattern"></span>
|
||
</div>
|
||
<div style="padding-left: 12px; display: flex; align-items: center;">
|
||
<span style="font-weight: 600; font-family: inherit; letter-spacing: normal;">替换表达式:</span><span class="badge badge-info" v-html="createTask.taskData.replace"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 文件管理器的命名预览 -->
|
||
<div v-else-if="fileSelect.index === -1 && document.getElementById('fileSelectModal').getAttribute('data-modal-type') === 'preview-filemanager'">
|
||
<div v-if="fileManager.use_sequence_naming">
|
||
<div style="margin-bottom: 12px; padding-left: 12px; display: flex; align-items: center;">
|
||
<span style="font-weight: 600; font-family: inherit; letter-spacing: normal;">顺序命名表达式:</span><span class="badge badge-info" v-html="fileManager.pattern"></span>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="fileManager.use_episode_naming">
|
||
<div style="padding-left: 12px; display: flex; align-items: center;">
|
||
<span style="font-weight: 600; font-family: inherit; letter-spacing: normal;">剧集命名表达式:</span><span class="badge badge-info" v-html="fileManager.pattern"></span>
|
||
</div>
|
||
</div>
|
||
<div v-else>
|
||
<div style="margin-bottom: 1px; padding-left: 12px; display: flex; align-items: center;">
|
||
<span style="font-weight: 600; font-family: inherit; letter-spacing: normal;">匹配表达式:</span><span class="badge badge-info" v-html="fileManager.pattern"></span>
|
||
</div>
|
||
<div style="padding-left: 12px; display: flex; align-items: center;">
|
||
<span style="font-weight: 600; font-family: inherit; letter-spacing: normal;">替换表达式:</span><span class="badge badge-info" v-html="fileManager.replace"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<table class="table table-hover table-sm">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col" class="col-filename cursor-pointer" style="padding-left: 5px; font-family: inherit; letter-spacing: normal;" @click="sortFileList('file_name')">文件名 <i v-if="fileSelect.sortBy === 'file_name'" :class="fileSelect.sortOrder === 'asc' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i></th>
|
||
<th scope="col" class="col-rename cursor-pointer" v-if="fileSelect.selectShare || fileSelect.previewRegex" style="padding-left: 5px; font-family: inherit; letter-spacing: normal;" @click="sortFileList('file_name_re')">{{
|
||
fileSelect.index !== null && fileSelect.index >= 0 && formData.tasklist[fileSelect.index] ?
|
||
(formData.tasklist[fileSelect.index].use_sequence_naming ? '顺序命名' :
|
||
(formData.tasklist[fileSelect.index].use_episode_naming ? '剧集命名' : '正则命名')) :
|
||
fileSelect.index === -1 && (document.getElementById('fileSelectModal').getAttribute('data-modal-type') === 'preview' || document.getElementById('fileSelectModal').getAttribute('data-modal-type') === 'source' || document.getElementById('fileSelectModal').getAttribute('data-modal-type') === 'start-file') ?
|
||
(createTask.taskData.use_sequence_naming ? '顺序命名' :
|
||
(createTask.taskData.use_episode_naming ? '剧集命名' : '正则命名')) :
|
||
(fileManager.use_sequence_naming ? '顺序命名' :
|
||
(fileManager.use_episode_naming ? '剧集命名' : '正则命名'))
|
||
}} <i v-if="fileSelect.sortBy === 'file_name_re'" :class="fileSelect.sortOrder === 'asc' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i></th>
|
||
<template v-if="!fileSelect.previewRegex">
|
||
<th scope="col" class="col-size cursor-pointer" style="font-family: inherit; letter-spacing: normal;" @click="sortFileList('size')">大小 <i v-if="fileSelect.sortBy === 'size'" :class="fileSelect.sortOrder === 'asc' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i></th>
|
||
<th scope="col" class="col-date cursor-pointer" style="font-family: inherit; letter-spacing: normal;" @click="sortFileList('updated_at')">修改日期 <i v-if="fileSelect.sortBy === 'updated_at'" :class="fileSelect.sortOrder === 'asc' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i></th>
|
||
<th scope="col" class="col-action" v-if="!fileSelect.selectShare && !fileSelect.moveMode" style="font-family: inherit; letter-spacing: normal;">操作</th>
|
||
</template>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(file, key) in fileSelect.fileList" :key="key"
|
||
@click="fileSelect.selectDir ? (file.dir ? navigateTo(file.fid, file.file_name) : selectFileItem($event, file.fid)) : selectStartFid(file.fid)"
|
||
:class="{
|
||
'cursor-pointer': (fileSelect.previewRegex ? file.dir : (file.dir || !fileSelect.selectShare || (!fileSelect.selectDir && !file.dir))),
|
||
'selected-file': fileSelect.selectedFiles.includes(file.fid)
|
||
}"
|
||
style="vertical-align: top;"
|
||
@mousedown="preventTextSelection($event, file.dir)">
|
||
<td class="col-filename position-relative" style="padding-left: 5px; vertical-align: top;">
|
||
<div v-if="!file._expandedFields || !file._expandedFields.includes('file_name')"
|
||
style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 25px;"
|
||
:title="file.file_name"
|
||
v-check-modal-overflow="key + '|file_name'">
|
||
<i class="bi" :class="getFileIconClass(file.file_name, file.dir)"></i> {{file.file_name}}
|
||
</div>
|
||
<div class="expand-button" v-if="isModalTextTruncated(file.file_name, key, 'file_name')" @click.stop="toggleModalExpand(key, 'file_name')">
|
||
<i :class="file._expandedFields && file._expandedFields.includes('file_name') ? 'bi bi-chevron-up' : 'bi bi-chevron-down'"></i>
|
||
</div>
|
||
<div v-if="file._expandedFields && file._expandedFields.includes('file_name')" style="white-space: normal; word-break: break-word; padding-right: 25px; display: block; width: 100%; position: relative;">
|
||
<i class="bi" :class="getFileIconClass(file.file_name, file.dir)"></i> {{file.file_name}}
|
||
</div>
|
||
</td>
|
||
<td class="col-rename position-relative" v-if="fileSelect.selectShare || fileSelect.previewRegex" :class="{'text-success': file.file_name_re && !file.file_name_re.startsWith('×'), 'text-danger': !file.file_name_re || file.file_name_re === '×' || file.file_name_re.startsWith('× ')}" style="padding-left: 5px; vertical-align: top;">
|
||
<div v-if="!file._expandedFields || !file._expandedFields.includes('file_name_re')"
|
||
style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 25px; color: inherit;"
|
||
:title="file.file_name_re || '×'"
|
||
v-check-modal-overflow="key + '|file_name_re'">
|
||
<template v-if="file.file_name_re && file.file_name_re.startsWith('× ')">
|
||
<span class="episode-x">×</span> <span class="episode-number-text">无法识别剧集编号</span>
|
||
</template>
|
||
<template v-else>
|
||
<span class="episode-x">{{file.file_name_re || '×'}}</span>
|
||
</template>
|
||
</div>
|
||
<div class="expand-button" v-if="isModalTextTruncated(file.file_name_re || '×', key, 'file_name_re')" @click.stop="toggleModalExpand(key, 'file_name_re')">
|
||
<i :class="file._expandedFields && file._expandedFields.includes('file_name_re') ? 'bi bi-chevron-up' : 'bi bi-chevron-down'"></i>
|
||
</div>
|
||
<div v-if="file._expandedFields && file._expandedFields.includes('file_name_re')" style="white-space: normal; word-break: break-word; padding-right: 25px; display: block; width: 100%; position: relative; color: inherit;">
|
||
<template v-if="file.file_name_re && file.file_name_re.startsWith('× ')">
|
||
<span class="episode-x">×</span> <span class="episode-number-text">无法识别剧集编号</span>
|
||
</template>
|
||
<template v-else>
|
||
<span class="episode-x">{{file.file_name_re || '×'}}</span>
|
||
</template>
|
||
</div>
|
||
</td>
|
||
<template v-if="!fileSelect.previewRegex">
|
||
<td class="col-size" v-if="file.dir" style="vertical-align: top;">{{ file.include_items }} 项</td>
|
||
<td class="col-size" v-else style="vertical-align: top;">{{file.size | size}}</td>
|
||
<td class="col-date" style="vertical-align: top;">{{file.updated_at | ts2date}}</td>
|
||
<td class="col-action" v-if="!fileSelect.selectShare && !fileSelect.moveMode" style="vertical-align: top;">
|
||
<a @click.stop.prevent="deleteSelectedFiles(file.fid, file.file_name, file.dir)" style="cursor: pointer;">删除文件</a>
|
||
<a @click.stop.prevent="deleteSelectedFiles(file.fid, file.file_name, file.dir, true)" style="cursor: pointer; margin-left: 10px;">删除文件和记录</a>
|
||
</td>
|
||
</template>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer" v-if="fileSelect.selectDir && !fileSelect.previewRegex">
|
||
<div class="file-selection-info mr-auto" style="color: var(--dark-text-color); font-size: 0.875rem; line-height: 1.5;">
|
||
<span v-if="fileSelect.selectShare && smart_param.taskSuggestions.data && smart_param.taskSuggestions.data.length > 1 && isCurrentResourceInSearchResults()">
|
||
第 {{ smart_param.currentResourceIndex + 1 }} 个资源,
|
||
</span>
|
||
共 {{ fileSelect.fileList.length }} 个项目<span v-if="fileSelect.selectedFiles.length > 0">,已选中 {{ fileSelect.selectedFiles.length }} 个项目</span>
|
||
</div>
|
||
<!-- 连续浏览按钮组(仅在资源搜索模式下且当前资源在搜索结果中时显示) -->
|
||
<div v-if="fileSelect.selectShare && smart_param.taskSuggestions.data && smart_param.taskSuggestions.data.length > 1 && isCurrentResourceInSearchResults()" class="resource-navigation-buttons">
|
||
<button type="button" class="btn btn-primary btn-sm"
|
||
:disabled="smart_param.currentResourceIndex <= 0"
|
||
@click="navigateToPreviousResource()"
|
||
title="上一个资源">
|
||
上一个
|
||
</button>
|
||
<button type="button" class="btn btn-primary btn-sm"
|
||
:disabled="smart_param.currentResourceIndex >= smart_param.taskSuggestions.data.length - 1"
|
||
@click="navigateToNextResource()"
|
||
title="下一个资源">
|
||
下一个
|
||
</button>
|
||
</div>
|
||
<button type="button" class="btn btn-primary btn-cancel" @click="$('#fileSelectModal').modal('hide')" v-if="fileSelect.moveMode">取消</button>
|
||
<button type="button" class="btn btn-primary btn-sm" @click="selectCurrentFolder()">
|
||
<span v-if="fileSelect.moveMode">移动到当前文件夹</span>
|
||
<span v-else>{{fileSelect.selectShare ? '转存当前文件夹' : '保存到当前文件夹'}}</span>
|
||
</button>
|
||
<button type="button" class="btn btn-primary btn-sm" v-if="!fileSelect.selectShare && !fileSelect.moveMode && fileSelect.index === -1 && createTask.taskData.taskname" @click="selectCurrentFolder(true)">保存到当前位置的「<span class="badge badge-light" v-html="generateCustomFolderPath(createTask.taskData)"></span>」文件夹</button>
|
||
<button type="button" class="btn btn-primary btn-sm" v-if="!fileSelect.selectShare && !fileSelect.moveMode && fileSelect.index !== null && fileSelect.index >= 0 && formData.tasklist[fileSelect.index]" @click="selectCurrentFolder(true)">保存到当前位置的「<span class="badge badge-light" v-html="formData.tasklist[fileSelect.index].taskname"></span>」文件夹</button>
|
||
</div>
|
||
<div class="modal-footer" v-if="fileSelect.previewRegex && fileSelect.index === -1 && document.getElementById('fileSelectModal').getAttribute('data-modal-type') === 'preview-filemanager'">
|
||
<div class="file-selection-info mr-auto" style="color: var(--dark-text-color); font-size: 0.875rem; line-height: 1.5;">
|
||
共 {{ fileSelect.fileList.filter(f => f.file_name_re && f.file_name_re !== '×' && !f.file_name_re.startsWith('×')).length }} 个项目可重命名
|
||
</div>
|
||
<button type="button" class="btn btn-primary btn-cancel" @click="cancelPreviewRename">取消</button>
|
||
<button type="button" class="btn btn-primary" :disabled="!fileSelect.canUndoRename || modalLoading" @click="undoPreviewRename">撤销</button>
|
||
<button type="button" class="btn btn-primary" :disabled="modalLoading || !fileSelect.fileList.some(f => f.file_name_re && f.file_name_re !== '×' && !f.file_name_re.startsWith('×'))" @click="applyPreviewRename">
|
||
重命名
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 模态框 创建任务 -->
|
||
<div class="modal" tabindex="-1" id="createTaskModal">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" style="font-weight: 600; font-family: inherit; letter-spacing: normal;">
|
||
<span style="font-weight: 600; font-family: inherit; letter-spacing: normal;">{{ createTask.isEditMode ? '编辑任务' : '创建任务' }}</span>
|
||
<div v-if="createTask.loading" class="spinner-border spinner-border-sm m-1" role="status"></div>
|
||
</h5>
|
||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||
<i class="bi bi-x-lg"></i>
|
||
</button>
|
||
</div>
|
||
<div class="modal-body small">
|
||
<div class="alert alert-warning" v-if="createTask.error" v-html="createTask.error"></div>
|
||
<div v-else>
|
||
<!-- 直接复制任务配置的完整代码 -->
|
||
<div class="alert alert-warning" role="alert" v-if="createTask.taskData.shareurl_ban" v-html="formatShareUrlBanMessage(createTask.taskData.shareurl_ban) || createTask.taskData.shareurl_ban"></div>
|
||
<div class="form-group row">
|
||
<label class="col-sm-2 col-form-label">任务名称</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<input type="text" name="taskname[]" class="form-control" v-model="createTask.taskData.taskname" placeholder="必填" @focus="smart_param.showSuggestions=true;focusTaskname(-1, createTask.taskData)" @input="changeTaskname(-1, createTask.taskData)">
|
||
<div class="dropdown-menu show task-suggestions" v-if="smart_param.showSuggestions && smart_param.index === -1 && (smart_param.taskSuggestions.success || smart_param.isSearching)">
|
||
<div class="dropdown-item text-muted" v-if="smart_param.isSearching" style="font-size:14px; padding: 0 8px; text-align: left;">
|
||
<span v-if="smart_param.validating">
|
||
<i class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></i>
|
||
正在验证链接有效性...({{ smart_param.validateProgress.current }}/{{ smart_param.validateProgress.total }})<span v-if="smart_param.validateProgress.valid > 0">已找到 {{ smart_param.validateProgress.valid }} 个有效链接</span>
|
||
</span>
|
||
<span v-else>正在搜索资源...</span>
|
||
</div>
|
||
<div class="dropdown-item text-muted" v-else style="font-size:14px; padding-left: 8px; text-align: left;">
|
||
{{ smart_param.taskSuggestions.message ? smart_param.taskSuggestions.message : smart_param.taskSuggestions.data && smart_param.taskSuggestions.data.length ? `以下资源由 ${(smart_param.taskSuggestions.source || '').replace(/,\s*/g, '、')} 搜索提供(仅显示有效链接,共 ${(smart_param.taskSuggestions.data || []).length} 个),如有侵权请联系资源发布方` : "未搜索到有效资源" }}
|
||
</div>
|
||
<div v-for="suggestion in smart_param.taskSuggestions.data || []" :key="(suggestion.shareurl || '') + '_' + (suggestion.taskname || '') + '_' + (suggestion.publish_date || '')" class="dropdown-item cursor-pointer" @click.prevent="selectSuggestion(-1, suggestion)" style="font-size: 14px;" :title="getSuggestionHoverTitle(suggestion)">
|
||
<span v-html="suggestion.verify ? '✅': ''"></span> {{ suggestion.taskname }}
|
||
<small class="text-muted">
|
||
<a :href="suggestion.shareurl" target="_blank" @click.stop> · {{ suggestion.shareurl.replace(/^https?:\/\/pan\.quark\.cn\/s\//, '') }}</a>
|
||
<template v-if="suggestion.source"><span class="source-badge" :class="suggestion.source.toLowerCase()" :data-publish-date="suggestion.publish_date ? ' · ' + formatPublishDate(suggestion.publish_date, suggestion.pansou_source, suggestion.source) : ''">{{ suggestion.source }}</span></template>
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<div class="input-group-append" title="资源搜索">
|
||
<button class="btn btn-primary" type="button" @click="searchSuggestions(-1, createTask.taskData.taskname)">
|
||
<i v-if="smart_param.isSearching && smart_param.index === -1" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></i>
|
||
<i v-else class="bi bi-search"></i>
|
||
</button>
|
||
<div class="input-group-text" title="谷歌搜索">
|
||
<a target="_blank" :href="`https://www.google.com/search?q=%22pan.quark.cn/s%22+${cleanTaskNameForSearch(createTask.taskData.taskname)}`"><i class="bi bi-google"></i></a>
|
||
</div>
|
||
<div class="input-group-text" title="TMDB搜索">
|
||
<a target="_blank" :href="`https://www.themoviedb.org/search?query=${cleanTaskNameForSearch(createTask.taskData.taskname)}`"><img src="./static/images/TMDB.svg" class="tmdb-icon"></a>
|
||
</div>
|
||
<div class="input-group-text" title="豆瓣搜索">
|
||
<a target="_blank" :href="`https://search.douban.com/movie/subject_search?search_text=${cleanTaskNameForSearch(createTask.taskData.taskname)}`"><img src="./static/images/Douban.svg" class="douban-icon"></a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group row" title="支持子目录链接,Web端打开分享点入目录,复制浏览器的URL即可;支持带提取码链接,查阅Wiki了解详情">
|
||
<label class="col-sm-2 col-form-label">分享链接</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<input type="text" name="shareurl[]" class="form-control" v-model="createTask.taskData.shareurl" placeholder="必填" @blur="changeShareurl(createTask.taskData)">
|
||
<div class="input-group-append" v-if="createTask.taskData.shareurl">
|
||
<button type="button" class="btn btn-outline-secondary" @click="fileSelect.selectDir=true;fileSelect.previewRegex=false;showShareSelect(-1)" title="选择需转存的文件夹"><i class="bi bi-folder"></i></button>
|
||
<div class="input-group-text">
|
||
<a target="_blank" :href="createTask.taskData.shareurl"><i class="bi bi-link-45deg"></i></a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group row">
|
||
<label class="col-sm-2 col-form-label">保存路径</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<input type="text" name="savepath[]" class="form-control" v-model="createTask.taskData.savepath" placeholder="必填" @focus="focusTaskname(-1, createTask.taskData)" @input="onSavepathChange(-1, createTask.taskData)">
|
||
<div class="input-group-append">
|
||
<button class="btn btn-outline-secondary" type="button" v-if="smart_param.savepath && smart_param.index == -1 && createTask.taskData.savepath != smart_param.origin_savepath" @click="createTask.taskData.savepath = smart_param.origin_savepath" title="恢复保存路径"><i class="bi bi-reply"></i></button>
|
||
<button class="btn btn-outline-secondary" type="button" @click="showSavepathSelect(-1)" title="选择保存到的文件夹"><i class="bi bi-folder"></i></button>
|
||
<button type="button" class="btn btn-outline-secondary" @click="resetFolderContent(-1)" title="重置文件夹:此操作将删除当前保存路径中的所有文件及相关转存记录,且不可恢复,请谨慎操作"><i class="bi bi-folder-x"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group row" :title="createTask.taskData.use_sequence_naming || createTask.taskData.use_episode_naming ? (createTask.taskData.use_sequence_naming ? '输入带{}占位符的重命名格式,如:剧名 - S01E{}、剧名.S03E{}等,{}将被替换为集序号(按文件名和修改日期智能排序),目录中的文件夹会被自动过滤' : '输入带[]占位符的重命名格式,如:剧名 - S01E[]、剧名.S03E[]等,[]将被替换为从文件名中提取的集编号,目录中的文件夹会被自动过滤') : '可用作筛选,只转存匹配到文件名的文件,留空则直接转存所有文件'">
|
||
<label class="col-sm-2 col-form-label">命名规则</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<button class="btn btn-outline-secondary" type="button" @click="fileSelect.selectDir=true;fileSelect.previewRegex=true;fileSelect.sortBy='file_name_re';fileSelect.sortOrder='desc';showShareSelect(-1)" :title="createTask.taskData.use_sequence_naming ? '预览顺序命名效果' : (createTask.taskData.use_episode_naming ? '预览剧集命名效果' : '预览正则命名效果')">
|
||
{{ createTask.taskData.use_sequence_naming ? '顺序命名' : (createTask.taskData.use_episode_naming ? '剧集命名' : '正则命名') }}
|
||
</button>
|
||
</div>
|
||
<input type="text" name="pattern[]" class="form-control" v-model="createTask.taskData.pattern" :placeholder="createTask.taskData.use_sequence_naming ? '输入带{}占位符的重命名格式,如:剧名 - S01E{}' : (createTask.taskData.use_episode_naming ? '输入带[]占位符的重命名格式,如:剧名 - S01E[]' : '匹配表达式')" list="magicRegex" @input="detectNamingMode(createTask.taskData); onPatternChange(-1, createTask.taskData)">
|
||
<input v-if="!createTask.taskData.use_sequence_naming && !createTask.taskData.use_episode_naming" type="text" name="replace[]" class="form-control" v-model="createTask.taskData.replace" placeholder="替换表达式">
|
||
<div class="input-group-append" title="保存时只比较文件名的部分,01.mp4和01.mkv视同为同一文件,不重复转存">
|
||
<div class="input-group-text">
|
||
<input type="checkbox" v-model="createTask.taskData.ignore_extension"> 忽略后缀
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<datalist id="magicRegex">
|
||
<option v-for="(value, key) in formData.magic_regex" :key="key" :value="`${key}`" v-html="`${value.pattern.replace('<', '<\u200B')} → ${value.replace}`"></option>
|
||
</datalist>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group row" title="名称包含过滤词汇的项目不会被转存,多个词用逗号分隔,支持通过文件名和扩展名过滤文件和文件夹,支持使用保留词|过滤词的格式实现高级过滤,查阅Wiki了解详情">
|
||
<label class="col-sm-2 col-form-label">过滤规则</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<input type="text" name="filterwords[]" class="form-control" v-model="createTask.taskData.filterwords" placeholder="可选,输入过滤词汇,用逗号分隔,名称包含过滤词汇的项目不会被转存,支持使用保留词|过滤词的格式实现高级过滤">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group row" title="只转存比选中文件更新的文件,请在符合筛选条件的文件中进行选择,在更换分享链接时非常有用">
|
||
<label class="col-sm-2 col-form-label">起始文件</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<input type="text" class="form-control" placeholder="可选,只转存比此文件更新的文件,请在符合筛选条件的文件中进行选择" name="startfid[]" v-model="createTask.taskData.startfid">
|
||
<div class="input-group-append" v-if="createTask.taskData.shareurl">
|
||
<button class="btn btn-outline-secondary" type="button" @click="fileSelect.selectDir=false;fileSelect.previewRegex=false;showShareSelect(-1)" title="选择起始文件"><i class="bi bi-folder"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group row" title="匹配成功的文件夹的所有嵌套目录都会被更新,并且会应用与根目录相同的正则命名和过滤规则。注意:原理是逐级索引,深层嵌套目录的场景下效率非常低,慎用.*">
|
||
<label class="col-sm-2 col-form-label">更新目录</label>
|
||
<div class="col-sm-10">
|
||
<input type="text" name="update_subdir[]" class="form-control" v-model="createTask.taskData.update_subdir" placeholder="可选,输入需要更新的子目录的文件夹名称(或正则表达式),多个项目用竖线分隔,如:4K|1080P">
|
||
</div>
|
||
</div>
|
||
<div class="form-group row">
|
||
<label class="col-sm-2 col-form-label">截止日期</label>
|
||
<div class="col-sm-10">
|
||
<div class="input-group">
|
||
<input type="date" name="enddate[]" class="form-control date-input-no-icon" v-model="createTask.taskData.enddate" placeholder="可选" ref="createTaskEnddate">
|
||
<div class="input-group-append">
|
||
<button type="button" class="btn btn-outline-secondary" @click="openCreateTaskDatePicker()" title="选择截止日期">
|
||
<i class="bi bi-calendar3"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group row" title="只在勾选的星期时才运行,对周更的内容非常有用">
|
||
<label class="col-sm-2 col-form-label">执行周期</label>
|
||
<div class="col-sm-10 col-form-label">
|
||
<div class="form-check form-check-inline" title="也可用作任务总开关">
|
||
<input class="form-check-input" type="checkbox" :checked="createTask.taskData.runweek.length === 7" @change="toggleAllWeekdays(createTask.taskData)" :indeterminate.prop="createTask.taskData.runweek.length > 0 && createTask.taskData.runweek.length < 7" :disabled="(createTask.taskData.execution_mode || formData.execution_mode || 'manual') === 'auto'">
|
||
<label class="form-check-label" :class="{'text-muted': (createTask.taskData.execution_mode || formData.execution_mode || 'manual') === 'auto'}">全选</label>
|
||
</div>
|
||
<div class="form-check form-check-inline" v-for="(day, index) in weekdays" :key="index">
|
||
<input class="form-check-input" type="checkbox" v-model="createTask.taskData.runweek" :value="index+1" :disabled="(createTask.taskData.execution_mode || formData.execution_mode || 'manual') === 'auto'">
|
||
<label class="form-check-label" :class="{'text-muted': (createTask.taskData.execution_mode || formData.execution_mode || 'manual') === 'auto'}" v-html="day"></label>
|
||
</div>
|
||
<div class="form-check form-check-inline">
|
||
<input class="form-check-input" type="checkbox" :checked="(createTask.taskData.execution_mode || formData.execution_mode || 'manual') === 'auto'" @change="createTask.taskData.execution_mode = $event.target.checked ? 'auto' : 'manual'">
|
||
<label class="form-check-label">自动</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group row" v-if="Object.keys(getAvailablePlugins(formData.plugins)).length" :title="getCreateTaskPluginConfigTitle()">
|
||
<label class="col-sm-2 col-form-label">插件配置</label>
|
||
<div class="col-sm-10">
|
||
<v-jsoneditor v-model="createTask.taskData.addition" :options="{mode:'tree'}" :plus="false" height="162px" :disabled="isPluginConfigDisabled(createTask.taskData)"></v-jsoneditor>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<div class="file-selection-info mr-auto" v-if="createTask.movieData" style="color: var(--dark-text-color); font-size: 0.875rem; line-height: 1.5;">
|
||
{{ createTask.movieData.title + (createTask.movieData.year ? ' (' + createTask.movieData.year + ')' : '') }}
|
||
</div>
|
||
<button type="button" class="btn btn-primary btn-cancel" @click="cancelCreateTask">取消</button>
|
||
<template v-if="createTask.isEditMode">
|
||
<button type="button" class="btn btn-primary" :disabled="createTask.loading" @click="confirmEditTask">
|
||
保存
|
||
</button>
|
||
<button type="button" class="btn btn-primary" :disabled="createTask.loading" @click="confirmEditAndRunTask" style="margin-left: 4px;">
|
||
保存并运行任务
|
||
</button>
|
||
</template>
|
||
<template v-else>
|
||
<button type="button" class="btn btn-primary" :disabled="createTask.loading" @click="confirmCreateTask">
|
||
创建任务
|
||
</button>
|
||
<button type="button" class="btn btn-primary" :disabled="createTask.loading" @click="confirmCreateAndRunTask">
|
||
创建并运行任务
|
||
</button>
|
||
<button type="button" class="btn btn-primary" :disabled="createTask.loading" @click="confirmCreateRunAndDeleteTask">
|
||
创建、运行并删除任务
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 模态框 编辑元数据(放在 #app 内,继承相同样式) -->
|
||
<div class="modal" tabindex="-1" id="editMetadataModal">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" style="font-weight: 600; font-family: inherit; letter-spacing: normal;">编辑元数据
|
||
<div v-show="editMetadata && editMetadata.loading" class="spinner-border spinner-border-sm m-1" role="status"></div>
|
||
</h5>
|
||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||
<i class="bi bi-x-lg"></i>
|
||
</button>
|
||
</div>
|
||
<div class="modal-body small">
|
||
<div>
|
||
<div class="row">
|
||
<div class="col-md-6 mb-2">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text" style="background-color: var(--button-gray-background-color);">任务名称</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="editMetadata.form.task_name" placeholder="任务名称">
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6 mb-2">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text" style="background-color: var(--button-gray-background-color);">任务类型</span>
|
||
</div>
|
||
<select class="form-control" v-model="editMetadata.form.content_type">
|
||
<option value="tv">剧集</option>
|
||
<option value="anime">动画</option>
|
||
<option value="variety">综艺</option>
|
||
<option value="documentary">纪录片</option>
|
||
<option value="other">其他</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<!-- 左:匹配结果 -->
|
||
<div class="col-md-6 mb-2">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text" style="background-color: var(--button-gray-background-color);">匹配结果</span>
|
||
</div>
|
||
<div class="form-control matched-result" style="display: flex; align-items: center; gap: 8px;">
|
||
<div class="matched-main">
|
||
<a v-if="editMetadata.display.matched_tmdb_id" href="#" @click.prevent="openTaskMatchedTmdbPage({ match: { tmdb_id: editMetadata.display.matched_tmdb_id } })" class="matched-link">
|
||
{{ editMetadata.display.matched_label }}
|
||
</a>
|
||
<span v-else>{{ editMetadata.display.matched_label }}</span>
|
||
</div>
|
||
<span v-if="editMetadata.display.matched_season_number" class="matched-season-wrap">
|
||
<span class="input-group-text square-append">第</span>
|
||
<a href="#" @click.prevent="editMetadata.display.matched_tmdb_id && window.open('https://www.themoviedb.org/tv/' + editMetadata.display.matched_tmdb_id + '/season/' + parseInt(editMetadata.display.matched_season_number), '_blank')" class="matched-link matched-season">
|
||
{{ editMetadata.display.matched_season_number }}
|
||
</a>
|
||
<span class="input-group-text square-append">季</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右:修正匹配 -->
|
||
<div class="col-md-6 mb-2">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text" style="background-color: var(--button-gray-background-color);">修正匹配</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="editMetadata.form.tmdb_id" placeholder="输入电视节目的 TMDB ID">
|
||
<div class="input-group-append">
|
||
<span class="input-group-text square-append season-left">第</span>
|
||
</div>
|
||
<input type="number" min="1" class="form-control no-spinner edit-season-number"
|
||
v-model.number="editMetadata.form.season_number"
|
||
placeholder="1"
|
||
:style="{ width: (editMetadata.display.seasonInputWidth || '32px'), minWidth: '32px', flex: '0 0 auto', padding: '0 8px' }"
|
||
@input="autoSizeSeasonInput($event)"
|
||
>
|
||
<div class="input-group-append">
|
||
<span class="input-group-text square-append season-right">季</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 更换海报 -->
|
||
<div class="row">
|
||
<div class="col-12 mb-2">
|
||
<div class="input-group">
|
||
<div class="input-group-prepend">
|
||
<span class="input-group-text" style="background-color: var(--button-gray-background-color);">更换海报</span>
|
||
</div>
|
||
<input type="text" class="form-control" v-model="editMetadata.form.custom_poster_url" placeholder="输入海报地址,支持网络地址或映射后的本地地址">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<div class="file-selection-info mr-auto" v-html="editMetadata.hint" style="color: var(--dark-text-color); font-size: 0.875rem; line-height: 1.5;"></div>
|
||
<button type="button" class="btn btn-primary btn-cancel" data-dismiss="modal"><span class="btn-text">取消</span></button>
|
||
<button type="button" class="btn btn-primary" @click="saveEditMetadata"><span class="btn-text">保存</span></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
|
||
<script>
|
||
var app = new Vue({
|
||
el: '#app',
|
||
data: {
|
||
version: "[[ version ]]",
|
||
versionTips: "",
|
||
plugin_flags: "[[ plugin_flags ]]",
|
||
weekdays: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
|
||
// 全局缓存穿透时间戳(兜底)
|
||
imageCacheBustTick: 0,
|
||
// 按节目维度的缓存穿透时间戳
|
||
imageCacheBustById: {}, // { [tmdb_id:number]: number }
|
||
imageCacheBustByShowName: {}, // { [show_name:string]: number }
|
||
sidebarCollapsed: false,
|
||
showCloudSaverPassword: false,
|
||
showWebuiPassword: false,
|
||
pageWidthMode: 'medium', // 页面宽度模式:narrow, medium, wide
|
||
pluginDisplayAliases: {
|
||
alist: 'AList',
|
||
alist_strm: 'AList Strm',
|
||
alist_strm_gen: 'AList Strm Gen',
|
||
aria2: 'Aria2',
|
||
emby: 'Emby',
|
||
plex: 'Plex'
|
||
},
|
||
configHasLoaded: false,
|
||
formData: {
|
||
cookie: [],
|
||
push_config: {},
|
||
push_notify_type: 'full',
|
||
media_servers: {},
|
||
tasklist: [],
|
||
magic_regex: {},
|
||
episode_patterns: [],
|
||
task_settings: {
|
||
movie_save_path: "电影目录前缀/片名 (年份)",
|
||
tv_save_path: "剧集目录前缀/剧名 (年份)/剧名 - S季数",
|
||
anime_save_path: "动画目录前缀/剧名 (年份)/剧名 - S季数",
|
||
variety_save_path: "综艺目录前缀/剧名 (年份)/剧名 - S季数",
|
||
documentary_save_path: "纪录片目录前缀/剧名 (年份)/剧名 - S季数",
|
||
movie_naming_pattern: "^(.*)\\.([^.]+)",
|
||
movie_naming_replace: "片名 (年份).\\2",
|
||
tv_naming_rule: "剧名 - S季数E[]",
|
||
tv_ignore_extension: true,
|
||
subtitle_naming_rule: "zh",
|
||
subtitle_add_language_code: false
|
||
},
|
||
source: {
|
||
cloudsaver: {
|
||
server: "",
|
||
username: "",
|
||
password: "",
|
||
token: ""
|
||
},
|
||
pansou: {
|
||
server: "https://so.252035.xyz"
|
||
}
|
||
},
|
||
webui: {
|
||
username: "",
|
||
password: ""
|
||
},
|
||
button_display: {
|
||
run_task: "always",
|
||
delete_task: "always",
|
||
refresh_plex: "always",
|
||
refresh_alist: "always",
|
||
season_counts: "always",
|
||
latest_update_date: "always",
|
||
task_progress: "always",
|
||
show_status: "always",
|
||
latest_transfer_file: "always",
|
||
today_update_indicator: "always"
|
||
},
|
||
poster_language: "zh-CN",
|
||
// 显示顺序(可拖拽修改):按钮 + 信息
|
||
button_display_order: [
|
||
"refresh_plex",
|
||
"refresh_alist",
|
||
"run_task",
|
||
"delete_task",
|
||
"latest_transfer_file",
|
||
"season_counts",
|
||
"latest_update_date",
|
||
"task_progress",
|
||
"show_status",
|
||
"today_update_indicator"
|
||
],
|
||
file_performance: {
|
||
api_page_size: 200,
|
||
cache_expire_time: 30,
|
||
discovery_items_count: 30
|
||
},
|
||
performance: {
|
||
// 追剧日历刷新周期(秒),默认6小时(21600秒)
|
||
calendar_refresh_interval_seconds: 21600,
|
||
// 已播出集数刷新时间,24小时制,格式:HH:MM,默认00:00
|
||
aired_refresh_time: "00:00",
|
||
// 运行日志显示范围(天),默认3天
|
||
runtime_log_display_days: 3
|
||
},
|
||
plugin_config_mode: {
|
||
aria2: "independent",
|
||
alist_strm_gen: "independent",
|
||
emby: "independent"
|
||
},
|
||
global_plugin_config: {
|
||
aria2: {
|
||
auto_download: true,
|
||
pause: false,
|
||
auto_delete_quark_files: false
|
||
},
|
||
alist_strm_gen: {
|
||
auto_gen: true
|
||
},
|
||
emby: {
|
||
try_match: true,
|
||
media_id: ""
|
||
}
|
||
},
|
||
tmdb_api_key: ""
|
||
},
|
||
userInfoList: [], // 用户信息列表
|
||
accountsDetail: [], // 账号详细信息列表
|
||
newTask: {
|
||
taskname: "",
|
||
shareurl: "",
|
||
savepath: "",
|
||
pattern: "",
|
||
replace: "",
|
||
enddate: "",
|
||
addition: {},
|
||
ignore_extension: false,
|
||
filterwords: "",
|
||
runweek: [1, 2, 3, 4, 5, 6, 7],
|
||
sequence_naming: "",
|
||
use_sequence_naming: false,
|
||
episode_naming: "",
|
||
use_episode_naming: false,
|
||
execution_mode: null // 将在openCreateTaskModal中设置为formData.execution_mode
|
||
},
|
||
run_log: "",
|
||
runtimeLogs: [],
|
||
runtimeLogLoading: false,
|
||
runtimeLogFilters: {
|
||
keyword: '',
|
||
task: '',
|
||
level: ''
|
||
},
|
||
runtimeLogScrollPositionBeforeTaskFilter: null, // 保存应用任务筛选之前的滚动位置
|
||
runtimeLogScrollPositionBeforeLevelFilter: null, // 保存应用级别筛选之前的滚动位置
|
||
runtimeLogScrollPositionBeforeKeywordFilter: null, // 保存应用内容筛选之前的滚动位置
|
||
runtimeLogLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
|
||
runtimeLogPollingTimer: null,
|
||
runtimeLogInitialized: false, // 跟踪运行日志是否已初始化(用于避免首次加载时的闪现)
|
||
taskDirs: [""],
|
||
taskDirSelected: "",
|
||
taskNameFilter: "",
|
||
taskStatusFilter: (() => {
|
||
try {
|
||
return localStorage.getItem('tasklist_status_filter') || '';
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
})(),
|
||
taskLatestRecords: {}, // 存储每个任务的最新转存记录日期
|
||
taskLatestFiles: {}, // 存储每个任务的最近转存文件
|
||
modalLoading: false,
|
||
// 编辑元数据状态
|
||
editMetadata: {
|
||
loading: false,
|
||
visible: false,
|
||
original: {
|
||
task_name: '',
|
||
content_type: '',
|
||
tmdb_id: '',
|
||
season_number: ''
|
||
},
|
||
form: {
|
||
task_name: '',
|
||
content_type: '',
|
||
tmdb_id: '',
|
||
season_number: 1
|
||
},
|
||
display: {
|
||
matched_label: '未匹配',
|
||
matched_tmdb_id: '',
|
||
matched_season_number: ''
|
||
},
|
||
hint: ''
|
||
},
|
||
smart_param: {
|
||
index: null,
|
||
savepath: "",
|
||
origin_savepath: "",
|
||
taskSuggestions: {
|
||
success: false,
|
||
data: []
|
||
},
|
||
showSuggestions: false,
|
||
isSearching: false,
|
||
validating: false,
|
||
validateProgress: {
|
||
total: 0,
|
||
current: 0,
|
||
valid: 0
|
||
},
|
||
searchTimer: null,
|
||
// 新增:搜索会话号用于取消上一次验证/渲染,避免卡死和重复
|
||
searchSessionId: 0,
|
||
// 新增:当前浏览的资源索引,用于连续浏览功能
|
||
currentResourceIndex: -1
|
||
},
|
||
activeTab: 'config',
|
||
configModified: false,
|
||
// 标志:当由后端推送或编辑元数据保存触发的程序性更新时,暂时抑制未保存提示
|
||
suppressConfigModifiedOnce: false,
|
||
fileSelect: {
|
||
index: null,
|
||
shareurl: "",
|
||
stoken: "",
|
||
fileList: [],
|
||
paths: [],
|
||
selectDir: true,
|
||
selectShare: true,
|
||
previewRegex: false,
|
||
sortBy: "updated_at", // 默认排序字段
|
||
sortOrder: "desc", // 默认排序顺序
|
||
selectedFiles: [], // 存储选中的文件ID
|
||
lastSelectedFileIndex: -1, // 记录最后选择的文件索引
|
||
canUndoRename: false,
|
||
moveMode: false, // 是否为移动文件模式
|
||
moveFileIds: [] // 要移动的文件ID列表
|
||
},
|
||
historyParams: {
|
||
sortBy: "transfer_time",
|
||
order: "desc",
|
||
page_size: 15,
|
||
page: 1
|
||
},
|
||
history: {
|
||
hasLoaded: false, // 标记是否已完成首次加载
|
||
records: [],
|
||
pagination: {}
|
||
},
|
||
historyNameFilter: "",
|
||
historyTaskSelected: "",
|
||
historyStatusFilter: (() => {
|
||
try {
|
||
return localStorage.getItem('history_status_filter') || '';
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
})(),
|
||
gotoPage: 1,
|
||
totalPages: 1,
|
||
displayedPages: [],
|
||
allTaskNames: [],
|
||
toastMessage: "",
|
||
selectedRecords: [],
|
||
lastSelectedRecordIndex: -1, // 记录最后选择的记录索引,用于Shift选择
|
||
_lastTaskFilter: "",
|
||
_lastNameFilter: "",
|
||
_lastStatusFilter: "",
|
||
fileManager: {
|
||
hasLoaded: false, // 标记是否已完成首次加载
|
||
// 当前文件夹ID - 根据localStorage和账号索引决定初始目录
|
||
currentFolder: (() => {
|
||
const selectedAccountIndex = parseInt(localStorage.getItem('quarkAutoSave_selectedAccountIndex') || '0');
|
||
return localStorage.getItem(`quarkAutoSave_fileManagerLastFolder_${selectedAccountIndex}`) || 'root';
|
||
})(),
|
||
// 选中的账号索引 - 从localStorage读取,默认为0
|
||
selectedAccountIndex: parseInt(localStorage.getItem('quarkAutoSave_selectedAccountIndex') || '0'),
|
||
// 面包屑导航路径
|
||
paths: [],
|
||
// 文件列表
|
||
fileList: [],
|
||
// 总数
|
||
total: 0,
|
||
// 当前页码
|
||
currentPage: 1,
|
||
// 页面大小
|
||
pageSize: 15,
|
||
// 总页数
|
||
totalPages: 1,
|
||
// 排序列 - 从localStorage读取,如果没有则使用默认值
|
||
sortBy: (() => {
|
||
const savedSort = localStorage.getItem('quarkAutoSave_fileManagerSort');
|
||
if (savedSort) {
|
||
try {
|
||
return JSON.parse(savedSort).sortBy || 'file_name';
|
||
} catch (e) {
|
||
return 'file_name';
|
||
}
|
||
}
|
||
return 'file_name';
|
||
})(),
|
||
// 排序方式 - 从localStorage读取,如果没有则使用默认值
|
||
sortOrder: (() => {
|
||
const savedSort = localStorage.getItem('quarkAutoSave_fileManagerSort');
|
||
if (savedSort) {
|
||
try {
|
||
return JSON.parse(savedSort).sortOrder || 'asc';
|
||
} catch (e) {
|
||
return 'asc';
|
||
}
|
||
}
|
||
return 'asc';
|
||
})(),
|
||
// 命名模式
|
||
use_sequence_naming: false,
|
||
use_episode_naming: false,
|
||
// 正则表达式
|
||
pattern: '',
|
||
replace: '',
|
||
// 过滤关键词
|
||
filterwords: '',
|
||
// 包含文件夹
|
||
include_folders: false,
|
||
// 选中的文件
|
||
selectedFiles: [],
|
||
// 上次选中的文件索引
|
||
lastSelectedFileIndex: -1,
|
||
// 跳转到页码
|
||
gotoPage: 1
|
||
},
|
||
batchRename: {
|
||
loading: false,
|
||
results: [],
|
||
namingMode: 'regex',
|
||
errors: []
|
||
},
|
||
// 影视发现相关数据
|
||
discovery: {
|
||
hasLoaded: false, // 标记是否已完成首次加载
|
||
isInitialized: false, // 标记是否已初始化,避免页面刷新时闪烁
|
||
error: null,
|
||
items: [],
|
||
selectedMainCategory: localStorage.getItem('discovery_main_category') || 'movie_hot', // 从localStorage读取或默认选中热门电影
|
||
selectedSubCategory: localStorage.getItem('discovery_sub_category') || '全部', // 从localStorage读取或默认选中全部
|
||
mainCategories: [
|
||
{ key: 'movie_hot', name: '热门电影' },
|
||
{ key: 'movie_latest', name: '最新电影' },
|
||
{ key: 'movie_top', name: '豆瓣高分' },
|
||
{ key: 'movie_underrated', name: '冷门佳片' },
|
||
{ key: 'tv_drama', name: '热门剧集' },
|
||
{ key: 'tv_animation', name: '热门动画' },
|
||
{ key: 'tv_variety', name: '热门综艺' },
|
||
{ key: 'tv_documentary', name: '热门纪录片' }
|
||
],
|
||
subCategories: {
|
||
movie_hot: [
|
||
{ key: '全部', name: '全部' },
|
||
{ key: '华语', name: '华语' },
|
||
{ key: '欧美', name: '欧美' },
|
||
{ key: '日本', name: '日本' },
|
||
{ key: '韩国', name: '韩国' }
|
||
],
|
||
movie_latest: [
|
||
{ key: '全部', name: '全部' },
|
||
{ key: '华语', name: '华语' },
|
||
{ key: '欧美', name: '欧美' },
|
||
{ key: '日本', name: '日本' },
|
||
{ key: '韩国', name: '韩国' }
|
||
],
|
||
movie_top: [
|
||
{ key: '全部', name: '全部' },
|
||
{ key: '华语', name: '华语' },
|
||
{ key: '欧美', name: '欧美' },
|
||
{ key: '日本', name: '日本' },
|
||
{ key: '韩国', name: '韩国' }
|
||
],
|
||
movie_underrated: [
|
||
{ key: '全部', name: '全部' },
|
||
{ key: '华语', name: '华语' },
|
||
{ key: '欧美', name: '欧美' },
|
||
{ key: '日本', name: '日本' },
|
||
{ key: '韩国', name: '韩国' }
|
||
],
|
||
tv_drama: [
|
||
{ key: '综合', name: '全部' },
|
||
{ key: '国产剧', name: '华语' },
|
||
{ key: '欧美剧', name: '欧美' },
|
||
{ key: '日剧', name: '日本' },
|
||
{ key: '韩剧', name: '韩国' }
|
||
],
|
||
tv_animation: [
|
||
{ key: '动画', name: '全部' }
|
||
],
|
||
tv_variety: [
|
||
{ key: '综合', name: '全部' },
|
||
{ key: '国内', name: '国内' },
|
||
{ key: '国外', name: '国外' }
|
||
],
|
||
tv_documentary: [
|
||
{ key: '纪录片', name: '全部' }
|
||
]
|
||
}
|
||
},
|
||
// 追剧日历相关数据
|
||
calendar: {
|
||
hasLoaded: false,
|
||
error: null,
|
||
tasks: [],
|
||
taskMapByName: {},
|
||
manageMode: false,
|
||
layoutTick: 0,
|
||
contentTypes: [],
|
||
// 仅用于“已转存”判定的进度映射(来源:/task_latest_info.latest_files)
|
||
progressByTaskName: {},
|
||
progressByShowName: {},
|
||
// 当日更新数据(来源:/api/calendar/today_updates_local)
|
||
todayUpdatesByTaskName: {}, // { [task_name]: true }
|
||
todayUpdatesByShow: {}, // { [show_name]: Set(keys) },key: 'S01E02' 或 'D:YYYY-MM-DD'
|
||
// 从本地存储读取已选择的类型,默认 all
|
||
selectedType: (localStorage.getItem('calendar_selected_type') || 'all'),
|
||
nameFilter: '',
|
||
taskFilter: '',
|
||
statusFilter: (() => {
|
||
try {
|
||
return localStorage.getItem('calendar_status_filter') || '';
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
})(),
|
||
taskNames: [],
|
||
viewMode: (localStorage.getItem('calendar_view_mode') === 'month' || localStorage.getItem('calendar_view_mode') === 'poster')
|
||
? localStorage.getItem('calendar_view_mode')
|
||
: 'poster', // poster 或 month(支持持久化)
|
||
mergeEpisodes: localStorage.getItem('calendar_merge_episodes') === 'true', // 合并集模式(支持持久化)
|
||
currentDate: new Date(),
|
||
weekDates: [],
|
||
monthWeeks: [],
|
||
episodes: [],
|
||
// 选中日期(YYYY-MM-DD),默认今天
|
||
selectedDate: (function(){
|
||
const d = new Date();
|
||
const y = d.getFullYear();
|
||
const m = (d.getMonth()+1).toString().padStart(2,'0');
|
||
const day = d.getDate().toString().padStart(2,'0');
|
||
return `${y}-${m}-${day}`;
|
||
})()
|
||
},
|
||
// 任务列表:类型筛选状态
|
||
tasklist: {
|
||
selectedType: (localStorage.getItem('tasklist_selected_type') || 'all'),
|
||
contentTypes: [],
|
||
// 任务列表视图模式:list 或 poster(默认列表视图,支持持久化)
|
||
viewMode: (localStorage.getItem('tasklist_view_mode') === 'poster') ? 'poster' : 'list'
|
||
},
|
||
// 任务列表海报加载标记:taskname -> boolean(用于优先显示图片,再渐进显示其他信息)
|
||
tasklistPosterLoaded: {},
|
||
// 任务列表排序设置(记忆到localStorage)
|
||
tasklistSort: (() => {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem('tasklist_sort_options') || '{}');
|
||
let by = ['index','name','progress','update_time'].includes(saved.by) ? saved.by : 'index';
|
||
const order = (saved.order === 'asc' || saved.order === 'desc') ? saved.order : 'asc';
|
||
// 注意:这里无法检查 TMDB API 配置,会在 mounted 中处理
|
||
return { by, order };
|
||
} catch (e) {
|
||
return { by: 'index', order: 'asc' };
|
||
}
|
||
})(),
|
||
// 日历页面resize监听器
|
||
calendarResizeHandler: null,
|
||
// 日历自动检测更新相关
|
||
calendarAutoWatchTimer: null,
|
||
calendarLatestFilesSignature: '',
|
||
calendarAutoWatchTickRef: null,
|
||
calendarAutoWatchFocusHandler: null,
|
||
calendarAutoWatchVisibilityHandler: null,
|
||
// 任务列表自动检测更新相关
|
||
tasklistAutoWatchTimer: null,
|
||
tasklistLatestFilesSignature: '',
|
||
// 全局 SSE 单例与监听标志
|
||
appSSE: null,
|
||
appSSEInitialized: false,
|
||
calendarSSEListenerAdded: false,
|
||
tasklistSSEListenerAdded: false,
|
||
// 存放已绑定的事件处理器,便于避免重复绑定
|
||
onCalendarChangedHandler: null,
|
||
onTasklistChangedHandler: null,
|
||
// 兼容旧字段(不再使用独立 SSE 实例)
|
||
tasklistSSE: null,
|
||
calendarSSE: null,
|
||
// 创建任务相关数据
|
||
createTask: {
|
||
loading: false,
|
||
error: null,
|
||
movieData: null, // 存储当前要创建任务的影视作品数据
|
||
isEditMode: false, // 是否为编辑模式
|
||
editTaskIndex: null, // 编辑的任务索引
|
||
taskData: {
|
||
taskname: "",
|
||
shareurl: "",
|
||
savepath: "",
|
||
pattern: "",
|
||
replace: "",
|
||
enddate: "",
|
||
addition: {},
|
||
ignore_extension: false,
|
||
filterwords: "",
|
||
runweek: [1, 2, 3, 4, 5, 6, 7],
|
||
sequence_naming: "",
|
||
use_sequence_naming: false,
|
||
episode_naming: "",
|
||
use_episode_naming: false,
|
||
update_subdir: "",
|
||
startfid: "",
|
||
shareurl_ban: null
|
||
}
|
||
}
|
||
},
|
||
computed: {
|
||
episodePatternsText: {
|
||
get() {
|
||
if (!this.formData.episode_patterns) return '';
|
||
return this.formData.episode_patterns.map(p => p.regex || '').join('|');
|
||
},
|
||
set(value) {
|
||
// 支持竖线分割的多个正则表达式
|
||
if (!value || value.trim() === '') {
|
||
this.formData.episode_patterns = [];
|
||
return;
|
||
}
|
||
|
||
// 按竖线分割并创建多个正则表达式对象
|
||
const patterns = value.split('|').map(p => p.trim()).filter(p => p !== '');
|
||
this.formData.episode_patterns = patterns.map(regex => ({ regex }));
|
||
}
|
||
},
|
||
// 管理视图:按任务名(拼音)排序并应用顶部筛选
|
||
managementTasksFiltered() {
|
||
if (!this.calendar.tasks || this.calendar.tasks.length === 0) return [];
|
||
let list = this.calendar.tasks.slice();
|
||
// 顶部筛选:类型
|
||
if (this.calendar.selectedType && this.calendar.selectedType !== 'all') {
|
||
list = list.filter(t => (t.content_type || 'other') === this.calendar.selectedType);
|
||
}
|
||
// 顶部筛选:名称关键词(任务名或剧名)
|
||
if (this.calendar.nameFilter && this.calendar.nameFilter.trim() !== '') {
|
||
const kw = this.calendar.nameFilter.toLowerCase();
|
||
list = list.filter(t => (t.task_name || '').toLowerCase().includes(kw) || (t.show_name || '').toLowerCase().includes(kw));
|
||
}
|
||
// 顶部筛选:任务筛选(精确任务名)
|
||
if (this.calendar.taskFilter && this.calendar.taskFilter.trim() !== '') {
|
||
list = list.filter(t => (t.task_name || '') === this.calendar.taskFilter);
|
||
}
|
||
if (this.calendar.statusFilter && this.calendar.statusFilter.trim() !== '') {
|
||
list = list.filter(t => this.filterTaskByStatus(t, this.calendar.statusFilter));
|
||
}
|
||
// 按匹配状态和任务名称拼音排序:匹配的项目在前,未匹配的项目在后
|
||
try {
|
||
list.sort((a, b) => {
|
||
// 首先按匹配状态排序:匹配的在前,未匹配的在后
|
||
const aMatched = !!(a.matched_show_name && a.matched_show_name.trim() !== '');
|
||
const bMatched = !!(b.matched_show_name && b.matched_show_name.trim() !== '');
|
||
|
||
if (aMatched !== bMatched) {
|
||
return aMatched ? -1 : 1; // 匹配的排在前面
|
||
}
|
||
|
||
// 匹配状态相同时,按任务名称拼音排序
|
||
const aKey = pinyinPro.pinyin(a.task_name || '', { toneType: 'none', type: 'string' }).toLowerCase();
|
||
const bKey = pinyinPro.pinyin(b.task_name || '', { toneType: 'none', type: 'string' }).toLowerCase();
|
||
if (aKey < bKey) return -1; if (aKey > bKey) return 1; return 0;
|
||
});
|
||
} catch (e) {}
|
||
return list;
|
||
},
|
||
|
||
// 任务列表:带排序的视图数据(使用计算属性,不修改原始数据)
|
||
sortedTasklist() {
|
||
return this.getSortedTasklist();
|
||
},
|
||
|
||
// 任务列表:可见任务集合(用于数量展示)
|
||
tasklistVisibleTasks() {
|
||
try {
|
||
if (!this.shouldShowTasklist) {
|
||
return [];
|
||
}
|
||
const keyword = this.taskNameFilter || '';
|
||
const selectedTaskName = this.taskDirSelected || '';
|
||
return (this.sortedTasklist || []).filter(task => {
|
||
if (!task) return false;
|
||
const taskName = task.taskname || '';
|
||
if (selectedTaskName && taskName !== selectedTaskName) {
|
||
return false;
|
||
}
|
||
if (keyword && !taskName.includes(keyword)) {
|
||
return false;
|
||
}
|
||
if (!this.tasklistFilterByType(task)) {
|
||
return false;
|
||
}
|
||
return this.tasklistFilterByStatus(task);
|
||
});
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
},
|
||
|
||
// 任务列表:当前可见任务数量
|
||
tasklistVisibleCount() {
|
||
try {
|
||
return this.tasklistVisibleTasks.length;
|
||
} catch (e) {
|
||
return 0;
|
||
}
|
||
},
|
||
|
||
// 判断是否应该显示任务列表(避免排序闪烁)
|
||
shouldShowTasklist() {
|
||
const { by } = this.tasklistSort || { by: 'index' };
|
||
|
||
// 任务编号和任务名称排序不需要额外数据,可以直接显示
|
||
if (by === 'index' || by === 'name') {
|
||
return true;
|
||
}
|
||
|
||
// 任务进度排序需要 taskLatestFiles 和 calendar.tasks 数据
|
||
if (by === 'progress') {
|
||
return this.taskLatestFiles && Object.keys(this.taskLatestFiles).length > 0 &&
|
||
this.calendar.tasks && Array.isArray(this.calendar.tasks) && this.calendar.tasks.length > 0;
|
||
}
|
||
|
||
// 更新时间排序需要 taskLatestRecords 数据
|
||
if (by === 'update_time') {
|
||
return this.taskLatestRecords && Object.keys(this.taskLatestRecords).length > 0;
|
||
}
|
||
|
||
// 默认显示
|
||
return true;
|
||
},
|
||
|
||
// 海报和日历视图下过滤掉所有内容都是未匹配的分类
|
||
filteredContentTypes() {
|
||
if (!this.calendar.tasks || this.calendar.tasks.length === 0) {
|
||
return this.calendar.contentTypes;
|
||
}
|
||
|
||
// 只在海报和日历视图下进行过滤
|
||
if (this.calendar.manageMode) {
|
||
return this.calendar.contentTypes;
|
||
}
|
||
|
||
return this.calendar.contentTypes.filter(type => {
|
||
// 获取该分类下的所有任务
|
||
const typeTasks = this.calendar.tasks.filter(task => (task.content_type || 'other') === type);
|
||
|
||
// 如果该分类下没有任务,隐藏该分类按钮
|
||
if (typeTasks.length === 0) {
|
||
return false;
|
||
}
|
||
|
||
// 检查该分类下是否有匹配的任务
|
||
const hasMatchedTask = typeTasks.some(task =>
|
||
task.matched_show_name && task.matched_show_name.trim() !== ''
|
||
);
|
||
|
||
// 如果有匹配的任务,保留该分类按钮
|
||
return hasMatchedTask;
|
||
});
|
||
},
|
||
|
||
// 管理视图:将过滤后的任务按当前海报列数进行分栏,复用相同布局
|
||
managementColumns() {
|
||
const tasks = this.managementTasksFiltered;
|
||
if (!tasks || tasks.length === 0) return [];
|
||
// 依赖布局tick以在窗口变化时强制重新计算
|
||
void this.calendar.layoutTick;
|
||
// 与周视图一致的列数推导逻辑
|
||
const availableWidth = this.getCalendarAvailableWidth ? this.getCalendarAvailableWidth() : 1200;
|
||
const minColumnWidth = 140;
|
||
const columnGap = 20;
|
||
// 列数计算与周视图一致,加入 eps 避免边界像素/滚动条导致的多列
|
||
const eps = 0.1;
|
||
// 桌面端也允许最少 2 列,避免在某些宽度下被强制多出一列
|
||
const columns = Math.max(2, Math.floor((availableWidth + columnGap - eps) / (minColumnWidth + columnGap)));
|
||
const cols = Array.from({ length: columns }, () => []);
|
||
// 简单逐列分配,保证与日历海报列宽/间距一致
|
||
tasks.forEach((t, idx) => {
|
||
cols[idx % columns].push(t);
|
||
});
|
||
return cols;
|
||
},
|
||
// 基于已加载的日历剧集构建:show_name -> poster_local_path 映射
|
||
posterByShowName() {
|
||
const map = {};
|
||
try {
|
||
(this.calendar.episodes || []).forEach(ep => {
|
||
const key = (ep && ep.show_name) ? String(ep.show_name).trim() : '';
|
||
const poster = ep && ep.poster_local_path;
|
||
if (key && poster && !map[key]) map[key] = poster;
|
||
});
|
||
} catch (e) {}
|
||
return map;
|
||
},
|
||
|
||
|
||
filteredHistoryRecords() {
|
||
if (!this.history.records || this.history.records.length === 0) {
|
||
return [];
|
||
}
|
||
return this.history.records;
|
||
},
|
||
historyTasks() {
|
||
// 如果已经通过API加载了所有任务名称,则使用它
|
||
if (this.allTaskNames && this.allTaskNames.length > 0) {
|
||
return this.allTaskNames;
|
||
}
|
||
|
||
// 否则,从当前页的历史记录中提取唯一的任务名称(后备方案)
|
||
if (!this.history.records || this.history.records.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const taskNames = new Set();
|
||
this.history.records.forEach(record => {
|
||
if (record.task_name) {
|
||
taskNames.add(record.task_name);
|
||
}
|
||
});
|
||
|
||
return this.sortTaskNamesByPinyin([...taskNames]);
|
||
},
|
||
taskNames() {
|
||
// 从任务列表中提取唯一的任务名称
|
||
if (!this.formData.tasklist || this.formData.tasklist.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const taskNames = new Set();
|
||
this.formData.tasklist.forEach(task => {
|
||
if (task.taskname) {
|
||
taskNames.add(task.taskname);
|
||
}
|
||
});
|
||
|
||
return this.sortTaskNamesByPinyin([...taskNames]);
|
||
},
|
||
filteredRuntimeLogs() {
|
||
try {
|
||
let logs = Array.isArray(this.runtimeLogs) ? this.runtimeLogs.slice() : [];
|
||
const keyword = (this.runtimeLogFilters.keyword || '').trim().toLowerCase();
|
||
const taskName = (this.runtimeLogFilters.task || '').trim();
|
||
const level = (this.runtimeLogFilters.level || '').trim().toUpperCase();
|
||
|
||
// 如果指定了任务筛选,需要按任务块进行完整筛选
|
||
if (taskName) {
|
||
// 首先解析所有任务块
|
||
const taskBlocks = this.parseTaskBlocks(logs);
|
||
|
||
// 找出所有匹配任务名称的任务块索引
|
||
const matchedBlockIndices = new Set();
|
||
taskBlocks.forEach((block, index) => {
|
||
if (block.taskName === taskName) {
|
||
matchedBlockIndices.add(index);
|
||
}
|
||
});
|
||
|
||
// 筛选出所有匹配任务块的日志行
|
||
const filteredLogs = [];
|
||
logs.forEach((log, logIndex) => {
|
||
const blockIndex = taskBlocks.findIndex(block =>
|
||
logIndex >= block.startIndex && logIndex <= block.endIndex
|
||
);
|
||
if (blockIndex !== -1 && matchedBlockIndices.has(blockIndex)) {
|
||
filteredLogs.push(log);
|
||
}
|
||
});
|
||
|
||
logs = filteredLogs;
|
||
}
|
||
|
||
// 关键词筛选(在任务块筛选之后进行)
|
||
if (keyword) {
|
||
logs = logs.filter(log => {
|
||
const text = this.getRuntimeLogText(log).toLowerCase();
|
||
return text.includes(keyword);
|
||
});
|
||
}
|
||
|
||
// 日志级别筛选
|
||
if (level) {
|
||
logs = logs.filter(log => (log.level || '').toUpperCase() === level);
|
||
}
|
||
|
||
// 移动端(窄屏设备):倒序显示,让最新日志在顶部
|
||
const isMobile = window.innerWidth <= 768;
|
||
if (isMobile) {
|
||
logs = logs.reverse();
|
||
}
|
||
return logs;
|
||
} catch (e) {
|
||
console.error('筛选运行日志时出错:', e);
|
||
return this.runtimeLogs || [];
|
||
}
|
||
},
|
||
totalPages() {
|
||
// 直接使用后端返回的total_pages
|
||
if (this.history.pagination && this.history.pagination.total_pages) {
|
||
return parseInt(this.history.pagination.total_pages);
|
||
}
|
||
|
||
// 后备方案
|
||
return 1;
|
||
},
|
||
displayedPages() {
|
||
// 显示有限数量的页码,最多显示5个页码
|
||
const pageCount = this.totalPages;
|
||
const currentPage = this.historyParams.page;
|
||
|
||
if (pageCount <= 5) {
|
||
// 页数少于等于5,显示所有页码
|
||
return Array.from({ length: pageCount }, (_, i) => i + 1);
|
||
} else {
|
||
// 页数多于5,显示当前页附近的页码
|
||
const pages = [];
|
||
let startPage = Math.max(currentPage - 2, 1);
|
||
let endPage = Math.min(startPage + 4, pageCount);
|
||
|
||
if (endPage - startPage < 4) {
|
||
startPage = Math.max(endPage - 4, 1);
|
||
}
|
||
|
||
for (let i = startPage; i <= endPage; i++) {
|
||
pages.push(i);
|
||
}
|
||
|
||
return pages;
|
||
}
|
||
},
|
||
// 影视发现计算属性
|
||
currentSubCategories() {
|
||
return this.discovery.subCategories[this.discovery.selectedMainCategory] || [];
|
||
}
|
||
},
|
||
filters: {
|
||
ts2date: function (value) {
|
||
const date = new Date(value);
|
||
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||
},
|
||
size: function (value) {
|
||
if (!value) return "";
|
||
const unitArr = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||
const srcsize = parseFloat(value);
|
||
const index = srcsize ? Math.floor(Math.log(srcsize) / Math.log(1024)) : 0;
|
||
const size = (srcsize / Math.pow(1024, index)).toFixed(1).replace(/\.?0+$/, "");
|
||
return size + " " + unitArr[index];
|
||
}
|
||
},
|
||
watch: {
|
||
formData: {
|
||
handler(newVal, oldVal) {
|
||
if (this.suppressConfigModifiedOnce) {
|
||
// 消费一次抑制标志,不触发未保存提示
|
||
this.suppressConfigModifiedOnce = false;
|
||
} else {
|
||
this.configModified = true;
|
||
}
|
||
},
|
||
deep: true
|
||
},
|
||
// 名称筛选变化时重建月视图
|
||
'calendar.nameFilter': function(newVal, oldVal) {
|
||
if (this.calendar.viewMode === 'month') {
|
||
this.initializeCalendarDates();
|
||
}
|
||
},
|
||
// 任务筛选变化时重建月视图
|
||
'calendar.taskFilter': function(newVal, oldVal) {
|
||
if (this.calendar.viewMode === 'month') {
|
||
this.initializeCalendarDates();
|
||
}
|
||
},
|
||
'calendar.statusFilter': function(newVal, oldVal) {
|
||
if (this.calendar.viewMode === 'month') {
|
||
this.initializeCalendarDates();
|
||
}
|
||
try {
|
||
localStorage.setItem('calendar_status_filter', newVal || '');
|
||
} catch (e) {}
|
||
},
|
||
taskStatusFilter(newValue) {
|
||
try {
|
||
localStorage.setItem('tasklist_status_filter', newValue || '');
|
||
} catch (e) {}
|
||
},
|
||
// 侧边栏折叠/展开变化时,触发布局重算
|
||
sidebarCollapsed(val) {
|
||
if (this.activeTab === 'calendar') {
|
||
if (this.calendar.manageMode) {
|
||
this.calendar.layoutTick = Date.now();
|
||
} else if (this.calendar.viewMode === 'poster') {
|
||
this.updateWeekDates();
|
||
}
|
||
}
|
||
},
|
||
// 页面宽度模式变化(窄/中/宽),触发布局重算
|
||
pageWidthMode(val) {
|
||
if (this.activeTab === 'calendar') {
|
||
if (this.calendar.manageMode) {
|
||
this.calendar.layoutTick = Date.now();
|
||
} else if (this.calendar.viewMode === 'poster') {
|
||
this.updateWeekDates();
|
||
}
|
||
}
|
||
},
|
||
|
||
historyNameFilter: {
|
||
handler(newVal, oldVal) {
|
||
// 延迟加载,避免频繁请求
|
||
if (this._filterTimeout) {
|
||
clearTimeout(this._filterTimeout);
|
||
}
|
||
this._filterTimeout = setTimeout(() => {
|
||
this.loadHistoryRecords();
|
||
}, 500);
|
||
}
|
||
},
|
||
historyTaskSelected: {
|
||
handler(newVal, oldVal) {
|
||
// 选择任务筛选条件后立即加载数据
|
||
this.loadHistoryRecords();
|
||
}
|
||
},
|
||
historyStatusFilter: {
|
||
handler(newVal) {
|
||
try {
|
||
localStorage.setItem('history_status_filter', newVal || '');
|
||
} catch (e) {}
|
||
this.loadHistoryRecords();
|
||
}
|
||
},
|
||
'runtimeLogFilters.task': function(newVal, oldVal) {
|
||
const viewport = this.$refs.runtimeLogViewport;
|
||
if (!viewport) return;
|
||
|
||
// 当应用任务筛选时(从空变为有值),保存当前的滚动位置
|
||
if (!oldVal && newVal) {
|
||
this.runtimeLogScrollPositionBeforeTaskFilter = {
|
||
scrollTop: viewport.scrollTop,
|
||
scrollHeight: viewport.scrollHeight
|
||
};
|
||
}
|
||
// 当清空任务筛选时(从有值变为空),恢复之前保存的滚动位置
|
||
else if (oldVal && !newVal && this.runtimeLogScrollPositionBeforeTaskFilter) {
|
||
this.$nextTick(() => {
|
||
const savedPosition = this.runtimeLogScrollPositionBeforeTaskFilter;
|
||
if (savedPosition && viewport && savedPosition.scrollHeight > 0) {
|
||
// 计算滚动位置的相对比例,以适应内容高度可能的变化
|
||
const ratio = savedPosition.scrollTop / savedPosition.scrollHeight;
|
||
const newScrollTop = viewport.scrollHeight * ratio;
|
||
// 确保滚动位置在有效范围内
|
||
viewport.scrollTop = Math.max(0, Math.min(newScrollTop, viewport.scrollHeight - viewport.clientHeight));
|
||
// 清空保存的位置
|
||
this.runtimeLogScrollPositionBeforeTaskFilter = null;
|
||
} else if (savedPosition && viewport) {
|
||
// 如果保存的 scrollHeight 为 0,直接使用保存的 scrollTop(如果有效)
|
||
if (savedPosition.scrollTop >= 0) {
|
||
viewport.scrollTop = Math.min(savedPosition.scrollTop, viewport.scrollHeight);
|
||
}
|
||
this.runtimeLogScrollPositionBeforeTaskFilter = null;
|
||
}
|
||
});
|
||
}
|
||
},
|
||
'runtimeLogFilters.level': function(newVal, oldVal) {
|
||
const viewport = this.$refs.runtimeLogViewport;
|
||
if (!viewport) return;
|
||
|
||
// 当应用级别筛选时(从空变为有值),保存当前的滚动位置
|
||
if (!oldVal && newVal) {
|
||
this.runtimeLogScrollPositionBeforeLevelFilter = {
|
||
scrollTop: viewport.scrollTop,
|
||
scrollHeight: viewport.scrollHeight
|
||
};
|
||
}
|
||
// 当清空级别筛选时(从有值变为空),恢复之前保存的滚动位置
|
||
else if (oldVal && !newVal && this.runtimeLogScrollPositionBeforeLevelFilter) {
|
||
this.$nextTick(() => {
|
||
const savedPosition = this.runtimeLogScrollPositionBeforeLevelFilter;
|
||
if (savedPosition && viewport && savedPosition.scrollHeight > 0) {
|
||
// 计算滚动位置的相对比例,以适应内容高度可能的变化
|
||
const ratio = savedPosition.scrollTop / savedPosition.scrollHeight;
|
||
const newScrollTop = viewport.scrollHeight * ratio;
|
||
// 确保滚动位置在有效范围内
|
||
viewport.scrollTop = Math.max(0, Math.min(newScrollTop, viewport.scrollHeight - viewport.clientHeight));
|
||
// 清空保存的位置
|
||
this.runtimeLogScrollPositionBeforeLevelFilter = null;
|
||
} else if (savedPosition && viewport) {
|
||
// 如果保存的 scrollHeight 为 0,直接使用保存的 scrollTop(如果有效)
|
||
if (savedPosition.scrollTop >= 0) {
|
||
viewport.scrollTop = Math.min(savedPosition.scrollTop, viewport.scrollHeight);
|
||
}
|
||
this.runtimeLogScrollPositionBeforeLevelFilter = null;
|
||
}
|
||
});
|
||
}
|
||
},
|
||
'runtimeLogFilters.keyword': function(newVal, oldVal) {
|
||
const viewport = this.$refs.runtimeLogViewport;
|
||
if (!viewport) return;
|
||
|
||
// 当应用内容筛选时(从空变为有值),保存当前的滚动位置
|
||
if (!oldVal && newVal) {
|
||
this.runtimeLogScrollPositionBeforeKeywordFilter = {
|
||
scrollTop: viewport.scrollTop,
|
||
scrollHeight: viewport.scrollHeight
|
||
};
|
||
}
|
||
// 当清空内容筛选时(从有值变为空),恢复之前保存的滚动位置
|
||
else if (oldVal && !newVal && this.runtimeLogScrollPositionBeforeKeywordFilter) {
|
||
this.$nextTick(() => {
|
||
const savedPosition = this.runtimeLogScrollPositionBeforeKeywordFilter;
|
||
if (savedPosition && viewport && savedPosition.scrollHeight > 0) {
|
||
// 计算滚动位置的相对比例,以适应内容高度可能的变化
|
||
const ratio = savedPosition.scrollTop / savedPosition.scrollHeight;
|
||
const newScrollTop = viewport.scrollHeight * ratio;
|
||
// 确保滚动位置在有效范围内
|
||
viewport.scrollTop = Math.max(0, Math.min(newScrollTop, viewport.scrollHeight - viewport.clientHeight));
|
||
// 清空保存的位置
|
||
this.runtimeLogScrollPositionBeforeKeywordFilter = null;
|
||
} else if (savedPosition && viewport) {
|
||
// 如果保存的 scrollHeight 为 0,直接使用保存的 scrollTop(如果有效)
|
||
if (savedPosition.scrollTop >= 0) {
|
||
viewport.scrollTop = Math.min(savedPosition.scrollTop, viewport.scrollHeight);
|
||
}
|
||
this.runtimeLogScrollPositionBeforeKeywordFilter = null;
|
||
}
|
||
});
|
||
}
|
||
},
|
||
activeTab(newValue, oldValue) {
|
||
if (newValue === 'runlogs') {
|
||
// 如果已经有日志,立即显示并滚动到底部,避免空白
|
||
const hasLogs = Array.isArray(this.runtimeLogs) && this.runtimeLogs.length > 0;
|
||
if (hasLogs && !this.runtimeLogInitialized) {
|
||
this.runtimeLogInitialized = true;
|
||
this.$nextTick(() => {
|
||
const viewport = this.$refs.runtimeLogViewport;
|
||
if (viewport) {
|
||
viewport.scrollTop = viewport.scrollHeight;
|
||
}
|
||
});
|
||
}
|
||
this.startRuntimeLogPolling();
|
||
} else if (oldValue === 'runlogs') {
|
||
this.stopRuntimeLogPolling();
|
||
// 离开运行日志页面时重置初始化标志,下次进入时会重新初始化
|
||
this.runtimeLogInitialized = false;
|
||
}
|
||
// 如果切换到任务列表页面,则刷新任务最新信息和元数据
|
||
if (newValue === 'tasklist') {
|
||
// 确保全局 SSE 已建立
|
||
try { this.ensureGlobalSSE(); } catch (e) {}
|
||
this.loadTaskLatestInfo();
|
||
this.loadTasklistMetadata();
|
||
// 启动任务列表的后台监听
|
||
this.startTasklistAutoWatch();
|
||
} else if (oldValue === 'tasklist') {
|
||
// 离开任务列表页面时停止后台监听
|
||
this.stopTasklistAutoWatch();
|
||
}
|
||
// 切换到追剧日历:立刻检查一次并启动后台监听;离开则停止监听
|
||
if (newValue === 'calendar') {
|
||
// 确保全局 SSE 已建立
|
||
try { this.ensureGlobalSSE(); } catch (e) {}
|
||
// 立即检查一次(若已初始化过监听,直接调用tick引用)
|
||
// 先本地读取一次,立刻应用“已转存”状态(不依赖轮询)
|
||
try { this.loadCalendarEpisodesLocal && this.loadCalendarEpisodesLocal(); } catch (e) {}
|
||
// 先启动监听以确保 tickRef 已就绪
|
||
this.startCalendarAutoWatch();
|
||
// 重置签名,确保本次切换能触发一次增量检查
|
||
this.calendarLatestFilesSignature = '';
|
||
// 进入页面即刻用现有的最近文件重建一次进度映射,避免UI滞后
|
||
try {
|
||
this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(this.taskLatestFiles || {});
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
} catch (e) {}
|
||
// 立刻触发一次增量检查与热更新(无需等待下一轮定时器)
|
||
if (this.calendarAutoWatchTickRef) {
|
||
this.calendarAutoWatchTickRef();
|
||
}
|
||
// 离开再返回时恢复管理模式记忆
|
||
try {
|
||
const savedManage = localStorage.getItem('calendar_manage_mode');
|
||
if (savedManage === 'true') this.calendar.manageMode = true;
|
||
} catch (e) {}
|
||
} else if (oldValue === 'calendar') {
|
||
this.stopCalendarAutoWatch();
|
||
}
|
||
// 如果切换到文件整理页面,则加载文件列表
|
||
if (newValue === 'filemanager') {
|
||
this.fetchAccountsDetail();
|
||
this.loadFileListWithoutLoading(this.fileManager.currentFolder);
|
||
}
|
||
// 如果切换到影视发现页面,则加载榜单数据
|
||
if (newValue === 'discovery') {
|
||
this.loadDiscoveryData();
|
||
}
|
||
},
|
||
'fileManager.pattern': {
|
||
handler(newValue, oldValue) {
|
||
// 自动检测并切换命名模式
|
||
this.detectFileManagerNamingMode();
|
||
}
|
||
}
|
||
},
|
||
mounted() {
|
||
this.fetchData();
|
||
this.checkNewVersion();
|
||
this.fetchUserInfo(); // 获取用户信息
|
||
this.fetchAccountsDetail(); // 获取账号详细信息
|
||
|
||
// 应用级别:在挂载时确保全局 SSE 建立一次
|
||
try { this.ensureGlobalSSE(); } catch (e) {}
|
||
|
||
// 迁移旧的localStorage数据到新格式(为每个账号单独存储目录)
|
||
this.migrateFileManagerFolderData();
|
||
|
||
// 添加点击事件监听
|
||
document.addEventListener('click', this.handleOutsideClick);
|
||
document.addEventListener('click', this.handleModalOutsideClick);
|
||
document.addEventListener('click', this.handleFileManagerOutsideClick);
|
||
|
||
// 添加模态框关闭事件监听
|
||
$('#fileSelectModal').on('hidden.bs.modal', () => {
|
||
this.fileSelect.selectedFiles = [];
|
||
this.fileSelect.lastSelectedFileIndex = -1;
|
||
// 重置移动模式相关参数
|
||
this.fileSelect.moveMode = false;
|
||
this.fileSelect.moveFileIds = [];
|
||
// 重置z-index
|
||
document.getElementById('fileSelectModal').style.zIndex = '';
|
||
});
|
||
|
||
// 检查本地存储中的标签页状态
|
||
const savedTab = localStorage.getItem('quarkAutoSave_activeTab');
|
||
if (savedTab) {
|
||
this.activeTab = savedTab;
|
||
}
|
||
|
||
// 从本地存储中恢复侧边栏折叠状态
|
||
const savedSidebarState = localStorage.getItem('quarkAutoSave_sidebarCollapsed');
|
||
if (savedSidebarState) {
|
||
this.sidebarCollapsed = savedSidebarState === 'true';
|
||
}
|
||
|
||
// 从本地存储中恢复用户设置的每页记录数
|
||
const savedPageSize = localStorage.getItem('quarkAutoSave_pageSize');
|
||
if (savedPageSize) {
|
||
this.historyParams.page_size = savedPageSize === 'all' ? 99999 : parseInt(savedPageSize);
|
||
}
|
||
|
||
// 从本地存储中恢复文件管理器的每页显示数
|
||
const savedFileManagerPageSize = localStorage.getItem('quarkAutoSave_fileManagerPageSize');
|
||
if (savedFileManagerPageSize) {
|
||
this.fileManager.pageSize = savedFileManagerPageSize === 'all' ? 99999 : parseInt(savedFileManagerPageSize);
|
||
}
|
||
|
||
// 从本地存储中恢复页面宽度设置
|
||
const savedPageWidthMode = localStorage.getItem('quarkAutoSave_pageWidthMode');
|
||
if (savedPageWidthMode) {
|
||
this.pageWidthMode = savedPageWidthMode;
|
||
document.body.classList.add('page-width-' + this.pageWidthMode);
|
||
} else {
|
||
// 默认使用中等宽度
|
||
document.body.classList.add('page-width-medium');
|
||
}
|
||
|
||
$('[data-toggle="tooltip"]').tooltip();
|
||
// 窗口尺寸变化时,及时重算追剧日历布局,避免列数滞后
|
||
this._onCalendarResize = this.debounce(() => {
|
||
if (this.activeTab === 'calendar') {
|
||
this.calendar.layoutTick = Date.now();
|
||
this.initializeCalendarDates();
|
||
}
|
||
}, 80);
|
||
window.addEventListener('resize', this._onCalendarResize);
|
||
window.addEventListener('orientationchange', this._onCalendarResize);
|
||
// 使用 ResizeObserver 监听日历根容器尺寸变化,实时刷新列数
|
||
if (window.ResizeObserver) {
|
||
this._calendarResizeObserver = new ResizeObserver(() => {
|
||
if (this.activeTab === 'calendar') {
|
||
this._onCalendarResize();
|
||
}
|
||
});
|
||
if (this.$refs && this.$refs.calendarRoot) {
|
||
this._calendarResizeObserver.observe(this.$refs.calendarRoot);
|
||
}
|
||
}
|
||
document.addEventListener('keydown', this.handleKeyDown);
|
||
document.addEventListener('click', (e) => {
|
||
// 如果点击的是输入框、搜索按钮或下拉菜单本身,不关闭下拉菜单
|
||
if (e.target.closest('.input-group input') ||
|
||
e.target.closest('.btn-primary[type="button"]') ||
|
||
e.target.closest('.dropdown-menu.task-suggestions') ||
|
||
e.target.closest('.bi-search') ||
|
||
// 新增:点击发生在文件选择模态框内(包括右上角关闭按钮)时,不关闭下拉
|
||
e.target.closest('#fileSelectModal')) {
|
||
return;
|
||
}
|
||
// 只隐藏下拉菜单,不清空搜索结果,这样点击同一任务的输入框时还能看到之前的搜索结果
|
||
this.smart_param.showSuggestions = false;
|
||
});
|
||
|
||
// 添加点击事件监听器,用于在点击表格外区域时取消选择记录
|
||
document.addEventListener('click', this.handleOutsideClick);
|
||
|
||
// 添加点击事件监听器,用于在点击模态框表格外区域时取消选择文件
|
||
document.addEventListener('click', this.handleModalOutsideClick);
|
||
|
||
// 添加模态框关闭事件监听,清空选中文件列表
|
||
$('#fileSelectModal').on('hidden.bs.modal', () => {
|
||
this.fileSelect.selectedFiles = [];
|
||
this.fileSelect.lastSelectedFileIndex = -1;
|
||
// 重置移动模式相关参数
|
||
this.fileSelect.moveMode = false;
|
||
this.fileSelect.moveFileIds = [];
|
||
// 重置z-index
|
||
document.getElementById('fileSelectModal').style.zIndex = '';
|
||
// 重置资源浏览索引
|
||
this.smart_param.currentResourceIndex = -1;
|
||
});
|
||
|
||
window.addEventListener('beforeunload', this.handleBeforeUnload);
|
||
|
||
// 监听模态框显示事件,检查滚动条状态
|
||
$('#fileSelectModal').on('shown.bs.modal', this.checkPreviewScrollbar);
|
||
// 监听窗口大小改变,重新检查滚动条状态
|
||
window.addEventListener('resize', this.checkPreviewScrollbar);
|
||
|
||
// 初始化时检查所有任务的命名模式
|
||
setTimeout(() => {
|
||
if (this.formData.tasklist && this.formData.tasklist.length > 0) {
|
||
this.formData.tasklist.forEach(task => {
|
||
// 检查现有的顺序命名设置
|
||
if (task.use_sequence_naming && task.sequence_naming) {
|
||
// 已经设置过顺序命名的,将顺序命名模式转换为匹配表达式
|
||
if (!task.pattern || task._pattern_backup) {
|
||
task.pattern = task.sequence_naming;
|
||
}
|
||
} else if (task.use_episode_naming && task.episode_naming) {
|
||
// 已经设置过剧集命名的,将剧集命名模式转换为匹配表达式
|
||
if (!task.pattern || task._pattern_backup) {
|
||
task.pattern = task.episode_naming;
|
||
}
|
||
} else {
|
||
// 检测是否包含顺序命名或剧集命名模式
|
||
this.detectNamingMode(task);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 确保剧集识别模式字段存在(但不自动添加默认规则)
|
||
if (!this.formData.episode_patterns) {
|
||
this.formData.episode_patterns = [];
|
||
}
|
||
|
||
// 如果当前标签是历史记录,则加载历史记录
|
||
if (this.activeTab === 'history') {
|
||
this.loadHistoryRecords();
|
||
// 加载所有任务名称用于筛选
|
||
this.loadAllTaskNames();
|
||
}
|
||
|
||
// 如果当前标签是文件整理,则加载文件列表
|
||
if (this.activeTab === 'filemanager') {
|
||
this.fetchAccountsDetail();
|
||
this.loadFileListWithoutLoading(this.fileManager.currentFolder);
|
||
}
|
||
|
||
// 添加对history.pagination的监听
|
||
this.$watch('history.pagination', function(newVal) {
|
||
if (newVal && newVal.total_pages) {
|
||
this.$nextTick(() => {
|
||
// 强制Vue更新视图
|
||
this.$forceUpdate();
|
||
});
|
||
}
|
||
}, { deep: true });
|
||
|
||
// 检查分享链接状态
|
||
this.checkShareUrlStatus();
|
||
|
||
// 添加移动端任务列表展开/收起状态监听
|
||
this.setupMobileTaskListToggle();
|
||
}, 500);
|
||
if (this.activeTab === 'runlogs') {
|
||
this.startRuntimeLogPolling();
|
||
}
|
||
},
|
||
beforeDestroy() {
|
||
window.removeEventListener('beforeunload', this.handleBeforeUnload);
|
||
if (this._onCalendarResize) {
|
||
window.removeEventListener('resize', this._onCalendarResize);
|
||
window.removeEventListener('orientationchange', this._onCalendarResize);
|
||
}
|
||
if (this._calendarResizeObserver && this.$refs && this.$refs.calendarRoot) {
|
||
this._calendarResizeObserver.unobserve(this.$refs.calendarRoot);
|
||
this._calendarResizeObserver.disconnect();
|
||
this._calendarResizeObserver = null;
|
||
}
|
||
// 移除点击事件监听器
|
||
document.removeEventListener('click', this.handleOutsideClick);
|
||
// 清理日历后台监听
|
||
this.stopCalendarAutoWatch();
|
||
this.stopRuntimeLogPolling();
|
||
},
|
||
methods: {
|
||
// 计算来源徽标 class:多来源时使用 multi-source,单来源使用小写来源名
|
||
getSourceBadgeClass(source) {
|
||
try {
|
||
if (!source) return '';
|
||
if (typeof source === 'string' && source.indexOf('·') !== -1) return 'multi-source';
|
||
return String(source).toLowerCase();
|
||
} catch (e) { return ''; }
|
||
},
|
||
// 启动任务列表轮询兜底(仅在未运行时启动)
|
||
startTasklistPollingFallback() {
|
||
try {
|
||
if (!this.tasklistAutoWatchTimer) {
|
||
this.tasklistAutoWatchTimer = setInterval(async () => {
|
||
try {
|
||
const res = await axios.get('/task_latest_info');
|
||
if (res.data && res.data.success) {
|
||
const latestFiles = res.data.data.latest_files || {};
|
||
const sig = this.calcLatestFilesSignature(latestFiles);
|
||
if (sig && sig !== this.tasklistLatestFilesSignature) {
|
||
// 更新签名,触发热更新
|
||
this.tasklistLatestFilesSignature = sig;
|
||
this.taskLatestFiles = latestFiles;
|
||
// 重新加载任务元数据,确保海报和元数据能热更新
|
||
await this.loadTasklistMetadata();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('任务列表后台监听检查失败:', e);
|
||
}
|
||
}, 60000);
|
||
}
|
||
} catch (e) {}
|
||
},
|
||
// 确保全局 SSE 单例存在(仅建立一次)
|
||
ensureGlobalSSE() {
|
||
try {
|
||
if (this.appSSEInitialized && this.appSSE) return;
|
||
if (!this.appSSE) {
|
||
this.appSSE = new EventSource('/api/calendar/stream');
|
||
}
|
||
this.appSSEInitialized = true;
|
||
// 统一 onopen:SSE 成功后停止两侧轮询
|
||
this.appSSE.onopen = () => {
|
||
try {
|
||
if (this.calendarAutoWatchTimer) {
|
||
clearInterval(this.calendarAutoWatchTimer);
|
||
this.calendarAutoWatchTimer = null;
|
||
}
|
||
} catch (e) {}
|
||
try {
|
||
if (this.tasklistAutoWatchTimer) {
|
||
clearInterval(this.tasklistAutoWatchTimer);
|
||
this.tasklistAutoWatchTimer = null;
|
||
}
|
||
} catch (e) {}
|
||
};
|
||
// 统一 onerror:关闭SSE并回退双侧轮询
|
||
this.appSSE.onerror = () => {
|
||
try { this.appSSE.close(); } catch (e) {}
|
||
this.appSSE = null;
|
||
this.appSSEInitialized = false;
|
||
// 日历回退:若没有轮询定时器,则恢复轮询并立即执行一次
|
||
try {
|
||
if (!this.calendarAutoWatchTimer && this.calendarAutoWatchTickRef) {
|
||
const baseIntervalMs = this.calendar && this.calendar.manageMode ? 5 * 1000 : 60 * 1000;
|
||
this.calendarAutoWatchTimer = setInterval(this.calendarAutoWatchTickRef, baseIntervalMs);
|
||
this.calendarAutoWatchTickRef();
|
||
}
|
||
} catch (e) {}
|
||
// 任务列表回退:若没有轮询定时器,则恢复轮询
|
||
try {
|
||
if (!this.tasklistAutoWatchTimer) {
|
||
this.startTasklistPollingFallback();
|
||
}
|
||
} catch (e) {}
|
||
};
|
||
// ping 心跳占位
|
||
try { this.appSSE.addEventListener('ping', () => {}); } catch (e) {}
|
||
} catch (e) {
|
||
// 忽略失败,后续可回退轮询
|
||
}
|
||
},
|
||
// 任务列表海报标题(悬停:#编号 任务名称 · 状态)
|
||
getTasklistPosterTitle(task, index) {
|
||
try {
|
||
const num = String(((task && task.__originalIndex !== undefined) ? task.__originalIndex : index) + 1).padStart(2, '0');
|
||
const name = task && task.taskname ? String(task.taskname).trim() : '';
|
||
const status = this.getTaskShowStatus(name);
|
||
return `#${num} ${name}${status ? ' · ' + status : ''}`;
|
||
} catch (e) { return ''; }
|
||
},
|
||
// 切换任务列表视图模式并持久化
|
||
toggleTasklistViewMode() {
|
||
try {
|
||
this.tasklist.viewMode = this.tasklist.viewMode === 'list' ? 'poster' : 'list';
|
||
try { localStorage.setItem('tasklist_view_mode', this.tasklist.viewMode); } catch (e) {}
|
||
} catch (e) {}
|
||
},
|
||
// 根据任务名获取对应的日历任务对象(若已加载)
|
||
getCalendarTaskByName(taskName) {
|
||
try {
|
||
const key = (taskName || '').trim();
|
||
if (!key || !this.calendar || !this.calendar.taskMapByName) return null;
|
||
return this.calendar.taskMapByName[key] || null;
|
||
} catch (e) { return null; }
|
||
},
|
||
// 将任务列表项适配为获取海报所需的 episode-like 对象
|
||
getTasklistPosterLikeEpisode(task) {
|
||
try {
|
||
const calTask = this.getCalendarTaskByName(task && task.taskname);
|
||
// 复用管理视图的取海报逻辑(优先匹配海报)
|
||
if (calTask) return this.getTaskPosterLikeEpisode(calTask);
|
||
// 兜底:仅返回空对象,getEpisodePosterUrl 内部会处理默认图
|
||
return { poster_local_path: '' };
|
||
} catch (e) { return { poster_local_path: '' }; }
|
||
},
|
||
// 任务列表排序:获取显示名称
|
||
getTasklistSortDisplayName(key) {
|
||
if (key === 'name') return '任务名称';
|
||
if (key === 'progress') return '任务进度';
|
||
if (key === 'update_time') return '更新时间';
|
||
return '任务编号';
|
||
},
|
||
// 任务列表排序:保存到localStorage
|
||
saveTasklistSort() {
|
||
try {
|
||
localStorage.setItem('tasklist_sort_options', JSON.stringify({ by: this.tasklistSort.by, order: this.tasklistSort.order }));
|
||
} catch (e) {}
|
||
},
|
||
// 任务列表排序:切换排序字段
|
||
// 仅切换排序字段,不改变当前升降序
|
||
changeTasklistSortBy(by) {
|
||
if (!['index','name','progress','update_time'].includes(by)) return;
|
||
if (this.tasklistSort.by !== by) {
|
||
this.tasklistSort.by = by;
|
||
// 不重置、不切换 order,保持现状
|
||
this.saveTasklistSort();
|
||
// 不再调用 applyTasklistSort,因为现在使用计算属性
|
||
}
|
||
},
|
||
// 任务列表排序:切换升降序
|
||
toggleTasklistSortOrder() {
|
||
this.tasklistSort.order = this.tasklistSort.order === 'asc' ? 'desc' : 'asc';
|
||
this.saveTasklistSort();
|
||
// 不再调用 applyTasklistSort,因为现在使用计算属性
|
||
},
|
||
// 获取排序后的任务列表(不修改原始数据,避免触发 configModified)
|
||
getSortedTasklist() {
|
||
try {
|
||
const { by, order } = this.tasklistSort || { by: 'index', order: 'asc' };
|
||
const factor = order === 'desc' ? -1 : 1;
|
||
// 建立稳定排序:附带原始索引,避免相等时抖动
|
||
const withIndex = (this.formData.tasklist || []).map((t, idx) => ({ t, idx }));
|
||
withIndex.sort((a, b) => {
|
||
let cmp = 0;
|
||
if (by === 'name') {
|
||
const an = (a.t.taskname || '').toString();
|
||
const bn = (b.t.taskname || '').toString();
|
||
try {
|
||
const ak = pinyinPro.pinyin(an, { toneType: 'none', type: 'string' }).toLowerCase();
|
||
const bk = pinyinPro.pinyin(bn, { toneType: 'none', type: 'string' }).toLowerCase();
|
||
if (ak < bk) cmp = -1; else if (ak > bk) cmp = 1; else cmp = 0;
|
||
} catch (e) { cmp = an.localeCompare(bn); }
|
||
} else if (by === 'progress') {
|
||
const ap = (this.getTaskProgress && this.getTaskProgress(a.t.taskname) != null) ? Number(this.getTaskProgress(a.t.taskname)) : -1;
|
||
const bp = (this.getTaskProgress && this.getTaskProgress(b.t.taskname) != null) ? Number(this.getTaskProgress(b.t.taskname)) : -1;
|
||
// 1) 主排序:任务进度(支持升降序)
|
||
const pDiff = ap - bp;
|
||
if (pDiff !== 0) {
|
||
cmp = pDiff;
|
||
} else {
|
||
// 2) 次排序:节目状态优先级(与主排序方向一致,通过最终 factor 生效)
|
||
const getStatusPriority = (task) => {
|
||
try {
|
||
// 任务对象可能为任务配置,需要先从日历任务映射中取状态
|
||
const calTask = this.getCalendarTaskByName(task && (task.taskname || task.task_name));
|
||
const status = this.getTaskShowStatus(calTask || task) || '';
|
||
if (status === '本季终') return 1;
|
||
if (status === '已取消') return 2;
|
||
if (status === '已完结') return 3;
|
||
return 0; // 播出中/无状态
|
||
} catch (e) { return 0; }
|
||
};
|
||
const aStatus = getStatusPriority(a.t);
|
||
const bStatus = getStatusPriority(b.t);
|
||
const sDiff = aStatus - bStatus;
|
||
if (sDiff !== 0) {
|
||
cmp = sDiff; // 顺序:播出中/无 < 本季终 < 已取消 < 已完结(方向由最终 factor 决定)
|
||
} else {
|
||
// 3) 三次排序:节目的播出进度(已播出/总集数),与主排序同方向
|
||
const getBroadcastPct = (task) => {
|
||
try {
|
||
const calTask = this.getCalendarTaskByName(task && (task.taskname || task.task_name));
|
||
const aired = this.getTaskAiredCount(calTask || {});
|
||
const total = this.getTaskTotalCount(calTask || {});
|
||
if (!total || total <= 0) return 0;
|
||
return aired / total;
|
||
} catch (e) { return 0; }
|
||
};
|
||
const aPct = getBroadcastPct(a.t);
|
||
const bPct = getBroadcastPct(b.t);
|
||
const pctDiff = aPct - bPct;
|
||
if (pctDiff !== 0) {
|
||
cmp = pctDiff;
|
||
} else {
|
||
// 3.1) 百分比也相同:按节目总集数排序(与主排序同方向)
|
||
const aTotal = this.getTaskTotalCount(this.getCalendarTaskByName(a.t && (a.t.taskname || a.t.task_name)) || {});
|
||
const bTotal = this.getTaskTotalCount(this.getCalendarTaskByName(b.t && (b.t.taskname || b.t.task_name)) || {});
|
||
const totalDiff = aTotal - bTotal;
|
||
if (totalDiff !== 0) {
|
||
cmp = totalDiff;
|
||
} else {
|
||
// 4) 末级排序:任务编号(#XX),与主排序同方向
|
||
const aNum = parseInt((a.t.taskname || '').match(/^#?(\d+)/)?.[1] || '0');
|
||
const bNum = parseInt((b.t.taskname || '').match(/^#?(\d+)/)?.[1] || '0');
|
||
cmp = aNum - bNum;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if (by === 'update_time') {
|
||
// 按任务最近转存时间排序
|
||
const aRecord = this.taskLatestRecords[a.t.taskname];
|
||
const bRecord = this.taskLatestRecords[b.t.taskname];
|
||
const aTime = aRecord && aRecord.full ? new Date(aRecord.full).getTime() : 0;
|
||
const bTime = bRecord && bRecord.full ? new Date(bRecord.full).getTime() : 0;
|
||
cmp = aTime - bTime;
|
||
} else {
|
||
// 按任务名称中的编号排序(解析 #XX 格式)
|
||
const aNum = parseInt((a.t.taskname || '').match(/^#?(\d+)/)?.[1] || '0');
|
||
const bNum = parseInt((b.t.taskname || '').match(/^#?(\d+)/)?.[1] || '0');
|
||
cmp = aNum - bNum;
|
||
}
|
||
if (cmp === 0) cmp = a.idx - b.idx; // 稳定
|
||
return cmp * factor;
|
||
});
|
||
// 返回带原始索引的任务对象,确保直接引用原始数据
|
||
return withIndex.map(x => {
|
||
const task = x.t;
|
||
task.__originalIndex = x.idx;
|
||
return task;
|
||
});
|
||
} catch (e) {
|
||
return (this.formData.tasklist || []).map((t, idx) => {
|
||
t.__originalIndex = idx;
|
||
return t;
|
||
});
|
||
}
|
||
},
|
||
// 选择日历日期
|
||
selectCalendarDate(day) {
|
||
try {
|
||
if (!day || !day.date) return;
|
||
this.calendar.selectedDate = day.date;
|
||
} catch (e) {}
|
||
},
|
||
// 根据日期字符串获取当日剧集数组
|
||
getEpisodesByDate(dateStr) {
|
||
if (!dateStr) return [];
|
||
// 在 monthWeeks 结构里查找对应日期的 episodes
|
||
for (const week of (this.calendar.monthWeeks || [])) {
|
||
for (const d of (week.days || [])) {
|
||
if (d.date === dateStr) {
|
||
return Array.isArray(d.episodes) ? d.episodes : [];
|
||
}
|
||
}
|
||
}
|
||
return [];
|
||
},
|
||
// --- 显示设置拖拽排序 ---
|
||
onDisplayDragStart(e, key) {
|
||
try {
|
||
e.dataTransfer.setData('text/plain', key);
|
||
// 为原位置元素添加半透明视觉,表示“已被拖起”
|
||
const origin = e.target.closest('.draggable-item');
|
||
if (origin) origin.classList.add('drag-origin');
|
||
} catch (err) {}
|
||
},
|
||
onDisplayDrop(e, targetKey) {
|
||
try {
|
||
const sourceKey = (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';
|
||
if (!sourceKey || sourceKey === targetKey) return;
|
||
const order = (this.formData && this.formData.button_display_order) ? this.formData.button_display_order.slice() : [];
|
||
const from = order.indexOf(sourceKey);
|
||
const to = order.indexOf(targetKey);
|
||
if (from === -1 || to === -1) return;
|
||
order.splice(from, 1);
|
||
order.splice(to, 0, sourceKey);
|
||
this.formData.button_display_order = order;
|
||
} catch (err) {}
|
||
},
|
||
onDisplayDragOver(e) {
|
||
try {
|
||
// 使用“move”效果,避免浏览器默认的“copy/+”指示
|
||
e.dataTransfer.dropEffect = 'move';
|
||
} catch (err) {}
|
||
},
|
||
onDisplayDragEnd(e) {
|
||
try {
|
||
// 拖拽结束时移除所有占位/高亮样式
|
||
const items = document.querySelectorAll('#display-setting-draggable .draggable-item');
|
||
items.forEach(el => el.classList.remove('drag-origin', 'drag-over'));
|
||
} catch (err) {}
|
||
},
|
||
getDisplayLabel(key) {
|
||
const map = {
|
||
refresh_plex: '刷新 Plex 媒体库',
|
||
refresh_alist: '刷新 AList 目录',
|
||
run_task: '运行此任务',
|
||
delete_task: '删除此任务',
|
||
latest_transfer_file: '最近转存文件',
|
||
season_counts: '集数信息统计',
|
||
latest_update_date: '最近更新日期',
|
||
task_progress: '当前任务进度',
|
||
show_status: '电视节目状态',
|
||
today_update_indicator: '当日更新标识'
|
||
};
|
||
return map[key] || key;
|
||
},
|
||
// 显示设置悬停说明
|
||
getDisplayHelp(key) {
|
||
const help = {
|
||
// 按钮类
|
||
refresh_plex: '手动刷新Plex媒体库按钮,仅在正确配置Plex插件时显示并生效',
|
||
refresh_alist: '手动刷新AList目录按钮,仅在正确配置AList插件时显示并生效',
|
||
run_task: '手动运行任务按钮,仅在任务资源有效时显示并生效',
|
||
delete_task: '删除任务按钮,操作不可撤回,请谨慎使用',
|
||
|
||
// 信息类
|
||
latest_transfer_file: '任务最近一次成功转存的文件名(剧集编号),需该任务有过成功转存记录才会显示',
|
||
season_counts: '已转存集数/已播出集数/节目总集数,依赖TMDB匹配的元数据,仅在配置了TMDB API且任务成功匹配到条目时显示',
|
||
latest_update_date: '任务最近一次成功转存的日期,需该任务有过成功转存记录才会显示',
|
||
task_progress: '已转存集数占已播出集数的百分比,依赖TMDB匹配的元数据,仅在配置了TMDB API且任务成功匹配到条目时显示',
|
||
show_status: '电视节目的完播状态(如:本季终/已完结/已取消),依赖TMDB匹配的元数据,仅在配置了TMDB API、任务成功匹配到条目且节目已完播时显示',
|
||
today_update_indicator: '任务在当日产生转存记录后,将显示当日更新标识'
|
||
};
|
||
return help[key] || '设置该项目在任务列表中的显示行为:始终显示/悬停显示/禁用,个别项目受插件或TMDB匹配状态影响是否生效或展示';
|
||
},
|
||
// ----- 任务列表新增显示:集数统计/任务进度/节目状态 -----
|
||
getTaskSeasonCounts(taskName) {
|
||
try {
|
||
if (!taskName || !this.calendar || !Array.isArray(this.calendar.tasks)) return null;
|
||
const t = this.calendar.tasks.find(x => (x.task_name || x.taskname) === taskName);
|
||
if (!t || !t.season_counts) return null;
|
||
const sc = t.season_counts || {};
|
||
const transferred = Number(sc.transferred_count || 0);
|
||
const aired = Number(sc.aired_count || 0);
|
||
const total = Number(sc.total_count || 0);
|
||
if (transferred === 0 && aired === 0 && total === 0) return null;
|
||
return { transferred, aired, total };
|
||
} catch (e) { return null; }
|
||
},
|
||
formatSeasonCounts(sc) {
|
||
try {
|
||
if (!sc) return '';
|
||
// 为斜杠包裹span,便于单独微调位置
|
||
return `${sc.transferred} <span class="count-slash">/</span> ${sc.aired} <span class=\"count-slash\">/</span> ${sc.total}`;
|
||
} catch (e) { return ''; }
|
||
},
|
||
getTaskProgress(taskName) {
|
||
try {
|
||
const sc = this.getTaskSeasonCounts(taskName);
|
||
if (!sc) return null;
|
||
if (sc.aired <= 0) return 0;
|
||
const pct = Math.floor((sc.transferred / sc.aired) * 100);
|
||
return Math.max(0, Math.min(100, pct));
|
||
} catch (e) { return null; }
|
||
},
|
||
getTaskShowStatus(taskNameOrTask) {
|
||
try {
|
||
// 如果传入的是任务对象,直接使用其状态
|
||
if (taskNameOrTask && typeof taskNameOrTask === 'object') {
|
||
const s = (taskNameOrTask.matched_status ? String(taskNameOrTask.matched_status) : '').trim();
|
||
if (!s) return '';
|
||
// 仅在这些状态展示
|
||
if (['本季终', '已完结', '已取消'].includes(s)) return s;
|
||
return '';
|
||
}
|
||
|
||
// 如果传入的是任务名字符串,从任务列表中查找
|
||
if (!taskNameOrTask || !this.calendar || !Array.isArray(this.calendar.tasks)) return '';
|
||
const t = this.calendar.tasks.find(x => (x.task_name || x.taskname) === taskNameOrTask);
|
||
const s = (t && t.matched_status ? String(t.matched_status) : '').trim();
|
||
if (!s) return '';
|
||
// 仅在这些状态展示
|
||
if (['本季终', '已完结', '已取消'].includes(s)) return s;
|
||
return '';
|
||
} catch (e) { return ''; }
|
||
},
|
||
// 获取任务节目状态的悬停提示
|
||
getTaskShowStatusTooltip(task) {
|
||
try {
|
||
if (!task) return '';
|
||
const taskName = task.task_name || task.taskname || '';
|
||
const status = this.getTaskShowStatus(task);
|
||
if (status) {
|
||
return `${taskName} · ${status}`;
|
||
}
|
||
return taskName;
|
||
} catch (e) {
|
||
return task.task_name || task.taskname || '';
|
||
}
|
||
},
|
||
seasonInputComputedWidth() {
|
||
try {
|
||
const val = String(this.editMetadata && this.editMetadata.form ? (this.editMetadata.form.season_number ?? '') : '');
|
||
const len = val.length || 1;
|
||
const px = Math.max(31, len * 9 + 12);
|
||
return px + 'px';
|
||
} catch (e) {
|
||
return '31px';
|
||
}
|
||
},
|
||
// 获取管理视图卡片展示用:实时“已转存集数”(优先使用进度映射,其次回退到任务自带的 season_counts)
|
||
getTaskTransferredCount(task) {
|
||
try {
|
||
if (!task) return 0;
|
||
return Number((task && task.season_counts && task.season_counts.transferred_count) || 0);
|
||
} catch (e) { return 0; }
|
||
},
|
||
// 获取管理视图卡片展示用:已播出集数(直接来自任务元数据)
|
||
getTaskAiredCount(task) {
|
||
try { return Number((task && task.season_counts && task.season_counts.aired_count) || 0); } catch (e) { return 0; }
|
||
},
|
||
// 获取管理视图卡片展示用:总集数(直接来自任务元数据)
|
||
getTaskTotalCount(task) {
|
||
try { return Number((task && task.season_counts && task.season_counts.total_count) || 0); } catch (e) { return 0; }
|
||
},
|
||
// —— 任务列表:类型筛选 ——
|
||
selectTasklistType(type) {
|
||
this.tasklist.selectedType = type;
|
||
try { localStorage.setItem('tasklist_selected_type', type); } catch (e) {}
|
||
},
|
||
tasklistFilterByType(task) {
|
||
try {
|
||
if (!task) return true;
|
||
if (!this.tasklist || !this.tasklist.selectedType || this.tasklist.selectedType === 'all') return true;
|
||
const name = task.taskname || task.task_name || '';
|
||
let contentType = '';
|
||
|
||
// 优先从任务配置中读取内容类型
|
||
if (task.content_type) {
|
||
contentType = task.content_type;
|
||
} else if (task.calendar_info && task.calendar_info.extracted && task.calendar_info.extracted.content_type) {
|
||
contentType = task.calendar_info.extracted.content_type;
|
||
} else {
|
||
// 如果任务配置中没有,则从 calendar.tasks 中查找
|
||
const t = (this.calendar.tasks || []).find(x => (x.task_name || x.taskname) === name);
|
||
contentType = (t && t.content_type) || 'other';
|
||
}
|
||
|
||
return contentType === this.tasklist.selectedType;
|
||
} catch (e) { return true; }
|
||
},
|
||
// 统一的状态筛选逻辑(任务列表与追剧日历共用)
|
||
filterTaskByStatus(task, filterValue) {
|
||
try {
|
||
const filter = filterValue || '';
|
||
if (!filter) return true;
|
||
if (!task) return false;
|
||
const taskName = task.taskname || task.task_name || '';
|
||
const status = this.getTasklistFullStatus(task);
|
||
const progress = this.getTaskProgress && taskName ? this.getTaskProgress(taskName) : null;
|
||
const normalizedProgress = (progress === null || progress === undefined) ? null : Number(progress);
|
||
const isCompleted = this.isTaskCompletedByStatusAndProgress(status, normalizedProgress);
|
||
const isMatched = this.isTaskMatchedWithMetadata(task);
|
||
switch (filter) {
|
||
case 'incomplete':
|
||
return !isCompleted;
|
||
case 'ongoing':
|
||
return normalizedProgress === null || normalizedProgress < 100;
|
||
case 'completed':
|
||
return isCompleted;
|
||
case 'airing':
|
||
return status === '播出中';
|
||
case 'finale':
|
||
return status === '本季终';
|
||
case 'ended':
|
||
return status === '已完结';
|
||
case 'unmatched':
|
||
return !isMatched;
|
||
default:
|
||
return true;
|
||
}
|
||
} catch (e) {
|
||
return true;
|
||
}
|
||
},
|
||
tasklistFilterByStatus(task) {
|
||
return this.filterTaskByStatus(task, this.taskStatusFilter);
|
||
},
|
||
getTasklistFullStatus(task) {
|
||
try {
|
||
if (!task) return '';
|
||
const taskName = task.taskname || task.task_name || '';
|
||
const calTask = taskName ? this.getCalendarTaskByName(taskName) : null;
|
||
const candidates = [
|
||
calTask && calTask.matched_status,
|
||
calTask && calTask.status,
|
||
task.matched_status,
|
||
task.status,
|
||
((task.calendar_info || {}).match || {}).status,
|
||
((task.calendar_info || {}).extracted || {}).status
|
||
];
|
||
for (const val of candidates) {
|
||
if (val && String(val).trim() !== '') {
|
||
return String(val).trim();
|
||
}
|
||
}
|
||
return '';
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
},
|
||
isTaskMatchedWithMetadata(task) {
|
||
try {
|
||
if (!task) return false;
|
||
const taskName = task.taskname || task.task_name || '';
|
||
const calTask = taskName ? this.getCalendarTaskByName(taskName) : null;
|
||
const candidates = [
|
||
calTask && (calTask.match_tmdb_id || (calTask.match && calTask.match.tmdb_id) || calTask.tmdb_id),
|
||
task.match_tmdb_id,
|
||
(task.match && task.match.tmdb_id),
|
||
((task.calendar_info || {}).match || {}).tmdb_id,
|
||
task.tmdb_id
|
||
];
|
||
return candidates.some(id => {
|
||
if (id === null || id === undefined) return false;
|
||
const str = String(id).trim();
|
||
return str !== '';
|
||
});
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
},
|
||
isTaskCompletedByStatusAndProgress(status, progressValue) {
|
||
try {
|
||
const finalStatuses = ['本季终', '已完结', '已取消'];
|
||
if (!finalStatuses.includes(status)) return false;
|
||
if (progressValue === null || progressValue === undefined) return false;
|
||
return Number(progressValue) >= 100;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
},
|
||
// 获取根据当前视图筛选条件过滤后的任务中编号最大的任务
|
||
getLastTaskByCurrentFilter() {
|
||
try {
|
||
if (!this.formData.tasklist || this.formData.tasklist.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// 如果当前视图是"全部",返回所有任务中编号最大的任务
|
||
if (!this.tasklist || !this.tasklist.selectedType || this.tasklist.selectedType === 'all') {
|
||
return this.formData.tasklist[this.formData.tasklist.length - 1];
|
||
}
|
||
|
||
// 根据当前筛选条件过滤任务
|
||
const filteredTasks = this.formData.tasklist.filter(task => {
|
||
return this.tasklistFilterByType(task);
|
||
});
|
||
|
||
// 如果没有匹配的任务,返回所有任务中编号最大的任务
|
||
if (filteredTasks.length === 0) {
|
||
return this.formData.tasklist[this.formData.tasklist.length - 1];
|
||
}
|
||
|
||
// 返回过滤后任务中编号最大的任务(最后一个)
|
||
return filteredTasks[filteredTasks.length - 1];
|
||
} catch (e) {
|
||
// 出错时返回所有任务中编号最大的任务
|
||
return this.formData.tasklist && this.formData.tasklist.length > 0
|
||
? this.formData.tasklist[this.formData.tasklist.length - 1]
|
||
: null;
|
||
}
|
||
},
|
||
// 计算转存进度(已转存/已播出 的百分比,取整),优先使用实时映射
|
||
getTransferProgress(task) {
|
||
try {
|
||
if (!task || !task.season_counts) return 0;
|
||
const transferred = this.getTaskTransferredCount(task);
|
||
const aired = this.getTaskAiredCount(task);
|
||
if (aired <= 0) return 0;
|
||
const pct = Math.floor((transferred / aired) * 100);
|
||
return Math.max(0, Math.min(100, pct));
|
||
} catch (e) { return 0; }
|
||
},
|
||
// 根据节目状态返回徽标颜色类(使用状态名便于直接在CSS中调整颜色)
|
||
getProgressBadgeClass(task) {
|
||
try {
|
||
const status = (task && task.matched_status ? String(task.matched_status) : '').trim();
|
||
if (!status) return '';
|
||
if (status === '本季终') return 'status-finale';
|
||
if (status === '已完结') return 'status-ended';
|
||
// 其他状态:统一用绿色
|
||
return 'status-other';
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
},
|
||
// 仅当有有效信息时返回悬停提示,否则返回null以不显示
|
||
getSuggestionHoverTitle(suggestion) {
|
||
if (!suggestion) return null;
|
||
let content = (suggestion.content || '').trim();
|
||
if (!content) return null;
|
||
// 统一标点为英文冒号,统一逗号
|
||
const normalized = content
|
||
.replace(/:/g, ':')
|
||
.replace(/,/g, ',')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
// 仅在明确的占位文本时隐藏:
|
||
// 1) 全文就是“大小:-”
|
||
if (/^大小\s*:\s*-$/i.test(normalized)) return null;
|
||
// 2) 完全匹配“类别:xx, 文件类型:yy, 大小:-”这类占位
|
||
if (/^类别\s*:[^,]*,\s*文件类型\s*:[^,]*,\s*大小\s*:\s*-$/i.test(normalized)) return null;
|
||
return content;
|
||
},
|
||
// 获取插件展示名称(支持别名,仅用于WebUI显示)
|
||
getPluginDisplayName(pluginName) {
|
||
return this.pluginDisplayAliases[pluginName] || pluginName;
|
||
},
|
||
// 设置移动端任务列表展开/收起状态监听
|
||
setupMobileTaskListToggle() {
|
||
// 监听所有collapse事件
|
||
$(document).on('show.bs.collapse', '[id^="collapse_"]', (e) => {
|
||
const collapseId = e.target.id;
|
||
const taskIndex = collapseId.replace('collapse_', '');
|
||
const taskElement = $(e.target).closest('.task');
|
||
if (taskElement.length) {
|
||
taskElement.addClass('task-expanded');
|
||
}
|
||
});
|
||
|
||
$(document).on('hide.bs.collapse', '[id^="collapse_"]', (e) => {
|
||
const collapseId = e.target.id;
|
||
const taskIndex = collapseId.replace('collapse_', '');
|
||
const taskElement = $(e.target).closest('.task');
|
||
if (taskElement.length) {
|
||
taskElement.removeClass('task-expanded');
|
||
}
|
||
});
|
||
},
|
||
// 获取文件图标类名
|
||
getFileIconClass(fileName, isDir = false) {
|
||
return getFileIconClass(fileName, isDir);
|
||
},
|
||
// 拼音排序辅助函数
|
||
sortTaskNamesByPinyin(taskNames) {
|
||
return taskNames.sort((a, b) => {
|
||
const aKey = pinyinPro.pinyin(a, { toneType: 'none', type: 'string' }).toLowerCase();
|
||
const bKey = pinyinPro.pinyin(b, { toneType: 'none', type: 'string' }).toLowerCase();
|
||
return aKey > bKey ? 1 : -1;
|
||
});
|
||
},
|
||
// 添加格式化分享链接警告信息的方法
|
||
formatShareUrlBanMessage(message) {
|
||
if (!message) return message;
|
||
|
||
// 检查是否为可恢复的网络错误或服务端临时错误
|
||
if (message.includes("inner error") ||
|
||
message.includes("request error") ||
|
||
message.includes("网络错误") ||
|
||
message.includes("服务端错误") ||
|
||
message.includes("临时错误")) {
|
||
// 对于可恢复错误,返回null表示不应该设置shareurl_ban
|
||
return null;
|
||
}
|
||
|
||
if (message.includes("分享者用户封禁链接查看受限") ||
|
||
message.includes("文件涉及违规内容") ||
|
||
message.includes("分享地址已失效")) {
|
||
return "该分享已失效,不可访问";
|
||
} else if (message.includes("好友已取消了分享")) {
|
||
return "该分享已被取消,无法访问";
|
||
} else if (message.includes("文件已被分享者删除") || message === "文件已被分享者删除或文件夹为空") {
|
||
return "该分享已被删除,无法访问";
|
||
}
|
||
|
||
return message;
|
||
},
|
||
|
||
// 追剧日历相关方法
|
||
// 加载追剧日历数据
|
||
async loadCalendarData() {
|
||
if (this.calendar.hasLoaded) return;
|
||
|
||
try {
|
||
console.log('开始加载追剧日历数据...');
|
||
|
||
// 直接加载任务信息(本地数据,无需缓存)
|
||
const tasksResponse = await axios.get('/api/calendar/tasks');
|
||
console.log('任务信息响应:', tasksResponse.data);
|
||
|
||
if (tasksResponse.data.success) {
|
||
this.calendar.tasks = tasksResponse.data.data.tasks;
|
||
// 规范化并排序内容类型:tv、anime、variety、documentary、other(其他始终最后)
|
||
const rawTypes = tasksResponse.data.data.content_types || [];
|
||
this.updateContentTypes(rawTypes);
|
||
this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name);
|
||
|
||
// 从任务列表页面接口读取最近转存文件,构建进度映射
|
||
try {
|
||
const latestRes = await axios.get('/task_latest_info');
|
||
if (latestRes.data && latestRes.data.success) {
|
||
const latestFiles = latestRes.data.data.latest_files || {};
|
||
// 同步到任务面板缓存,避免重复请求
|
||
this.taskLatestFiles = latestFiles;
|
||
// 构建映射
|
||
this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles);
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks, this.calendar.progressByTaskName);
|
||
} else {
|
||
this.calendar.progressByTaskName = {};
|
||
this.calendar.progressByShowName = {};
|
||
}
|
||
} catch (e) {
|
||
console.warn('读取任务最近转存文件失败:', e);
|
||
this.calendar.progressByTaskName = {};
|
||
this.calendar.progressByShowName = {};
|
||
}
|
||
|
||
// 读取持久化的内容类型选择并校验有效性
|
||
try {
|
||
const savedType = localStorage.getItem('calendar_selected_type') || 'all';
|
||
const validTypes = ['all', ...this.calendar.contentTypes];
|
||
if (validTypes.includes(savedType)) {
|
||
this.calendar.selectedType = savedType;
|
||
} else {
|
||
this.calendar.selectedType = 'all';
|
||
}
|
||
} catch (e) {
|
||
this.calendar.selectedType = 'all';
|
||
}
|
||
// 读取管理模式持久化(支持三态记忆:管理/海报/月历)
|
||
try {
|
||
const savedManage = localStorage.getItem('calendar_manage_mode');
|
||
if (savedManage === 'true') {
|
||
this.calendar.manageMode = true;
|
||
} else if (savedManage === 'false') {
|
||
this.calendar.manageMode = false;
|
||
}
|
||
} catch (e) {}
|
||
|
||
console.log('任务数据:', this.calendar.tasks);
|
||
console.log('内容类型:', this.calendar.contentTypes);
|
||
|
||
// 首屏优先渲染本地缓存,保证页面快速可见
|
||
await this.loadCalendarEpisodesLocal();
|
||
// 同步加载今日更新数据
|
||
try { await this.loadTodayUpdatesLocal(); } catch (e) {}
|
||
// 背景增量刷新(带节流),完成后热更新
|
||
if (this.shouldAutoRefreshCalendar()) {
|
||
this.refreshCalendarData().catch(() => {});
|
||
}
|
||
// 启动后台轻量监听,近实时发现“最近转存文件”变化
|
||
this.startCalendarAutoWatch();
|
||
|
||
this.calendar.hasLoaded = true;
|
||
|
||
// 初始化日期数据
|
||
this.initializeCalendarDates();
|
||
} else {
|
||
this.calendar.error = tasksResponse.data.message;
|
||
console.error('加载任务信息失败:', tasksResponse.data.message);
|
||
}
|
||
} catch (error) {
|
||
this.calendar.error = '加载追剧日历数据失败';
|
||
console.error('加载追剧日历数据失败:', error);
|
||
}
|
||
},
|
||
|
||
// 切换内容管理模式
|
||
toggleCalendarManageMode() {
|
||
this.calendar.manageMode = !this.calendar.manageMode;
|
||
try {
|
||
localStorage.setItem('calendar_manage_mode', this.calendar.manageMode ? 'true' : 'false');
|
||
} catch (e) {}
|
||
// 进入或退出管理模式时触发布局重算
|
||
this.calendar.layoutTick = Date.now();
|
||
},
|
||
|
||
// 将任务转为与海报视图一致的episode-like对象:优先使用后端返回的匹配海报
|
||
getTaskPosterLikeEpisode(task) {
|
||
const show = (task && task.show_name) ? String(task.show_name).trim() : '';
|
||
// 1) 后端提供的 matched_poster_local_path(真实匹配结果)
|
||
let poster = (task && task.matched_poster_local_path) || '';
|
||
// 2) 若无,则根据 show_name 在已加载episodes中兜底
|
||
if (!poster && show && this.posterByShowName && this.posterByShowName[show]) {
|
||
poster = this.posterByShowName[show];
|
||
}
|
||
// 3) 仍无则使用任务自身字段或默认逻辑
|
||
if (!poster) {
|
||
poster = (task && task.poster_local_path) || '';
|
||
}
|
||
return {
|
||
poster_local_path: poster,
|
||
task_info: { task_name: task && task.task_name, content_type: task && task.content_type },
|
||
show_name: task && task.show_name
|
||
};
|
||
},
|
||
|
||
// 内容类型中文名
|
||
getContentTypeCN(type) {
|
||
const map = { tv: '剧集', anime: '动画', variety: '综艺', documentary: '纪录片', other: '其他' };
|
||
return map[type] || '其他';
|
||
},
|
||
|
||
// 基于最近转存文件构建:task_name -> { episode_number, air_date }
|
||
buildProgressByTaskNameFromLatestFiles(latestFiles) {
|
||
const result = {};
|
||
if (!latestFiles || typeof latestFiles !== 'object') return result;
|
||
const patterns = [
|
||
/S(\d{1,2})E(\d{1,3})/i, // S01E01
|
||
/E(\d{1,3})/i, // E01
|
||
/第(\d{1,3})集/, // 第1集
|
||
/第(\d{1,3})期/, // 第1期(综艺)
|
||
/(\d{1,3})集/, // 1集
|
||
/(\d{1,3})期/, // 1期
|
||
];
|
||
const datePatterns = [
|
||
/(\d{4})-(\d{1,2})-(\d{1,2})/, // 2025-01-01
|
||
/(\d{4})\/(\d{1,2})\/(\d{1,2})/,
|
||
/(\d{4})\.(\d{1,2})\.(\d{1,2})/,
|
||
];
|
||
const parseOne = (txt) => {
|
||
if (!txt) return null;
|
||
|
||
// 特殊处理:检测"日期 连接符 第x期"格式,优先使用日期
|
||
// 支持各种连接符号:空格、-、_、.、/等
|
||
const dateEpisodePatterns = [
|
||
/(\d{4})-(\d{1,2})-(\d{1,2})\s*[-\s_\.\/]\s*第(\d{1,3})期/, // 2025-09-08 - 第128期, 2025-09-08 第128期, 2025-09-08_第128期, 2025-09-08.第128期
|
||
/(\d{4})\/(\d{1,2})\/(\d{1,2})\s*[-\s_\.\/]\s*第(\d{1,3})期/, // 2025/09/08 - 第128期, 2025/09/08 第128期, 2025/09/08_第128期, 2025/09/08.第128期
|
||
/(\d{4})\.(\d{1,2})\.(\d{1,2})\s*[-\s_\.\/]\s*第(\d{1,3})期/, // 2025.09.08 - 第128期, 2025.09.08 第128期, 2025.09.08_第128期, 2025.09.08.第128期
|
||
];
|
||
|
||
for (const pattern of dateEpisodePatterns) {
|
||
const match = String(txt).match(pattern);
|
||
if (match) {
|
||
const y = match[1];
|
||
const mm = String(match[2]).padStart(2, '0');
|
||
const dd = String(match[3]).padStart(2, '0');
|
||
return { episode_number: null, air_date: `${y}-${mm}-${dd}` };
|
||
}
|
||
}
|
||
|
||
// 原有的逻辑:先尝试提取集数信息
|
||
for (const re of patterns) {
|
||
const m = String(txt).match(re);
|
||
if (m) {
|
||
if (/S\d+E\d+/i.test(m[0])) {
|
||
return { episode_number: parseInt(m[2]), air_date: null };
|
||
}
|
||
return { episode_number: parseInt(m[1]), air_date: null };
|
||
}
|
||
}
|
||
|
||
// 最后尝试提取日期信息
|
||
for (const re of datePatterns) {
|
||
const m = String(txt).match(re);
|
||
if (m) {
|
||
const y = m[1];
|
||
const mm = String(m[2]).padStart(2, '0');
|
||
const dd = String(m[3]).padStart(2, '0');
|
||
return { episode_number: null, air_date: `${y}-${mm}-${dd}` };
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
Object.keys(latestFiles || {}).forEach(taskName => {
|
||
if (taskName && taskName !== '__calendar_shows__' && taskName !== '__calendar_tmdb_ids__') {
|
||
const parsed = parseOne(latestFiles[taskName]);
|
||
if (parsed && (parsed.episode_number != null || parsed.air_date)) {
|
||
result[taskName] = parsed;
|
||
}
|
||
}
|
||
});
|
||
return result;
|
||
},
|
||
|
||
// 基于任务列表信息构建:show_name -> 任务进度(同一剧取更大集号或更晚日期)
|
||
buildProgressByShowNameFromTasks(tasks, progressByTaskName) {
|
||
const result = {};
|
||
if (!Array.isArray(tasks)) return result;
|
||
const pickNewer = (a, b) => {
|
||
if (!a) return b; if (!b) return a;
|
||
if (a.episode_number != null && b.episode_number != null) {
|
||
return b.episode_number > a.episode_number ? b : a;
|
||
}
|
||
if (a.episode_number != null) return a;
|
||
if (b.episode_number != null) return b;
|
||
if ((b.air_date || '') > (a.air_date || '')) return b; return a;
|
||
};
|
||
tasks.forEach(t => {
|
||
const tname = t.task_name;
|
||
const sname = t.show_name;
|
||
const prog = progressByTaskName[tname];
|
||
if (sname && prog) {
|
||
result[sname] = pickNewer(result[sname], prog);
|
||
}
|
||
});
|
||
return result;
|
||
},
|
||
|
||
// 加载本地剧集数据;如无则自动 bootstrap + refresh 再读
|
||
async loadCalendarEpisodesLocal() {
|
||
const tryReadLocal = async () => {
|
||
const res = await axios.get('/api/calendar/episodes_local');
|
||
if (res.data && res.data.success) {
|
||
this.calendar.episodes = res.data.data.episodes || [];
|
||
return this.calendar.episodes.length;
|
||
}
|
||
return 0;
|
||
};
|
||
|
||
let count = await tryReadLocal();
|
||
if (count > 0) return;
|
||
|
||
try {
|
||
await axios.post('/api/calendar/bootstrap');
|
||
} catch (e) {
|
||
console.warn('bootstrap 失败:', e);
|
||
}
|
||
|
||
const tasklist = (this.formData && this.formData.tasklist) ? this.formData.tasklist : [];
|
||
const tmdbIds = [];
|
||
tasklist.forEach(t => {
|
||
const cal = (t && t.calendar_info) ? t.calendar_info : {};
|
||
const match = cal.match || {};
|
||
if (match.tmdb_id) tmdbIds.push(match.tmdb_id);
|
||
});
|
||
|
||
for (const id of tmdbIds) {
|
||
try {
|
||
await axios.get('/api/calendar/refresh_latest_season', { params: { tmdb_id: id } });
|
||
} catch (e) {
|
||
console.warn('refresh 失败:', id, e);
|
||
}
|
||
}
|
||
|
||
await tryReadLocal();
|
||
},
|
||
|
||
// 初始化日历日期数据
|
||
initializeCalendarDates() {
|
||
this.updateWeekDates();
|
||
this.updateMonthWeeks();
|
||
},
|
||
|
||
// 更新周视图日期
|
||
updateWeekDates() {
|
||
const today = new Date();
|
||
const currentDay = new Date(this.calendar.currentDate);
|
||
|
||
// 动态计算显示的列数,根据主内容区域宽度自动调整
|
||
const availableWidth = this.getCalendarAvailableWidth();
|
||
const minColumnWidth = 140; // 最小列宽
|
||
const columnGap = 20; // 列间距
|
||
// 计算列数:n = floor((W + gap - eps) / (minWidth + gap)),避免边界像素/滚动条导致的多列
|
||
const eps = 0.1;
|
||
const maxColumns = Math.max(2, Math.floor((availableWidth + columnGap - eps) / (minColumnWidth + columnGap)));
|
||
|
||
|
||
|
||
// 从当前日期开始,向后扩展日期范围(今天始终在第一列)
|
||
const startDate = new Date(currentDay);
|
||
|
||
this.calendar.weekDates = [];
|
||
for (let i = 0; i < maxColumns; i++) {
|
||
const date = new Date(startDate);
|
||
date.setDate(startDate.getDate() + i);
|
||
|
||
const isToday = date.toDateString() === today.toDateString();
|
||
const dayOfWeek = date.getDay();
|
||
const weekdayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||
const weekday = weekdayNames[dayOfWeek];
|
||
const day = date.getDate().toString().padStart(2, '0');
|
||
|
||
this.calendar.weekDates.push({
|
||
date: this.formatDateToYYYYMMDD(date),
|
||
weekday: isToday ? '今天' : weekday,
|
||
day: `${(date.getMonth() + 1).toString().padStart(2, '0')}/${day}`,
|
||
isToday: isToday
|
||
});
|
||
}
|
||
},
|
||
|
||
// 更新月视图周数据
|
||
updateMonthWeeks() {
|
||
const currentDay = new Date(this.calendar.currentDate);
|
||
const year = currentDay.getFullYear();
|
||
const month = currentDay.getMonth();
|
||
|
||
const firstDay = new Date(year, month, 1);
|
||
const lastDay = new Date(year, month + 1, 0);
|
||
const firstDayOfWeek = firstDay.getDay() || 7; // 转换为周一到周日(1-7)
|
||
|
||
const weeks = [];
|
||
let currentWeek = [];
|
||
|
||
// 添加上个月的日期
|
||
for (let i = firstDayOfWeek - 1; i > 0; i--) {
|
||
const date = new Date(year, month, 1 - i);
|
||
currentWeek.push({
|
||
date: this.formatDateToYYYYMMDD(date),
|
||
dayNumber: date.getDate(),
|
||
isCurrentMonth: false,
|
||
isToday: false,
|
||
episodes: []
|
||
});
|
||
}
|
||
|
||
// 添加当前月的日期
|
||
for (let day = 1; day <= lastDay.getDate(); day++) {
|
||
const date = new Date(year, month, day);
|
||
const isToday = date.toDateString() === new Date().toDateString();
|
||
|
||
currentWeek.push({
|
||
date: this.formatDateToYYYYMMDD(date),
|
||
dayNumber: day,
|
||
isCurrentMonth: true,
|
||
isToday: isToday,
|
||
episodes: this.getEpisodesByDate(this.formatDateToYYYYMMDD(date))
|
||
});
|
||
|
||
if (currentWeek.length === 7) {
|
||
weeks.push({
|
||
weekIndex: weeks.length,
|
||
days: currentWeek
|
||
});
|
||
currentWeek = [];
|
||
}
|
||
}
|
||
|
||
// 添加下个月的日期
|
||
if (currentWeek.length > 0) {
|
||
for (let day = 1; currentWeek.length < 7; day++) {
|
||
const date = new Date(year, month + 1, day);
|
||
currentWeek.push({
|
||
date: this.formatDateToYYYYMMDD(date),
|
||
dayNumber: date.getDate(),
|
||
isCurrentMonth: false,
|
||
isToday: false,
|
||
episodes: []
|
||
});
|
||
}
|
||
weeks.push({
|
||
weekIndex: weeks.length,
|
||
days: currentWeek
|
||
});
|
||
}
|
||
|
||
this.calendar.monthWeeks = weeks;
|
||
},
|
||
|
||
// 根据日期获取剧集
|
||
getEpisodesByDate(date) {
|
||
// 从已加载的剧集中筛选指定日期的剧集
|
||
if (!this.calendar.episodes || this.calendar.episodes.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
// 先进行基础筛选
|
||
let filteredEpisodes = this.calendar.episodes.filter(episode => {
|
||
// 根据筛选条件过滤
|
||
const matchedTask = this.findTaskByShowName(episode.show_name);
|
||
if (this.calendar.selectedType !== 'all') {
|
||
const episodeContentType = matchedTask ? (matchedTask.content_type || 'other') : 'other';
|
||
if (episodeContentType !== this.calendar.selectedType) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 名称筛选:检查剧集名称
|
||
if (this.calendar.nameFilter && this.calendar.nameFilter.trim() !== '') {
|
||
const nameFilter = this.calendar.nameFilter.toLowerCase();
|
||
const showName = (episode.show_name || '').toLowerCase();
|
||
if (!showName.includes(nameFilter)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 任务筛选:检查任务名称
|
||
if (this.calendar.taskFilter && this.calendar.taskFilter.trim() !== '') {
|
||
const taskName = matchedTask ? (matchedTask.task_name || matchedTask.taskname || '') : '';
|
||
if (taskName !== this.calendar.taskFilter) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 状态筛选:复用任务列表逻辑
|
||
if (!this.filterTaskByStatus(matchedTask, this.calendar.statusFilter)) {
|
||
return false;
|
||
}
|
||
|
||
return episode.air_date === date;
|
||
});
|
||
|
||
// 如果启用了合并集功能,则进行合并处理
|
||
let result = this.calendar.mergeEpisodes
|
||
? this.mergeEpisodesByShow(filteredEpisodes)
|
||
: filteredEpisodes;
|
||
|
||
// 统一对同一天节目按节目名称拼音排序(与内容管理一致)
|
||
try {
|
||
result = result.slice().sort((a, b) => {
|
||
const an = (a && a.show_name) ? String(a.show_name) : '';
|
||
const bn = (b && b.show_name) ? String(b.show_name) : '';
|
||
const ak = pinyinPro.pinyin(an, { toneType: 'none', type: 'string' }).toLowerCase();
|
||
const bk = pinyinPro.pinyin(bn, { toneType: 'none', type: 'string' }).toLowerCase();
|
||
if (ak === bk) return 0;
|
||
return ak > bk ? 1 : -1;
|
||
});
|
||
} catch (e) {}
|
||
|
||
return result;
|
||
},
|
||
|
||
// 根据剧集名称查找对应的任务
|
||
findTaskByShowName(showName) {
|
||
if (!this.calendar.tasks || !showName) return null;
|
||
|
||
// 首先尝试精确匹配
|
||
let matchedTask = this.calendar.tasks.find(task =>
|
||
task.show_name === showName ||
|
||
task.matched_show_name === showName
|
||
);
|
||
|
||
if (matchedTask) return matchedTask;
|
||
|
||
// 如果精确匹配失败,尝试模糊匹配
|
||
matchedTask = this.calendar.tasks.find(task =>
|
||
(task.show_name && task.show_name.includes(showName)) ||
|
||
(task.matched_show_name && task.matched_show_name.includes(showName))
|
||
);
|
||
|
||
return matchedTask || null;
|
||
},
|
||
// 判断剧集是否为 finale 集(支持合并集)
|
||
isFinaleEpisode(episode) {
|
||
try {
|
||
if (!episode) return false;
|
||
// 合并集:检查任一原始集是否 finale
|
||
if (episode.is_merged && Array.isArray(episode.original_episodes) && episode.original_episodes.length > 0) {
|
||
return episode.original_episodes.some(ep => {
|
||
const t = String((ep && (ep.type || ep.ep_type || ep.episode_type)) || '').toLowerCase();
|
||
return t.includes('finale');
|
||
});
|
||
}
|
||
// 单集:直接检查类型字段
|
||
const tp = String((episode.type || episode.ep_type || episode.episode_type || '')).toLowerCase();
|
||
return tp.includes('finale');
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
},
|
||
// 获取需要在 finale 集显示的节目状态文案
|
||
getEpisodeFinaleStatus(episode) {
|
||
try {
|
||
if (!this.isFinaleEpisode(episode)) return '';
|
||
const task = this.findTaskByShowName(episode && episode.show_name);
|
||
const status = task && task.matched_status ? String(task.matched_status).trim() : '';
|
||
// 规范:若节目为“已完结/已取消”,则在 finale 集显示对应状态;否则显示“本季终”
|
||
if (status === '已完结' || status === '已取消') return status;
|
||
return '本季终';
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
},
|
||
// 返回用于悬停提示的 文本:剧名 或 剧名 · 状态
|
||
getEpisodeShowTitleWithStatus(episode) {
|
||
try {
|
||
const name = (episode && episode.show_name) ? String(episode.show_name).trim() : '';
|
||
const status = this.getEpisodeFinaleStatus(episode);
|
||
return status ? `${name} · ${status}` : name;
|
||
} catch (e) {
|
||
return (episode && episode.show_name) || '';
|
||
}
|
||
},
|
||
|
||
// 合并同一天同一节目的多集
|
||
mergeEpisodesByShow(episodes) {
|
||
if (!episodes || episodes.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
// 按节目名称和任务信息分组
|
||
const groupedEpisodes = {};
|
||
|
||
episodes.forEach(episode => {
|
||
// 验证episode对象的基本结构
|
||
if (!episode || typeof episode !== 'object') {
|
||
return;
|
||
}
|
||
|
||
// 创建唯一键:节目名称 + 任务名称 + 季数
|
||
const showName = episode.show_name || 'unknown';
|
||
const taskName = (episode.task_info && episode.task_info.task_name) ? episode.task_info.task_name : 'unknown';
|
||
const seasonNumber = episode.season_number || 1;
|
||
|
||
// 清理键值,防止特殊字符导致问题
|
||
const cleanShowName = String(showName).replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_');
|
||
const cleanTaskName = String(taskName).replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_');
|
||
const showKey = `${cleanShowName}_${cleanTaskName}_${seasonNumber}`;
|
||
|
||
if (!groupedEpisodes[showKey]) {
|
||
groupedEpisodes[showKey] = [];
|
||
}
|
||
groupedEpisodes[showKey].push(episode);
|
||
});
|
||
|
||
// 对每个分组进行合并处理
|
||
const mergedEpisodes = [];
|
||
|
||
Object.values(groupedEpisodes).forEach(episodeGroup => {
|
||
if (episodeGroup.length === 1) {
|
||
// 只有一集,直接添加
|
||
mergedEpisodes.push(episodeGroup[0]);
|
||
} else {
|
||
// 多集,进行合并
|
||
const mergedEpisode = this.createMergedEpisode(episodeGroup);
|
||
mergedEpisodes.push(mergedEpisode);
|
||
}
|
||
});
|
||
|
||
return mergedEpisodes;
|
||
},
|
||
|
||
// 创建合并后的剧集对象
|
||
createMergedEpisode(episodeGroup) {
|
||
// 按集数排序
|
||
episodeGroup.sort((a, b) => {
|
||
const episodeA = parseInt(a.episode_number) || 0;
|
||
const episodeB = parseInt(b.episode_number) || 0;
|
||
return episodeA - episodeB;
|
||
});
|
||
|
||
// 使用最后一集的数据作为基础
|
||
const baseEpisode = episodeGroup[episodeGroup.length - 1];
|
||
const firstEpisode = episodeGroup[0];
|
||
const lastEpisode = episodeGroup[episodeGroup.length - 1];
|
||
|
||
// 创建合并后的剧集对象
|
||
const mergedEpisode = {
|
||
...baseEpisode,
|
||
// 合并集数显示
|
||
episode_number: this.formatMergedEpisodeNumbers(episodeGroup),
|
||
// 合并集数范围(用于显示)
|
||
episode_range: {
|
||
start: parseInt(firstEpisode.episode_number) || 0,
|
||
end: parseInt(lastEpisode.episode_number) || 0,
|
||
count: episodeGroup.length
|
||
},
|
||
// 标记为合并集
|
||
is_merged: true,
|
||
// 原始剧集列表(用于调试或其他用途)
|
||
original_episodes: episodeGroup
|
||
};
|
||
|
||
return mergedEpisode;
|
||
},
|
||
|
||
// 格式化合并集数显示
|
||
formatMergedEpisodeNumbers(episodeGroup) {
|
||
if (!episodeGroup || episodeGroup.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
if (episodeGroup.length === 1) {
|
||
return episodeGroup[0].episode_number || '';
|
||
}
|
||
|
||
const firstEpisode = parseInt(episodeGroup[0].episode_number) || 0;
|
||
const lastEpisode = parseInt(episodeGroup[episodeGroup.length - 1].episode_number) || 0;
|
||
const seasonNumber = parseInt(episodeGroup[0].season_number) || 1;
|
||
|
||
// 确保集数和季数都是有效的数字
|
||
if (isNaN(firstEpisode) || isNaN(lastEpisode) || isNaN(seasonNumber)) {
|
||
return episodeGroup[0].episode_number || '';
|
||
}
|
||
|
||
if (firstEpisode === lastEpisode) {
|
||
return `S${String(seasonNumber).padStart(2, '0')}E${String(firstEpisode).padStart(2, '0')}`;
|
||
} else {
|
||
return `S${String(seasonNumber).padStart(2, '0')}E${String(firstEpisode).padStart(2, '0')}-E${String(lastEpisode).padStart(2, '0')}`;
|
||
}
|
||
},
|
||
|
||
// 获取内容类型显示名称
|
||
getContentTypeDisplayName(type) {
|
||
const typeNames = {
|
||
'all': '全部',
|
||
'tv': '剧集',
|
||
'anime': '动画',
|
||
'variety': '综艺',
|
||
'documentary': '纪录片',
|
||
'other': '其他'
|
||
};
|
||
return typeNames[type] || type;
|
||
},
|
||
|
||
// 缓存管理方法
|
||
setCachedData(key, data) {
|
||
try {
|
||
localStorage.setItem(`quark_calendar_${key}`, JSON.stringify(data));
|
||
} catch (error) {
|
||
console.warn('缓存数据失败:', error);
|
||
}
|
||
},
|
||
|
||
getCachedData(key) {
|
||
try {
|
||
const data = localStorage.getItem(`quark_calendar_${key}`);
|
||
return data ? JSON.parse(data) : null;
|
||
} catch (error) {
|
||
console.warn('读取缓存数据失败:', error);
|
||
return null;
|
||
}
|
||
},
|
||
|
||
|
||
|
||
// 增量刷新追剧日历数据:仅对最新季执行增量拉取,然后本地读取
|
||
async refreshCalendarData() {
|
||
try {
|
||
const tasklist = (this.formData && this.formData.tasklist) ? this.formData.tasklist : [];
|
||
const tmdbIds = [];
|
||
tasklist.forEach(t => {
|
||
const cal = (t && t.calendar_info) ? t.calendar_info : {};
|
||
const match = cal.match || {};
|
||
if (match.tmdb_id) tmdbIds.push(match.tmdb_id);
|
||
});
|
||
for (const id of tmdbIds) {
|
||
try {
|
||
await axios.get('/api/calendar/refresh_latest_season', { params: { tmdb_id: id } });
|
||
} catch (e) {
|
||
console.warn('refresh 失败:', id, e);
|
||
}
|
||
}
|
||
|
||
// 重新加载任务数据,确保内容管理页面能热更新
|
||
try {
|
||
const tasksResponse = await axios.get('/api/calendar/tasks');
|
||
if (tasksResponse.data.success) {
|
||
this.calendar.tasks = tasksResponse.data.data.tasks;
|
||
this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name);
|
||
// 重新构建进度映射
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
|
||
// 重新计算内容类型,确保类型按钮能热更新
|
||
const rawTypes = tasksResponse.data.data.content_types || [];
|
||
this.updateContentTypes(rawTypes);
|
||
}
|
||
} catch (e) {}
|
||
|
||
// 刷新内存数据,不清空缓存键,直接本地读取覆盖
|
||
await this.loadCalendarEpisodesLocal();
|
||
// 热更新当前视图(不打断用户操作)
|
||
this.initializeCalendarDates();
|
||
// 并行刷新今日更新数据,确保当日更新标识正常显示
|
||
try { await this.loadTodayUpdatesLocal(); } catch (e) {}
|
||
// 记录自动刷新时间戳,用于节流
|
||
try { localStorage.setItem('calendar_last_auto_refresh', String(Date.now())); } catch (e) {}
|
||
} catch (error) {
|
||
console.warn('增量刷新失败:', error);
|
||
}
|
||
},
|
||
|
||
// 刷新单集或合并集的元数据(海报视图使用)
|
||
async refreshEpisodeMetadata(episode) {
|
||
try {
|
||
if (!episode || !episode.tmdb_id || !episode.season_number) {
|
||
console.warn('刷新单集元数据失败:缺少必要参数', episode);
|
||
return;
|
||
}
|
||
|
||
// 检查是否为合并集
|
||
if (episode.is_merged && episode.original_episodes && episode.original_episodes.length > 0) {
|
||
// 合并集:刷新所有原始集
|
||
console.log('检测到合并集,将刷新所有原始集:', episode.original_episodes.length, '集');
|
||
|
||
let successCount = 0;
|
||
let failCount = 0;
|
||
const successEpisodes = [];
|
||
const failEpisodes = [];
|
||
const errors = [];
|
||
|
||
// 并行刷新所有原始集
|
||
const refreshPromises = episode.original_episodes.map(async (originalEpisode) => {
|
||
try {
|
||
const response = await axios.get('/api/calendar/refresh_episode', {
|
||
params: {
|
||
tmdb_id: episode.tmdb_id,
|
||
season_number: episode.season_number,
|
||
episode_number: originalEpisode.episode_number
|
||
}
|
||
});
|
||
|
||
if (response.data.success) {
|
||
successCount++;
|
||
successEpisodes.push(originalEpisode.episode_number);
|
||
return { success: true, episode: originalEpisode.episode_number };
|
||
} else {
|
||
failCount++;
|
||
failEpisodes.push(originalEpisode.episode_number);
|
||
errors.push(`第${originalEpisode.episode_number}集: ${response.data.message}`);
|
||
return { success: false, episode: originalEpisode.episode_number, error: response.data.message };
|
||
}
|
||
} catch (error) {
|
||
failCount++;
|
||
failEpisodes.push(originalEpisode.episode_number);
|
||
const errorMsg = error.response?.data?.message || error.message;
|
||
errors.push(`第${originalEpisode.episode_number}集: ${errorMsg}`);
|
||
return { success: false, episode: originalEpisode.episode_number, error: errorMsg };
|
||
}
|
||
});
|
||
|
||
// 等待所有刷新完成
|
||
await Promise.all(refreshPromises);
|
||
|
||
// 显示结果提示
|
||
const showName = episode.show_name || '未知剧集';
|
||
const seasonNumber = episode.season_number;
|
||
|
||
if (successCount > 0 && failCount === 0) {
|
||
// 全部成功
|
||
const episodeRange = this.formatEpisodeRange(successEpisodes);
|
||
this.showToast(`《${showName}》第 ${seasonNumber} 季 · ${episodeRange}刷新成功`);
|
||
} else if (successCount > 0 && failCount > 0) {
|
||
// 部分成功:使用顿号明确区分成功和失败的集数
|
||
const successRange = this.formatEpisodeRange(successEpisodes, true);
|
||
const failRange = this.formatEpisodeRange(failEpisodes, true);
|
||
this.showToast(`《${showName}》第 ${seasonNumber} 季 · ${successRange}刷新成功,${failRange}刷新失败`);
|
||
console.warn('部分刷新失败:', errors);
|
||
} else {
|
||
// 全部失败
|
||
const episodeRange = this.formatEpisodeRange(failEpisodes);
|
||
this.showToast(`《${showName}》第 ${seasonNumber} 季 · ${episodeRange}刷新失败`);
|
||
console.error('集刷新失败:', errors);
|
||
}
|
||
|
||
} else {
|
||
// 单集:直接刷新
|
||
if (!episode.episode_number) {
|
||
console.warn('单集缺少集号参数', episode);
|
||
this.showToast('缺少集号信息');
|
||
return;
|
||
}
|
||
|
||
const response = await axios.get('/api/calendar/refresh_episode', {
|
||
params: {
|
||
tmdb_id: episode.tmdb_id,
|
||
season_number: episode.season_number,
|
||
episode_number: episode.episode_number
|
||
}
|
||
});
|
||
|
||
if (response.data.success) {
|
||
this.showToast(response.data.message || '单集刷新成功');
|
||
} else {
|
||
this.showToast(response.data.message || '单集刷新失败');
|
||
}
|
||
}
|
||
|
||
// 热更新数据
|
||
await this.refreshCalendarData();
|
||
|
||
} catch (error) {
|
||
console.error('刷新单集元数据失败:', error);
|
||
this.showToast('刷新失败:' + (error.response?.data?.message || error.message));
|
||
}
|
||
},
|
||
|
||
// 刷新整个季的元数据(内容管理视图使用)
|
||
async refreshSeasonMetadata(task) {
|
||
try {
|
||
if (!task || !task.match_tmdb_id) {
|
||
console.warn('刷新季元数据失败:缺少必要参数', task);
|
||
return;
|
||
}
|
||
|
||
// 获取季数:仅使用任务匹配到的季(未匹配则不继续)
|
||
const season_number = task.matched_latest_season_number;
|
||
if (!season_number) {
|
||
this.showToast('该任务未匹配季信息');
|
||
return;
|
||
}
|
||
|
||
// 先刷新剧级别详情(更新节目状态/最新季/海报等)
|
||
try {
|
||
await axios.get('/api/calendar/refresh_show', {
|
||
params: { tmdb_id: task.match_tmdb_id }
|
||
});
|
||
} catch (e) {
|
||
// 忽略失败,继续刷新季
|
||
console.warn('刷新剧详情失败(忽略继续):', e);
|
||
}
|
||
|
||
const response = await axios.get('/api/calendar/refresh_season', {
|
||
params: {
|
||
tmdb_id: task.match_tmdb_id,
|
||
season_number: season_number
|
||
}
|
||
});
|
||
|
||
if (response.data.success) {
|
||
// 显示成功提示
|
||
this.showToast(response.data.message || '季元数据刷新成功');
|
||
|
||
// 刷新任务数据,确保节目状态(如 本季终/已完结/已取消)及时更新
|
||
// 同时更新集数数据(已转存集数/已播出集数/节目总集数)
|
||
try {
|
||
const tasksResponse = await axios.get('/api/calendar/tasks');
|
||
if (tasksResponse.data && tasksResponse.data.success) {
|
||
this.calendar.tasks = tasksResponse.data.data.tasks;
|
||
this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name);
|
||
// 重新构建进度映射,确保任务列表和管理视图的集数数据正确更新
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
|
||
// 重新计算内容类型,确保类型按钮能热更新
|
||
const rawTypes = tasksResponse.data.data.content_types || [];
|
||
this.updateContentTypes(rawTypes);
|
||
}
|
||
} catch (e) {}
|
||
|
||
// 热更新日历与本地剧集数据
|
||
await this.refreshCalendarData();
|
||
} else {
|
||
this.showToast(response.data.message || '季元数据刷新失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('刷新季元数据失败:', error);
|
||
this.showToast('季元数据刷新失败:' + (error.response?.data?.message || error.message));
|
||
}
|
||
},
|
||
|
||
// 打开编辑元数据模态框
|
||
openEditMetadataModal(task) {
|
||
try {
|
||
if (!task) return;
|
||
// 预填充表单数据
|
||
const currentName = task.task_name || '';
|
||
const currentType = this.getContentTypeCN(task.content_type) || '';
|
||
const currentTmdbId = (task.match && task.match.tmdb_id) || task.match_tmdb_id || (task.calendar_info && task.calendar_info.match && task.calendar_info.match.tmdb_id) || '';
|
||
// 仅使用匹配季:若未匹配则为空,由界面表现为未匹配
|
||
const currentSeason = task.matched_latest_season_number || '';
|
||
const matchedName = task.matched_show_name || '';
|
||
const matchedYear = task.matched_year || '';
|
||
|
||
this.editMetadata = {
|
||
visible: true,
|
||
original: {
|
||
task_name: currentName,
|
||
content_type: task.content_type || '',
|
||
tmdb_id: currentTmdbId || '',
|
||
season_number: currentSeason || ''
|
||
},
|
||
form: {
|
||
task_name: currentName,
|
||
content_type: task.content_type || '',
|
||
tmdb_id: '',
|
||
season_number: currentSeason || 1,
|
||
custom_poster_url: ''
|
||
},
|
||
display: {
|
||
matched_label: matchedName ? `${matchedName}${matchedYear ? ' (' + matchedYear + ')' : ''}` : '未匹配',
|
||
matched_tmdb_id: currentTmdbId || '',
|
||
matched_season_number: currentSeason || '',
|
||
seasonInputWidth: '32px'
|
||
},
|
||
hint: `若匹配结果不正确,请前往 <a href="https://www.themoviedb.org/" target="_blank" class="tmdb-link">TMDB</a> 搜索对应的正确条目,并使用该条目网址末尾的数字(即 TMDB ID)来修正匹配`
|
||
};
|
||
|
||
$('#editMetadataModal').modal('show');
|
||
// 初始化季数输入宽度
|
||
this.$nextTick(() => {
|
||
try {
|
||
const initVal = String(this.editMetadata.form.season_number || '1');
|
||
const px = this.measureSeasonInputWidth(initVal);
|
||
this.$set(this.editMetadata.display, 'seasonInputWidth', px + 'px');
|
||
} catch (e) {}
|
||
});
|
||
// 若已匹配但没有季数信息,则从后端获取最新季数用于展示
|
||
try {
|
||
const tid = this.editMetadata.display.matched_tmdb_id;
|
||
const hasSeason = !!this.editMetadata.display.matched_season_number;
|
||
if (tid && !hasSeason) {
|
||
axios.get('/api/calendar/show_info', { params: { tmdb_id: tid } })
|
||
.then(res => {
|
||
if (res.data && res.data.success && res.data.data) {
|
||
const sn = res.data.data.latest_season_number;
|
||
if (sn) {
|
||
this.$set(this.editMetadata.display, 'matched_season_number', sn);
|
||
}
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
} catch (e) {}
|
||
} catch (e) {
|
||
this.showToast('打开编辑失败');
|
||
}
|
||
},
|
||
|
||
// 保存编辑元数据
|
||
async saveEditMetadata() {
|
||
try {
|
||
this.editMetadata.loading = true;
|
||
// 立即刷新一次视图,确保标题处 spinner 及时渲染
|
||
await this.$nextTick();
|
||
// 直接强制显示并触发一次重绘,确保首帧可见
|
||
try {
|
||
var __sp = document.querySelector('#editMetadataModal .modal-title .spinner-border');
|
||
if (__sp) {
|
||
__sp.style.display = 'inline-block';
|
||
void __sp.offsetHeight;
|
||
}
|
||
} catch (e) {}
|
||
// 再让出到宏任务队列,确保浏览器先完成一次绘制
|
||
await new Promise(function(resolve){ setTimeout(resolve, 0); });
|
||
if (!this.editMetadata || !this.editMetadata.form) return;
|
||
const payload = {
|
||
task_name: this.editMetadata.original.task_name,
|
||
new_task_name: this.editMetadata.form.task_name,
|
||
new_content_type: this.editMetadata.form.content_type,
|
||
new_tmdb_id: this.editMetadata.form.tmdb_id,
|
||
new_season_number: this.editMetadata.form.season_number,
|
||
custom_poster_url: this.editMetadata.form.custom_poster_url
|
||
};
|
||
|
||
// 如果没有任何变化则直接关闭
|
||
const noNameChange = (payload.new_task_name || '') === (this.editMetadata.original.task_name || '');
|
||
const noTypeChange = (payload.new_content_type || '') === (this.editMetadata.original.content_type || '');
|
||
const noRematch = !(payload.new_tmdb_id && String(payload.new_tmdb_id).trim()) && !(payload.new_season_number && String(payload.new_season_number).trim());
|
||
const noPosterChange = !(payload.custom_poster_url && String(payload.custom_poster_url).trim());
|
||
if (noNameChange && noTypeChange && noRematch && noPosterChange) {
|
||
$('#editMetadataModal').modal('hide');
|
||
return;
|
||
}
|
||
|
||
const res = await axios.post('/api/calendar/edit_metadata', payload);
|
||
if (res.data && res.data.success) {
|
||
this.showToast(res.data.message || '保存成功');
|
||
$('#editMetadataModal').modal('hide');
|
||
// 热更新任务与日历
|
||
// 避免触发“未保存修改”提示:本次更新由后端变更引发
|
||
this.suppressConfigModifiedOnce = true;
|
||
// 本次操作来自编辑元数据,不应提示未保存
|
||
this.configModified = false;
|
||
// 触发海报缓存穿透(按节目维度):尽可能多地命中该节目的不同名称键
|
||
try {
|
||
const nowTick = Date.now();
|
||
const eid = (this.editMetadata.display && this.editMetadata.display.matched_tmdb_id) || (this.editMetadata.original && this.editMetadata.original.tmdb_id);
|
||
const taskName = (this.editMetadata.original && this.editMetadata.original.task_name) || '';
|
||
const matchedLabel = (this.editMetadata.display && this.editMetadata.display.matched_label) || '';
|
||
const normalizeMatchedName = (label) => {
|
||
try {
|
||
// 去掉类似 "名称 (2023)" 的年份后缀
|
||
const m = String(label).trim().match(/^(.*?)(\s*\(\d{4}\))?$/);
|
||
return (m && m[1]) ? m[1].trim() : String(label).trim();
|
||
} catch (e) { return String(label || '').trim(); }
|
||
};
|
||
const normalizedMatched = normalizeMatchedName(matchedLabel);
|
||
// 从任务映射中找 show_name
|
||
let showName = '';
|
||
try {
|
||
if (taskName && this.calendar && this.calendar.taskMapByName && this.calendar.taskMapByName[taskName]) {
|
||
showName = (this.calendar.taskMapByName[taskName].show_name || '').trim();
|
||
}
|
||
} catch (e) {}
|
||
|
||
if (eid) { this.$set(this.imageCacheBustById, eid, nowTick); }
|
||
if (taskName) { this.$set(this.imageCacheBustByShowName, taskName, nowTick); }
|
||
if (showName) { this.$set(this.imageCacheBustByShowName, showName, nowTick); }
|
||
if (normalizedMatched) { this.$set(this.imageCacheBustByShowName, normalizedMatched, nowTick); }
|
||
if (!eid && !taskName && !showName && !normalizedMatched) { this.imageCacheBustTick = nowTick; }
|
||
} catch (e) { this.imageCacheBustTick = Date.now(); }
|
||
// 并行刷新:优先尽快更新任务列表的类型按钮与映射
|
||
try {
|
||
const tasksPromise = axios.get('/api/calendar/tasks');
|
||
// 同时后台刷新日历数据(不阻塞UI)
|
||
const refreshPromise = this.refreshCalendarData().catch(() => {});
|
||
const tasksResponse = await tasksPromise;
|
||
if (tasksResponse.data && tasksResponse.data.success) {
|
||
this.calendar.tasks = tasksResponse.data.data.tasks || [];
|
||
try {
|
||
this.calendar.taskMapByName = {};
|
||
(this.calendar.tasks || []).forEach(t => {
|
||
const key = (t.task_name || t.taskname || '').trim();
|
||
if (key) this.calendar.taskMapByName[key] = t;
|
||
});
|
||
} catch (e) { this.calendar.taskMapByName = {}; }
|
||
// 同步更新任务列表类型集合(热更新左上角类型按钮)
|
||
try {
|
||
let rawTypes = (tasksResponse.data && tasksResponse.data.data && tasksResponse.data.data.content_types) || [];
|
||
if (!rawTypes || rawTypes.length === 0) {
|
||
rawTypes = Array.from(new Set((this.calendar.tasks || []).map(t => (t && t.content_type) || '').filter(Boolean)));
|
||
}
|
||
const known = ['tv','anime','variety','documentary'];
|
||
const uniq = Array.from(new Set(rawTypes.filter(Boolean)));
|
||
const ordered = uniq.sort((a,b) => {
|
||
const ia = known.indexOf(a); const ib = known.indexOf(b);
|
||
if (ia === -1 && ib === -1) return 0;
|
||
if (ia === -1) return 1;
|
||
if (ib === -1) return -1;
|
||
return ia - ib;
|
||
});
|
||
this.tasklist.contentTypes = ordered;
|
||
const validSet = ['all', ...ordered];
|
||
if (!validSet.includes(this.tasklist.selectedType)) {
|
||
this.tasklist.selectedType = 'all';
|
||
}
|
||
} catch (e) { /* 忽略类型热更新异常 */ }
|
||
}
|
||
await refreshPromise;
|
||
// 再次触发缓存穿透,保证刷新后的数据也使用新时间戳
|
||
try {
|
||
const nowTick = Date.now();
|
||
const eid = (this.editMetadata.display && this.editMetadata.display.matched_tmdb_id) || (this.editMetadata.original && this.editMetadata.original.tmdb_id);
|
||
const taskName = (this.editMetadata.original && this.editMetadata.original.task_name) || '';
|
||
const matchedLabel = (this.editMetadata.display && this.editMetadata.display.matched_label) || '';
|
||
const normalizeMatchedName = (label) => {
|
||
try {
|
||
const m = String(label).trim().match(/^(.*?)(\s*\(\d{4}\))?$/);
|
||
return (m && m[1]) ? m[1].trim() : String(label).trim();
|
||
} catch (e) { return String(label || '').trim(); }
|
||
};
|
||
const normalizedMatched = normalizeMatchedName(matchedLabel);
|
||
let showName = '';
|
||
try {
|
||
if (taskName && this.calendar && this.calendar.taskMapByName && this.calendar.taskMapByName[taskName]) {
|
||
showName = (this.calendar.taskMapByName[taskName].show_name || '').trim();
|
||
}
|
||
} catch (e) {}
|
||
|
||
if (eid) { this.$set(this.imageCacheBustById, eid, nowTick); }
|
||
if (taskName) { this.$set(this.imageCacheBustByShowName, taskName, nowTick); }
|
||
if (showName) { this.$set(this.imageCacheBustByShowName, showName, nowTick); }
|
||
if (normalizedMatched) { this.$set(this.imageCacheBustByShowName, normalizedMatched, nowTick); }
|
||
if (!eid && !taskName && !showName && !normalizedMatched) { this.imageCacheBustTick = nowTick; }
|
||
} catch (e) { this.imageCacheBustTick = Date.now(); }
|
||
// 刷新链路结束后,确保未保存标记为false
|
||
this.configModified = false;
|
||
} catch (e) { /* 忽略后台刷新异常,前端不报错 */ }
|
||
} else {
|
||
this.showToast(res.data.message || '保存失败');
|
||
}
|
||
} catch (e) {
|
||
this.showToast('保存失败:' + (e.response?.data?.message || e.message));
|
||
} finally {
|
||
this.editMetadata.loading = false;
|
||
}
|
||
},
|
||
// 根据输入内容自适应季数输入框宽度,最小32px
|
||
autoSizeSeasonInput(e) {
|
||
try {
|
||
const el = e && e.target;
|
||
if (!el) return;
|
||
const val = String(el.value || '');
|
||
// 使用隐藏量尺精确测量当前文本宽度
|
||
let ruler = document.getElementById('season-width-ruler');
|
||
if (!ruler) {
|
||
ruler = document.createElement('span');
|
||
ruler.id = 'season-width-ruler';
|
||
ruler.style.position = 'absolute';
|
||
ruler.style.visibility = 'hidden';
|
||
ruler.style.whiteSpace = 'pre';
|
||
ruler.style.font = window.getComputedStyle(el).font;
|
||
document.body.appendChild(ruler);
|
||
}
|
||
ruler.style.font = window.getComputedStyle(el).font;
|
||
ruler.textContent = val || '1';
|
||
const textWidth = ruler.getBoundingClientRect().width;
|
||
// 左右 padding(16) + 边框(2)
|
||
const px = Math.max(32, Math.ceil(textWidth + 16 + 2));
|
||
if (this.editMetadata && this.editMetadata.display) {
|
||
this.$set(this.editMetadata.display, 'seasonInputWidth', px + 'px');
|
||
} else {
|
||
el.style.width = px + 'px';
|
||
}
|
||
} catch (err) {}
|
||
},
|
||
// 供初始化时测量使用
|
||
measureSeasonInputWidth(val) {
|
||
try {
|
||
const el = document.querySelector('#editMetadataModal .edit-season-number');
|
||
if (!el) return 32;
|
||
let ruler = document.getElementById('season-width-ruler');
|
||
if (!ruler) {
|
||
ruler = document.createElement('span');
|
||
ruler.id = 'season-width-ruler';
|
||
ruler.style.position = 'absolute';
|
||
ruler.style.visibility = 'hidden';
|
||
ruler.style.whiteSpace = 'pre';
|
||
document.body.appendChild(ruler);
|
||
}
|
||
const style = window.getComputedStyle(el);
|
||
ruler.style.font = style.font;
|
||
ruler.textContent = String(val || '1');
|
||
const textWidth = ruler.getBoundingClientRect().width;
|
||
return Math.max(32, Math.ceil(textWidth + 16 + 2));
|
||
} catch (e) { return 32; }
|
||
},
|
||
|
||
// 格式化集数范围显示
|
||
formatEpisodeRange(episodeNumbers, useComma = false) {
|
||
if (!episodeNumbers || episodeNumbers.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
// 排序集数
|
||
const sortedEpisodes = [...episodeNumbers].sort((a, b) => a - b);
|
||
|
||
if (sortedEpisodes.length === 1) {
|
||
return `第 ${sortedEpisodes[0]} 集`;
|
||
}
|
||
|
||
// 如果强制使用顿号(用于部分成功的情况)
|
||
if (useComma) {
|
||
return `第 ${sortedEpisodes.join('、')} 集`;
|
||
}
|
||
|
||
// 检查是否为连续集数
|
||
const isConsecutive = sortedEpisodes.every((ep, index) => {
|
||
if (index === 0) return true;
|
||
return ep === sortedEpisodes[index - 1] + 1;
|
||
});
|
||
|
||
if (isConsecutive) {
|
||
// 连续集数:第 33 至 35 集
|
||
return `第 ${sortedEpisodes[0]} 至 ${sortedEpisodes[sortedEpisodes.length - 1]} 集`;
|
||
} else {
|
||
// 非连续集数:第 33、35、37 集
|
||
return `第 ${sortedEpisodes.join('、')} 集`;
|
||
}
|
||
},
|
||
|
||
// 是否需要自动刷新:默认30分钟节流(可根据需要调整)
|
||
shouldAutoRefreshCalendar() {
|
||
const key = 'calendar_last_auto_refresh';
|
||
const throttleMs = 30 * 60 * 1000; // 30分钟
|
||
try {
|
||
const last = parseInt(localStorage.getItem(key) || '0');
|
||
if (!last || (Date.now() - last) > throttleMs) return true;
|
||
} catch (e) {
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
|
||
// 计算“最近转存文件”的签名,便于快速判断是否有变化
|
||
calcLatestFilesSignature(mapObj) {
|
||
try {
|
||
if (!mapObj) return '';
|
||
const entries = Object.keys(mapObj).sort().map(k => `${k}:${mapObj[k]}`);
|
||
return entries.join('|');
|
||
} catch (e) { return ''; }
|
||
},
|
||
|
||
// 启动后台监听:每60秒轻量检查一次 task_latest_info,变更则触发热更新
|
||
startCalendarAutoWatch() {
|
||
try {
|
||
if (this.calendarAutoWatchTimer) return;
|
||
// 初始化当前签名(若任务面板已加载过最新信息可直接使用;否则置空)
|
||
this.calendarLatestFilesSignature = this.calcLatestFilesSignature(this.taskLatestFiles);
|
||
const tick = async () => {
|
||
try {
|
||
const res = await axios.get('/task_latest_info');
|
||
if (res.data && res.data.success) {
|
||
const latestFiles = res.data.data.latest_files || {};
|
||
const sig = this.calcLatestFilesSignature(latestFiles);
|
||
if (sig && sig !== this.calendarLatestFilesSignature) {
|
||
// 更新签名,触发热更新
|
||
this.calendarLatestFilesSignature = sig;
|
||
// 先更新任务面板数据(若有显示)
|
||
this.taskLatestFiles = latestFiles;
|
||
this.taskLatestRecords = res.data.data.latest_records || {};
|
||
// 同步重建“已转存”判定所需的进度映射,确保UI立即反映
|
||
try {
|
||
this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles);
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
} catch (e) {}
|
||
// 拉取并热更新日历
|
||
await this.refreshCalendarData();
|
||
// 同步“今日更新”
|
||
try { await this.loadTodayUpdatesLocal(); } catch (e) {}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// 忽略错误,下一轮继续
|
||
}
|
||
};
|
||
this.calendarAutoWatchTickRef = tick;
|
||
// 立即执行一次检查(不阻塞UI),随后每60秒执行
|
||
setTimeout(tick, 0);
|
||
const baseIntervalMs = this.calendar && this.calendar.manageMode ? 5 * 1000 : 60 * 1000;
|
||
if (!document.hidden) {
|
||
this.calendarAutoWatchTimer = setInterval(tick, baseIntervalMs);
|
||
}
|
||
// 页面可见性变化:隐藏时暂停,显示时恢复并立刻检查(SSE 在时不重复启轮询)
|
||
const onFocusOrVisible = () => tick();
|
||
const onVisibilityChange = () => {
|
||
if (document.hidden) {
|
||
if (this.calendarAutoWatchTimer) {
|
||
clearInterval(this.calendarAutoWatchTimer);
|
||
this.calendarAutoWatchTimer = null;
|
||
}
|
||
} else {
|
||
if (!this.appSSE && !this.calendarAutoWatchTimer && this.calendarAutoWatchTickRef) {
|
||
const baseIntervalMs = this.calendar && this.calendar.manageMode ? 5 * 1000 : 60 * 1000;
|
||
this.calendarAutoWatchTimer = setInterval(this.calendarAutoWatchTickRef, baseIntervalMs);
|
||
this.calendarAutoWatchTickRef();
|
||
}
|
||
}
|
||
};
|
||
this.calendarAutoWatchFocusHandler = onFocusOrVisible;
|
||
this.calendarAutoWatchVisibilityHandler = onVisibilityChange;
|
||
window.addEventListener('focus', this.calendarAutoWatchFocusHandler);
|
||
document.addEventListener('visibilitychange', this.calendarAutoWatchVisibilityHandler);
|
||
|
||
// 建立/复用全局 SSE 连接(成功建立后停用轮询,失败时回退轮询)
|
||
try {
|
||
this.ensureGlobalSSE();
|
||
if (this.appSSE && !this.calendarSSEListenerAdded) {
|
||
const onChanged = async (ev) => {
|
||
try {
|
||
// 解析变更原因(后端通过 SSE data 传递)
|
||
let changeReason = '';
|
||
try { changeReason = JSON.parse(ev && ev.data || '{}').reason || ''; } catch (e) {}
|
||
|
||
// 如果是海报更新通知(poster_updated:<tmdb_id>),为该节目设置缓存穿透,避免使用旧缓存
|
||
try {
|
||
if (typeof changeReason === 'string' && changeReason.startsWith('poster_updated:')) {
|
||
const idStr = changeReason.split(':')[1] || '';
|
||
const eid = parseInt(idStr, 10);
|
||
if (!isNaN(eid)) {
|
||
const nowTick = Date.now();
|
||
this.$set(this.imageCacheBustById, eid, nowTick);
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
|
||
// 先拉取最新转存信息并重建映射(用于管理视图与进度判定)
|
||
try {
|
||
const latestRes = await axios.get('/task_latest_info');
|
||
if (latestRes.data && latestRes.data.success) {
|
||
const latestFiles = latestRes.data.data.latest_files || {};
|
||
this.taskLatestFiles = latestFiles;
|
||
this.taskLatestRecords = latestRes.data.data.latest_records || {};
|
||
this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles);
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
}
|
||
} catch (e) {}
|
||
|
||
// 重新加载任务数据,确保内容管理页面能热更新
|
||
try {
|
||
const tasksResponse = await axios.get('/api/calendar/tasks');
|
||
if (tasksResponse.data.success) {
|
||
this.calendar.tasks = tasksResponse.data.data.tasks;
|
||
this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name);
|
||
// 重新构建进度映射
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
|
||
// 重新计算内容类型,确保类型按钮能热更新
|
||
const rawTypes = tasksResponse.data.data.content_types || [];
|
||
this.updateContentTypes(rawTypes);
|
||
}
|
||
} catch (e) {}
|
||
|
||
// 若为任务编辑/保存操作,刷新任务列表 formData.tasklist,实现任务列表页面热更新
|
||
// 注意:task_updated 事件不重新加载任务列表,因为用户刚刚保存的数据就是最新的
|
||
try {
|
||
if (changeReason === 'edit_metadata') {
|
||
const dataRes = await axios.get('/data');
|
||
if (dataRes.data && dataRes.data.success) {
|
||
const cfg = dataRes.data.data || {};
|
||
const oldTaskCount = (this.formData.tasklist || []).length;
|
||
// 后端推送导致的任务列表更新,不应触发"未保存修改"提示
|
||
this.suppressConfigModifiedOnce = true;
|
||
this.formData.tasklist = cfg.tasklist || [];
|
||
// 同步任务名集合用于筛选
|
||
this.calendar.taskNames = (this.formData.tasklist || []).map(t => t.taskname).filter(Boolean);
|
||
// 如任务数量变化,重建与任务相关的最新文件映射的键集合
|
||
if ((this.formData.tasklist || []).length !== oldTaskCount) {
|
||
try {
|
||
this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(this.taskLatestFiles || {});
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
|
||
// 如果是转存记录相关的通知,立即刷新今日更新数据
|
||
if (changeReason === 'transfer_record_created' || changeReason === 'transfer_record_updated' || changeReason === 'batch_rename_completed' || changeReason === 'crontab_task_completed') {
|
||
try { await this.loadTodayUpdatesLocal(); } catch (e) {}
|
||
}
|
||
|
||
// 再仅本地读取并热更新日历/海报视图
|
||
await this.loadCalendarEpisodesLocal();
|
||
this.initializeCalendarDates();
|
||
// 并行刷新今日更新数据
|
||
try { await this.loadTodayUpdatesLocal(); } catch (e) {}
|
||
} catch (e) {}
|
||
};
|
||
this.onCalendarChangedHandler = onChanged;
|
||
this.appSSE.addEventListener('calendar_changed', onChanged);
|
||
this.calendarSSEListenerAdded = true;
|
||
// onopen/onerror 已集中在 ensureGlobalSSE,无需重复设置
|
||
}
|
||
} catch (e) {
|
||
// 忽略 SSE 失败,继续使用轮询
|
||
}
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
},
|
||
|
||
// 停止后台监听并移除事件
|
||
stopCalendarAutoWatch() {
|
||
try {
|
||
if (this.calendarAutoWatchTimer) {
|
||
clearInterval(this.calendarAutoWatchTimer);
|
||
this.calendarAutoWatchTimer = null;
|
||
}
|
||
if (this.calendarAutoWatchFocusHandler) {
|
||
window.removeEventListener('focus', this.calendarAutoWatchFocusHandler);
|
||
this.calendarAutoWatchFocusHandler = null;
|
||
}
|
||
if (this.calendarAutoWatchVisibilityHandler) {
|
||
document.removeEventListener('visibilitychange', this.calendarAutoWatchVisibilityHandler);
|
||
this.calendarAutoWatchVisibilityHandler = null;
|
||
}
|
||
// 不再关闭全局 SSE;仅移除本地监听(如有需要)
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
},
|
||
|
||
// 选择内容类型
|
||
selectCalendarType(type) {
|
||
this.calendar.selectedType = type;
|
||
try {
|
||
localStorage.setItem('calendar_selected_type', type);
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
// 重新构建月视图数据以应用筛选(天内episodes依赖selectedType)
|
||
this.initializeCalendarDates();
|
||
},
|
||
|
||
// 清除筛选器
|
||
clearCalendarFilter(filterType) {
|
||
if (filterType === 'nameFilter') {
|
||
this.calendar.nameFilter = '';
|
||
} else if (filterType === 'taskFilter') {
|
||
this.calendar.taskFilter = '';
|
||
} else if (filterType === 'statusFilter') {
|
||
this.calendar.statusFilter = '';
|
||
}
|
||
},
|
||
|
||
// 更新内容类型列表(用于热更新)
|
||
updateContentTypes(rawTypes) {
|
||
const knownOrderWithoutOther = ['tv', 'anime', 'variety', 'documentary'];
|
||
const hasOther = rawTypes.includes('other');
|
||
const typeSet = new Set(rawTypes);
|
||
const orderedKnown = knownOrderWithoutOther.filter(t => typeSet.has(t));
|
||
const unknownTypes = rawTypes.filter(t => !knownOrderWithoutOther.concat(['other']).includes(t));
|
||
this.calendar.contentTypes = [
|
||
...orderedKnown,
|
||
...unknownTypes,
|
||
...(hasOther ? ['other'] : [])
|
||
];
|
||
},
|
||
|
||
// 切换视图模式
|
||
toggleCalendarViewMode() {
|
||
this.calendar.viewMode = this.calendar.viewMode === 'poster' ? 'month' : 'poster';
|
||
try {
|
||
localStorage.setItem('calendar_view_mode', this.calendar.viewMode);
|
||
} catch (e) {
|
||
console.warn('无法持久化保存日历视图模式:', e);
|
||
}
|
||
this.initializeCalendarDates();
|
||
},
|
||
|
||
// 切换合并集模式
|
||
toggleCalendarMergeEpisodes() {
|
||
this.calendar.mergeEpisodes = !this.calendar.mergeEpisodes;
|
||
try {
|
||
localStorage.setItem('calendar_merge_episodes', this.calendar.mergeEpisodes.toString());
|
||
} catch (e) {
|
||
console.warn('无法持久化保存合并集设置:', e);
|
||
}
|
||
// 重新初始化日历数据以应用新的合并集设置
|
||
this.initializeCalendarDates();
|
||
},
|
||
|
||
// 更改日期
|
||
changeCalendarDate(action) {
|
||
const currentDate = new Date(this.calendar.currentDate);
|
||
|
||
switch (action) {
|
||
case 'prevDay':
|
||
currentDate.setDate(currentDate.getDate() - 1);
|
||
break;
|
||
case 'nextDay':
|
||
currentDate.setDate(currentDate.getDate() + 1);
|
||
break;
|
||
case 'prevWeek':
|
||
currentDate.setDate(currentDate.getDate() - 7);
|
||
break;
|
||
case 'nextWeek':
|
||
currentDate.setDate(currentDate.getDate() + 7);
|
||
break;
|
||
case 'prevMonth':
|
||
currentDate.setMonth(currentDate.getMonth() - 1);
|
||
break;
|
||
case 'nextMonth':
|
||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||
break;
|
||
}
|
||
|
||
this.calendar.currentDate = currentDate;
|
||
this.initializeCalendarDates();
|
||
},
|
||
|
||
// 回到今天
|
||
goToToday() {
|
||
this.calendar.currentDate = new Date();
|
||
this.initializeCalendarDates();
|
||
},
|
||
|
||
// 处理海报悬停
|
||
handleCalendarPosterHover(event, episode) {
|
||
// 从图片提取颜色并为覆盖层生成渐变背景(与影视发现页一致)
|
||
const posterElement = event.currentTarget;
|
||
const imgElement = posterElement.querySelector('img');
|
||
const overlayElement = posterElement.querySelector('.calendar-poster-overlay');
|
||
const overviewElement = overlayElement ? overlayElement.querySelector('.info-line.overview') : null;
|
||
|
||
if (imgElement && overlayElement) {
|
||
if (imgElement.complete) {
|
||
const gradient = this.createGradientFromImage(imgElement);
|
||
overlayElement.style.background = gradient;
|
||
} else {
|
||
overlayElement.style.background = 'var(--dark-text-color)';
|
||
}
|
||
}
|
||
|
||
// 按行数截断:根据宽度估算每行字符数与行高,保证最后一行完整并追加省略号
|
||
if (overviewElement) {
|
||
try {
|
||
const maxHeightRatio = 0.6; // 简介最高占比60%
|
||
const posterHeight = posterElement.clientHeight || 0;
|
||
const overlayStyles = window.getComputedStyle(overviewElement);
|
||
const lineHeight = parseFloat(overlayStyles.lineHeight) || 18; // 兜底18px
|
||
const fontSize = parseFloat(overlayStyles.fontSize) || 14; // 兜底14px
|
||
const containerWidth = posterElement.clientWidth || 0;
|
||
if (!posterHeight || !containerWidth || !lineHeight) return;
|
||
|
||
const maxHeight = Math.floor(posterHeight * maxHeightRatio);
|
||
const maxLines = Math.max(1, Math.floor(maxHeight / lineHeight));
|
||
|
||
// 基于经验的单行字符估算(中文字符大约 ~0.55 * 宽度/字体大小)
|
||
const charsPerLine = Math.max(6, Math.floor(containerWidth / (fontSize * 0.55)));
|
||
|
||
const fullText = overviewElement.getAttribute('data-fulltext') || overviewElement.textContent || '';
|
||
// 如果天然高度未超过限制则不处理
|
||
overviewElement.textContent = fullText;
|
||
overviewElement.style.maxHeight = '';
|
||
const naturalHeight = overviewElement.scrollHeight;
|
||
if (naturalHeight <= maxHeight) {
|
||
return; // 文本本来就不超出,无需截断
|
||
}
|
||
|
||
// 目标字符上限(留出省略号空间)
|
||
const targetChars = Math.max(1, (maxLines * charsPerLine) - 1);
|
||
|
||
// 二分查找截断位置,确保不超过最大高度
|
||
let left = 0, right = fullText.length, best = 0;
|
||
while (left <= right) {
|
||
const mid = Math.floor((left + right) / 2);
|
||
const trial = fullText.slice(0, Math.min(mid, targetChars)) + '…';
|
||
overviewElement.textContent = trial;
|
||
if (overviewElement.scrollHeight <= maxHeight) {
|
||
best = mid;
|
||
left = mid + 1;
|
||
} else {
|
||
right = mid - 1;
|
||
}
|
||
}
|
||
|
||
const finalText = fullText.length > best ? (fullText.slice(0, Math.min(best, targetChars)) + '…') : fullText;
|
||
overviewElement.textContent = finalText;
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
}
|
||
},
|
||
|
||
// 隐藏海报悬停
|
||
hideCalendarPosterHover() {
|
||
// 仅由CSS控制透明度,不重置背景以保留取色渐变
|
||
},
|
||
|
||
// 获取剧集海报URL
|
||
getEpisodePosterUrl(episode) {
|
||
// 优先使用本地缓存海报
|
||
if (episode.poster_local_path) {
|
||
// 确保路径以 / 开头,避免相对路径问题
|
||
const path = episode.poster_local_path.startsWith('/')
|
||
? episode.poster_local_path
|
||
: '/' + episode.poster_local_path;
|
||
// 优先使用按节目维度的缓存穿透参数
|
||
let tick = 0;
|
||
try {
|
||
const eid = (episode.tmdb_id || episode.tv_id || episode.id);
|
||
const sname = (episode.show_name || '').trim();
|
||
if (eid && this.imageCacheBustById && this.imageCacheBustById[eid]) {
|
||
tick = this.imageCacheBustById[eid];
|
||
} else if (sname && this.imageCacheBustByShowName && this.imageCacheBustByShowName[sname]) {
|
||
tick = this.imageCacheBustByShowName[sname];
|
||
} else {
|
||
// 优化:任务列表海报视图下,未命中特定节目/名称的情况下不使用全局穿透参数,避免本地海报反复绕过浏览器缓存
|
||
// 仅对日历等视图保留全局 bust(以确保热更新及时生效)
|
||
if (this.activeTab === 'calendar') {
|
||
tick = this.imageCacheBustTick || 0;
|
||
} else {
|
||
tick = 0;
|
||
}
|
||
}
|
||
} catch (e) { tick = this.imageCacheBustTick || 0; }
|
||
return tick ? `${path}?t=${tick}` : path;
|
||
}
|
||
|
||
// 如果没有海报,返回默认图片
|
||
return '/static/images/no-poster.svg';
|
||
},
|
||
|
||
// 处理图片加载错误
|
||
handleCalendarImageError(event) {
|
||
console.warn('海报加载失败:', event.target.src);
|
||
event.target.src = '/static/images/no-poster.svg';
|
||
},
|
||
|
||
// ===== 精简版“已转存”判定:仅依据任务列表最近转存文件构建的映射 =====
|
||
// 提取进度(优先按任务名命中,其次按剧名命中)
|
||
getSimpleProgress(episode) {
|
||
if (!episode) return null;
|
||
// 1) 任务名映射
|
||
const tname = episode.task_info && episode.task_info.task_name ? episode.task_info.task_name : null;
|
||
if (tname && this.calendar.progressByTaskName && this.calendar.progressByTaskName[tname]) {
|
||
const p = this.calendar.progressByTaskName[tname] || {};
|
||
const epNum = p.episode_number != null ? parseInt(p.episode_number) : null;
|
||
const air = p.air_date || null;
|
||
return { episode_number: isNaN(epNum) ? null : epNum, air_date: air };
|
||
}
|
||
// 2) 剧名映射
|
||
const sname = episode.show_name || null;
|
||
if (sname && this.calendar.progressByShowName && this.calendar.progressByShowName[sname]) {
|
||
const p = this.calendar.progressByShowName[sname] || {};
|
||
const epNum = p.episode_number != null ? parseInt(p.episode_number) : null;
|
||
const air = p.air_date || null;
|
||
return { episode_number: isNaN(epNum) ? null : epNum, air_date: air };
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// 用于比较的集号(合并集取末尾一集;否则取自身集号)
|
||
getEpisodeCompareNumber(episode) {
|
||
if (!episode) return null;
|
||
if (episode.is_merged && episode.episode_range && episode.episode_range.end != null) {
|
||
const n = parseInt(episode.episode_range.end);
|
||
return isNaN(n) ? null : n;
|
||
}
|
||
if (episode.episode_number != null) {
|
||
const n = parseInt(episode.episode_number);
|
||
return isNaN(n) ? null : n;
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// 统一判断“是否已转存/已完成”
|
||
isEpisodeReachedProgress(episode) {
|
||
const prog = this.getSimpleProgress(episode);
|
||
if (!prog) return false;
|
||
const lastNum = this.getEpisodeCompareNumber(episode);
|
||
if (prog.episode_number != null && lastNum != null) {
|
||
return lastNum <= prog.episode_number;
|
||
}
|
||
if (prog.air_date && episode && episode.air_date) {
|
||
return episode.air_date <= prog.air_date;
|
||
}
|
||
return false;
|
||
},
|
||
|
||
|
||
|
||
// 获取剧集显示集数
|
||
getEpisodeDisplayNumber(episode) {
|
||
if (episode.is_merged) {
|
||
// 合并集直接使用格式化后的集数
|
||
return episode.episode_number;
|
||
} else {
|
||
// 普通集数按原格式显示
|
||
return `S${episode.season_number.toString().padStart(2, '0')}E${episode.episode_number.toString().padStart(2, '0')}`;
|
||
}
|
||
},
|
||
|
||
// 获取仅集数格式(省略季)
|
||
getEpisodeOnlyNumber(episode) {
|
||
if (episode.is_merged) {
|
||
// 合并集:S01E34-E38 -> E34-E38
|
||
return episode.episode_number.replace(/^S\d+/, '');
|
||
} else {
|
||
// 普通集数:S01E06 -> E06
|
||
return `E${episode.episode_number.toString().padStart(2, '0')}`;
|
||
}
|
||
},
|
||
|
||
// 获取纯数字格式
|
||
getNumberOnly(episode) {
|
||
if (episode.is_merged) {
|
||
// 合并集:S01E34-E38 -> 34-38
|
||
return episode.episode_number.replace(/^S\d+E/, '').replace(/E/g, '');
|
||
} else {
|
||
// 普通集数:S01E06 -> 06
|
||
return episode.episode_number.toString().padStart(2, '0');
|
||
}
|
||
},
|
||
|
||
// 获取剧集提示信息
|
||
getEpisodeTooltip(episode) {
|
||
// 规范:集数悬停显示对应集在TMDB上的单集标题
|
||
// 普通:SxxExx 标题
|
||
// 合并:逐行列出每一集:SxxExx 标题
|
||
const pad2 = n => String(n).padStart(2, '0');
|
||
if (episode.is_merged && Array.isArray(episode.original_episodes) && episode.original_episodes.length) {
|
||
const lines = episode.original_episodes.map(ep => {
|
||
const s = ep.season_number ? pad2(ep.season_number) : pad2(episode.season_number || 1);
|
||
const e = ep.episode_number ? pad2(ep.episode_number) : '';
|
||
const title = ep.name || '';
|
||
return `S${s}E${e} ${title}`.trim();
|
||
});
|
||
return lines.join('\n');
|
||
}
|
||
const s = episode.season_number ? pad2(episode.season_number) : '01';
|
||
const e = episode.episode_number ? pad2(episode.episode_number) : '01';
|
||
const title = episode.name || '';
|
||
return `S${s}E${e} ${title}`.trim();
|
||
},
|
||
|
||
// 打开剧的TMDB页面
|
||
openShowTmdbPage(episode) {
|
||
try {
|
||
const tmdbId = episode.tmdb_id || (episode.task_info && episode.task_info.match && episode.task_info.match.tmdb_id) || (episode.task_info && episode.task_info.tmdb_id);
|
||
if (tmdbId) {
|
||
const url = `https://www.themoviedb.org/tv/${tmdbId}`;
|
||
window.open(url, '_blank');
|
||
}
|
||
} catch (e) {
|
||
console.warn('打开剧TMDB页面失败', e);
|
||
}
|
||
},
|
||
|
||
// 打开"内容管理"任务匹配到的节目的 TMDB 页面
|
||
openTaskMatchedTmdbPage(task) {
|
||
try {
|
||
if (!task) return;
|
||
const tmdbId = (task.match && task.match.tmdb_id) || task.match_tmdb_id || (task.calendar_info && task.calendar_info.match && task.calendar_info.match.tmdb_id) || task.tmdb_id;
|
||
if (tmdbId) {
|
||
const url = `https://www.themoviedb.org/tv/${tmdbId}`;
|
||
window.open(url, '_blank');
|
||
}
|
||
} catch (e) {
|
||
console.warn('打开任务匹配TMDB页面失败', e);
|
||
}
|
||
},
|
||
// 获取任务的 TMDB ID(用于任务列表海报视图)
|
||
getTaskTmdbId(task) {
|
||
try {
|
||
if (!task) return null;
|
||
// 优先从 calendar.tasks 中获取匹配的任务信息
|
||
const calendarTask = this.getCalendarTaskByName(task.taskname);
|
||
if (calendarTask) {
|
||
return (calendarTask.match && calendarTask.match.tmdb_id) || calendarTask.match_tmdb_id || calendarTask.tmdb_id;
|
||
}
|
||
// 如果 calendar.tasks 中没有,则从任务配置中获取
|
||
const cal = (task.calendar_info) || {};
|
||
const match = cal.match || {};
|
||
return match.tmdb_id || task.tmdb_id;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
},
|
||
// 获取任务的季数(用于任务列表海报视图)
|
||
getTaskSeasonNumber(task) {
|
||
try {
|
||
if (!task) return null;
|
||
// 优先从 calendar.tasks 中获取匹配的任务信息
|
||
const calendarTask = this.getCalendarTaskByName(task.taskname);
|
||
if (calendarTask) {
|
||
return (calendarTask.match && calendarTask.match.latest_season_number) ||
|
||
calendarTask.matched_latest_season_number ||
|
||
calendarTask.season_number;
|
||
}
|
||
// 如果 calendar.tasks 中没有,则从任务配置中获取
|
||
const cal = (task.calendar_info) || {};
|
||
const match = cal.match || {};
|
||
return match.latest_season_number || task.season_number;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
},
|
||
// 打开任务列表海报视图中任务的 TMDB 页面
|
||
openTaskTmdbPage(task) {
|
||
try {
|
||
if (!task) return;
|
||
const tmdbId = this.getTaskTmdbId(task);
|
||
if (tmdbId) {
|
||
// 获取匹配的季数
|
||
const seasonNumber = this.getTaskSeasonNumber(task);
|
||
if (seasonNumber) {
|
||
// 打开特定季的页面
|
||
const url = `https://www.themoviedb.org/tv/${tmdbId}/season/${seasonNumber}`;
|
||
window.open(url, '_blank');
|
||
} else {
|
||
// 如果没有季数信息,则打开整个节目的页面
|
||
const url = `https://www.themoviedb.org/tv/${tmdbId}`;
|
||
window.open(url, '_blank');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('打开任务TMDB页面失败', e);
|
||
}
|
||
},
|
||
|
||
// 打开集的TMDB页面(合并集打开最后一集)
|
||
openEpisodeTmdbPage(episode) {
|
||
try {
|
||
const tmdbId = episode.tmdb_id || (episode.task_info && episode.task_info.match && episode.task_info.match.tmdb_id) || (episode.task_info && episode.task_info.tmdb_id);
|
||
if (!tmdbId) return;
|
||
const season = episode.season_number || (episode.original_episodes && episode.original_episodes.length ? episode.original_episodes[0].season_number : 1);
|
||
const episodeNum = episode.is_merged && episode.episode_range ? episode.episode_range.end : episode.episode_number;
|
||
if (!season || !episodeNum) return;
|
||
const url = `https://www.themoviedb.org/tv/${tmdbId}/season/${parseInt(season)}/episode/${parseInt(episodeNum)}`;
|
||
window.open(url, '_blank');
|
||
} catch (e) {
|
||
console.warn('打开集TMDB页面失败', e);
|
||
}
|
||
},
|
||
|
||
// 获取插件配置的占位符文本
|
||
getPluginConfigPlaceholder(pluginName, key) {
|
||
const placeholders = {
|
||
aria2: {
|
||
host_port: "Aria2 RPC 地址,如:192.168.1.100:6800",
|
||
secret: "Aria2 RPC 密钥",
|
||
dir: "下载目录,需要 Aria2 拥有读写权限,如:/downloads"
|
||
},
|
||
alist: {
|
||
url: "AList 服务器地址,如:http://192.168.1.100:5244",
|
||
token: "AList 访问令牌,在 AList 的管理 - 设置 - 其他中获取",
|
||
storage_id: "AList 服务器夸克存储的 ID,多个 ID 用逗号分隔,如:1, 2, 3"
|
||
},
|
||
alist_strm: {
|
||
url: "AList Strm 服务器地址,如:http://192.168.1.100:5000",
|
||
cookie: "AList Strm 的 Cookie,通过 F12 抓取,关键参数:session=ey***",
|
||
config_id: "要触发运行的配置 ID,多个 ID 用逗号分隔,如:1, 2, 3"
|
||
},
|
||
alist_strm_gen: {
|
||
url: "AList 服务器地址,如:http://192.168.1.100:5244",
|
||
token: "AList 访问令牌,在 AList 的管理 - 设置 - 其他中获取",
|
||
storage_id: "AList 服务器夸克存储的 ID",
|
||
strm_save_dir: "生成的 strm 文件的保存路径,如:/volume1/media/strm,请确保宿主机路径和容器路径映射一致",
|
||
strm_replace_host: "strm 文件内链接的主机地址,可选,缺省时使用 url"
|
||
},
|
||
plex: {
|
||
url: "Plex 服务器地址,如:http://192.168.1.100:32400",
|
||
token: "Plex 访问令牌,通过 F12 抓取(或通过 XML 地址获取)",
|
||
quark_root_path: "夸克根目录在 Plex 中的路径,多个路径用逗号分隔,如:/volume1/media/quark1, /volume1/media/quark2"
|
||
},
|
||
emby: {
|
||
url: "Emby 服务器地址,如:http://192.168.1.100:8096",
|
||
token: "Emby 的 API 密钥,在 Emby 的管理 - 高级 - API 密钥中生成"
|
||
}
|
||
};
|
||
|
||
return placeholders[pluginName]?.[key] || '';
|
||
},
|
||
|
||
// 获取插件配置的帮助文本
|
||
getPluginConfigHelp(pluginName, key) {
|
||
const helpTexts = {
|
||
aria2: {
|
||
host_port: "Aria2 RPC服务地址,确保网络可达且端口开放",
|
||
secret: "Aria2 RPC密钥,用于安全认证",
|
||
dir: "下载文件的保存目录,确保Aria2有读写权限"
|
||
},
|
||
alist: {
|
||
url: "AList服务器地址,用于获取存储信息",
|
||
token: "AList访问令牌,用于API调用",
|
||
storage_id: "多账号支持:多个存储ID用逗号分隔,顺序与Cookie顺序对应,如:1, 2, 3"
|
||
},
|
||
alist_strm: {
|
||
url: "AList Strm服务器地址",
|
||
cookie: "AList Strm的Cookie",
|
||
config_id: "要触发运行的配置ID,多个ID用逗号分隔,如:1, 2, 3"
|
||
},
|
||
alist_strm_gen: {
|
||
url: "AList服务器地址,用于获取存储信息",
|
||
token: "AList访问令牌,用于API调用",
|
||
storage_id: "夸克网盘在AList中的存储ID,在AList的存储管理中查看",
|
||
strm_save_dir: "生成的strm文件的保存路径,确保有写入权限",
|
||
strm_replace_host: "strm文件内链接的主机地址,用于替换默认的AList地址"
|
||
},
|
||
plex: {
|
||
url: "Plex服务器地址",
|
||
token: "Plex访问令牌",
|
||
quark_root_path: "夸克根目录在Plex中的路径,多个路径用逗号分隔,如:/volume1/media/quark1, /volume1/media/quark2"
|
||
},
|
||
emby: {
|
||
url: "Emby服务器地址",
|
||
token: "Emby的API密钥"
|
||
}
|
||
};
|
||
|
||
return helpTexts[pluginName]?.[key] || '';
|
||
},
|
||
|
||
// 获取插件任务配置
|
||
getPluginTaskConfig(pluginName) {
|
||
const taskConfigs = {
|
||
aria2: {
|
||
auto_download: true,
|
||
pause: false,
|
||
auto_delete_quark_files: false
|
||
},
|
||
alist_strm_gen: {
|
||
auto_gen: true
|
||
},
|
||
emby: {
|
||
try_match: true,
|
||
media_id: ""
|
||
}
|
||
};
|
||
return taskConfigs[pluginName] || {};
|
||
},
|
||
|
||
// 获取插件配置模式的帮助文本
|
||
getPluginConfigModeHelp(pluginName) {
|
||
return "选择插件的配置模式:独立配置允许每个任务单独设置,全局配置则所有任务共享同一套设置,且只能在系统配置页面修改";
|
||
},
|
||
|
||
// 获取插件任务配置的帮助文本
|
||
getPluginTaskConfigHelp(pluginName, key) {
|
||
const helpTexts = {
|
||
aria2: {
|
||
auto_download: "是否自动添加下载任务",
|
||
pause: "添加任务后为暂停状态,不自动开始(手动下载)",
|
||
auto_delete_quark_files: "是否在添加下载任务后自动删除夸克网盘文件"
|
||
},
|
||
alist_strm_gen: {
|
||
auto_gen: "是否自动生成strm文件"
|
||
},
|
||
emby: {
|
||
try_match: "是否尝试自动匹配媒体",
|
||
media_id: "指定要刷新的媒体ID,留空则自动匹配,0表示不刷新"
|
||
}
|
||
};
|
||
return helpTexts[pluginName]?.[key] || '';
|
||
},
|
||
|
||
// 获取插件任务配置的占位符文本
|
||
getPluginTaskConfigPlaceholder(pluginName, key) {
|
||
const placeholders = {
|
||
aria2: {
|
||
auto_download: "",
|
||
pause: "",
|
||
auto_delete_quark_files: ""
|
||
},
|
||
alist_strm_gen: {
|
||
auto_gen: ""
|
||
},
|
||
emby: {
|
||
try_match: "",
|
||
media_id: "输入媒体 ID,留空则自动匹配,0 表示不刷新"
|
||
}
|
||
};
|
||
return placeholders[pluginName]?.[key] || '';
|
||
},
|
||
|
||
// 检查插件配置是否被禁用
|
||
isPluginConfigDisabled(task) {
|
||
for (const pluginName of ['aria2', 'alist_strm_gen', 'emby']) {
|
||
if (this.formData.plugin_config_mode[pluginName] === 'global') {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
|
||
// 插件配置模式改变时的处理
|
||
onPluginConfigModeChange(pluginName) {
|
||
if (this.formData.plugin_config_mode[pluginName] === 'global') {
|
||
// 切换到全局模式时,初始化全局配置
|
||
if (!this.formData.global_plugin_config[pluginName]) {
|
||
this.formData.global_plugin_config[pluginName] = { ...this.getPluginTaskConfig(pluginName) };
|
||
}
|
||
}
|
||
|
||
// 更新新任务的配置,应用全局配置
|
||
this.applyGlobalPluginConfig(this.newTask);
|
||
|
||
// 更新影视发现页面创建任务的配置,应用全局配置
|
||
if (this.createTask && this.createTask.taskData) {
|
||
this.applyGlobalPluginConfig(this.createTask.taskData);
|
||
}
|
||
},
|
||
|
||
// 全局插件配置改变时的处理
|
||
onGlobalPluginConfigChange() {
|
||
// 更新新任务的配置,应用全局配置
|
||
this.applyGlobalPluginConfig(this.newTask);
|
||
|
||
// 更新影视发现页面创建任务的配置,应用全局配置
|
||
if (this.createTask && this.createTask.taskData) {
|
||
this.applyGlobalPluginConfig(this.createTask.taskData);
|
||
}
|
||
},
|
||
|
||
// 应用全局插件配置到任务
|
||
applyGlobalPluginConfig(task) {
|
||
if (!task.addition) {
|
||
task.addition = {};
|
||
}
|
||
|
||
for (const pluginName of ['aria2', 'alist_strm_gen', 'emby']) {
|
||
if (this.formData.plugin_config_mode[pluginName] === 'global') {
|
||
// 应用全局配置到任务
|
||
task.addition[pluginName] = { ...this.formData.global_plugin_config[pluginName] };
|
||
}
|
||
}
|
||
},
|
||
|
||
|
||
|
||
// 获取插件配置的悬停提示文本
|
||
getPluginConfigTitle(task) {
|
||
if (this.isPluginConfigDisabled(task)) {
|
||
return `单个任务的插件配置,具体键值由插件定义,当前有部分插件使用了全局配置模式,在该模式下对应的配置选项将被锁定,若要修改配置,请前往系统配置页面进行操作,查阅Wiki了解详情`;
|
||
}
|
||
return "单个任务的插件配置,具体键值由插件定义,查阅Wiki了解详情";
|
||
},
|
||
|
||
// 获取创建任务时的插件配置悬停提示文本
|
||
getCreateTaskPluginConfigTitle() {
|
||
if (this.isPluginConfigDisabled(this.createTask.taskData)) {
|
||
return `单个任务的插件配置,具体键值由插件定义,当前有部分插件使用了全局配置模式,在该模式下对应的配置选项将被锁定,若要修改配置,请前往系统配置页面进行操作,查阅Wiki了解详情`;
|
||
}
|
||
return "单个任务的插件配置,具体键值由插件定义,查阅Wiki了解详情";
|
||
},
|
||
|
||
// 获取Cookie状态悬停提示信息
|
||
getCookieStatusTooltip(userInfo) {
|
||
if (!userInfo) {
|
||
return "账号未验证,请获取Cookie";
|
||
}
|
||
|
||
// 有昵称且账号激活
|
||
if (userInfo.nickname && userInfo.is_active) {
|
||
if (userInfo.has_mparam) {
|
||
// 第一个账号支持转存和签到,其他账号只支持签到
|
||
if (userInfo.index === 0) {
|
||
return "账号已登录,支持转存和签到";
|
||
} else {
|
||
return "账号已登录,仅支持签到功能";
|
||
}
|
||
} else {
|
||
// 第一个账号支持转存,其他账号不支持任何功能
|
||
if (userInfo.index === 0) {
|
||
return "账号已登录,仅支持转存功能";
|
||
} else {
|
||
return "账号已登录,不支持转存和签到";
|
||
}
|
||
}
|
||
}
|
||
|
||
// 有昵称但账号未激活
|
||
if (userInfo.nickname && !userInfo.is_active) {
|
||
return "账号已失效,请重新获取Cookie";
|
||
}
|
||
|
||
// 没有昵称但有移动端参数
|
||
if (!userInfo.nickname && userInfo.has_mparam) {
|
||
return "仅支持签到功能";
|
||
}
|
||
|
||
// 没有昵称也没有移动端参数
|
||
return "账号未登录,请获取Cookie";
|
||
},
|
||
|
||
fetchUserInfo() {
|
||
// 获取所有cookie对应的用户信息
|
||
axios.get('/get_user_info')
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.userInfoList = response.data.data;
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('获取用户信息失败:', error);
|
||
});
|
||
},
|
||
|
||
fetchAccountsDetail() {
|
||
// 获取所有账号的详细信息,包括空间使用情况
|
||
axios.get('/get_accounts_detail')
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.accountsDetail = response.data.data;
|
||
// 验证当前选中的账号索引是否有效
|
||
if (this.fileManager.selectedAccountIndex >= this.accountsDetail.length) {
|
||
this.fileManager.selectedAccountIndex = 0;
|
||
localStorage.setItem('quarkAutoSave_selectedAccountIndex', '0');
|
||
}
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('获取账号详细信息失败:', error);
|
||
});
|
||
},
|
||
|
||
// 迁移旧的localStorage数据到新格式(为每个账号单独存储目录)
|
||
migrateFileManagerFolderData() {
|
||
const oldFolderKey = 'quarkAutoSave_fileManagerLastFolder';
|
||
const oldFolderValue = localStorage.getItem(oldFolderKey);
|
||
|
||
if (oldFolderValue) {
|
||
// 如果存在旧数据,将其迁移到当前选中账号的新格式
|
||
const currentAccountIndex = this.fileManager.selectedAccountIndex;
|
||
const newFolderKey = `quarkAutoSave_fileManagerLastFolder_${currentAccountIndex}`;
|
||
|
||
// 只有当新格式的数据不存在时才进行迁移
|
||
if (!localStorage.getItem(newFolderKey)) {
|
||
localStorage.setItem(newFolderKey, oldFolderValue);
|
||
}
|
||
|
||
// 删除旧的数据
|
||
localStorage.removeItem(oldFolderKey);
|
||
}
|
||
},
|
||
|
||
onAccountChange() {
|
||
// 保存选中的账号索引到localStorage
|
||
localStorage.setItem('quarkAutoSave_selectedAccountIndex', this.fileManager.selectedAccountIndex.toString());
|
||
|
||
// 获取新账号最后一次访问的目录
|
||
const newAccountLastFolder = localStorage.getItem(`quarkAutoSave_fileManagerLastFolder_${this.fileManager.selectedAccountIndex}`) || 'root';
|
||
|
||
// 重置页码并切换到新账号的最后访问目录
|
||
this.fileManager.currentPage = 1;
|
||
this.loadFileListWithFallback(newAccountLastFolder);
|
||
|
||
// 如果移动文件模态框正在显示,需要重新加载目录
|
||
if ($('#fileSelectModal').hasClass('show')) {
|
||
const modalType = document.getElementById('fileSelectModal').getAttribute('data-modal-type');
|
||
if (modalType === 'move') {
|
||
// 重置路径并重新加载根目录
|
||
this.fileSelect.paths = [];
|
||
this.getSavepathDetail(0);
|
||
}
|
||
}
|
||
},
|
||
|
||
refreshCurrentFolderCache(retryCount = 0) {
|
||
// 刷新当前目录的缓存,强制重新请求最新的文件列表
|
||
// 调用后端接口,添加强制刷新参数
|
||
const params = {
|
||
folder_id: this.fileManager.currentFolder || 'root',
|
||
sort_by: this.fileManager.sortBy,
|
||
order: this.fileManager.sortOrder,
|
||
page_size: this.fileManager.pageSize,
|
||
page: this.fileManager.currentPage,
|
||
account_index: this.fileManager.selectedAccountIndex,
|
||
force_refresh: true, // 强制刷新参数
|
||
timestamp: Date.now() // 添加时间戳避免缓存
|
||
};
|
||
|
||
axios.get('/file_list', { params })
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.fileManager.fileList = response.data.data.list;
|
||
this.fileManager.total = response.data.data.total;
|
||
this.fileManager.totalPages = Math.ceil(response.data.data.total / this.fileManager.pageSize);
|
||
this.fileManager.paths = response.data.data.paths || [];
|
||
this.fileManager.gotoPage = this.fileManager.currentPage;
|
||
// 移除成功通知
|
||
|
||
// 检测当前的命名模式
|
||
this.detectFileManagerNamingMode();
|
||
} else {
|
||
// 如果刷新失败且重试次数少于2次,则重试
|
||
if (retryCount < 2) {
|
||
setTimeout(() => {
|
||
this.refreshCurrentFolderCache(retryCount + 1);
|
||
}, 1000);
|
||
} else {
|
||
this.showToast('刷新失败:' + response.data.message);
|
||
}
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('刷新缓存失败:', error);
|
||
// 如果网络错误且重试次数少于2次,则重试
|
||
if (retryCount < 2) {
|
||
setTimeout(() => {
|
||
this.refreshCurrentFolderCache(retryCount + 1);
|
||
}, 1000);
|
||
} else {
|
||
this.showToast('刷新缓存失败,请稍后重试');
|
||
}
|
||
});
|
||
},
|
||
// 获取指定文件夹的上级目录ID
|
||
getParentFolderId(folderId) {
|
||
// 如果是根目录,没有上级目录
|
||
if (folderId === 'root' || folderId === '0') {
|
||
return null;
|
||
}
|
||
|
||
// 如果是当前目录(源目录),从fileManager.paths中获取上级目录
|
||
if (folderId === this.fileManager.currentFolder || folderId === (this.fileManager.currentFolder || "0")) {
|
||
if (this.fileManager.paths.length === 0) {
|
||
// 当前目录是根目录的直接子目录,上级是根目录
|
||
return 'root';
|
||
} else {
|
||
// 当前目录的上级是paths中的最后一个目录
|
||
return this.fileManager.paths[this.fileManager.paths.length - 1].fid;
|
||
}
|
||
}
|
||
|
||
// 如果是目标目录,从fileSelect.paths中获取上级目录
|
||
if (this.fileSelect.paths && this.fileSelect.paths.length > 0) {
|
||
// 目标目录就是fileSelect.paths的最后一个目录,其上级目录是倒数第二个
|
||
if (this.fileSelect.paths.length === 1) {
|
||
// 目标目录是根目录的直接子目录
|
||
return 'root';
|
||
} else {
|
||
// 目标目录的上级是paths中的倒数第二个目录
|
||
return this.fileSelect.paths[this.fileSelect.paths.length - 2].fid;
|
||
}
|
||
} else {
|
||
// 如果fileSelect.paths为空,说明目标目录是根目录,没有上级
|
||
return null;
|
||
}
|
||
},
|
||
// 刷新指定文件夹的缓存
|
||
refreshFolderCache(folderId) {
|
||
// 刷新指定目录的缓存,强制重新请求最新的文件列表
|
||
// 调用后端接口,添加强制刷新参数
|
||
const params = {
|
||
folder_id: folderId || 'root',
|
||
sort_by: this.fileManager.sortBy,
|
||
order: this.fileManager.sortOrder,
|
||
page_size: this.fileManager.pageSize,
|
||
page: 1, // 使用第一页来刷新缓存
|
||
account_index: this.fileManager.selectedAccountIndex,
|
||
force_refresh: true // 强制刷新参数
|
||
};
|
||
|
||
axios.get('/file_list', { params })
|
||
.then(response => {
|
||
// 如果刷新的是当前显示的文件夹,则更新显示
|
||
if (folderId === this.fileManager.currentFolder) {
|
||
if (response.data.success) {
|
||
this.fileManager.fileList = response.data.data.list;
|
||
this.fileManager.total = response.data.data.total;
|
||
this.fileManager.totalPages = Math.ceil(response.data.data.total / this.fileManager.pageSize);
|
||
this.fileManager.paths = response.data.data.paths || [];
|
||
this.fileManager.gotoPage = this.fileManager.currentPage;
|
||
|
||
// 检测当前的命名模式
|
||
this.detectFileManagerNamingMode();
|
||
} else {
|
||
this.showToast('刷新失败:' + response.data.message);
|
||
}
|
||
}
|
||
// 如果不是当前文件夹,只是刷新缓存,不更新显示
|
||
})
|
||
.catch(error => {
|
||
console.error('刷新文件夹缓存失败:', error);
|
||
// 只有在刷新当前文件夹时才显示错误提示
|
||
if (folderId === this.fileManager.currentFolder) {
|
||
this.showToast('刷新缓存失败,请稍后重试');
|
||
}
|
||
});
|
||
},
|
||
// 新建文件夹
|
||
createNewFolder() {
|
||
// 检查是否有文件正在编辑状态
|
||
const editingFile = this.fileManager.fileList.find(file => file._editing);
|
||
if (editingFile) {
|
||
// 如果有其他文件正在编辑,先保存编辑状态
|
||
this.saveRenameFile(editingFile);
|
||
return;
|
||
}
|
||
|
||
// 生成带时间戳的文件夹名称
|
||
const timestamp = new Date().toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
}).replace(/[\/\s:]/g, '');
|
||
const folderName = `新建文件夹 ${timestamp}`;
|
||
|
||
// 调用后端API创建文件夹
|
||
axios.post('/create_folder', {
|
||
parent_folder_id: this.fileManager.currentFolder || '0',
|
||
folder_name: folderName,
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
})
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
// 创建成功,将新文件夹添加到文件列表
|
||
const newFolder = response.data.data;
|
||
|
||
// 设置编辑状态
|
||
this.$set(newFolder, '_editing', true);
|
||
this.$set(newFolder, '_editingName', newFolder.file_name);
|
||
|
||
// 将新文件夹添加到列表开头(文件夹通常显示在前面)
|
||
this.fileManager.fileList.unshift(newFolder);
|
||
this.fileManager.total += 1;
|
||
|
||
// 下一个tick后聚焦输入框并全选文本
|
||
this.$nextTick(() => {
|
||
const input = this.$refs['renameInput_' + newFolder.fid];
|
||
if (input) {
|
||
input.focus();
|
||
input.select();
|
||
}
|
||
});
|
||
|
||
this.showToast('文件夹创建成功');
|
||
} else {
|
||
this.showToast(response.data.message || '创建文件夹失败');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('创建文件夹失败:', error);
|
||
this.showToast('创建文件夹失败');
|
||
});
|
||
},
|
||
// 添加一个检查分享链接状态的方法
|
||
checkShareUrlStatus() {
|
||
// 只在任务列表页面检查
|
||
if (this.activeTab !== 'tasklist') return;
|
||
|
||
// 遍历所有任务
|
||
this.formData.tasklist.forEach((task, index) => {
|
||
// 如果任务有分享链接且没有设置shareurl_ban
|
||
if (task.shareurl && !task.shareurl_ban) {
|
||
// 检查分享链接
|
||
axios.get('/get_share_detail', { params: { shareurl: task.shareurl } })
|
||
.then(response => {
|
||
const share_detail = response.data.data;
|
||
if (!response.data.success) {
|
||
// 检查是否是可恢复的网络错误或服务端临时错误
|
||
if (share_detail.error && (
|
||
share_detail.error.includes("request error") ||
|
||
share_detail.error.includes("inner error") ||
|
||
share_detail.error.includes("网络错误") ||
|
||
share_detail.error.includes("服务端错误") ||
|
||
share_detail.error.includes("临时错误"))) {
|
||
// 忽略可恢复的错误,不设置 shareurl_ban
|
||
console.log('检查分享链接时出现可恢复错误,忽略此错误:', share_detail.error);
|
||
return;
|
||
}
|
||
// 使用格式化函数处理其他错误信息
|
||
const formattedError = this.formatShareUrlBanMessage(share_detail.error);
|
||
if (formattedError) {
|
||
this.$set(task, "shareurl_ban", formattedError);
|
||
}
|
||
} else if (share_detail.list !== undefined && share_detail.list.length === 0) {
|
||
// 检查文件列表是否为空,确保列表存在且为空
|
||
this.$set(task, "shareurl_ban", "该分享已被删除,无法访问");
|
||
}
|
||
})
|
||
.catch(error => {
|
||
// 网络请求失败,忽略错误,不设置 shareurl_ban
|
||
console.log('检查分享链接状态时网络请求失败,忽略此错误:', error);
|
||
});
|
||
}
|
||
});
|
||
},
|
||
toggleSidebar() {
|
||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||
// 保存侧边栏状态到本地存储
|
||
localStorage.setItem('quarkAutoSave_sidebarCollapsed', this.sidebarCollapsed);
|
||
|
||
// 如果当前在追剧日历页面且为海报模式,重新计算列数
|
||
if (this.activeTab === 'calendar' && this.calendar.viewMode === 'poster') {
|
||
// 延迟一点时间,等待DOM更新完成
|
||
this.$nextTick(() => {
|
||
this.updateWeekDates();
|
||
});
|
||
}
|
||
},
|
||
cleanTaskNameForSearch(taskName) {
|
||
if (!taskName) return '';
|
||
|
||
// 清理任务名称中的连续空格和特殊符号
|
||
let cleanedName = taskName.replace(/\u3000/g, ' ').replace(/\t/g, ' ');
|
||
cleanedName = cleanedName.replace(/\s+/g, ' ').trim();
|
||
|
||
// 匹配常见的季数格式
|
||
const seasonPatterns = [
|
||
/^(.*?)[\s\.\-_]+S\d+$/i, // 黑镜 - S07、折腰.S01、音你而来-S02
|
||
/^(.*?)[\s\.\-_]+Season\s*\d+$/i, // 黑镜 - Season 1
|
||
/^(.*?)\s+S\d+$/i, // 快乐的大人 S02
|
||
/^(.*?)[\s\.\-_]+S\d+E\d+$/i, // 处理 S01E01 格式
|
||
/^(.*?)\s+第\s*\d+\s*季$/i, // 处理 第N季 格式
|
||
/^(.*?)[\s\.\-_]+第\s*\d+\s*季$/i, // 处理 - 第N季 格式
|
||
/^(.*?)\s+第[一二三四五六七八九十零]+季$/i, // 处理 第一季、第二季 格式
|
||
/^(.*?)[\s\.\-_]+第[一二三四五六七八九十零]+季$/i, // 处理 - 第一季、- 第二季 格式
|
||
];
|
||
|
||
for (const pattern of seasonPatterns) {
|
||
const match = cleanedName.match(pattern);
|
||
if (match) {
|
||
let showName = match[1].trim();
|
||
// 去除末尾可能残留的分隔符
|
||
showName = showName.replace(/[\s\.\-_]+$/, '');
|
||
return encodeURIComponent(showName);
|
||
}
|
||
}
|
||
|
||
// 如果没有匹配到季数格式,执行原有的清理逻辑
|
||
// 移除孤立的特殊符号
|
||
cleanedName = cleanedName.replace(/\s*[-–—_]\s*$/, '');
|
||
// 移除开头和结尾的特殊符号
|
||
cleanedName = cleanedName.trim().replace(/^[-–—_\s]+|[-–—_\s]+$/g, '');
|
||
|
||
return encodeURIComponent(cleanedName);
|
||
},
|
||
changeTab(tab) {
|
||
this.activeTab = tab;
|
||
// 在本地存储中保存当前标签页状态
|
||
localStorage.setItem('quarkAutoSave_activeTab', tab);
|
||
if (window.innerWidth <= 768) {
|
||
$('#sidebarMenu').collapse('toggle')
|
||
}
|
||
|
||
// 当切换到任务列表标签时刷新任务最新信息和元数据
|
||
if (tab === 'tasklist') {
|
||
this.loadTaskLatestInfo();
|
||
// 加载任务元数据信息,确保海报和元数据能热更新
|
||
this.loadTasklistMetadata();
|
||
// 启动任务列表的后台监听
|
||
this.startTasklistAutoWatch();
|
||
// 该分支的系统性加载不应触发“未保存”提示
|
||
try {
|
||
this.suppressConfigModifiedOnce = true;
|
||
// 异步队列尾部再置一次,覆盖可能的深层变更
|
||
this.$nextTick(() => {
|
||
this.suppressConfigModifiedOnce = true;
|
||
this.configModified = false;
|
||
});
|
||
} catch (e) {}
|
||
} else if (this.activeTab === 'tasklist') {
|
||
// 离开任务列表页面时停止后台监听
|
||
this.stopTasklistAutoWatch();
|
||
}
|
||
|
||
// 当切换到历史记录标签时加载数据
|
||
if (tab === 'history') {
|
||
this.loadHistoryRecords();
|
||
// 加载所有任务名称用于筛选
|
||
this.loadAllTaskNames();
|
||
}
|
||
|
||
// 当切换到文件整理标签时加载文件列表
|
||
if (tab === 'filemanager') {
|
||
// 从本地存储中恢复文件管理器的分页大小设置
|
||
const savedFileManagerPageSize = localStorage.getItem('quarkAutoSave_fileManagerPageSize');
|
||
if (savedFileManagerPageSize) {
|
||
this.fileManager.pageSize = savedFileManagerPageSize === 'all' ? 99999 : parseInt(savedFileManagerPageSize);
|
||
}
|
||
// 获取账号详细信息
|
||
this.fetchAccountsDetail();
|
||
this.loadFileListWithoutLoading(this.fileManager.currentFolder);
|
||
}
|
||
|
||
// 当切换到追剧日历标签时加载日历数据
|
||
if (tab === 'calendar') {
|
||
this.loadCalendarData();
|
||
// 添加窗口大小变化监听器,自动调整列数
|
||
this.addCalendarResizeListener();
|
||
}
|
||
},
|
||
checkNewVersion() {
|
||
// 移除本地版本中的v前缀
|
||
const localVersionClean = this.version.replace(/^v/i, '');
|
||
|
||
// 显示时也去掉v前缀
|
||
this.versionTips = localVersionClean;
|
||
|
||
axios.get('https://api.github.com/repos/x1ao4/quark-auto-save-x/tags')
|
||
.then(response => {
|
||
const latestVersion = response.data[0].name; // GitHub的版本不带v,不需要replace
|
||
|
||
// 使用处理后的版本号进行比较
|
||
if (latestVersion !== localVersionClean) {
|
||
this.versionTips += ` <sup><span class="badge badge-pill badge-danger">${latestVersion}</span></sup>`;
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
});
|
||
},
|
||
fetchData() {
|
||
axios.get('/data')
|
||
.then(response => {
|
||
config_data = response.data.data
|
||
// cookie兼容
|
||
if (typeof config_data.cookie === 'string')
|
||
config_data.cookie = [config_data.cookie];
|
||
// 添加星期预设和执行周期模式
|
||
config_data.tasklist = config_data.tasklist.map(task => {
|
||
if (!task.hasOwnProperty('runweek')) {
|
||
task.runweek = [1, 2, 3, 4, 5, 6, 7];
|
||
}
|
||
// 确保execution_mode有默认值
|
||
if (!task.hasOwnProperty('execution_mode')) {
|
||
task.execution_mode = config_data.execution_mode || 'manual';
|
||
}
|
||
|
||
// 格式化已有的警告信息
|
||
if (task.shareurl_ban) {
|
||
const formattedError = this.formatShareUrlBanMessage(task.shareurl_ban);
|
||
if (formattedError) {
|
||
task.shareurl_ban = formattedError;
|
||
}
|
||
}
|
||
|
||
return task;
|
||
});
|
||
|
||
// 直接赋值,排序通过计算属性处理(属于系统性加载,抑制未保存提示)
|
||
this.suppressConfigModifiedOnce = true;
|
||
this.formData.tasklist = config_data.tasklist || [];
|
||
|
||
// 获取所有任务父目录
|
||
this.formData.tasklist.forEach(item => {
|
||
parentDir = this.getParentDirectory(item.savepath)
|
||
if (!this.taskDirs.includes(parentDir))
|
||
this.taskDirs.push(parentDir);
|
||
});
|
||
// 初始化新任务的插件配置,应用全局配置
|
||
this.newTask.addition = { ...config_data.task_plugins_config_default };
|
||
this.applyGlobalPluginConfig(this.newTask);
|
||
// --- 新增:预加载追剧日历任务元数据,支持任务列表直接显示 season_counts / matched_status ---
|
||
axios.get('/api/calendar/tasks')
|
||
.then(res => {
|
||
if (res.data && res.data.success) {
|
||
this.calendar.tasks = res.data.data.tasks || [];
|
||
try {
|
||
this.calendar.taskMapByName = {};
|
||
(this.calendar.tasks || []).forEach(t => {
|
||
const key = (t.task_name || t.taskname || '').trim();
|
||
if (key) this.calendar.taskMapByName[key] = t;
|
||
});
|
||
} catch (e) {}
|
||
// 同步任务列表可用类型集合(复用日历的类型热更新逻辑)
|
||
try {
|
||
const rawTypes = (res.data.data && res.data.data.content_types) || [];
|
||
// 与日历相同的排序:tv、anime、variety、documentary、other(其他最后)
|
||
const known = ['tv','anime','variety','documentary'];
|
||
const uniq = Array.from(new Set(rawTypes.filter(Boolean)));
|
||
const ordered = uniq.sort((a,b) => {
|
||
const ia = known.indexOf(a); const ib = known.indexOf(b);
|
||
if (ia === -1 && ib === -1) return 0;
|
||
if (ia === -1) return 1;
|
||
if (ib === -1) return -1;
|
||
return ia - ib;
|
||
});
|
||
this.tasklist.contentTypes = ordered;
|
||
// 校验已选项有效性
|
||
const validSet = ['all', ...ordered];
|
||
if (!validSet.includes(this.tasklist.selectedType)) {
|
||
this.tasklist.selectedType = 'all';
|
||
}
|
||
} catch (e) { this.tasklist.contentTypes = []; }
|
||
} else {
|
||
this.calendar.tasks = [];
|
||
this.calendar.taskMapByName = {};
|
||
this.tasklist.contentTypes = [];
|
||
}
|
||
})
|
||
.catch(() => {
|
||
this.calendar.tasks = [];
|
||
this.calendar.taskMapByName = {};
|
||
this.tasklist.contentTypes = [];
|
||
});
|
||
// 确保source配置存在
|
||
if (!config_data.source) {
|
||
config_data.source = {};
|
||
}
|
||
if (!config_data.source.cloudsaver) {
|
||
config_data.source.cloudsaver = {
|
||
server: "",
|
||
username: "",
|
||
password: "",
|
||
token: ""
|
||
};
|
||
}
|
||
// 确保剧集识别模式存在
|
||
if (!config_data.episode_patterns) {
|
||
config_data.episode_patterns = [];
|
||
}
|
||
// 确保任务设置存在
|
||
if (!config_data.task_settings) {
|
||
config_data.task_settings = {
|
||
movie_save_path: "电影目录前缀/片名 (年份)",
|
||
tv_save_path: "剧集目录前缀/剧名 (年份)/剧名 - S季数",
|
||
anime_save_path: "动画目录前缀/剧名 (年份)/剧名 - S季数",
|
||
variety_save_path: "综艺目录前缀/剧名 (年份)/剧名 - S季数",
|
||
documentary_save_path: "纪录片目录前缀/剧名 (年份)/剧名 - S季数",
|
||
movie_naming_pattern: "^(.*)\\.([^.]+)",
|
||
movie_naming_replace: "片名 (年份).\\2",
|
||
tv_naming_rule: "剧名 - S季数E[]",
|
||
tv_ignore_extension: true,
|
||
subtitle_naming_rule: "zh",
|
||
subtitle_add_language_code: false,
|
||
auto_search_resources: "enabled"
|
||
};
|
||
}
|
||
// 确保电影命名规则字段存在,只在字段不存在时设置默认值,允许用户设置为空字符串
|
||
if (config_data.task_settings.movie_naming_pattern === undefined) {
|
||
config_data.task_settings.movie_naming_pattern = "^(.*)\\.([^.]+)";
|
||
}
|
||
if (config_data.task_settings.movie_naming_replace === undefined) {
|
||
config_data.task_settings.movie_naming_replace = "片名 (年份).\\2";
|
||
}
|
||
// 确保电视忽略后缀设置存在
|
||
if (config_data.task_settings.tv_ignore_extension === undefined) {
|
||
config_data.task_settings.tv_ignore_extension = true;
|
||
}
|
||
// 为字幕命名规则添加默认值(向后兼容)
|
||
if (config_data.task_settings.subtitle_naming_rule === undefined) {
|
||
config_data.task_settings.subtitle_naming_rule = "zh";
|
||
}
|
||
if (config_data.task_settings.subtitle_add_language_code === undefined) {
|
||
config_data.task_settings.subtitle_add_language_code = false;
|
||
}
|
||
// 确保自动搜索资源设置存在
|
||
if (!config_data.task_settings.auto_search_resources) {
|
||
config_data.task_settings.auto_search_resources = "enabled";
|
||
}
|
||
// 确保按钮显示配置存在
|
||
if (!config_data.button_display) {
|
||
config_data.button_display = {
|
||
run_task: "always",
|
||
delete_task: "always",
|
||
refresh_plex: "always",
|
||
refresh_alist: "always",
|
||
season_counts: "always",
|
||
latest_update_date: "always",
|
||
task_progress: "always",
|
||
show_status: "always",
|
||
latest_transfer_file: "always",
|
||
today_update_indicator: "always"
|
||
};
|
||
}
|
||
if (!config_data.button_display.season_counts) {
|
||
config_data.button_display.season_counts = "always";
|
||
}
|
||
if (!config_data.button_display.task_progress) {
|
||
config_data.button_display.task_progress = "always";
|
||
}
|
||
if (!config_data.button_display.show_status) {
|
||
config_data.button_display.show_status = "always";
|
||
}
|
||
// 确保最近更新日期配置存在(向后兼容)
|
||
if (!config_data.button_display.latest_update_date) {
|
||
config_data.button_display.latest_update_date = "always";
|
||
}
|
||
// 确保最近转存文件配置存在(向后兼容)
|
||
if (!config_data.button_display.latest_transfer_file) {
|
||
config_data.button_display.latest_transfer_file = "always";
|
||
}
|
||
// 确保当日更新图标配置存在(向后兼容)
|
||
if (!config_data.button_display.today_update_indicator) {
|
||
config_data.button_display.today_update_indicator = "always";
|
||
}
|
||
// 确保显示顺序配置存在
|
||
if (!config_data.button_display_order || !Array.isArray(config_data.button_display_order)) {
|
||
config_data.button_display_order = [
|
||
"refresh_plex",
|
||
"refresh_alist",
|
||
"run_task",
|
||
"delete_task",
|
||
"latest_transfer_file",
|
||
"season_counts",
|
||
"latest_update_date",
|
||
"task_progress",
|
||
"show_status",
|
||
"today_update_indicator"
|
||
];
|
||
}
|
||
// 确保文件整理性能配置存在
|
||
if (!config_data.file_performance) {
|
||
config_data.file_performance = {
|
||
api_page_size: 200,
|
||
cache_expire_time: 30,
|
||
discovery_items_count: 30
|
||
};
|
||
} else {
|
||
// 确保必要的字段存在,移除废弃的字段
|
||
if (!config_data.file_performance.api_page_size) {
|
||
config_data.file_performance.api_page_size = 200;
|
||
}
|
||
if (!config_data.file_performance.cache_expire_time) {
|
||
config_data.file_performance.cache_expire_time = 30;
|
||
}
|
||
if (!config_data.file_performance.discovery_items_count) {
|
||
config_data.file_performance.discovery_items_count = 30;
|
||
}
|
||
// 移除废弃的字段
|
||
delete config_data.file_performance.large_page_size;
|
||
delete config_data.file_performance.cache_cleanup_interval;
|
||
}
|
||
// 确保性能设置存在并补默认值(单位:秒)
|
||
if (!config_data.performance) {
|
||
config_data.performance = { calendar_refresh_interval_seconds: 21600, aired_refresh_time: "00:00", runtime_log_display_days: 3 };
|
||
} else {
|
||
if (config_data.performance.calendar_refresh_interval_seconds === undefined || config_data.performance.calendar_refresh_interval_seconds === null) {
|
||
config_data.performance.calendar_refresh_interval_seconds = 21600;
|
||
}
|
||
if (config_data.performance.aired_refresh_time === undefined || config_data.performance.aired_refresh_time === null) {
|
||
config_data.performance.aired_refresh_time = "00:00";
|
||
}
|
||
if (config_data.performance.runtime_log_display_days === undefined || config_data.performance.runtime_log_display_days === null) {
|
||
config_data.performance.runtime_log_display_days = 3;
|
||
}
|
||
}
|
||
// 确保execution_mode有默认值
|
||
if (!config_data.execution_mode) {
|
||
config_data.execution_mode = 'manual';
|
||
}
|
||
// 后端加载配置属于系统性赋值,不应触发未保存提示
|
||
this.suppressConfigModifiedOnce = true;
|
||
this.formData = config_data;
|
||
|
||
setTimeout(() => {
|
||
this.configModified = false;
|
||
}, 100);
|
||
this.configHasLoaded = true;
|
||
|
||
// 加载任务最新信息(包括记录和文件)
|
||
this.loadTaskLatestInfo();
|
||
|
||
// 数据加载完成后检查分享链接状态
|
||
if (this.activeTab === 'tasklist') {
|
||
setTimeout(() => {
|
||
this.checkShareUrlStatus();
|
||
}, 300);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
// 错误处理
|
||
});
|
||
},
|
||
handleKeyDown(event) {
|
||
if (event.ctrlKey || event.metaKey) {
|
||
if (event.keyCode === 83 || event.key === 's') {
|
||
event.preventDefault();
|
||
this.saveConfig();
|
||
} else if (event.keyCode === 82 || event.key === 'r') {
|
||
event.preventDefault();
|
||
this.runScriptNow();
|
||
}
|
||
}
|
||
},
|
||
handleBeforeUnload(e) {
|
||
if (this.configModified) {
|
||
e.preventDefault();
|
||
e.returnValue = '配置已修改但未保存,确定要离开吗?';
|
||
return e.returnValue;
|
||
}
|
||
},
|
||
saveConfig() {
|
||
// 保存前处理每个任务的命名模式
|
||
if (this.formData.tasklist && this.formData.tasklist.length > 0) {
|
||
this.formData.tasklist.forEach(task => {
|
||
// 如果是顺序命名模式,确保sequence_naming字段已正确设置
|
||
if (task.use_sequence_naming && task.pattern && task.pattern.includes('{}')) {
|
||
task.sequence_naming = task.pattern;
|
||
}
|
||
// 如果是剧集命名模式,确保episode_naming字段已正确设置
|
||
if (task.use_episode_naming && task.pattern) {
|
||
if (task.pattern === "[]" || task.pattern.includes('[]')) {
|
||
task.episode_naming = task.pattern;
|
||
}
|
||
}
|
||
|
||
// 应用全局插件配置
|
||
this.applyGlobalPluginConfig(task);
|
||
});
|
||
}
|
||
|
||
axios.post('/update', this.formData)
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.configModified = false;
|
||
// 使用Toast通知替代alert
|
||
this.showToast(response.data.message);
|
||
// 保存成功后更新用户信息
|
||
this.fetchUserInfo();
|
||
|
||
// 保存成功后触发热更新
|
||
this.triggerTasklistHotUpdate();
|
||
} else {
|
||
// 错误信息仍然使用alert,确保用户看到
|
||
alert(response.data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
// 错误处理
|
||
alert("保存失败: " + (error.response?.data?.message || error.message || "未知错误"));
|
||
});
|
||
},
|
||
addCookie() {
|
||
this.formData.cookie.push("");
|
||
// 添加cookie后,添加一个空的用户信息占位
|
||
this.userInfoList.push({
|
||
index: this.userInfoList.length,
|
||
nickname: "",
|
||
is_active: false
|
||
});
|
||
},
|
||
removeCookie(index) {
|
||
if (this.formData.cookie[index] == "" || confirm("确定要删除吗?")) {
|
||
this.formData.cookie.splice(index, 1);
|
||
// 删除对应的用户信息
|
||
if (this.userInfoList.length > index) {
|
||
this.userInfoList.splice(index, 1);
|
||
// 更新剩余用户信息的索引
|
||
this.userInfoList.forEach((user, idx) => {
|
||
user.index = idx;
|
||
});
|
||
}
|
||
}
|
||
},
|
||
addPush() {
|
||
key = prompt("增加的键名", "");
|
||
if (key != "" && key != null)
|
||
this.$set(this.formData.push_config, key, "");
|
||
},
|
||
removePush(key) {
|
||
if (confirm("确定要删除吗?"))
|
||
this.$delete(this.formData.push_config, key);
|
||
},
|
||
addTask() {
|
||
if (!this.formData.tasklist)
|
||
this.formData.tasklist = [];
|
||
let newTask = { ...this.newTask };
|
||
|
||
// 如果有上一个任务,继承保存路径和命名规则
|
||
if (this.formData.tasklist.length > 0) {
|
||
// 使用新的筛选逻辑:根据当前视图筛选条件获取对应类型中编号最大的任务
|
||
const lastTask = this.getLastTaskByCurrentFilter();
|
||
if (lastTask) {
|
||
newTask.savepath = lastTask.savepath || "";
|
||
newTask.pattern = lastTask.pattern || "";
|
||
|
||
// 继承命名规则选择模式
|
||
newTask.use_sequence_naming = lastTask.use_sequence_naming || false;
|
||
newTask.use_episode_naming = lastTask.use_episode_naming || false;
|
||
newTask.sequence_naming = lastTask.sequence_naming || "";
|
||
newTask.episode_naming = lastTask.episode_naming || "";
|
||
newTask.replace = lastTask.replace || "";
|
||
}
|
||
}
|
||
|
||
// 根据当前视图筛选条件设置新任务的内容类型
|
||
if (this.tasklist && this.tasklist.selectedType && this.tasklist.selectedType !== 'all') {
|
||
// 确保 calendar_info 结构存在
|
||
if (!newTask.calendar_info) {
|
||
newTask.calendar_info = {};
|
||
}
|
||
if (!newTask.calendar_info.extracted) {
|
||
newTask.calendar_info.extracted = {};
|
||
}
|
||
// 设置内容类型
|
||
newTask.calendar_info.extracted.content_type = this.tasklist.selectedType;
|
||
newTask.content_type = this.tasklist.selectedType;
|
||
}
|
||
|
||
// 应用全局插件配置到新任务
|
||
this.applyGlobalPluginConfig(newTask);
|
||
|
||
// 给新任务添加临时标识,用于后续定位
|
||
newTask.__isNewlyAdded = true;
|
||
this.formData.tasklist.push(newTask)
|
||
const originalIndex = this.formData.tasklist.length - 1;
|
||
|
||
// 清除之前任务的搜索记录,避免影响新任务
|
||
this.smart_param.taskSuggestions = {
|
||
success: false,
|
||
data: []
|
||
};
|
||
|
||
// 等Vue更新DOM后,自动展开新添加的任务
|
||
this.$nextTick(() => {
|
||
// 找到新任务在排序后列表中的位置
|
||
const sortedIndex = this.sortedTasklist.findIndex(task => task.__isNewlyAdded);
|
||
if (sortedIndex !== -1) {
|
||
// 展开新任务的编辑模块
|
||
$(`#collapse_${sortedIndex}`).collapse('show');
|
||
// 滚动到新任务位置
|
||
this.scrollToX();
|
||
// 移除临时标识
|
||
delete newTask.__isNewlyAdded;
|
||
}
|
||
});
|
||
},
|
||
focusTaskname(index, task) {
|
||
// 如果当前聚焦的任务不同于上一个任务,清除搜索记录
|
||
if (this.smart_param.index !== index) {
|
||
this.smart_param.taskSuggestions = {
|
||
success: false,
|
||
data: []
|
||
};
|
||
}
|
||
|
||
this.smart_param.index = index
|
||
this.smart_param.origin_savepath = task.savepath
|
||
regex = new RegExp(`/${task.taskname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/|$)`)
|
||
if (task.savepath.includes('TASKNAME')) {
|
||
this.smart_param.savepath = task.savepath;
|
||
} else if (task.savepath.match(regex)) {
|
||
this.smart_param.savepath = task.savepath.replace(task.taskname, 'TASKNAME');
|
||
} else {
|
||
this.smart_param.savepath = undefined;
|
||
}
|
||
},
|
||
// Sxx季数信息和格式同步工具函数
|
||
syncSeasonNumberAndFormat(sourceValue, targetValue) {
|
||
if (!sourceValue || !targetValue) return targetValue;
|
||
|
||
// 从源字符串中提取Sxx格式的季数和前缀格式
|
||
const sourceMatch = sourceValue.match(/(.*?)([\s\.\-_]+)S(\d+)/i);
|
||
if (!sourceMatch) return targetValue; // 源字符串没有Sxx,不同步
|
||
|
||
const sourcePrefix = sourceMatch[1];
|
||
const sourceSeparator = sourceMatch[2];
|
||
const sourceSeasonNumber = sourceMatch[3];
|
||
|
||
// 检查目标字符串是否包含Sxx格式
|
||
const targetMatch = targetValue.match(/(.*?)([\s\.\-_]+)S(\d+)(.*)/i);
|
||
if (!targetMatch) return targetValue; // 目标字符串没有Sxx,不同步
|
||
|
||
const targetPrefix = targetMatch[1];
|
||
const targetSeparator = targetMatch[2];
|
||
const targetSeasonNumber = targetMatch[3];
|
||
const targetSuffix = targetMatch[4]; // 保留后缀部分(如E[]、E{}等)
|
||
|
||
// 检查前缀是否相似(去除空格后比较)
|
||
const sourceCleanPrefix = sourcePrefix.replace(/\s+/g, '').toLowerCase();
|
||
const targetCleanPrefix = targetPrefix.replace(/\s+/g, '').toLowerCase();
|
||
|
||
// 如果前缀相似,则同步格式和季数
|
||
if (sourceCleanPrefix === targetCleanPrefix ||
|
||
sourceCleanPrefix.includes(targetCleanPrefix) ||
|
||
targetCleanPrefix.includes(sourceCleanPrefix)) {
|
||
|
||
// 同步格式:使用源的前缀+分隔符+季数+目标的后缀
|
||
return sourcePrefix + sourceSeparator + 'S' + sourceSeasonNumber.padStart(2, '0') + targetSuffix;
|
||
} else {
|
||
// 前缀不相似,只同步季数,保持原格式
|
||
return targetValue.replace(/S\d+/i, 'S' + sourceSeasonNumber.padStart(2, '0'));
|
||
}
|
||
},
|
||
|
||
// 保存路径变化时的处理函数
|
||
onSavepathChange(index, task) {
|
||
// 同步Sxx和格式到命名规则
|
||
if (task.pattern) {
|
||
task.pattern = this.syncFormatFromSavepath(task.savepath, task.pattern);
|
||
|
||
// 同步到相关的命名规则字段
|
||
if (task.use_sequence_naming && task.sequence_naming) {
|
||
task.sequence_naming = this.syncFormatFromSavepath(task.savepath, task.sequence_naming);
|
||
}
|
||
if (task.use_episode_naming && task.episode_naming) {
|
||
task.episode_naming = this.syncFormatFromSavepath(task.savepath, task.episode_naming);
|
||
}
|
||
}
|
||
},
|
||
|
||
// 命名规则变化时的处理函数
|
||
onPatternChange(index, task) {
|
||
// 从命名规则同步格式到保存路径
|
||
if (task.savepath) {
|
||
task.savepath = this.syncFormatFromNamingToSavepath(task.pattern, task.savepath);
|
||
}
|
||
},
|
||
|
||
// 从命名规则提取格式信息并应用到保存路径的辅助函数
|
||
syncFormatFromNamingToSavepath(namingPattern, savepath) {
|
||
if (!namingPattern || !savepath) return savepath;
|
||
|
||
// 提取命名规则中的格式信息
|
||
const patternMatch = namingPattern.match(/(.*?)([\s\.\-_]+)S(\d+)/i);
|
||
if (!patternMatch) return savepath; // 命名规则没有Sxx格式,不同步
|
||
|
||
const patternPrefix = patternMatch[1];
|
||
const patternSeparator = patternMatch[2];
|
||
const patternSeasonNumber = patternMatch[3];
|
||
|
||
// 检查保存路径是否包含Sxx格式
|
||
const pathParts = savepath.split('/');
|
||
if (pathParts.length === 0) return savepath;
|
||
|
||
const lastPart = pathParts[pathParts.length - 1];
|
||
const pathMatch = lastPart.match(/(.*?)([\s\.\-_]+)S(\d+)/i);
|
||
|
||
if (!pathMatch) return savepath; // 保存路径没有Sxx格式,不同步
|
||
|
||
const pathPrefix = pathMatch[1];
|
||
const pathSeparator = pathMatch[2];
|
||
const pathSeasonNumber = pathMatch[3];
|
||
|
||
// 检查前缀是否相似
|
||
const patternCleanPrefix = patternPrefix.replace(/\s+/g, '').toLowerCase();
|
||
const pathCleanPrefix = pathPrefix.replace(/\s+/g, '').toLowerCase();
|
||
|
||
if (patternCleanPrefix === pathCleanPrefix ||
|
||
patternCleanPrefix.includes(pathCleanPrefix) ||
|
||
pathCleanPrefix.includes(patternCleanPrefix)) {
|
||
|
||
// 同步格式:使用命名规则的格式更新保存路径
|
||
const newLastPart = patternPrefix + patternSeparator + 'S' + patternSeasonNumber.padStart(2, '0');
|
||
pathParts[pathParts.length - 1] = newLastPart;
|
||
return pathParts.join('/');
|
||
} else {
|
||
// 前缀不相似,只同步季数
|
||
const newLastPart = pathPrefix + pathSeparator + 'S' + patternSeasonNumber.padStart(2, '0');
|
||
pathParts[pathParts.length - 1] = newLastPart;
|
||
return pathParts.join('/');
|
||
}
|
||
},
|
||
|
||
// 从保存路径提取格式信息并应用到命名规则的辅助函数
|
||
syncFormatFromSavepath(savepath, namingPattern) {
|
||
if (!savepath || !namingPattern) return namingPattern;
|
||
|
||
// 从保存路径的最后一级提取格式信息
|
||
const pathParts = savepath.split('/');
|
||
if (pathParts.length === 0) return namingPattern;
|
||
|
||
const lastPart = pathParts[pathParts.length - 1];
|
||
const pathMatch = lastPart.match(/(.*?)([\s\.\-_]+)S(\d+)/i);
|
||
|
||
if (!pathMatch) return namingPattern;
|
||
|
||
const pathPrefix = pathMatch[1];
|
||
const pathSeparator = pathMatch[2];
|
||
const pathSeasonNumber = pathMatch[3];
|
||
|
||
// 检查命名规则是否包含Sxx格式
|
||
const namingMatch = namingPattern.match(/(.*?)([\s\.\-_]+)S(\d+)(.*)/i);
|
||
if (!namingMatch) return namingPattern;
|
||
|
||
const namingPrefix = namingMatch[1];
|
||
const namingSeparator = namingMatch[2];
|
||
const namingSeasonNumber = namingMatch[3];
|
||
const namingSuffix = namingMatch[4]; // 保留后缀部分(如E[]、E{}等)
|
||
|
||
// 检查前缀是否相似
|
||
const pathCleanPrefix = pathPrefix.replace(/\s+/g, '').toLowerCase();
|
||
const namingCleanPrefix = namingPrefix.replace(/\s+/g, '').toLowerCase();
|
||
|
||
if (pathCleanPrefix === namingCleanPrefix ||
|
||
pathCleanPrefix.includes(namingCleanPrefix) ||
|
||
namingCleanPrefix.includes(pathCleanPrefix)) {
|
||
|
||
// 同步格式:使用保存路径的格式更新命名规则
|
||
return pathPrefix + pathSeparator + 'S' + pathSeasonNumber.padStart(2, '0') + namingSuffix;
|
||
} else {
|
||
// 前缀不相似,只同步季数
|
||
return namingPattern.replace(/S\d+/i, 'S' + pathSeasonNumber.padStart(2, '0'));
|
||
}
|
||
},
|
||
|
||
changeTaskname(index, task) {
|
||
if (this.smart_param.searchTimer) {
|
||
clearTimeout(this.smart_param.searchTimer);
|
||
}
|
||
this.smart_param.searchTimer = setTimeout(() => {
|
||
this.searchSuggestions(index, task.taskname, 0);
|
||
}, 1000);
|
||
|
||
// 添加防抖机制,避免频繁的中间状态触发同步
|
||
if (this.smart_param.syncTimer) {
|
||
clearTimeout(this.smart_param.syncTimer);
|
||
}
|
||
this.smart_param.syncTimer = setTimeout(() => {
|
||
this.performTasknameSync(index, task);
|
||
}, 300); // 300ms 防抖延迟
|
||
},
|
||
|
||
performTasknameSync(index, task) {
|
||
// 重新计算智能路径参数
|
||
this.smart_param.index = index;
|
||
this.smart_param.origin_savepath = task.savepath;
|
||
const regex = new RegExp(`/${task.taskname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/|$)`);
|
||
if (task.savepath && task.savepath.includes('TASKNAME')) {
|
||
this.smart_param.savepath = task.savepath;
|
||
} else if (task.savepath && task.savepath.match(regex)) {
|
||
this.smart_param.savepath = task.savepath.replace(task.taskname, 'TASKNAME');
|
||
} else {
|
||
this.smart_param.savepath = undefined;
|
||
}
|
||
|
||
// 清理任务名称中的连续空格和特殊符号
|
||
const cleanTaskName = task.taskname.replace(/\s+/g, ' ').trim();
|
||
|
||
// 检测是否是不完整的季数格式(如 S0, S, - S 等),如果是则不处理
|
||
const incompleteSeasonPattern = /[\s\.\-_]+S\d{0,1}$/i;
|
||
if (incompleteSeasonPattern.test(cleanTaskName)) {
|
||
return; // 不处理不完整的季数格式
|
||
}
|
||
|
||
// 提取任务名称中的分隔符格式
|
||
// 使用与cleanTaskNameForSearch相同的季数格式匹配模式
|
||
let showName, seasonNumber, nameSeparator = ' - '; // 默认分隔符
|
||
let isTVShow = false; // 标记是否为电视节目
|
||
let separatorMatch = null;
|
||
|
||
// 匹配常见的季数格式
|
||
const seasonPatterns = [
|
||
/^(.*?)([\s\.\-_]+)S(\d+)$/i, // 黑镜 - S07、折腰.S01、音你而来-S02
|
||
/^(.*?)([\s\.\-_]+)Season\s*(\d+)$/i, // 黑镜 - Season 1
|
||
/^(.*?)(\s+)S(\d+)$/i, // 快乐的大人 S02
|
||
/^(.*?)([\s\.\-_]+)S(\d+)E\d+$/i, // 处理 S01E01 格式
|
||
/^(.*?)(\s+)第\s*(\d+)\s*季$/i, // 处理 第N季 格式
|
||
/^(.*?)([\s\.\-_]+)第\s*(\d+)\s*季$/i, // 处理 - 第N季 格式
|
||
/^(.*?)(\s+)第([一二三四五六七八九十零]+)季$/i, // 处理 第一季、第二季 格式
|
||
/^(.*?)([\s\.\-_]+)第([一二三四五六七八九十零]+)季$/i, // 处理 - 第一季、- 第二季 格式
|
||
];
|
||
|
||
// 尝试匹配各种格式
|
||
for (const pattern of seasonPatterns) {
|
||
const match = cleanTaskName.match(pattern);
|
||
if (match) {
|
||
separatorMatch = match;
|
||
// 根据不同的格式,确定季序号的位置
|
||
if (match[3] && match[3].match(/[一二三四五六七八九十百千万零两]/)) {
|
||
// 将中文数字转换为阿拉伯数字
|
||
const arabicNumber = chineseToArabic(match[3]);
|
||
seasonNumber = arabicNumber !== null ? String(arabicNumber) : '01';
|
||
} else {
|
||
seasonNumber = match[3] || '01';
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 判断保存路径是否包含电视节目相关的关键词
|
||
if (task.savepath) {
|
||
const tvKeywords = ["电视", "节目", "剧", "动漫", "动画", "番", "综艺", "真人秀", "TV", "Tv", "tv", "Series", "series", "Show", "show"];
|
||
isTVShow = tvKeywords.some(keyword => task.savepath.includes(keyword));
|
||
}
|
||
|
||
// 当任务名称包含季信息时,也按电视节目处理
|
||
const hasSeason = separatorMatch !== null;
|
||
if (hasSeason) {
|
||
isTVShow = true;
|
||
}
|
||
|
||
if (separatorMatch) {
|
||
// 提取剧名(去除末尾空格和特殊符号)
|
||
showName = separatorMatch[1].trim().replace(/[\s\.\-_]+$/, '');
|
||
// 季序号已在上面的循环中提取
|
||
// 提取实际使用的分隔符
|
||
const rawSeparator = separatorMatch[2];
|
||
|
||
// 处理特殊情况:如果是"黑镜 - 第五季"这种格式,需要保留完整的" - "分隔符
|
||
// 检查原始字符串中剧名后面的分隔符
|
||
const fullSeparator = cleanTaskName.substring(showName.length, cleanTaskName.length - (separatorMatch[3] ? separatorMatch[3].length + 1 : 0)).match(/^([\s\.\-_]+)/);
|
||
|
||
if (fullSeparator && fullSeparator[1]) {
|
||
// 使用完整的分隔符
|
||
nameSeparator = fullSeparator[1];
|
||
} else {
|
||
// 规范化分隔符(如果是连续的空格,转为单个空格)
|
||
nameSeparator = rawSeparator.replace(/\s+/g, ' ');
|
||
// 如果只有一个点号,保留点号
|
||
if (rawSeparator === '.') {
|
||
nameSeparator = '.';
|
||
}
|
||
// 如果是单个空格,保留单个空格
|
||
else if (rawSeparator === ' ') {
|
||
nameSeparator = ' ';
|
||
}
|
||
}
|
||
// 其他情况保持原样
|
||
} else {
|
||
// 如果没有匹配到季序号格式,处理删除季数信息的情况
|
||
showName = cleanTaskName;
|
||
|
||
// 检查保存路径中是否已有季数信息,如果有且是电视节目类型,则保留原有季数
|
||
let preserveExistingSeason = false;
|
||
|
||
if (task.savepath && isTVShow) {
|
||
const existingSeasonMatch = task.savepath.match(/S(\d+)/i);
|
||
|
||
if (existingSeasonMatch) {
|
||
// 只有当季数是有效的(不是00或空)时才保留
|
||
const existingSeasonNum = existingSeasonMatch[1];
|
||
if (existingSeasonNum && existingSeasonNum !== '00' && existingSeasonNum !== '0') {
|
||
seasonNumber = existingSeasonNum;
|
||
preserveExistingSeason = true;
|
||
|
||
// 从保存路径中提取分隔符格式
|
||
const pathParts = task.savepath.split('/');
|
||
if (pathParts.length > 0) {
|
||
const lastPart = pathParts[pathParts.length - 1];
|
||
const separatorMatch = lastPart.match(/^(.*?)([\s\.\-_]+)S\d+/i);
|
||
if (separatorMatch) {
|
||
nameSeparator = separatorMatch[2];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果没有保留现有季数,则使用默认值
|
||
if (!preserveExistingSeason) {
|
||
seasonNumber = '01';
|
||
}
|
||
}
|
||
|
||
// 更新保存路径 - 无论是否使用智能路径,都确保倒数第二级目录更新
|
||
if (task.savepath) {
|
||
// 分割保存路径为各级目录
|
||
const pathParts = task.savepath.split('/');
|
||
|
||
if (pathParts.length >= 2) {
|
||
// 如果智能路径已设置,使用原有逻辑更新最后一级
|
||
if (this.smart_param.savepath) {
|
||
// 更新最后一级目录,但保留前面的路径结构
|
||
let replacementName;
|
||
if (isTVShow) {
|
||
// 电视节目格式:剧名 + 分隔符 + S季序号
|
||
replacementName = showName + nameSeparator + 'S' + seasonNumber.padStart(2, '0');
|
||
} else {
|
||
// 非电视节目直接使用任务名称
|
||
replacementName = cleanTaskName;
|
||
}
|
||
const newPath = this.smart_param.savepath.replace('TASKNAME', replacementName);
|
||
const newPathParts = newPath.split('/');
|
||
pathParts[pathParts.length - 1] = newPathParts[newPathParts.length - 1];
|
||
} else {
|
||
// 根据是否为电视节目决定处理方式
|
||
if (isTVShow) {
|
||
// 电视节目格式:剧名 + 分隔符 + S季序号
|
||
const newLastPart = showName + nameSeparator + 'S' + seasonNumber.padStart(2, '0');
|
||
pathParts[pathParts.length - 1] = newLastPart;
|
||
} else {
|
||
// 非电视节目直接使用任务名称
|
||
pathParts[pathParts.length - 1] = cleanTaskName;
|
||
}
|
||
}
|
||
|
||
// 处理倒数第二级目录(剧名+年份)- 无论是否使用智能路径,都更新
|
||
if (pathParts.length >= 3) {
|
||
const parentDir = pathParts[pathParts.length - 2];
|
||
// 检查倒数第二级是否符合 "名称 (年份)" 格式
|
||
const yearMatch = parentDir.match(/^(.+?)\s*[\((](\d{4})[\))]$/);
|
||
|
||
if (yearMatch) {
|
||
if (isTVShow) {
|
||
// 电视节目:更新倒数第二级为新的剧名 + 年份
|
||
const year = yearMatch[2];
|
||
pathParts[pathParts.length - 2] = showName + ' (' + year + ')';
|
||
} else {
|
||
// 非电视节目:去除倒数第二级目录
|
||
pathParts.splice(pathParts.length - 2, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 更新保存路径
|
||
task.savepath = pathParts.join('/');
|
||
}
|
||
}
|
||
|
||
// 额外处理:检查保存路径最末级是否包含中文季序号,如果包含则转换为标准格式
|
||
if (task.savepath) {
|
||
const pathParts = task.savepath.split('/');
|
||
if (pathParts.length > 0) {
|
||
const lastPart = pathParts[pathParts.length - 1];
|
||
// 匹配中文季序号格式
|
||
const chineseSeasonMatch = lastPart.match(/^(.*?)([\s\.\-_]+)第([一二三四五六七八九十百千万零两]+)季$/);
|
||
if (chineseSeasonMatch) {
|
||
const showName = chineseSeasonMatch[1].trim();
|
||
const separator = chineseSeasonMatch[2];
|
||
const chineseSeason = chineseSeasonMatch[3];
|
||
|
||
// 将中文数字转换为阿拉伯数字
|
||
const arabicNumber = chineseToArabic(chineseSeason);
|
||
const seasonNumber = arabicNumber !== null ? String(arabicNumber) : '1';
|
||
|
||
// 更新最末级目录为标准格式
|
||
pathParts[pathParts.length - 1] = showName + separator + 'S' + seasonNumber.padStart(2, '0');
|
||
task.savepath = pathParts.join('/');
|
||
}
|
||
|
||
// 匹配阿拉伯数字季序号格式
|
||
const arabicSeasonMatch = lastPart.match(/^(.*?)([\s\.\-_]+)第(\d+)季$/);
|
||
if (arabicSeasonMatch) {
|
||
const showName = arabicSeasonMatch[1].trim();
|
||
const separator = arabicSeasonMatch[2];
|
||
const seasonNumber = arabicSeasonMatch[3];
|
||
|
||
// 更新最末级目录为标准格式
|
||
pathParts[pathParts.length - 1] = showName + separator + 'S' + seasonNumber.padStart(2, '0');
|
||
task.savepath = pathParts.join('/');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 更新命名规则 - 始终使用当前任务名称中的信息
|
||
if (task.pattern) {
|
||
// 根据是否为电视节目决定处理方式
|
||
if (isTVShow) {
|
||
// 处理剧集命名模式
|
||
if (task.use_episode_naming) {
|
||
// 直接使用任务名称中提取的信息构建命名规则
|
||
task.pattern = showName + nameSeparator + 'S' + seasonNumber.padStart(2, '0') + 'E[]';
|
||
task.episode_naming = task.pattern;
|
||
}
|
||
// 处理顺序命名模式
|
||
else if (task.use_sequence_naming) {
|
||
// 直接使用任务名称中提取的信息构建命名规则
|
||
task.pattern = showName + nameSeparator + 'S' + seasonNumber.padStart(2, '0') + 'E{}';
|
||
task.sequence_naming = task.pattern;
|
||
}
|
||
} else {
|
||
// 非电视节目,仅在使用剧集或顺序命名时更新
|
||
if (task.use_episode_naming) {
|
||
// 如果原模式是纯E[],直接使用任务名称
|
||
if (task.pattern.trim() === 'E[]') {
|
||
task.pattern = cleanTaskName + 'E[]';
|
||
} else if (task.pattern.includes('[]')) {
|
||
// 尝试保留原有格式,但更新剧名部分
|
||
const patternParts = task.pattern.split(/E\[\]/i);
|
||
if (patternParts.length > 0) {
|
||
task.pattern = cleanTaskName + 'E[]' + (patternParts[1] || '');
|
||
} else {
|
||
task.pattern = cleanTaskName + 'E[]';
|
||
}
|
||
}
|
||
task.episode_naming = task.pattern;
|
||
} else if (task.use_sequence_naming) {
|
||
// 同样处理顺序命名
|
||
if (task.pattern.trim() === 'E{}' || task.pattern.trim() === '{}') {
|
||
task.pattern = cleanTaskName + 'E{}';
|
||
} else if (task.pattern.includes('{}')) {
|
||
const patternParts = task.pattern.split(/E\{\}/i);
|
||
if (patternParts.length > 0) {
|
||
task.pattern = cleanTaskName + 'E{}' + (patternParts[1] || '');
|
||
} else {
|
||
task.pattern = cleanTaskName + 'E{}';
|
||
}
|
||
}
|
||
task.sequence_naming = task.pattern;
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
},
|
||
removeTask(index) {
|
||
if (!confirm("确定要删除任务 [#" + (index + 1) + ": " + this.formData.tasklist[index].taskname + "] 吗?")) return;
|
||
const task = this.formData.tasklist[index];
|
||
const taskName = task.taskname || task.task_name;
|
||
// 一次性任务(skip_calendar)不做日历清理,直接删除并保存
|
||
if (task.skip_calendar === true) {
|
||
this.formData.tasklist.splice(index, 1);
|
||
this.saveConfig();
|
||
return;
|
||
}
|
||
// 非一次性任务:先请求后端清理(含数据库与海报文件),再删除任务并保存配置
|
||
axios.post('/api/calendar/purge_by_task', { task_name: taskName })
|
||
.then(() => {
|
||
this.formData.tasklist.splice(index, 1);
|
||
this.saveConfig();
|
||
})
|
||
.catch(() => {
|
||
this.formData.tasklist.splice(index, 1);
|
||
this.saveConfig();
|
||
});
|
||
},
|
||
changeShareurl(task) {
|
||
if (!task.shareurl)
|
||
return;
|
||
this.$set(task, "shareurl_ban", undefined);
|
||
// 从URL中提取任务名
|
||
try {
|
||
const matches = decodeURIComponent(task.shareurl).match(/\/(\w{32})-([^\/]+)$/);
|
||
if (matches) {
|
||
task.taskname = task.taskname == "" ? matches[2] : task.taskname;
|
||
task.savepath = task.savepath.replace(/TASKNAME/g, matches[2]);
|
||
}
|
||
} catch (e) {
|
||
// 错误处理
|
||
}
|
||
// 从分享中提取任务名
|
||
axios.get('/get_share_detail', { params: { shareurl: task.shareurl } })
|
||
.then(response => {
|
||
share_detail = response.data.data
|
||
if (!response.data.success) {
|
||
if (share_detail.error.includes("提取码")) {
|
||
const passcode = prompt("检查失败[" + share_detail.error + "],请输入提取码:");
|
||
if (passcode != null) {
|
||
task.shareurl = task.shareurl.replace(/pan.quark.cn\/s\/(\w+)(\?pwd=\w*)*/, `pan.quark.cn/s/$1?pwd=${passcode}`);
|
||
this.changeShareurl(task);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 检查是否是可恢复的网络错误或服务端临时错误
|
||
if (share_detail.error && (
|
||
share_detail.error.includes("request error") ||
|
||
share_detail.error.includes("inner error") ||
|
||
share_detail.error.includes("网络错误") ||
|
||
share_detail.error.includes("服务端错误") ||
|
||
share_detail.error.includes("临时错误"))) {
|
||
// 忽略可恢复的错误,不设置 shareurl_ban
|
||
console.log('修改分享链接时出现可恢复错误,忽略此错误:', share_detail.error);
|
||
return;
|
||
}
|
||
|
||
// 使用格式化函数处理其他错误信息
|
||
const formattedError = this.formatShareUrlBanMessage(share_detail.error);
|
||
if (formattedError) {
|
||
this.$set(task, "shareurl_ban", formattedError);
|
||
}
|
||
} else {
|
||
// 检查文件列表是否为空
|
||
if (share_detail.list !== undefined && share_detail.list.length === 0) {
|
||
this.$set(task, "shareurl_ban", "该分享已被删除,无法访问");
|
||
} else {
|
||
task.taskname = task.taskname == "" ? share_detail.share.title : task.taskname;
|
||
task.savepath = task.savepath.replace(/TASKNAME/g, share_detail.share.title);
|
||
this.$set(task, "shareurl_ban", undefined);
|
||
}
|
||
}
|
||
})
|
||
.catch(error => {
|
||
// 网络请求失败,忽略错误,不设置 shareurl_ban
|
||
console.log('修改分享链接时网络请求失败,忽略此错误:', error);
|
||
});
|
||
},
|
||
clearData(target) {
|
||
this[target] = "";
|
||
},
|
||
clearRuntimeLogFilter(field) {
|
||
if (!this.runtimeLogFilters) return;
|
||
if (Object.prototype.hasOwnProperty.call(this.runtimeLogFilters, field)) {
|
||
this.$set(this.runtimeLogFilters, field, '');
|
||
}
|
||
},
|
||
// 运行日志:日志级别点击筛选
|
||
filterByLogLevel(level, event) {
|
||
// 防止事件冒泡
|
||
if (event) {
|
||
event.stopPropagation();
|
||
}
|
||
|
||
// 如果当前已经筛选了该级别,则取消筛选
|
||
if (this.runtimeLogFilters.level === level) {
|
||
this.$set(this.runtimeLogFilters, 'level', '');
|
||
} else {
|
||
// 设置级别筛选值
|
||
this.$set(this.runtimeLogFilters, 'level', level);
|
||
}
|
||
},
|
||
// 运行日志:统一获取日志原始文本
|
||
getRuntimeLogText(log) {
|
||
// 统一获取日志展示文本,避免空字符串被误判为无内容
|
||
if (!log) {
|
||
return '';
|
||
}
|
||
if (Object.prototype.hasOwnProperty.call(log, 'message')) {
|
||
const message = log.message;
|
||
if (message !== undefined && message !== null) {
|
||
return String(message);
|
||
}
|
||
}
|
||
return log.raw ? String(log.raw) : '';
|
||
},
|
||
// 运行日志:将日志文本转换为带可点击区域的 HTML
|
||
getRuntimeLogDisplayHtml(log) {
|
||
// 使用 v-html 时,先对原始文本进行转义,再插入自定义的可点击 span
|
||
const text = this.getRuntimeLogText(log) || '';
|
||
const escapeHtml = (str) => {
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
};
|
||
const escapeAttr = (str) => {
|
||
// 属性用的简单转义,避免引号破坏属性
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
};
|
||
|
||
let html = escapeHtml(text);
|
||
|
||
// 1. 任务名称: xxxx 中的 xxxx 可点击 —— 只处理首个“任务名称:”匹配,避免误伤其他内容
|
||
html = html.replace(/(任务名称:\s*)(.+)/, (match, prefix, name) => {
|
||
const trimmedName = name.trim();
|
||
if (!trimmedName) return match;
|
||
const safeName = escapeHtml(trimmedName);
|
||
const safeAttr = escapeAttr(trimmedName);
|
||
return `${prefix}<span class="runtime-log-taskname-clickable" data-taskname="${safeAttr}">${safeName}</span>`;
|
||
});
|
||
|
||
// 2. 将所有 >>> 替换为可点击的 span(在转义后的文本中是 >>>)
|
||
html = html.replace(/>>>/g, '<span class="runtime-log-arrow-clickable" data-keyword=">>>">>>></span>');
|
||
|
||
return html;
|
||
},
|
||
// 运行日志:统一处理内容区域点击(事件委托,避免影响复制)
|
||
handleRuntimeLogClick(log, event) {
|
||
if (!event || !event.target) return;
|
||
const target = event.target;
|
||
|
||
// 点击 >>> 触发内容关键词快速筛选(再次点击则清空)
|
||
if (target.classList && target.classList.contains('runtime-log-arrow-clickable')) {
|
||
event.stopPropagation();
|
||
this.toggleRuntimeLogKeywordFilter('>>>');
|
||
return;
|
||
}
|
||
|
||
// 点击任务名称触发任务筛选(再次点击则清空)
|
||
if (target.classList && target.classList.contains('runtime-log-taskname-clickable')) {
|
||
event.stopPropagation();
|
||
const taskName = (target.getAttribute('data-taskname') || target.textContent || '').trim();
|
||
if (taskName) {
|
||
this.toggleRuntimeLogTaskFilter(taskName);
|
||
}
|
||
}
|
||
},
|
||
// 运行日志:基于固定关键词(例如 >>>)的内容筛选开关
|
||
toggleRuntimeLogKeywordFilter(keyword) {
|
||
const current = (this.runtimeLogFilters.keyword || '').trim();
|
||
if (current === keyword) {
|
||
this.$set(this.runtimeLogFilters, 'keyword', '');
|
||
} else {
|
||
this.$set(this.runtimeLogFilters, 'keyword', keyword);
|
||
}
|
||
},
|
||
// 运行日志:基于任务名称的任务筛选开关(与任务下拉保持一致)
|
||
toggleRuntimeLogTaskFilter(taskName) {
|
||
const current = (this.runtimeLogFilters.task || '').trim();
|
||
if (current === taskName) {
|
||
this.$set(this.runtimeLogFilters, 'task', '');
|
||
} else {
|
||
this.$set(this.runtimeLogFilters, 'task', taskName);
|
||
}
|
||
},
|
||
parseTaskBlocks(logs) {
|
||
// 解析日志中的任务块,返回任务块数组
|
||
// 每个任务块包含:startIndex, endIndex, taskName, taskNumber
|
||
const blocks = [];
|
||
let currentBlock = null;
|
||
|
||
for (let i = 0; i < logs.length; i++) {
|
||
const log = logs[i];
|
||
const text = this.getRuntimeLogText(log);
|
||
|
||
// 检查是否是任务块开始标记:格式为 #数字------------------
|
||
const taskStartMatch = text.match(/^#(\d+)------------------/);
|
||
if (taskStartMatch) {
|
||
// 如果之前有未完成的任务块,先结束它(结束于上一个日志行)
|
||
if (currentBlock && currentBlock.endIndex === -1) {
|
||
currentBlock.endIndex = Math.max(i - 1, currentBlock.startIndex);
|
||
}
|
||
|
||
// 开始新的任务块
|
||
const taskNumber = taskStartMatch[1];
|
||
currentBlock = {
|
||
startIndex: i,
|
||
endIndex: -1, // 暂时设为-1,表示未结束
|
||
taskName: '',
|
||
taskNumber: taskNumber
|
||
};
|
||
blocks.push(currentBlock);
|
||
continue;
|
||
}
|
||
|
||
// 如果当前在任务块中,检查是否是任务名称行
|
||
if (currentBlock && currentBlock.taskName === '') {
|
||
const taskNameMatch = text.match(/^任务名称:\s*(.+)$/);
|
||
if (taskNameMatch) {
|
||
currentBlock.taskName = taskNameMatch[1].trim();
|
||
}
|
||
}
|
||
|
||
// 检查是否是任务块结束标记
|
||
if (currentBlock && currentBlock.endIndex === -1) {
|
||
// 检查是否是程序结束相关标记(这些标记表示任务块应该结束)
|
||
// 任务块应该在推送通知之前结束,包括推送通知之前的空行
|
||
if (text.includes('===============推送通知===============') ||
|
||
text.includes('===============程序结束===============')) {
|
||
// 任务块结束于推送通知之前的最后一行(包括空行)
|
||
// 如果上一行是空行,则包含它;否则结束于上一行
|
||
currentBlock.endIndex = i - 1;
|
||
currentBlock = null;
|
||
continue;
|
||
}
|
||
|
||
// 检查是否是程序执行成功标记(这些标记不属于任务块)
|
||
if (text.match(/^>>>\s+(定时运行全部任务|手动运行任务\s+\[.+\])\s+执行成功$/)) {
|
||
// 执行成功标记不属于任务块,所以任务块结束于上一行
|
||
currentBlock.endIndex = Math.max(i - 1, currentBlock.startIndex);
|
||
currentBlock = null;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理最后一个未结束的任务块
|
||
if (currentBlock && currentBlock.endIndex === -1) {
|
||
currentBlock.endIndex = logs.length - 1;
|
||
}
|
||
|
||
return blocks;
|
||
},
|
||
fetchRuntimeLogs(force = false) {
|
||
const hasLogs = Array.isArray(this.runtimeLogs) && this.runtimeLogs.length > 0;
|
||
const shouldShowLoading = force || (!hasLogs && !this.runtimeLogLoading);
|
||
const isInitialLoad = force && !hasLogs; // 判断是否是首次加载(强制刷新且没有现有日志)
|
||
|
||
// 首次加载时先隐藏内容,避免闪现
|
||
if (isInitialLoad) {
|
||
this.runtimeLogInitialized = false;
|
||
} else if (!this.runtimeLogInitialized && hasLogs) {
|
||
// 如果不是首次加载但日志被隐藏了(比如从其他页面切换回来),立即显示
|
||
this.runtimeLogInitialized = true;
|
||
}
|
||
|
||
if (shouldShowLoading) {
|
||
this.runtimeLogLoading = true;
|
||
}
|
||
|
||
// 记录刷新前的滚动位置和内容高度
|
||
const viewport = this.$refs.runtimeLogViewport;
|
||
let previousScrollTop = 0;
|
||
let previousScrollHeight = 0;
|
||
let wasNearBottom = false;
|
||
|
||
if (viewport) {
|
||
previousScrollTop = viewport.scrollTop;
|
||
previousScrollHeight = viewport.scrollHeight;
|
||
// 判断用户是否接近底部(距离底部小于50像素视为接近底部)
|
||
const distanceFromBottom = previousScrollHeight - previousScrollTop - viewport.clientHeight;
|
||
wasNearBottom = distanceFromBottom < 50;
|
||
}
|
||
|
||
// 获取运行日志显示范围配置(天数),默认3天
|
||
const displayDays = this.formData.performance && this.formData.performance.runtime_log_display_days
|
||
? parseFloat(this.formData.performance.runtime_log_display_days) || 3
|
||
: 3;
|
||
axios.get('/api/runtime_logs', { params: { days: displayDays } })
|
||
.then(res => {
|
||
if (res.data && res.data.success) {
|
||
this.runtimeLogs = res.data.logs || [];
|
||
this.$nextTick(() => {
|
||
const viewport = this.$refs.runtimeLogViewport;
|
||
if (viewport) {
|
||
if (isInitialLoad || wasNearBottom) {
|
||
// 首次加载或用户接近底部时,滚动到底部(保持自动跟随最新日志)
|
||
viewport.scrollTop = viewport.scrollHeight;
|
||
// 首次加载时,滚动完成后再显示内容,避免闪现
|
||
if (isInitialLoad) {
|
||
// 使用 requestAnimationFrame 确保滚动完成后再显示
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
this.runtimeLogInitialized = true;
|
||
});
|
||
});
|
||
}
|
||
} else {
|
||
// 否则保持用户的滚动位置
|
||
const newScrollHeight = viewport.scrollHeight;
|
||
const heightDiff = newScrollHeight - previousScrollHeight;
|
||
viewport.scrollTop = previousScrollTop + heightDiff;
|
||
}
|
||
// 确保如果不是首次加载,日志是显示的
|
||
if (!isInitialLoad && !this.runtimeLogInitialized) {
|
||
this.runtimeLogInitialized = true;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('获取运行日志失败:', error);
|
||
// 出错时也要显示内容
|
||
if (isInitialLoad) {
|
||
this.runtimeLogInitialized = true;
|
||
} else if (!this.runtimeLogInitialized) {
|
||
this.runtimeLogInitialized = true;
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (shouldShowLoading) {
|
||
this.runtimeLogLoading = false;
|
||
}
|
||
});
|
||
},
|
||
startRuntimeLogPolling() {
|
||
if (this.runtimeLogPollingTimer) {
|
||
return;
|
||
}
|
||
this.fetchRuntimeLogs(true);
|
||
this.runtimeLogPollingTimer = setInterval(() => {
|
||
this.fetchRuntimeLogs(false);
|
||
}, 5000);
|
||
},
|
||
stopRuntimeLogPolling() {
|
||
if (this.runtimeLogPollingTimer) {
|
||
clearInterval(this.runtimeLogPollingTimer);
|
||
this.runtimeLogPollingTimer = null;
|
||
}
|
||
},
|
||
async runScriptNow(task_index = null) {
|
||
body = {};
|
||
if (task_index != null) {
|
||
task = { ...this.formData.tasklist[task_index] };
|
||
delete task.runweek;
|
||
delete task.enddate;
|
||
body = {
|
||
"tasklist": [task],
|
||
"original_index": task_index + 1 // 添加原始索引,从1开始计数
|
||
};
|
||
} else if (this.configModified) {
|
||
if (!confirm('配置已修改但未保存,是否继续运行?')) {
|
||
return;
|
||
}
|
||
}
|
||
$('#logModal').modal('toggle');
|
||
this.modalLoading = true;
|
||
this.run_log = '';
|
||
try {
|
||
// 1. 发送 POST 请求
|
||
const response = await fetch(`/run_script_now`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||
}
|
||
// 2. 处理 SSE 流
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let partialData = '';
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) {
|
||
this.modalLoading = false;
|
||
// 运行后刷新数据
|
||
this.fetchData();
|
||
break;
|
||
}
|
||
partialData += decoder.decode(value);
|
||
const lines = partialData.split('\n').filter(line => line.trim() !== '');
|
||
for (const line of lines) {
|
||
if (line.startsWith('data:')) {
|
||
const eventData = line.substring(5).trim();
|
||
if (eventData === '[DONE]') {
|
||
this.modalLoading = false;
|
||
this.fetchData();
|
||
return;
|
||
}
|
||
this.run_log += eventData + '\n';
|
||
// 在更新 run_log 后将滚动条滚动到底部
|
||
this.$nextTick(() => {
|
||
const modalBody = document.querySelector('.modal-body');
|
||
modalBody.scrollTop = modalBody.scrollHeight;
|
||
});
|
||
}
|
||
}
|
||
partialData = '';
|
||
}
|
||
} catch (error) {
|
||
this.modalLoading = false;
|
||
}
|
||
},
|
||
getParentDirectory(path) {
|
||
parentDir = path.substring(0, path.lastIndexOf('/'))
|
||
if (parentDir == "")
|
||
parentDir = "/"
|
||
return parentDir;
|
||
},
|
||
scrollToX(top = undefined) {
|
||
if (top == undefined)
|
||
top = document.documentElement.scrollHeight
|
||
window.scrollTo({
|
||
top: top,
|
||
behavior: "smooth"
|
||
});
|
||
},
|
||
getAvailablePlugins(plugins) {
|
||
availablePlugins = {};
|
||
const pluginsFlagsArray = this.plugin_flags.split(',');
|
||
for (const pluginName in plugins) {
|
||
if (!pluginsFlagsArray.includes(`-${pluginName}`)) {
|
||
availablePlugins[pluginName] = plugins[pluginName];
|
||
}
|
||
}
|
||
return availablePlugins;
|
||
},
|
||
toggleAllWeekdays(task) {
|
||
if (task.runweek.length === 7) {
|
||
task.runweek = [];
|
||
} else {
|
||
task.runweek = [1, 2, 3, 4, 5, 6, 7];
|
||
}
|
||
},
|
||
onExecutionModeChange() {
|
||
// 批量更新所有任务的execution_mode
|
||
if (this.formData.tasklist && this.formData.tasklist.length > 0) {
|
||
this.formData.tasklist.forEach(task => {
|
||
if (!task.hasOwnProperty('execution_mode')) {
|
||
this.$set(task, 'execution_mode', this.formData.execution_mode);
|
||
} else {
|
||
task.execution_mode = this.formData.execution_mode;
|
||
}
|
||
});
|
||
}
|
||
},
|
||
searchSuggestions(index, taskname, deep = 1) {
|
||
if (taskname.length < 2) {
|
||
return;
|
||
}
|
||
// 开始搜索前清除之前的搜索结果
|
||
this.smart_param.taskSuggestions = {
|
||
success: false,
|
||
data: []
|
||
};
|
||
this.smart_param.isSearching = true;
|
||
this.smart_param.validating = false; // 重置验证状态
|
||
this.smart_param.index = index;
|
||
// 确保显示下拉菜单
|
||
this.smart_param.showSuggestions = true;
|
||
try {
|
||
// 启动新的搜索会话,后续增量结果仅在会话一致时才渲染
|
||
const sessionId = ++this.smart_param.searchSessionId;
|
||
axios.get('/task_suggestions', {
|
||
params: {
|
||
q: taskname,
|
||
d: deep
|
||
}
|
||
}).then(response => {
|
||
// 接收到数据后,过滤无效链接
|
||
if (sessionId !== this.smart_param.searchSessionId) return; // 旧会话结果忽略
|
||
if (response.data.success && response.data.data && response.data.data.length > 0) {
|
||
// 使用新增的方法验证链接有效性
|
||
this.validateSearchResults(response.data, sessionId);
|
||
} else {
|
||
this.smart_param.taskSuggestions = response.data;
|
||
// 重新确认设置为true
|
||
this.smart_param.showSuggestions = true;
|
||
this.smart_param.isSearching = false;
|
||
this.smart_param.validating = false; // 重置验证状态
|
||
}
|
||
}).catch(error => {
|
||
this.smart_param.isSearching = false;
|
||
this.smart_param.validating = false; // 重置验证状态
|
||
|
||
});
|
||
} catch (e) {
|
||
this.smart_param.taskSuggestions = {
|
||
error: "网络异常"
|
||
};
|
||
this.smart_param.isSearching = false;
|
||
this.smart_param.validating = false; // 重置验证状态
|
||
|
||
}
|
||
},
|
||
// 添加新方法来验证搜索结果
|
||
validateSearchResults(searchData, sessionId) {
|
||
const invalidTerms = [
|
||
"分享者用户封禁链接查看受限",
|
||
"好友已取消了分享",
|
||
"文件已被分享者删除",
|
||
"文件夹为空",
|
||
"分享地址已失效",
|
||
"文件涉及违规内容"
|
||
];
|
||
|
||
// 如果没有搜索结果,直接返回
|
||
if (!searchData.data || searchData.data.length === 0) {
|
||
this.smart_param.taskSuggestions = searchData;
|
||
this.smart_param.isSearching = false;
|
||
this.smart_param.validating = false;
|
||
return;
|
||
}
|
||
|
||
// 设置验证状态
|
||
this.smart_param.validating = true;
|
||
|
||
// 初始化验证进度
|
||
this.smart_param.validateProgress = {
|
||
total: searchData.data.length,
|
||
current: 0,
|
||
valid: 0
|
||
};
|
||
|
||
// 初始化一个结果数组和待处理数组
|
||
const validResults = [];
|
||
const toProcess = [...searchData.data]; // 复制数组,保留原始顺序
|
||
|
||
// 批量处理的大小,每批次最多处理5个链接
|
||
const batchSize = 5;
|
||
|
||
// 解析时间用于排序(降序:最新在前)
|
||
const getItemTs = (item) => this.parsePublishTs(item && item.publish_date, item && item.source, item && item.pansou_source);
|
||
|
||
// 处理单个链接的函数
|
||
const processLink = (link) => {
|
||
return new Promise((resolve) => {
|
||
if (!link.shareurl) {
|
||
// 没有分享链接,直接跳过
|
||
|
||
resolve(null);
|
||
return;
|
||
}
|
||
|
||
// 使用现有的get_share_detail接口验证链接
|
||
axios.get('/get_share_detail', {
|
||
params: { shareurl: link.shareurl }
|
||
})
|
||
.then(response => {
|
||
// 更新进度
|
||
this.smart_param.validateProgress.current++;
|
||
if (sessionId !== this.smart_param.searchSessionId) {
|
||
resolve(null);
|
||
return;
|
||
}
|
||
|
||
if (response.data.success) {
|
||
// 检查文件列表是否为空
|
||
const shareDetail = response.data.data;
|
||
if (shareDetail.list && shareDetail.list.length > 0) {
|
||
// 链接有效,添加到有效结果列表
|
||
this.smart_param.validateProgress.valid++;
|
||
|
||
resolve(link);
|
||
return;
|
||
}
|
||
} else {
|
||
// 检查是否包含已知的失效原因
|
||
const error = response.data.data.error || "";
|
||
let isInvalid = false;
|
||
|
||
// 如果是可恢复的网络错误或服务端临时错误,视为有效(网络问题,不是资源问题)
|
||
if (error.includes("request error") ||
|
||
error.includes("inner error") ||
|
||
error.includes("网络错误") ||
|
||
error.includes("服务端错误") ||
|
||
error.includes("临时错误")) {
|
||
this.smart_param.validateProgress.valid++;
|
||
resolve(link);
|
||
return;
|
||
}
|
||
|
||
for (const term of invalidTerms) {
|
||
if (error.includes(term)) {
|
||
isInvalid = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 如果不是已知的失效原因,保留该结果
|
||
if (!isInvalid) {
|
||
this.smart_param.validateProgress.valid++;
|
||
|
||
resolve(link);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 链接无效
|
||
|
||
resolve(null);
|
||
})
|
||
.catch(error => {
|
||
// 验证出错,保守处理为有效
|
||
this.smart_param.validateProgress.current++;
|
||
this.smart_param.validateProgress.valid++;
|
||
|
||
resolve(link);
|
||
});
|
||
});
|
||
};
|
||
|
||
// 修改processBatch函数,增加快速显示功能
|
||
const processBatch = async () => {
|
||
// 新会话已开始或已被取消(validating=false)则停止当前批次
|
||
if (sessionId !== this.smart_param.searchSessionId || !this.smart_param.validating) return;
|
||
// 取下一批处理
|
||
const batch = toProcess.splice(0, batchSize);
|
||
if (batch.length === 0) {
|
||
// 所有批次处理完毕
|
||
this.finishValidation(searchData, validResults);
|
||
return;
|
||
}
|
||
|
||
// 并行处理当前批次
|
||
const results = await Promise.all(batch.map(processLink));
|
||
|
||
// 收集有效结果
|
||
results.forEach(result => {
|
||
if (result) {
|
||
validResults.push(result);
|
||
}
|
||
});
|
||
|
||
// 动态排序(按发布时间/日期降序)
|
||
validResults.sort((a, b) => getItemTs(b) - getItemTs(a));
|
||
|
||
// 每批次都增量更新到界面,显示当前有效数量并保持正在验证状态
|
||
if (sessionId !== this.smart_param.searchSessionId || !this.smart_param.validating) return; // 渲染前再次检查会话
|
||
this.smart_param.taskSuggestions = {
|
||
success: searchData.success,
|
||
source: searchData.source,
|
||
data: [...validResults],
|
||
message: `已找到${validResults.length}个有效链接,验证继续进行中...`
|
||
};
|
||
|
||
// 继续处理下一批
|
||
processBatch();
|
||
};
|
||
|
||
// 开始批量处理前重置快速显示标记
|
||
this.smart_param._hasShownInterimResults = false;
|
||
processBatch();
|
||
|
||
// 设置超时,避免永久等待
|
||
setTimeout(() => {
|
||
// 如果验证还在进行中,强制完成
|
||
if (this.smart_param.validating && sessionId === this.smart_param.searchSessionId) {
|
||
// 在收尾前立即取消会话,避免后续批次继续追加导致重复
|
||
this.smart_param.validating = false;
|
||
this.smart_param.searchSessionId++;
|
||
// 将剩余未验证的链接添加到结果中
|
||
const remaining = toProcess.filter(item => item.shareurl);
|
||
validResults.push(...remaining);
|
||
|
||
// 更新进度统计
|
||
this.smart_param.validateProgress.current = this.smart_param.validateProgress.total;
|
||
this.smart_param.validateProgress.valid = validResults.length;
|
||
|
||
// 完成验证
|
||
this.finishValidation(searchData, validResults);
|
||
|
||
}
|
||
}, 30000); // 30秒超时
|
||
},
|
||
// 完成验证并更新搜索结果
|
||
finishValidation(searchData, validResults) {
|
||
// 清除快速显示标记
|
||
this.smart_param._hasShownInterimResults = false;
|
||
|
||
// 结束前做一次排序,确保最终顺序正确
|
||
const getItemTs = (item) => this.parsePublishTs(item && item.publish_date, item && item.source, item && item.pansou_source);
|
||
validResults.sort((a, b) => getItemTs(b) - getItemTs(a));
|
||
|
||
// 更新搜索结果
|
||
const result = {
|
||
success: searchData.success,
|
||
source: searchData.source,
|
||
data: validResults,
|
||
message: validResults.length === 0 ? "未找到有效的分享链接" : searchData.message
|
||
};
|
||
|
||
|
||
this.smart_param.taskSuggestions = result;
|
||
this.smart_param.isSearching = false;
|
||
this.smart_param.validating = false;
|
||
this.smart_param.showSuggestions = true;
|
||
},
|
||
selectSuggestion(index, suggestion) {
|
||
// 不直接设置分享链接到输入框,只是打开文件选择模态框让用户浏览
|
||
// 用户在模态框中导航后,最终的 this.fileSelect.shareurl 才是需要的地址
|
||
|
||
// 记录当前选择的资源索引,用于连续浏览功能
|
||
const resourceIndex = this.smart_param.taskSuggestions.data.findIndex(item =>
|
||
item.shareurl === suggestion.shareurl &&
|
||
item.taskname === suggestion.taskname
|
||
);
|
||
this.smart_param.currentResourceIndex = resourceIndex;
|
||
|
||
// 确保显示的是选择需转存的文件夹界面,而不是命名预览界面
|
||
this.fileSelect.previewRegex = false;
|
||
this.fileSelect.selectDir = true;
|
||
this.showShareSelect(index, suggestion.shareurl);
|
||
},
|
||
// 导航到上一个资源
|
||
navigateToPreviousResource() {
|
||
if (this.smart_param.currentResourceIndex > 0) {
|
||
this.smart_param.currentResourceIndex--;
|
||
const previousResource = this.smart_param.taskSuggestions.data[this.smart_param.currentResourceIndex];
|
||
if (previousResource) {
|
||
// 在切换资源时,重置展示相关状态,避免错误提示残留
|
||
this.fileSelect.error = undefined;
|
||
this.fileSelect.fileList = [];
|
||
this.fileSelect.paths = [];
|
||
// 确保处于“选择需转存的文件夹”界面
|
||
this.fileSelect.previewRegex = false;
|
||
this.fileSelect.selectDir = true;
|
||
// 如果基础链接变化,清空stoken强制刷新
|
||
try {
|
||
const oldBase = this.getShareurl(this.fileSelect.shareurl);
|
||
const newBase = this.getShareurl(previousResource.shareurl);
|
||
if (oldBase !== newBase) {
|
||
this.fileSelect.stoken = "";
|
||
}
|
||
} catch (e) {
|
||
this.fileSelect.stoken = "";
|
||
}
|
||
// 更新当前分享链接并重新加载内容
|
||
this.fileSelect.shareurl = previousResource.shareurl;
|
||
this.getShareDetail(0, 1);
|
||
}
|
||
}
|
||
},
|
||
// 导航到下一个资源
|
||
navigateToNextResource() {
|
||
if (this.smart_param.currentResourceIndex < this.smart_param.taskSuggestions.data.length - 1) {
|
||
this.smart_param.currentResourceIndex++;
|
||
const nextResource = this.smart_param.taskSuggestions.data[this.smart_param.currentResourceIndex];
|
||
if (nextResource) {
|
||
// 在切换资源时,重置展示相关状态,避免错误提示残留
|
||
this.fileSelect.error = undefined;
|
||
this.fileSelect.fileList = [];
|
||
this.fileSelect.paths = [];
|
||
// 确保处于“选择需转存的文件夹”界面
|
||
this.fileSelect.previewRegex = false;
|
||
this.fileSelect.selectDir = true;
|
||
// 如果基础链接变化,清空stoken强制刷新
|
||
try {
|
||
const oldBase = this.getShareurl(this.fileSelect.shareurl);
|
||
const newBase = this.getShareurl(nextResource.shareurl);
|
||
if (oldBase !== newBase) {
|
||
this.fileSelect.stoken = "";
|
||
}
|
||
} catch (e) {
|
||
this.fileSelect.stoken = "";
|
||
}
|
||
// 更新当前分享链接并重新加载内容
|
||
this.fileSelect.shareurl = nextResource.shareurl;
|
||
this.getShareDetail(0, 1);
|
||
}
|
||
}
|
||
},
|
||
// 判断当前资源是否在搜索结果中
|
||
isCurrentResourceInSearchResults() {
|
||
if (!this.fileSelect.selectShare || !this.smart_param.taskSuggestions.data || this.smart_param.taskSuggestions.data.length === 0) {
|
||
return false;
|
||
}
|
||
|
||
// 如果currentResourceIndex有效,说明用户是从搜索结果进入的(包括下级目录)
|
||
if (this.smart_param.currentResourceIndex >= 0 && this.smart_param.currentResourceIndex < this.smart_param.taskSuggestions.data.length) {
|
||
return true;
|
||
}
|
||
|
||
// 通过资源ID来判断当前资源是否在搜索结果中
|
||
const currentResourceId = this.getResourceIdFromUrl(this.fileSelect.shareurl);
|
||
if (!currentResourceId) {
|
||
return false;
|
||
}
|
||
|
||
const isInResults = this.smart_param.taskSuggestions.data.some(resource => {
|
||
const resourceId = this.getResourceIdFromUrl(resource.shareurl);
|
||
return resourceId && resourceId === currentResourceId;
|
||
});
|
||
|
||
return isInResults;
|
||
},
|
||
// 从分享链接中提取资源ID
|
||
getResourceIdFromUrl(shareurl) {
|
||
if (!shareurl) return null;
|
||
|
||
try {
|
||
// 夸克网盘分享链接格式:https://pan.quark.cn/s/资源ID
|
||
const match = shareurl.match(/pan\.quark\.cn\/s\/([a-zA-Z0-9]+)/);
|
||
return match ? match[1] : null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
},
|
||
addMagicRegex() {
|
||
const newKey = `$MAGIC_${Object.keys(this.formData.magic_regex).length + 1}`;
|
||
this.$set(this.formData.magic_regex, newKey, { pattern: '', replace: '' });
|
||
},
|
||
updateMagicRegexKey(oldKey, newKey) {
|
||
if (oldKey !== newKey) {
|
||
if (this.formData.magic_regex[newKey]) {
|
||
alert(`魔法名 [${newKey}] 已存在,请使用其他名称`);
|
||
return;
|
||
}
|
||
this.$set(this.formData.magic_regex, newKey, this.formData.magic_regex[oldKey]);
|
||
this.$delete(this.formData.magic_regex, oldKey);
|
||
}
|
||
},
|
||
removeMagicRegex(key) {
|
||
if (confirm(`确定要删除魔法匹配规则 [${key}] 吗?`)) {
|
||
this.$delete(this.formData.magic_regex, key);
|
||
}
|
||
},
|
||
deleteFileForSelect(fid, fname, isDir, deleteRecords = false) {
|
||
let confirmMessage = deleteRecords
|
||
? `确定要删除此项目及其关联记录吗?`
|
||
: `确定要删除此项目吗?`;
|
||
|
||
if (!confirm(confirmMessage)) {
|
||
return;
|
||
}
|
||
|
||
// 获取当前路径作为save_path参数
|
||
let save_path = "";
|
||
if (this.fileSelect && this.fileSelect.paths) {
|
||
save_path = this.fileSelect.paths.map(item => item.name).join("/");
|
||
}
|
||
|
||
axios.post('/delete_file', { fid: fid, file_name: fname, delete_records: deleteRecords, save_path: save_path })
|
||
.then(response => {
|
||
if (response.data.code === 0) {
|
||
// 从列表中移除文件
|
||
this.fileSelect.fileList = this.fileSelect.fileList.filter(item => item.fid !== fid);
|
||
|
||
// 如果文件在选中列表中,也从选中列表中移除
|
||
if (this.fileSelect.selectedFiles.includes(fid)) {
|
||
this.fileSelect.selectedFiles = this.fileSelect.selectedFiles.filter(id => id !== fid);
|
||
|
||
// 如果选中列表为空,重置最后选择的索引
|
||
if (this.fileSelect.selectedFiles.length === 0) {
|
||
this.fileSelect.lastSelectedFileIndex = -1;
|
||
}
|
||
}
|
||
|
||
// 显示成功消息,根据是否删除记录显示不同的消息
|
||
if (deleteRecords) {
|
||
const deletedRecords = response.data.deleted_records || 0;
|
||
this.showToast(`成功删除 1 个项目${deletedRecords > 0 ? `及其关联的 ${deletedRecords} 条记录` : ''}`);
|
||
} else {
|
||
this.showToast('成功删除 1 个项目');
|
||
}
|
||
|
||
// 如果同时删除了记录,无论当前在哪个页面,都刷新历史记录
|
||
if (deleteRecords) {
|
||
this.loadHistoryRecords();
|
||
}
|
||
// 仅当删除了记录时,才触发任务/日历刷新(统计依赖记录)
|
||
if (deleteRecords && (response.data.deleted_records || 0) > 0) {
|
||
(async () => {
|
||
try {
|
||
const latestRes = await axios.get('/task_latest_info');
|
||
if (latestRes.data && latestRes.data.success) {
|
||
const latestFiles = latestRes.data.data.latest_files || {};
|
||
this.taskLatestFiles = latestFiles;
|
||
this.taskLatestRecords = latestRes.data.data.latest_records || {};
|
||
this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles);
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
}
|
||
} catch (e) {}
|
||
try {
|
||
const tasksResponse = await axios.get('/api/calendar/tasks');
|
||
if (tasksResponse.data && tasksResponse.data.success) {
|
||
this.calendar.tasks = tasksResponse.data.data.tasks || [];
|
||
this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name);
|
||
const rawTypes = tasksResponse.data.data.content_types || [];
|
||
this.updateContentTypes(rawTypes);
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
}
|
||
} catch (e) {}
|
||
try {
|
||
await this.loadCalendarEpisodesLocal();
|
||
this.initializeCalendarDates();
|
||
await this.loadTodayUpdatesLocal();
|
||
} catch (e) {}
|
||
})();
|
||
}
|
||
} else {
|
||
alert('删除失败: ' + response.data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('删除项目出错:', error);
|
||
alert('删除项目出错: ' + (error.response?.data?.message || error.message));
|
||
});
|
||
},
|
||
getSavepathDetail(params = 0, retryCount = 0, maxRetries = 1) {
|
||
if (params === "" || params === null || params === undefined) {
|
||
// 为空字符串时直接使用根目录fid
|
||
params = 0;
|
||
} else if (params.includes && params.includes('/')) {
|
||
params = { path: params }
|
||
} else {
|
||
params = { fid: params }
|
||
}
|
||
this.modalLoading = true;
|
||
|
||
// 根据模态框类型决定使用哪个账号
|
||
// 任务配置相关的模态框始终使用主账号(索引0)
|
||
// 文件整理页面的预览模态框和移动文件模态框使用选中的账号
|
||
const modalType = document.getElementById('fileSelectModal').getAttribute('data-modal-type');
|
||
const accountIndex = (modalType === 'preview-filemanager' || modalType === 'move') ? this.fileManager.selectedAccountIndex : 0;
|
||
|
||
// 添加账号索引参数
|
||
if (typeof params === 'object' && params !== null) {
|
||
params.account_index = accountIndex;
|
||
} else {
|
||
params = {
|
||
fid: params,
|
||
account_index: accountIndex
|
||
};
|
||
}
|
||
axios.get('/get_savepath_detail', {
|
||
params: params
|
||
}).then(response => {
|
||
this.fileSelect.fileList = response.data.data.list
|
||
// 应用默认排序,但不改变当前的排序顺序
|
||
this.manualSortFileList(this.fileSelect.sortBy, this.fileSelect.sortOrder);
|
||
if (response.data.data.paths.length > 0) {
|
||
this.fileSelect.paths = response.data.data.paths
|
||
}
|
||
this.modalLoading = false;
|
||
}).catch(error => {
|
||
// 增强版无感重试:添加缓存破坏参数,并仅在模态框仍打开时重试
|
||
if (retryCount < maxRetries) {
|
||
console.log(`获取文件夹列表失败,正在进行第 ${retryCount + 1} 次重试...`);
|
||
setTimeout(() => {
|
||
// 确保模态框仍处于打开状态,否则中止并结束loading
|
||
const fileSelectModal = document.getElementById('fileSelectModal');
|
||
if (!(fileSelectModal && fileSelectModal.classList.contains('show'))) {
|
||
this.modalLoading = false;
|
||
return;
|
||
}
|
||
// 为请求参数添加时间戳,避免潜在的缓存干扰
|
||
if (typeof params === 'object' && params !== null) {
|
||
params._ts = Date.now();
|
||
}
|
||
this.getSavepathDetail(params, retryCount + 1, maxRetries);
|
||
}, 600);
|
||
} else {
|
||
this.fileSelect.error = "获取文件夹列表失败,请关闭窗口再试一次";
|
||
this.modalLoading = false;
|
||
}
|
||
});
|
||
},
|
||
showSavepathSelect(index) {
|
||
this.fileSelect.selectShare = false;
|
||
this.fileSelect.selectDir = true;
|
||
this.fileSelect.previewRegex = false;
|
||
this.fileSelect.error = undefined;
|
||
this.fileSelect.fileList = [];
|
||
this.fileSelect.paths = [];
|
||
this.fileSelect.index = index;
|
||
// 重置排序状态为默认值 - 选择需转存的文件夹模态框默认修改时间倒序
|
||
this.fileSelect.sortBy = "updated_at";
|
||
this.fileSelect.sortOrder = "desc";
|
||
|
||
// 设置模态框类型为target(保存目标文件夹)
|
||
document.getElementById('fileSelectModal').setAttribute('data-modal-type', 'target');
|
||
|
||
// 检查是否从创建任务模态框中打开,如果是则设置更高的z-index
|
||
const createTaskModal = document.getElementById('createTaskModal');
|
||
const fileSelectModal = document.getElementById('fileSelectModal');
|
||
if (createTaskModal && createTaskModal.classList.contains('show')) {
|
||
fileSelectModal.style.zIndex = '1060';
|
||
// 查找并设置backdrop的z-index
|
||
setTimeout(() => {
|
||
const backdrop = document.querySelector('.modal-backdrop:last-child');
|
||
if (backdrop) {
|
||
backdrop.style.zIndex = '1055';
|
||
}
|
||
}, 50);
|
||
} else {
|
||
// 重置为默认值
|
||
fileSelectModal.style.zIndex = '';
|
||
}
|
||
|
||
$('#fileSelectModal').modal('toggle');
|
||
|
||
// 当savepath为空时,直接加载根目录
|
||
// 处理创建任务模态框的情况(index为-1)
|
||
const savepath = index === -1 ? this.createTask.taskData.savepath : this.formData.tasklist[index].savepath;
|
||
if (!savepath || savepath === "") {
|
||
this.getSavepathDetail(0); // 加载根目录
|
||
} else {
|
||
this.getSavepathDetail(savepath);
|
||
}
|
||
},
|
||
getShareDetail(retryCount = 0, maxRetries = 1) {
|
||
// 切换或重试前清理残留错误提示,避免覆盖新资源展示
|
||
this.fileSelect.error = undefined;
|
||
this.modalLoading = true;
|
||
|
||
// 检查index是否有效,如果无效则使用默认值
|
||
let regexConfig = {};
|
||
if (this.fileSelect.index === -1) {
|
||
// 创建任务模态框的情况
|
||
const task = this.createTask.taskData;
|
||
regexConfig = {
|
||
pattern: task.pattern,
|
||
replace: task.replace,
|
||
taskname: task.taskname,
|
||
filterwords: task.filterwords,
|
||
magic_regex: this.formData.magic_regex,
|
||
use_sequence_naming: task.use_sequence_naming,
|
||
sequence_naming: task.sequence_naming,
|
||
use_episode_naming: task.use_episode_naming,
|
||
episode_naming: task.episode_naming,
|
||
episode_patterns: this.formData.episode_patterns
|
||
};
|
||
} else if (this.fileSelect.index !== null && this.fileSelect.index >= 0 && this.formData.tasklist[this.fileSelect.index]) {
|
||
const task = this.formData.tasklist[this.fileSelect.index];
|
||
regexConfig = {
|
||
pattern: task.pattern,
|
||
replace: task.replace,
|
||
taskname: task.taskname,
|
||
filterwords: task.filterwords,
|
||
magic_regex: this.formData.magic_regex,
|
||
use_sequence_naming: task.use_sequence_naming,
|
||
sequence_naming: task.sequence_naming,
|
||
use_episode_naming: task.use_episode_naming,
|
||
episode_naming: task.episode_naming,
|
||
episode_patterns: this.formData.episode_patterns
|
||
};
|
||
} else {
|
||
// 使用默认配置
|
||
regexConfig = {
|
||
pattern: '',
|
||
replace: '',
|
||
taskname: '',
|
||
filterwords: '',
|
||
magic_regex: this.formData.magic_regex,
|
||
use_sequence_naming: false,
|
||
sequence_naming: '',
|
||
use_episode_naming: false,
|
||
episode_naming: '',
|
||
episode_patterns: this.formData.episode_patterns
|
||
};
|
||
}
|
||
|
||
axios.post('/get_share_detail', {
|
||
shareurl: this.fileSelect.shareurl,
|
||
stoken: this.fileSelect.stoken,
|
||
regex: regexConfig
|
||
}).then(response => {
|
||
if (response.data.success) {
|
||
this.fileSelect.fileList = response.data.data.list;
|
||
// 应用默认排序,但不改变当前的排序顺序
|
||
this.manualSortFileList(this.fileSelect.sortBy, this.fileSelect.sortOrder);
|
||
this.fileSelect.paths = response.data.data.paths;
|
||
this.fileSelect.stoken = response.data.data.stoken;
|
||
|
||
// 检查文件列表是否为空,仅在预览界面显示错误,不影响任务本身
|
||
if (this.fileSelect.selectShare && (!this.fileSelect.fileList || this.fileSelect.fileList.length === 0)) {
|
||
this.fileSelect.error = "该分享已被删除,无法访问";
|
||
// 移除下面这段代码,避免在预览时直接设置任务的shareurl_ban
|
||
// if (this.fileSelect.index !== null && this.formData.tasklist[this.fileSelect.index]) {
|
||
// this.$set(this.formData.tasklist[this.fileSelect.index], "shareurl_ban", "该分享已被删除,无法访问");
|
||
// }
|
||
}
|
||
|
||
// 命名预览模式下,在数据加载完成后检查滚动条状态
|
||
if (this.fileSelect.previewRegex) {
|
||
this.$nextTick(() => {
|
||
this.checkPreviewScrollbar();
|
||
});
|
||
}
|
||
// 成功加载后关闭加载状态
|
||
this.modalLoading = false;
|
||
} else {
|
||
// 非法token等可恢复错误:执行一次无感自动重试
|
||
const rawError = (response && response.data && response.data.data && response.data.data.error) ? response.data.data.error : '';
|
||
const errorText = typeof rawError === 'string' ? rawError : String(rawError || '');
|
||
const isIllegalToken = errorText.includes('非法token') || errorText.includes('Bad Parameter');
|
||
const isRequestError = errorText.toLowerCase().includes('request error') ||
|
||
errorText.toLowerCase().includes('inner error') ||
|
||
errorText.toLowerCase().includes('网络错误') ||
|
||
errorText.toLowerCase().includes('服务端错误') ||
|
||
errorText.toLowerCase().includes('临时错误');
|
||
if (isIllegalToken || isRequestError) {
|
||
if (retryCount < maxRetries) {
|
||
console.log(`分享详情获取失败(${isIllegalToken ? '非法token' : 'request error'}),进行第 ${retryCount + 1} 次重试...`);
|
||
setTimeout(() => {
|
||
const fileSelectModal = document.getElementById('fileSelectModal');
|
||
if (!(fileSelectModal && fileSelectModal.classList.contains('show'))) {
|
||
this.modalLoading = false;
|
||
return;
|
||
}
|
||
// 清空stoken以强制刷新
|
||
this.fileSelect.stoken = "";
|
||
this.getShareDetail(retryCount + 1, maxRetries);
|
||
}, 600);
|
||
return; // 等待重试结果,不立刻结束loading
|
||
}
|
||
// 重试已用尽,给出统一提示
|
||
this.fileSelect.error = "获取文件夹列表失败,请关闭窗口再试一次";
|
||
this.modalLoading = false;
|
||
} else {
|
||
// 使用格式化函数处理不可恢复错误
|
||
this.fileSelect.error = this.formatShareUrlBanMessage(rawError);
|
||
this.modalLoading = false;
|
||
}
|
||
}
|
||
}).catch(error => {
|
||
// 增强版无感重试:清空 stoken 强制刷新令牌,并仅在模态框仍打开时重试
|
||
if (retryCount < maxRetries) {
|
||
console.log(`获取文件夹列表失败,正在进行第 ${retryCount + 1} 次重试...`);
|
||
setTimeout(() => {
|
||
const fileSelectModal = document.getElementById('fileSelectModal');
|
||
if (!(fileSelectModal && fileSelectModal.classList.contains('show'))) {
|
||
this.modalLoading = false;
|
||
return;
|
||
}
|
||
// 清空stoken,促使后端重新获取有效的stoken
|
||
this.fileSelect.stoken = "";
|
||
this.getShareDetail(retryCount + 1, maxRetries);
|
||
}, 600);
|
||
} else {
|
||
this.fileSelect.error = "获取文件夹列表失败,请关闭窗口再试一次";
|
||
this.modalLoading = false;
|
||
}
|
||
});
|
||
},
|
||
showShareSelect(index, shareurl = null) {
|
||
this.fileSelect.selectShare = true;
|
||
this.fileSelect.fileList = [];
|
||
this.fileSelect.paths = [];
|
||
this.fileSelect.error = undefined;
|
||
// 根据模式设置不同的默认排序
|
||
if (this.fileSelect.previewRegex) {
|
||
// 命名预览模式下使用重命名列倒序排序
|
||
this.fileSelect.sortBy = "file_name_re";
|
||
this.fileSelect.sortOrder = "desc";
|
||
} else if (this.fileSelect.selectDir) {
|
||
// 选择需转存的文件夹模态框:默认修改时间倒序
|
||
this.fileSelect.sortBy = "updated_at";
|
||
this.fileSelect.sortOrder = "desc";
|
||
} else {
|
||
// 选择起始文件模态框:使用文件名倒序排序(使用全局文件排序函数)
|
||
this.fileSelect.sortBy = "file_name";
|
||
this.fileSelect.sortOrder = "desc";
|
||
}
|
||
// 处理创建任务模态框的情况(index为-1)
|
||
const currentShareurl = index === -1 ? this.createTask.taskData.shareurl : this.formData.tasklist[index].shareurl;
|
||
if (this.getShareurl(this.fileSelect.shareurl) != this.getShareurl(currentShareurl)) {
|
||
this.fileSelect.stoken = "";
|
||
}
|
||
this.fileSelect.shareurl = shareurl || currentShareurl;
|
||
this.fileSelect.index = index;
|
||
|
||
// 检查当前分享链接是否在搜索结果中,如果在则设置正确的索引
|
||
const finalShareurl = shareurl || currentShareurl;
|
||
if (this.smart_param.taskSuggestions.data && this.smart_param.taskSuggestions.data.length > 0) {
|
||
const currentResourceId = this.getResourceIdFromUrl(finalShareurl);
|
||
let resourceIndex = -1;
|
||
|
||
if (currentResourceId) {
|
||
resourceIndex = this.smart_param.taskSuggestions.data.findIndex(item => {
|
||
const itemResourceId = this.getResourceIdFromUrl(item.shareurl);
|
||
return itemResourceId && itemResourceId === currentResourceId;
|
||
});
|
||
}
|
||
|
||
this.smart_param.currentResourceIndex = resourceIndex;
|
||
} else {
|
||
// 如果没有搜索结果,重置currentResourceIndex
|
||
this.smart_param.currentResourceIndex = -1;
|
||
}
|
||
|
||
// 根据不同条件设置模态框类型
|
||
if (this.fileSelect.previewRegex) {
|
||
document.getElementById('fileSelectModal').setAttribute('data-modal-type', 'preview');
|
||
} else if (this.fileSelect.selectDir) {
|
||
document.getElementById('fileSelectModal').setAttribute('data-modal-type', 'source');
|
||
} else {
|
||
document.getElementById('fileSelectModal').setAttribute('data-modal-type', 'start-file');
|
||
}
|
||
|
||
// 检查是否从创建任务模态框中打开,如果是则设置更高的z-index
|
||
const createTaskModal = document.getElementById('createTaskModal');
|
||
const fileSelectModal = document.getElementById('fileSelectModal');
|
||
if (createTaskModal && createTaskModal.classList.contains('show')) {
|
||
fileSelectModal.style.zIndex = '1060';
|
||
// 查找并设置backdrop的z-index
|
||
setTimeout(() => {
|
||
const backdrop = document.querySelector('.modal-backdrop:last-child');
|
||
if (backdrop) {
|
||
backdrop.style.zIndex = '1055';
|
||
}
|
||
}, 50);
|
||
} else {
|
||
// 重置为默认值
|
||
fileSelectModal.style.zIndex = '';
|
||
}
|
||
|
||
$('#fileSelectModal').modal('toggle');
|
||
// 调用getShareDetail时不传递任何参数,使用默认的重试机制
|
||
this.getShareDetail(0, 1);
|
||
|
||
// 命名预览模式下,确保在模态框显示后检查滚动条状态
|
||
if (this.fileSelect.previewRegex) {
|
||
this.$nextTick(() => {
|
||
this.checkPreviewScrollbar();
|
||
});
|
||
}
|
||
},
|
||
navigateTo(fid, name) {
|
||
// 新增:命名预览模式下(文件整理页面),切换目录时刷新预览
|
||
if (this.fileSelect.previewRegex && this.fileSelect.index === -1) {
|
||
// 更新fileManager当前目录
|
||
this.fileManager.currentFolder = fid;
|
||
// 更新面包屑路径
|
||
if (fid == "0") {
|
||
this.fileSelect.paths = [];
|
||
} else {
|
||
const index = this.fileSelect.paths.findIndex(item => item.fid === fid);
|
||
if (index !== -1) {
|
||
this.fileSelect.paths = this.fileSelect.paths.slice(0, index + 1);
|
||
} else {
|
||
this.fileSelect.paths.push({ fid: fid, name: name });
|
||
}
|
||
}
|
||
// 直接刷新命名预览(不关闭模态框)
|
||
this.showFileManagerNamingPreview(fid);
|
||
return;
|
||
}
|
||
path = { fid: fid, name: name }
|
||
if (this.fileSelect.selectShare) {
|
||
this.fileSelect.shareurl = this.getShareurl(this.fileSelect.shareurl, path);
|
||
// 使用重试机制调用getShareDetail
|
||
this.getShareDetail(0, 1);
|
||
} else {
|
||
if (fid == "0") {
|
||
this.fileSelect.paths = []
|
||
} else {
|
||
index = this.fileSelect.paths.findIndex(item => item.fid === fid);
|
||
if (index !== -1) {
|
||
this.fileSelect.paths = this.fileSelect.paths.slice(0, index + 1)
|
||
} else {
|
||
this.fileSelect.paths.push({ fid: fid, name: name })
|
||
}
|
||
}
|
||
// 使用重试机制调用getSavepathDetail
|
||
this.getSavepathDetail(fid, 0, 1);
|
||
}
|
||
},
|
||
selectCurrentFolder(addTaskname = false) {
|
||
if (this.fileSelect.moveMode) {
|
||
// 移动文件模式
|
||
this.moveFilesToCurrentFolder();
|
||
} else if (this.fileSelect.selectShare) {
|
||
// 检查index是否有效
|
||
if (this.fileSelect.index === -1) {
|
||
// 创建任务模态框的情况
|
||
this.createTask.taskData.shareurl_ban = undefined;
|
||
// 如果是分享文件夹且文件列表为空,则设置shareurl_ban
|
||
if (!this.fileSelect.fileList || this.fileSelect.fileList.length === 0) {
|
||
this.$set(this.createTask.taskData, "shareurl_ban", "该分享已被删除,无法访问");
|
||
}
|
||
// 只有在用户点击"转存当前文件夹"时才更新分享链接
|
||
// 使用用户最终访问的分享链接地址(包含用户导航后的路径)
|
||
this.createTask.taskData.shareurl = this.fileSelect.shareurl;
|
||
} else if (this.fileSelect.index !== null && this.fileSelect.index >= 0 && this.formData.tasklist[this.fileSelect.index]) {
|
||
this.formData.tasklist[this.fileSelect.index].shareurl_ban = undefined;
|
||
// 如果是分享文件夹且文件列表为空,则设置shareurl_ban
|
||
if (!this.fileSelect.fileList || this.fileSelect.fileList.length === 0) {
|
||
this.$set(this.formData.tasklist[this.fileSelect.index], "shareurl_ban", "该分享已被删除,无法访问");
|
||
}
|
||
// 只有在用户点击"转存当前文件夹"时才更新分享链接
|
||
// 使用用户最终访问的分享链接地址(包含用户导航后的路径)
|
||
this.formData.tasklist[this.fileSelect.index].shareurl = this.fileSelect.shareurl;
|
||
}
|
||
} else {
|
||
// 检查index是否有效
|
||
if (this.fileSelect.index === -1) {
|
||
// 创建任务模态框的情况
|
||
this.createTask.taskData.savepath = this.fileSelect.paths.map(item => item.name).join("/");
|
||
if (addTaskname) {
|
||
// 使用自定义文件夹路径
|
||
const customFolderPath = this.generateCustomFolderPath(this.createTask.taskData);
|
||
this.createTask.taskData.savepath += "/" + customFolderPath;
|
||
}
|
||
} else if (this.fileSelect.index !== null && this.fileSelect.index >= 0 && this.formData.tasklist[this.fileSelect.index]) {
|
||
// 去掉前导斜杠,避免双斜杠问题
|
||
this.formData.tasklist[this.fileSelect.index].savepath = this.fileSelect.paths.map(item => item.name).join("/");
|
||
if (addTaskname) {
|
||
this.formData.tasklist[this.fileSelect.index].savepath += "/" + this.formData.tasklist[this.fileSelect.index].taskname
|
||
}
|
||
}
|
||
}
|
||
if (!this.fileSelect.moveMode) {
|
||
$('#fileSelectModal').modal('hide')
|
||
// 用户已确认选择,自动关闭搜索结果下拉菜单
|
||
this.smart_param.showSuggestions = false;
|
||
}
|
||
},
|
||
// 移动文件到当前文件夹
|
||
moveFilesToCurrentFolder() {
|
||
if (!this.fileSelect.moveFileIds || this.fileSelect.moveFileIds.length === 0) {
|
||
alert('没有选择要移动的文件');
|
||
return;
|
||
}
|
||
|
||
// 获取目标文件夹ID
|
||
const targetFolderId = this.fileSelect.paths.length > 0
|
||
? this.fileSelect.paths[this.fileSelect.paths.length - 1].fid
|
||
: "0";
|
||
|
||
// 记录源目录ID(文件原本所在的目录)
|
||
const sourceFolderId = this.fileManager.currentFolder || "0";
|
||
|
||
const fileCount = this.fileSelect.moveFileIds.length;
|
||
const confirmMessage = fileCount === 1
|
||
? '确定要移动此文件吗?'
|
||
: `确定要移动选中的 ${fileCount} 个文件吗?`;
|
||
|
||
if (!confirm(confirmMessage)) {
|
||
return;
|
||
}
|
||
|
||
// 调用移动文件API
|
||
axios.post('/move_file', {
|
||
file_ids: this.fileSelect.moveFileIds,
|
||
target_folder_id: targetFolderId,
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
})
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.showToast(`成功移动 ${response.data.moved_count} 个文件`);
|
||
|
||
// 关闭模态框
|
||
$('#fileSelectModal').modal('hide');
|
||
|
||
// 重置移动模式相关参数
|
||
this.fileSelect.moveMode = false;
|
||
this.fileSelect.moveFileIds = [];
|
||
this.fileSelect.index = null; // 重置index避免后续访问undefined
|
||
|
||
// 清空选中的文件
|
||
this.fileManager.selectedFiles = [];
|
||
|
||
// 延迟刷新以确保后端处理完成
|
||
setTimeout(() => {
|
||
// 刷新当前页面显示(源目录)
|
||
this.refreshCurrentFolderCache();
|
||
|
||
// 如果目标目录不同于源目录,也刷新目标目录的缓存
|
||
if (targetFolderId !== sourceFolderId) {
|
||
this.refreshFolderCache(targetFolderId); // 刷新目标目录缓存
|
||
}
|
||
|
||
// 刷新源目录的上级目录(更新源文件夹的项目数量)
|
||
const sourceParentFolderId = this.getParentFolderId(sourceFolderId);
|
||
if (sourceParentFolderId !== null) {
|
||
this.refreshFolderCache(sourceParentFolderId);
|
||
}
|
||
|
||
// 刷新目标目录的上级目录(更新目标文件夹的项目数量)
|
||
if (targetFolderId !== sourceFolderId) {
|
||
const targetParentFolderId = this.getParentFolderId(targetFolderId);
|
||
if (targetParentFolderId !== null && targetParentFolderId !== sourceParentFolderId) {
|
||
this.refreshFolderCache(targetParentFolderId);
|
||
}
|
||
}
|
||
}, 500); // 延迟500ms确保后端处理完成
|
||
} else {
|
||
const errorMessage = response.data.message || '移动失败';
|
||
// 对于"不能移动至相同目录"错误使用Toast通知
|
||
if (errorMessage.includes('不能移动至相同目录')) {
|
||
this.showToast('不能移动至相同目录');
|
||
} else {
|
||
this.showToast(errorMessage);
|
||
}
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('移动文件失败:', error);
|
||
this.showToast('移动文件失败');
|
||
});
|
||
},
|
||
selectStartFid(fid) {
|
||
// 检查index是否有效
|
||
if (this.fileSelect.index === -1) {
|
||
// 创建任务模态框的情况
|
||
Vue.set(this.createTask.taskData, 'startfid', fid);
|
||
} else if (this.fileSelect.index !== null && this.fileSelect.index >= 0 && this.formData.tasklist[this.fileSelect.index]) {
|
||
Vue.set(this.formData.tasklist[this.fileSelect.index], 'startfid', fid);
|
||
}
|
||
$('#fileSelectModal').modal('hide')
|
||
},
|
||
getShareurl(shareurl, path = {}) {
|
||
if (path == {} || path.fid == 0) {
|
||
shareurl = shareurl.match(`.*s/[a-z0-9]+`)[0]
|
||
} else if (shareurl.includes(path.fid)) {
|
||
shareurl = shareurl.match(`.*/${path.fid}[^\/]*`)[0]
|
||
} else if (shareurl.includes('#/list/share')) {
|
||
shareurl = `${shareurl}/${path.fid}-${path.name}`
|
||
} else {
|
||
shareurl = `${shareurl}#/list/share/${path.fid}-${path.name}`
|
||
}
|
||
return shareurl;
|
||
},
|
||
detectNamingMode(task) {
|
||
// 检测是否为顺序命名模式或剧集命名模式
|
||
let isSequenceNaming = false;
|
||
let isEpisodeNaming = false;
|
||
const currentValue = task.pattern;
|
||
|
||
if (currentValue !== undefined) {
|
||
isSequenceNaming = currentValue.includes('{}');
|
||
isEpisodeNaming = !isSequenceNaming && currentValue.includes('[]');
|
||
}
|
||
|
||
// 只要不是正则命名,强制清空 replace
|
||
if (isSequenceNaming || isEpisodeNaming) {
|
||
task.replace = '';
|
||
}
|
||
|
||
if (isSequenceNaming) {
|
||
// 切换到顺序命名模式
|
||
if (!task.use_sequence_naming) {
|
||
task._pattern_backup = task.pattern;
|
||
task._replace_backup = task.replace;
|
||
task.use_sequence_naming = true;
|
||
task.use_episode_naming = false;
|
||
}
|
||
task.sequence_naming = task.pattern;
|
||
} else if (isEpisodeNaming) {
|
||
// 切换到剧集命名模式
|
||
if (!task.use_episode_naming) {
|
||
task._pattern_backup = task.pattern;
|
||
task._replace_backup = task.replace;
|
||
task.use_episode_naming = true;
|
||
task.use_sequence_naming = false;
|
||
}
|
||
task.episode_naming = task.pattern;
|
||
} else {
|
||
// 切换回正则命名模式
|
||
if (task.use_sequence_naming || task.use_episode_naming) {
|
||
task.use_sequence_naming = false;
|
||
task.use_episode_naming = false;
|
||
task.sequence_naming = null;
|
||
task.episode_naming = null;
|
||
}
|
||
}
|
||
|
||
// 保存当前值,用于下次比较
|
||
task._lastPatternValue = currentValue;
|
||
|
||
// 强制Vue更新视图
|
||
this.$forceUpdate();
|
||
},
|
||
// 增加剧集识别模式
|
||
addEpisodePattern() {
|
||
// 此方法保留但不再使用
|
||
},
|
||
|
||
// 移除剧集识别模式
|
||
removeEpisodePattern(index) {
|
||
// 此方法保留但不再使用
|
||
},
|
||
loadHistoryRecords() {
|
||
// 构建请求参数
|
||
const params = {
|
||
sort_by: this.historyParams.sortBy || "transfer_time",
|
||
order: this.historyParams.order || "desc",
|
||
page_size: parseInt(this.historyParams.page_size || 15),
|
||
page: this.historyParams.page || 1
|
||
};
|
||
|
||
// 添加筛选条件
|
||
if (this.historyTaskSelected) {
|
||
params.task_name = this.historyTaskSelected;
|
||
}
|
||
|
||
if (this.historyNameFilter) {
|
||
params.keyword = this.historyNameFilter;
|
||
}
|
||
|
||
// 追加状态筛选
|
||
if (!this.prepareStatusFilterParams(params, true)) {
|
||
return;
|
||
}
|
||
|
||
// 判断筛选条件是否变化,只有变化时才重置页码
|
||
const isFilterChanged =
|
||
(this._lastTaskFilter !== this.historyTaskSelected) ||
|
||
(this._lastNameFilter !== this.historyNameFilter) ||
|
||
(this._lastStatusFilter !== this.historyStatusFilter);
|
||
|
||
if (isFilterChanged) {
|
||
// 筛选条件变化时重置为第一页
|
||
params.page = 1;
|
||
this.gotoPage = 1;
|
||
}
|
||
|
||
// 记录当前筛选条件
|
||
this._lastTaskFilter = this.historyTaskSelected;
|
||
this._lastNameFilter = this.historyNameFilter;
|
||
this._lastStatusFilter = this.historyStatusFilter;
|
||
|
||
// 更新当前参数
|
||
this.historyParams = {
|
||
sortBy: params.sort_by,
|
||
order: params.order,
|
||
page_size: params.page_size,
|
||
page: params.page
|
||
};
|
||
|
||
axios.get('/history_records', {
|
||
params: params
|
||
}).then(response => {
|
||
if (response.data.success) {
|
||
this.history = response.data.data;
|
||
|
||
// 确保页码信息正确反映在UI上
|
||
if (this.history.pagination) {
|
||
this.totalPages = parseInt(this.history.pagination.total_pages);
|
||
}
|
||
} else {
|
||
console.error('Error:', response.data.message);
|
||
}
|
||
this.history.hasLoaded = true;
|
||
}).catch(error => {
|
||
console.error('Error loading history records:', error);
|
||
this.history.hasLoaded = true;
|
||
});
|
||
},
|
||
prepareStatusFilterParams(params, handleEmptyResult = false) {
|
||
if (!this.historyStatusFilter) {
|
||
return true;
|
||
}
|
||
const taskNames = this.getStatusFilteredTaskNames(this.historyStatusFilter);
|
||
if (!taskNames || taskNames.length === 0) {
|
||
if (handleEmptyResult) {
|
||
this.handleEmptyHistoryResult(params.page_size);
|
||
}
|
||
return false;
|
||
}
|
||
params.task_names = JSON.stringify(taskNames);
|
||
return true;
|
||
},
|
||
getStatusFilteredTaskNames(filterValue) {
|
||
if (!filterValue) {
|
||
return [];
|
||
}
|
||
if (filterValue === 'ended_task') {
|
||
const allTaskNames = (this.allTaskNames && this.allTaskNames.length > 0)
|
||
? this.allTaskNames
|
||
: this.extractTaskNamesFromHistory();
|
||
if (!this.allTaskNames || this.allTaskNames.length === 0) {
|
||
this.loadAllTaskNames();
|
||
}
|
||
const currentNames = new Set();
|
||
(this.formData.tasklist || []).forEach(task => {
|
||
if (task && task.taskname) {
|
||
currentNames.add(task.taskname);
|
||
}
|
||
});
|
||
return allTaskNames.filter(name => name && !currentNames.has(name));
|
||
}
|
||
const matchedNames = new Set();
|
||
(this.formData.tasklist || []).forEach(task => {
|
||
if (!task || !task.taskname) {
|
||
return;
|
||
}
|
||
if (this.filterTaskByStatus(task, filterValue)) {
|
||
matchedNames.add(task.taskname);
|
||
}
|
||
});
|
||
return Array.from(matchedNames);
|
||
},
|
||
extractTaskNamesFromHistory() {
|
||
const names = new Set();
|
||
(this.history.records || []).forEach(record => {
|
||
if (record && record.task_name) {
|
||
names.add(record.task_name);
|
||
}
|
||
});
|
||
return Array.from(names);
|
||
},
|
||
handleEmptyHistoryResult(pageSize) {
|
||
const resolvedPageSize = pageSize || parseInt(this.historyParams.page_size || 15);
|
||
this.history.records = [];
|
||
this.history.pagination = {
|
||
total_records: 0,
|
||
total_pages: 1,
|
||
current_page: 1,
|
||
page_size: resolvedPageSize
|
||
};
|
||
this.history.hasLoaded = true;
|
||
this.totalPages = 1;
|
||
this.historyParams.page = 1;
|
||
this.gotoPage = 1;
|
||
this.selectedRecords = [];
|
||
},
|
||
|
||
sortHistory(field) {
|
||
// 更新排序参数
|
||
if (this.historyParams.sortBy === field) {
|
||
this.historyParams.order = this.historyParams.order === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
this.historyParams.sortBy = field;
|
||
// 保持默认排序顺序为'desc',与后端一致
|
||
this.historyParams.order = 'desc';
|
||
}
|
||
|
||
// 构建请求参数
|
||
const params = {
|
||
sort_by: this.historyParams.sortBy,
|
||
order: this.historyParams.order,
|
||
page_size: parseInt(this.historyParams.page_size || 15),
|
||
page: this.historyParams.page || 1
|
||
};
|
||
|
||
// 添加筛选条件
|
||
if (this.historyTaskSelected) {
|
||
params.task_name = this.historyTaskSelected;
|
||
}
|
||
|
||
if (this.historyNameFilter) {
|
||
params.keyword = this.historyNameFilter;
|
||
}
|
||
|
||
if (!this.prepareStatusFilterParams(params, true)) {
|
||
return;
|
||
}
|
||
|
||
// 重新加载数据,使用当前页和当前设置的排序方式
|
||
axios.get('/history_records', { params })
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.history = response.data.data;
|
||
// 确保总页数更新
|
||
if (this.history.pagination) {
|
||
this.totalPages = parseInt(this.history.pagination.total_pages);
|
||
}
|
||
} else {
|
||
console.error('排序失败:', response.data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('排序请求错误:', error);
|
||
});
|
||
},
|
||
|
||
changePage(page) {
|
||
if (page < 1) page = 1;
|
||
if (page > this.totalPages) page = this.totalPages;
|
||
|
||
// 更新当前页码
|
||
this.historyParams.page = page;
|
||
this.gotoPage = page;
|
||
|
||
// 直接请求新页的数据
|
||
const params = {
|
||
sort_by: this.historyParams.sortBy,
|
||
order: this.historyParams.order,
|
||
page_size: parseInt(this.historyParams.page_size),
|
||
page: page
|
||
};
|
||
|
||
if (this.historyTaskSelected) {
|
||
params.task_name = this.historyTaskSelected;
|
||
}
|
||
|
||
if (this.historyNameFilter) {
|
||
params.keyword = this.historyNameFilter;
|
||
}
|
||
|
||
if (!this.prepareStatusFilterParams(params, true)) {
|
||
return;
|
||
}
|
||
|
||
axios.get('/history_records', { params })
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.history = response.data.data;
|
||
|
||
// 确保页码信息正确反映在UI上
|
||
if (this.history.pagination) {
|
||
this.totalPages = parseInt(this.history.pagination.total_pages);
|
||
}
|
||
} else {
|
||
console.error('Error:', response.data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error changing page:', error);
|
||
});
|
||
},
|
||
|
||
changePageSize() {
|
||
// 重置为第一页
|
||
this.historyParams.page = 1;
|
||
this.gotoPage = 1;
|
||
|
||
// 构建参数
|
||
const params = {
|
||
sort_by: this.historyParams.sortBy,
|
||
order: this.historyParams.order,
|
||
page_size: parseInt(this.historyParams.page_size),
|
||
page: 1
|
||
};
|
||
|
||
if (this.historyTaskSelected) {
|
||
params.task_name = this.historyTaskSelected;
|
||
}
|
||
|
||
if (this.historyNameFilter) {
|
||
params.keyword = this.historyNameFilter;
|
||
}
|
||
|
||
if (!this.prepareStatusFilterParams(params, true)) {
|
||
return;
|
||
}
|
||
|
||
// 重新加载数据
|
||
axios.get('/history_records', { params })
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.history = response.data.data;
|
||
|
||
// 确保页码信息正确反映在UI上
|
||
if (this.history.pagination) {
|
||
this.totalPages = parseInt(this.history.pagination.total_pages);
|
||
}
|
||
} else {
|
||
console.error('Error:', response.data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error changing page size:', error);
|
||
});
|
||
},
|
||
|
||
changePageSizeTo(size) {
|
||
this.historyParams.page_size = size === 'all' ? 99999 : size;
|
||
// 保存用户设置的每页记录数到本地存储
|
||
localStorage.setItem('quarkAutoSave_pageSize', size.toString());
|
||
this.changePageSize();
|
||
},
|
||
|
||
getVisiblePageNumbers() {
|
||
const total = parseInt(this.totalPages) || 1;
|
||
const current = parseInt(this.historyParams.page) || 1;
|
||
// 根据屏幕宽度动态调整显示的页码数:移动端显示较少页码,桌面端显示较多页码
|
||
const isMobile = window.innerWidth <= 768;
|
||
const delta = isMobile ? 1 : 2; // 移动端左右各显示1个页码,桌面端左右各显示2个页码
|
||
|
||
// 处理特殊情况
|
||
if (total <= 1) return [];
|
||
|
||
let range = [];
|
||
|
||
// 移动端简化逻辑:只显示当前页附近的少数页码
|
||
if (isMobile) {
|
||
// 移动端:最多显示4个中间页码按钮(不包括第1页和最后1页),总共5个页码
|
||
if (current <= 4) {
|
||
// 当前页在前面时,显示 1, 2, 3, 4
|
||
for (let i = 2; i <= Math.min(4, total - 1); i++) {
|
||
range.push(i);
|
||
}
|
||
} else if (current >= total - 3) {
|
||
// 当前页在后面时,显示倒数几页
|
||
for (let i = Math.max(2, total - 3); i <= total - 1; i++) {
|
||
range.push(i);
|
||
}
|
||
} else {
|
||
// 当前页在中间时,显示当前页前后各1页,总共3个中间页码
|
||
range.push(current - 1);
|
||
range.push(current);
|
||
range.push(current + 1);
|
||
}
|
||
} else {
|
||
// 桌面端保持原有逻辑
|
||
let rangeStart = Math.max(2, current - delta);
|
||
let rangeEnd = Math.min(total - 1, current + delta);
|
||
|
||
// 调整范围,确保显示足够的页码
|
||
if (rangeEnd - rangeStart < delta * 2) {
|
||
if (current - rangeStart < delta) {
|
||
// 当前页靠近开始,扩展结束范围
|
||
rangeEnd = Math.min(total - 1, rangeStart + delta * 2);
|
||
} else {
|
||
// 当前页靠近结束,扩展开始范围
|
||
rangeStart = Math.max(2, rangeEnd - delta * 2);
|
||
}
|
||
}
|
||
|
||
// 生成页码数组
|
||
for (let i = rangeStart; i <= rangeEnd; i++) {
|
||
range.push(i);
|
||
}
|
||
}
|
||
|
||
return range;
|
||
},
|
||
isTextTruncated(text, recordIndex, field) {
|
||
if (!this.filteredHistoryRecords[recordIndex]) return false;
|
||
|
||
// 确保文本是字符串类型
|
||
const str = String(text || '');
|
||
|
||
// 先检查文本是否有足够长度,太短的文本肯定不需要展开
|
||
if (str.length <= 20) return false;
|
||
|
||
// 从记录的_isOverflowing属性中获取溢出状态
|
||
const record = this.filteredHistoryRecords[recordIndex];
|
||
|
||
// 如果已经展开了,那么我们认为它可能是需要截断的
|
||
if (record._expandedFields && record._expandedFields.includes(field)) {
|
||
return true;
|
||
}
|
||
|
||
return record._isOverflowing && record._isOverflowing[field];
|
||
},
|
||
toggleExpand(index, field, event) {
|
||
// 阻止事件冒泡,避免触发行选择
|
||
if (event) {
|
||
event.stopPropagation();
|
||
}
|
||
|
||
// 获取当前记录
|
||
const record = this.filteredHistoryRecords[index];
|
||
// 初始化_expandedFields属性(如果不存在)
|
||
if (!record._expandedFields) {
|
||
this.$set(record, '_expandedFields', []);
|
||
}
|
||
|
||
// 检查字段是否已经展开
|
||
const fieldIndex = record._expandedFields.indexOf(field);
|
||
const isExpanding = fieldIndex === -1;
|
||
|
||
// 所有可能需要展开的字段
|
||
const allFields = ['task_name', 'original_name', 'renamed_to'];
|
||
|
||
if (isExpanding) {
|
||
// 如果是展开操作,展开该行所有超长字段
|
||
allFields.forEach(fieldName => {
|
||
// 只有超长的字段才需要展开
|
||
if (this.isTextTruncated(record[fieldName], index, fieldName) &&
|
||
!record._expandedFields.includes(fieldName)) {
|
||
record._expandedFields.push(fieldName);
|
||
}
|
||
});
|
||
} else {
|
||
// 如果是收起操作,收起该行所有字段
|
||
record._expandedFields = [];
|
||
}
|
||
|
||
// 在下一个DOM更新周期中强制重新检测溢出状态
|
||
this.$nextTick(() => {
|
||
// 确保_isOverflowing属性存在
|
||
if (!record._isOverflowing) {
|
||
this.$set(record, '_isOverflowing', {});
|
||
}
|
||
|
||
// 在收起状态下,通过设置为true确保展开按钮依然可见
|
||
allFields.forEach(fieldName => {
|
||
if (!isExpanding) {
|
||
this.$set(record._isOverflowing, fieldName, true);
|
||
}
|
||
});
|
||
});
|
||
},
|
||
loadAllTaskNames() {
|
||
// 向后端请求获取所有可用的任务名称
|
||
axios.get('/history_records', {
|
||
params: {
|
||
get_all_task_names: true,
|
||
page_size: 1, // 只需要任务名称,不需要大量记录
|
||
page: 1
|
||
}
|
||
}).then(response => {
|
||
if (response.data.success && response.data.data.all_task_names) {
|
||
this.allTaskNames = this.sortTaskNamesByPinyin(response.data.data.all_task_names);
|
||
} else {
|
||
// 如果API失败,回退到从当前页记录中提取任务名称
|
||
if (this.history.records && this.history.records.length > 0) {
|
||
const taskNames = new Set();
|
||
this.history.records.forEach(record => {
|
||
if (record.task_name) {
|
||
taskNames.add(record.task_name);
|
||
}
|
||
});
|
||
this.allTaskNames = this.sortTaskNamesByPinyin([...taskNames]);
|
||
}
|
||
}
|
||
}).catch(error => {
|
||
// 如果API请求出错,回退到从当前页记录中提取任务名称
|
||
if (this.history.records && this.history.records.length > 0) {
|
||
const taskNames = new Set();
|
||
this.history.records.forEach(record => {
|
||
if (record.task_name) {
|
||
taskNames.add(record.task_name);
|
||
}
|
||
});
|
||
this.allTaskNames = this.sortTaskNamesByPinyin([...taskNames]);
|
||
}
|
||
});
|
||
},
|
||
async loadTaskLatestInfo() {
|
||
// 获取所有任务的最新转存信息(包括日期和文件)
|
||
try {
|
||
const response = await axios.get('/task_latest_info');
|
||
if (response.data.success) {
|
||
this.taskLatestRecords = response.data.data.latest_records;
|
||
this.taskLatestFiles = response.data.data.latest_files;
|
||
|
||
// 构建进度映射,确保基于日期的集数统计能正常工作
|
||
this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(this.taskLatestFiles || {});
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
|
||
// 同时加载 episodes 数据,确保基于日期的集数统计能正常工作
|
||
try {
|
||
await this.loadCalendarEpisodesLocal();
|
||
} catch (e) {
|
||
console.warn('加载 episodes 数据失败:', e);
|
||
}
|
||
} else {
|
||
console.error('获取任务最新信息失败:', response.data.message);
|
||
}
|
||
} catch (error) {
|
||
console.error('获取任务最新信息失败:', error);
|
||
}
|
||
},
|
||
// 加载任务列表的元数据信息(用于热更新海报和元数据)
|
||
async loadTasklistMetadata() {
|
||
try {
|
||
// 加载任务信息,获取元数据和海报信息
|
||
const tasksResponse = await axios.get('/api/calendar/tasks');
|
||
if (tasksResponse.data.success) {
|
||
// 更新任务映射,确保海报视图能正确显示元数据,使用Vue.set确保响应式更新
|
||
this.calendar.tasks = tasksResponse.data.data.tasks || [];
|
||
this.$delete(this.calendar, 'taskMapByName');
|
||
this.$set(this.calendar, 'taskMapByName', {});
|
||
this.calendar.tasks.forEach(task => {
|
||
const key = task.task_name;
|
||
if (key) this.$set(this.calendar.taskMapByName, key, task);
|
||
});
|
||
|
||
// 更新内容类型,确保类型筛选按钮能热更新
|
||
const rawTypes = tasksResponse.data.data.content_types || [];
|
||
this.updateContentTypes(rawTypes);
|
||
|
||
// 更新任务列表的内容类型
|
||
this.tasklist.contentTypes = this.calendar.contentTypes;
|
||
}
|
||
} catch (error) {
|
||
console.warn('加载任务列表元数据失败:', error);
|
||
}
|
||
},
|
||
// 触发热更新(保存配置后调用)
|
||
async triggerTasklistHotUpdate() {
|
||
try {
|
||
// 如果当前是任务列表页面,只刷新元数据,不重新加载任务列表
|
||
if (this.activeTab === 'tasklist') {
|
||
await this.loadTasklistMetadata();
|
||
}
|
||
|
||
// 如果当前是追剧日历页面,也刷新日历数据
|
||
if (this.activeTab === 'calendar') {
|
||
await this.refreshCalendarData();
|
||
}
|
||
} catch (error) {
|
||
console.warn('触发热更新失败:', error);
|
||
}
|
||
},
|
||
// 启动任务列表后台监听:优先使用SSE,失败时回退轮询
|
||
startTasklistAutoWatch() {
|
||
try {
|
||
// 先停止现有的轮询
|
||
this.stopTasklistAutoWatch();
|
||
|
||
// 建立 SSE 连接,实时感知任务列表变化(成功建立后停用轮询,失败时回退轮询)
|
||
try {
|
||
// 使用全局 SSE 单例
|
||
this.ensureGlobalSSE();
|
||
if (this.appSSE && !this.tasklistSSEListenerAdded) {
|
||
const onTasklistChanged = async (ev) => {
|
||
try {
|
||
// 解析变更原因(后端通过 SSE data 传递)
|
||
let changeReason = '';
|
||
try { changeReason = JSON.parse(ev && ev.data || '{}').reason || ''; } catch (e) {}
|
||
|
||
// 先拉取最新转存信息并重建映射
|
||
try {
|
||
const latestRes = await axios.get('/task_latest_info');
|
||
if (latestRes.data && latestRes.data.success) {
|
||
const latestFiles = latestRes.data.data.latest_files || {};
|
||
this.taskLatestFiles = latestFiles;
|
||
this.taskLatestRecords = latestRes.data.data.latest_records || {};
|
||
}
|
||
} catch (e) {}
|
||
|
||
// 重新加载任务元数据,确保海报和元数据能热更新
|
||
try {
|
||
await this.loadTasklistMetadata();
|
||
} catch (e) {}
|
||
|
||
// 如果是任务相关的变更,也更新任务列表数据
|
||
if (changeReason === 'edit_metadata' || changeReason === 'task_updated') {
|
||
try {
|
||
const dataRes = await axios.get('/data');
|
||
if (dataRes.data && dataRes.data.success) {
|
||
const cfg = dataRes.data.data || {};
|
||
const oldTaskCount = (this.formData.tasklist || []).length;
|
||
// 后端推送导致的任务列表更新,不应触发"未保存修改"提示
|
||
this.suppressConfigModifiedOnce = true;
|
||
this.formData.tasklist = cfg.tasklist || [];
|
||
// 同步任务名集合用于筛选
|
||
this.calendar.taskNames = (this.formData.tasklist || []).map(t => t.taskname).filter(Boolean);
|
||
// 如任务数量变化,重建与任务相关的最新文件映射的键集合
|
||
if ((this.formData.tasklist || []).length !== oldTaskCount) {
|
||
try {
|
||
this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(this.taskLatestFiles || {});
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
} catch (e) {}
|
||
};
|
||
this.onTasklistChangedHandler = onTasklistChanged;
|
||
this.appSSE.addEventListener('calendar_changed', onTasklistChanged);
|
||
this.tasklistSSEListenerAdded = true;
|
||
// onopen/onerror 已集中在 ensureGlobalSSE,无需重复设置
|
||
}
|
||
} catch (e) {
|
||
// 忽略 SSE 失败,继续使用轮询
|
||
}
|
||
} catch (e) {
|
||
console.warn('启动任务列表后台监听失败:', e);
|
||
}
|
||
},
|
||
// 停止任务列表后台监听
|
||
stopTasklistAutoWatch() {
|
||
try {
|
||
// 关闭SSE连接
|
||
if (this.tasklistSSE) {
|
||
this.tasklistSSE.close();
|
||
this.tasklistSSE = null;
|
||
}
|
||
// 停止轮询
|
||
if (this.tasklistAutoWatchTimer) {
|
||
clearInterval(this.tasklistAutoWatchTimer);
|
||
this.tasklistAutoWatchTimer = null;
|
||
}
|
||
} catch (e) {
|
||
console.warn('停止任务列表后台监听失败:', e);
|
||
}
|
||
},
|
||
getTaskLatestRecordDisplay(taskName) {
|
||
// 获取任务最新记录的显示文本
|
||
const latestRecord = this.taskLatestRecords[taskName];
|
||
return latestRecord ? latestRecord.display : '';
|
||
},
|
||
isTaskUpdatedToday(taskName) {
|
||
// 检查任务是否在今天更新
|
||
const latestRecord = this.taskLatestRecords[taskName];
|
||
if (!latestRecord || !latestRecord.full) {
|
||
return false;
|
||
}
|
||
|
||
// 获取今天的完整日期,格式为 YYYY-MM-DD
|
||
const today = new Date();
|
||
const todayFormatted = today.getFullYear() + '-' +
|
||
String(today.getMonth() + 1).padStart(2, '0') + '-' +
|
||
String(today.getDate()).padStart(2, '0');
|
||
|
||
return latestRecord.full === todayFormatted;
|
||
},
|
||
isRecordUpdatedToday(record) {
|
||
// 检查转存记录是否在今天更新
|
||
if (!record || !record.transfer_time_readable) {
|
||
return false;
|
||
}
|
||
|
||
// 获取今天的日期,格式为 YYYY-MM-DD
|
||
const today = new Date();
|
||
const todayFormatted = today.getFullYear() + '-' +
|
||
String(today.getMonth() + 1).padStart(2, '0') + '-' +
|
||
String(today.getDate()).padStart(2, '0');
|
||
|
||
// 从 transfer_time_readable 中提取日期部分(格式通常为 "YYYY-MM-DD HH:MM:SS")
|
||
const recordDate = record.transfer_time_readable.split(' ')[0];
|
||
|
||
return recordDate === todayFormatted;
|
||
},
|
||
isFileUpdatedToday(file) {
|
||
// 检查文件是否在今天更新(基于修改日期)
|
||
if (!file || !file.updated_at) {
|
||
return false;
|
||
}
|
||
|
||
// 获取今天的日期,格式为 YYYY-MM-DD
|
||
const today = new Date();
|
||
const todayFormatted = today.getFullYear() + '-' +
|
||
String(today.getMonth() + 1).padStart(2, '0') + '-' +
|
||
String(today.getDate()).padStart(2, '0');
|
||
|
||
// 使用与 formatDate 方法相同的逻辑处理时间戳
|
||
try {
|
||
const fileDate = new Date(file.updated_at);
|
||
const fileDateFormatted = fileDate.getFullYear() + '-' +
|
||
String(fileDate.getMonth() + 1).padStart(2, '0') + '-' +
|
||
String(fileDate.getDate()).padStart(2, '0');
|
||
|
||
return fileDateFormatted === todayFormatted;
|
||
} catch (error) {
|
||
console.error('处理文件时间戳时出错:', error, file.updated_at);
|
||
return false;
|
||
}
|
||
},
|
||
shouldShowTodayIndicator() {
|
||
// 检查是否应该显示当日更新图标
|
||
return this.formData.button_display.today_update_indicator !== 'disabled';
|
||
},
|
||
getTodayIndicatorClass() {
|
||
// 获取当日更新图标的CSS类
|
||
if (this.formData.button_display.today_update_indicator === 'hover') {
|
||
return 'hover-only';
|
||
}
|
||
return '';
|
||
},
|
||
openDatePicker(index) {
|
||
// 使用$refs访问对应的日期选择器并打开它
|
||
const dateRef = this.$refs[`enddate_${index}`];
|
||
if (dateRef && dateRef[0]) {
|
||
// 由于Vue的ref在v-for中会返回一个数组,所以我们需要访问第一个元素
|
||
dateRef[0].showPicker();
|
||
|
||
// 点击按钮后立即使按钮失去焦点
|
||
document.activeElement.blur();
|
||
}
|
||
},
|
||
toggleCloudSaverPassword() {
|
||
this.showCloudSaverPassword = !this.showCloudSaverPassword;
|
||
},
|
||
toggleWebuiPassword() {
|
||
this.showWebuiPassword = !this.showWebuiPassword;
|
||
},
|
||
togglePageWidth() {
|
||
// 循环切换页面宽度模式:窄 -> 中 -> 宽 -> 窄
|
||
if (this.pageWidthMode === 'narrow') {
|
||
this.pageWidthMode = 'medium';
|
||
} else if (this.pageWidthMode === 'medium') {
|
||
this.pageWidthMode = 'wide';
|
||
} else {
|
||
this.pageWidthMode = 'narrow';
|
||
}
|
||
// 保存用户选择到本地存储
|
||
localStorage.setItem('quarkAutoSave_pageWidthMode', this.pageWidthMode);
|
||
// 应用页面宽度类
|
||
document.body.classList.remove('page-width-narrow', 'page-width-medium', 'page-width-wide');
|
||
document.body.classList.add('page-width-' + this.pageWidthMode);
|
||
},
|
||
showToast(message) {
|
||
this.toastMessage = message;
|
||
$(this.$refs.toast).toast('show');
|
||
},
|
||
checkPreviewScrollbar() {
|
||
// 检查命名预览界面的滚动条状态
|
||
const fileSelectModal = document.getElementById('fileSelectModal');
|
||
if (fileSelectModal && fileSelectModal.getAttribute('data-modal-type') === 'preview') {
|
||
// 查找命名预览模式的内容容器
|
||
const contentContainers = fileSelectModal.querySelectorAll('.modal-body > div:not(.alert-warning)');
|
||
|
||
contentContainers.forEach(container => {
|
||
// 检查是否有水平滚动条(滚动宽度大于客户端宽度)
|
||
if (container.scrollWidth > container.clientWidth) {
|
||
container.classList.add('has-scrollbar');
|
||
} else {
|
||
container.classList.remove('has-scrollbar');
|
||
}
|
||
});
|
||
}
|
||
},
|
||
isModalTextTruncated(text, fileIndex, field) {
|
||
if (!this.fileSelect.fileList[fileIndex]) return false;
|
||
|
||
// 确保文本是字符串类型
|
||
const str = String(text || '');
|
||
|
||
// 先检查文本是否有足够长度,太短的文本肯定不需要展开
|
||
if (str.length <= 20) return false;
|
||
|
||
// 从文件的_isOverflowing属性中获取溢出状态
|
||
const file = this.fileSelect.fileList[fileIndex];
|
||
|
||
// 如果已经展开了,那么我们认为它可能是需要截断的
|
||
if (file._expandedFields && file._expandedFields.includes(field)) {
|
||
return true;
|
||
}
|
||
|
||
return file._isOverflowing && file._isOverflowing[field];
|
||
},
|
||
toggleModalExpand(index, field) {
|
||
// 获取当前文件
|
||
const file = this.fileSelect.fileList[index];
|
||
// 初始化_expandedFields属性(如果不存在)
|
||
if (!file._expandedFields) {
|
||
this.$set(file, '_expandedFields', []);
|
||
}
|
||
|
||
// 检查字段是否已经展开
|
||
const fieldIndex = file._expandedFields.indexOf(field);
|
||
const isExpanding = fieldIndex === -1;
|
||
|
||
if (isExpanding) {
|
||
// 如果是展开操作,添加字段到展开列表
|
||
file._expandedFields.push(field);
|
||
} else {
|
||
// 如果是收起操作,从展开列表中移除字段
|
||
file._expandedFields.splice(fieldIndex, 1);
|
||
}
|
||
|
||
// 在下一个DOM更新周期中强制重新检测溢出状态
|
||
this.$nextTick(() => {
|
||
// 确保_isOverflowing属性存在
|
||
if (!file._isOverflowing) {
|
||
this.$set(file, '_isOverflowing', {});
|
||
}
|
||
|
||
// 在收起状态下,通过设置为true确保展开按钮依然可见
|
||
if (!isExpanding) {
|
||
this.$set(file._isOverflowing, field, true);
|
||
}
|
||
|
||
// 强制更新视图
|
||
this.$forceUpdate();
|
||
});
|
||
|
||
// 阻止事件冒泡,避免触发行点击事件
|
||
event.stopPropagation();
|
||
},
|
||
// 文件选择模态框的排序方法
|
||
sortFileList(field) {
|
||
// 切换排序方向
|
||
if (this.fileSelect.sortBy === field) {
|
||
this.fileSelect.sortOrder = this.fileSelect.sortOrder === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
this.fileSelect.sortBy = field;
|
||
// 默认降序(除了文件名外)
|
||
this.fileSelect.sortOrder = field === 'file_name' ? 'asc' : 'desc';
|
||
}
|
||
|
||
// 按选定字段和顺序对文件列表进行排序
|
||
this.fileSelect.fileList.sort((a, b) => {
|
||
if (field === 'file_name') {
|
||
// 检查当前模态框类型,选择起始文件模态框使用全局文件排序函数
|
||
const modalType = document.getElementById('fileSelectModal').getAttribute('data-modal-type');
|
||
if (modalType === 'start-file') {
|
||
// 选择起始文件模态框:按过滤状态和类型排序
|
||
// 1. 检查过滤状态 - 包括没有file_name_re字段的情况
|
||
const aFiltered = !a.file_name_re || a.file_name_re === '×' || a.file_name_re.startsWith('×');
|
||
const bFiltered = !b.file_name_re || b.file_name_re === '×' || b.file_name_re.startsWith('×');
|
||
|
||
// 无论升序还是降序,未被过滤的项目都排在前面,被过滤的项目排在后面
|
||
if (!aFiltered && bFiltered) return -1;
|
||
if (aFiltered && !bFiltered) return 1;
|
||
|
||
// 2. 在同一过滤状态内,文件夹排在文件前面
|
||
if (a.dir && !b.dir) return -1;
|
||
if (!a.dir && b.dir) return 1;
|
||
|
||
// 使用全局文件排序函数
|
||
const ka = sortFileByName(a), kb = sortFileByName(b);
|
||
for (let i = 0; i < ka.length; ++i) {
|
||
if (ka[i] !== kb[i]) return this.fileSelect.sortOrder === 'asc' ? (ka[i] > kb[i] ? 1 : -1) : (ka[i] < kb[i] ? 1 : -1);
|
||
}
|
||
return 0;
|
||
} else if (modalType === 'source' || modalType === 'target' || modalType === 'move') {
|
||
// 选择文件夹的模态框(选择需转存的文件夹、选择保存到的文件夹、选择移动到的文件夹)
|
||
// 文件夹排在文件前面
|
||
if (a.dir && !b.dir) return -1;
|
||
if (!a.dir && b.dir) return 1;
|
||
// 根据类型使用不同的排序键顺序
|
||
const kaRaw = sortFileByName(a), kbRaw = sortFileByName(b);
|
||
let ka, kb;
|
||
if (a.dir && b.dir) {
|
||
// 文件夹:日期、上中下、拼音、更新时间(去掉期数/集数)
|
||
// [date_value, episode_value, segment_value, update_time, pinyin_sort_key]
|
||
// 调整为:[date_value, segment_value, pinyin_sort_key, update_time]
|
||
ka = [kaRaw[0], kaRaw[2], kaRaw[4], kaRaw[3]];
|
||
kb = [kbRaw[0], kbRaw[2], kbRaw[4], kbRaw[3]];
|
||
} else {
|
||
// 文件:日期、期数、上中下、拼音、更新时间
|
||
// [date_value, episode_value, segment_value, update_time, pinyin_sort_key]
|
||
// 调整为:[date_value, episode_value, segment_value, pinyin_sort_key, update_time]
|
||
ka = [kaRaw[0], kaRaw[1], kaRaw[2], kaRaw[4], kaRaw[3]];
|
||
kb = [kbRaw[0], kbRaw[1], kbRaw[2], kbRaw[4], kbRaw[3]];
|
||
}
|
||
for (let i = 0; i < ka.length; ++i) {
|
||
if (ka[i] !== kb[i]) return this.fileSelect.sortOrder === 'asc' ? (ka[i] > kb[i] ? 1 : -1) : (ka[i] < kb[i] ? 1 : -1);
|
||
}
|
||
return 0;
|
||
} else {
|
||
// 其他模态框:文件夹排在文件前面
|
||
if (a.dir && !b.dir) return -1;
|
||
if (!a.dir && b.dir) return 1;
|
||
// 其他模态框:使用与任务列表一致的自然排序算法
|
||
const ka = sortFileByName(a), kb = sortFileByName(b);
|
||
for (let i = 0; i < ka.length; ++i) {
|
||
if (ka[i] !== kb[i]) return this.fileSelect.sortOrder === 'asc' ? (ka[i] > kb[i] ? 1 : -1) : (ka[i] < kb[i] ? 1 : -1);
|
||
}
|
||
return 0;
|
||
}
|
||
}
|
||
if (field === 'file_name_re') {
|
||
const aHasValidRename = a.file_name_re && a.file_name_re !== '×' && !a.file_name_re.startsWith('×');
|
||
const bHasValidRename = b.file_name_re && b.file_name_re !== '×' && !b.file_name_re.startsWith('×');
|
||
|
||
// 有效重命名的项目排在前面
|
||
if (aHasValidRename && !bHasValidRename) return -1;
|
||
if (!aHasValidRename && bHasValidRename) return 1;
|
||
|
||
// 对于有效重命名的项目,按重命名结果排序
|
||
if (aHasValidRename && bHasValidRename) {
|
||
// 同类型内,文件夹排在文件前面
|
||
if (a.dir && !b.dir) return -1;
|
||
if (!a.dir && b.dir) return 1;
|
||
|
||
let aValue, bValue;
|
||
// 对于重命名列,优先使用episode_number进行数值排序(如果存在)
|
||
if (a.episode_number !== undefined && b.episode_number !== undefined) {
|
||
// 确保进行数值比较
|
||
aValue = parseInt(a.episode_number, 10);
|
||
bValue = parseInt(b.episode_number, 10);
|
||
|
||
// 如果解析失败,回退到字符串比较
|
||
if (isNaN(aValue) || isNaN(bValue)) {
|
||
aValue = String(a.episode_number);
|
||
bValue = String(b.episode_number);
|
||
}
|
||
} else {
|
||
// 否则使用智能排序:优先数值排序,其次日期排序,最后字符串排序
|
||
const aRename = a.file_name_re || '';
|
||
const bRename = b.file_name_re || '';
|
||
|
||
// 尝试提取所有数字进行数值排序
|
||
const aNumbers = aRename.match(/\d+/g);
|
||
const bNumbers = bRename.match(/\d+/g);
|
||
|
||
if (aNumbers && bNumbers && aNumbers.length > 0 && bNumbers.length > 0) {
|
||
// 如果都能提取到数字,进行数值比较
|
||
// 优先比较第一个数字,如果相同则比较后续数字
|
||
for (let i = 0; i < Math.max(aNumbers.length, bNumbers.length); i++) {
|
||
const aNum = parseInt(aNumbers[i] || '0', 10);
|
||
const bNum = parseInt(bNumbers[i] || '0', 10);
|
||
if (aNum !== bNum) {
|
||
aValue = aNum;
|
||
bValue = bNum;
|
||
break;
|
||
}
|
||
}
|
||
// 如果所有数字都相同,使用自然排序
|
||
if (aValue === undefined) {
|
||
aValue = aRename;
|
||
bValue = bRename;
|
||
}
|
||
} else {
|
||
// 否则使用自然排序(支持数值和日期的智能排序)
|
||
aValue = aRename;
|
||
bValue = bRename;
|
||
}
|
||
}
|
||
|
||
if (this.fileSelect.sortOrder === 'asc') {
|
||
// 字符串使用自然排序,数值直接比较
|
||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||
return aValue.localeCompare(bValue, undefined, { numeric: true, sensitivity: 'base' });
|
||
}
|
||
return aValue > bValue ? 1 : -1;
|
||
} else {
|
||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||
return bValue.localeCompare(aValue, undefined, { numeric: true, sensitivity: 'base' });
|
||
}
|
||
return aValue < bValue ? 1 : -1;
|
||
}
|
||
}
|
||
|
||
// 对于无效重命名的项目,按照特定优先级排序
|
||
if (!aHasValidRename && !bHasValidRename) {
|
||
// 定义无效重命名的优先级(数字越小优先级越高)
|
||
const getInvalidRenamePriority = (item) => {
|
||
const renameResult = item.file_name_re || '×';
|
||
if (renameResult.includes('无法识别剧集编号')) return 1;
|
||
if (renameResult === '×') return 2;
|
||
return 3; // 其他无效重命名
|
||
};
|
||
|
||
const aPriority = getInvalidRenamePriority(a);
|
||
const bPriority = getInvalidRenamePriority(b);
|
||
|
||
// 如果优先级不同,按优先级排序
|
||
if (aPriority !== bPriority) {
|
||
return aPriority - bPriority;
|
||
}
|
||
|
||
// 同一优先级内,文件夹排在文件前面
|
||
if (a.dir && !b.dir) return -1;
|
||
if (!a.dir && b.dir) return 1;
|
||
|
||
// 同一优先级且同一类型内,使用智能排序
|
||
const ka = sortFileByName(a), kb = sortFileByName(b);
|
||
for (let i = 0; i < ka.length; ++i) {
|
||
if (ka[i] !== kb[i]) return this.fileSelect.sortOrder === 'asc' ? (ka[i] > kb[i] ? 1 : -1) : (ka[i] < kb[i] ? 1 : -1);
|
||
}
|
||
return 0;
|
||
}
|
||
return 0;
|
||
} else if (field === 'size') {
|
||
// 文件夹始终在前
|
||
if (a.dir && !b.dir) return -1;
|
||
if (!a.dir && b.dir) return 1;
|
||
|
||
// 文件夹按项目数量排序,文件按大小排序
|
||
let aValue = a.dir ? (a.include_items || 0) : (a.size || 0);
|
||
let bValue = b.dir ? (b.include_items || 0) : (b.size || 0);
|
||
if (this.fileSelect.sortOrder === 'asc') {
|
||
return aValue > bValue ? 1 : -1;
|
||
} else {
|
||
return aValue < bValue ? 1 : -1;
|
||
}
|
||
} else if (field === 'updated_at') {
|
||
// 修改日期排序严格按照修改日期,不区分文件夹和文件
|
||
let aValue = a.updated_at || 0;
|
||
let bValue = b.updated_at || 0;
|
||
if (this.fileSelect.sortOrder === 'asc') {
|
||
return aValue > bValue ? 1 : -1;
|
||
} else {
|
||
return aValue < bValue ? 1 : -1;
|
||
}
|
||
}
|
||
});
|
||
},
|
||
manualSortFileList(field, order) {
|
||
this.fileSelect.fileList.sort((a, b) => {
|
||
if (field === 'file_name') {
|
||
// 检查当前模态框类型,选择起始文件模态框使用特殊排序逻辑
|
||
const modalType = document.getElementById('fileSelectModal').getAttribute('data-modal-type');
|
||
if (modalType === 'start-file') {
|
||
// 选择起始文件模态框:按过滤状态和类型排序
|
||
// 1. 检查过滤状态 - 包括没有file_name_re字段的情况
|
||
const aFiltered = !a.file_name_re || a.file_name_re === '×' || a.file_name_re.startsWith('×');
|
||
const bFiltered = !b.file_name_re || b.file_name_re === '×' || b.file_name_re.startsWith('×');
|
||
|
||
// 无论升序还是降序,未被过滤的项目都排在前面,被过滤的项目排在后面
|
||
if (!aFiltered && bFiltered) return -1;
|
||
if (aFiltered && !bFiltered) return 1;
|
||
|
||
// 2. 在同一过滤状态内,文件夹排在文件前面
|
||
if (a.dir && !b.dir) return -1;
|
||
if (!a.dir && b.dir) return 1;
|
||
|
||
// 使用全局文件排序函数
|
||
const ka = sortFileByName(a), kb = sortFileByName(b);
|
||
for (let i = 0; i < ka.length; ++i) {
|
||
if (ka[i] !== kb[i]) return order === 'asc' ? (ka[i] > kb[i] ? 1 : -1) : (ka[i] < kb[i] ? 1 : -1);
|
||
}
|
||
return 0;
|
||
} else if (modalType === 'source' || modalType === 'target' || modalType === 'move') {
|
||
// 选择文件夹的模态框(选择需转存的文件夹、选择保存到的文件夹、选择移动到的文件夹)
|
||
// 文件夹排在文件前面
|
||
if (a.dir && !b.dir) return -1;
|
||
if (!a.dir && b.dir) return 1;
|
||
// 根据类型使用不同的排序键顺序
|
||
const kaRaw = sortFileByName(a), kbRaw = sortFileByName(b);
|
||
let ka, kb;
|
||
if (a.dir && b.dir) {
|
||
// 文件夹:日期、上中下、拼音、更新时间(去掉期数/集数)
|
||
// [date_value, episode_value, segment_value, update_time, pinyin_sort_key]
|
||
// 调整为:[date_value, segment_value, pinyin_sort_key, update_time]
|
||
ka = [kaRaw[0], kaRaw[2], kaRaw[4], kaRaw[3]];
|
||
kb = [kbRaw[0], kbRaw[2], kbRaw[4], kbRaw[3]];
|
||
} else {
|
||
// 文件:日期、期数、上中下、拼音、更新时间
|
||
// [date_value, episode_value, segment_value, update_time, pinyin_sort_key]
|
||
// 调整为:[date_value, episode_value, segment_value, pinyin_sort_key, update_time]
|
||
ka = [kaRaw[0], kaRaw[1], kaRaw[2], kaRaw[4], kaRaw[3]];
|
||
kb = [kbRaw[0], kbRaw[1], kbRaw[2], kbRaw[4], kbRaw[3]];
|
||
}
|
||
for (let i = 0; i < ka.length; ++i) {
|
||
if (ka[i] !== kb[i]) return order === 'asc' ? (ka[i] > kb[i] ? 1 : -1) : (ka[i] < kb[i] ? 1 : -1);
|
||
}
|
||
return 0;
|
||
} else {
|
||
// 其他模态框:文件夹排在文件前面
|
||
if (a.dir && !b.dir) return -1;
|
||
if (!a.dir && b.dir) return 1;
|
||
// 使用智能排序函数
|
||
const ka = sortFileByName(a), kb = sortFileByName(b);
|
||
for (let i = 0; i < ka.length; ++i) {
|
||
if (ka[i] !== kb[i]) return order === 'asc' ? (ka[i] > kb[i] ? 1 : -1) : (ka[i] < kb[i] ? 1 : -1);
|
||
}
|
||
return 0;
|
||
}
|
||
}
|
||
if (field === 'file_name_re') {
|
||
const aHasValidRename = a.file_name_re && a.file_name_re !== '×' && !a.file_name_re.startsWith('×');
|
||
const bHasValidRename = b.file_name_re && b.file_name_re !== '×' && !b.file_name_re.startsWith('×');
|
||
|
||
// 有效重命名的项目排在前面
|
||
if (aHasValidRename && !bHasValidRename) return -1;
|
||
if (!aHasValidRename && bHasValidRename) return 1;
|
||
|
||
// 对于有效重命名的项目,按重命名结果排序
|
||
if (aHasValidRename && bHasValidRename) {
|
||
// 同类型内,文件夹排在文件前面
|
||
if (a.dir && !b.dir) return -1;
|
||
if (!a.dir && b.dir) return 1;
|
||
|
||
let aValue, bValue;
|
||
// 对于重命名列,优先使用episode_number进行数值排序(如果存在)
|
||
if (a.episode_number !== undefined && b.episode_number !== undefined) {
|
||
// 确保进行数值比较
|
||
aValue = parseInt(a.episode_number, 10);
|
||
bValue = parseInt(b.episode_number, 10);
|
||
|
||
// 如果解析失败,回退到字符串比较
|
||
if (isNaN(aValue) || isNaN(bValue)) {
|
||
aValue = String(a.episode_number);
|
||
bValue = String(b.episode_number);
|
||
}
|
||
} else {
|
||
// 否则使用智能排序:优先数值排序,其次日期排序,最后字符串排序
|
||
const aRename = a.file_name_re || '';
|
||
const bRename = b.file_name_re || '';
|
||
|
||
// 尝试提取所有数字进行数值排序
|
||
const aNumbers = aRename.match(/\d+/g);
|
||
const bNumbers = bRename.match(/\d+/g);
|
||
|
||
if (aNumbers && bNumbers && aNumbers.length > 0 && bNumbers.length > 0) {
|
||
// 如果都能提取到数字,进行数值比较
|
||
// 优先比较第一个数字,如果相同则比较后续数字
|
||
for (let i = 0; i < Math.max(aNumbers.length, bNumbers.length); i++) {
|
||
const aNum = parseInt(aNumbers[i] || '0', 10);
|
||
const bNum = parseInt(bNumbers[i] || '0', 10);
|
||
if (aNum !== bNum) {
|
||
aValue = aNum;
|
||
bValue = bNum;
|
||
break;
|
||
}
|
||
}
|
||
// 如果所有数字都相同,使用自然排序
|
||
if (aValue === undefined) {
|
||
aValue = aRename;
|
||
bValue = bRename;
|
||
}
|
||
} else {
|
||
// 否则使用自然排序(支持数值和日期的智能排序)
|
||
aValue = aRename;
|
||
bValue = bRename;
|
||
}
|
||
}
|
||
|
||
if (order === 'asc') {
|
||
// 如果都是字符串,使用自然排序
|
||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||
return aValue.localeCompare(bValue, undefined, { numeric: true, sensitivity: 'base' });
|
||
}
|
||
return aValue > bValue ? 1 : -1;
|
||
} else {
|
||
// 如果都是字符串,使用自然排序
|
||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||
return bValue.localeCompare(aValue, undefined, { numeric: true, sensitivity: 'base' });
|
||
}
|
||
return aValue < bValue ? 1 : -1;
|
||
}
|
||
}
|
||
|
||
// 对于无效重命名的项目,按照特定优先级排序
|
||
if (!aHasValidRename && !bHasValidRename) {
|
||
// 定义无效重命名的优先级(数字越小优先级越高)
|
||
const getInvalidRenamePriority = (item) => {
|
||
const renameResult = item.file_name_re || '×';
|
||
if (renameResult.includes('无法识别剧集编号')) return 1;
|
||
if (renameResult === '×') return 2;
|
||
return 3; // 其他无效重命名
|
||
};
|
||
|
||
const aPriority = getInvalidRenamePriority(a);
|
||
const bPriority = getInvalidRenamePriority(b);
|
||
|
||
// 如果优先级不同,按优先级排序
|
||
if (aPriority !== bPriority) {
|
||
return aPriority - bPriority;
|
||
}
|
||
|
||
// 同一优先级内,文件夹排在文件前面
|
||
if (a.dir && !b.dir) return -1;
|
||
if (!a.dir && b.dir) return 1;
|
||
|
||
// 同一优先级且同一类型内,使用智能排序
|
||
const ka = sortFileByName(a), kb = sortFileByName(b);
|
||
for (let i = 0; i < ka.length; ++i) {
|
||
if (ka[i] !== kb[i]) return order === 'asc' ? (ka[i] > kb[i] ? 1 : -1) : (ka[i] < kb[i] ? 1 : -1);
|
||
}
|
||
return 0;
|
||
}
|
||
return 0;
|
||
} else if (field === 'size') {
|
||
// 文件夹始终在前
|
||
if (a.dir && !b.dir) return -1;
|
||
if (!a.dir && b.dir) return 1;
|
||
|
||
// 文件夹按项目数量排序,文件按大小排序
|
||
let aValue = a.dir ? (a.include_items || 0) : (a.size || 0);
|
||
let bValue = b.dir ? (b.include_items || 0) : (b.size || 0);
|
||
if (order === 'asc') {
|
||
return aValue > bValue ? 1 : -1;
|
||
} else {
|
||
return aValue < bValue ? 1 : -1;
|
||
}
|
||
} else if (field === 'updated_at') {
|
||
// 修改日期排序严格按照修改日期,不区分文件夹和文件
|
||
let aValue = a.updated_at || 0;
|
||
let bValue = b.updated_at || 0;
|
||
if (order === 'asc') {
|
||
return aValue > bValue ? 1 : -1;
|
||
} else {
|
||
return aValue < bValue ? 1 : -1;
|
||
}
|
||
}
|
||
});
|
||
},
|
||
validateNumberInput(event, field, max) {
|
||
// 获取当前输入值
|
||
let value = event.target.value;
|
||
// 获取输入框的当前光标位置
|
||
const cursorPosition = event.target.selectionStart;
|
||
|
||
// 记录原始长度
|
||
const originalLength = value.length;
|
||
|
||
// 移除非数字字符
|
||
const cleanValue = value.replace(/[^\d]/g, '');
|
||
|
||
// 如果有非数字字符被移除
|
||
if (cleanValue !== value) {
|
||
// 计算移除了多少个字符
|
||
const diff = originalLength - cleanValue.length;
|
||
|
||
// 更新输入框的值
|
||
event.target.value = cleanValue;
|
||
|
||
// 调整光标位置(考虑到字符被移除)
|
||
setTimeout(() => {
|
||
event.target.setSelectionRange(Math.max(0, cursorPosition - diff), Math.max(0, cursorPosition - diff));
|
||
}, 0);
|
||
}
|
||
|
||
// 确保不超过最大值
|
||
if (cleanValue !== '' && parseInt(cleanValue) > max) {
|
||
event.target.value = max.toString();
|
||
}
|
||
|
||
// 更新数据模型,如果为空则默认为0
|
||
this.formData[field] = event.target.value === '' ? 0 : parseInt(event.target.value);
|
||
},
|
||
selectRecord(event, recordId) {
|
||
// 获取当前记录的索引
|
||
const currentIndex = this.filteredHistoryRecords.findIndex(record => record.id === recordId);
|
||
if (currentIndex === -1) return;
|
||
|
||
// 如果是Shift+点击,选择范围
|
||
if (event.shiftKey && this.selectedRecords.length > 0) {
|
||
// 找出所有已选中记录的索引
|
||
const selectedIndices = this.selectedRecords.map(id =>
|
||
this.filteredHistoryRecords.findIndex(record => record.id === id)
|
||
).filter(index => index !== -1); // 过滤掉未找到的记录
|
||
|
||
if (selectedIndices.length > 0) {
|
||
// 找出已选中记录中最靠前的索引
|
||
const earliestSelectedIndex = Math.min(...selectedIndices);
|
||
// 确定最终的选择范围
|
||
const startIndex = Math.min(earliestSelectedIndex, currentIndex);
|
||
const endIndex = Math.max(earliestSelectedIndex, currentIndex);
|
||
|
||
// 获取新的选择范围内所有记录的ID
|
||
this.selectedRecords = this.filteredHistoryRecords
|
||
.slice(startIndex, endIndex + 1)
|
||
.map(record => record.id);
|
||
} else {
|
||
// 如果没有有效的选中记录(可能是由于列表刷新),则只选择当前记录
|
||
this.selectedRecords = [recordId];
|
||
}
|
||
}
|
||
// 如果是Ctrl/Cmd+点击,切换单个记录选择状态
|
||
else if (event.ctrlKey || event.metaKey) {
|
||
if (this.selectedRecords.includes(recordId)) {
|
||
this.selectedRecords = this.selectedRecords.filter(id => id !== recordId);
|
||
} else {
|
||
this.selectedRecords.push(recordId);
|
||
}
|
||
}
|
||
// 普通点击,清除当前选择并选择当前记录
|
||
else {
|
||
if (this.selectedRecords.length === 1 && this.selectedRecords.includes(recordId)) {
|
||
this.selectedRecords = [];
|
||
} else {
|
||
this.selectedRecords = [recordId];
|
||
}
|
||
}
|
||
|
||
// 更新最后选择的记录索引,只有在有选择记录时才更新
|
||
if (this.selectedRecords.length > 0) {
|
||
this.lastSelectedRecordIndex = currentIndex;
|
||
} else {
|
||
this.lastSelectedRecordIndex = -1;
|
||
}
|
||
},
|
||
deleteSelectedRecords() {
|
||
if (this.selectedRecords.length === 0) return;
|
||
|
||
if (confirm(`确定要删除选中的 ${this.selectedRecords.length} 条记录吗?`)) {
|
||
axios.post('/delete_history_records', {
|
||
record_ids: this.selectedRecords
|
||
})
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.showToast(response.data.message);
|
||
// 重新加载记录
|
||
this.loadHistoryRecords();
|
||
// 清空选择
|
||
this.selectedRecords = [];
|
||
this.lastSelectedRecordIndex = -1;
|
||
// 删除历史记录成功后,主动触发任务/日历刷新,确保任务列表的进度与统计热更新
|
||
(async () => {
|
||
try {
|
||
const latestRes = await axios.get('/task_latest_info');
|
||
if (latestRes.data && latestRes.data.success) {
|
||
const latestFiles = latestRes.data.data.latest_files || {};
|
||
this.taskLatestFiles = latestFiles;
|
||
this.taskLatestRecords = latestRes.data.data.latest_records || {};
|
||
this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles);
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
}
|
||
} catch (e) {}
|
||
try {
|
||
const tasksResponse = await axios.get('/api/calendar/tasks');
|
||
if (tasksResponse.data && tasksResponse.data.success) {
|
||
this.calendar.tasks = tasksResponse.data.data.tasks || [];
|
||
this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name);
|
||
const rawTypes = tasksResponse.data.data.content_types || [];
|
||
this.updateContentTypes(rawTypes);
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
}
|
||
} catch (e) {}
|
||
try {
|
||
await this.loadCalendarEpisodesLocal();
|
||
this.initializeCalendarDates();
|
||
await this.loadTodayUpdatesLocal();
|
||
} catch (e) {}
|
||
})();
|
||
} else {
|
||
alert(response.data.message || '删除失败');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('删除记录时出错:', error);
|
||
alert('删除记录时出错: ' + (error.response?.data?.message || error.message));
|
||
});
|
||
}
|
||
},
|
||
deleteRecord(id, original_name) {
|
||
// 检查是否有多条记录被选中
|
||
if (this.selectedRecords.length > 1) {
|
||
// 如果有多条记录被选中,调用批量删除方法
|
||
if (confirm(`确定要删除选中的 ${this.selectedRecords.length} 条记录吗?`)) {
|
||
axios.post('/delete_history_records', {
|
||
record_ids: this.selectedRecords
|
||
})
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.showToast(`成功删除 ${this.selectedRecords.length} 条记录`);
|
||
// 重新加载记录
|
||
this.loadHistoryRecords();
|
||
// 清空选择
|
||
this.selectedRecords = [];
|
||
this.lastSelectedRecordIndex = -1;
|
||
// 批量删除历史记录成功后,触发任务/日历刷新
|
||
(async () => {
|
||
try {
|
||
const latestRes = await axios.get('/task_latest_info');
|
||
if (latestRes.data && latestRes.data.success) {
|
||
const latestFiles = latestRes.data.data.latest_files || {};
|
||
this.taskLatestFiles = latestFiles;
|
||
this.taskLatestRecords = latestRes.data.data.latest_records || {};
|
||
this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles);
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
}
|
||
} catch (e) {}
|
||
try {
|
||
const tasksResponse = await axios.get('/api/calendar/tasks');
|
||
if (tasksResponse.data && tasksResponse.data.success) {
|
||
this.calendar.tasks = tasksResponse.data.data.tasks || [];
|
||
this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name);
|
||
const rawTypes = tasksResponse.data.data.content_types || [];
|
||
this.updateContentTypes(rawTypes);
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
}
|
||
} catch (e) {}
|
||
try {
|
||
await this.loadCalendarEpisodesLocal();
|
||
this.initializeCalendarDates();
|
||
await this.loadTodayUpdatesLocal();
|
||
} catch (e) {}
|
||
})();
|
||
} else {
|
||
alert(response.data.message || '删除失败');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('删除记录时出错:', error);
|
||
alert('删除记录时出错: ' + (error.response?.data?.message || error.message));
|
||
});
|
||
}
|
||
} else {
|
||
// 单条记录删除
|
||
if (confirm(`确定要删除此条记录吗?`)) {
|
||
axios.post('/delete_history_record', {
|
||
id: id
|
||
})
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.showToast('成功删除 1 条记录');
|
||
// 重新加载记录
|
||
this.loadHistoryRecords();
|
||
// 清空选择
|
||
this.selectedRecords = [];
|
||
this.lastSelectedRecordIndex = -1;
|
||
// 单条删除历史记录成功后,触发任务/日历刷新
|
||
(async () => {
|
||
try {
|
||
const latestRes = await axios.get('/task_latest_info');
|
||
if (latestRes.data && latestRes.data.success) {
|
||
const latestFiles = latestRes.data.data.latest_files || {};
|
||
this.taskLatestFiles = latestFiles;
|
||
this.taskLatestRecords = latestRes.data.data.latest_records || {};
|
||
this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles);
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
}
|
||
} catch (e) {}
|
||
try {
|
||
const tasksResponse = await axios.get('/api/calendar/tasks');
|
||
if (tasksResponse.data && tasksResponse.data.success) {
|
||
this.calendar.tasks = tasksResponse.data.data.tasks || [];
|
||
this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name);
|
||
const rawTypes = tasksResponse.data.data.content_types || [];
|
||
this.updateContentTypes(rawTypes);
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
}
|
||
} catch (e) {}
|
||
try {
|
||
await this.loadCalendarEpisodesLocal();
|
||
this.initializeCalendarDates();
|
||
await this.loadTodayUpdatesLocal();
|
||
} catch (e) {}
|
||
})();
|
||
} else {
|
||
alert(response.data.message || '删除失败');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('删除记录时出错:', error);
|
||
alert('删除记录时出错: ' + (error.response?.data?.message || error.message));
|
||
});
|
||
}
|
||
}
|
||
},
|
||
// 处理点击表格外区域的事件
|
||
handleOutsideClick(event) {
|
||
// 如果当前不是历史记录页面或者没有选中的记录,则不处理
|
||
if (this.activeTab !== 'history' || this.selectedRecords.length === 0) {
|
||
return;
|
||
}
|
||
|
||
// 检查点击是否在表格内
|
||
const tableElement = document.querySelector('.table.selectable-records');
|
||
// 检查点击是否在分页控制区域
|
||
const paginationElement = document.querySelector('.pagination-container');
|
||
|
||
// 如果点击不在表格内且不在分页控制区域内,则清除选择
|
||
if (tableElement && !tableElement.contains(event.target) &&
|
||
paginationElement && !paginationElement.contains(event.target)) {
|
||
this.selectedRecords = [];
|
||
this.lastSelectedRecordIndex = -1;
|
||
}
|
||
},
|
||
// 处理重命名编辑状态的外部点击事件
|
||
handleRenameOutsideClick(event) {
|
||
// 如果当前不是文件整理页面,则不处理
|
||
if (this.activeTab !== 'filemanager') {
|
||
return;
|
||
}
|
||
|
||
// 检查是否有文件正在编辑状态
|
||
const editingFile = this.fileManager.fileList.find(file => file._editing);
|
||
if (!editingFile) {
|
||
return;
|
||
}
|
||
|
||
// 检查点击是否在重命名按钮上
|
||
const renameButton = event.target.closest('.rename-record-btn');
|
||
if (renameButton) {
|
||
return; // 点击在重命名按钮上,不处理
|
||
}
|
||
|
||
// 点击在任何地方(包括输入框内),都保存重命名并关闭输入框
|
||
this.saveRenameFile(editingFile);
|
||
|
||
// 阻止事件继续传播,避免与其他点击事件处理器冲突
|
||
event.stopPropagation();
|
||
},
|
||
selectFileItem(event, fileId) {
|
||
// 如果是在预览模式或选择分享模式,不允许选择
|
||
if (this.fileSelect.previewRegex || this.fileSelect.selectShare) return;
|
||
|
||
// 获取当前文件的索引
|
||
const currentIndex = this.fileSelect.fileList.findIndex(file => file.fid === fileId);
|
||
if (currentIndex === -1) return;
|
||
|
||
// 如果是Shift+点击,选择范围
|
||
if (event.shiftKey && this.fileSelect.selectedFiles.length > 0) {
|
||
// 找出所有已选中文件的索引
|
||
const selectedIndices = this.fileSelect.selectedFiles.map(id =>
|
||
this.fileSelect.fileList.findIndex(file => file.fid === id)
|
||
).filter(index => index !== -1); // 过滤掉未找到的文件
|
||
|
||
if (selectedIndices.length > 0) {
|
||
// 找出已选中文件中最靠前的索引
|
||
const earliestSelectedIndex = Math.min(...selectedIndices);
|
||
// 确定最终的选择范围
|
||
const startIndex = Math.min(earliestSelectedIndex, currentIndex);
|
||
const endIndex = Math.max(earliestSelectedIndex, currentIndex);
|
||
|
||
// 获取范围内所有文件的ID(排除文件夹)
|
||
this.fileSelect.selectedFiles = this.fileSelect.fileList
|
||
.slice(startIndex, endIndex + 1)
|
||
.filter(file => !file.dir) // 只选择文件,不选择文件夹
|
||
.map(file => file.fid);
|
||
} else {
|
||
// 如果没有有效的选中文件(可能是由于列表刷新),则只选择当前文件
|
||
const file = this.fileSelect.fileList[currentIndex];
|
||
if (!file.dir) { // 不选择文件夹
|
||
this.fileSelect.selectedFiles = [fileId];
|
||
}
|
||
}
|
||
}
|
||
// 如果是Ctrl/Cmd+点击,切换单个文件选择状态
|
||
else if (event.ctrlKey || event.metaKey) {
|
||
const file = this.fileSelect.fileList[currentIndex];
|
||
if (file.dir) return; // 不允许选择文件夹
|
||
|
||
if (this.fileSelect.selectedFiles.includes(fileId)) {
|
||
this.fileSelect.selectedFiles = this.fileSelect.selectedFiles.filter(id => id !== fileId);
|
||
} else {
|
||
this.fileSelect.selectedFiles.push(fileId);
|
||
}
|
||
}
|
||
// 普通点击,清除当前选择并选择当前文件
|
||
else {
|
||
const file = this.fileSelect.fileList[currentIndex];
|
||
if (file.dir) return; // 不允许选择文件夹
|
||
|
||
if (this.fileSelect.selectedFiles.length === 1 && this.fileSelect.selectedFiles.includes(fileId)) {
|
||
this.fileSelect.selectedFiles = [];
|
||
} else {
|
||
this.fileSelect.selectedFiles = [fileId];
|
||
}
|
||
}
|
||
|
||
// 更新最后选择的文件索引,只有在有选择文件时才更新
|
||
if (this.fileSelect.selectedFiles.length > 0) {
|
||
this.fileSelect.lastSelectedFileIndex = currentIndex;
|
||
} else {
|
||
this.fileSelect.lastSelectedFileIndex = -1;
|
||
}
|
||
|
||
// 阻止事件冒泡
|
||
event.stopPropagation();
|
||
},
|
||
deleteSelectedFiles(clickedFid, clickedFname, isDir, deleteRecords = false) {
|
||
// 如果是文件夹或者没有选中的文件,则按原来的方式删除单个文件
|
||
if (isDir || this.fileSelect.selectedFiles.length === 0) {
|
||
this.deleteFileForSelect(clickedFid, clickedFname, isDir, deleteRecords);
|
||
return;
|
||
}
|
||
|
||
// 如果点击的文件不在选中列表中,也按原来的方式删除单个文件
|
||
if (!this.fileSelect.selectedFiles.includes(clickedFid)) {
|
||
this.deleteFileForSelect(clickedFid, clickedFname, isDir, deleteRecords);
|
||
return;
|
||
}
|
||
|
||
// 多选删除
|
||
const selectedCount = this.fileSelect.selectedFiles.length;
|
||
|
||
// 根据选中数量和是否删除记录使用不同的确认提示
|
||
let confirmMessage = '';
|
||
if (deleteRecords) {
|
||
confirmMessage = selectedCount === 1
|
||
? `确定要删除此项目及其关联记录吗?`
|
||
: `确定要删除选中的 ${selectedCount} 个项目及其关联记录吗?`;
|
||
} else {
|
||
confirmMessage = selectedCount === 1
|
||
? `确定要删除此项目吗?`
|
||
: `确定要删除选中的 ${selectedCount} 个项目吗?`;
|
||
}
|
||
|
||
if (confirm(confirmMessage)) {
|
||
// 获取当前路径作为save_path参数
|
||
let save_path = "";
|
||
if (this.fileSelect && this.fileSelect.paths) {
|
||
save_path = this.fileSelect.paths.map(item => item.name).join("/");
|
||
}
|
||
|
||
// 创建一个Promise数组来处理所有删除请求
|
||
const deletePromises = this.fileSelect.selectedFiles.map(fid => {
|
||
// 查找对应的文件对象,获取文件名
|
||
const fileObj = this.fileSelect.fileList.find(file => file.fid === fid);
|
||
const fileName = fileObj ? fileObj.file_name : '';
|
||
|
||
return axios.post('/delete_file', { fid: fid, file_name: fileName, delete_records: deleteRecords, save_path: save_path })
|
||
.then(response => {
|
||
return { fid: fid, success: response.data.code === 0, deleted_records: response.data.deleted_records || 0 };
|
||
})
|
||
.catch(error => {
|
||
return { fid: fid, success: false, deleted_records: 0 };
|
||
});
|
||
});
|
||
|
||
// 等待所有删除请求完成
|
||
Promise.all(deletePromises)
|
||
.then(results => {
|
||
// 统计成功和失败的数量
|
||
const successCount = results.filter(r => r.success).length;
|
||
const failCount = results.length - successCount;
|
||
// 统计删除的记录数
|
||
const totalDeletedRecords = results.reduce((sum, r) => sum + (r.deleted_records || 0), 0);
|
||
|
||
// 从文件列表中移除成功删除的文件
|
||
const successfullyDeletedFids = results.filter(r => r.success).map(r => r.fid);
|
||
this.fileSelect.fileList = this.fileSelect.fileList.filter(item => !successfullyDeletedFids.includes(item.fid));
|
||
|
||
// 清空选中文件列表
|
||
this.fileSelect.selectedFiles = [];
|
||
this.fileSelect.lastSelectedFileIndex = -1;
|
||
|
||
// 显示结果
|
||
if (failCount > 0) {
|
||
alert(`成功删除 ${successCount} 个项目,${failCount} 个项目删除失败`);
|
||
} else {
|
||
// 根据是否删除记录显示不同的消息
|
||
if (deleteRecords) {
|
||
this.showToast(`成功删除 ${successCount} 个项目${totalDeletedRecords > 0 ? `及其关联的 ${totalDeletedRecords} 条记录` : ''}`);
|
||
} else {
|
||
this.showToast(`成功删除 ${successCount} 个项目`);
|
||
}
|
||
}
|
||
|
||
// 如果同时删除了记录,无论当前在哪个页面,都刷新历史记录
|
||
if (deleteRecords) {
|
||
this.loadHistoryRecords();
|
||
}
|
||
// 批量删除:仅当删除了记录时再刷新任务/日历
|
||
if (deleteRecords && totalDeletedRecords > 0) {
|
||
(async () => {
|
||
try {
|
||
const latestRes = await axios.get('/task_latest_info');
|
||
if (latestRes.data && latestRes.data.success) {
|
||
const latestFiles = latestRes.data.data.latest_files || {};
|
||
this.taskLatestFiles = latestFiles;
|
||
this.taskLatestRecords = latestRes.data.data.latest_records || {};
|
||
this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles);
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
}
|
||
} catch (e) {}
|
||
try {
|
||
const tasksResponse = await axios.get('/api/calendar/tasks');
|
||
if (tasksResponse.data && tasksResponse.data.success) {
|
||
this.calendar.tasks = tasksResponse.data.data.tasks || [];
|
||
this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name);
|
||
const rawTypes = tasksResponse.data.data.content_types || [];
|
||
this.updateContentTypes(rawTypes);
|
||
this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName);
|
||
}
|
||
} catch (e) {}
|
||
try {
|
||
await this.loadCalendarEpisodesLocal();
|
||
this.initializeCalendarDates();
|
||
await this.loadTodayUpdatesLocal();
|
||
} catch (e) {}
|
||
})();
|
||
}
|
||
});
|
||
}
|
||
},
|
||
handleModalOutsideClick(event) {
|
||
// 如果当前不是文件选择模式或者没有选中的文件,则不处理
|
||
if (this.fileSelect.previewRegex || this.fileSelect.selectShare || this.fileSelect.selectedFiles.length === 0) {
|
||
return;
|
||
}
|
||
|
||
// 检查点击是否在表格内
|
||
const tableElement = document.querySelector('#fileSelectModal .table');
|
||
|
||
// 如果点击不在表格内,则清除选择
|
||
if (tableElement && !tableElement.contains(event.target)) {
|
||
this.fileSelect.selectedFiles = [];
|
||
this.fileSelect.lastSelectedFileIndex = -1;
|
||
}
|
||
},
|
||
preventTextSelection(event, isDir) {
|
||
// 如果是文件夹,不阻止默认行为
|
||
if (isDir) return;
|
||
|
||
// 如果是Shift点击或Ctrl/Cmd点击,阻止文本选择
|
||
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
||
event.preventDefault();
|
||
}
|
||
},
|
||
|
||
// 重置文件夹内容(删除所有文件和记录)
|
||
resetFolderContent(index) {
|
||
// 获取当前任务的保存路径和任务名称
|
||
let savePath, taskName;
|
||
|
||
if (index === -1) {
|
||
// 创建任务模态框的情况
|
||
savePath = this.createTask.taskData.savepath;
|
||
taskName = this.createTask.taskData.taskname;
|
||
} else {
|
||
// 任务列表的情况
|
||
savePath = this.formData.tasklist[index].savepath;
|
||
taskName = this.formData.tasklist[index].taskname;
|
||
}
|
||
|
||
if (!savePath) {
|
||
this.showToast('保存路径为空,无法重置');
|
||
return;
|
||
}
|
||
|
||
// 显示确认对话框
|
||
if (confirm(`确定要重置文件夹「${savePath}」吗?`)) {
|
||
// 显示加载状态
|
||
this.modalLoading = true;
|
||
|
||
// 调用后端API
|
||
axios.post('/reset_folder', {
|
||
save_path: savePath,
|
||
task_name: taskName,
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
})
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.showToast(`重置成功:删除了 ${response.data.deleted_files || 0} 个文件,${response.data.deleted_records || 0} 条记录`);
|
||
// 重置成功后,主动热更新最近转存文件与任务元数据,避免等待 SSE 或轮询
|
||
(async () => {
|
||
try {
|
||
const latestRes = await axios.get('/task_latest_info');
|
||
if (latestRes.data && latestRes.data.success) {
|
||
const latestFiles = latestRes.data.data.latest_files || {};
|
||
this.taskLatestFiles = latestFiles;
|
||
this.taskLatestRecords = latestRes.data.data.latest_records || {};
|
||
}
|
||
} catch (e) {}
|
||
// 重新加载任务元数据(海报与详细信息)
|
||
try { await this.loadTasklistMetadata(); } catch (e) {}
|
||
})();
|
||
// 如果当前是历史记录页面,刷新记录
|
||
if (this.activeTab === 'history') {
|
||
this.loadHistoryRecords();
|
||
}
|
||
} else {
|
||
alert(response.data.message || '重置文件夹失败');
|
||
}
|
||
this.modalLoading = false;
|
||
})
|
||
.catch(error => {
|
||
console.error('重置文件夹出错:', error);
|
||
alert('重置文件夹出错: ' + (error.response?.data?.message || error.message));
|
||
this.modalLoading = false;
|
||
});
|
||
}
|
||
},
|
||
refreshPlexLibrary(index) {
|
||
axios.post('/refresh_plex_library', { task_index: index })
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.showToast(response.data.message);
|
||
} else {
|
||
alert(response.data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
alert("刷新 Plex 媒体库失败: " + (error.response?.data?.message || error.message || "未知错误"));
|
||
});
|
||
},
|
||
refreshAlistDirectory(index) {
|
||
axios.post('/refresh_alist_directory', { task_index: index })
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.showToast(response.data.message);
|
||
} else {
|
||
alert(response.data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
alert("刷新 AList 目录失败: " + (error.response?.data?.message || error.message || "未知错误"));
|
||
});
|
||
},
|
||
// 文件整理页面刷新Plex媒体库
|
||
refreshFileManagerPlexLibrary() {
|
||
// 获取当前目录路径
|
||
const currentPath = this.getCurrentFolderPath();
|
||
|
||
axios.post('/refresh_filemanager_plex_library', {
|
||
folder_path: currentPath,
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
})
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.showToast(response.data.message);
|
||
} else {
|
||
alert(response.data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
alert("刷新 Plex 媒体库失败: " + (error.response?.data?.message || error.message || "未知错误"));
|
||
});
|
||
},
|
||
// 文件整理页面刷新AList目录
|
||
refreshFileManagerAlistDirectory() {
|
||
// 获取当前目录路径
|
||
const currentPath = this.getCurrentFolderPath();
|
||
|
||
axios.post('/refresh_filemanager_alist_directory', {
|
||
folder_path: currentPath,
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
})
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.showToast(response.data.message);
|
||
} else {
|
||
alert(response.data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
alert("刷新 AList 目录失败: " + (error.response?.data?.message || error.message || "未知错误"));
|
||
});
|
||
},
|
||
// 获取当前文件夹路径(相对于夸克网盘根目录)
|
||
getCurrentFolderPath() {
|
||
if (this.fileManager.currentFolder === 'root') {
|
||
return '/'; // 根目录返回 /,表示夸克网盘根目录
|
||
}
|
||
|
||
// 构建完整路径(相对于夸克网盘根目录)
|
||
let path = '';
|
||
for (const pathItem of this.fileManager.paths) {
|
||
path += '/' + pathItem.name;
|
||
}
|
||
return path || '/';
|
||
},
|
||
filterByTaskName(taskName, event) {
|
||
// 防止事件冒泡,避免触发行选择
|
||
if (event) {
|
||
event.stopPropagation();
|
||
}
|
||
|
||
// 如果当前已经筛选了该任务,则取消筛选
|
||
if (this.historyTaskSelected === taskName) {
|
||
this.historyTaskSelected = "";
|
||
} else {
|
||
// 设置任务筛选值
|
||
this.historyTaskSelected = taskName;
|
||
}
|
||
|
||
// 重新加载记录
|
||
this.loadHistoryRecords();
|
||
},
|
||
navigateToFolder(folderId, folderName) {
|
||
this.fileManager.selectedFiles = []; // 清空选中的文件
|
||
|
||
if (folderId === 'root') {
|
||
// 回到根目录
|
||
this.fileManager.paths = [];
|
||
} else {
|
||
// 查找是否已经在路径中
|
||
const index = this.fileManager.paths.findIndex(path => path.fid === folderId);
|
||
|
||
if (index !== -1) {
|
||
// 如果已存在,截取到该位置
|
||
this.fileManager.paths = this.fileManager.paths.slice(0, index + 1);
|
||
} else {
|
||
// 否则添加到路径
|
||
this.fileManager.paths.push({
|
||
fid: folderId,
|
||
name: folderName
|
||
});
|
||
}
|
||
}
|
||
|
||
// 保存当前目录到localStorage(为每个账号单独保存)
|
||
localStorage.setItem(`quarkAutoSave_fileManagerLastFolder_${this.fileManager.selectedAccountIndex}`, folderId);
|
||
|
||
// 重置页码并加载新文件夹内容,不显示加载动画
|
||
this.fileManager.currentPage = 1;
|
||
this.loadFileListWithoutLoading(folderId);
|
||
},
|
||
selectFileForManager(event, file) {
|
||
// 如果是文件夹,不允许选择
|
||
if (file.dir) return;
|
||
|
||
// 如果是Shift+点击,选择范围
|
||
if (event.shiftKey && this.fileManager.selectedFiles.length > 0) {
|
||
// 找出所有已选中文件的索引
|
||
const selectedIndices = this.fileManager.selectedFiles.map(id =>
|
||
this.fileManager.fileList.findIndex(file => file.fid === id)
|
||
).filter(index => index !== -1); // 过滤掉未找到的文件
|
||
|
||
if (selectedIndices.length > 0) {
|
||
// 找出已选中文件中最靠前的索引
|
||
const earliestSelectedIndex = Math.min(...selectedIndices);
|
||
// 确定最终的选择范围
|
||
const startIndex = Math.min(earliestSelectedIndex, this.fileManager.fileList.indexOf(file));
|
||
const endIndex = Math.max(earliestSelectedIndex, this.fileManager.fileList.indexOf(file));
|
||
|
||
// 获取范围内所有文件的ID(排除文件夹)
|
||
this.fileManager.selectedFiles = this.fileManager.fileList
|
||
.slice(startIndex, endIndex + 1)
|
||
.filter(file => !file.dir) // 只选择文件,不选择文件夹
|
||
.map(file => file.fid);
|
||
} else {
|
||
// 如果没有有效的选中文件(可能是由于列表刷新),则只选择当前文件
|
||
this.fileManager.selectedFiles = [file.fid];
|
||
}
|
||
}
|
||
// 如果是Ctrl/Cmd+点击,切换单个文件选择状态
|
||
else if (event.ctrlKey || event.metaKey) {
|
||
if (this.fileManager.selectedFiles.includes(file.fid)) {
|
||
this.fileManager.selectedFiles = this.fileManager.selectedFiles.filter(id => id !== file.fid);
|
||
} else {
|
||
this.fileManager.selectedFiles.push(file.fid);
|
||
}
|
||
}
|
||
// 普通点击,清除当前选择并选择当前文件
|
||
else {
|
||
if (this.fileManager.selectedFiles.length === 1 && this.fileManager.selectedFiles.includes(file.fid)) {
|
||
this.fileManager.selectedFiles = [];
|
||
} else {
|
||
this.fileManager.selectedFiles = [file.fid];
|
||
}
|
||
}
|
||
|
||
// 更新最后选择的文件索引,只有在有选择文件时才更新
|
||
if (this.fileManager.selectedFiles.length > 0) {
|
||
this.fileManager.lastSelectedFileIndex = this.fileManager.fileList.indexOf(file);
|
||
} else {
|
||
this.fileManager.lastSelectedFileIndex = -1;
|
||
}
|
||
|
||
// 阻止事件冒泡
|
||
event.stopPropagation();
|
||
},
|
||
formatFileSize(fileSize) {
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
let size = fileSize;
|
||
let unitIndex = 0;
|
||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||
size /= 1024;
|
||
unitIndex++;
|
||
}
|
||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||
},
|
||
formatDate(date) {
|
||
// 将时间戳转换为年-月-日 时:分:秒格式
|
||
const d = new Date(date);
|
||
const year = d.getFullYear();
|
||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
const hours = String(d.getHours()).padStart(2, '0');
|
||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||
const seconds = String(d.getSeconds()).padStart(2, '0');
|
||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||
},
|
||
// 统一解析资源发布日期为时间戳
|
||
parsePublishTs(raw, source = null, pansouSource = null) {
|
||
if (!raw) return 0;
|
||
const s = String(raw).trim();
|
||
|
||
// 判断是否需要+8小时:
|
||
// 1. 如果是PanSou的tg来源,需要+8小时
|
||
// 2. 如果是CloudSaver来源,需要+8小时
|
||
// 3. 其他来源都不+8小时
|
||
const needAdd8Hours = (pansouSource && pansouSource.startsWith('tg:')) ||
|
||
(source === 'CloudSaver');
|
||
|
||
// YYYY-MM-DD HH:mm:ss
|
||
let m = /^\s*(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s*$/.exec(s);
|
||
if (m) {
|
||
const [, y, mo, d, h, mi, se] = m;
|
||
const date = new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se));
|
||
if (needAdd8Hours) {
|
||
date.setHours(date.getHours() + 8);
|
||
}
|
||
return date.getTime();
|
||
}
|
||
// YYYY-MM-DD
|
||
m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(s);
|
||
if (m) {
|
||
const [, y, mo, d] = m;
|
||
const date = new Date(Number(y), Number(mo) - 1, Number(d), 0, 0, 0);
|
||
if (needAdd8Hours) {
|
||
date.setHours(date.getHours() + 8);
|
||
}
|
||
return date.getTime();
|
||
}
|
||
// ISO 回退
|
||
const ts = Date.parse(s);
|
||
if (isNaN(ts)) return 0;
|
||
if (needAdd8Hours) {
|
||
const date = new Date(ts);
|
||
date.setHours(date.getHours() + 8);
|
||
return date.getTime();
|
||
}
|
||
return ts;
|
||
},
|
||
// 规范化资源发布日期展示:将 ISO 格式(含 T/Z/偏移)转为 "YYYY-MM-DD HH:mm:ss"
|
||
formatPublishDate(value, pansouSource = null, source = null) {
|
||
if (!value) return '';
|
||
const s = String(value).trim();
|
||
|
||
// 已是标准格式则直接返回
|
||
if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}$/.test(s)) return s;
|
||
|
||
// 优先匹配 ISO 主体部分
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/.exec(s);
|
||
if (m) {
|
||
const [, y, mo, d, h, mi, se] = m;
|
||
// 判断是否需要+8小时:
|
||
// 1. 如果是PanSou的tg来源,需要+8小时
|
||
// 2. 如果是CloudSaver来源,需要+8小时
|
||
// 3. 其他来源都不+8小时
|
||
const needAdd8Hours = (pansouSource && pansouSource.startsWith('tg:')) ||
|
||
(source === 'CloudSaver');
|
||
|
||
if (needAdd8Hours) {
|
||
// 创建Date对象并加上8小时
|
||
const date = new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se));
|
||
date.setHours(date.getHours() + 8);
|
||
return date.getFullYear() + '-' +
|
||
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||
String(date.getDate()).padStart(2, '0') + ' ' +
|
||
String(date.getHours()).padStart(2, '0') + ':' +
|
||
String(date.getMinutes()).padStart(2, '0') + ':' +
|
||
String(date.getSeconds()).padStart(2, '0');
|
||
}
|
||
return `${y}-${mo}-${d} ${h}:${mi}:${se}`;
|
||
}
|
||
|
||
// 回退:简单替换T为空格并去除尾部Z/时区偏移
|
||
let out = s.replace('T', ' ');
|
||
out = out.replace(/Z$/i, '');
|
||
out = out.replace(/([+-]\d{2}:?\d{2})$/i, '');
|
||
|
||
// 判断是否需要+8小时
|
||
const needAdd8Hours = (pansouSource && pansouSource.startsWith('tg:')) ||
|
||
(source === 'CloudSaver');
|
||
|
||
if (needAdd8Hours) {
|
||
try {
|
||
const date = new Date(out);
|
||
if (!isNaN(date.getTime())) {
|
||
date.setHours(date.getHours() + 8);
|
||
return date.getFullYear() + '-' +
|
||
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||
String(date.getDate()).padStart(2, '0') + ' ' +
|
||
String(date.getHours()).padStart(2, '0') + ':' +
|
||
String(date.getMinutes()).padStart(2, '0') + ':' +
|
||
String(date.getSeconds()).padStart(2, '0');
|
||
}
|
||
} catch (e) {
|
||
// 如果转换失败,返回原始处理结果
|
||
}
|
||
}
|
||
|
||
return out;
|
||
},
|
||
changeFolderPage(page) {
|
||
if (page < 1) page = 1;
|
||
if (page > this.fileManager.totalPages) page = this.fileManager.totalPages;
|
||
|
||
// 更新当前页码
|
||
this.fileManager.currentPage = page;
|
||
this.fileManager.gotoPage = page;
|
||
|
||
// 使用不显示加载动画的方式加载文件列表
|
||
this.loadFileListWithoutLoading(this.fileManager.currentFolder);
|
||
},
|
||
getVisibleFolderPageNumbers() {
|
||
const current = parseInt(this.fileManager.currentPage) || 1;
|
||
const total = parseInt(this.fileManager.totalPages) || 1;
|
||
// 根据屏幕宽度动态调整显示的页码数:移动端显示较少页码,桌面端显示较多页码
|
||
const isMobile = window.innerWidth <= 768;
|
||
const delta = isMobile ? 1 : 2; // 移动端左右各显示1个页码,桌面端左右各显示2个页码
|
||
|
||
// 处理特殊情况
|
||
if (total <= 1) return [];
|
||
|
||
let range = [];
|
||
|
||
// 移动端简化逻辑:只显示当前页附近的少数页码
|
||
if (isMobile) {
|
||
// 移动端:最多显示4个中间页码按钮(不包括第1页和最后1页),总共5个页码
|
||
if (current <= 4) {
|
||
// 当前页在前面时,显示 1, 2, 3, 4
|
||
for (let i = 2; i <= Math.min(4, total - 1); i++) {
|
||
range.push(i);
|
||
}
|
||
} else if (current >= total - 3) {
|
||
// 当前页在后面时,显示倒数几页
|
||
for (let i = Math.max(2, total - 3); i <= total - 1; i++) {
|
||
range.push(i);
|
||
}
|
||
} else {
|
||
// 当前页在中间时,显示当前页前后各1页,总共3个中间页码
|
||
range.push(current - 1);
|
||
range.push(current);
|
||
range.push(current + 1);
|
||
}
|
||
} else {
|
||
// 桌面端保持原有逻辑
|
||
let rangeStart = Math.max(2, current - delta);
|
||
let rangeEnd = Math.min(total - 1, current + delta);
|
||
|
||
// 调整范围,确保显示足够的页码
|
||
if (rangeEnd - rangeStart < delta * 2) {
|
||
if (current - rangeStart < delta) {
|
||
// 当前页靠近开始,扩展结束范围
|
||
rangeEnd = Math.min(total - 1, rangeStart + delta * 2);
|
||
} else {
|
||
// 当前页靠近结束,扩展开始范围
|
||
rangeStart = Math.max(2, rangeEnd - delta * 2);
|
||
}
|
||
}
|
||
|
||
// 生成页码数组
|
||
for (let i = rangeStart; i <= rangeEnd; i++) {
|
||
range.push(i);
|
||
}
|
||
}
|
||
|
||
return range;
|
||
},
|
||
isFilenameTruncated(file_name, index, field) {
|
||
if (!this.fileManager.fileList[index]) return false;
|
||
|
||
// 确保文本是字符串类型
|
||
const str = String(file_name || '');
|
||
|
||
// 先检查文本是否有足够长度,太短的文本肯定不需要展开
|
||
if (str.length <= 20) return false;
|
||
|
||
// 从文件的_isOverflowing属性中获取溢出状态
|
||
const file = this.fileManager.fileList[index];
|
||
|
||
// 如果已经展开了,那么我们认为它可能是需要截断的
|
||
if (file._expanded) {
|
||
return true;
|
||
}
|
||
|
||
return file._isOverflowing && file._isOverflowing[field];
|
||
},
|
||
toggleExpandFilename(file) {
|
||
// 切换展开状态
|
||
this.$set(file, '_expanded', !file._expanded);
|
||
|
||
// 在下一个DOM更新周期中强制重新检测溢出状态
|
||
this.$nextTick(() => {
|
||
// 确保_isOverflowing属性存在
|
||
if (!file._isOverflowing) {
|
||
this.$set(file, '_isOverflowing', {});
|
||
}
|
||
|
||
// 在收起状态下,通过设置为true确保展开按钮依然可见
|
||
if (!file._expanded) {
|
||
this.$set(file._isOverflowing, 'file_name', true);
|
||
}
|
||
});
|
||
|
||
// 确保视图更新
|
||
this.$forceUpdate();
|
||
},
|
||
renameFile(file) {
|
||
// 单个文件重命名
|
||
const newName = prompt('请输入新的文件名:', file.file_name);
|
||
if (newName && newName !== file.file_name) {
|
||
axios.post('/batch_rename', {
|
||
files: [{
|
||
file_id: file.fid,
|
||
new_name: newName
|
||
}],
|
||
// 添加账号索引参数,使用文件整理页面选中的账号
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
})
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.showToast('重命名成功');
|
||
// 刷新文件列表以确保缓存同步
|
||
this.refreshCurrentFolderCache();
|
||
// 如果命名预览模态框是打开的,也要刷新它
|
||
if ($('#fileSelectModal').hasClass('show')) {
|
||
this.showFileManagerNamingPreview(this.fileManager.currentFolder);
|
||
}
|
||
} else {
|
||
alert(response.data.message || '重命名失败');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('重命名文件失败:', error);
|
||
alert('重命名文件失败');
|
||
});
|
||
}
|
||
},
|
||
previewAndRename() {
|
||
// 显示文件整理页面的命名预览模态框
|
||
this.showFileManagerNamingPreview();
|
||
},
|
||
|
||
showFileManagerNamingPreview(folderId) {
|
||
// 显示文件整理页面的命名预览模态框(区别于任务配置的命名预览)
|
||
this.fileSelect.selectShare = false;
|
||
this.fileSelect.selectDir = true;
|
||
this.fileSelect.previewRegex = true;
|
||
this.fileSelect.fileList = [];
|
||
this.fileSelect.paths = [];
|
||
this.fileSelect.error = undefined;
|
||
this.fileSelect.selectedFiles = [];
|
||
// 预览默认按重命名列降序(与任务列表一致)
|
||
this.fileSelect.sortBy = "file_name_re";
|
||
this.fileSelect.sortOrder = "desc";
|
||
// 展示当前文件夹的文件
|
||
const currentFolderId = folderId || this.fileManager.currentFolder;
|
||
// 模拟一个任务,用于预览
|
||
const previewTask = {
|
||
pattern: this.fileManager.pattern,
|
||
replace: this.fileManager.replace,
|
||
use_sequence_naming: this.fileManager.use_sequence_naming,
|
||
use_episode_naming: this.fileManager.use_episode_naming,
|
||
filterwords: this.fileManager.filterwords
|
||
};
|
||
// 存储临时任务索引
|
||
this.fileSelect.index = -1; // 使用-1表示这是文件管理器的预览
|
||
// 设置模态框标题类型
|
||
document.getElementById('fileSelectModal').setAttribute('data-modal-type', 'preview-filemanager');
|
||
// 显示模态框
|
||
if (!folderId) $('#fileSelectModal').modal('toggle'); // 仅首次打开时toggle
|
||
// 获取文件列表并处理重命名预览
|
||
this.modalLoading = true;
|
||
// 获取重命名预览结果
|
||
axios.get('/preview_rename', {
|
||
params: {
|
||
folder_id: currentFolderId,
|
||
pattern: this.fileManager.pattern,
|
||
replace: this.fileManager.replace,
|
||
naming_mode: this.fileManager.use_sequence_naming ? 'sequence' : (this.fileManager.use_episode_naming ? 'episode' : 'regex'),
|
||
include_folders: this.fileManager.include_folders,
|
||
filterwords: this.fileManager.filterwords,
|
||
// 添加页面大小参数,获取所有文件
|
||
page_size: 99999,
|
||
// 添加账号索引参数,使用文件整理页面选中的账号
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
}
|
||
})
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
// 处理预览结果
|
||
const previewData = response.data.data || [];
|
||
|
||
// 获取当前文件夹信息,包括路径
|
||
axios.get('/file_list', {
|
||
params: {
|
||
folder_id: currentFolderId,
|
||
sort_by: this.fileSelect.sortBy,
|
||
order: this.fileSelect.sortOrder,
|
||
// 使用一个较大的值以获取全部文件,不使用分页
|
||
page_size: 99999,
|
||
page: 1,
|
||
// 同时添加文件夹包含参数
|
||
include_folders: this.fileManager.include_folders,
|
||
// 添加账号索引参数,使用文件整理页面选中的账号
|
||
account_index: this.fileManager.selectedAccountIndex,
|
||
// 强制刷新缓存
|
||
force_refresh: true
|
||
}
|
||
})
|
||
.then(listResponse => {
|
||
if (listResponse.data.success) {
|
||
// 获取文件列表和路径
|
||
const fileList = listResponse.data.data.list;
|
||
this.fileSelect.paths = listResponse.data.data.paths || [];
|
||
|
||
// 将重命名结果添加到文件列表
|
||
fileList.forEach(file => {
|
||
// 尝试通过ID匹配预览结果
|
||
let previewItem = previewData.find(item => item.file_id === file.fid);
|
||
|
||
// 如果通过ID没有匹配到,尝试通过文件名匹配
|
||
if (!previewItem) {
|
||
previewItem = previewData.find(item =>
|
||
item.original_name === file.file_name &&
|
||
(!item.file_id || item.file_id === file.fid)
|
||
);
|
||
}
|
||
|
||
if (previewItem) {
|
||
// 设置重命名字段
|
||
file.file_name_re = previewItem.new_name;
|
||
// 设置集数字段用于排序
|
||
file.episode_number = previewItem.episode_number;
|
||
} else {
|
||
file.file_name_re = null;
|
||
file.episode_number = undefined;
|
||
}
|
||
});
|
||
|
||
// 更新文件列表
|
||
this.fileSelect.fileList = fileList;
|
||
|
||
// 按照重命名结果排序,使用统一的排序逻辑
|
||
if (this.fileSelect.sortBy === 'file_name_re') {
|
||
this.manualSortFileList('file_name_re', this.fileSelect.sortOrder);
|
||
}
|
||
} else {
|
||
this.fileSelect.error = listResponse.data.message || '获取文件列表失败';
|
||
}
|
||
this.modalLoading = false;
|
||
|
||
// 检查滚动条
|
||
this.$nextTick(() => {
|
||
this.checkPreviewScrollbar();
|
||
});
|
||
})
|
||
.catch(error => {
|
||
console.error('获取文件列表失败:', error);
|
||
this.fileSelect.error = '获取文件列表失败,请稍后再试';
|
||
this.modalLoading = false;
|
||
});
|
||
} else {
|
||
this.fileSelect.error = response.data.message || '获取预览失败';
|
||
this.modalLoading = false;
|
||
}
|
||
// 判断当前目录下是否还有可撤销的rename记录
|
||
axios.get('/api/has_rename_record', {
|
||
params: { save_path: currentFolderId }
|
||
}).then(resp => {
|
||
this.fileSelect.canUndoRename = !!(resp.data && resp.data.has_rename);
|
||
}).catch(() => {
|
||
this.fileSelect.canUndoRename = false;
|
||
});
|
||
})
|
||
.catch(error => {
|
||
console.error('获取预览失败:', error);
|
||
this.fileSelect.error = '获取预览失败,请稍后再试';
|
||
this.modalLoading = false;
|
||
});
|
||
},
|
||
// showBatchRenameModal方法已删除,功能已整合到showFileManagerNamingPreview中
|
||
// confirmBatchRename方法已删除,功能已整合到applyPreviewRename中
|
||
deleteFile(file) {
|
||
if (confirm(`确定要删除${file.dir ? '文件夹' : '文件'} "${file.file_name}" 吗?`)) {
|
||
// 获取当前路径作为save_path参数
|
||
let save_path = "";
|
||
if (this.fileManager && this.fileManager.paths) {
|
||
save_path = this.fileManager.paths.map(item => item.name).join("/");
|
||
}
|
||
|
||
axios.post('/delete_file', {
|
||
fid: file.fid,
|
||
file_name: file.file_name,
|
||
delete_records: false,
|
||
save_path: save_path,
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
})
|
||
.then(response => {
|
||
if (response.data.code === 0) {
|
||
this.showToast('成功删除 1 个项目');
|
||
// 刷新文件列表以确保缓存同步
|
||
this.refreshCurrentFolderCache();
|
||
// 如果命名预览模态框是打开的,也要刷新它
|
||
if ($('#fileSelectModal').hasClass('show')) {
|
||
this.showFileManagerNamingPreview(this.fileManager.currentFolder);
|
||
}
|
||
} else {
|
||
alert(response.data.message || '删除失败');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('删除文件失败:', error);
|
||
alert('删除文件失败');
|
||
});
|
||
}
|
||
},
|
||
// 开始重命名文件
|
||
startRenameFile(file) {
|
||
// 设置编辑状态
|
||
this.$set(file, '_editing', true);
|
||
this.$set(file, '_editingName', file.file_name);
|
||
|
||
// 下一个tick后聚焦输入框并全选文本
|
||
this.$nextTick(() => {
|
||
const input = this.$refs['renameInput_' + file.fid];
|
||
if (input) {
|
||
input.focus();
|
||
input.select();
|
||
}
|
||
});
|
||
},
|
||
// 开始移动文件
|
||
startMoveFile(file) {
|
||
// 如果是文件夹,不允许移动
|
||
if (file.dir) {
|
||
this.showToast('暂不支持移动文件夹');
|
||
return;
|
||
}
|
||
|
||
// 设置移动模式相关参数
|
||
this.fileSelect.moveMode = true;
|
||
this.fileSelect.moveFileIds = this.fileManager.selectedFiles.length > 0 && this.fileManager.selectedFiles.includes(file.fid)
|
||
? this.fileManager.selectedFiles
|
||
: [file.fid];
|
||
this.fileSelect.selectShare = false;
|
||
this.fileSelect.selectDir = true;
|
||
this.fileSelect.previewRegex = false;
|
||
this.fileSelect.error = undefined;
|
||
this.fileSelect.fileList = [];
|
||
this.fileSelect.paths = [];
|
||
this.fileSelect.index = -1;
|
||
|
||
// 重置排序状态为默认值 - 选择移动目标文件夹模态框默认修改时间倒序
|
||
this.fileSelect.sortBy = "updated_at";
|
||
this.fileSelect.sortOrder = "desc";
|
||
|
||
// 设置模态框类型为move(移动目标文件夹)
|
||
document.getElementById('fileSelectModal').setAttribute('data-modal-type', 'move');
|
||
|
||
$('#fileSelectModal').modal('show');
|
||
|
||
// 加载根目录
|
||
this.getSavepathDetail(0);
|
||
},
|
||
// 保存重命名
|
||
saveRenameFile(file) {
|
||
if (!file._editing) return;
|
||
|
||
const newName = file._editingName.trim();
|
||
|
||
// 如果名称没有变化,直接取消编辑
|
||
if (newName === file.file_name) {
|
||
this.cancelRenameFile(file);
|
||
return;
|
||
}
|
||
|
||
// 如果新名称为空,提示错误并恢复原始文件名,然后退出编辑状态
|
||
if (!newName) {
|
||
this.showToast('文件名不能为空');
|
||
// 恢复原始文件名并退出编辑状态
|
||
this.cancelRenameFile(file);
|
||
return;
|
||
}
|
||
|
||
// 调用重命名API
|
||
axios.post('/batch_rename', {
|
||
files: [{
|
||
file_id: file.fid,
|
||
new_name: newName,
|
||
old_name: file.file_name
|
||
}],
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
})
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
// 更新文件名
|
||
file.file_name = newName;
|
||
this.showToast('重命名成功');
|
||
// 刷新文件列表以确保缓存同步
|
||
this.refreshCurrentFolderCache();
|
||
// 如果命名预览模态框是打开的,也要刷新它
|
||
if ($('#fileSelectModal').hasClass('show')) {
|
||
this.showFileManagerNamingPreview(this.fileManager.currentFolder);
|
||
}
|
||
} else {
|
||
this.showToast(response.data.message || '重命名失败');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('重命名文件失败:', error);
|
||
this.showToast('重命名文件失败');
|
||
})
|
||
.finally(() => {
|
||
// 无论成功失败都退出编辑状态
|
||
this.cancelRenameFile(file);
|
||
});
|
||
},
|
||
// 取消重命名
|
||
cancelRenameFile(file) {
|
||
this.$set(file, '_editing', false);
|
||
this.$set(file, '_editingName', '');
|
||
},
|
||
// 处理文件行点击事件
|
||
handleFileRowClick(file, event) {
|
||
// 如果文件正在编辑状态,不处理点击事件
|
||
if (file._editing) {
|
||
return;
|
||
}
|
||
|
||
// 检查是否有任何文件正在编辑状态
|
||
const editingFile = this.fileManager.fileList.find(f => f._editing);
|
||
if (editingFile) {
|
||
// 如果有其他文件正在编辑,先保存编辑状态
|
||
this.saveRenameFile(editingFile);
|
||
return;
|
||
}
|
||
|
||
// 正常处理点击事件
|
||
if (file.dir) {
|
||
this.navigateToFolder(file.fid, file.file_name);
|
||
} else {
|
||
this.selectFileForManager(event, file);
|
||
}
|
||
},
|
||
loadFileList(folderId) {
|
||
this.fileManager.currentFolder = folderId || 'root';
|
||
|
||
const params = {
|
||
folder_id: folderId || 'root',
|
||
sort_by: this.fileManager.sortBy,
|
||
order: this.fileManager.sortOrder,
|
||
page_size: this.fileManager.pageSize,
|
||
page: this.fileManager.currentPage,
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
};
|
||
|
||
axios.get('/file_list', { params })
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.fileManager.fileList = response.data.data.list;
|
||
this.fileManager.total = response.data.data.total;
|
||
this.fileManager.totalPages = Math.ceil(response.data.data.total / this.fileManager.pageSize);
|
||
this.fileManager.paths = response.data.data.paths || [];
|
||
this.fileManager.gotoPage = this.fileManager.currentPage;
|
||
} else {
|
||
alert(response.data.message || '获取文件列表失败');
|
||
}
|
||
this.fileManager.hasLoaded = true;
|
||
})
|
||
.catch(error => {
|
||
console.error('获取文件列表失败:', error);
|
||
this.fileManager.hasLoaded = true;
|
||
});
|
||
},
|
||
sortFiles(field) {
|
||
if (this.fileManager.sortBy === field) {
|
||
// 如果已经按此字段排序,则切换排序方向
|
||
this.fileManager.sortOrder = this.fileManager.sortOrder === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
// 否则切换排序字段,并设置默认排序方向
|
||
this.fileManager.sortBy = field;
|
||
// 文件名默认按字母升序,日期和大小默认按降序
|
||
this.fileManager.sortOrder = field === 'file_name' ? 'asc' : 'desc';
|
||
}
|
||
|
||
// 保存排序设置到localStorage
|
||
const sortSettings = {
|
||
sortBy: this.fileManager.sortBy,
|
||
sortOrder: this.fileManager.sortOrder
|
||
};
|
||
localStorage.setItem('quarkAutoSave_fileManagerSort', JSON.stringify(sortSettings));
|
||
|
||
// 使用不显示加载动画的方式加载文件列表
|
||
this.loadFileListWithoutLoading(this.fileManager.currentFolder);
|
||
},
|
||
changeFileManagerPageSize(size) {
|
||
// 先更新内存中的值
|
||
this.fileManager.pageSize = size === 'all' ? 99999 : size;
|
||
this.fileManager.currentPage = 1;
|
||
|
||
// 然后保存到localStorage
|
||
localStorage.setItem('quarkAutoSave_fileManagerPageSize', size.toString());
|
||
|
||
// 最后重新加载文件列表
|
||
this.loadFileListWithoutLoading(this.fileManager.currentFolder);
|
||
},
|
||
navigateToFolder(folderId, folderName) {
|
||
this.fileManager.selectedFiles = []; // 清空选中的文件
|
||
|
||
if (folderId === 'root') {
|
||
// 回到根目录
|
||
this.fileManager.paths = [];
|
||
} else {
|
||
// 查找是否已经在路径中
|
||
const index = this.fileManager.paths.findIndex(path => path.fid === folderId);
|
||
|
||
if (index !== -1) {
|
||
// 如果已存在,截取到该位置
|
||
this.fileManager.paths = this.fileManager.paths.slice(0, index + 1);
|
||
} else {
|
||
// 否则添加到路径
|
||
this.fileManager.paths.push({
|
||
fid: folderId,
|
||
name: folderName
|
||
});
|
||
}
|
||
}
|
||
|
||
// 保存当前目录到localStorage(为每个账号单独保存)
|
||
localStorage.setItem(`quarkAutoSave_fileManagerLastFolder_${this.fileManager.selectedAccountIndex}`, folderId);
|
||
|
||
// 重置页码并加载新文件夹内容,不显示加载动画
|
||
this.fileManager.currentPage = 1;
|
||
this.loadFileListWithoutLoading(folderId);
|
||
},
|
||
|
||
// 添加一个不显示加载动画的文件列表加载方法
|
||
loadFileListWithoutLoading(folderId) {
|
||
this.fileManager.currentFolder = folderId || 'root';
|
||
|
||
// 从localStorage读取分页大小设置(仅在初始化时使用)
|
||
// 在changeFileManagerPageSize方法中,我们已经确保了正确的执行顺序
|
||
const savedFileManagerPageSize = localStorage.getItem('quarkAutoSave_fileManagerPageSize');
|
||
if (savedFileManagerPageSize && this.fileManager.pageSize === 15) { // 只在默认值时读取
|
||
this.fileManager.pageSize = savedFileManagerPageSize === 'all' ? 99999 : parseInt(savedFileManagerPageSize);
|
||
}
|
||
|
||
const params = {
|
||
folder_id: folderId || 'root',
|
||
sort_by: this.fileManager.sortBy,
|
||
order: this.fileManager.sortOrder,
|
||
page_size: this.fileManager.pageSize,
|
||
page: this.fileManager.currentPage,
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
};
|
||
|
||
axios.get('/file_list', { params })
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.fileManager.fileList = response.data.data.list;
|
||
this.fileManager.total = response.data.data.total;
|
||
this.fileManager.totalPages = Math.ceil(response.data.data.total / this.fileManager.pageSize);
|
||
this.fileManager.paths = response.data.data.paths || [];
|
||
this.fileManager.gotoPage = this.fileManager.currentPage;
|
||
} else {
|
||
console.error('获取文件列表失败:', response.data.message);
|
||
}
|
||
|
||
// 检测当前的命名模式
|
||
this.detectFileManagerNamingMode();
|
||
this.fileManager.hasLoaded = true;
|
||
})
|
||
.catch(error => {
|
||
console.error('获取文件列表失败:', error);
|
||
this.fileManager.hasLoaded = true;
|
||
});
|
||
},
|
||
|
||
// 添加带fallback机制的文件列表加载方法,用于账号切换
|
||
loadFileListWithFallback(folderId) {
|
||
this.fileManager.currentFolder = folderId || 'root';
|
||
|
||
const params = {
|
||
folder_id: folderId || 'root',
|
||
sort_by: this.fileManager.sortBy,
|
||
order: this.fileManager.sortOrder,
|
||
page_size: this.fileManager.pageSize,
|
||
page: this.fileManager.currentPage,
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
};
|
||
|
||
axios.get('/file_list', { params })
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.fileManager.fileList = response.data.data.list;
|
||
this.fileManager.total = response.data.data.total;
|
||
this.fileManager.totalPages = Math.ceil(response.data.data.total / this.fileManager.pageSize);
|
||
this.fileManager.paths = response.data.data.paths || [];
|
||
this.fileManager.gotoPage = this.fileManager.currentPage;
|
||
|
||
// 检测当前的命名模式
|
||
this.detectFileManagerNamingMode();
|
||
} else {
|
||
// 如果获取文件列表失败,可能是目录在新账号中不存在
|
||
console.warn('当前目录在新账号中不存在,自动切换到根目录:', response.data.message);
|
||
this.fallbackToRoot();
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('获取文件列表失败,自动切换到根目录:', error);
|
||
this.fallbackToRoot();
|
||
});
|
||
},
|
||
|
||
// fallback到根目录的方法
|
||
fallbackToRoot() {
|
||
this.fileManager.currentFolder = 'root';
|
||
this.fileManager.paths = [];
|
||
this.fileManager.currentPage = 1;
|
||
|
||
// 更新localStorage中的当前目录(为每个账号单独保存)
|
||
localStorage.setItem(`quarkAutoSave_fileManagerLastFolder_${this.fileManager.selectedAccountIndex}`, 'root');
|
||
|
||
// 重新加载根目录
|
||
this.loadFileListWithoutLoading('root');
|
||
},
|
||
getVisibleFolderPageNumbers() {
|
||
const current = parseInt(this.fileManager.currentPage) || 1;
|
||
const total = parseInt(this.fileManager.totalPages) || 1;
|
||
// 根据屏幕宽度动态调整显示的页码数:移动端显示较少页码,桌面端显示较多页码
|
||
const isMobile = window.innerWidth <= 768;
|
||
const delta = isMobile ? 1 : 2; // 移动端左右各显示1个页码,桌面端左右各显示2个页码
|
||
|
||
// 处理特殊情况
|
||
if (total <= 1) return [];
|
||
|
||
let range = [];
|
||
|
||
// 移动端简化逻辑:只显示当前页附近的少数页码
|
||
if (isMobile) {
|
||
// 移动端:最多显示4个中间页码按钮(不包括第1页和最后1页),总共5个页码
|
||
if (current <= 4) {
|
||
// 当前页在前面时,显示 1, 2, 3, 4
|
||
for (let i = 2; i <= Math.min(4, total - 1); i++) {
|
||
range.push(i);
|
||
}
|
||
} else if (current >= total - 3) {
|
||
// 当前页在后面时,显示倒数几页
|
||
for (let i = Math.max(2, total - 3); i <= total - 1; i++) {
|
||
range.push(i);
|
||
}
|
||
} else {
|
||
// 当前页在中间时,显示当前页前后各1页,总共3个中间页码
|
||
range.push(current - 1);
|
||
range.push(current);
|
||
range.push(current + 1);
|
||
}
|
||
} else {
|
||
// 桌面端保持原有逻辑
|
||
let rangeStart = Math.max(2, current - delta);
|
||
let rangeEnd = Math.min(total - 1, current + delta);
|
||
|
||
// 调整范围,确保显示足够的页码
|
||
if (rangeEnd - rangeStart < delta * 2) {
|
||
if (current - rangeStart < delta) {
|
||
// 当前页靠近开始,扩展结束范围
|
||
rangeEnd = Math.min(total - 1, rangeStart + delta * 2);
|
||
} else {
|
||
// 当前页靠近结束,扩展开始范围
|
||
rangeStart = Math.max(2, rangeEnd - delta * 2);
|
||
}
|
||
}
|
||
|
||
// 生成页码数组
|
||
for (let i = rangeStart; i <= rangeEnd; i++) {
|
||
range.push(i);
|
||
}
|
||
}
|
||
|
||
return range;
|
||
},
|
||
// cancelBatchRename和applyBatchRename方法已删除,功能已整合到fileSelectModal中
|
||
handleFileManagerOutsideClick(event) {
|
||
// 如果当前不是文件整理页面或者没有选中的文件,则不处理
|
||
if (this.activeTab !== 'filemanager' || this.fileManager.selectedFiles.length === 0) {
|
||
return;
|
||
}
|
||
|
||
// 如果有文件正在编辑状态,不处理文件选择逻辑,让重命名处理器优先处理
|
||
const editingFile = this.fileManager.fileList.find(file => file._editing);
|
||
if (editingFile) {
|
||
return;
|
||
}
|
||
|
||
// 检查点击是否在表格内
|
||
const tableElement = document.querySelector('.table.selectable-files');
|
||
// 检查点击是否在分页控制区域
|
||
const paginationElement = document.querySelector('.pagination-container');
|
||
|
||
// 如果点击不在表格内且不在分页控制区域内,则清除选择
|
||
if (tableElement && !tableElement.contains(event.target) &&
|
||
paginationElement && !paginationElement.contains(event.target)) {
|
||
this.fileManager.selectedFiles = [];
|
||
this.fileManager.lastSelectedFileIndex = -1;
|
||
}
|
||
},
|
||
deleteSelectedFilesForManager(clickedFid, clickedFname, isDir, deleteRecords = false) {
|
||
// 如果是文件夹或者没有选中的文件,则按原来的方式删除单个文件
|
||
if (isDir || this.fileManager.selectedFiles.length === 0) {
|
||
this.deleteFile({ fid: clickedFid, file_name: clickedFname, dir: isDir });
|
||
return;
|
||
}
|
||
|
||
// 如果点击的文件不在选中列表中,也按原来的方式删除单个文件
|
||
if (!this.fileManager.selectedFiles.includes(clickedFid)) {
|
||
this.deleteFile({ fid: clickedFid, file_name: clickedFname, dir: isDir });
|
||
return;
|
||
}
|
||
|
||
// 多选删除
|
||
const selectedCount = this.fileManager.selectedFiles.length;
|
||
|
||
// 根据选中数量使用不同的确认提示
|
||
let confirmMessage = selectedCount === 1
|
||
? `确定要删除此项目吗?`
|
||
: `确定要删除选中的 ${selectedCount} 个项目吗?`;
|
||
|
||
if (confirm(confirmMessage)) {
|
||
// 获取当前路径作为save_path参数
|
||
let save_path = "";
|
||
if (this.fileManager && this.fileManager.paths) {
|
||
save_path = this.fileManager.paths.map(item => item.name).join("/");
|
||
}
|
||
|
||
// 创建一个Promise数组来处理所有删除请求
|
||
const deletePromises = this.fileManager.selectedFiles.map(fid => {
|
||
// 查找对应的文件对象,获取文件名
|
||
const fileObj = this.fileManager.fileList.find(file => file.fid === fid);
|
||
const fileName = fileObj ? fileObj.file_name : '';
|
||
|
||
return axios.post('/delete_file', {
|
||
fid: fid,
|
||
file_name: fileName,
|
||
delete_records: deleteRecords,
|
||
save_path: save_path,
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
})
|
||
.then(response => {
|
||
return { fid: fid, success: response.data.code === 0, deleted_records: response.data.deleted_records || 0 };
|
||
})
|
||
.catch(error => {
|
||
return { fid: fid, success: false, deleted_records: 0 };
|
||
});
|
||
});
|
||
|
||
// 等待所有删除请求完成
|
||
Promise.all(deletePromises)
|
||
.then(results => {
|
||
// 统计成功和失败的数量
|
||
const successCount = results.filter(r => r.success).length;
|
||
const failCount = results.length - successCount;
|
||
|
||
// 显示结果
|
||
if (failCount > 0) {
|
||
alert(`成功删除 ${successCount} 个项目,${failCount} 个项目删除失败`);
|
||
} else {
|
||
this.showToast(`成功删除 ${successCount} 个项目`);
|
||
}
|
||
|
||
// 如果有成功删除的文件,刷新文件列表以确保缓存同步
|
||
if (successCount > 0) {
|
||
this.refreshCurrentFolderCache();
|
||
// 如果命名预览模态框是打开的,也要刷新它
|
||
if ($('#fileSelectModal').hasClass('show')) {
|
||
this.showFileManagerNamingPreview(this.fileManager.currentFolder);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
},
|
||
detectFileManagerNamingMode() {
|
||
// 检测是否为顺序命名模式或剧集命名模式
|
||
const currentValue = this.fileManager.pattern;
|
||
|
||
if (currentValue !== undefined) {
|
||
// 检查是否包含完整的{}占位符(确保不是分开的{和})
|
||
const isSequenceNaming = currentValue.includes('{}');
|
||
|
||
// 如果不是顺序命名模式,检查是否包含完整的[]占位符
|
||
const isEpisodeNaming = !isSequenceNaming && currentValue.includes('[]');
|
||
|
||
// 处理模式切换
|
||
if (isSequenceNaming) {
|
||
// 切换到顺序命名模式
|
||
this.fileManager.use_sequence_naming = true;
|
||
this.fileManager.use_episode_naming = false;
|
||
} else if (isEpisodeNaming) {
|
||
// 切换到剧集命名模式
|
||
this.fileManager.use_sequence_naming = false;
|
||
this.fileManager.use_episode_naming = true;
|
||
} else {
|
||
// 切换到正则命名模式
|
||
this.fileManager.use_sequence_naming = false;
|
||
this.fileManager.use_episode_naming = false;
|
||
}
|
||
}
|
||
},
|
||
cancelPreviewRename() {
|
||
$('#fileSelectModal').modal('hide');
|
||
},
|
||
async applyPreviewRename() {
|
||
if (this.modalLoading) return;
|
||
this.modalLoading = true;
|
||
try {
|
||
const renameList = this.fileSelect.fileList.filter(f => f.file_name_re && f.file_name_re !== '×' && !f.file_name_re.startsWith('×'));
|
||
if (renameList.length === 0) {
|
||
this.showToast('没有可重命名的项目');
|
||
this.modalLoading = false;
|
||
return;
|
||
}
|
||
// 只传 file_id 和 new_name,并加上 save_path
|
||
const files = renameList.map(f => ({
|
||
file_id: f.fid,
|
||
new_name: f.file_name_re,
|
||
old_name: f.file_name // 传递原文件名,便于撤销
|
||
}));
|
||
const save_path = this.fileManager.currentFolder;
|
||
const response = await axios.post('/batch_rename', {
|
||
files,
|
||
save_path,
|
||
// 添加账号索引参数,使用文件整理页面选中的账号
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
});
|
||
if (response.data.success) {
|
||
this.showToast(`成功重命名 ${response.data.success_count || files.length} 个项目`);
|
||
// 强制刷新文件列表缓存
|
||
this.refreshCurrentFolderCache();
|
||
$('#fileSelectModal').modal('hide');
|
||
this.fileSelect.canUndoRename = true; // 重命名后立即可撤销
|
||
} else {
|
||
this.showToast(response.data.message || '重命名失败');
|
||
}
|
||
} catch (e) {
|
||
this.showToast('重命名请求失败');
|
||
}
|
||
this.modalLoading = false;
|
||
},
|
||
async undoPreviewRename() {
|
||
if (this.modalLoading) return;
|
||
this.modalLoading = true;
|
||
try {
|
||
const save_path = this.fileManager.currentFolder;
|
||
const response = await axios.post('/undo_rename', {
|
||
save_path,
|
||
// 添加账号索引参数,使用文件整理页面选中的账号
|
||
account_index: this.fileManager.selectedAccountIndex
|
||
});
|
||
if (response.data.success) {
|
||
this.showToast(`成功撤销 ${response.data.success_count} 个项目重命名`);
|
||
this.showFileManagerNamingPreview(save_path);
|
||
// 强制刷新文件列表缓存
|
||
this.refreshCurrentFolderCache();
|
||
// 撤销后如无可撤销项再设为false(由showFileManagerNamingPreview刷新)
|
||
} else {
|
||
this.showToast(response.data.message || '撤销失败');
|
||
this.fileSelect.canUndoRename = false;
|
||
}
|
||
} catch (e) {
|
||
this.showToast('撤销失败: ' + (e.message || e));
|
||
this.fileSelect.canUndoRename = false;
|
||
}
|
||
this.modalLoading = false;
|
||
},
|
||
// 影视发现相关方法
|
||
selectMainCategory(categoryKey) {
|
||
this.discovery.selectedMainCategory = categoryKey;
|
||
// 保存主榜单选择到localStorage
|
||
localStorage.setItem('discovery_main_category', categoryKey);
|
||
|
||
// 重置子分类为第一个选项
|
||
const subCategories = this.discovery.subCategories[categoryKey];
|
||
if (subCategories && subCategories.length > 0) {
|
||
this.discovery.selectedSubCategory = subCategories[0].key;
|
||
// 保存子榜单选择到localStorage
|
||
localStorage.setItem('discovery_sub_category', subCategories[0].key);
|
||
}
|
||
// 加载新的榜单数据
|
||
this.loadDiscoveryData();
|
||
},
|
||
selectSubCategory(subCategoryKey) {
|
||
this.discovery.selectedSubCategory = subCategoryKey;
|
||
// 保存子榜单选择到localStorage
|
||
localStorage.setItem('discovery_sub_category', subCategoryKey);
|
||
// 加载新的榜单数据
|
||
this.loadDiscoveryData();
|
||
},
|
||
initializeDiscoverySelection() {
|
||
// 验证从localStorage读取的主榜单是否有效
|
||
const savedMainCategory = localStorage.getItem('discovery_main_category');
|
||
const validMainCategories = this.discovery.mainCategories.map(cat => cat.key);
|
||
|
||
if (savedMainCategory && validMainCategories.includes(savedMainCategory)) {
|
||
this.discovery.selectedMainCategory = savedMainCategory;
|
||
} else {
|
||
// 如果无效,使用默认值并更新localStorage
|
||
this.discovery.selectedMainCategory = 'movie_hot';
|
||
localStorage.setItem('discovery_main_category', 'movie_hot');
|
||
}
|
||
|
||
// 验证从localStorage读取的子榜单是否有效
|
||
const savedSubCategory = localStorage.getItem('discovery_sub_category');
|
||
const validSubCategories = this.discovery.subCategories[this.discovery.selectedMainCategory];
|
||
|
||
if (savedSubCategory && validSubCategories && validSubCategories.some(sub => sub.key === savedSubCategory)) {
|
||
this.discovery.selectedSubCategory = savedSubCategory;
|
||
} else {
|
||
// 如果无效,使用该主榜单的第一个子榜单
|
||
if (validSubCategories && validSubCategories.length > 0) {
|
||
this.discovery.selectedSubCategory = validSubCategories[0].key;
|
||
localStorage.setItem('discovery_sub_category', validSubCategories[0].key);
|
||
}
|
||
}
|
||
},
|
||
async loadDiscoveryData() {
|
||
// 如果配置还没有加载完成,直接返回,不清空现有数据
|
||
if (!this.formData.file_performance || this.formData.file_performance.discovery_items_count === undefined) {
|
||
return;
|
||
}
|
||
|
||
// 标记为已初始化
|
||
this.discovery.isInitialized = true;
|
||
|
||
// 只有在首次加载时才显示加载状态,避免页面刷新时的闪烁
|
||
if (!this.discovery.hasLoaded) {
|
||
this.discovery.hasLoaded = false;
|
||
this.discovery.items = [];
|
||
}
|
||
this.discovery.error = null;
|
||
|
||
try {
|
||
// 构建API路径 - 使用通用接口
|
||
let apiPath = '';
|
||
// 使用配置的影视榜单项目数量,如果没有配置则使用默认值30
|
||
const itemsCount = this.formData.file_performance.discovery_items_count || 30;
|
||
let params = { limit: itemsCount };
|
||
const mainCategory = this.discovery.selectedMainCategory;
|
||
const subCategory = this.discovery.selectedSubCategory;
|
||
|
||
if (mainCategory.startsWith('movie_')) {
|
||
apiPath = '/api/douban/movie/recent_hot';
|
||
// 映射分类参数
|
||
const categoryMapping = {
|
||
'movie_hot': '热门',
|
||
'movie_latest': '最新',
|
||
'movie_top': '豆瓣高分',
|
||
'movie_underrated': '冷门佳片'
|
||
};
|
||
params.category = categoryMapping[mainCategory] || '热门';
|
||
params.type = subCategory;
|
||
} else if (mainCategory.startsWith('tv_')) {
|
||
apiPath = '/api/douban/tv/recent_hot';
|
||
// 映射分类参数
|
||
if (mainCategory === 'tv_drama') {
|
||
params.category = 'tv';
|
||
params.type = subCategory;
|
||
} else if (mainCategory === 'tv_animation') {
|
||
params.category = 'tv';
|
||
params.type = subCategory; // 使用子榜的key值'动画'
|
||
} else if (mainCategory === 'tv_variety') {
|
||
params.category = 'show';
|
||
params.type = subCategory;
|
||
} else if (mainCategory === 'tv_documentary') {
|
||
params.category = 'tv';
|
||
params.type = subCategory; // 使用子榜的key值'纪录片'
|
||
} else {
|
||
params.category = 'tv';
|
||
params.type = subCategory;
|
||
}
|
||
}
|
||
|
||
const response = await axios.get(apiPath, {
|
||
params: params
|
||
});
|
||
|
||
if (response.data.success) {
|
||
this.discovery.items = response.data.data.items || [];
|
||
} else {
|
||
this.discovery.error = response.data.message || '获取榜单数据失败';
|
||
}
|
||
} catch (error) {
|
||
console.error('获取豆瓣榜单失败:', error);
|
||
this.discovery.error = '网络错误,请稍后重试';
|
||
} finally {
|
||
this.discovery.hasLoaded = true;
|
||
}
|
||
},
|
||
openDoubanPage(item) {
|
||
if (item.url) {
|
||
window.open(item.url, '_blank');
|
||
} else if (item.id) {
|
||
// 构建豆瓣页面URL
|
||
const doubanUrl = `https://movie.douban.com/subject/${item.id}/`;
|
||
window.open(doubanUrl, '_blank');
|
||
}
|
||
},
|
||
|
||
getMovieGenre(item) {
|
||
// 从card_subtitle中提取类型信息
|
||
// 格式: 年份 / 地区 / 类型 / 导演 / 主演
|
||
if (item.card_subtitle) {
|
||
const parts = item.card_subtitle.split(' / ');
|
||
if (parts.length >= 3) {
|
||
const genreText = parts[2]; // 第三部分是类型
|
||
if (genreText && genreText.trim()) {
|
||
// 将空格分隔的类型转换为斜杠分隔,并为斜杠添加特殊样式
|
||
return genreText.replace(/\s+/g, ' <span class="genre-slash">/</span> ');
|
||
}
|
||
}
|
||
}
|
||
return '未知';
|
||
},
|
||
getMovieGenreText(item) {
|
||
// 获取纯文本版本的类型信息,用于title属性
|
||
if (item.card_subtitle) {
|
||
const parts = item.card_subtitle.split(' / ');
|
||
if (parts.length >= 3) {
|
||
const genreText = parts[2]; // 第三部分是类型
|
||
if (genreText && genreText.trim()) {
|
||
// 将空格分隔的类型转换为斜杠分隔
|
||
return genreText.replace(/\s+/g, ' / ');
|
||
}
|
||
}
|
||
}
|
||
return '未知';
|
||
},
|
||
getMovieDetails(item) {
|
||
// 处理card_subtitle信息用于海报悬停显示
|
||
if (item.card_subtitle) {
|
||
const parts = item.card_subtitle.split(' / ');
|
||
const details = [];
|
||
|
||
if (parts.length >= 1 && parts[0]) {
|
||
details.push(parts[0]); // 年份
|
||
}
|
||
if (parts.length >= 2 && parts[1]) {
|
||
details.push(parts[1]); // 地区
|
||
}
|
||
if (parts.length >= 3 && parts[2]) {
|
||
details.push(parts[2]); // 类型
|
||
}
|
||
if (parts.length >= 4 && parts[3]) {
|
||
// 处理导演信息,最多显示两个导演
|
||
const directors = parts[3].trim();
|
||
if (directors) {
|
||
const directorList = directors.split(/\s+/); // 按空格分割导演名字
|
||
if (directorList.length > 2) {
|
||
// 如果导演超过2个,只取前两个
|
||
const limitedDirectors = directorList.slice(0, 2).join(' ');
|
||
details.push(limitedDirectors);
|
||
} else {
|
||
// 导演不超过2个,直接显示
|
||
details.push(directors);
|
||
}
|
||
}
|
||
}
|
||
if (parts.length >= 5 && parts[4]) {
|
||
details.push(parts[4]); // 主演
|
||
}
|
||
|
||
return details;
|
||
}
|
||
return [];
|
||
},
|
||
createGradientFromImage(imgElement) {
|
||
// 从图片提取颜色生成渐变背景
|
||
try {
|
||
const canvas = document.createElement('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// 设置canvas尺寸
|
||
canvas.width = imgElement.naturalWidth || imgElement.width || 200;
|
||
canvas.height = imgElement.naturalHeight || imgElement.height || 300;
|
||
|
||
// 绘制图片到canvas
|
||
ctx.drawImage(imgElement, 0, 0, canvas.width, canvas.height);
|
||
|
||
// 固定四个非对称点位
|
||
const fixedPoints = [
|
||
{ x: Math.floor(canvas.width * 0.2), y: Math.floor(canvas.height * 0.15), pos: '20% 15%' },
|
||
{ x: Math.floor(canvas.width * 0.8), y: Math.floor(canvas.height * 0.35), pos: '80% 35%' },
|
||
{ x: Math.floor(canvas.width * 0.15), y: Math.floor(canvas.height * 0.7), pos: '15% 70%' },
|
||
{ x: Math.floor(canvas.width * 0.65), y: Math.floor(canvas.height * 0.85), pos: '65% 85%' }
|
||
];
|
||
|
||
const colors = [];
|
||
|
||
// 从四个固定点位提取颜色
|
||
fixedPoints.forEach(point => {
|
||
const pixel = ctx.getImageData(point.x, point.y, 1, 1).data;
|
||
// 降低亮度以便更好显示文本
|
||
const darkenFactor = 0.6; // 降低到60%亮度
|
||
const r = Math.floor(pixel[0] * darkenFactor);
|
||
const g = Math.floor(pixel[1] * darkenFactor);
|
||
const b = Math.floor(pixel[2] * darkenFactor);
|
||
const alpha = 0.9;
|
||
colors.push(`rgba(${r}, ${g}, ${b}, ${alpha})`);
|
||
});
|
||
|
||
// 使用多个径向渐变叠加,模拟全方位渐变效果
|
||
const gradients = [
|
||
`radial-gradient(circle at 20% 15%, ${colors[0]} 0%, transparent 80%)`,
|
||
`radial-gradient(circle at 80% 35%, ${colors[1]} 0%, transparent 80%)`,
|
||
`radial-gradient(circle at 15% 70%, ${colors[2]} 0%, transparent 80%)`,
|
||
`radial-gradient(circle at 65% 85%, ${colors[3]} 0%, transparent 80%)`,
|
||
`radial-gradient(ellipse at center, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.7) 100%)`
|
||
];
|
||
|
||
return gradients.join(', ');
|
||
|
||
} catch (error) {
|
||
console.warn('无法从图片提取颜色:', error);
|
||
// 降级为统一的深色背景(使用主题变量 --dark-text-color)
|
||
return 'var(--dark-text-color)';
|
||
}
|
||
},
|
||
handlePosterHover(event, item) {
|
||
// 处理海报悬停事件
|
||
const posterElement = event.currentTarget;
|
||
const imgElement = posterElement.querySelector('img');
|
||
const overlayElement = posterElement.querySelector('.discovery-poster-overlay');
|
||
|
||
if (imgElement && overlayElement) {
|
||
if (imgElement.complete) {
|
||
const gradient = this.createGradientFromImage(imgElement);
|
||
overlayElement.style.background = gradient;
|
||
} else {
|
||
// 图片未完成加载时,直接使用降级颜色
|
||
overlayElement.style.background = 'var(--dark-text-color)';
|
||
}
|
||
}
|
||
},
|
||
|
||
// 内容管理页面的海报悬停处理
|
||
handleManagementPosterHover(event, task) {
|
||
const posterElement = event.currentTarget;
|
||
const overlayElement = posterElement.querySelector('.discovery-poster-overlay');
|
||
|
||
if (overlayElement) {
|
||
// 检查任务是否匹配
|
||
const isMatched = task.matched_show_name && task.matched_show_name.trim() !== '';
|
||
|
||
if (isMatched) {
|
||
// 匹配的任务:从图片提取颜色
|
||
const imgElement = posterElement.querySelector('img');
|
||
if (imgElement && imgElement.complete) {
|
||
const gradient = this.createGradientFromImage(imgElement);
|
||
overlayElement.style.background = gradient;
|
||
} else {
|
||
overlayElement.style.background = 'var(--dark-text-color)';
|
||
}
|
||
} else {
|
||
// 未匹配的任务:使用固定颜色
|
||
overlayElement.style.background = 'var(--dark-text-color)';
|
||
}
|
||
}
|
||
},
|
||
handleImageError(event) {
|
||
// 图片加载失败时显示默认图片
|
||
console.warn('海报加载失败:', event.target.src);
|
||
event.target.src = '/static/images/no-poster.svg';
|
||
},
|
||
getProxiedImageUrl(originalUrl) {
|
||
// 保持直连,发现页加载更快,且不需要取色跨域处理
|
||
if (!originalUrl) return '/static/images/no-poster.svg';
|
||
return originalUrl;
|
||
},
|
||
createTaskFromDiscovery(item) {
|
||
// 从影视发现页面创建任务
|
||
try {
|
||
// 强制切换为创建模式并重置表单
|
||
this.createTask.isEditMode = false;
|
||
this.createTask.editTaskIndex = null;
|
||
this.createTask.error = null;
|
||
// 使用 newTask 的完整结构初始化,再由智能填充覆盖
|
||
this.createTask.taskData = { ...this.newTask };
|
||
// 应用系统配置的执行周期默认选项
|
||
this.createTask.taskData.execution_mode = this.formData.execution_mode || 'manual';
|
||
|
||
// 存储影视作品数据,并提取年份信息
|
||
const movieData = { ...item };
|
||
// 如果没有year字段,尝试从card_subtitle中提取
|
||
if (!movieData.year && movieData.card_subtitle) {
|
||
const yearFromSubtitle = this.extractYearFromCardSubtitle(movieData.card_subtitle);
|
||
if (yearFromSubtitle) {
|
||
movieData.year = yearFromSubtitle;
|
||
}
|
||
}
|
||
this.createTask.movieData = movieData;
|
||
|
||
// 智能填充任务数据
|
||
this.smartFillTaskData(item, movieData);
|
||
|
||
// 打开创建任务模态框
|
||
$('#createTaskModal').modal('show');
|
||
|
||
// 如果启用了自动搜索资源且配置了有效的搜索来源,自动触发资源搜索
|
||
this.$nextTick(() => {
|
||
if (this.formData.task_settings.auto_search_resources === 'enabled' &&
|
||
this.hasAnyValidSearchSource() &&
|
||
this.createTask.taskData.taskname) {
|
||
this.searchSuggestions(-1, this.createTask.taskData.taskname);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('创建任务时出错:', error);
|
||
}
|
||
},
|
||
openCreateTaskModal() {
|
||
// 从任务列表页面创建任务(不传递影视数据)
|
||
try {
|
||
// 清空影视数据
|
||
this.createTask.movieData = null;
|
||
// 重置编辑模式状态
|
||
this.createTask.isEditMode = false;
|
||
this.createTask.editTaskIndex = null;
|
||
|
||
// 重置任务数据为默认值,使用 newTask 的完整结构
|
||
this.createTask.taskData = { ...this.newTask };
|
||
// 应用系统配置的执行周期默认选项
|
||
this.createTask.taskData.execution_mode = this.formData.execution_mode || 'manual';
|
||
|
||
// 如果有上一个任务,继承保存路径和命名规则
|
||
if (this.formData.tasklist && this.formData.tasklist.length > 0) {
|
||
// 使用新的筛选逻辑:根据当前视图筛选条件获取对应类型中编号最大的任务
|
||
const lastTask = this.getLastTaskByCurrentFilter();
|
||
if (lastTask) {
|
||
this.createTask.taskData.savepath = lastTask.savepath || "";
|
||
this.createTask.taskData.pattern = lastTask.pattern || "";
|
||
this.createTask.taskData.replace = lastTask.replace || "";
|
||
|
||
// 继承命名规则选择模式
|
||
this.createTask.taskData.use_sequence_naming = lastTask.use_sequence_naming || false;
|
||
this.createTask.taskData.use_episode_naming = lastTask.use_episode_naming || false;
|
||
this.createTask.taskData.sequence_naming = lastTask.sequence_naming || "";
|
||
this.createTask.taskData.episode_naming = lastTask.episode_naming || "";
|
||
}
|
||
}
|
||
|
||
// 应用全局插件配置
|
||
this.applyGlobalPluginConfig(this.createTask.taskData);
|
||
|
||
// 打开创建任务模态框
|
||
$('#createTaskModal').modal('show');
|
||
} catch (error) {
|
||
console.error('打开创建任务模态框时出错:', error);
|
||
}
|
||
},
|
||
// 打开编辑任务模态框
|
||
openEditTaskModal(taskIndex) {
|
||
try {
|
||
if (taskIndex === null || taskIndex === undefined || !this.formData.tasklist || !this.formData.tasklist[taskIndex]) {
|
||
this.showToast('任务不存在');
|
||
return;
|
||
}
|
||
|
||
const task = this.formData.tasklist[taskIndex];
|
||
|
||
// 设置编辑模式
|
||
this.createTask.isEditMode = true;
|
||
this.createTask.editTaskIndex = taskIndex;
|
||
this.createTask.movieData = null;
|
||
this.createTask.error = null;
|
||
|
||
// 复制任务数据到编辑表单
|
||
this.createTask.taskData = { ...task };
|
||
|
||
// 确保所有必要字段都有默认值
|
||
this.createTask.taskData.runweek = task.runweek || [1, 2, 3, 4, 5, 6, 7];
|
||
this.createTask.taskData.addition = task.addition || {};
|
||
this.createTask.taskData.ignore_extension = task.ignore_extension || false;
|
||
this.createTask.taskData.use_sequence_naming = task.use_sequence_naming || false;
|
||
this.createTask.taskData.use_episode_naming = task.use_episode_naming || false;
|
||
// 确保execution_mode有默认值
|
||
if (!this.createTask.taskData.execution_mode) {
|
||
this.createTask.taskData.execution_mode = this.formData.execution_mode || 'manual';
|
||
}
|
||
|
||
// 打开模态框
|
||
$('#createTaskModal').modal('show');
|
||
} catch (error) {
|
||
console.error('打开编辑任务模态框时出错:', error);
|
||
this.showToast('打开编辑任务失败');
|
||
}
|
||
},
|
||
// 更新任务列表的元数据(用于热更新TMDB匹配信息)
|
||
async updateTasklistMetadata(delay = 0) {
|
||
const updateMetadata = async () => {
|
||
try {
|
||
// 获取最新的任务元数据,包括TMDB匹配信息
|
||
const tasksResponse = await axios.get('/api/calendar/tasks');
|
||
if (tasksResponse.data && tasksResponse.data.success) {
|
||
this.calendar.tasks = tasksResponse.data.data.tasks || [];
|
||
this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name);
|
||
|
||
// 重新构建任务映射,使用Vue.set确保响应式更新
|
||
try {
|
||
// 先清空现有映射
|
||
this.$delete(this.calendar, 'taskMapByName');
|
||
this.$set(this.calendar, 'taskMapByName', {});
|
||
|
||
(this.calendar.tasks || []).forEach(t => {
|
||
const key = (t.task_name || t.taskname || '').trim();
|
||
if (key) {
|
||
this.$set(this.calendar.taskMapByName, key, t);
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.warn('构建任务映射失败:', e);
|
||
}
|
||
|
||
// 更新任务列表的内容类型,确保类型筛选按钮能热更新
|
||
try {
|
||
const rawTypes = tasksResponse.data.data.content_types || [];
|
||
this.updateContentTypes(rawTypes);
|
||
} catch (e) {
|
||
console.warn('更新内容类型失败:', e);
|
||
}
|
||
|
||
console.log('任务列表元数据已更新,包含TMDB匹配信息');
|
||
}
|
||
} catch (error) {
|
||
console.error('更新任务列表元数据失败:', error);
|
||
}
|
||
};
|
||
|
||
if (delay > 0) {
|
||
// 延迟更新,给TMDB匹配一些时间
|
||
setTimeout(updateMetadata, delay);
|
||
} else {
|
||
updateMetadata();
|
||
}
|
||
},
|
||
hasAnyValidSearchSource() {
|
||
const src = this.formData.source || {};
|
||
const cs = src.cloudsaver || {};
|
||
const ps = src.pansou || {};
|
||
const csValid = cs.server && cs.username && cs.password;
|
||
const psValid = ps.server;
|
||
return !!(csValid || psValid);
|
||
},
|
||
smartFillTaskData(item, movieData) {
|
||
// 智能填充任务数据
|
||
const taskSettings = this.formData.task_settings || {};
|
||
|
||
// 获取当前榜单类型
|
||
const selectedMainCategory = this.discovery.selectedMainCategory;
|
||
|
||
// 根据主分类确定榜单类型名称
|
||
const categoryNameMap = {
|
||
'movie_hot': '热门电影',
|
||
'movie_latest': '最新电影',
|
||
'movie_top': '豆瓣高分',
|
||
'movie_underrated': '冷门佳片',
|
||
'tv_drama': '热门剧集',
|
||
'tv_animation': '热门动画',
|
||
'tv_variety': '热门综艺',
|
||
'tv_documentary': '热门纪录片'
|
||
};
|
||
|
||
const currentRanking = categoryNameMap[selectedMainCategory] || '热门电影';
|
||
|
||
// 判断是否为电视类内容
|
||
const isTvContent = currentRanking.includes('剧集') || currentRanking.includes('动画') ||
|
||
currentRanking.includes('综艺') || currentRanking.includes('纪录片');
|
||
|
||
// 判断当前内容类型
|
||
let contentType = 'movie'; // 默认为电影
|
||
if (currentRanking.includes('剧集')) {
|
||
contentType = 'tv';
|
||
} else if (currentRanking.includes('动画')) {
|
||
contentType = 'anime';
|
||
} else if (currentRanking.includes('综艺')) {
|
||
contentType = 'variety';
|
||
} else if (currentRanking.includes('纪录片')) {
|
||
contentType = 'documentary';
|
||
}
|
||
|
||
// 检查当前内容类型是否使用了自定义设置
|
||
const isCustomSettings = this.isUsingCustomTaskSettingsForType(taskSettings, contentType);
|
||
|
||
if (!isCustomSettings) {
|
||
// 如果当前类型没有自定义设置,使用原有的继承逻辑
|
||
this.createTask.taskData.taskname = item.title || "";
|
||
|
||
// 继承最后一个任务的配置
|
||
if (this.formData.tasklist && this.formData.tasklist.length > 0) {
|
||
const lastTask = this.formData.tasklist[this.formData.tasklist.length - 1];
|
||
this.createTask.taskData.savepath = lastTask.savepath || "";
|
||
this.createTask.taskData.pattern = lastTask.pattern || "";
|
||
this.createTask.taskData.replace = lastTask.replace || "";
|
||
this.createTask.taskData.use_sequence_naming = lastTask.use_sequence_naming || false;
|
||
this.createTask.taskData.use_episode_naming = lastTask.use_episode_naming || false;
|
||
this.createTask.taskData.sequence_naming = lastTask.sequence_naming || "";
|
||
this.createTask.taskData.episode_naming = lastTask.episode_naming || "";
|
||
}
|
||
|
||
// 但是对于电视类内容,总是应用电视命名规则
|
||
if (isTvContent) {
|
||
const title = item.title || "";
|
||
const tvInfo = this.extractTvInfo(title);
|
||
// 使用用户设置的电视命名规则,如果没有设置则使用默认值
|
||
const namingRule = taskSettings.tv_naming_rule && taskSettings.tv_naming_rule.trim() !== ""
|
||
? taskSettings.tv_naming_rule
|
||
: "剧名 - S季数E[]";
|
||
|
||
const generatedNamingRule = this.generateTvNamingRule(namingRule, tvInfo.seriesName, tvInfo.season);
|
||
|
||
this.createTask.taskData.taskname = tvInfo.seriesName;
|
||
this.createTask.taskData.use_episode_naming = true;
|
||
this.createTask.taskData.episode_naming = generatedNamingRule;
|
||
this.createTask.taskData.pattern = generatedNamingRule; // 同时设置到pattern字段以便模态框显示
|
||
this.createTask.taskData.ignore_extension = taskSettings.tv_ignore_extension !== undefined ? taskSettings.tv_ignore_extension : true; // 应用电视忽略后缀设置
|
||
}
|
||
|
||
// 设置其他默认值
|
||
this.createTask.taskData.shareurl = "";
|
||
this.createTask.taskData.enddate = "";
|
||
this.createTask.taskData.runweek = [1, 2, 3, 4, 5, 6, 7];
|
||
this.createTask.taskData.filterwords = "";
|
||
this.createTask.taskData.startfid = "";
|
||
this.createTask.taskData.update_subdir = "";
|
||
|
||
// 设置默认的插件配置,并应用全局配置
|
||
if (this.formData.task_plugins_config_default) {
|
||
this.createTask.taskData.addition = { ...this.formData.task_plugins_config_default };
|
||
this.applyGlobalPluginConfig(this.createTask.taskData);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 使用自定义设置进行智能填充
|
||
|
||
// 提取影片信息
|
||
const title = item.title || "";
|
||
const year = movieData.year || "";
|
||
|
||
// 获取对应的配置
|
||
let savePathTemplate = taskSettings.movie_save_path;
|
||
let namingRule = "";
|
||
let useEpisodeNaming = false;
|
||
|
||
// 根据内容类型获取对应的配置
|
||
if (contentType === 'tv') {
|
||
savePathTemplate = taskSettings.tv_save_path;
|
||
namingRule = taskSettings.tv_naming_rule;
|
||
useEpisodeNaming = true;
|
||
} else if (contentType === 'anime') {
|
||
savePathTemplate = taskSettings.anime_save_path;
|
||
namingRule = taskSettings.tv_naming_rule;
|
||
useEpisodeNaming = true;
|
||
} else if (contentType === 'variety') {
|
||
savePathTemplate = taskSettings.variety_save_path;
|
||
namingRule = taskSettings.tv_naming_rule;
|
||
useEpisodeNaming = true;
|
||
} else if (contentType === 'documentary') {
|
||
savePathTemplate = taskSettings.documentary_save_path;
|
||
namingRule = taskSettings.tv_naming_rule;
|
||
useEpisodeNaming = true;
|
||
}
|
||
|
||
// 处理电影类型
|
||
if (contentType === 'movie') {
|
||
this.createTask.taskData.taskname = title;
|
||
this.createTask.taskData.savepath = this.generateMovieSavePath(savePathTemplate, title, year);
|
||
|
||
// 应用电影命名规则
|
||
const movieNamingPattern = taskSettings.movie_naming_pattern && taskSettings.movie_naming_pattern.trim() !== ""
|
||
? taskSettings.movie_naming_pattern
|
||
: "";
|
||
const movieNamingReplace = taskSettings.movie_naming_replace && taskSettings.movie_naming_replace.trim() !== ""
|
||
? taskSettings.movie_naming_replace
|
||
: "";
|
||
|
||
if (movieNamingPattern && movieNamingReplace) {
|
||
// 生成智能填充的替换表达式
|
||
const generatedReplace = this.generateMovieNamingRule(movieNamingReplace, title, year);
|
||
this.createTask.taskData.pattern = movieNamingPattern;
|
||
this.createTask.taskData.replace = generatedReplace;
|
||
} else {
|
||
this.createTask.taskData.pattern = "";
|
||
this.createTask.taskData.replace = "";
|
||
}
|
||
|
||
this.createTask.taskData.use_sequence_naming = false;
|
||
this.createTask.taskData.use_episode_naming = false;
|
||
this.createTask.taskData.sequence_naming = "";
|
||
this.createTask.taskData.episode_naming = "";
|
||
} else {
|
||
// 处理电视类型(剧集、动画、综艺、纪录片)
|
||
const tvInfo = this.extractTvInfo(title);
|
||
this.createTask.taskData.taskname = tvInfo.seriesName;
|
||
this.createTask.taskData.savepath = this.generateTvSavePath(savePathTemplate, tvInfo.seriesName, year, tvInfo.season, tvInfo.isFirstSeason);
|
||
this.createTask.taskData.pattern = "";
|
||
this.createTask.taskData.replace = "";
|
||
this.createTask.taskData.use_sequence_naming = false;
|
||
const generatedNamingRule = this.generateTvNamingRule(namingRule, tvInfo.seriesName, tvInfo.season);
|
||
|
||
this.createTask.taskData.use_episode_naming = useEpisodeNaming;
|
||
this.createTask.taskData.sequence_naming = "";
|
||
this.createTask.taskData.episode_naming = generatedNamingRule;
|
||
this.createTask.taskData.pattern = generatedNamingRule; // 同时设置到pattern字段以便模态框显示
|
||
|
||
// 只有电视类内容才应用电视忽略后缀设置
|
||
if (useEpisodeNaming) {
|
||
this.createTask.taskData.ignore_extension = taskSettings.tv_ignore_extension !== undefined ? taskSettings.tv_ignore_extension : true;
|
||
} else {
|
||
// 电影类内容保持默认的false
|
||
this.createTask.taskData.ignore_extension = false;
|
||
}
|
||
}
|
||
|
||
// 设置其他默认值
|
||
this.createTask.taskData.shareurl = "";
|
||
this.createTask.taskData.enddate = "";
|
||
this.createTask.taskData.runweek = [1, 2, 3, 4, 5, 6, 7];
|
||
this.createTask.taskData.filterwords = "";
|
||
this.createTask.taskData.startfid = "";
|
||
this.createTask.taskData.update_subdir = "";
|
||
|
||
// 设置默认的插件配置,并应用全局配置
|
||
if (this.formData.task_plugins_config_default) {
|
||
this.createTask.taskData.addition = { ...this.formData.task_plugins_config_default };
|
||
this.applyGlobalPluginConfig(this.createTask.taskData);
|
||
}
|
||
},
|
||
isUsingCustomTaskSettingsForType(taskSettings, contentType) {
|
||
// 检查特定内容类型是否使用了自定义任务设置
|
||
const defaultSettings = {
|
||
movie: "电影目录前缀/片名 (年份)",
|
||
tv: "剧集目录前缀/剧名 (年份)/剧名 - S季数",
|
||
anime: "动画目录前缀/剧名 (年份)/剧名 - S季数",
|
||
variety: "综艺目录前缀/剧名 (年份)/剧名 - S季数",
|
||
documentary: "纪录片目录前缀/剧名 (年份)/剧名 - S季数"
|
||
};
|
||
|
||
const settingKeys = {
|
||
movie: 'movie_save_path',
|
||
tv: 'tv_save_path',
|
||
anime: 'anime_save_path',
|
||
variety: 'variety_save_path',
|
||
documentary: 'documentary_save_path'
|
||
};
|
||
|
||
const settingKey = settingKeys[contentType];
|
||
if (!settingKey) return false;
|
||
|
||
const userValue = taskSettings[settingKey] || "";
|
||
const defaultValue = defaultSettings[contentType];
|
||
|
||
// 如果用户设置了非空且与默认值不同的值,则认为是自定义设置
|
||
return userValue && userValue !== defaultValue;
|
||
},
|
||
isUsingCustomTaskSettings(taskSettings) {
|
||
// 检查是否使用了自定义任务设置(非默认模板)
|
||
// 注意:电视命名规则总是生效,不参与自定义判断
|
||
const defaultSettings = {
|
||
movie_save_path: "电影目录前缀/片名 (年份)",
|
||
tv_save_path: "剧集目录前缀/剧名 (年份)/剧名 - S季数",
|
||
anime_save_path: "动画目录前缀/剧名 (年份)/剧名 - S季数",
|
||
variety_save_path: "综艺目录前缀/剧名 (年份)/剧名 - S季数",
|
||
documentary_save_path: "纪录片目录前缀/剧名 (年份)/剧名 - S季数"
|
||
// tv_naming_rule 不参与判断,总是生效
|
||
};
|
||
|
||
// 检查每个设置是否与默认值不同且不为空
|
||
for (const key in defaultSettings) {
|
||
const userValue = taskSettings[key] || "";
|
||
const defaultValue = defaultSettings[key];
|
||
|
||
// 如果用户设置了非空且与默认值不同的值,则认为是自定义设置
|
||
if (userValue && userValue !== defaultValue) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
},
|
||
extractTvInfo(title) {
|
||
// 从标题中提取电视剧信息(剧名、季数等)
|
||
// 复用cleanTaskNameForSearch的逻辑
|
||
if (!title) return { seriesName: '', season: '01', isFirstSeason: true };
|
||
|
||
// 清理任务名称中的连续空格和特殊符号
|
||
let cleanedName = title.replace(/\u3000/g, ' ').replace(/\t/g, ' ');
|
||
cleanedName = cleanedName.replace(/\s+/g, ' ').trim();
|
||
|
||
// 匹配常见的季数格式(与cleanTaskNameForSearch保持一致)
|
||
const seasonPatterns = [
|
||
/^(.*?)[\s\.\-_]+S(\d+)$/i, // 黑镜 - S07、折腰.S01、音你而来-S02
|
||
/^(.*?)[\s\.\-_]+Season\s*(\d+)$/i, // 黑镜 - Season 1
|
||
/^(.*?)\s+S(\d+)$/i, // 快乐的大人 S02
|
||
/^(.*?)[\s\.\-_]+S(\d+)E\d+$/i, // 处理 S01E01 格式
|
||
/^(.*?)\s+第\s*(\d+)\s*季$/i, // 处理 第N季 格式
|
||
/^(.*?)[\s\.\-_]+第\s*(\d+)\s*季$/i, // 处理 - 第N季 格式
|
||
/^(.*?)\s+第([一二三四五六七八九十零]+)季$/i, // 处理 第一季、第二季 格式
|
||
/^(.*?)[\s\.\-_]+第([一二三四五六七八九十零]+)季$/i, // 处理 - 第一季、- 第二季 格式
|
||
];
|
||
|
||
for (const pattern of seasonPatterns) {
|
||
const match = cleanedName.match(pattern);
|
||
if (match) {
|
||
let seriesName = match[1].trim();
|
||
// 去除末尾可能残留的分隔符
|
||
seriesName = seriesName.replace(/[\s\.\-_]+$/, '');
|
||
|
||
let seasonNum = match[2];
|
||
// 处理中文数字
|
||
if (isNaN(seasonNum)) {
|
||
const chineseNumbers = {
|
||
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
|
||
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10, '零': 0
|
||
};
|
||
seasonNum = chineseNumbers[seasonNum] || 1;
|
||
}
|
||
seasonNum = parseInt(seasonNum);
|
||
|
||
return {
|
||
seriesName: seriesName,
|
||
season: seasonNum.toString().padStart(2, '0'),
|
||
isFirstSeason: seasonNum === 1
|
||
};
|
||
}
|
||
}
|
||
|
||
// 如果没有匹配到季数格式,返回原标题作为剧名,默认第一季
|
||
return {
|
||
seriesName: cleanedName,
|
||
season: '01',
|
||
isFirstSeason: true
|
||
};
|
||
},
|
||
|
||
// 加载“今日更新”的本地数据,用于指示当日转存的剧集/任务
|
||
async loadTodayUpdatesLocal() {
|
||
try {
|
||
const res = await axios.get('/api/calendar/today_updates_local');
|
||
if (res.data && res.data.success) {
|
||
const items = res.data.data && res.data.data.items ? res.data.data.items : [];
|
||
const byTask = {};
|
||
const byShow = {};
|
||
const makeKey = (it) => {
|
||
if (it && it.episode_number != null && it.season_number != null) {
|
||
const s = String(it.season_number).padStart(2, '0');
|
||
const e = String(it.episode_number).padStart(2, '0');
|
||
return `S${s}E${e}`;
|
||
}
|
||
if (it && it.air_date) {
|
||
return `D:${it.air_date}`;
|
||
}
|
||
return null;
|
||
};
|
||
items.forEach(it => {
|
||
if (it && it.task_name) byTask[it.task_name] = true;
|
||
const key = makeKey(it);
|
||
const sname = (it && it.show_name) ? String(it.show_name).trim() : '';
|
||
if (sname && key) {
|
||
if (!byShow[sname]) byShow[sname] = new Set();
|
||
byShow[sname].add(key);
|
||
}
|
||
});
|
||
// 将 Set 序列化为对象数组以便 Vue 响应式
|
||
const byShowObj = {};
|
||
Object.keys(byShow).forEach(k => { byShowObj[k] = Array.from(byShow[k]); });
|
||
this.calendar.todayUpdatesByTaskName = byTask;
|
||
this.calendar.todayUpdatesByShow = byShowObj;
|
||
}
|
||
} catch (e) {
|
||
// 忽略错误,维持上次数据
|
||
}
|
||
},
|
||
|
||
// 判断剧集是否属于“今日更新”的已转存集(按剧名 + SxxExx / 日期 匹配)
|
||
isEpisodeUpdatedToday(episode) {
|
||
try {
|
||
if (!episode) return false;
|
||
const sname = (episode.show_name || '').trim();
|
||
if (!sname) return false;
|
||
const list = this.calendar.todayUpdatesByShow && this.calendar.todayUpdatesByShow[sname];
|
||
if (!list || list.length === 0) return false;
|
||
// 拆分模式:逐集匹配
|
||
const makeKey = (ep) => {
|
||
if (ep && ep.episode_number != null && ep.season_number != null) {
|
||
const s = String(ep.season_number).padStart(2, '0');
|
||
const e = String(ep.episode_number).padStart(2, '0');
|
||
return `S${s}E${e}`;
|
||
}
|
||
if (ep && ep.air_date) return `D:${ep.air_date}`;
|
||
return null;
|
||
};
|
||
if (!episode.is_merged) {
|
||
const key = makeKey(episode);
|
||
if (!key) return false;
|
||
return list.includes(key);
|
||
}
|
||
// 合并模式:任一原始集命中即可
|
||
if (Array.isArray(episode.original_episodes) && episode.original_episodes.length > 0) {
|
||
return episode.original_episodes.some(ep => {
|
||
const key = makeKey({
|
||
season_number: ep.season_number || episode.season_number,
|
||
episode_number: ep.episode_number,
|
||
air_date: ep.air_date || episode.air_date
|
||
});
|
||
return key && list.includes(key);
|
||
});
|
||
}
|
||
// 兜底:用合并后的显示信息尝试一次
|
||
const key = makeKey(episode);
|
||
return key ? list.includes(key) : false;
|
||
} catch (e) { return false; }
|
||
},
|
||
|
||
// 判断管理视图中的任务是否有当日更新
|
||
isCalendarTaskUpdatedToday(task) {
|
||
try {
|
||
const name = task && (task.task_name || task.taskname);
|
||
if (!name) return false;
|
||
return !!(this.calendar.todayUpdatesByTaskName && this.calendar.todayUpdatesByTaskName[name]);
|
||
} catch (e) { return false; }
|
||
},
|
||
generateMovieSavePath(template, title, year) {
|
||
// 生成电影保存路径
|
||
let savePath = template;
|
||
|
||
// 替换片名
|
||
savePath = savePath.replace(/片名/g, title);
|
||
|
||
// 替换年份
|
||
if (year) {
|
||
savePath = savePath.replace(/年份/g, year);
|
||
} else {
|
||
// 如果没有年份,移除年份相关的括号
|
||
savePath = savePath.replace(/\s*\(年份\)/g, '');
|
||
}
|
||
|
||
return savePath;
|
||
},
|
||
generateTvSavePath(template, seriesName, year, season, isFirstSeason) {
|
||
// 生成电视剧保存路径
|
||
let savePath = template;
|
||
|
||
// 替换剧名
|
||
savePath = savePath.replace(/剧名/g, seriesName);
|
||
|
||
// 替换季数
|
||
savePath = savePath.replace(/季数/g, season);
|
||
|
||
// 处理年份:如果不是第一季,忽略年份信息
|
||
if (isFirstSeason && year) {
|
||
savePath = savePath.replace(/年份/g, year);
|
||
} else {
|
||
// 移除年份相关的部分
|
||
savePath = savePath.replace(/\s*\(年份\)\//g, '/');
|
||
savePath = savePath.replace(/\s*\(年份\)/g, '');
|
||
}
|
||
|
||
return savePath;
|
||
},
|
||
generateTvNamingRule(template, seriesName, season) {
|
||
// 生成电视剧命名规则
|
||
let namingRule = template;
|
||
|
||
// 替换剧名
|
||
namingRule = namingRule.replace(/剧名/g, seriesName);
|
||
|
||
// 替换季数
|
||
namingRule = namingRule.replace(/季数/g, season);
|
||
|
||
return namingRule;
|
||
},
|
||
generateMovieNamingRule(replaceTemplate, movieTitle, year) {
|
||
// 生成电影命名规则
|
||
let namingRule = replaceTemplate;
|
||
|
||
// 替换片名
|
||
namingRule = namingRule.replace(/片名/g, movieTitle);
|
||
|
||
// 替换年份
|
||
if (year) {
|
||
namingRule = namingRule.replace(/年份/g, year);
|
||
} else {
|
||
// 如果没有年份,移除包含年份的部分
|
||
namingRule = namingRule.replace(/\s*\(年份\)/g, '');
|
||
namingRule = namingRule.replace(/\s*(年份)/g, '');
|
||
}
|
||
|
||
// 注意:正则表达式的反向引用(如\2)保持不变,将在实际重命名时由正则引擎处理
|
||
|
||
return namingRule;
|
||
},
|
||
generateCustomFolderPath(taskData) {
|
||
// 根据任务设置生成自定义文件夹路径
|
||
const taskSettings = this.formData.task_settings || {};
|
||
const movieData = this.createTask.movieData;
|
||
|
||
if (!movieData) {
|
||
return taskData.taskname; // 如果没有影视数据,使用任务名称
|
||
}
|
||
|
||
// 获取当前榜单类型
|
||
const selectedMainCategory = this.discovery.selectedMainCategory;
|
||
const categoryNameMap = {
|
||
'movie_hot': '热门电影',
|
||
'movie_latest': '最新电影',
|
||
'movie_top': '豆瓣高分',
|
||
'movie_underrated': '冷门佳片',
|
||
'tv_drama': '热门剧集',
|
||
'tv_animation': '热门动画',
|
||
'tv_variety': '热门综艺',
|
||
'tv_documentary': '热门纪录片'
|
||
};
|
||
|
||
const currentRanking = categoryNameMap[selectedMainCategory] || '热门电影';
|
||
|
||
// 判断内容类型
|
||
let contentType = 'movie';
|
||
if (currentRanking.includes('剧集')) {
|
||
contentType = 'tv';
|
||
} else if (currentRanking.includes('动画')) {
|
||
contentType = 'anime';
|
||
} else if (currentRanking.includes('综艺')) {
|
||
contentType = 'variety';
|
||
} else if (currentRanking.includes('纪录片')) {
|
||
contentType = 'documentary';
|
||
}
|
||
|
||
// 检查是否有自定义路径设置
|
||
let savePathTemplate = '';
|
||
if (contentType === 'tv') {
|
||
savePathTemplate = taskSettings.tv_save_path;
|
||
} else if (contentType === 'anime') {
|
||
savePathTemplate = taskSettings.anime_save_path;
|
||
} else if (contentType === 'variety') {
|
||
savePathTemplate = taskSettings.variety_save_path;
|
||
} else if (contentType === 'documentary') {
|
||
savePathTemplate = taskSettings.documentary_save_path;
|
||
} else if (contentType === 'movie') {
|
||
savePathTemplate = taskSettings.movie_save_path;
|
||
}
|
||
|
||
// 如果没有自定义路径设置,使用任务名称
|
||
if (!savePathTemplate || savePathTemplate.trim() === '') {
|
||
return taskData.taskname;
|
||
}
|
||
|
||
// 提取相对路径部分(去掉前缀)
|
||
const pathParts = savePathTemplate.split('/');
|
||
let relativePath = '';
|
||
|
||
// 找到第一个包含模板变量的部分开始
|
||
for (let i = 0; i < pathParts.length; i++) {
|
||
if (pathParts[i].includes('片名') || pathParts[i].includes('剧名')) {
|
||
relativePath = pathParts.slice(i).join('/');
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!relativePath) {
|
||
return taskData.taskname;
|
||
}
|
||
|
||
const title = movieData.title || taskData.taskname;
|
||
const year = movieData.year || '';
|
||
|
||
if (contentType === 'movie') {
|
||
// 电影类型:使用generateMovieSavePath的逻辑
|
||
return this.generateMovieSavePath(relativePath, title, year);
|
||
} else {
|
||
// 电视类型:使用generateTvSavePath的逻辑
|
||
const tvInfo = this.extractTvInfo(title);
|
||
return this.generateTvSavePath(relativePath, tvInfo.seriesName, year, tvInfo.season, tvInfo.isFirstSeason);
|
||
}
|
||
},
|
||
extractYearFromCardSubtitle(cardSubtitle) {
|
||
// 从card_subtitle中提取年份
|
||
// 格式: 年份 / 地区 / 类型 / 导演 / 主演
|
||
if (cardSubtitle) {
|
||
const parts = cardSubtitle.split(' / ');
|
||
if (parts.length >= 1 && parts[0]) {
|
||
const yearPart = parts[0].trim();
|
||
// 匹配4位数字年份
|
||
const yearMatch = yearPart.match(/(\d{4})/);
|
||
if (yearMatch) {
|
||
return yearMatch[1];
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
cancelCreateTask() {
|
||
// 取消创建任务
|
||
$('#createTaskModal').modal('hide');
|
||
this.createTask.movieData = null;
|
||
this.createTask.error = null;
|
||
// 重置编辑模式状态
|
||
this.createTask.isEditMode = false;
|
||
this.createTask.editTaskIndex = null;
|
||
// 重置任务数据,使用 newTask 的完整结构
|
||
this.createTask.taskData = { ...this.newTask };
|
||
},
|
||
confirmCreateTask() {
|
||
// 确认创建任务
|
||
if (this.createTask.loading) return;
|
||
|
||
// 验证必填字段
|
||
if (!this.createTask.taskData.taskname.trim()) {
|
||
this.createTask.error = '任务名称不能为空';
|
||
return;
|
||
}
|
||
if (!this.createTask.taskData.shareurl.trim()) {
|
||
this.createTask.error = '分享链接不能为空';
|
||
return;
|
||
}
|
||
if (!this.createTask.taskData.savepath.trim()) {
|
||
this.createTask.error = '保存路径不能为空';
|
||
return;
|
||
}
|
||
|
||
this.createTask.loading = true;
|
||
this.createTask.error = null;
|
||
|
||
// 创建新任务
|
||
const newTask = { ...this.createTask.taskData };
|
||
|
||
// 应用全局插件配置
|
||
this.applyGlobalPluginConfig(newTask);
|
||
|
||
// 处理命名模式
|
||
if (newTask.use_sequence_naming) {
|
||
newTask.pattern = newTask.sequence_naming;
|
||
} else if (newTask.use_episode_naming) {
|
||
newTask.pattern = newTask.episode_naming;
|
||
}
|
||
|
||
// 添加到任务列表
|
||
if (!this.formData.tasklist) {
|
||
this.formData.tasklist = [];
|
||
}
|
||
this.formData.tasklist.push(newTask);
|
||
|
||
// 保存配置(不显示配置更新消息)
|
||
axios.post('/update', this.formData)
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.configModified = false;
|
||
// 显示任务创建成功消息
|
||
this.showToast('任务创建成功', 'success');
|
||
// 保存成功后更新用户信息
|
||
this.fetchUserInfo();
|
||
// 更新任务列表元数据,确保海报视图能显示TMDB匹配信息
|
||
this.updateTasklistMetadata();
|
||
this.createTask.loading = false;
|
||
this.cancelCreateTask();
|
||
} else {
|
||
// 错误信息使用alert,确保用户看到
|
||
alert(response.data.message);
|
||
this.createTask.loading = false;
|
||
}
|
||
})
|
||
.catch(error => {
|
||
// 错误处理
|
||
alert("保存失败: " + (error.response?.data?.message || error.message || "未知错误"));
|
||
this.createTask.loading = false;
|
||
});
|
||
},
|
||
confirmEditTask() {
|
||
// 确认编辑任务
|
||
if (this.createTask.loading) return;
|
||
|
||
// 验证必填字段
|
||
if (!this.createTask.taskData.taskname.trim()) {
|
||
this.createTask.error = '任务名称不能为空';
|
||
return;
|
||
}
|
||
if (!this.createTask.taskData.shareurl.trim()) {
|
||
this.createTask.error = '分享链接不能为空';
|
||
return;
|
||
}
|
||
if (!this.createTask.taskData.savepath.trim()) {
|
||
this.createTask.error = '保存路径不能为空';
|
||
return;
|
||
}
|
||
|
||
this.createTask.loading = true;
|
||
this.createTask.error = null;
|
||
|
||
// 更新任务数据
|
||
const updatedTask = { ...this.createTask.taskData };
|
||
|
||
// 应用全局插件配置
|
||
this.applyGlobalPluginConfig(updatedTask);
|
||
|
||
// 处理命名模式
|
||
if (updatedTask.use_sequence_naming) {
|
||
updatedTask.pattern = updatedTask.sequence_naming;
|
||
} else if (updatedTask.use_episode_naming) {
|
||
updatedTask.pattern = updatedTask.episode_naming;
|
||
}
|
||
|
||
// 更新任务列表中的任务
|
||
if (this.createTask.editTaskIndex !== null && this.formData.tasklist && this.formData.tasklist[this.createTask.editTaskIndex]) {
|
||
this.$set(this.formData.tasklist, this.createTask.editTaskIndex, updatedTask);
|
||
}
|
||
|
||
// 保存配置(不显示配置更新消息)
|
||
axios.post('/update', this.formData)
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.configModified = false;
|
||
// 显示任务编辑成功消息
|
||
this.showToast('任务编辑成功', 'success');
|
||
// 保存成功后更新用户信息
|
||
this.fetchUserInfo();
|
||
// 更新任务列表元数据,确保海报视图能显示TMDB匹配信息
|
||
this.updateTasklistMetadata();
|
||
this.createTask.loading = false;
|
||
this.cancelCreateTask();
|
||
} else {
|
||
// 错误信息使用alert,确保用户看到
|
||
alert(response.data.message);
|
||
this.createTask.loading = false;
|
||
}
|
||
})
|
||
.catch(error => {
|
||
// 错误处理
|
||
alert("保存失败: " + (error.response?.data?.message || error.message || "未知错误"));
|
||
this.createTask.loading = false;
|
||
});
|
||
},
|
||
confirmEditAndRunTask() {
|
||
// 确认编辑任务并立即运行
|
||
if (this.createTask.loading) return;
|
||
|
||
// 复用与编辑验证相同的逻辑
|
||
if (!this.createTask.taskData.taskname.trim()) {
|
||
this.createTask.error = '任务名称不能为空';
|
||
return;
|
||
}
|
||
if (!this.createTask.taskData.shareurl.trim()) {
|
||
this.createTask.error = '分享链接不能为空';
|
||
return;
|
||
}
|
||
if (!this.createTask.taskData.savepath.trim()) {
|
||
this.createTask.error = '保存路径不能为空';
|
||
return;
|
||
}
|
||
|
||
this.createTask.loading = true;
|
||
this.createTask.error = null;
|
||
|
||
// 更新任务数据
|
||
const updatedTask = { ...this.createTask.taskData };
|
||
|
||
// 应用全局插件配置
|
||
this.applyGlobalPluginConfig(updatedTask);
|
||
|
||
// 处理命名模式
|
||
if (updatedTask.use_sequence_naming) {
|
||
updatedTask.pattern = updatedTask.sequence_naming;
|
||
} else if (updatedTask.use_episode_naming) {
|
||
updatedTask.pattern = updatedTask.episode_naming;
|
||
}
|
||
|
||
// 更新任务列表中的任务
|
||
if (this.createTask.editTaskIndex !== null && this.formData.tasklist && this.formData.tasklist[this.createTask.editTaskIndex]) {
|
||
this.$set(this.formData.tasklist, this.createTask.editTaskIndex, updatedTask);
|
||
}
|
||
|
||
// 保存配置
|
||
axios.post('/update', this.formData)
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.configModified = false;
|
||
this.showToast('任务编辑成功并开始运行', 'success');
|
||
this.fetchUserInfo();
|
||
this.updateTasklistMetadata();
|
||
const taskIndex = this.createTask.editTaskIndex;
|
||
this.createTask.loading = false;
|
||
this.cancelCreateTask();
|
||
// 稍后运行任务,保证模态框关闭完成
|
||
setTimeout(() => {
|
||
if (taskIndex !== null && taskIndex >= 0) {
|
||
this.runScriptNow(taskIndex);
|
||
}
|
||
}, 300);
|
||
} else {
|
||
alert(response.data.message);
|
||
this.createTask.loading = false;
|
||
}
|
||
})
|
||
.catch(error => {
|
||
alert("保存失败: " + (error.response?.data?.message || error.message || "未知错误"));
|
||
this.createTask.loading = false;
|
||
});
|
||
},
|
||
confirmCreateAndRunTask() {
|
||
// 确认创建并运行任务
|
||
if (this.createTask.loading) return;
|
||
|
||
// 验证必填字段
|
||
if (!this.createTask.taskData.taskname.trim()) {
|
||
this.createTask.error = '任务名称不能为空';
|
||
return;
|
||
}
|
||
if (!this.createTask.taskData.shareurl.trim()) {
|
||
this.createTask.error = '分享链接不能为空';
|
||
return;
|
||
}
|
||
if (!this.createTask.taskData.savepath.trim()) {
|
||
this.createTask.error = '保存路径不能为空';
|
||
return;
|
||
}
|
||
|
||
this.createTask.loading = true;
|
||
this.createTask.error = null;
|
||
|
||
// 创建新任务
|
||
const newTask = { ...this.createTask.taskData };
|
||
|
||
// 应用全局插件配置
|
||
this.applyGlobalPluginConfig(newTask);
|
||
|
||
// 处理命名模式
|
||
if (newTask.use_sequence_naming) {
|
||
newTask.pattern = newTask.sequence_naming;
|
||
} else if (newTask.use_episode_naming) {
|
||
newTask.pattern = newTask.episode_naming;
|
||
}
|
||
|
||
// 添加到任务列表
|
||
if (!this.formData.tasklist) {
|
||
this.formData.tasklist = [];
|
||
}
|
||
this.formData.tasklist.push(newTask);
|
||
|
||
// 保存配置(不显示配置更新消息)
|
||
axios.post('/update', this.formData)
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.configModified = false;
|
||
// 保存成功后更新用户信息
|
||
this.fetchUserInfo();
|
||
// 更新任务列表元数据,确保海报视图能显示TMDB匹配信息
|
||
this.updateTasklistMetadata();
|
||
// 延迟再次更新,给TMDB匹配更多时间
|
||
this.updateTasklistMetadata(3000);
|
||
|
||
// 显示任务创建成功消息
|
||
this.showToast('任务创建成功并开始运行', 'success');
|
||
this.createTask.loading = false;
|
||
|
||
// 先关闭创建任务模态框,然后运行新创建的任务
|
||
this.cancelCreateTask();
|
||
|
||
// 等待模态框完全关闭后再打开运行日志模态框
|
||
setTimeout(() => {
|
||
const taskIndex = this.formData.tasklist.length - 1;
|
||
this.runScriptNow(taskIndex);
|
||
}, 300);
|
||
} else {
|
||
// 错误信息使用alert,确保用户看到
|
||
alert(response.data.message);
|
||
this.createTask.loading = false;
|
||
}
|
||
})
|
||
.catch(error => {
|
||
// 错误处理
|
||
alert("保存失败: " + (error.response?.data?.message || error.message || "未知错误"));
|
||
this.createTask.loading = false;
|
||
});
|
||
},
|
||
confirmCreateRunAndDeleteTask() {
|
||
// 确认创建、运行并删除任务
|
||
if (this.createTask.loading) return;
|
||
|
||
// 验证必填字段
|
||
if (!this.createTask.taskData.taskname.trim()) {
|
||
this.createTask.error = '任务名称不能为空';
|
||
return;
|
||
}
|
||
if (!this.createTask.taskData.shareurl.trim()) {
|
||
this.createTask.error = '分享链接不能为空';
|
||
return;
|
||
}
|
||
if (!this.createTask.taskData.savepath.trim()) {
|
||
this.createTask.error = '保存路径不能为空';
|
||
return;
|
||
}
|
||
|
||
this.createTask.loading = true;
|
||
this.createTask.error = null;
|
||
|
||
// 创建新任务
|
||
const newTask = { ...this.createTask.taskData };
|
||
// 一次性任务:跳过日历匹配
|
||
newTask.skip_calendar = true;
|
||
|
||
// 应用全局插件配置
|
||
this.applyGlobalPluginConfig(newTask);
|
||
|
||
// 处理命名模式
|
||
if (newTask.use_sequence_naming) {
|
||
newTask.pattern = newTask.sequence_naming;
|
||
} else if (newTask.use_episode_naming) {
|
||
newTask.pattern = newTask.episode_naming;
|
||
}
|
||
|
||
// 添加到任务列表
|
||
if (!this.formData.tasklist) {
|
||
this.formData.tasklist = [];
|
||
}
|
||
this.formData.tasklist.push(newTask);
|
||
|
||
// 保存配置(不显示配置更新消息)
|
||
axios.post('/update', this.formData)
|
||
.then(response => {
|
||
if (response.data.success) {
|
||
this.configModified = false;
|
||
// 保存成功后更新用户信息
|
||
this.fetchUserInfo();
|
||
|
||
// 显示任务创建成功消息
|
||
this.showToast('任务创建成功,开始运行并将在完成后自动删除', 'success');
|
||
this.createTask.loading = false;
|
||
|
||
// 先关闭创建任务模态框,然后运行新创建的任务
|
||
this.cancelCreateTask();
|
||
|
||
// 等待模态框完全关闭后再打开运行日志模态框并运行任务
|
||
setTimeout(async () => {
|
||
const taskIndex = this.formData.tasklist.length - 1;
|
||
|
||
// 运行任务并等待完成
|
||
await this.runScriptNowWithCallback(taskIndex, () => {
|
||
// 任务完成后删除该任务
|
||
setTimeout(() => {
|
||
this.removeTaskSilently(taskIndex);
|
||
this.showToast('一次性任务已完成并自动删除', 'info');
|
||
}, 1000); // 等待1秒确保任务状态更新
|
||
});
|
||
}, 300);
|
||
} else {
|
||
// 错误信息使用alert,确保用户看到
|
||
alert(response.data.message);
|
||
this.createTask.loading = false;
|
||
}
|
||
})
|
||
.catch(error => {
|
||
// 错误处理
|
||
alert("保存失败: " + (error.response?.data?.message || error.message || "未知错误"));
|
||
this.createTask.loading = false;
|
||
});
|
||
},
|
||
async runScriptNowWithCallback(task_index, callback) {
|
||
// 运行任务的包装函数,支持完成回调
|
||
body = {};
|
||
if (task_index != null) {
|
||
task = { ...this.formData.tasklist[task_index] };
|
||
delete task.runweek;
|
||
delete task.enddate;
|
||
body = {
|
||
"tasklist": [task],
|
||
"original_index": task_index + 1 // 添加原始索引,从1开始计数
|
||
};
|
||
} else if (this.configModified) {
|
||
if (!confirm('配置已修改但未保存,是否继续运行?')) {
|
||
return;
|
||
}
|
||
}
|
||
$('#logModal').modal('toggle');
|
||
this.modalLoading = true;
|
||
this.run_log = '';
|
||
try {
|
||
// 1. 发送 POST 请求
|
||
const response = await fetch(`/run_script_now`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||
}
|
||
// 2. 处理 SSE 流
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let partialData = '';
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) {
|
||
this.modalLoading = false;
|
||
// 运行后刷新数据
|
||
this.fetchData();
|
||
// 调用完成回调
|
||
if (callback) callback();
|
||
break;
|
||
}
|
||
partialData += decoder.decode(value);
|
||
const lines = partialData.split('\n').filter(line => line.trim() !== '');
|
||
for (const line of lines) {
|
||
if (line.startsWith('data:')) {
|
||
const eventData = line.substring(5).trim();
|
||
if (eventData === '[DONE]') {
|
||
this.modalLoading = false;
|
||
this.fetchData();
|
||
// 调用完成回调
|
||
if (callback) callback();
|
||
return;
|
||
}
|
||
this.run_log += eventData + '\n';
|
||
// 在更新 run_log 后将滚动条滚动到底部
|
||
this.$nextTick(() => {
|
||
const modalBody = document.querySelector('.modal-body');
|
||
modalBody.scrollTop = modalBody.scrollHeight;
|
||
});
|
||
}
|
||
}
|
||
partialData = '';
|
||
}
|
||
} catch (error) {
|
||
this.modalLoading = false;
|
||
// 即使出错也调用回调
|
||
if (callback) callback();
|
||
}
|
||
},
|
||
removeTaskSilently(index) {
|
||
// 静默删除任务,不显示确认对话框
|
||
if (index >= 0 && index < this.formData.tasklist.length) {
|
||
const task = this.formData.tasklist[index];
|
||
const taskName = task.taskname || task.task_name;
|
||
// 一次性任务(skip_calendar)不做日历清理,直接删除并保存
|
||
if (task.skip_calendar === true) {
|
||
this.formData.tasklist.splice(index, 1);
|
||
this.saveConfig();
|
||
return;
|
||
}
|
||
// 非一次性任务:删除前执行清理
|
||
axios.post('/api/calendar/purge_by_task', { task_name: taskName })
|
||
.then(() => {
|
||
this.formData.tasklist.splice(index, 1);
|
||
this.saveConfig();
|
||
})
|
||
.catch(() => {
|
||
this.formData.tasklist.splice(index, 1);
|
||
this.saveConfig();
|
||
});
|
||
}
|
||
},
|
||
openCreateTaskDatePicker() {
|
||
// 打开创建任务的日期选择器
|
||
if (this.$refs.createTaskEnddate) {
|
||
this.$refs.createTaskEnddate.showPicker();
|
||
}
|
||
},
|
||
|
||
// 添加日历页面窗口大小变化监听器
|
||
addCalendarResizeListener() {
|
||
// 移除之前的监听器(如果存在)
|
||
if (this.calendarResizeHandler) {
|
||
window.removeEventListener('resize', this.calendarResizeHandler);
|
||
}
|
||
|
||
// 创建新的监听器
|
||
this.calendarResizeHandler = this.debounce(() => {
|
||
if (this.activeTab === 'calendar') {
|
||
if (this.calendar.viewMode === 'poster' && !this.calendar.manageMode) {
|
||
this.updateWeekDates();
|
||
}
|
||
// 管理模式下仅触发布局tick,使用相同的列计算逻辑
|
||
if (this.calendar.manageMode) {
|
||
this.calendar.layoutTick = Date.now();
|
||
}
|
||
}
|
||
}, 300); // 300ms防抖
|
||
|
||
// 添加监听器
|
||
window.addEventListener('resize', this.calendarResizeHandler);
|
||
},
|
||
|
||
// 防抖函数
|
||
debounce(func, wait) {
|
||
let timeout;
|
||
return function executedFunction(...args) {
|
||
const later = () => {
|
||
clearTimeout(timeout);
|
||
func(...args);
|
||
};
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(later, wait);
|
||
};
|
||
},
|
||
|
||
// 获取追剧日历页面的可用宽度(排除侧边栏)
|
||
getCalendarAvailableWidth() {
|
||
const windowWidth = window.innerWidth || document.documentElement.clientWidth;
|
||
|
||
// 移动设备(小屏幕)
|
||
if (windowWidth < 768) {
|
||
// 移动设备下侧边栏会折叠,主内容区域占满宽度
|
||
return windowWidth - 20; // 减去左右边距
|
||
}
|
||
|
||
// 桌面设备(大屏幕)
|
||
let sidebarWidth = 184; // 默认展开状态宽度
|
||
|
||
// 如果侧边栏已折叠
|
||
if (this.sidebarCollapsed) {
|
||
sidebarWidth = 54; // 折叠状态宽度
|
||
}
|
||
|
||
// 计算主内容区域可用宽度
|
||
const availableWidth = windowWidth - sidebarWidth - 20; // 减去侧边栏宽度和左右边距
|
||
|
||
// 根据页面宽度模式应用最大宽度上限,避免中/窄模式下超限
|
||
// 对应 CSS: .page-width-narrow/.page-width-medium/.page-width-wide 的 container-fluid max-width
|
||
let maxContentWidth;
|
||
if (this.pageWidthMode === 'narrow') {
|
||
maxContentWidth = 1440; // 与 CSS 保持一致
|
||
} else if (this.pageWidthMode === 'medium') {
|
||
maxContentWidth = 1680; // 与 CSS 保持一致
|
||
} else {
|
||
maxContentWidth = 2160; // 宽模式上限
|
||
}
|
||
// 主内容区域在 Bootstrap 栅格中通常占 10/12 宽度(col-md-10 col-lg-10),需要按比例折算
|
||
// 这里以可视窗口宽度估算容器宽度并取上限,再减去侧边栏与边距后作为可用宽度
|
||
const containerMaxWidth = Math.min(windowWidth, maxContentWidth);
|
||
const maxAvailableByMode = containerMaxWidth - sidebarWidth - 20;
|
||
const boundedWidth = Math.min(availableWidth, maxAvailableByMode);
|
||
|
||
return Math.max(boundedWidth, 300); // 确保最小可用宽度为300px
|
||
},
|
||
|
||
// 格式化日期为YYYY-MM-DD格式(使用本地时间)
|
||
formatDateToYYYYMMDD(date) {
|
||
const year = date.getFullYear();
|
||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||
const day = date.getDate().toString().padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
},
|
||
mounted() {
|
||
this.fetchData();
|
||
this.checkNewVersion();
|
||
this.fetchUserInfo(); // 获取用户信息
|
||
|
||
// 添加点击事件监听
|
||
document.addEventListener('click', this.handleOutsideClick);
|
||
document.addEventListener('click', this.handleModalOutsideClick);
|
||
document.addEventListener('click', this.handleFileManagerOutsideClick);
|
||
|
||
// 添加模态框关闭事件监听
|
||
$('#fileSelectModal').on('hidden.bs.modal', () => {
|
||
this.fileSelect.selectedFiles = [];
|
||
this.fileSelect.lastSelectedFileIndex = -1;
|
||
// 重置移动模式相关参数
|
||
this.fileSelect.moveMode = false;
|
||
this.fileSelect.moveFileIds = [];
|
||
// 重置z-index
|
||
document.getElementById('fileSelectModal').style.zIndex = '';
|
||
});
|
||
|
||
// 检查本地存储中的标签页状态
|
||
const savedTab = localStorage.getItem('quarkAutoSave_activeTab');
|
||
if (savedTab) {
|
||
this.activeTab = savedTab;
|
||
}
|
||
|
||
// 从本地存储中恢复侧边栏折叠状态
|
||
const savedSidebarState = localStorage.getItem('quarkAutoSave_sidebarCollapsed');
|
||
if (savedSidebarState) {
|
||
this.sidebarCollapsed = savedSidebarState === 'true';
|
||
}
|
||
|
||
// 从本地存储中恢复用户设置的每页记录数
|
||
const savedPageSize = localStorage.getItem('quarkAutoSave_pageSize');
|
||
if (savedPageSize) {
|
||
this.historyParams.page_size = savedPageSize === 'all' ? 99999 : parseInt(savedPageSize);
|
||
}
|
||
|
||
// 从本地存储中恢复页面宽度设置
|
||
const savedPageWidthMode = localStorage.getItem('quarkAutoSave_pageWidthMode');
|
||
if (savedPageWidthMode) {
|
||
this.pageWidthMode = savedPageWidthMode;
|
||
document.body.classList.add('page-width-' + this.pageWidthMode);
|
||
} else {
|
||
// 默认使用中等宽度
|
||
document.body.classList.add('page-width-medium');
|
||
}
|
||
|
||
$('[data-toggle="tooltip"]').tooltip();
|
||
document.addEventListener('keydown', this.handleKeyDown);
|
||
document.addEventListener('click', (e) => {
|
||
// 如果点击的是输入框、搜索按钮或下拉菜单本身,不关闭下拉菜单
|
||
if (e.target.closest('.input-group input') ||
|
||
e.target.closest('.btn-primary[type="button"]') ||
|
||
e.target.closest('.dropdown-menu.task-suggestions') ||
|
||
e.target.closest('.bi-search') ||
|
||
// 点击发生在文件选择模态框内(包括右上角关闭按钮)时,不关闭下拉
|
||
e.target.closest('#fileSelectModal')) {
|
||
return;
|
||
}
|
||
// 只隐藏下拉菜单,不清空搜索结果,这样点击同一任务的输入框时还能看到之前的搜索结果
|
||
this.smart_param.showSuggestions = false;
|
||
});
|
||
|
||
// 添加点击事件监听器,用于在点击表格外区域时取消选择记录
|
||
document.addEventListener('click', this.handleOutsideClick);
|
||
|
||
// 添加点击事件监听器,用于在点击模态框表格外区域时取消选择文件
|
||
document.addEventListener('click', this.handleModalOutsideClick);
|
||
|
||
// 添加点击事件监听器,用于处理重命名编辑状态
|
||
document.addEventListener('click', this.handleRenameOutsideClick);
|
||
|
||
// 添加模态框关闭事件监听,清空选中文件列表
|
||
$('#fileSelectModal').on('hidden.bs.modal', () => {
|
||
this.fileSelect.selectedFiles = [];
|
||
this.fileSelect.lastSelectedFileIndex = -1;
|
||
// 重置移动模式相关参数
|
||
this.fileSelect.moveMode = false;
|
||
this.fileSelect.moveFileIds = [];
|
||
// 重置z-index
|
||
document.getElementById('fileSelectModal').style.zIndex = '';
|
||
});
|
||
|
||
window.addEventListener('beforeunload', this.handleBeforeUnload);
|
||
|
||
// 监听模态框显示事件,检查滚动条状态
|
||
$('#fileSelectModal').on('shown.bs.modal', this.checkPreviewScrollbar);
|
||
// 监听窗口大小改变,重新检查滚动条状态
|
||
window.addEventListener('resize', this.checkPreviewScrollbar);
|
||
|
||
// 初始化时检查所有任务的命名模式
|
||
setTimeout(() => {
|
||
if (this.formData.tasklist && this.formData.tasklist.length > 0) {
|
||
this.formData.tasklist.forEach(task => {
|
||
// 检查现有的顺序命名设置
|
||
if (task.use_sequence_naming && task.sequence_naming) {
|
||
// 已经设置过顺序命名的,将顺序命名模式转换为匹配表达式
|
||
if (!task.pattern || task._pattern_backup) {
|
||
task.pattern = task.sequence_naming;
|
||
}
|
||
} else if (task.use_episode_naming && task.episode_naming) {
|
||
// 已经设置过剧集命名的,将剧集命名模式转换为匹配表达式
|
||
if (!task.pattern || task._pattern_backup) {
|
||
task.pattern = task.episode_naming;
|
||
}
|
||
} else {
|
||
// 检测是否包含顺序命名或剧集命名模式
|
||
this.detectNamingMode(task);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 确保剧集识别模式字段存在(但不自动添加默认规则)
|
||
if (!this.formData.episode_patterns) {
|
||
this.formData.episode_patterns = [];
|
||
}
|
||
|
||
// 如果当前标签是历史记录,则加载历史记录
|
||
if (this.activeTab === 'history') {
|
||
this.loadHistoryRecords();
|
||
// 加载所有任务名称用于筛选
|
||
this.loadAllTaskNames();
|
||
}
|
||
|
||
// 添加对history.pagination的监听
|
||
this.$watch('history.pagination', function(newVal) {
|
||
if (newVal && newVal.total_pages) {
|
||
this.$nextTick(() => {
|
||
// 强制Vue更新视图
|
||
this.$forceUpdate();
|
||
});
|
||
}
|
||
}, { deep: true });
|
||
|
||
// 检查分享链接状态
|
||
this.checkShareUrlStatus();
|
||
|
||
// 初始化影视发现页面的选择状态
|
||
this.initializeDiscoverySelection();
|
||
|
||
// 如果当前是影视发现页面,加载榜单数据
|
||
if (this.activeTab === 'discovery') {
|
||
this.loadDiscoveryData();
|
||
}
|
||
|
||
// 如果当前是追剧日历页面,加载日历数据
|
||
if (this.activeTab === 'calendar') {
|
||
this.loadCalendarData();
|
||
}
|
||
|
||
// 如果当前是任务列表页面,启动后台监听
|
||
if (this.activeTab === 'tasklist') {
|
||
this.startTasklistAutoWatch();
|
||
}
|
||
}, 500);
|
||
},
|
||
beforeDestroy() {
|
||
window.removeEventListener('beforeunload', this.handleBeforeUnload);
|
||
// 移除点击事件监听器
|
||
document.removeEventListener('click', this.handleOutsideClick);
|
||
document.removeEventListener('click', this.handleRenameOutsideClick);
|
||
|
||
// 移除日历页面resize监听器
|
||
if (this.calendarResizeHandler) {
|
||
window.removeEventListener('resize', this.calendarResizeHandler);
|
||
}
|
||
|
||
// 停止任务列表后台监听
|
||
this.stopTasklistAutoWatch();
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|
||
|