quark-auto-save/app/templates/index.html
x1ao4 e69ad46ddd 优化选择文件夹模态框的文件名排序逻辑
- 为 source/target/move 三个模态框调整文件名排序优先级
- 文件:日期 > 期数 > 上中下 > 拼音 > 修改日期
- 文件夹:日期 > 上中下 > 拼音 > 修改日期
- 拼音排序键提前到修改日期之前,便于按拼音排序
2025-11-16 11:23:41 +08:00

13562 lines
666 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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="/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到设定秒数之间随机延迟执行。建议值036000表示不延迟">
<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">&nbsp;忽略后缀
</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">&nbsp;添加语言代码
</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时获取的文件数量适当增大该数值可减少请求次数提升大文件夹的加载效率。建议值100500">
<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-3000表示不缓存">
<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或负数可关闭自动刷新。默认值216006小时">
<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>
<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 === 'tasklist'">
<div style="height: 20px;"></div>
<div class="row" style="margin-bottom: 8px;">
<div class="col-lg-6 col-md-6 mb-2 mb-md-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-lg-6 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="taskDirSelected" style="padding-left: 8px !important; text-indent: 0 !important; display: flex !important; align-items: center !important; line-height: 1.5 !important; padding-right: 24px !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>
<!-- 任务列表:类型筛选按钮和排序组件(复用追剧日历移动端样式) -->
<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="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)">
<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">&nbsp;忽略后缀
</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)">
<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" style="margin-bottom: 20px;">
<div class="col-lg-6 col-md-6 mb-2 mb-md-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-lg-6 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="historyTaskSelected" style="padding-left: 8px !important; text-indent: 0 !important; display: flex !important; align-items: center !important; line-height: 1.5 !important; padding-right: 24px !important;">
<option value="">全部任务</option>
<option v-for="task in historyTasks" :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('historyTaskSelected')"><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">&nbsp;含文件夹
</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">&nbsp;含文件夹
</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 calendar-filter-row" style="margin-bottom: 20px;">
<div class="col-lg-6 col-md-6 mb-2 mb-md-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-lg-6 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="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: 24px !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>
<!-- 分类按钮和视图切换 -->
<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">&nbsp;忽略后缀
</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"
},
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: "",
taskDirs: [""],
taskDirSelected: "",
taskNameFilter: "",
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: "",
gotoPage: 1,
totalPages: 1,
displayedPages: [],
allTaskNames: [],
toastMessage: "",
selectedRecords: [],
lastSelectedRecordIndex: -1, // 记录最后选择的记录索引用于Shift选择
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: '',
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);
}
// 按匹配状态和任务名称拼音排序:匹配的项目在前,未匹配的项目在后
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();
},
// 判断是否应该显示任务列表(避免排序闪烁)
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]);
},
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();
}
},
// 侧边栏折叠/展开变化时触发布局重算
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();
}
},
activeTab(newValue, oldValue) {
// 如果切换到任务列表页面,则刷新任务最新信息和元数据
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);
},
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();
},
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;
// 统一 onopenSSE 成功后停止两侧轮询
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; }
},
// 获取根据当前视图筛选条件过滤后的任务中编号最大的任务
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 => {
// 根据筛选条件过滤
if (this.calendar.selectedType !== 'all') {
// 通过剧集名称匹配任务,获取内容类型
const matchedTask = this.findTaskByShowName(episode.show_name);
const episodeContentType = matchedTask ? matchedTask.content_type : '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 matchedTask = this.findTaskByShowName(episode.show_name);
const taskName = matchedTask ? matchedTask.task_name : '';
if (taskName !== this.calendar.taskFilter) {
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);
}
} 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 = '';
}
},
// 更新内容类型列表(用于热更新)
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" };
} 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";
}
}
// 确保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] = "";
},
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;
}
// 判断筛选条件是否变化,只有变化时才重置页码
const isFilterChanged =
(this._lastTaskFilter !== this.historyTaskSelected) ||
(this._lastNameFilter !== this.historyNameFilter);
if (isFilterChanged) {
// 筛选条件变化时重置为第一页
params.page = 1;
this.gotoPage = 1;
}
// 记录当前筛选条件
this._lastTaskFilter = this.historyTaskSelected;
this._lastNameFilter = this.historyNameFilter;
// 更新当前参数
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;
});
},
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;
}
// 重新加载数据,使用当前页和当前设置的排序方式
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;
}
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;
}
// 重新加载数据
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 current = parseInt(this.historyParams.page) || 1;
const total = parseInt(this.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;
},
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>