1、nfo文件优化,修复演员信息(视频作者)

2、图文视频,因版权原因无法下载的音频,系统配置新增默认音频上传入口,上传成功后,后面在遇到无法下载音频时,将用该音频进行图文视频合成的音频数据
3、增加动态视频合成配置。
4、容器重启后,手机端会连续跳转登录页很多次的bug修复
5、其他优化
This commit is contained in:
jianzhichu
2026-01-12 22:13:59 +08:00
parent 858a21ec9e
commit e32d783de4
21 changed files with 1286 additions and 483 deletions
-1
View File
@@ -31,7 +31,6 @@ const isMobileBrowser = computed(() => {
const enforceMobileRoute = () => {
const targetMobilePath = '/mobile';
const currentPath = route.path.toLowerCase().trim();
// 核心规则:手机浏览器 → 强制跳转到/mobile(无论当前路由是什么)
if (isMobileBrowser.value) {
if (currentPath !== targetMobilePath) {
+1 -1
View File
@@ -368,7 +368,7 @@ const loadLogDetailContent = (log: LogItem) => {
try {
const type = log.type.toLowerCase();
const date = log.date;
const requestParams = `type=${type}&date=${date}`;
const requestParams = `${type}/${date}`;
useApiStore()
.apiGetLogs(requestParams)
+176 -72
View File
@@ -1,92 +1,196 @@
<template>
<a-form layout="inline" style="margin-top:5px;margin-bottom:5px;">
<a-form-item>
<a-date-picker v-model:value="dateValue" format="YYYYMMDD" :locale="locale" @change="datePickChange" />
</a-form-item>
<a-form-item>
<a-radio-group v-model:value="typeValue" button-style="solid" @change="typeChange">
<a-radio-button value="debug">debug</a-radio-button>
<a-radio-button value="error">error</a-radio-button>
</a-radio-group>
</a-form-item>
</a-form>
<div class="container">
<a-card title="" :bordered="true">
<pre>{{ logs }}</pre>
<div style="margin: 5px 0;">
<!-- 日志类型按钮组 -->
<a-button-group>
<a-button :type="typeValue === 'debug' ? 'primary' : 'default'" @click="typeValue = 'debug'; loadLogs()" class="type-btn">
普通日志
</a-button>
<a-button :type="typeValue === 'error' ? 'primary' : 'default'" @click="typeValue = 'error'; loadLogs()" class="type-btn">
错误日志
</a-button>
</a-button-group>
<!-- 日期操作按钮组核心调整颜色 -->
<a-button-group style="margin-left: 10px;" class="date-btn-group">
<a-button @click="changeDate('prev')">前一天</a-button>
<a-button class="today-btn" @click="changeDate('current')">今天</a-button>
<a-button @click="changeDate('next')">下一天</a-button>
</a-button-group>
</div>
<!-- 日志展示区域 -->
<div class="log-container">
<a-card bordered>
<pre class="log-content">{{ logs }}</pre>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { defineComponent, reactive, ref, watch, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useApiStore } from '@/store';
import type { UnwrapRef } from 'vue';
import dayjs, { Dayjs } from 'dayjs';
import locale from 'ant-design-vue/es/date-picker/locale/zh_CN';
type RangeValue = [Dayjs, Dayjs];
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
// 初始化dayjs中文环境
dayjs.locale('zh-cn');
const dateValue = ref<Dayjs>(dayjs(Date()));
const typeValue = ref<string>('debug');
const iframeUrl = ref<string>('');
const dateValue1 = ref<string>();
const logs = ref<string>('');
dateValue1.value = dayjs(Date()).format('YYYYMMDD');
iframeUrl.value = `type=${typeValue.value}&date=${dateValue1.value}`;
const datePickChange = (e, dateStr) => {
dateValue1.value = dateStr;
iframeUrl.value = `type=${typeValue.value}&date=${dateValue1.value}`;
console.log(iframeUrl);
// 核心变量(极简:只维护两个核心变量)
const typeValue = ref<string>('debug'); // 日志类型
const currentDate = ref<dayjs.Dayjs>(dayjs()); // 当前选中的日期(唯一日期变量)
const logs = ref<string>('加载中...'); // 日志内容
// 计算可访问的日期范围(近10天:今天 ~ 9天前)
const maxDate = computed(() => dayjs()); // 最大日期:今天
const minDate = computed(() => dayjs().subtract(9, 'day')); // 最小日期:9天前
// 格式化日期文本(供显示和请求使用)
const currentDateText = computed(() => currentDate.value.format('YYYYMMDD'));
const minDateText = computed(() => minDate.value.format('YYYYMMDD'));
const maxDateText = computed(() => maxDate.value.format('YYYYMMDD'));
// 日期切换核心逻辑(极简:直接修改currentDate
const changeDate = (action: 'prev' | 'current' | 'next') => {
let newDate: dayjs.Dayjs;
switch (action) {
case 'prev':
newDate = currentDate.value.subtract(1, 'day');
break;
case 'next':
newDate = currentDate.value.add(1, 'day');
break;
case 'current':
default:
newDate = dayjs();
break;
}
// 范围校验:限制在10天内
if (newDate.isBefore(minDate.value)) {
newDate = minDate.value;
logs.value = `已到最早可查看日期(${minDateText.value}),无法继续往前`;
} else if (newDate.isAfter(maxDate.value)) {
newDate = maxDate.value;
logs.value = `已到最新可查看日期(${maxDateText.value}),无法继续往后`;
}
// 更新当前日期并加载日志
currentDate.value = newDate;
loadLogs();
};
const typeChange = (e) => {
console.log(e.target);
iframeUrl.value = `type=${e.target.value}&date=${dateValue1.value}`;
loadLogs();
};
const mIfrm = ref<any>(null);
// 核心:处理日志时间戳,移除 .xxx +08:00 部分
const formatLogTime = (logContent: string) => {
// 正则匹配:2025-12-11 18:41:39.624 +08:00 格式,捕获前面的日期时间部分
// 正则解释:
// (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) 捕获 年-月-日 时:分:秒
// \.\d+ \+08:00 匹配 .毫秒 +时区 部分
const timeRegex = /(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.\d+ \+08:00/g;
// 替换匹配到的内容,只保留捕获的日期时间部分
return logContent.replace(timeRegex, '$1');
};
onMounted(() => {
// console.log(mIfrm.value);
loadLogs();
});
// 加载日志数据
const loadLogs = () => {
useApiStore()
.apiGetLogs(iframeUrl.value)
.then((log) => {
// console.log(log);
log = formatLogTime(log);
const lines = log.split('\n'); // 将文本按换行符分割为行数组
const reversedLines = lines.reverse(); // 对行数组进行倒序操作
const reversedText = reversedLines.join('\n'); // 将行数组重新连接为一个字符串
// 拼接请求参数
const params = `${typeValue.value}/${currentDateText.value}`;
logs.value = reversedText;
// 调用接口加载日志
useApiStore()
.apiGetLogs(params)
.then((logContent) => {
if (!logContent) {
logs.value = `${currentDateText.value}】暂无${typeValue.value === 'debug' ? '普通' : '错误'}日志数据`;
return;
}
// 格式化日志时间(移除毫秒和时区)
const formatLog = logContent.replace(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.\d+ \+08:00/g, '$1');
// 倒序显示
logs.value = formatLog.split('\n').reverse().join('\n');
})
.catch((err) => {
console.error('加载日志失败:', err);
logs.value = `加载日志失败:${err.message || '网络异常'}`;
});
};
// 页面挂载时加载初始日志
onMounted(() => {
loadLogs();
});
</script>
<style lang='less' scoped>
html {
height: 100vh;
}
.container {
<style lang='less' scoped>
// 日志展示容器样式
.log-container {
width: 100%;
height: 100%;
// max-height: 400px;
iframe {
.word-wrap {
color: white !important;
height: calc(100vh - 80px);
margin-top: 10px;
.log-content {
white-space: pre-wrap;
word-wrap: break-word;
height: 100%;
max-height: 100%;
overflow-y: auto;
margin: 0;
padding: 15px;
background: #f9f9f9;
font-size: 14px;
line-height: 1.5;
}
}
// 按钮组基础样式
:deep(.ant-btn-group) {
.ant-btn {
height: 36px;
padding: 0 18px;
font-size: 14px;
border-radius: 0 !important; // 按钮组圆角处理
transition: all 0.2s ease;
}
// 第一个按钮左圆角
.ant-btn:first-child {
border-radius: 6px 0 0 6px !important;
}
// 最后一个按钮右圆角
.ant-btn:last-child {
border-radius: 0 6px 6px 0 !important;
}
}
// ========== 核心修改:日期按钮颜色 ==========
.date-btn-group {
:deep(.ant-btn) {
// 日期按钮默认样式
background: #fff;
border-color: #e6e6e6;
color: #333;
&:hover {
background: #f0f9ff;
border-color: #00b42a; // hover时边框变绿色
color: #00b42a;
}
}
// “今天”按钮高亮样式(核心颜色修改处)
:deep(.today-btn) {
background: #00b42a !important; // 深绿色背景(可替换成你想要的颜色)
border-color: #00b42a !important;
color: #fff !important;
font-weight: 500;
&:hover {
background: #00c834 !important; // hover时稍浅的绿色
border-color: #00c834 !important;
}
}
}
</style>
// ========== 可选:日志类型按钮颜色也同步调整(可选) ==========
.type-btn {
:deep(&.ant-btn-primary) {
background: #722ed1 !important; // 日志类型选中用紫色(可改)
border-color: #722ed1 !important;
color: #fff !important;
&:hover {
background: #8046e0 !important;
border-color: #8046e0 !important;
}
}
}
</style>
+367 -108
View File
@@ -1,12 +1,11 @@
<template>
<a-card :bordered="false" :body-style="{ padding: '10px' }">
<a-form :model="formState" :label-col="labelCol" :rules="rules" :wrapper-col="wrapperCol" ref="formRef" label-align="right">
<!-- 原有所有表单内容保持不变 -->
<!-- 任务调度配置 -->
<div class="form-section">
<h3 class="section-title">任务调度</h3>
<a-form-item has-feedback label="同步周期(分钟)" name="Cron" :wrapper-col="{ span: 20}" style="margin-left:30px">
<a-form-item has-feedback label="同步周期(分钟)" name="Cron" :wrapper-col="{ span: 20 }" style="margin-left: 30px">
<a-input-number v-model:value="formState.Cron" placeholder="请输入数字" :min="15" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
@@ -14,7 +13,7 @@
</div>
</a-form-item>
<a-form-item has-feedback label="每次同步上限" name="BatchCount" :wrapper-col="{ span:20}" style="margin-left:30px">
<a-form-item has-feedback label="每次同步上限" name="BatchCount" :wrapper-col="{ span: 20 }" style="margin-left: 30px">
<a-input-number v-model:value="formState.BatchCount" placeholder="请输入每次下载数量上限(最大30)" :min="10" :max="30" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
@@ -33,13 +32,13 @@
<!-- 文件保存配置 -->
<div class="form-section">
<h3 class="section-title">博主视频仅关注有效</h3>
<h3 class="section-title">博主视频</h3>
<a-form-item has-feedback label="标题当文件名" name="UperUseViedoTitle" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.UperUseViedoTitle" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>启用用原标题未启用用模板无模板则默认用视频Id</span>
<span>开启后用原标题作为文件名不开启但又没设置标题规则模板则默认用视频id命名</span>
</div>
</a-form-item>
@@ -47,7 +46,7 @@
<a-switch v-model:checked="formState.UperSaveTogether" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>默认按博主名建文件夹后直接存映射目录根目录</span>
<span>默认按博主名建文件夹启后直接存映射目录根目录</span>
</div>
</a-form-item>
@@ -55,8 +54,11 @@
<a-form-item has-feedback label="定义标题模板" name="FollowedTitleTemplate" :wrapper-col="{ span: 12 }" v-if="!formState.UperUseViedoTitle">
<a-select v-model:value="formState.FollowedTitleTemplate" :options="template_options" mode="multiple" size="middle" placeholder="请选择占位符组合"></a-select>
<div class="flex items-start mt-1 text-sm text-gray-500">
<p></p>
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>选择文件名占位符顺序为文件名顺序需配合分隔符<br /><strong class="text-gray-700">占位符</strong>{Id}=视频ID{VideoTitle}=标题{ReleaseTime}=发布时间{Author}=博主名{FileHash}=文件哈希{Resolution}=分辨率</span>
<span style="color: red">
请选择文件名占位符和模板分隔符文件名命名规则配置仅博主视频有效
</span>
</div>
</a-form-item>
@@ -86,51 +88,97 @@
<div class="form-section" v-if="downImgVideo">
<h3 class="section-title">图文视频</h3>
<a-form-item has-feedback label="是否单独存储" name="ImageViedoSaveAlone" :wrapper-col="{ span: 20 }">
<a-form-item has-feedback label="下载图文视频" name="DownImageVideo" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownImageVideo" @change="downImageVideoHandler" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>开启后将图片文件和音频文件合成为视频文件</span>
</div>
</a-form-item>
<a-form-item v-if="formState.DownImageVideo" has-feedback label="单独存储" name="ImageViedoSaveAlone" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.ImageViedoSaveAlone" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>
开启图文视频统一存入抖音授权 Cookie 配置的目录且需提前配置该存储路径关闭按类型分别存入对应文件夹如收藏视频存入收藏视频目录
开启图文视频统一存入抖音授权 Cookie 配置的目录且需提前配置该存储路径关闭按类型分别存入对应文件夹如收藏视频存入收藏视频目录
</span>
</div>
</a-form-item>
<a-form-item has-feedback label="下载图文视频" name="DownImageVideo" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownImageVideo" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>启用后会将图片合成为视频文件下载</span>
</div>
</a-form-item>
<a-form-item has-feedback label="下载音频文件" name="DownMp3" :wrapper-col="{ span: 20 }">
<a-form-item v-if="formState.DownImageVideo" has-feedback label="保留音频" name="DownMp3" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownMp3" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>单独下载音频文件</span>
<span>启后保留音频文件</span>
</div>
</a-form-item>
<a-form-item has-feedback label="下载图片文件" name="DownImage" :wrapper-col="{ span: 20 }">
<a-form-item v-if="formState.DownImageVideo" has-feedback label="保留图片" name="DownImage" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownImage" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>单独下载所有图片文件</span>
</div>
</a-form-item>
<a-form-item has-feedback label="下载动态视频" name="DownImage" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownDynamicVideo" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>针对有些视频是多个视频生成的实际是分为多个视频启用后将会分别下载多个视频名字带_001,002这样</span>
<span>启后保留图片文件</span>
</div>
</a-form-item>
<a-form-item v-if="formState.DownImageVideo" has-feedback label="默认音频" name="AudioFile" :wrapper-col="{ span: 20 }">
<!-- 音频上传与播放器容器 -->
<div class="audio-upload-player-wrapper" style="display: flex; align-items: center; gap: 16px;">
<a-upload :before-upload="beforeUpload" :custom-request="customUpload" :show-upload-list="false" accept=".mp3,.wav">
<a-button type="default">
<UploadOutlined /> 选择默认音频文件
</a-button>
</a-upload>
<!-- 新增启用原生完整控件controls属性自带可拖拽进度条 -->
<div class="audio-player" v-if="audioUrl" style="flex: 1; max-width: 500px;">
<audio ref="audioInstance" :src="audioUrl" controls controlsList="nodownload" @ended="() => isPlaying = false" @pause="() => isPlaying = false" @play="() => isPlaying = true" class="native-audio-player">
您的浏览器不支持HTML5音频播放请升级至现代浏览器
</audio>
</div>
</div>
<div class="flex items-start mt-1 text-sm text-gray-500" style="color:red">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>当下载图文视频时音频因版权原因无法下载时将用该音频文件作为合成视频的音频</span>
</div>
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>支持格式MP3WAVAACFLACOGGM4AWMA单个文件最大20MB</span>
</div>
</a-form-item>
</div>
<div class="form-section">
<h3 class="section-title">动态视频</h3>
<a-form-item has-feedback label="下载动态视频" name="DownDynamicVideo" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownDynamicVideo" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>针对有些视频是多个视频生成的实际是分为多个视频开启后将会分别下载多个视频名字带_001,002这样</span>
</div>
</a-form-item>
<a-form-item v-if="formState.DownDynamicVideo" has-feedback label="合并动态视频" name="MegDynamicVideo" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.MegDynamicVideo" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>开启后将多个动态视频会合并为一个视频</span>
</div>
</a-form-item>
<a-form-item v-if="formState.MegDynamicVideo" has-feedback label="保留原视频" name="KeepDynamicVideo" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.KeepDynamicVideo" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>开启后将保留合成视频之前的每个短视频</span>
</div>
</a-form-item>
</div>
<!-- 系统配置 -->
<div class="form-section">
<h3 class="section-title">去重配置</h3>
<h3 class="section-title">视频去重</h3>
<a-form-item v-show="formState.AutoDistinct" has-feedback label="去重优先等级" name="PriorityLevel" :wrapper-col="{ span: 20 }">
<!-- Tag 拖拽容器 -->
@@ -150,7 +198,6 @@
鼠标放到点击鼠标左键即可拖拽调整优先级</span>
</div>
</a-form-item>
</div>
<!-- 操作按钮 -->
@@ -168,13 +215,11 @@
</a-form>
</a-card>
<!-- 配置导入导出悬浮按钮优化布局+动画 -->
<div class="config-float-btn-container">
<!-- 主按钮 -->
<a-tooltip title="配置导出导入" placement="left">
<a-button class="main-float-btn" type="primary" shape="circle">
<tool-outlined />
<shake-outlined />
</a-button>
</a-tooltip>
@@ -183,38 +228,47 @@
<!-- 关键修改添加 Tooltip 组件包裹导出按钮 -->
<a-tooltip title="导出配置" placement="left">
<a-button class="sub-float-btn export-btn" type="default" shape="circle" @click="exportConfig">
<cloud-download-outlined />
<CloudDownloadOutlined />
</a-button>
</a-tooltip>
<!-- 关键修改添加 Tooltip 组件包裹导入按钮 -->
<a-tooltip title="导入配置" placement="left">
<a-button class="sub-float-btn import-btn" type="default" shape="circle" @click="triggerImportFile">
<cloud-upload-outlined />
<CloudUploadOutlined />
</a-button>
</a-tooltip>
</div>
<input ref="importFileInput" type="file" accept=".json" class="import-file-input" @change="handleImportFile">
</div>
<div class="top-right-float-btn-container">
<a-tooltip title="重新对同步好的视频文件进行刮削" placement="bottom">
<a-button class="top-right-float-btn" type="primary" shape="circle" @click="renfo">
<bulb-outlined />
</a-button>
</a-tooltip>
</div>
</template>
<script lang="ts" setup>
import { reactive, toRaw, ref, watch, onMounted, computed, nextTick } from 'vue';
import type { UnwrapRef } from 'vue';
import { Form, Tooltip } from 'ant-design-vue'; // 关键修改:导入 Tooltip 组件
import { Form, Tooltip } from 'ant-design-vue';
import type { Rule } from 'ant-design-vue/es/form';
import type { FormInstance } from 'ant-design-vue';
import { useApiStore } from '@/store';
import { message } from 'ant-design-vue';
import { message, Modal } from 'ant-design-vue';
import { Sortable } from 'sortablejs';
import type { UploadProps } from 'ant-design-vue/es/upload/interface';
import {
InfoCircleOutlined,
SaveOutlined,
CheckOutlined,
ToolOutlined, // 修正:原代码中用了 tool-outlined 但未导入
ToolOutlined,
CloudDownloadOutlined,
CloudUploadOutlined,
UploadOutlined,
} from '@ant-design/icons-vue';
// 表单引用
@@ -238,7 +292,15 @@ const downImgVideo = ref(true);
const floatMenuVisible = ref(false);
const importFileInput = ref<HTMLInputElement | null>(null);
// 表单数据结构(新增 FullFollowedTitleTemplate 字段
// 新增:文件上传相关状态(已删除 uploadFileList,仅保留 isUploading 可选
const isUploading = ref(false);
//开启或关闭合成视频
const downImageVideoHandler = () => {
if (formState.DownImageVideo) {
}
};
// 表单数据结构(包含 FullFollowedTitleTemplate 字段)
interface FormState {
Cron: number;
Id: string;
@@ -250,12 +312,14 @@ interface FormState {
DownImage: boolean;
DownMp3: boolean;
ImageViedoSaveAlone: boolean;
FollowedTitleTemplate: string[]; // 占位符数组
FollowedTitleSeparator: string; // 分隔符
FullFollowedTitleTemplate: string; // 新增:完整模板字符串(自动生成)
FollowedTitleTemplate: string[];
FollowedTitleSeparator: string;
FullFollowedTitleTemplate: string;
AutoDistinct: boolean;
PriorityLevel: string;
DownDynamicVideo: boolean;
MegDynamicVideo: boolean; // 补充原有缺失字段
KeepDynamicVideo: boolean; // 补充原有缺失字段
OnlySyncNew: boolean;
}
@@ -273,24 +337,25 @@ const formState: UnwrapRef<FormState> = reactive({
ImageViedoSaveAlone: true,
FollowedTitleTemplate: [],
FollowedTitleSeparator: '',
FullFollowedTitleTemplate: '', // 初始为空
FullFollowedTitleTemplate: '',
AutoDistinct: false,
PriorityLevel: '',
DownDynamicVideo: false,
MegDynamicVideo: false, // 初始化缺失字段
KeepDynamicVideo: false, // 初始化缺失字段
OnlySyncNew: false,
});
// 实时计算完整模板(可选:让用户实时预览,提交时无需重复计算)
// 实时计算完整模板
const computeFullTemplate = computed(() => {
return formState.FollowedTitleTemplate.join(formState.FollowedTitleSeparator);
});
// 监听占位符/分隔符变化,实时更新预览(可选)
// 监听占位符/分隔符变化,实时更新预览
watch(
[() => [...formState.FollowedTitleTemplate], () => formState.FollowedTitleSeparator],
() => {
if (!formState.UperUseViedoTitle) {
// 只有关闭标题当文件名时才更新预览
formState.FullFollowedTitleTemplate = computeFullTemplate.value;
}
},
@@ -302,21 +367,18 @@ watch(
() => formState.UperUseViedoTitle,
(isEnabled) => {
if (isEnabled) {
// 开启时清空模板相关数据,避免数据残留
formState.FollowedTitleTemplate = [];
formState.FollowedTitleSeparator = '';
formState.FullFollowedTitleTemplate = '';
// 清空相关表单项的校验状态
formRef.value?.clearValidate(['FollowedTitleTemplate', 'FollowedTitleSeparator', 'FullFollowedTitleTemplate']);
} else {
// 关闭时重新计算完整模板
formState.FullFollowedTitleTemplate = computeFullTemplate.value;
}
},
{ immediate: true }
);
// 表单校验规则(新增 FullFollowedTitleTemplate 校验)
// 表单校验规则
const rules: Record<string, Rule[]> = {
FollowedTitleSeparator: [{ max: 5, message: '分隔符长度不能超过5个字符', trigger: 'change' }],
FullFollowedTitleTemplate: [{ max: 200, message: '完整模板字符串长度不能超过200个字符', trigger: 'change' }],
@@ -326,17 +388,15 @@ const rules: Record<string, Rule[]> = {
const labelCol = { style: { width: '150px', textAlign: 'right' } };
const wrapperCol = { span: 12 };
// 获取配置数据(适配 FullFollowedTitleTemplate 字段)
// 获取配置数据
const getConfig = () => {
useApiStore()
.apiGetConfig()
.then((res) => {
if (res.code === 0) {
// 优先从 FullFollowedTitleTemplate 解析占位符数组(确保数据一致)
const fullTemplate = res.data.FullFollowedTitleTemplate || res.data.followedTitleTemplate || '';
const parsedTemplateArr = parseTemplateToArr(fullTemplate);
// 赋值所有字段(包含新增的 FullFollowedTitleTemplate
Object.assign(formState, {
Cron: res.data.cron,
Id: res.data.id,
@@ -349,15 +409,17 @@ const getConfig = () => {
DownMp3: res.data.downMp3,
FollowedTitleTemplate: parsedTemplateArr,
FollowedTitleSeparator: res.data.followedTitleSeparator || '',
FullFollowedTitleTemplate: fullTemplate, // 回显完整模板
FullFollowedTitleTemplate: fullTemplate,
ImageViedoSaveAlone: res.data.imageViedoSaveAlone,
AutoDistinct: res.data.autoDistinct,
PriorityLevel: res.data.priorityLevel,
DownDynamicVideo: res.data.downDynamicVideo,
MegDynamicVideo: res.data.megDynamicVideo || false, // 补充赋值
KeepDynamicVideo: res.data.keepDynamicVideo || false, // 补充赋值
OnlySyncNew: res.data.onlySyncNew,
});
tagData.value = JSON.parse(res.data.priorityLevel);
tagData.value = JSON.parse(res.data.priorityLevel || '[]');
} else {
message.error(res.message || '获取配置失败', 8);
}
@@ -368,7 +430,7 @@ const getConfig = () => {
});
};
// 模板字符串 → 占位符数组(原有逻辑不变)
// 模板字符串 → 占位符数组
const parseTemplateToArr = (templateStr: string | null | undefined) => {
if (!templateStr || typeof templateStr !== 'string' || templateStr.trim() === '') {
return [];
@@ -382,24 +444,153 @@ const parseTemplateToArr = (templateStr: string | null | undefined) => {
// 拖拽容器 ref
const tagContainer = ref(null);
let sortableInstance = ref(null);
// 模拟 Tag 数据(替换为你的实际数据)
let sortableInstance = ref<any>(null);
// 模拟 Tag 数据
const tagData = ref([
{ id: 1, name: '喜欢的视频', sort: 1 },
{ id: 2, name: '收藏的视频', sort: 2 },
{ id: 3, name: '关注的视频', sort: 3 },
]);
// 重新计算所有 Tag 的 sort 值(核心方法)
// 重新计算所有 Tag 的 sort 值
const updateTagSort = () => {
tagData.value.forEach((item, index) => {
item.sort = index + 1; // sort = 索引+1(保证顺序和 sort 一一对应)
item.sort = index + 1;
});
};
// ========== 文件上传相关方法(已修改:移除进度条逻辑) ==========
/**
* 上传前校验
*/
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
// 1. 校验文件大小(10MB
const isLt20M = file.size / 1024 / 1024 < 20;
if (!isLt20M) {
message.error('文件大小不能超过20MB!');
return false;
}
// 2. 校验文件类型
const acceptTypes = ['.mp3', '.wav', '.aac', '.flac', '.ogg', '.m4a', '.wma'];
const fileExt = '.' + file.name.split('.').pop()?.toLowerCase();
if (!acceptTypes.includes(fileExt || '')) {
message.error('仅支持MP3、WAV、AAC、FLAC、OGG、M4A、WMA格式的音频文件!');
return false;
}
return true;
};
// 1. 新增音频播放相关状态(放在现有状态定义区域,如 isUploading 下方)
const audioUrl = ref('/api/config/defaudio'); // 上传成功后的音频文件URL
const isPlaying = ref(false); // 音频是否正在播放
const audioInstance = ref<HTMLAudioElement | null>(null); // 音频播放器实例
// 2. 改造原有 customUpload 方法,保存上传成功后的音频URL
const customUpload: UploadProps['customRequest'] = (options) => {
const { file, onSuccess, onError } = options;
isUploading.value = true;
// 构造FormData
const formData = new FormData();
formData.append('file', file);
useApiStore()
.apiUploadAudio(formData)
.then((res) => {
console.log(res);
if (res.code === 0) {
// message.success('音频文件上传成功!');
audioUrl.value = `/api/config/defaudio?t=${Date.now()}`;
onSuccess(res);
} else {
message.error(res.message || '文件上传失败!');
onError(new Error(res.message || '上传失败'), file);
}
})
.catch((error) => {
console.error('文件上传失败:', error);
message.error('文件上传失败,请稍后重试!');
onError(error, file);
})
.finally(() => {
isUploading.value = false;
});
};
// 新增:封装 load() 为 Promise (可复用)
const audioLoadPromise = (audio: HTMLAudioElement): Promise<void> => {
return new Promise((resolve, reject) => {
// 加载成功回调
const onLoadSuccess = () => {
audio.removeEventListener('canplaythrough', onLoadSuccess);
audio.removeEventListener('error', onLoadError);
resolve();
};
// 加载失败回调
const onLoadError = () => {
audio.removeEventListener('canplaythrough', onLoadSuccess);
audio.removeEventListener('error', onLoadError);
reject(new Error('音频加载失败'));
};
audio.addEventListener('canplaythrough', onLoadSuccess);
audio.addEventListener('error', onLoadError);
audio.load();
});
};
// 修改 refreshAudioPlayer 为 async 方法
const refreshAudioPlayer = async () => {
if (!audioInstance.value) return;
try {
// 1. 暂停播放、重置状态
audioInstance.value.pause();
audioInstance.value.currentTime = 0;
isPlaying.value = false;
// 2. 等待加载完成(核心:解决时序冲突)
await audioLoadPromise(audioInstance.value);
// 3. 加载完成后,安全调用 play()
await audioInstance.value.play();
isPlaying.value = true;
// message.success('音频已自动播放');
} catch (err) {
if ((err as Error).message !== '音频加载失败') {
// 区分加载失败和自动播放失败
message.warning('自动播放失败,请手动点击播放按钮(浏览器限制)');
} else {
message.error('音频加载失败,无法自动播放');
}
console.log('错误详情:', err);
isPlaying.value = false;
}
};
// 监听 audioUrl 变化,重置播放状态(可选,优化用户体验)
watch(
audioUrl,
(newVal, oldVal) => {
// 排除初始值(仅当 url 发生有效变更时刷新)
if (newVal && newVal !== oldVal) {
isPlaying.value = false;
// 核心:调用刷新方法,强制加载新音频
refreshAudioPlayer();
} else if (!newVal) {
// 若 url 为空,仅重置状态
isPlaying.value = false;
if (audioInstance.value) {
audioInstance.value.currentTime = 0;
}
}
},
{ immediate: false }
); // 关闭 immediate,避免初始加载时触发
// 组件挂载时获取配置
onMounted(async () => {
getConfig();
// 等待 Form 组件完全渲染(关键:解决 DOM 未挂载问题)
await nextTick();
if (tagContainer.value) {
sortableInstance.value = new Sortable(tagContainer.value, {
@@ -408,14 +599,9 @@ onMounted(async () => {
ghostClass: 'tag-ghost',
preventOnFilter: true,
onEnd: (evt) => {
// 1. 调整 Tag 顺序
const [movedTag] = tagData.value.splice(evt.oldIndex, 1);
tagData.value.splice(evt.newIndex, 0, movedTag);
// 2. 重新计算所有 Tag 的 sort 值(关键:同步 sort 和顺序)
updateTagSort();
// 打印结果(验证 sort 是否同步)
console.log('最新 Tag 数据(含 sort):', tagData.value);
},
});
@@ -424,28 +610,23 @@ onMounted(async () => {
}
});
// 提交表单(核心:自动生成完整模板字符串)
// 提交表单
const onSubmit = () => {
formRef.value
.validate()
?.validate()
.then(() => {
// 1. 如果开启了标题当文件名,清空模板相关字段
let fullTemplate = '';
let templateTitle = '';
if (!formState.UperUseViedoTitle) {
// 自动拼接完整模板字符串(数组 + 分隔符)
fullTemplate = formState.FollowedTitleTemplate.join(formState.FollowedTitleSeparator);
}
// 2. 构造提交数据(包含三个模板相关字段)
const submitData = {
...toRaw(formState),
FullFollowedTitleTemplate: fullTemplate, // 确保提交最新拼接结果
FullFollowedTitleTemplate: fullTemplate,
FollowedTitleTemplate: formState.FollowedTitleTemplate.join(''),
PriorityLevel: JSON.stringify(tagData.value),
};
// 3. 提交接口
useApiStore()
.apiUpdateConfig(submitData)
.then((res) => {
@@ -476,27 +657,24 @@ const onUpdate = () => {
const onCancel = () => {
componentDisabled.value = true;
formRef.value?.clearValidate();
// 取消时恢复完整模板预览(只有关闭标题当文件名时)
if (!formState.UperUseViedoTitle) {
formState.FullFollowedTitleTemplate = computeFullTemplate.value;
}
};
const importOrexportConf = ref();
// 新增:导出配置
const importOrexportConf = ref<any>(null);
// 导出配置
const exportConfig = () => {
try {
useApiStore()
.ExportConf()
.then((res) => {
if (res.code == 0) {
if (res.code === 0) {
importOrexportConf.value = res.data;
// 转换为JSON字符串并格式化
const jsonStr = JSON.stringify(importOrexportConf.value, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
// 创建下载链接
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -504,7 +682,6 @@ const exportConfig = () => {
document.body.appendChild(a);
a.click();
// 清理
document.body.removeChild(a);
URL.revokeObjectURL(url);
@@ -513,22 +690,22 @@ const exportConfig = () => {
}
})
.catch((x) => {
console.log(x);
console.error('导出接口请求失败:', x);
});
} catch (error) {
console.error('导出配置及手动关注列表失败:', error);
message.error('导出配置及手动关注列表,请稍后重试');
message.error('导出配置及手动关注列表失败,请稍后重试');
}
};
// 新增:触发导入文件选择
// 触发导入文件选择
const triggerImportFile = () => {
if (importFileInput.value) {
importFileInput.value.click();
}
};
// 新增:处理导入文件
// 处理导入文件
const handleImportFile = (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
@@ -537,14 +714,12 @@ const handleImportFile = (e: Event) => {
return;
}
// 验证文件类型
if (file.type !== 'application/json' && !file.name.endsWith('.json')) {
message.error('请选择JSON格式的配置文件');
target.value = '';
return;
}
// 读取文件内容
const reader = new FileReader();
reader.onload = (event) => {
try {
@@ -553,25 +728,49 @@ const handleImportFile = (e: Event) => {
useApiStore()
.ImportConf(importData)
.then((res) => {
if (res.code == 0) {
if (res.code === 0) {
message.success('配置及手动关注列表导入成功,下次运行会按新的配置规则运行。');
floatMenuVisible.value = false;
getConfig();
} else {
message.error(`配置及手动关注列表导入失败`);
message.error('配置及手动关注列表导入失败');
}
});
} catch (error) {
console.error('解析配置文件失败:', error);
message.error(`配置及手动关注列表导入失败:${(error as Error).message}`);
} finally {
// 清空文件选择
target.value = '';
}
};
reader.readAsText(file);
};
const renfo = () => {
// AntD 确认弹窗:Modal.confirm
Modal.confirm({
title: '操作确认', // 弹窗标题
content:
'确认要重新生成所有视频的.nfo刮削文件吗?根据视频数量,该操作可能需要较长时间,操作后可查看日志信息,然后进emby查看最新刮削信息。', // 弹窗提示内容
okText: '确认执行', // 确认按钮文本
cancelText: '取消', // 取消按钮文本
iconType: 'warning', // 警告图标(强化提醒效果,防误操作)
// 用户点击「确认」时触发
onOk() {
// 执行原有接口请求逻辑
useApiStore()
.Renfo()
.then((r) => {
if (r.code == 0) {
message.success(
'所有视频将重新生成.nfo刮削文件。根据视频数量,可能需要的时间不同,稍后可以到emby查看最新的刮削信息'
);
}
});
},
onCancel() {},
});
};
</script>
<style lang='less' scoped>
@@ -632,18 +831,18 @@ const handleImportFile = (e: Event) => {
color: #444 !important;
font-weight: 500;
}
/* 关键修改:确保拖拽容器不限制子元素 */
/* 拖拽容器样式 */
.tag-drag-container {
position: relative; /* 必须:让拖拽的 ghost 元素不被截断 */
overflow: visible !important; /* 覆盖 Form 可能的 overflow: hidden */
position: relative;
overflow: visible !important;
}
.tag-
/* Tag 列表布局(换行显示) */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 12px; /* Tag 之间间距 */
gap: 12px;
}
/* 拖拽中的 Tag 占位样式 */
@@ -657,7 +856,7 @@ const handleImportFile = (e: Event) => {
.drag-handle {
font-size: 14px;
color: #666;
user-select: none; /* 禁止选中文字 */
user-select: none;
}
.drag-handle:hover {
@@ -674,33 +873,33 @@ const handleImportFile = (e: Event) => {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
// 1. 悬浮按钮容器(恢复固定定位,作为子按钮的定位基准)
// 悬浮按钮容器
.config-float-btn-container {
position: fixed; // 关键:恢复固定定位,确保在页面右下角
position: fixed;
right: 30px;
bottom: 100px;
z-index: 1000;
width: 60px; // 与主按钮宽度一致,确保居中
height: auto; // 自适应高度,不限制子按钮展开
width: 60px;
height: auto;
&:hover {
cursor: default;
}
}
// 2. 子按钮容器(修正定位,基于父容器居中)
// 子按钮容器
.float-sub-btn-wrapper {
position: absolute;
bottom: 70px; // 主按钮高度(60px)+ 间距(10px),向上展开
bottom: 70px;
left: 50%;
transform: translateX(-50%); // 水平居中
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
// 其他样式(主按钮、子按钮)保持不变
// 主按钮样式
.main-float-btn {
width: 60px;
height: 60px;
@@ -709,17 +908,18 @@ const handleImportFile = (e: Event) => {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: scale(1.05); // 移除多余的 translateX(-50%)
transform: scale(1.05);
}
}
// 子按钮样式
.sub-float-btn {
width: 50px;
height: 50px;
font-size: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
opacity: 0;
transform: translateY(10px) scale(0.9); // 仅保留垂直偏移,水平居中由父容器控制
transform: translateY(10px) scale(0.9);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
&.export-btn {
@@ -733,15 +933,74 @@ const handleImportFile = (e: Event) => {
.config-float-btn-container:hover .sub-float-btn {
opacity: 1;
transform: translateY(0) scale(1); // 恢复正常位置
transform: translateY(0) scale(1);
}
// 隐藏文件导入输入框
.import-file-input {
display: none;
}
// 修复子按钮tooltip显示
// 修复子按钮tooltip层级
:deep(.ant-tooltip) {
z-index: 1001 !important;
}
// 上传组件样式适配
:deep(.ant-upload) {
display: inline-block;
}
:deep(.ant-upload.ant-upload-select) {
display: inline-block;
}
// 音频上传与播放器容器样式
.audio-upload-player-wrapper {
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
}
}
// 音频播放器样式优化
:deep(.audio-player audio) {
// border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 2px;
&:hover {
border-color: #1890ff;
}
}
// 播放/清空按钮 hover 效果
:deep(.audio-player .ant-btn-text:hover) {
background-color: #f5f5f5;
}
// 右上角悬浮按钮容器
.top-right-float-btn-container {
position: fixed;
top: 100px; // 距离顶部间距
right: 30px; // 距离右侧间距(与现有底部按钮对齐)
z-index: 1000; // 保证悬浮在最上层
}
// 右上角悬浮按钮样式
.top-right-float-btn {
width: 56px;
height: 56px;
font-size: 18px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
opacity: 0.6; // 默认半透明(0-1之间,0.6为适中的半透明效果)
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); // 平滑过渡动画
// 鼠标悬浮时:移除半透明,轻微放大增强交互感
&:hover {
opacity: 1; // 移除半透明,完全不透明
transform: scale(1.05); // 轻微放大,提升交互体验(可选,可删除)
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); // 悬浮时阴影加深(可选,可删除)
}
}
</style>
+77 -21
View File
@@ -71,10 +71,13 @@
<a-list size="small" bordered :data-source="deleteVideos">
<template #renderItem="{item, index}">
<a-list-item>
<span>
{{index+1}}. {{ item.videoTitle }}
</span>
<!-- 新增文本容器用于控制省略号 -->
<div class="delete-video-title-container">
<span class="delete-video-index">{{ index + 1 }}.</span>
<span class="delete-video-title" :title="item.videoTitle || '无标题'">
{{ item.videoTitle }}
</span>
</div>
<a-button type="text" size="small" class="copy-delete-video-btn" @click="(e) => copyVideoPath(item.videoSavePath)">
<CopyOutlined /> 复制
@@ -1313,34 +1316,87 @@ onMounted(() => {
text-decoration-color: #1890ff;
text-decoration-thickness: 1px;
}
/* 已删除视频条目样式 */
.delete-video-text {
flex: 1; /* 文本占满剩余空间 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
/* 已删除视频抽屉 - 列表容器基础样式 */
:deep(.ant-drawer-body) {
padding: 16px !important;
overflow-y: auto;
}
/* 复制按钮样式 */
:deep(.ant-list) {
margin: 0 !important;
}
/* 已删除视频 - 列表项布局优化 */
:deep(.ant-list-item) {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
padding: 12px 16px !important;
border-bottom: 1px solid #f0f0f0 !important;
transition: background-color 0.2s ease;
}
/* 列表项悬停效果,增强交互感 */
:deep(.ant-list-item:hover) {
background-color: #f8f9fa !important;
}
/* 已删除视频 - 标题容器(核心:实现单行省略) */
.delete-video-title-container {
display: flex;
align-items: center;
flex: 1; /* 占满左侧剩余空间,限制文本宽度 */
margin-right: 16px; /* 与复制按钮保持间距 */
overflow: hidden; /* 隐藏溢出内容 */
}
/* 序号样式 */
.delete-video-index {
color: #666;
margin-right: 8px;
flex: 0 0 auto; /* 序号不收缩、不放大,固定宽度 */
white-space: nowrap;
}
/* 视频标题(核心:单行文本溢出省略) */
.delete-video-title {
flex: 1; /* 占满容器剩余空间,触发宽度限制 */
white-space: nowrap; /* 强制文本单行显示 */
overflow: hidden; /* 隐藏溢出的文本 */
text-overflow: ellipsis; /* 溢出部分显示省略号... */
color: #333;
font-size: 14px;
line-height: 1.5;
}
/* 复制按钮样式优化 */
.copy-delete-video-btn {
padding: 0 4px !important;
height: 24px !important;
padding: 0 8px !important;
height: 28px !important;
font-size: 12px !important;
color: #1890ff !important;
flex: 0 0 auto; /* 按钮不收缩、不放大,固定宽度 */
}
.copy-delete-video-btn:hover {
color: #40a9ff !important;
background-color: #f0f9ff !important;
border-radius: 4px !important;
}
/* 列表项布局调整 */
:deep(.ant-list-item) {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
padding: 8px 16px !important;
/* 可选:适配移动端,优化小屏幕显示 */
@media (max-width: 768px) {
.delete-video-title-container {
margin-right: 12px;
}
.delete-video-title {
font-size: 13px;
}
.copy-delete-video-btn {
padding: 0 6px !important;
height: 24px !important;
}
}
</style>
+103 -27
View File
@@ -13,21 +13,69 @@ interface NaviGuard {
after?: NavigationHookAfter;
}
const loginGuard: NavigationGuard = function (to, from) {
// console.log('Authorization', http.checkAuthorization())
const account = useAccountStore();
if (!http.checkAuthorization() && !/^\/(init|login|home|mobile)?$/.test(to.fullPath)) {
console.log(123)
console.log(to.fullPath)
account.setLogged(false)
return '/login';
} else {
}
// ========== 新增:移动端检测核心函数 ==========
/**
* 检测是否为移动端设备(UA + 屏幕宽度双检测)
*/
const isMobile = (): boolean => {
const userAgent = navigator.userAgent.toLowerCase();
const mobileUaReg = /iphone|android|ipad|ipod|mobile|wap|symbian|windows ce|blackberry|webos|ucbrowser/i;
const isSmallScreen = window.innerWidth < 768;
return mobileUaReg.test(userAgent) || isSmallScreen;
};
const dynamicinitRoute =
{
// 标记是否已跳转到移动端路由,防止无限循环
let hasRedirectedToMobile = false;
// ========== 新增:移动端跳转守卫(已集成登录状态判断) ==========
const MobileRedirectGuard: NavigationGuard = function (to, from, next) {
// 1. 排除/mobile路由本身,避免无限循环
if (to.path === '/mobile') {
hasRedirectedToMobile = true;
next();
return;
}
// 2. 排除/login路由,避免登录页被移动端跳转逻辑覆盖
if (to.path === '/login') {
next();
return;
}
// 3. 检测是否为移动端
if (isMobile() && !hasRedirectedToMobile) {
// 4. 核心判断:检查登录状态
const isAuthorized = http.checkAuthorization();
if (!isAuthorized) {
// 未登录:优先跳转到登录页
hasRedirectedToMobile = false; // 重置标记,不影响后续登录后的跳转
next('/login');
} else {
// 已登录:跳转到移动端路由
hasRedirectedToMobile = true;
next({ path: '/mobile' });
}
} else {
// 非移动端/已跳转:重置标记并执行原有逻辑
hasRedirectedToMobile = false;
next();
}
};
// ========== 原有守卫逻辑(无修改) ==========
const loginGuard: NavigationGuard = function (to, from, next) {
// 补充next参数,保证守卫链正常执行
if (!http.checkAuthorization() && !/^\/(init|login|home|mobile)?$/.test(to.fullPath)) {
console.log(to.fullPath)
const account = useAccountStore();
account.setLogged(false);
next('/login');
} else {
next();
}
};
const dynamicinitRoute = {
path: '/',
name: 'login',
redirect: '/login',
@@ -40,22 +88,23 @@ const dynamicinitRoute =
component: () => import('@/pages/login'),
};
const InitGuard: NavigationGuard = function (to, from) {
const InitGuard: NavigationGuard = function (to, from, next) {
// 补充next参数
if (to.fullPath != '/login') {
if (!router.hasRoute('login')) {
router.addRoute(dynamicinitRoute)
router.addRoute(dynamicinitRoute);
}
router.push('/login')
next('/login');
} else {
next();
}
};
// 进度条
const ProgressGuard: NaviGuard = {
before(to, from) {
before(to, from, next) {
NProgress.start();
next(); // 补充next参数
},
after(to, from) {
NProgress.done();
@@ -63,16 +112,18 @@ const ProgressGuard: NaviGuard = {
};
const AuthGuard: NaviGuard = {
before(to, from) {
before(to, from, next) {
const { hasAuthority } = useAuthStore();
if (to.meta?.permission && !hasAuthority(to.meta?.permission)) {
return { name: '403', query: { permission: to.meta.permission, path: to.fullPath } };
next({ name: '403', query: { permission: to.meta.permission, path: to.fullPath } });
} else {
next();
}
},
};
const ForbiddenGuard: NaviGuard = {
before(to) {
before(to, from, next) {
if (to.name === '403' && (to.query.permission || to.query.path)) {
to.fullPath = to.fullPath
.replace(/permission=[^&=]*&?/, '')
@@ -83,21 +134,46 @@ const ForbiddenGuard: NaviGuard = {
delete to.query.permission;
delete to.query.path;
}
next(); // 补充next参数
},
};
// 404 not found
const NotFoundGuard: NaviGuard = {
before(to, from) {
before(to, from, next) {
const { loading } = useMenuStore();
if (to.meta._is404Page && loading) {
to.params.loading = true as any;
}
next(); // 补充next参数
},
};
// ========== 页面刷新时的移动端检测(已集成登录状态判断) ==========
window.addEventListener('load', () => {
if (isMobile() && window.location.pathname !== '/mobile') {
// 检查登录状态:未登录则跳登录,已登录则跳移动端
const isAuthorized = http.checkAuthorization();
if (!isAuthorized) {
if (window.location.pathname !== '/login') {
router.push('/login').catch(err => {
if (!err.message.includes('NavigationDuplicated')) {
console.error('刷新时跳转登录页失败:', err);
}
});
}
} else {
router.push('/mobile').catch(err => {
if (!err.message.includes('NavigationDuplicated')) {
console.error('刷新时跳转移动端路由失败:', err);
}
});
}
}
});
export default {
// before: [ProgressGuard.before, InitGuard, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before],
before: [ProgressGuard.before, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before],
// 把MobileRedirectGuard放在最前面,优先执行移动端检测
before: [ProgressGuard.before, MobileRedirectGuard, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before],
after: [ProgressGuard.after],
};
};
+38 -2
View File
@@ -2,6 +2,7 @@ import { defineStore, storeToRefs } from 'pinia';
import http from './http';
import { ref, watch } from 'vue';
import { Response } from '@/types';
// import { RouteOption } from '@/router/interface';
// import { addRoutes, removeRoute } from '@/router/dynamicRoutes';
// import { useSettingStore } from './setting';
@@ -36,7 +37,6 @@ export const useApiStore = defineStore('coreapi', () => {
return http
.request<any, Response<any>>('/api/config/GetConfig', 'GET')
.then((res) => {
console.log(res)
return res;
})
.finally(() => {
@@ -57,7 +57,7 @@ export const useApiStore = defineStore('coreapi', () => {
}
//后台日志
async function apiGetLogs(param: string) {
return http.request<any, Response<any>>('/api/logs/GetLog?' + param, 'get').then(r => {
return http.request<any, Response<any>>('/api/logs/GetLog/' + param, 'get').then(r => {
// console.log(r)
return r.data;
}).finally(() => {
@@ -296,7 +296,43 @@ export const useApiStore = defineStore('coreapi', () => {
});
}
//Renfo
async function Renfo() {
return http.request<any, Response<any>>('/api/Video/renfo', 'get').then(r => {
return r;
}).finally(() => {
});
}
// 音频文件上传接口
async function apiUploadAudio(formData: FormData, options?: { onUploadProgress?: (progressEvent: ProgressEvent) => void }) {
return http
.request<any, Response<any>>(
'/api/config/UploadAudio', // 请求地址
'post_form', // 使用新增的 post_form 类型
formData, // FormData 参数(文件+其他参数)
{
onUploadProgress: options?.onUploadProgress, // 上传进度回调(原生 ProgressEvent
timeout: 120000 // 上传文件超时时间设为2分钟(可选)
}
)
.then((res) => {
// console.log('音频上传结果:', res);
// 适配你的响应格式(如果响应是包裹层,取 data)
return res;
})
.catch((err) => {
console.error('音频上传失败:', err);
throw err; // 抛出错误让前端捕获
});
}
return {
Renfo,
apiUploadAudio,
GetAppPort,
AppisInit,
DeskInitAsync,
+36 -23
View File
@@ -4,7 +4,8 @@ import { isResponse } from '@/types';
import NProgress from 'nprogress';
import { useAccountStore } from '@/store';
import { message } from 'ant-design-vue';
import router from '@/router'; // 关键:导入路由实例(路径要和实际一致)
import router from '@/router';
const http = createHttp({
timeout: 60000,
baseURL: '/',
@@ -17,7 +18,10 @@ const isAxiosResponse = (obj: any): obj is AxiosResponse => {
return typeof obj === 'object' && obj.status && obj.statusText && obj.headers && obj.config;
};
// progress 进度条 -- 开启
// 仅新增这一行:跳转锁
let isRedirecting = false;
// progress 进度条 -- 开启(和你原本一致)
http.interceptors.request.use((req: AxiosRequestConfig) => {
if (!NProgress.isStarted()) {
NProgress.start();
@@ -25,7 +29,7 @@ http.interceptors.request.use((req: AxiosRequestConfig) => {
return req;
});
// 解析响应结果
// 解析响应结果(完全和你原本一致,一字未改)
http.interceptors.response.use(
(rep: AxiosResponse<String>) => {
const { data } = rep;
@@ -35,26 +39,34 @@ http.interceptors.response.use(
return Promise.reject({ message: rep.statusText, code: rep.status, data });
},
(error) => {
if (error.response.status === 401) {
const accountStore = useAccountStore();
// 1. 清除登录状态
accountStore.setLogged(false);
// 可选:提示用户登录过期
message.warning('登录状态已过期,请重新登录'); // 如使用Element Plus
if (error.response?.status === 401) {
// 仅新增:加锁判断(这是唯一改动)
if (!isRedirecting) {
isRedirecting = true;
const accountStore = useAccountStore();
accountStore.setLogged(false);
message.warning('登录状态已过期,请重新登录');
setTimeout(() => {
const redirectPath = router.currentRoute.value.fullPath;
router.push({
path: '/login',
query: { redirect: redirectPath }
}).then(() => {
console.log('跳转登录页成功');
}).catch((err) => {
console.error('跳转登录页失败:', err); // 关键!捕获跳转失败的原因
});
}, 100);
setTimeout(() => {
const redirectPath = router.currentRoute.value.fullPath;
if (redirectPath !== '/login') {
router.push({
path: '/login',
query: { redirect: redirectPath }
}).then(() => {
console.log('跳转登录页成功');
}).catch((err) => {
console.error('跳转登录页失败:', err);
}).finally(() => {
isRedirecting = false;
});
} else {
isRedirecting = false;
}
}, 100);
}
// 新增结束
} else {
if (error.response && isAxiosResponse(error.response)) {
return Promise.reject({
@@ -69,7 +81,7 @@ http.interceptors.response.use(
}
);
// progress 进度条 -- 关闭
// progress 进度条 -- 关闭(改回你原本的逻辑,不碰返回值)
http.interceptors.response.use(
(rep) => {
if (NProgress.isStarted()) {
@@ -81,8 +93,9 @@ http.interceptors.response.use(
if (NProgress.isStarted()) {
NProgress.done();
}
// 改回你原本的返回值:return error(之前改这个导致登录异常)
return error;
}
);
export default http;
export default http;
+37 -18
View File
@@ -1,5 +1,4 @@
import axios, { AxiosInstance, AxiosRequestConfig, Method as _Method, AxiosResponse } from 'axios';
import axios, { AxiosInstance, AxiosRequestConfig, Method as _Method, AxiosResponse } from 'axios'; // 移除 AxiosProgressEvent
import qs from 'qs';
import Cookie from 'js-cookie';
@@ -9,13 +8,13 @@ declare interface _AxiosExtend {
* @param url 请求地址
* @param method 请求方法
* @param params 请求参数
* @param config 请求配置
* @param config 请求配置(新增上传进度回调)
*/
request<T = any, R = AxiosResponse<T>>(
url: string,
method: Method,
params?: Record<string | number, any>,
config?: AxiosRequestConfig
params?: Record<string | number, any> | FormData, // 支持 FormData 类型
config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void } // 改用原生 ProgressEvent
): Promise<R>;
/**
* 设置token
@@ -41,7 +40,8 @@ declare interface _AxiosExtend {
export interface AxiosHttp extends Omit<AxiosInstance, 'request'>, _AxiosExtend { }
export type Method = _Method | 'POST_JSON' | 'post_json' | 'PUT_JSON' | 'put_json';
// 新增 post_form / POST_FORM 类型
export type Method = _Method | 'POST_JSON' | 'post_json' | 'PUT_JSON' | 'put_json' | 'POST_FORM' | 'post_form';
/**
* 转表单格式
@@ -106,10 +106,16 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
request<T = any, R = AxiosResponse<T>>(
url: string,
method: Method,
params?: Record<string | number, any>,
config?: AxiosRequestConfig
params?: Record<string | number, any> | FormData,
config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void } // 改用原生 ProgressEvent
): Promise<R> {
const _method = method.toUpperCase();
// 处理上传进度配置
const requestConfig: AxiosRequestConfig = {
...config,
onUploadProgress: config?.onUploadProgress, // 透传上传进度回调
};
switch (_method) {
case 'GET':
return _axios.get(url, {
@@ -117,24 +123,37 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
paramsSerializer: (data) => {
return qs.stringify(data, { indices: false, skipNulls: true });
},
...config,
...requestConfig,
});
case 'POST':
return _axios.post(url, toUrlencoded(params), config);
return _axios.post(url, toUrlencoded(params as Record<string | number, any>), requestConfig);
case 'POST_JSON':
return _axios.post(url, params, config);
return _axios.post(url, params, {
...requestConfig,
headers: { 'Content-Type': 'application/json', ...requestConfig.headers },
});
// 新增:POST_FORM 类型(适配文件上传的 FormData)
case 'POST_FORM':
return _axios.post(url, params, {
...requestConfig,
// FormData 不需要手动设置 Content-Typeaxios 会自动处理为 multipart/form-data
headers: { ...requestConfig.headers },
});
case 'PUT':
return _axios.put(url, toFormData(params), config);
return _axios.put(url, toFormData(params as Record<string | number, any>), requestConfig);
case 'PUT_JSON':
return _axios.put(url, params, config);
return _axios.put(url, params, {
...requestConfig,
headers: { 'Content-Type': 'application/json', ...requestConfig.headers },
});
case 'DELETE':
return _axios.delete(url, { data: toFormData(params), ...config });
return _axios.delete(url, { data: toFormData(params as Record<string | number, any>), ...requestConfig });
case 'HEAD':
return _axios.head(url, { params, ...config });
return _axios.head(url, { params, ...requestConfig });
case 'OPTIONS':
return _axios.options(url, { params, ...config });
return _axios.options(url, { params, ...requestConfig });
case 'PATCH':
return _axios.patch(url, { params, ...config });
return _axios.patch(url, { params, ...requestConfig });
case 'PURGE':
case 'LINK':
case 'UNLINK':
@@ -158,4 +177,4 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
return http;
}
export default createAxiosHttp;
export default createAxiosHttp;