1、nfo文件优化,修复演员信息(视频作者)
2、图文视频,因版权原因无法下载的音频,系统配置新增默认音频上传入口,上传成功后,后面在遇到无法下载音频时,将用该音频进行图文视频合成的音频数据 3、增加动态视频合成配置。 4、容器重启后,手机端会连续跳转登录页很多次的bug修复 5、其他优化
This commit is contained in:
@@ -28,6 +28,18 @@ namespace dy.net.Controllers
|
||||
private readonly DouyinFollowService douyinFollowService;
|
||||
private readonly DouyinCookieService douyinCookieService;
|
||||
|
||||
// 定义允许上传的音频扩展名(小写)
|
||||
private readonly string[] _allowedAudioExtensions = { ".mp3", ".wav" };
|
||||
|
||||
// 定义允许的音频 MIME 类型(增强验证)
|
||||
private readonly string[] _allowedAudioMimeTypes = {
|
||||
"audio/mpeg", "audio/wav"
|
||||
};
|
||||
|
||||
// 最大文件大小:20MB(可根据需求调整)
|
||||
private const long _maxFileSize = 20 * 1024 * 1024;
|
||||
|
||||
|
||||
public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService)
|
||||
{
|
||||
this.dyCookieService = dyCookieService;
|
||||
@@ -374,5 +386,108 @@ namespace dy.net.Controllers
|
||||
return ApiResult.Fail("请求失败");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 上传音频文件接口
|
||||
/// </summary>
|
||||
/// <param name="file">要上传的音频文件</param>
|
||||
/// <returns>上传结果</returns>
|
||||
[HttpPost("UploadAudio")]
|
||||
public IActionResult UploadAudio(IFormFile file)
|
||||
{
|
||||
// 1. 验证文件是否为空
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { success = false, message = "请选择要上传的音频文件" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 2. 验证文件大小
|
||||
if (file.Length > _maxFileSize)
|
||||
{
|
||||
return BadRequest(new { success = false, message = $"文件大小超过限制(最大允许 {_maxFileSize / 1024 / 1024}MB)" });
|
||||
}
|
||||
|
||||
// 3. 获取文件扩展名并验证
|
||||
var fileExtension = Path.GetExtension(file.FileName).ToLower();
|
||||
if (!_allowedAudioExtensions.Contains(fileExtension))
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
success = false,
|
||||
message = $"不支持的音频格式,仅允许:{string.Join(", ", _allowedAudioExtensions)}"
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 验证 MIME 类型(可选但推荐,防止扩展名伪造)
|
||||
var contentType = file.ContentType.ToLower();
|
||||
if (!_allowedAudioMimeTypes.Contains(contentType))
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
success = false,
|
||||
message = "文件类型验证失败,请上传合法的音频文件"
|
||||
});
|
||||
}
|
||||
|
||||
//先删除
|
||||
var uploadMp3 = Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "mp3"))
|
||||
.Where(filePath => Path.GetFileNameWithoutExtension(filePath) != "silent_10")
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(uploadMp3) && System.IO.File.Exists(uploadMp3))
|
||||
{
|
||||
System.IO.File.Delete(uploadMp3);
|
||||
}
|
||||
|
||||
|
||||
// 5. 生成唯一文件名(避免重复)
|
||||
var uniqueFileName = $"dysync_default{fileExtension}";
|
||||
|
||||
// 6. 定义文件保存路径(建议配置在 appsettings.json 中,这里简化处理)
|
||||
var uploadPath = Path.Combine(AppContext.BaseDirectory, "mp3");
|
||||
|
||||
// 确保目录存在
|
||||
if (!Directory.Exists(uploadPath))
|
||||
{
|
||||
Directory.CreateDirectory(uploadPath);
|
||||
}
|
||||
|
||||
// 7. 保存文件
|
||||
var filePath = Path.Combine(uploadPath, uniqueFileName);
|
||||
|
||||
if (System.IO.File.Exists(filePath))
|
||||
{
|
||||
System.IO.File.Delete(filePath);
|
||||
}
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
file.CopyTo(stream);
|
||||
}
|
||||
|
||||
// 8. 返回成功结果(可根据需求返回文件路径/URL 等)
|
||||
return ApiResult.Success(new { fileName = uniqueFileName, filePath });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 捕获异常并返回错误信息
|
||||
return ApiResult.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
[AllowAnonymous]
|
||||
[HttpGet("defaudio")]
|
||||
public async Task<IActionResult> GetDefaultAudioUrl()
|
||||
{
|
||||
var uploadMp3 = Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "mp3"))
|
||||
.Where(filePath => Path.GetFileNameWithoutExtension(filePath) != "silent_10")
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(uploadMp3) && System.IO.File.Exists(uploadMp3))
|
||||
{
|
||||
return File(System.IO.File.ReadAllBytes(uploadMp3), "application/octet-stream", Path.GetFileName(uploadMp3));
|
||||
}
|
||||
return ApiResult.Fail("未上传音频");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,13 @@ namespace dy.net.Controllers
|
||||
this.logInfoService = logInfoService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetLog(string type, string date)
|
||||
[HttpGet("/api/logs/GetLog/{type}/{date}")]
|
||||
public async Task<IActionResult> GetLog([FromRoute]string type, [FromRoute] string date)
|
||||
{
|
||||
var filePath = Path.Combine(webHostEnvironment.IsDevelopment() ? Directory.GetCurrentDirectory() : AppDomain.CurrentDomain.BaseDirectory, "logs", $"log-{type}-{date}.txt");
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
{
|
||||
var msg = $"Log file log-{type}-{date}.txt not found.";
|
||||
var msg = $"{date},没有发现{type}的日志";
|
||||
//Serilog.Log.Error(msg);
|
||||
return Ok(msg);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using dy.net.service;
|
||||
using dy.net.utils;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System;
|
||||
|
||||
namespace dy.net.Controllers
|
||||
{
|
||||
@@ -270,5 +271,45 @@ namespace dy.net.Controllers
|
||||
var data = await douyinVideoService.DeleteInvalidVideo();
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 所有视频重新生成nfo文件
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("renfo")]
|
||||
public async Task<IActionResult> ReCreateNfo()
|
||||
{
|
||||
var videos= await douyinVideoService.GetAllAsync();
|
||||
if (videos == null || videos.Count == 0)
|
||||
{
|
||||
return ApiResult.Success("暂无视频数据需要生成NFO文件");
|
||||
}
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var totalCount = videos.Count;
|
||||
foreach (var video in videos)
|
||||
{
|
||||
try
|
||||
{
|
||||
NfoFileGenerator.GenerateVideoNfoFile(video);
|
||||
Serilog.Log.Debug($"刮削视频(Path:{video.VideoSavePath})生成NFO成功!");
|
||||
await Task.Delay(50);
|
||||
}
|
||||
catch (Exception singleEx)
|
||||
{
|
||||
Serilog.Log.Error($"刮削视频(Path:{video.VideoSavePath})生成NFO失败:{singleEx.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error($"NFO文件生成任务执行异常:{ex.Message}\n{ex.StackTrace}");
|
||||
}
|
||||
});
|
||||
|
||||
return ApiResult.Success();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\</_PublishTargetUrl>
|
||||
<History>True|2026-01-11T13:25:07.5052845Z||;False|2026-01-11T21:24:27.5744557+08:00||;True|2026-01-11T19:59:15.4734611+08:00||;False|2026-01-11T19:59:04.9543339+08:00||;True|2026-01-11T18:51:30.6649269+08:00||;True|2026-01-08T21:07:57.5094466+08:00||;True|2026-01-08T14:51:18.2266231+08:00||;True|2026-01-08T14:31:55.1137120+08:00||;True|2026-01-08T00:16:24.4451490+08:00||;True|2026-01-08T00:14:20.7430793+08:00||;True|2026-01-08T00:07:46.7228194+08:00||;True|2026-01-07T23:52:13.3290082+08:00||;True|2026-01-07T23:49:28.4838650+08:00||;True|2026-01-07T23:47:45.1936189+08:00||;True|2026-01-07T23:35:09.7818611+08:00||;True|2026-01-07T23:20:43.7462863+08:00||;True|2026-01-07T23:04:39.5140429+08:00||;False|2026-01-07T23:04:36.5367611+08:00||;True|2026-01-07T22:53:14.6013449+08:00||;True|2026-01-07T22:49:59.9549006+08:00||;True|2026-01-07T22:36:40.0254856+08:00||;False|2026-01-07T22:36:28.8275903+08:00||;True|2026-01-07T21:59:51.4355171+08:00||;True|2026-01-03T21:38:51.4307034+08:00||;True|2026-01-02T19:09:16.9668906+08:00||;False|2026-01-02T19:09:11.7496369+08:00||;True|2026-01-02T15:42:08.2215697+08:00||;True|2026-01-02T09:46:56.1654861+08:00||;True|2026-01-02T09:35:28.0211225+08:00||;True|2026-01-01T23:07:01.9022045+08:00||;False|2026-01-01T23:06:56.0537216+08:00||;True|2026-01-01T22:16:10.2974067+08:00||;True|2026-01-01T22:16:06.2123787+08:00||;False|2026-01-01T22:15:33.3626979+08:00||;True|2026-01-01T22:01:30.7161900+08:00||;True|2026-01-01T21:10:19.3664263+08:00||;True|2026-01-01T21:09:38.6071080+08:00||;True|2025-12-30T11:45:54.5543034+08:00||;True|2025-12-30T09:19:08.3178124+08:00||;True|2025-12-29T18:57:29.7032246+08:00||;True|2025-12-27T14:52:24.4780776+08:00||;False|2025-12-27T14:52:19.5635794+08:00||;True|2025-12-27T14:48:01.6252748+08:00||;False|2025-12-27T14:47:55.7976192+08:00||;True|2025-12-27T14:38:23.9723838+08:00||;True|2025-12-27T13:00:29.8583858+08:00||;True|2025-12-26T22:18:42.4015637+08:00||;True|2025-12-26T22:10:58.5274572+08:00||;True|2025-12-26T22:06:26.3129600+08:00||;True|2025-12-26T22:03:55.6718618+08:00||;False|2025-12-26T22:03:48.3809954+08:00||;True|2025-12-26T22:02:33.1840390+08:00||;True|2025-12-26T22:01:16.1660100+08:00||;True|2025-12-25T00:50:26.1116465+08:00||;True|2025-12-25T00:48:27.3087708+08:00||;True|2025-12-25T00:47:38.4835720+08:00||;</History>
|
||||
<History>True|2026-01-12T14:04:14.5886955Z||;False|2026-01-12T22:04:08.2008101+08:00||;True|2026-01-12T21:53:46.9947176+08:00||;True|2026-01-12T21:11:15.8358024+08:00||;True|2026-01-12T21:09:51.8663228+08:00||;True|2026-01-11T21:25:07.5052845+08:00||;False|2026-01-11T21:24:27.5744557+08:00||;True|2026-01-11T19:59:15.4734611+08:00||;False|2026-01-11T19:59:04.9543339+08:00||;True|2026-01-11T18:51:30.6649269+08:00||;True|2026-01-08T21:07:57.5094466+08:00||;True|2026-01-08T14:51:18.2266231+08:00||;True|2026-01-08T14:31:55.1137120+08:00||;True|2026-01-08T00:16:24.4451490+08:00||;True|2026-01-08T00:14:20.7430793+08:00||;True|2026-01-08T00:07:46.7228194+08:00||;True|2026-01-07T23:52:13.3290082+08:00||;True|2026-01-07T23:49:28.4838650+08:00||;True|2026-01-07T23:47:45.1936189+08:00||;True|2026-01-07T23:35:09.7818611+08:00||;True|2026-01-07T23:20:43.7462863+08:00||;True|2026-01-07T23:04:39.5140429+08:00||;False|2026-01-07T23:04:36.5367611+08:00||;True|2026-01-07T22:53:14.6013449+08:00||;True|2026-01-07T22:49:59.9549006+08:00||;True|2026-01-07T22:36:40.0254856+08:00||;False|2026-01-07T22:36:28.8275903+08:00||;True|2026-01-07T21:59:51.4355171+08:00||;True|2026-01-03T21:38:51.4307034+08:00||;True|2026-01-02T19:09:16.9668906+08:00||;False|2026-01-02T19:09:11.7496369+08:00||;True|2026-01-02T15:42:08.2215697+08:00||;True|2026-01-02T09:46:56.1654861+08:00||;True|2026-01-02T09:35:28.0211225+08:00||;True|2026-01-01T23:07:01.9022045+08:00||;False|2026-01-01T23:06:56.0537216+08:00||;True|2026-01-01T22:16:10.2974067+08:00||;True|2026-01-01T22:16:06.2123787+08:00||;False|2026-01-01T22:15:33.3626979+08:00||;True|2026-01-01T22:01:30.7161900+08:00||;True|2026-01-01T21:10:19.3664263+08:00||;True|2026-01-01T21:09:38.6071080+08:00||;True|2025-12-30T11:45:54.5543034+08:00||;True|2025-12-30T09:19:08.3178124+08:00||;True|2025-12-29T18:57:29.7032246+08:00||;True|2025-12-27T14:52:24.4780776+08:00||;False|2025-12-27T14:52:19.5635794+08:00||;True|2025-12-27T14:48:01.6252748+08:00||;False|2025-12-27T14:47:55.7976192+08:00||;True|2025-12-27T14:38:23.9723838+08:00||;True|2025-12-27T13:00:29.8583858+08:00||;True|2025-12-26T22:18:42.4015637+08:00||;True|2025-12-26T22:10:58.5274572+08:00||;True|2025-12-26T22:06:26.3129600+08:00||;True|2025-12-26T22:03:55.6718618+08:00||;False|2025-12-26T22:03:48.3809954+08:00||;True|2025-12-26T22:02:33.1840390+08:00||;True|2025-12-26T22:01:16.1660100+08:00||;True|2025-12-25T00:50:26.1116465+08:00||;True|2025-12-25T00:48:27.3087708+08:00||;True|2025-12-25T00:47:38.4835720+08:00||;</History>
|
||||
<LastFailureDetails />
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+173
-69
@@ -1,91 +1,195 @@
|
||||
<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 {
|
||||
// 日志展示容器样式
|
||||
.log-container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 80px);
|
||||
margin-top: 10px;
|
||||
|
||||
.log-content {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
height: 100%;
|
||||
// max-height: 400px;
|
||||
iframe {
|
||||
.word-wrap {
|
||||
color: white !important;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 可选:日志类型按钮颜色也同步调整(可选) ==========
|
||||
.type-btn {
|
||||
:deep(&.ant-btn-primary) {
|
||||
background: #722ed1 !important; // 日志类型选中用紫色(可改)
|
||||
border-color: #722ed1 !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover {
|
||||
background: #8046e0 !important;
|
||||
border-color: #8046e0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+367
-108
@@ -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>支持格式:MP3、WAV、AAC、FLAC、OGG、M4A、WMA,单个文件最大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>
|
||||
@@ -71,10 +71,13 @@
|
||||
<a-list size="small" bordered :data-source="deleteVideos">
|
||||
<template #renderItem="{item, index}">
|
||||
<a-list-item>
|
||||
|
||||
<span>
|
||||
{{index+1}}. {{ item.videoTitle }}
|
||||
<!-- 新增文本容器,用于控制省略号 -->
|
||||
<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>
|
||||
+102
-26
@@ -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],
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
+25
-12
@@ -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;
|
||||
if (redirectPath !== '/login') {
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: { redirect: redirectPath }
|
||||
}).then(() => {
|
||||
console.log('跳转登录页成功');
|
||||
}).catch((err) => {
|
||||
console.error('跳转登录页失败:', 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,6 +93,7 @@ http.interceptors.response.use(
|
||||
if (NProgress.isStarted()) {
|
||||
NProgress.done();
|
||||
}
|
||||
// 改回你原本的返回值:return error(之前改这个导致登录异常)
|
||||
return error;
|
||||
}
|
||||
);
|
||||
|
||||
+36
-17
@@ -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-Type,axios 会自动处理为 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':
|
||||
|
||||
+48
-49
@@ -512,8 +512,44 @@ namespace dy.net.job
|
||||
var dynamicVideo = await ProcessDynamicVideo(dynamicVideoUrls, cookie, item, data, config);
|
||||
if (dynamicVideo != null)
|
||||
{
|
||||
if(!string.IsNullOrEmpty(dynamicVideo.DynamicVideos))
|
||||
{
|
||||
var dynamicVideos = JsonConvert.DeserializeObject<List<string>>(dynamicVideo.DynamicVideos);
|
||||
Log.Debug($"{VideoType}-动态视频[{item.Desc}],下载成功 ,共{dynamicVideos?.Count}个视频...");
|
||||
if (config.MegDynamicVideo)
|
||||
{
|
||||
if (dynamicVideos != null && dynamicVideos.Count > 0)
|
||||
{
|
||||
int width = 1080;
|
||||
int height = 1920;
|
||||
var bit = item?.Video?.BitRate?.FirstOrDefault();
|
||||
if (bit != null)
|
||||
{
|
||||
width = bit.PlayAddr.Width;
|
||||
height = bit.PlayAddr.Height;
|
||||
}
|
||||
var savePath = DouyinFileNameHelper.RemoveNumberSuffix(dynamicVideo.VideoSavePath);
|
||||
var outPath= await douyinMergeVideoService.MergeMultipleVideosAsync(dynamicVideos, savePath, width, height);
|
||||
if (File.Exists(outPath))
|
||||
{
|
||||
dynamicVideo.VideoSavePath = outPath;
|
||||
|
||||
if (!config.KeepDynamicVideo)
|
||||
{
|
||||
foreach (var opath in dynamicVideos)
|
||||
{
|
||||
if (File.Exists(opath))
|
||||
File.Delete(opath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
videos.Add(dynamicVideo);
|
||||
Log.Debug($"{VideoType}-动态视频[{item.Desc}],下载成功 ,共{dynamicVideo.DynamicVideos.Count()}个视频...");
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -774,10 +810,9 @@ namespace dy.net.job
|
||||
await DownVideoCover(item, saveFolder, cookie, config);
|
||||
// 下载作者头像
|
||||
var (avatarSavePath, avatarUrl) = await DownAuthorAvatar(cookie, item);
|
||||
// 生成NFO文件
|
||||
await GenerateNfoFile(saveFolder, item, avatarUrl, cookie, config);
|
||||
|
||||
// 创建视频实体
|
||||
return CreateVideoEntity(config,cookie, item, v, savePath, saveFolder, tag1, tag2, tag3, avatarSavePath, avatarUrl, data);
|
||||
return await CreateVideoEntity(config,cookie, item, v, savePath, saveFolder, tag1, tag2, tag3, avatarSavePath, avatarUrl, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -846,7 +881,7 @@ namespace dy.net.job
|
||||
DataSize = DouyinFileUtils.GetTotalFileSize(dynamicSavePaths) // 合成视频的文件大小
|
||||
}
|
||||
};
|
||||
return CreateVideoEntity(config,cookie, item, virtualBitRate, dynamicSavePaths.FirstOrDefault(), saveFolder, tag1, tag2, tag3,"", "", data,dynamicSavePaths);
|
||||
return await CreateVideoEntity(config,cookie, item, virtualBitRate, dynamicSavePaths.FirstOrDefault(), saveFolder, tag1, tag2, tag3,"", "", data,dynamicSavePaths);
|
||||
}
|
||||
|
||||
|
||||
@@ -1025,8 +1060,6 @@ namespace dy.net.job
|
||||
await DownVideoCover(imageUrls.FirstOrDefault(), fileNamefolder, cookie, item, config);
|
||||
// 下载作者头像
|
||||
var (avatarSavePath, avatarUrl) = await DownAuthorAvatar(cookie, item);
|
||||
// 生成NFO文件
|
||||
await GenerateNfoFile(fileNamefolder, item, avatarUrl, cookie, config);
|
||||
|
||||
// 获取视频标签
|
||||
var (tag1, tag2, tag3) = GetVideoTags(item);
|
||||
@@ -1042,7 +1075,7 @@ namespace dy.net.job
|
||||
};
|
||||
|
||||
// 创建视频实体
|
||||
var videoEntity = CreateVideoEntity(config,
|
||||
var videoEntity =await CreateVideoEntity(config,
|
||||
cookie, item, virtualBitRate, savePath, fileNamefolder,
|
||||
tag1, tag2, tag3, avatarSavePath, avatarUrl, data);
|
||||
|
||||
@@ -1084,45 +1117,7 @@ namespace dy.net.job
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成NFO文件
|
||||
/// NFO文件包含视频的元数据信息,如标题、作者、封面等
|
||||
/// </summary>
|
||||
/// <param name="saveFolder">NFO文件的保存文件夹</param>
|
||||
/// <param name="item">视频信息</param>
|
||||
/// <param name="avatarSavePath">作者头像保存路径</param>
|
||||
/// <param name="cookie"></param>
|
||||
/// <param name="config"></param>
|
||||
/// <returns>一个表示异步操作的任务</returns>
|
||||
protected async Task GenerateNfoFile(string saveFolder, Aweme item,
|
||||
string avatarSavePath, DouyinCookie cookie, AppConfig config)
|
||||
{
|
||||
// 异步生成NFO文件,避免阻塞主线程
|
||||
await Task.Run(() =>
|
||||
{
|
||||
(string tag1, string tag2, string tag3) = GetVideoTags(item);
|
||||
var nfoFileName = GetNfoFileName(cookie, item, config, ".nfo");
|
||||
var poster = GetNfoFileName(cookie, item, config, "poster.jpg");
|
||||
var nfoPath = Path.Combine(saveFolder, nfoFileName);
|
||||
NfoFileGenerator.GenerateNfoFile(new DouyinVideoNfo
|
||||
{
|
||||
Actors = new List<Actor>
|
||||
{
|
||||
new() {
|
||||
Name = item.Author?.Nickname,
|
||||
Role = "主演",
|
||||
Thumb = avatarSavePath
|
||||
}
|
||||
},
|
||||
Author = item.Author?.Nickname,
|
||||
Poster = poster,
|
||||
Title = item.Desc,
|
||||
Thumbnail = poster,// 使用poster作为缩略图
|
||||
ReleaseDate = DateTimeUtil.Convert10BitTimestamp(item.CreateTime),
|
||||
Genres = new List<string> { tag1, tag2, tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList()
|
||||
}, nfoPath);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 下载视频封面
|
||||
@@ -1275,7 +1270,7 @@ namespace dy.net.job
|
||||
/// <param name="data">视频信息对象</param>
|
||||
/// <param name="dynamicVideos">动态视频</param>
|
||||
/// <returns>创建的视频实体对象</returns>
|
||||
private DouyinVideo CreateVideoEntity(AppConfig config,
|
||||
private async Task<DouyinVideo> CreateVideoEntity(AppConfig config,
|
||||
DouyinCookie cookie, Aweme item, VideoBitRate bitRate, string savePath, string saveFolder,
|
||||
string tag1, string tag2, string tag3, string avatarSavePath, string avatarUrl, DouyinVideoInfo data,List<string> dynamicVideos=null)
|
||||
{
|
||||
@@ -1290,7 +1285,7 @@ namespace dy.net.job
|
||||
AuthorAvatar = avatarSavePath,
|
||||
AuthorAvatarUrl = avatarUrl,
|
||||
CreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime),
|
||||
VideoTitle = item.Desc,
|
||||
VideoTitle = string.IsNullOrWhiteSpace(item.Desc) ? $"{item.Author?.Nickname}-{item.CreateTime}" : item.Desc,
|
||||
VideoTitleSimplify = diffs.VideoTitleSimplify,
|
||||
Id = IdGener.GetLong().ToString(),
|
||||
Resolution = $"{bitRate.PlayAddr.Width}×{bitRate.PlayAddr.Height}",
|
||||
@@ -1312,6 +1307,10 @@ namespace dy.net.job
|
||||
{
|
||||
video.DynamicVideos = JsonConvert.SerializeObject(dynamicVideos);
|
||||
}
|
||||
|
||||
// 生成NFO文件
|
||||
NfoFileGenerator.GenerateVideoNfoFile(video);
|
||||
|
||||
return video;
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -24,7 +24,7 @@ namespace dy.net.model
|
||||
/// <summary>
|
||||
/// 每次查询数量
|
||||
/// </summary>
|
||||
public int BatchCount { get; set; } = 10;
|
||||
public int BatchCount { get; set; } = 18;
|
||||
|
||||
/// <summary>
|
||||
/// 博主视频是否 直接用标题做文件名
|
||||
@@ -92,6 +92,14 @@ namespace dy.net.model
|
||||
/// 仅同步新视频(github 有人提议增加这个配置项,因为之前收藏了很多烂七八糟的视频。不想同步,又不想一个一个清除)
|
||||
/// </summary>
|
||||
public bool OnlySyncNew { get; set; } = true;
|
||||
/// <summary>
|
||||
/// 是否合并下载动态图视频
|
||||
/// </summary>
|
||||
public bool MegDynamicVideo { get; set; }
|
||||
/// <summary>
|
||||
/// 保留原动态视频文件
|
||||
/// </summary>
|
||||
public bool KeepDynamicVideo { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ namespace dy.net.service
|
||||
conf.PriorityLevel = "[{\"id\":1,\"name\":\"喜欢的视频\",\"sort\":1},{\"id\":2,\"name\":\"收藏的视频\",\"sort\":2},{\"id\":3,\"name\":\"关注的视频\",\"sort\":3}]";
|
||||
}
|
||||
conf.IsFirstRunning = true;//标记为程序刚启动第一次运行
|
||||
conf.AutoDistinct = true;
|
||||
sqlSugarClient.Updateable(conf).ExecuteCommand();
|
||||
//兼容旧版本
|
||||
return conf;
|
||||
@@ -60,7 +61,10 @@ namespace dy.net.service
|
||||
AutoDistinct = true,//默认开启
|
||||
PriorityLevel = "[{\"id\":1,\"name\":\"喜欢的视频\",\"sort\":1},{\"id\":2,\"name\":\"收藏的视频\",\"sort\":2},{\"id\":3,\"name\":\"关注的视频\",\"sort\":3}]",
|
||||
IsFirstRunning = true,
|
||||
OnlySyncNew = true
|
||||
OnlySyncNew = true,
|
||||
DownDynamicVideo = false,
|
||||
KeepDynamicVideo = false,
|
||||
MegDynamicVideo = false
|
||||
};
|
||||
sqlSugarClient.Insertable(config).ExecuteCommand();
|
||||
return config;
|
||||
|
||||
@@ -11,12 +11,10 @@ namespace dy.net.service
|
||||
/// </summary>
|
||||
public class DouyinMergeVideoService
|
||||
{
|
||||
private readonly FFmpegHelper _fFmpegHelper;
|
||||
private readonly DouyinHttpClientService douyinHttpClientService;
|
||||
|
||||
public DouyinMergeVideoService(FFmpegHelper fFmpegHelper,DouyinHttpClientService douyinHttpClientService)
|
||||
public DouyinMergeVideoService(DouyinHttpClientService douyinHttpClientService)
|
||||
{
|
||||
_fFmpegHelper = fFmpegHelper;
|
||||
this.douyinHttpClientService = douyinHttpClientService;
|
||||
}
|
||||
|
||||
@@ -27,142 +25,23 @@ namespace dy.net.service
|
||||
// 重试间隔(毫秒)
|
||||
private const int RetryDelay = 1000;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 视频合成
|
||||
/// 多视频合成一个视频
|
||||
/// </summary>
|
||||
/// <param name="cookie"></param>
|
||||
/// <param name="rootPath"></param>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="outputVideoPath"></param>
|
||||
/// <param name="fileNamefolder"></param>
|
||||
/// <param name="mergeImg2Viedo"></param>
|
||||
/// <param name="downImage"></param>
|
||||
/// <param name="downMp3"></param>
|
||||
/// <param name="videoFilePaths"></param>
|
||||
/// <param name="savePath"></param>
|
||||
/// <param name="width"></param>
|
||||
/// <param name="height"></param>
|
||||
/// <returns></returns>
|
||||
//public async Task<bool> MergeToVideo(string cookie,string rootPath, MediaMergeRequest request,string outputVideoPath,string fileNamefolder,bool mergeImg2Viedo,bool downImage=false,bool downMp3=false)
|
||||
//{
|
||||
|
||||
// try
|
||||
// {
|
||||
// // 创建唯一临时目录(避免并发冲突)
|
||||
// var tempDir = Path.Combine(rootPath, "temp", Guid.NewGuid().ToString());
|
||||
// try
|
||||
// {
|
||||
// // 1. 下载图片
|
||||
// var (rawImages, error) = await DownloadMediaAsync(request.ImageUrls, Path.Combine(tempDir, "raw-images"), "image_", "webp",cookie);
|
||||
// if (!string.IsNullOrEmpty(error))
|
||||
// {
|
||||
// Serilog.Log.Error($"{error}");
|
||||
// return false;
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// if(downImage)
|
||||
// {
|
||||
// for (int i = 0; i < rawImages.Length; i++)
|
||||
// {
|
||||
// string sourcePath = rawImages[i];
|
||||
// // 重命名为有规律的文件名,如 temp_001.jpg, temp_002.png
|
||||
// string extension = Path.GetExtension(sourcePath);
|
||||
// string destFileName = $"temp_{i + 1:D3}{extension}"; // D3 确保是3位数字,不足补0
|
||||
// string destPath = Path.Combine(fileNamefolder, destFileName);
|
||||
// if (destPath.Contains("小可爱") || sourcePath.Contains("小可爱")) {
|
||||
// Console.WriteLine("发现小可爱图片");
|
||||
// }
|
||||
// if (!File.Exists(destPath))
|
||||
// File.Copy(sourcePath, destPath);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// // 2. 下载音频
|
||||
// var (rawAudios, audioError) = await DownloadMediaAsync(request.AudioUrls, Path.Combine(tempDir, "raw-audios"), "audio_", "mp3", cookie);
|
||||
// if (!string.IsNullOrEmpty(audioError))
|
||||
// {
|
||||
// Serilog.Log.Error($"{audioError}");
|
||||
// return false;
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// if(downMp3)
|
||||
// {
|
||||
// for (int i = 0; i < rawAudios.Length; i++)
|
||||
// {
|
||||
// string sourcePath = rawAudios[i];
|
||||
// // 重命名为有规律的文件名,如 temp_001.mp3, temp_002.mp3
|
||||
// string extension = Path.GetExtension(sourcePath);
|
||||
// string destFileName = $"temp_{i + 1:D3}{extension}"; // D3 确保是3位数字,不足补0
|
||||
// string destPath = Path.Combine(fileNamefolder, destFileName);
|
||||
// if (!File.Exists(destPath))
|
||||
// File.Copy(sourcePath, destPath);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// if (!mergeImg2Viedo)
|
||||
// {
|
||||
// // 不合成视频,直接返回成功
|
||||
// Serilog.Log.Debug($"不合成视频,直接返回");
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// // 4. 合成视频
|
||||
// //var outputVideoPath = Path.Combine(tempDir, "output", $"merged-video.{request.OutputFormat.ToLower()}");
|
||||
|
||||
// // 2. 创建帮助类实例
|
||||
// // 在Docker容器内,FFmpeg通常在PATH中,所以直接用 "ffmpeg" 即可
|
||||
|
||||
// // 根据图片数量调整每张图片显示时长
|
||||
// if (request.ImageUrls.Count <= 3)
|
||||
// {
|
||||
// request.ImageDurationPerSecond = 3;
|
||||
// }
|
||||
// if (request.ImageUrls.Count > 20)
|
||||
// {
|
||||
// request.ImageDurationPerSecond = 2;
|
||||
// }
|
||||
// // 3. (可选)自定义视频参数
|
||||
// _fFmpegHelper.VideoWidth = 1080;
|
||||
// _fFmpegHelper.VideoHeight = 1920;
|
||||
// _fFmpegHelper.ImageDisplayDurationSeconds = request.ImageDurationPerSecond;
|
||||
// _fFmpegHelper.OutputFrameRate = 30;
|
||||
|
||||
// // 4. 创建进度
|
||||
// var progress = new Progress<double>(p =>
|
||||
// {
|
||||
// Console.WriteLine($"进度: {p:F2}%");
|
||||
// });
|
||||
|
||||
// // 5. 执行合成任务
|
||||
// using (var cancellationTokenSource = new CancellationTokenSource())
|
||||
// {
|
||||
// string resultPath = await _fFmpegHelper.CreateVideoFromImagesAndAudioAsync(
|
||||
// rawImages,
|
||||
// rawAudios[0],
|
||||
// outputVideoPath,
|
||||
// request.VideoWidth,
|
||||
// request.VideoHeight,
|
||||
// progress,
|
||||
// cancellationTokenSource.Token);
|
||||
|
||||
// //Console.WriteLine($"视频合成成功!文件已保存至: {resultPath}");
|
||||
// Serilog.Log.Debug($"视频合成成功!文件已保存至: {resultPath}");
|
||||
// }
|
||||
// }
|
||||
// finally
|
||||
// {
|
||||
// // 清理临时目录(无论成功失败)
|
||||
// if (Directory.Exists(tempDir))
|
||||
// {
|
||||
// Directory.Delete(tempDir, recursive: true);
|
||||
// }
|
||||
// }
|
||||
// return true;
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Serilog.Log.Error($"{ex.StackTrace}");
|
||||
// return false;
|
||||
// }
|
||||
//}
|
||||
public async Task<string> MergeMultipleVideosAsync(
|
||||
List<string> videoFilePaths,
|
||||
string savePath,
|
||||
int width = 1080,
|
||||
int height = 1920)
|
||||
{
|
||||
return await new FFmpegHelper().MergeMultipleVideosAsync(videoFilePaths, savePath, width, height);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
@@ -261,7 +140,16 @@ namespace dy.net.service
|
||||
|
||||
if (rawAudios.Length == 0)
|
||||
{
|
||||
rawAudios= new string[] { Path.Combine(AppContext.BaseDirectory,"mp3", "silent_10.mp3") };
|
||||
|
||||
var mp3Path = Path.Combine(AppContext.BaseDirectory, "mp3", "silent_10.mp3");
|
||||
var uploadMp3 = Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "mp3"))
|
||||
.Where(filePath => Path.GetFileNameWithoutExtension(filePath) != "silent_10")
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(uploadMp3) && File.Exists(uploadMp3))
|
||||
{
|
||||
mp3Path = uploadMp3;
|
||||
}
|
||||
rawAudios = new string[] { mp3Path };
|
||||
Log.Debug("版权原因无法下载音频,使用默认无声音频文件");
|
||||
}
|
||||
|
||||
@@ -298,7 +186,6 @@ namespace dy.net.service
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 保存下载的文件(图片/音频)到目标目录
|
||||
/// </summary>
|
||||
@@ -356,8 +243,6 @@ namespace dy.net.service
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 安全清理临时目录(避免文件被占用)
|
||||
/// </summary>
|
||||
|
||||
@@ -4,6 +4,7 @@ using dy.net.model;
|
||||
using dy.net.repository;
|
||||
using dy.net.utils;
|
||||
using Newtonsoft.Json;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -124,7 +125,7 @@ namespace dy.net.service
|
||||
data.GraphicVideoSize = "<0.01";//避免显示0.00误导用户
|
||||
}
|
||||
}
|
||||
data.Authors = list.GroupBy(x => x.Author).Select(x => new VideoStaticsItemDto { Name = x.Key, Count = x.LongCount(), Icon = x.FirstOrDefault().AuthorAvatarUrl }).OrderByDescending(d => d.Count).ToList();
|
||||
data.Authors = list.GroupBy(x => x.Author).Select(x => new VideoStaticsItemDto { Name = x.Key, Count = x.LongCount(), Icon = x.LastOrDefault().AuthorAvatarUrl }).OrderByDescending(d => d.Count).ToList();
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -148,6 +149,11 @@ namespace dy.net.service
|
||||
return await _dyCollectVideoRepository.GetPagedAsync(dto);
|
||||
}
|
||||
|
||||
public async Task<List<DouyinVideo>> GetAllAsync()
|
||||
{
|
||||
return await _dyCollectVideoRepository.GetAllAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关注的博主的视频如果配置为视频标题作为文件名,生成文件名
|
||||
/// </summary>
|
||||
|
||||
@@ -99,5 +99,20 @@ namespace dy.net.utils
|
||||
// 忽略文化差异,仅按字符编码匹配
|
||||
return Regex.IsMatch(input, pattern, RegexOptions.None);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 去掉动态视频001_002
|
||||
/// </summary>
|
||||
/// <param name="fileName"></param>
|
||||
/// <returns></returns>
|
||||
public static string RemoveNumberSuffix(string fileName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fileName))
|
||||
return fileName;
|
||||
// 核心正则:只匹配「_+数字」且后面紧跟.的情况
|
||||
var pattern = @"_\d+(?=\.)";
|
||||
return Regex.Replace(fileName, pattern, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,113 @@ namespace dy.net.utils
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 合并多个视频文件为一个MP4视频
|
||||
/// </summary>
|
||||
/// <param name="videoFilePaths">待合并的视频路径列表(按合并顺序排列)</param>
|
||||
/// <param name="savePath">输出视频的保存路径</param>
|
||||
/// <param name="width">输出视频宽度(自动修正为偶数)</param>
|
||||
/// <param name="height">输出视频高度(自动修正为偶数)</param>
|
||||
/// <param name="progress">进度回调</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>输出视频路径</returns>
|
||||
public async Task<string> MergeMultipleVideosAsync(
|
||||
List<string> videoFilePaths,
|
||||
string savePath,
|
||||
int width = 1080,
|
||||
int height = 1920,
|
||||
IProgress<double> progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 输入验证
|
||||
if (videoFilePaths == null || !videoFilePaths.Any())
|
||||
throw new ArgumentException("视频路径列表不能为空。", nameof(videoFilePaths));
|
||||
|
||||
foreach (var videoPath in videoFilePaths)
|
||||
{
|
||||
if (!File.Exists(videoPath))
|
||||
throw new FileNotFoundException("视频文件未找到。", videoPath);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(savePath))
|
||||
throw new ArgumentNullException(nameof(savePath));
|
||||
|
||||
// 自动修正分辨率为偶数(H264编码要求)
|
||||
if (width % 2 != 0) width++;
|
||||
if (height % 2 != 0) height++;
|
||||
|
||||
// 步骤1:创建临时文件列表(FFmpeg合并视频需要先生成文件列表)
|
||||
string tempListFile = Path.Combine(AppContext.BaseDirectory, "temp", $"{Guid.NewGuid()}.txt");
|
||||
var tempDir = Path.GetDirectoryName(tempListFile);
|
||||
if (!Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.CreateDirectory(tempDir);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 生成FFmpeg识别的文件列表(格式:file '绝对路径')
|
||||
var fileListContent = new StringBuilder();
|
||||
foreach (var videoPath in videoFilePaths)
|
||||
{
|
||||
// 处理路径中的特殊字符,确保跨平台兼容
|
||||
string escapedPath = videoPath.Replace("\\", "/").Replace("'", "\\'");
|
||||
fileListContent.AppendLine($"file '{escapedPath}'");
|
||||
}
|
||||
File.WriteAllText(tempListFile, fileListContent.ToString(), Encoding.UTF8);
|
||||
|
||||
// 步骤2:构建FFmpeg合并参数
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"-y", // 覆盖输出文件
|
||||
"-f", "concat", // 指定合并格式
|
||||
"-safe", "0", // 允许访问绝对路径
|
||||
"-i", tempListFile, // 输入文件列表
|
||||
|
||||
// 视频编码参数(复用现有类的编码配置,保证输出格式统一)
|
||||
"-c:v", VideoCodec,
|
||||
"-preset", VideoPreset,
|
||||
"-crf", $"{VideoCrf}",
|
||||
"-s", $"{width}x{height}", // 统一输出分辨率
|
||||
"-pix_fmt", "yuv420p", // 兼容所有播放器
|
||||
"-profile:v", "main",
|
||||
|
||||
// 音频编码参数
|
||||
"-c:a", AudioCodec,
|
||||
"-b:a", AudioBitrate,
|
||||
"-ac", "2", // 立体声
|
||||
"-ar", "44100", // 标准采样率
|
||||
|
||||
// 封装优化
|
||||
"-f", "mp4",
|
||||
"-movflags", "+faststart", // 适合网络播放
|
||||
|
||||
// 输出路径
|
||||
savePath
|
||||
};
|
||||
|
||||
// 执行FFmpeg合并命令
|
||||
await ExecuteFFmpegAsync(arguments, progress, cancellationToken);
|
||||
|
||||
// 验证输出文件
|
||||
if (File.Exists(savePath))
|
||||
{
|
||||
return savePath;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("视频合并失败,未生成输出文件。");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 清理临时文件
|
||||
if (File.Exists(tempListFile))
|
||||
{
|
||||
File.Delete(tempListFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步执行FFmpeg命令
|
||||
|
||||
+74
-12
@@ -1,4 +1,6 @@
|
||||
using dy.net.dto;
|
||||
using dy.net.model;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
|
||||
@@ -10,7 +12,69 @@ namespace dy.net.utils
|
||||
public class NfoFileGenerator
|
||||
{
|
||||
|
||||
public static void GenerateNfoFile(DouyinVideoNfo videoInfo, string filePath)
|
||||
|
||||
/// <summary>
|
||||
/// 生成NFO文件
|
||||
/// NFO文件包含视频的元数据信息,如标题、作者、封面等
|
||||
/// </summary>
|
||||
/// <param name="video">视频信息</param>
|
||||
/// <returns>一个表示异步操作的任务</returns>
|
||||
public static void GenerateVideoNfoFile(DouyinVideo video)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
string videoDirectory = Path.GetDirectoryName(video.VideoSavePath); // 视频所在目录
|
||||
string videoFileNameWithoutExt = Path.GetFileNameWithoutExtension(video.VideoSavePath); // 无扩展名的文件名
|
||||
string nfoFullPath = Path.Combine(videoDirectory, $"{videoFileNameWithoutExt}.nfo"); // NFO文件完整路径
|
||||
string postFullPath = Path.Combine(videoDirectory, "poster.jpg"); // NFO文件完整路径
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(video.AuthorAvatar))
|
||||
{
|
||||
if (!video.OnlyImgOrOnlyMp3)
|
||||
{
|
||||
//说明是视频
|
||||
//复制作者头像到当前目录 并改名为跟nfo里面的作者相同的名字
|
||||
if (File.Exists(video.AuthorAvatar))
|
||||
{
|
||||
var fileExt = Path.GetExtension(video.AuthorAvatar);
|
||||
|
||||
var nfoActorFullPath = Path.Combine(videoDirectory, $"{video.Author}{fileExt}");
|
||||
|
||||
if (File.Exists(nfoActorFullPath))
|
||||
{
|
||||
File.Delete(nfoActorFullPath);
|
||||
}
|
||||
// 执行复制(CopyTo支持覆盖,但先删除更可控)
|
||||
File.Copy(video.AuthorAvatar, nfoActorFullPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GenerateNfoFile(new DouyinVideoNfo
|
||||
{
|
||||
Actors = new List<Actor>
|
||||
{
|
||||
new() {
|
||||
Name = video.Author,
|
||||
Role = "主演",
|
||||
}
|
||||
},
|
||||
Author = video.Author,
|
||||
Poster = postFullPath,
|
||||
Title = video.VideoTitle,
|
||||
Thumbnail = postFullPath,// 使用poster作为缩略图
|
||||
ReleaseDate = video.CreateTime,
|
||||
Genres = new List<string> { video.Tag1, video.Tag2, video.Tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList()
|
||||
}, nfoFullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "{f}nfo文件生成异常", video.VideoTitle);
|
||||
}
|
||||
}
|
||||
|
||||
private static void GenerateNfoFile(DouyinVideoNfo videoInfo, string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -22,6 +86,10 @@ namespace dy.net.utils
|
||||
|
||||
// 创建根元素
|
||||
XElement root = new XElement("movie");
|
||||
root.Add(new XElement("outline"));
|
||||
root.Add(new XElement("lockdata", true));
|
||||
root.Add(new XElement("director", videoInfo.Author));
|
||||
root.Add(new XElement("plot", $"<![CDATA[{videoInfo.Title}]]>"));
|
||||
|
||||
// 添加视频信息(先清理无效字符)
|
||||
if (!string.IsNullOrWhiteSpace(videoInfo.Title))
|
||||
@@ -32,7 +100,10 @@ namespace dy.net.utils
|
||||
|
||||
// 发布时间(无需清理,因为是格式化的日期字符串)
|
||||
if (videoInfo.ReleaseDate.HasValue)
|
||||
{
|
||||
root.Add(new XElement("releasedate", videoInfo.ReleaseDate.Value.ToString("yyyy-MM-dd")));
|
||||
root.Add(new XElement("premiered", videoInfo.ReleaseDate.Value.ToString("yyyy-MM-dd")));
|
||||
}
|
||||
|
||||
// 分类标签(清理每个标签)
|
||||
if (videoInfo.Genres != null && videoInfo.Genres.Any())
|
||||
@@ -47,7 +118,6 @@ namespace dy.net.utils
|
||||
// --- 新增:处理演员信息 ---
|
||||
if (videoInfo.Actors != null && videoInfo.Actors.Any())
|
||||
{
|
||||
var actorsElement = new XElement("actors");
|
||||
foreach (var actor in videoInfo.Actors)
|
||||
{
|
||||
// 至少需要演员姓名
|
||||
@@ -55,22 +125,14 @@ namespace dy.net.utils
|
||||
{
|
||||
var actorElement = new XElement("actor");
|
||||
actorElement.Add(new XElement("name", CleanInvalidXmlChars(actor.Name)));
|
||||
|
||||
// 可选的角色和头像
|
||||
if (!string.IsNullOrWhiteSpace(actor.Role))
|
||||
actorElement.Add(new XElement("role", CleanInvalidXmlChars(actor.Role)));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actor.Thumb))
|
||||
actorElement.Add(new XElement("thumb", CleanInvalidXmlChars(actor.Thumb)));
|
||||
actorElement.Add(new XElement("tmdbid", "3141592610000"));//写死一个反正不存在的ID,防止被媒体管理软件误认
|
||||
|
||||
actorsElement.Add(actorElement);
|
||||
root.Add(actorElement);
|
||||
}
|
||||
}
|
||||
// 将整个 <actors> 节点添加到根节点
|
||||
if (actorsElement.HasElements)
|
||||
{
|
||||
root.Add(actorsElement);
|
||||
}
|
||||
}
|
||||
// --- 演员信息处理结束 ---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user