feat: add fnOS packaging, storage workflows and release pipeline

This commit is contained in:
2026-08-11 18:05:49 +08:00
parent c5922f9b08
commit 95932f0199
181 changed files with 24024 additions and 1164 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.ci
artifacts
app/node_modules
app/dist
bin
obj
tests/**/bin
tests/**/obj
db
logs
storage-data
*.fpk
+5 -1
View File
@@ -5,6 +5,10 @@ logs
upload
data1
/db/*.sqlite
/appsettings.json
/artifacts/
/.ci/
/.npm-cache/
/.nuget-packages/
/.dotnet-cli-home/
/Properties/PublishProfiles/fn.pubxml.user
/Properties/PublishProfiles/*.user
+81 -9
View File
@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using System.Net;
using System.Text.RegularExpressions;
using dy.net.storage;
namespace dy.net.Controllers
{
@@ -25,10 +26,12 @@ namespace dy.net.Controllers
private readonly DouyinFollowService douyinFollowService;
private readonly DouyinCookieService douyinCookieService;
private readonly DouyinHttpClientService httpClientService;
private readonly WebDavSettingsService _webDavSettingsService;
private readonly VideoTaskService _videoTaskService;
public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService)
public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService, WebDavSettingsService webDavSettingsService, VideoTaskService videoTaskService)
{
this.dyCookieService = dyCookieService;
this.commonService = commonService;
@@ -36,6 +39,8 @@ namespace dy.net.Controllers
this.douyinFollowService = douyinFollowService;
this.douyinCookieService = douyinCookieService;
this.httpClientService = httpClientService;
_webDavSettingsService = webDavSettingsService;
_videoTaskService = videoTaskService;
}
@@ -102,6 +107,11 @@ namespace dy.net.Controllers
{
if (cookies?.Count > 0)
{
foreach (var cookie in cookies) ClearImportedSourceHealth(cookie);
if (commonService.GetConfig()?.StorageType.IsRemote() == true)
{
foreach (var cookie in cookies) _webDavSettingsService.ApplyDefaultCookiePaths(cookie);
}
var imported = await douyinCookieService.ImportCookies(cookies);
if (imported)
Serilog.Log.Debug("抖音Cookie配置导入成功");
@@ -164,6 +174,12 @@ namespace dy.net.Controllers
if (!cookieValid)
return ApiResult.Fail("Cookie无效或已过期,请按照文档提示重新获取有效Cookie,不要使用插件获取cookie");
var result = await dyCookieService.FastResetCookie(dto.id, dto.cookie);
if (result)
{
if (!string.IsNullOrWhiteSpace(dto.id)) await _videoTaskService.ResetSourceHealthAsync(dto.id);
else
foreach (var item in await dyCookieService.GetAllAsync()) await _videoTaskService.ResetSourceHealthAsync(item.Id);
}
return result ? ApiResult.Success() : ApiResult.Fail("cookie设置失败");
}
@@ -193,8 +209,12 @@ namespace dy.net.Controllers
// 1. 基础赋值
dyUserCookies.Id = IdGener.GetLong().ToString();
var storageType = commonService.GetConfig()?.StorageType ?? StorageType.Local;
if (storageType.IsRemote())
_webDavSettingsService.ApplyDefaultCookiePaths(dyUserCookies);
// 2. 路径权限校验
var (Success, Message) = ValidatePaths(dyUserCookies);
var (Success, Message) = ValidatePaths(dyUserCookies, storageType);
if (!Success)
return ApiResult.Fail(Message);
@@ -212,8 +232,28 @@ namespace dy.net.Controllers
return saved ? ApiResult.Success() : ApiResult.Fail("添加失败");
}
private static (bool Success, string Message) ValidatePaths(DouyinCookie cookie)
private static (bool Success, string Message) ValidatePaths(DouyinCookie cookie, StorageType storageType)
{
if (storageType.IsRemote())
{
var remotePaths = new Dictionary<string, string>
{
{ "收藏 OpenList 相对路径", cookie.WebDavCollectPath },
{ "喜欢 OpenList 相对路径", cookie.WebDavFavoritePath },
{ "关注 OpenList 相对路径", cookie.WebDavFollowPath },
{ "合集 OpenList 相对路径", cookie.WebDavMixPath },
{ "短剧 OpenList 相对路径", cookie.WebDavSeriesPath }
};
foreach (var (label, path) in remotePaths)
{
if (string.IsNullOrWhiteSpace(path)) return (false, $"{label}不能为空");
try { StoragePath.NormalizeRemote(path); }
catch (ArgumentException ex) { return (false, $"{label}无效:{ex.Message}"); }
}
return (true, string.Empty);
}
var pathsToCheck = new Dictionary<string, string>
{
{ "收藏存储路径", cookie.SavePath },
@@ -262,6 +302,19 @@ namespace dy.net.Controllers
[HttpPost("update")]
public async Task<IActionResult> AddOrUpdateAsync([FromBody] DouyinCookie dyUserCookies)
{
if (dyUserCookies == null) return ApiResult.Fail("配置不能为空");
var isNewCookie = string.IsNullOrWhiteSpace(dyUserCookies.Id) || dyUserCookies.Id == "0";
var storageType = commonService.GetConfig()?.StorageType ?? StorageType.Local;
if (storageType.IsRemote())
{
if (isNewCookie)
dyUserCookies.Id = IdGener.GetLong().ToString();
_webDavSettingsService.ApplyDefaultCookiePaths(dyUserCookies);
var (success, message) = ValidatePaths(dyUserCookies, storageType);
if (!success) return ApiResult.Fail(message);
}
RemoveCookieLineString(dyUserCookies);
var checkCk = await httpClientService.CheckCookie(dyUserCookies);
@@ -271,9 +324,10 @@ namespace dy.net.Controllers
}
dyUserCookies.StatusCode = 0;
dyUserCookies.StatusMsg = "正常";
if (dyUserCookies.Id == "0")
if (isNewCookie)
{
dyUserCookies.Id = IdGener.GetLong().ToString();
if (string.IsNullOrWhiteSpace(dyUserCookies.Id) || dyUserCookies.Id == "0")
dyUserCookies.Id = IdGener.GetLong().ToString();
var result = await dyCookieService.Add(dyUserCookies);
if (result)
{
@@ -287,6 +341,7 @@ namespace dy.net.Controllers
var result = await dyCookieService.UpdateCookieAsync(dyUserCookies);
if (result)
{
await _videoTaskService.ResetSourceHealthAsync(dyUserCookies.Id);
ReStartJob();
return ApiResult.Success();
}
@@ -305,6 +360,18 @@ namespace dy.net.Controllers
}
}
private static void ClearImportedSourceHealth(DouyinCookie cookie)
{
cookie.ConsecutiveSourceForbidden = 0;
cookie.SourceCooldownUntil = null;
cookie.SourceRequiresAuthorization = false;
cookie.SourceProbePending = false;
cookie.SourceProbeInProgress = false;
cookie.LastSourceStatusCode = null;
cookie.LastSourceError = null;
cookie.SourceHealthUpdatedAt = null;
}
/// <summary>
/// 批量删除用户Cookie
/// </summary>
@@ -351,10 +418,15 @@ namespace dy.net.Controllers
//[Authorize]
public async Task<IActionResult> ExecuteJobNow()
{
var config = commonService.GetConfig();
if (config != null)
await quartzJobService.InitOrReStartAllJobs(config.Cron.ToString());
return ApiResult.Success();
try
{
var tasks = await quartzJobService.TriggerVideoJobsNowAsync();
return ApiResult.Success(new { taskIds = tasks.Select(x => x.Id).ToList(), count = tasks.Count });
}
catch (InvalidOperationException ex)
{
return ApiResult.Fail(ex.Message);
}
}
+47
View File
@@ -0,0 +1,47 @@
using dy.net.model.dto;
using dy.net.service;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/email")]
[ApiController]
[Authorize]
public sealed class EmailController : ControllerBase
{
private readonly EmailNotificationSettingsService _settings;
public EmailController(EmailNotificationSettingsService settings) => _settings = settings;
[HttpGet("config")]
public async Task<IActionResult> GetConfig()
{
var settings = await _settings.GetAsync();
return ApiResult.Success(_settings.ToDto(settings));
}
[HttpPut("config")]
public async Task<IActionResult> UpdateConfig(EmailNotificationSettingsDto dto)
{
try { return ApiResult.Success(await _settings.SaveAsync(dto)); }
catch (InvalidOperationException ex) { return ApiResult.Fail(ex.Message); }
}
[HttpPost("test")]
public async Task<IActionResult> Test(EmailNotificationSettingsDto dto, CancellationToken cancellationToken)
{
try
{
var message = await _settings.TestAsync(dto, cancellationToken);
return ApiResult.Success(new { message });
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
var message = ex.GetBaseException().Message;
Serilog.Log.Warning(ex, "SMTP 测试邮件发送失败");
return ApiResult.Fail(string.IsNullOrWhiteSpace(message) ? "测试邮件发送失败" : message);
}
}
}
}
+156 -7
View File
@@ -4,6 +4,7 @@ using dy.net.service;
using dy.net.utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Serilog;
namespace dy.net.Controllers
{
@@ -14,11 +15,19 @@ namespace dy.net.Controllers
{
private readonly DouyinFollowService _douyinFollowService;
private readonly DouyinQuartzJobService _douyinQuartzJobService;
private readonly DouyinUserLookupService _douyinUserLookupService;
private readonly DouyinLiveStatusService _douyinLiveStatusService;
public FollowController(DouyinFollowService douyinFollowService, DouyinQuartzJobService douyinQuartzJobService)
public FollowController(
DouyinFollowService douyinFollowService,
DouyinQuartzJobService douyinQuartzJobService,
DouyinUserLookupService douyinUserLookupService,
DouyinLiveStatusService douyinLiveStatusService)
{
this._douyinFollowService = douyinFollowService;
_douyinQuartzJobService = douyinQuartzJobService;
_douyinUserLookupService = douyinUserLookupService;
_douyinLiveStatusService = douyinLiveStatusService;
}
@@ -52,20 +61,107 @@ namespace dy.net.Controllers
[HttpGet("sync")]
public async Task<IActionResult> SyncFollowList()
{
//后台异步
_douyinQuartzJobService.StartFollowJobOnceAsync();
await Task.Delay(1000);
return ApiResult.Success();
try
{
var started = await _douyinQuartzJobService.StartFollowJobOnceAsync();
return started
? ApiResult.Success(new { message = "关注列表更新任务已启动" })
: ApiResult.Fail("关注列表更新任务启动失败,请查看错误日志。");
}
catch (Exception ex)
{
Log.Error(ex, "手动启动关注列表更新任务失败");
return ApiResult.Fail("关注列表更新任务启动失败:" + ex.GetBaseException().Message);
}
}
[HttpPost("add")]
public async Task<IActionResult> AddFollow(DouyinFollowed followed)
{
if (followed == null)
{
return ApiResult.Fail("新增信息不能为空");
}
if (string.IsNullOrWhiteSpace(followed.mySelfId))
{
return ApiResult.Fail("请先配置抖音授权信息,抖音授权配置里面填写你的uid");
}
var res = await _douyinFollowService.AddAsync(followed);
return ApiResult.SuccOrFail(res, "", res ? "" : "添加失败,或者已存在相同secuid和uid");
if (string.IsNullOrWhiteSpace(followed.SecUid))
{
return ApiResult.Fail("博主 SecUid 不能为空");
}
if (string.IsNullOrWhiteSpace(followed.UperId))
{
return ApiResult.Fail("博主 UID 不能为空");
}
if (string.IsNullOrWhiteSpace(followed.UperName))
{
return ApiResult.Fail("博主姓名不能为空");
}
followed.mySelfId = followed.mySelfId.Trim();
followed.SecUid = followed.SecUid.Trim();
followed.UperId = followed.UperId.Trim();
followed.UperName = followed.UperName.Trim();
followed.DouyinNo = followed.DouyinNo?.Trim();
followed.SavePath = followed.SavePath?.Trim();
followed.Signature = Limit(followed.Signature, 500);
followed.UperAvatar = Limit(followed.UperAvatar, 1000);
followed.Enterprise = Limit(followed.Enterprise, 200);
followed.FullSync = followed.OpenSync && followed.FullSync;
if (followed.mySelfId.Length > 200 || followed.SecUid.Length > 500 ||
followed.UperId.Length > 100 || followed.UperName.Length > 200)
{
return ApiResult.Fail("新增信息长度超出限制,请检查 UID、SecUid 和博主姓名");
}
if (!string.IsNullOrWhiteSpace(followed.DouyinNo) && followed.DouyinNo.Length > 100)
{
return ApiResult.Fail("抖音号长度不能超过 100 个字符");
}
if (!string.IsNullOrWhiteSpace(followed.SavePath))
{
if (followed.SavePath.Length > 20)
{
return ApiResult.Fail("保存文件夹名称最长 20 个字符");
}
if (!DouyinFileNameHelper.IsValidWithoutSpecialChars(followed.SavePath))
{
return ApiResult.Fail("保存文件夹名称只能包含字母、数字或中文");
}
}
var result = await _douyinFollowService.TryAddAsync(followed);
return result switch
{
AddFollowResult.Added => ApiResult.Success("新增非关注博主成功"),
AddFollowResult.AlreadyExists => ApiResult.Fail("该博主已存在,无需重复添加"),
_ => ApiResult.Fail("添加失败,请稍后重试")
};
}
/// <summary>
/// 使用展示在抖音资料页上的抖音号查找博主。结果需要用户确认后再调用 add。
/// </summary>
[HttpPost("resolve-by-douyin-no")]
public async Task<IActionResult> ResolveByDouyinNo(
DouyinFollowLookupRequest request,
CancellationToken cancellationToken)
{
try
{
var result = await _douyinUserLookupService.ResolveAsync(request, cancellationToken);
return ApiResult.Success(result);
}
catch (InvalidOperationException ex)
{
return ApiResult.Fail(ex.Message);
}
}
private static string Limit(string value, int maxLength)
{
value = value?.Trim();
return value != null && value.Length > maxLength ? value[..maxLength] : value;
}
/// <summary>
@@ -107,6 +203,59 @@ namespace dy.net.Controllers
return await OpenOrCloseSync(dto);
}
[HttpPost("live-monitor")]
public async Task<IActionResult> UpdateLiveMonitor(
FollowLiveMonitorUpdateDto dto,
CancellationToken cancellationToken)
{
try
{
return ApiResult.Success(await _douyinLiveStatusService.SetMonitorAsync(dto, cancellationToken));
}
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
return ApiResult.Fail(ex.Message);
}
}
[HttpPost("live-status/refresh")]
public async Task<IActionResult> RefreshLiveStatus(
FollowLiveStatusRefreshDto dto,
CancellationToken cancellationToken)
{
try
{
if (dto == null || string.IsNullOrWhiteSpace(dto.Id))
return ApiResult.Fail("博主记录不能为空");
return ApiResult.Success(await _douyinLiveStatusService.RefreshAsync(dto.Id, cancellationToken));
}
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
return ApiResult.Fail(ex.Message);
}
}
[HttpPost("live-status/query")]
public async Task<IActionResult> QueryLiveStatus(FollowLiveStatusQueryDto dto)
{
if (dto?.Ids == null || dto.Ids.Count == 0)
return ApiResult.Success(Array.Empty<FollowLiveStatusDto>());
return ApiResult.Success(await _douyinLiveStatusService.QueryAsync(dto.Ids));
}
[HttpPost("live-email")]
public async Task<IActionResult> UpdateLiveEmail(FollowLiveEmailUpdateDto dto)
{
try
{
return ApiResult.Success(await _douyinLiveStatusService.SetEmailNotificationAsync(dto));
}
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
return ApiResult.Fail(ex.Message);
}
}
/// <summary>
/// 删除关注对象
/// </summary>
@@ -0,0 +1,75 @@
using dy.net.model.dto;
using dy.net.service;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/storage/openlist/directory-repairs")]
[ApiController]
[Authorize]
public sealed class OpenListDirectoryRepairsController : ControllerBase
{
private readonly OpenListDirectoryRepairService _service;
public OpenListDirectoryRepairsController(OpenListDirectoryRepairService service) => _service = service;
[HttpPost("preflight")]
public Task<IActionResult> Preflight(
[FromBody] OpenListDirectoryRepairPreflightRequest request,
CancellationToken cancellationToken) => ExecuteAsync(async () =>
ApiResult.Success(await _service.PreflightAsync(request, cancellationToken)));
[HttpPost]
public Task<IActionResult> Create(
[FromBody] CreateOpenListDirectoryRepairRequest request,
CancellationToken cancellationToken) => ExecuteAsync(async () =>
ApiResult.Success(await _service.CreateAsync(request, cancellationToken)));
[HttpGet("latest")]
public Task<IActionResult> Latest() => ExecuteAsync(async () =>
ApiResult.Success(await _service.GetLatestAsync()));
[HttpGet("{id}")]
public Task<IActionResult> Get(string id) => ExecuteAsync(async () =>
ApiResult.Success(await _service.GetAsync(id)));
[HttpGet("{id}/items")]
public Task<IActionResult> Items(string id, [FromQuery] OpenListDirectoryRepairItemPageRequest request) =>
ExecuteAsync(async () => ApiResult.Success(await _service.GetItemsAsync(id, request)));
[HttpPost("{id}/confirm-cleanup")]
public Task<IActionResult> Confirm(
string id,
[FromBody] ConfirmOpenListDirectoryRepairRequest request) => ExecuteAsync(async () =>
{
await _service.ConfirmCleanupAsync(id, request);
return ApiResult.Success("清理任务已提交");
});
[HttpPost("{id}/cancel")]
public Task<IActionResult> Cancel(string id) => ExecuteActionAsync(() => _service.CancelAsync(id));
[HttpPost("{id}/resume")]
public Task<IActionResult> Resume(string id) => ExecuteActionAsync(() => _service.ResumeAsync(id));
[HttpPost("{id}/retry-failed")]
public Task<IActionResult> RetryFailed(string id) => ExecuteActionAsync(() => _service.RetryFailedAsync(id));
private Task<IActionResult> ExecuteActionAsync(Func<Task> action) => ExecuteAsync(async () =>
{
await action();
return ApiResult.Success("操作已提交");
});
private static async Task<IActionResult> ExecuteAsync(Func<Task<IActionResult>> action)
{
try { return await action(); }
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException
or HttpRequestException or TimeoutException)
{
return ApiResult.Fail(ex.GetBaseException().Message);
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
using dy.net.model.dto;
using dy.net.service;
using dy.net.storage;
using jzc.http.lib;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class StorageController : ControllerBase
{
private readonly DouyinCommonService _commonService;
private readonly OpenListSettingsService _openListSettingsService;
private readonly OpenListMediaStorage _openListStorage;
private readonly DouyinQuartzJobService _quartzJobService;
private readonly DouyinVideoService _videoService;
public StorageController(
DouyinCommonService commonService,
OpenListSettingsService openListSettingsService,
OpenListMediaStorage openListStorage,
DouyinQuartzJobService quartzJobService,
DouyinVideoService videoService)
{
_commonService = commonService;
_openListSettingsService = openListSettingsService;
_openListStorage = openListStorage;
_quartzJobService = quartzJobService;
_videoService = videoService;
}
[HttpGet("inventory")]
public async Task<IActionResult> GetInventory()
{
var type = _commonService.GetConfig()?.StorageType ?? StorageType.Local;
return ApiResult.Success(await _videoService.GetStorageInventoryAsync(type));
}
[HttpGet("config")]
public async Task<IActionResult> GetConfig()
{
var type = _commonService.GetConfig()?.StorageType ?? StorageType.Local;
var inventory = await _videoService.GetStorageInventoryAsync(type);
var saved = await _openListSettingsService.HasSavedSettingsAsync();
var dto = await _openListSettingsService.ToDtoAsync(type);
dto.RequiresCutover = type == StorageType.WebDav || inventory.WebDavRecordCount > 0;
dto.LegacyWebDavRecordCount = inventory.WebDavRecordCount;
dto.SuggestedFromLegacy = !saved;
return ApiResult.Success(dto);
}
[HttpPost("test")]
public async Task<IActionResult> Test(
[FromBody] StorageTestRequest request, CancellationToken cancellationToken)
{
if (request == null) return ApiResult.Fail("测试请求不能为空");
if (request.StorageType != StorageType.OpenList)
return ApiResult.Fail("新远端存储只支持 OpenList 原生 API。");
var candidate = await _openListSettingsService.BuildCandidateAsync(
new OpenListTestRequest
{
Endpoint = request.Endpoint,
BasePath = request.BasePath,
LocalStagingPath = request.LocalStagingPath,
SourcePath = request.SourcePath,
UserName = request.UserName,
Password = request.Password
});
var result = await _openListStorage.ProbeAsync(candidate, cancellationToken);
return result.Success
? ApiResult.Success(new { message = result.Message })
: ApiResult.Fail(result.Message);
}
[HttpPost("openlist/directories")]
public async Task<IActionResult> ListOpenListDirectories(
[FromBody] OpenListDirectoryRequest request,
CancellationToken cancellationToken)
{
if (request == null) return ApiResult.Fail("目录请求不能为空");
try
{
var candidate = await _openListSettingsService.BuildCandidateAsync(request);
return ApiResult.Success(await _openListStorage.ListDirectoriesAsync(
candidate, request.Path, cancellationToken));
}
catch (Exception ex) when (ex is InvalidOperationException or HttpRequestException)
{ return ApiResult.Fail(ex.GetBaseException().Message); }
}
[HttpPut("config")]
public async Task<IActionResult> Update(
[FromBody] StorageSettingsDto dto, CancellationToken cancellationToken)
{
if (dto == null) return ApiResult.Fail("配置不能为空");
if (dto.StorageType == StorageType.WebDav)
return ApiResult.Fail("WebDAV 已停止作为新存储目标,请改用 OpenList 原生 API。");
if (dto.StorageType == StorageType.OpenList)
{
var candidate = await _openListSettingsService.BuildCandidateAsync(
new OpenListTestRequest
{
Endpoint = dto.Endpoint,
BasePath = dto.BasePath,
LocalStagingPath = dto.LocalStagingPath,
SourcePath = dto.SourcePath,
UserName = dto.UserName,
Password = dto.Password
});
var result = await _openListStorage.ProbeAsync(candidate, cancellationToken);
if (!result.Success) return ApiResult.Fail("无法启用 OpenList" + result.Message);
await _openListSettingsService.SaveAsync(candidate, true, result.Message);
await _openListSettingsService.InitializeCookiePathsAsync();
var config = _commonService.GetConfig();
if (config == null) return ApiResult.Fail("系统配置不存在");
config.StorageType = dto.StorageType;
if (!await _commonService.UpdateConfig(config))
return ApiResult.Fail("存储模式保存失败");
await _quartzJobService.InitOrReStartAllJobs(config.Cron.ToString());
var response = await _openListSettingsService.ToDtoAsync(config.StorageType);
var inventory = await _videoService.GetStorageInventoryAsync(config.StorageType);
response.RequiresCutover = inventory.WebDavRecordCount > 0;
response.LegacyWebDavRecordCount = inventory.WebDavRecordCount;
return ApiResult.Success(response);
}
if (dto.StorageType == StorageType.Local)
{
var config = _commonService.GetConfig();
if (config == null) return ApiResult.Fail("系统配置不存在");
config.StorageType = StorageType.Local;
if (!await _commonService.UpdateConfig(config))
return ApiResult.Fail("存储模式保存失败");
await _quartzJobService.InitOrReStartAllJobs(config.Cron.ToString());
var response = await _openListSettingsService.ToDtoAsync(config.StorageType);
var inventory = await _videoService.GetStorageInventoryAsync(config.StorageType);
response.RequiresCutover = inventory.WebDavRecordCount > 0;
response.LegacyWebDavRecordCount = inventory.WebDavRecordCount;
return ApiResult.Success(response);
}
return ApiResult.Fail("不支持的存储类型");
}
}
}
@@ -0,0 +1,88 @@
using dy.net.model.dto;
using dy.net.service;
using jzc.http.lib;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/storage/migrations")]
[ApiController]
[Authorize]
public class StorageMigrationsController : ControllerBase
{
private readonly StorageMigrationService _service;
public StorageMigrationsController(StorageMigrationService service) => _service = service;
[HttpPost("preflight")]
public async Task<IActionResult> Preflight([FromBody] StorageMigrationPreflightRequest request, CancellationToken cancellationToken) =>
ApiResult.Success(await _service.PreflightAsync(request?.VerifyCapabilities ?? true, cancellationToken));
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateStorageMigrationRequest request, CancellationToken cancellationToken) =>
await ExecuteAsync(async () => ApiResult.Success(await _service.CreateAsync(request, cancellationToken)));
[HttpGet("latest")]
public async Task<IActionResult> Latest() => ApiResult.Success(await _service.GetLatestAsync());
[HttpGet("failed-records/preview")]
public async Task<IActionResult> FailedRecordsPreview() =>
ApiResult.Success(await _service.PreviewFailedRecordRemovalAsync());
[HttpPost("failed-records/remove")]
public async Task<IActionResult> RemoveFailedRecords([FromBody] RemoveFailedMigrationRecordsRequest request) =>
await ExecuteAsync(async () => ApiResult.Success(await _service.RemoveFailedRecordsAsync(request)));
[HttpPost("history/archive")]
public async Task<IActionResult> ArchiveHistory() =>
await ExecuteAsync(async () => ApiResult.Success(await _service.ArchiveAllSettledAsync()));
[HttpGet("{id}")]
public async Task<IActionResult> Get(string id) =>
await ExecuteAsync(async () => ApiResult.Success(await _service.GetAsync(id)));
[HttpGet("{id}/items")]
public async Task<IActionResult> Items(string id, [FromQuery] StorageMigrationItemPageRequest request) =>
await ExecuteAsync(async () => ApiResult.Success(await _service.GetItemsAsync(id, request)));
[HttpPost("{id}/pause")]
public Task<IActionResult> Pause(string id) => ExecuteActionAsync(() => _service.PauseAsync(id));
[HttpPost("{id}/resume")]
public Task<IActionResult> Resume(string id) => ExecuteActionAsync(() => _service.ResumeAsync(id));
[HttpPost("{id}/cancel")]
public Task<IActionResult> Cancel(string id) => ExecuteActionAsync(() => _service.CancelAsync(id));
[HttpPost("{id}/retry-failed")]
public Task<IActionResult> RetryFailed(string id) => ExecuteActionAsync(() => _service.RetryFailedAsync(id));
[HttpPost("{id}/cleanup")]
public Task<IActionResult> Cleanup(string id, CancellationToken cancellationToken) =>
ExecuteActionAsync(() => _service.CleanupAsync(id, cancellationToken));
[HttpPost("{id}/rollback")]
public Task<IActionResult> Rollback(string id, CancellationToken cancellationToken) =>
ExecuteActionAsync(() => _service.RollbackAsync(id, cancellationToken));
[HttpPost("{id}/archive")]
public Task<IActionResult> Archive(string id) => ExecuteActionAsync(() => _service.ArchiveAsync(id));
private async Task<IActionResult> ExecuteActionAsync(Func<Task> action) =>
await ExecuteAsync(async () =>
{
await action();
return ApiResult.Success("操作已提交");
});
private static async Task<IActionResult> ExecuteAsync(Func<Task<IActionResult>> action)
{
try { return await action(); }
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
return ApiResult.Fail(ex.Message);
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
using dy.net.model.dto;
using dy.net.service;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/tasks")]
[ApiController]
[Authorize]
public class TasksController : ControllerBase
{
private readonly VideoTaskService _tasks;
private readonly DouyinQuartzJobService _quartz;
private readonly DouyinVideoService _videos;
public TasksController(VideoTaskService tasks, DouyinQuartzJobService quartz, DouyinVideoService videos)
{
_tasks = tasks;
_quartz = quartz;
_videos = videos;
}
[HttpPost("sync")]
public async Task<IActionResult> StartSync([FromQuery] VideoTypeEnum? videoType = null) => await ExecuteAsync(async () =>
{
var tasks = await _quartz.TriggerVideoJobsNowAsync(videoType);
return ApiResult.Success(new { taskIds = tasks.Select(x => x.Id).ToList(), count = tasks.Count });
});
[HttpGet]
public async Task<IActionResult> List([FromQuery] VideoTaskPageRequest request) =>
ApiResult.Success(await _tasks.GetTasksAsync(request));
[HttpGet("summary")]
public async Task<IActionResult> Summary() => ApiResult.Success(await _tasks.GetSummaryAsync());
[HttpGet("{type}/{id}")]
public async Task<IActionResult> Detail(VideoTaskType type, string id) =>
await ExecuteAsync(async () => ApiResult.Success(await _tasks.GetTaskAsync(type, id)));
[HttpGet("{type}/{id}/items")]
public async Task<IActionResult> Items(VideoTaskType type, string id, [FromQuery] VideoTaskItemPageRequest request) =>
await ExecuteAsync(async () => ApiResult.Success(await _tasks.GetItemsAsync(type, id, request)));
[HttpPost("{type}/{id}/retry-failed")]
public async Task<IActionResult> RetryFailed(VideoTaskType type, string id) =>
await ExecuteAsync(async () =>
{
await _tasks.RetryFailedAsync(type, id);
return ApiResult.Success("重试任务已提交");
});
[HttpPost("{type}/{id}/items/{itemId}/retry")]
public async Task<IActionResult> RetryItem(VideoTaskType type, string id, string itemId) =>
await ExecuteAsync(async () =>
{
await _tasks.RetryItemAsync(type, id, itemId);
return ApiResult.Success("重试任务已提交");
});
[HttpPost("{type}/{id}/items/{itemId}/retry-cleanup")]
public async Task<IActionResult> RetryCleanup(VideoTaskType type, string id, string itemId) =>
await ExecuteAsync(async () =>
{
if (type != VideoTaskType.Sync)
throw new InvalidOperationException("仅普通同步任务支持重试旧本地文件清理。");
var deleted = await _videos.RetryTaskItemLocalCleanupAsync(id, itemId);
return ApiResult.Success(new
{
deletedCount = deleted,
message = deleted > 0 ? $"已清理 {deleted} 个旧本地文件。" : "旧本地文件已不存在或仍被共享引用,清理状态已确认。"
});
});
[HttpPost("{type}/{id}/actions/{action}")]
public async Task<IActionResult> Action(VideoTaskType type, string id, string action, CancellationToken cancellationToken) =>
await ExecuteAsync(async () =>
{
await _tasks.ExecuteTaskActionAsync(type, id, action, cancellationToken);
return ApiResult.Success("操作已提交");
});
[HttpPost("storage-health/probe")]
public async Task<IActionResult> Probe(CancellationToken cancellationToken)
{
var result = await _tasks.ProbeStorageAsync(cancellationToken);
return result.Success ? ApiResult.Success(new { message = result.Message }) : ApiResult.Fail(result.Message);
}
private static async Task<IActionResult> ExecuteAsync(Func<Task<IActionResult>> action)
{
try { return await action(); }
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
return ApiResult.Fail(ex.Message);
}
}
}
}
+42 -1
View File
@@ -4,6 +4,7 @@ using dy.net.service;
using dy.net.utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using dy.net.storage;
namespace dy.net.Controllers
{
@@ -13,11 +14,13 @@ namespace dy.net.Controllers
{
private readonly DouyinVideoService douyinVideoService;
private readonly DouyinCommonService douyinCommonService;
private readonly MediaStorageRouter _storageRouter;
public VideoController(DouyinVideoService dyCollectVideoService, DouyinCommonService douyinCommonService)
public VideoController(DouyinVideoService dyCollectVideoService, DouyinCommonService douyinCommonService, MediaStorageRouter storageRouter)
{
this.douyinVideoService = dyCollectVideoService;
this.douyinCommonService = douyinCommonService;
_storageRouter = storageRouter;
}
/// <summary>
/// 分页查询收藏视频
@@ -88,6 +91,9 @@ namespace dy.net.Controllers
return ApiResult.Fail("视频信息不能为空");
}
if (video.StorageType.IsRemote())
return await PlayRemoteVideoAsync(video);
// 1. 获取完整物理路径并校验
string videoFullPath = video.VideoSavePath;
if (string.IsNullOrWhiteSpace(videoFullPath))
@@ -153,6 +159,34 @@ namespace dy.net.Controllers
}
}
private async Task<IActionResult> PlayRemoteVideoAsync(DouyinVideo video)
{
if (string.IsNullOrWhiteSpace(video.VideoSavePath)) return ApiResult.Fail("远端视频路径不能为空");
long? from = null;
long? to = null;
var range = Request.Headers.Range.ToString();
if (!string.IsNullOrWhiteSpace(range) && range.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
{
var parts = range[6..].Split('-', 2);
if (long.TryParse(parts[0], out var parsedFrom)) from = parsedFrom;
if (parts.Length > 1 && long.TryParse(parts[1], out var parsedTo)) to = parsedTo;
}
await using var remote = await _storageRouter.Resolve(video.StorageType)
.OpenReadAsync(video.VideoSavePath, from, to, HttpContext.RequestAborted);
Response.StatusCode = remote.StatusCode;
Response.ContentType = remote.ContentType ?? GetContentType(video.VideoSavePath);
Response.Headers.AcceptRanges = "bytes";
if (remote.ContentLength.HasValue) Response.ContentLength = remote.ContentLength.Value;
if (!string.IsNullOrWhiteSpace(remote.ContentRange)) Response.Headers.ContentRange = remote.ContentRange;
await remote.Stream.CopyToAsync(Response.Body, 81920, HttpContext.RequestAborted);
await Response.Body.FlushAsync(HttpContext.RequestAborted);
return new EmptyResult();
}
/// <summary>
/// 处理分片请求(Range),异步安全处理流
/// </summary>
@@ -275,6 +309,7 @@ namespace dy.net.Controllers
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize]
[HttpPost("redown")]
public async Task<IActionResult> ReDownload(ReDownViedoDto dto)
{
@@ -301,6 +336,7 @@ namespace dy.net.Controllers
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize]
[HttpPost("vdelete/batch")]
public async Task<IActionResult> BathRealDelete(ReDownViedoDto dto)
{
@@ -321,6 +357,7 @@ namespace dy.net.Controllers
/// </summary>
/// <param name="vid"></param>
/// <returns></returns>
[Authorize]
[HttpGet("vdelete/{vid}")]
public async Task<IActionResult> DeleteVideo([FromRoute] string vid)
{
@@ -346,6 +383,7 @@ namespace dy.net.Controllers
/// 查询已删除视频列表
/// </summary>
/// <returns></returns>
[Authorize]
[HttpGet("vdelete/get")]
public async Task<IActionResult> GetDeleteVideo()
{
@@ -356,6 +394,7 @@ namespace dy.net.Controllers
/// </summary>
/// <param name="uperUid"></param>
/// <returns></returns>
[Authorize]
[HttpGet("vdelete/byauthor/{uperUid}")]
public async Task<IActionResult> DeleteByAuthor([FromRoute] string uperUid)
{
@@ -396,6 +435,7 @@ namespace dy.net.Controllers
/// 删除无效视频记录
/// </summary>
/// <returns></returns>
[Authorize]
[HttpGet("removeInvalid")]
public async Task<IActionResult> RemoveInvalidVideo()
{
@@ -463,6 +503,7 @@ namespace dy.net.Controllers
}
}
[Authorize]
[HttpGet("/Move")]
public async Task<IActionResult> Move()
{
+31
View File
@@ -0,0 +1,31 @@
using dy.net.model.dto;
using dy.net.service;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/video/exclusions")]
[ApiController]
[Authorize]
public class VideoExclusionsController : ControllerBase
{
private readonly VideoExclusionService _service;
public VideoExclusionsController(VideoExclusionService service) => _service = service;
[HttpGet]
public async Task<IActionResult> List([FromQuery] VideoExclusionPageRequest request) =>
ApiResult.Success(await _service.GetPageAsync(request));
[HttpPost("unexclude")]
public async Task<IActionResult> Unexclude([FromBody] UnexcludeVideosRequest request)
{
try { return ApiResult.Success(await _service.UnexcludeAsync(request)); }
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
return ApiResult.Fail(ex.Message);
}
}
}
}
+32 -18
View File
@@ -1,21 +1,35 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
ARG NODE_IMAGE=node:20-bookworm-slim
ARG DOTNET_SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:6.0
ARG DOTNET_RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:6.0
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
FROM ${NODE_IMAGE} AS frontend-build
WORKDIR /src/app
COPY app/package.json app/package-lock.json ./
RUN npm ci
COPY app/ ./
RUN npm run build
FROM ${DOTNET_SDK_IMAGE} AS publish
WORKDIR /src
COPY dy.net.csproj dy.net.sln ./
COPY lib/ ./lib/
RUN dotnet restore dy.net.csproj --disable-parallel
COPY . ./
COPY --from=frontend-build /src/app/dist/ ./app/dist/
RUN dotnet publish dy.net.csproj -c Release --no-restore \
-p:DebugType=None -p:DebugSymbols=false -o /out /maxcpucount:1
FROM ${DOTNET_RUNTIME_IMAGE}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=publish /out/ ./
ENV ASPNETCORE_URLS=http://0.0.0.0:10101 \
DYSYNC_LISTEN_URL=http://0.0.0.0:10101 \
TZ=Asia/Shanghai
VOLUME ["/data"]
EXPOSE 10101
RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main non-free contrib" > /etc/apt/sources.list && \
echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main" >> /etc/apt/sources.list && \
echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main non-free contrib" >> /etc/apt/sources.list && \
echo "deb http://mirrors.aliyun.com/debian/ bookworm-backports main non-free contrib" >> /etc/apt/sources.list && \
apt-get update && \
apt-get install -y --no-install-recommends ffmpeg && \
rm -rf /var/lib/apt/lists/*
RUN ffmpeg -version
COPY . .
ENV ASPNETCORE_URLS=http://*:10101
ENV TZ=Asia/Shanghai
ENTRYPOINT ["dotnet", "dy.net.dll"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://127.0.0.1:10101/ >/dev/null || exit 1
ENTRYPOINT ["dotnet", "dy.net.dll", "/data"]
Vendored
+222
View File
@@ -0,0 +1,222 @@
pipeline {
agent {
node {
label '构建机1'
customWorkspace '/home/nanxunai/goujian/workspace/douyin-release'
}
}
options {
timestamps()
disableConcurrentBuilds()
skipDefaultCheckout(true)
timeout(time: 180, unit: 'MINUTES')
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '20'))
}
environment {
PATH = '/home/nanxunai/.local/bin:/usr/local/bin:/usr/bin:/bin'
DOTNET = '/home/nanxunai/.local/bin/dotnet'
FNPACK_BIN = '/home/nanxunai/.local/bin/fnpack'
REGISTRY_URL = 'reg.nxsir.cn'
IMAGE_REPO = 'reg.nxsir.cn/douyin'
HARBOR_CREDENTIALS = 'douyin_key'
OPENLIST_CREDENTIALS = 'openlist_key'
NODE_CREDENTIALS = 'bbb939ea-4f01-4b47-aecb-c5ee2a551ef4'
OPENLIST_BASE_URL = 'https://openlist.nxsir.cn'
OPENLIST_REMOTE_DIR = '/yidongpan/构建产物/douyin'
HTTP_PROXY_URL = 'http://192.168.5.200:7890'
NO_PROXY_HOSTS = '127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn,openlist.nxsir.cn,mcr.microsoft.com,docker.m.daocloud.io'
BUILDER_NAME = "douyin-${BUILD_NUMBER}"
DOCKER_CONFIG = "${WORKSPACE}/.ci/docker"
NPM_CONFIG_CACHE = "${WORKSPACE}/.ci/npm-cache"
NUGET_PACKAGES = "${WORKSPACE}/.ci/nuget-packages"
DOTNET_CLI_HOME = "${WORKSPACE}/.ci/dotnet-home"
DYSYNC_BUILD_TMPDIR = "${WORKSPACE}/.ci/fnos-tmp"
DYSYNC_VERIFY_TMPDIR = "${WORKSPACE}/.ci/fnos-verify"
DYSYNC_SMOKE_TMPDIR = "${WORKSPACE}/.ci/fnos-smoke"
}
stages {
stage('Checkout') {
steps {
deleteDir()
checkout scm
}
}
stage('Metadata And Preflight') {
steps {
script {
env.APP_VERSION = sh(script: "sed -n 's/^version[[:space:]]*=[[:space:]]*//p' fnos/manifest | head -n 1", returnStdout: true).trim()
env.SHORT_SHA = sh(script: 'git rev-parse --short=8 HEAD', returnStdout: true).trim()
env.FNOS_VERSION = sh(script: '''
set -euo pipefail
base="$APP_VERSION"
major=${base%%.*}; remainder=${base#*.}
minor=${remainder%%.*}; patch=${remainder#*.}
test "$base" = "$major.$minor.$patch"
test "$BUILD_NUMBER" -lt 100000
printf '%s.%s.%s\n' "$major" "$minor" "$((patch * 100000 + BUILD_NUMBER))"
''', returnStdout: true).trim()
env.IMMUTABLE_TAG = "${env.APP_VERSION}-b${env.BUILD_NUMBER}-${env.SHORT_SHA}"
env.IMAGE_REF = "${env.IMAGE_REPO}:${env.IMMUTABLE_TAG}"
env.FPK_BASENAME = "dy.net-${env.FNOS_VERSION}-x86_64.fpk"
env.FPK_PATH = "${env.WORKSPACE}/artifacts/${env.FPK_BASENAME}"
env.MANIFEST_PATH = "${env.WORKSPACE}/artifacts/douyin-${env.IMMUTABLE_TAG}-build-manifest.json"
currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.IMMUTABLE_TAG}"
currentBuild.description = "fnOS ${env.FNOS_VERSION}"
}
sh '''
set -euo pipefail
test "$(uname -m)" = x86_64
for command_name in git sudo docker curl python3 npm node sha256sum tar; do command -v "$command_name" >/dev/null; done
test -x "$DOTNET" && test -x "$FNPACK_BIN"
mkdir -p "$DOCKER_CONFIG" "$NPM_CONFIG_CACHE" "$NUGET_PACKAGES" "$DOTNET_CLI_HOME" \
"$DYSYNC_BUILD_TMPDIR" "$DYSYNC_VERIFY_TMPDIR" "$DYSYNC_SMOKE_TMPDIR" artifacts
chmod 700 "$DOCKER_CONFIG"
available_kb=$(df -Pk "$WORKSPACE" | awk 'NR == 2 { print $4 }')
test "$available_kb" -ge 3145728
'''
}
}
stage('Restore And Test') {
steps {
sh '''
set -euo pipefail
npm ci --prefix app
npm run test:mobile-contract --prefix app
npm run build --prefix app
"$DOTNET" restore tests/dy.net.Tests/dy.net.Tests.csproj --disable-parallel
"$DOTNET" test tests/dy.net.Tests/dy.net.Tests.csproj -c Release --no-restore \
--logger 'console;verbosity=normal' /maxcpucount:1
'''
}
}
stage('Build And Smoke fnOS x64') {
steps {
sh '''
set -euo pipefail
PACKAGE_VERSION="$FNOS_VERSION" FNPACK="$FNPACK_BIN" DOTNET="$DOTNET" \
./scripts/build-fnos-package.sh artifacts
test -s "$FPK_PATH" && test -s "$FPK_PATH.sha256"
./scripts/verify-fnos-package.sh "$FPK_PATH" "$FNOS_VERSION"
./scripts/smoke-fnos-package.sh "$FPK_PATH" "$DYSYNC_SMOKE_TMPDIR"
'''
}
}
stage('Build And Smoke Docker amd64') {
steps {
withCredentials([usernamePassword(credentialsId: "${NODE_CREDENTIALS}", usernameVariable: 'JENKINS_NODE_USERNAME', passwordVariable: 'JENKINS_NODE_PASSWORD')]) {
sh '''
set -euo pipefail
./scripts/ci-docker.sh buildx rm -f "$BUILDER_NAME" >/dev/null 2>&1 || true
./scripts/ci-docker.sh buildx create --name "$BUILDER_NAME" --driver docker-container --driver-opt network=host --use
./scripts/ci-docker.sh buildx inspect "$BUILDER_NAME" --bootstrap
./scripts/ci-docker.sh buildx build --builder "$BUILDER_NAME" --platform linux/amd64 --network host \
--progress=plain --provenance=false --no-cache --load \
--build-arg HTTP_PROXY="$HTTP_PROXY_URL" --build-arg HTTPS_PROXY="$HTTP_PROXY_URL" \
--build-arg NO_PROXY="$NO_PROXY_HOSTS" -t "$IMAGE_REF" .
smoke_name="douyin-smoke-$BUILD_NUMBER"
mkdir -p .ci/docker-smoke
./scripts/ci-docker.sh rm -f "$smoke_name" >/dev/null 2>&1 || true
./scripts/ci-docker.sh run -d --name "$smoke_name" -p 127.0.0.1::10101 \
-v "$WORKSPACE/.ci/docker-smoke:/data" "$IMAGE_REF"
host_port=$(./scripts/ci-docker.sh port "$smoke_name" 10101/tcp | sed -n 's/.*://p' | head -n 1)
test -n "$host_port"
ready=0
for attempt in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:$host_port/" >/dev/null; then ready=1; break; fi
sleep 2
done
test "$ready" = 1 || { ./scripts/ci-docker.sh logs "$smoke_name" || true; exit 1; }
./scripts/ci-docker.sh restart "$smoke_name" >/dev/null
sleep 3
curl -fsS "http://127.0.0.1:$host_port/" >/dev/null
./scripts/ci-docker.sh rm -f "$smoke_name"
./scripts/ci-docker.sh buildx prune --builder "$BUILDER_NAME" --all --force
'''
}
}
}
stage('Push Immutable And Describe') {
steps {
withCredentials([
usernamePassword(credentialsId: "${NODE_CREDENTIALS}", usernameVariable: 'JENKINS_NODE_USERNAME', passwordVariable: 'JENKINS_NODE_PASSWORD'),
usernamePassword(credentialsId: "${HARBOR_CREDENTIALS}", usernameVariable: 'HARBOR_USERNAME', passwordVariable: 'HARBOR_PASSWORD')
]) {
sh '''
set -euo pipefail
auth=$(printf '%s:%s' "$HARBOR_USERNAME" "$HARBOR_PASSWORD" | base64 -w0)
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$REGISTRY_URL" "$auth" >"$DOCKER_CONFIG/config.json"
chmod 600 "$DOCKER_CONFIG/config.json"
./scripts/ci-docker.sh push "$IMAGE_REF"
digest=$(./scripts/ci-docker.sh image inspect --format '{{index .RepoDigests 0}}' "$IMAGE_REF" | sed -n 's/.*@//p')
test -n "$digest"; export digest
fpk_sha=$(awk '{print $1}' "$FPK_PATH.sha256"); export fpk_sha
python3 - <<'PY'
import json, os, pathlib, subprocess
path = pathlib.Path(os.environ['MANIFEST_PATH'])
payload = {
'project': 'douyin', 'buildNumber': os.environ['BUILD_NUMBER'],
'commit': subprocess.check_output(['git','rev-parse','HEAD'], text=True).strip(),
'productVersion': os.environ['APP_VERSION'], 'fnosVersion': os.environ['FNOS_VERSION'],
'fpk': pathlib.Path(os.environ['FPK_PATH']).name, 'fpkSha256': os.environ['fpk_sha'],
'image': os.environ['IMAGE_REF'], 'digest': os.environ['digest'], 'platform': 'linux/amd64'
}
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
PY
sha256sum "$MANIFEST_PATH" >"$MANIFEST_PATH.sha256"
'''
}
}
}
stage('Upload Artifacts') {
steps {
withCredentials([usernamePassword(credentialsId: "${OPENLIST_CREDENTIALS}", usernameVariable: 'OPENLIST_USERNAME', passwordVariable: 'OPENLIST_PASSWORD')]) {
sh '''
set -euo pipefail
for artifact in "$FPK_PATH" "$FPK_PATH.sha256" "$MANIFEST_PATH" "$MANIFEST_PATH.sha256"; do
./scripts/upload-openlist-artifact.sh "$artifact" "$OPENLIST_REMOTE_DIR"
done
'''
}
}
}
stage('Publish Stable Image Tags') {
steps {
withCredentials([usernamePassword(credentialsId: "${NODE_CREDENTIALS}", usernameVariable: 'JENKINS_NODE_USERNAME', passwordVariable: 'JENKINS_NODE_PASSWORD')]) {
sh '''
set -euo pipefail
for tag in "$APP_VERSION" latest; do
./scripts/ci-docker.sh tag "$IMAGE_REF" "$IMAGE_REPO:$tag"
./scripts/ci-docker.sh push "$IMAGE_REPO:$tag"
done
'''
}
}
}
}
post {
success { archiveArtifacts artifacts: 'artifacts/*.fpk,artifacts/*.sha256,artifacts/*.json', fingerprint: true }
always {
withCredentials([usernamePassword(credentialsId: "${NODE_CREDENTIALS}", usernameVariable: 'JENKINS_NODE_USERNAME', passwordVariable: 'JENKINS_NODE_PASSWORD')]) {
sh '''
set +e
./scripts/ci-docker.sh rm -f "douyin-smoke-$BUILD_NUMBER" >/dev/null 2>&1 || true
./scripts/ci-docker.sh buildx rm -f "$BUILDER_NAME" >/dev/null 2>&1 || true
for tag in "$IMMUTABLE_TAG" "$APP_VERSION" latest; do ./scripts/ci-docker.sh rmi "$IMAGE_REPO:$tag" >/dev/null 2>&1 || true; done
: >"$DOCKER_CONFIG/config.json" 2>/dev/null || true
'''
}
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
+31 -2
View File
@@ -4,13 +4,15 @@ using dy.net.utils;
using Serilog;
using System.Reflection;
using System.Text;
using Microsoft.AspNetCore.DataProtection;
using dy.net.storage;
namespace dy.net
{
public class Program
{
// 常量定义
private static readonly string DefaultListenUrl = "http://*:10101";
private static readonly string DefaultListenUrl = Environment.GetEnvironmentVariable("DYSYNC_LISTEN_URL") ?? "http://*:10101";
private const string SpaRootPath = "app/dist";
private const string SpaSourcePath = "app/";
private const string SwaggerDocTitle = "dysync.net WebApi Docs";
@@ -29,6 +31,9 @@ namespace dy.net
ConfigureHost(builder, isDevelopment);
// 配置服务--数据库保存路径从命令行参数传入的第一个参数
string dbPath = args.Length > 0 ? args[0] : "";
// The backup is deliberately completed before DI can construct SqlSugar and run CodeFirst.
// A failed schema update therefore leaves the previous database, WAL/SHM and key ring recoverable.
var upgradeBackup = DatabaseUpgradeBackup.Prepare(dbPath);
ConfigureServices(builder.Services, builder.Configuration, builder.Environment, dbPath);
// 构建应用
var app = builder.Build();
@@ -37,6 +42,7 @@ namespace dy.net
Log.Debug($"dy.sync app is started successfully on {DefaultListenUrl}");
// 初始化应用服务
await InitApplicationServices(app, isDevelopment, dbPath);
upgradeBackup.MarkSchemaReady();
Console.WriteLine();
await app.RunAsync();
@@ -124,6 +130,16 @@ namespace dy.net
//PrintApp();
services.AddSingleton(new Appsettings(config));
// WebDAV 密码使用 Data Protection 加密;密钥与 SQLite 一同持久化,容器重启后仍可解密。
var databaseRoot = string.IsNullOrWhiteSpace(dbPath)
? Path.Combine(Environment.CurrentDirectory, "db")
: Path.Combine(dbPath, "db");
var keyRingPath = Path.Combine(databaseRoot, "keys");
Directory.CreateDirectory(keyRingPath);
services.AddDataProtection()
.SetApplicationName("dysync.net")
.PersistKeysToFileSystem(new DirectoryInfo(keyRingPath));
// 雪花ID生成器
services.AddSnowFlakeId(options => options.WorkId = new Random().Next(1, 100));
@@ -143,6 +159,17 @@ namespace dy.net
services.AddServicesFromNamespace("dy.net.repository")
.AddServicesFromNamespace("dy.net.service");
services.AddScoped<LocalMediaStorage>();
services.AddScoped<WebDavMediaStorage>();
services.AddScoped<OpenListMediaStorage>();
services.AddSingleton<OpenListClient>();
services.AddScoped<MediaStorageRouter>();
services.AddHostedService<OpenListTransferWorker>();
services.AddHostedService<OpenListDirectoryRepairWorker>();
services.AddHostedService<StorageMigrationWorker>();
services.AddHostedService<VideoRedownloadWorker>();
services.AddHostedService<VideoTaskWorker>();
services.AddScoped<FFmpegHelper>();
// SPA静态文件支持
@@ -263,8 +290,10 @@ namespace dy.net
catch (Exception ex)
{
Serilog.Log.Error(ex, "Failed to initialize services on startup");
// Never listen with a partially migrated schema. fnOS can retain/restore the pre-upgrade backup.
throw;
}
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("dy.net.Tests")]
+90 -1
View File
@@ -19,6 +19,9 @@
- [1.1 提取抖音 Cookie](#11-提取抖音-cookie)
- [1.2 提取 `sec_user_id`(个人/指定博主)](#12-提取-sec_user_id个人指定博主)
- [2. 路径映射规则(核心!错配会导致无法访问/数据丢失)](#2-路径映射规则核心错配会导致无法访问数据丢失)
- [2.1 直接同步到 WebDAVAList/OpenList](#21-直接同步到-webdavalistopenlist)
- [2.2 升级后将本地视频迁移到 WebDAV](#22-升级后将本地视频迁移到-webdav)
- [2.3 WebDAV 自动化与真实服务测试](#23-webdav-自动化与真实服务测试)
- [3. 默认账号密码(首次登录用)](#3-默认账号密码首次登录用)
- [4. 运行方式(推荐 Docker Compose](#4-运行方式推荐-docker-compose)
- [镜像版本](#镜像版本)
@@ -80,6 +83,92 @@ Cookie 及 `sec_user_id` 是同步功能的核心,需严格按步骤获取,
---
### 2.1 通过 OpenList 原生 API 同步到远端
`0.2.22` 不再使用 WebDAV 上传新内容。应用先把完整文件写入本地共享中转目录,再调用 OpenList `/api/fs/copy` 在服务端复制,并通过暂存目录、移动和长度校验原子提升到最终路径。OpenList 返回外部云盘签名地址时不会向该域名转发 OpenList Token,避免移动云 Range 校验返回 400,并防止登录凭据跨主机发送。
`0.2.23` 将“检测存储”固定显示在任务中心顶部;存储正常时可随时主动检测,熔断后会自动变为“重新检测存储”。移动端按钮独占一行,不再依赖警告框的操作区域。
`0.2.24` 修复 OpenList 新视频目录尚未创建时被误判为存储故障的问题。应用会继续保留已有目录的服务端真实大小写,并把缺失的尾部路径交给安全传输流程创建。抖音作品列表和媒体连接超时会进行两次带退避的有限重试;切换到 OpenList 后遗留的旧 WebDAV 同步任务会在启动恢复阶段安全终结并隐藏,不删除视频记录或媒体文件。普通同步成功切换存储但旧本地文件清理失败时,任务条目会显示“重试清理旧文件”,再次验证远端主媒体后才执行清理。
`0.2.25` 补齐 OpenList 对象检查的强制刷新分支:远端尚未创建的新视频目录会稳定返回“不存在”并进入下载,不再在 `ExistsAsync` 的第二次检查中误报 `object not found`。新建目录后会进行总计最多约 7.5 秒的有限刷新确认,以兼容远端云盘的可见性延迟;若服务端确实自动改名,仍会停止写入以避免生成重复目录。
当底层云盘目录大小写不敏感时,应用会根据 OpenList 目录列表解析真实名称。例如逻辑路径 `/collect/Kk` 会稳定复用现有 `/collect/KK`,不会再触发 `Kk_日期_时间` 自动改名。任务中心的“修复异常目录”会严格筛选时间后缀候选,逐个检查并等待人工确认;删除前还会复检,只删除空目录。
1. 在 fnOS 或宿主机创建应用可写的本地中转目录。
2. 在 OpenList 中添加一个本地存储驱动,让“源挂载目录”指向同一个物理目录。应用与 OpenList 看到的路径名称可以不同,但内容必须完全对应。
3. 进入“系统配置 → 媒体存储”,选择 **OpenList 原生 API**。地址填写站点根地址,例如 `http://192.168.1.2:5244`,不要填写 `/dav`
4. 填写本地中转目录、OpenList 源挂载目录和目标基础目录,然后执行“测试完整复制链路”。测试会验证登录、本地文件可见、服务端复制、Range 读取和删除。
5. 测试通过后启用 OpenList,再进入“抖音授权”配置收藏、喜欢、关注、合集和短剧路径。这些字段都是目标基础目录之后的相对路径;留空保存时会按账号生成默认值。
注意事项:
- OpenList 账号需要源挂载目录的读取权限,以及目标基础目录的读取、写入、移动、重命名和删除权限。
- 新写入不会调用 `/api/fs/put` 或 WebDAV。服务端复制完成并确认最终文件长度后,本地中转副本才会清理;失败任务会持久化并按退避规则恢复。
- 切换存储模式只影响之后的新同步内容,不会自动移动或删除历史文件。历史 WebDAV 驱动仅用于旧记录继续播放、删除和回滚。
- OpenList 密码使用 Data Protection 加密。密钥位于数据库目录的 `keys` 子目录,因此 Docker 部署必须持续映射并备份 `/app/db`
- 图文和动态视频仍会先在本地完成 FFmpeg 合成,再进入同一 OpenList 中转与服务端复制流程。
### 2.2 升级后将旧视频接管到 OpenList
`0.2.25` 支持从官方 `0.2.x` 原地升级,保留数据库、Cookie、配置、Data Protection 密钥及历史媒体路径。系统配置会分别显示本地、历史 WebDAV、OpenList 和“不在当前存储”的记录数量;旧 WebDAV 迁移任务只保留审计信息,可直接归档隐藏。
> 升级 → 配置并测试 OpenList → 启用 OpenList → 迁移预检 → 手动创建任务 → 后台迁移 → 选择回滚或清理旧文件
1. 安装 `0.2.25 x86_64` FPK 覆盖升级。升级不会自动迁移视频,也不会覆盖 fnOS 数据共享目录;启动前会备份 SQLite、WAL/SHM 和 Data Protection 密钥,并只保留最近三份升级备份。
2. 按上一节配置共享中转目录和 OpenList,完成完整复制链路测试后再启用。此后新同步内容直接写入 OpenList,旧记录仍按原存储类型工作。
3. 打开“系统配置 → 存储迁移”执行预检。页面会统计本地记录、历史 WebDAV、可直接接管、需复制或回源、缺失文件、总字节数、无效 Cookie、未配置路径和目标冲突;存在冲突时不会创建任务。
4. 同一 OpenList 实例中可见且长度匹配的历史 WebDAV 文件会直接改标接管,不会重复复制。其余记录优先读取安全范围内的本地文件;本地主媒体缺失时才尝试记录 URL 和抖音全量列表回源。
5. 选择并发数 `13`(默认 `1`)并手动创建任务。升级、保存配置和启用 OpenList 都不会自动启动迁移。配置地址、源挂载、基础目录或账号变化时,任务会暂停并要求重新预检。
6. 任务支持暂停、恢复、取消和失败项重试;应用重启后会恢复中断项。单条记录只有在远端最终文件通过长度校验且数据库事务成功后才会改为 OpenList,失败项仍指向原存储。
7. 任务结束后可以整批回滚或清理成功项。清理前会再次检查数据库与远端文件;本地来源只删除不再被引用的旧文件,直接接管的 WebDAV 文件不会被误删。清理开始后不再允许回滚。
8. 已结束且无需继续清理或回滚的迁移历史可在系统配置或任务中心隐藏;归档只影响显示,不删除视频、文件或审计条目。
建议在抽样播放确认无误后再清理旧文件,并始终保留 `/app/db` 与原媒体目录的外部备份。
### 2.3 媒体来源 403、429 与重新授权
任务中心会把抖音/CDN 来源错误与 OpenList 存储错误分开显示。任务条目只记录安全的来源域名、HTTP 状态和重试时间,不保存或输出带签名的完整媒体 URL、响应正文与 Cookie。
- 下载会优先尝试所选清晰度,再去重轮换其他清晰度地址;403、404 和 410 会立即切换候选地址。
- 同一抖音授权连续 3 个作品的全部候选地址均返回 403 时,只暂停该授权 15 分钟,其他授权继续同步。冷却结束后后台只用最早的等待条目探测一次。
- 探测成功会自动恢复该授权的其余等待条目;探测仍返回 403 或请求直接返回 401 时,任务中心和“抖音授权”页会提示重新授权。
- 保存新的 Cookie 会清除来源锁并安排一次探测。429 会遵循服务端 `Retry-After` 进入冷却,但不会直接判定 Cookie 失效。
- 404/410 只标记当前作品失败,通常表示作品下架、私密或签名地址已失效,不会暂停整个授权,也不会触发存储熔断。
如果任务中心显示“需要重新授权”,请进入“抖音授权”保留原入口并更新 Cookie;无需修改 OpenList 配置,也不要删除已有视频记录或文件。
### 2.4 关注博主直播监测与邮箱通知
- 在“关注列表”中为每个博主独立开启“直播监测”。开启后会立即检查一次,之后后台每 5 分钟检查;单个博主也可手动刷新,30 秒内重复点击不会再次请求抖音。
- 页面每分钟读取一次本地状态,不会因此额外请求抖音。直播中可直接进入直播间;检查失败时保留上次成功状态并显示失败或过期提示。
- 同一授权账号下的博主串行检查并加入随机间隔;遇到 403、429 或验证响应时按账号进入 15–360 分钟递增冷却,避免持续请求扩大风控影响。
- 在“系统配置 → 邮箱通知”配置 SMTP 地址、端口、安全方式、账号、授权码、发件人和收件人。支持无加密、STARTTLS 和 SSL/TLS,建议优先使用邮箱服务商提供的授权码并先发送测试邮件。
- SMTP 密码使用 Data Protection 加密保存,不会通过读取配置接口返回。密码留空保存表示继续使用原密码;可填写多个收件人,使用逗号、分号或换行分隔。
- 全局邮箱启用并配置完整后,可在每个博主卡片上独立开启“开播邮件”。同一 `web_rid` 或直播房间只通知一次,邮件失败不会覆盖已获取的直播状态,15 分钟后才会重试;检测到下播后才会为下一场直播重新准备通知。
### 2.5 OpenList 自动化与真实服务测试
本地自动化测试覆盖 OpenList Token 缓存与 401 刷新、Unicode 路径、服务端复制、Range 读取、重启恢复、数据库升级,以及历史 WebDAV 兼容行为:
```bash
dotnet test tests/dy.net.Tests/dy.net.Tests.csproj
```
AList 和 OpenList 的历史 WebDAV 兼容实测默认跳过;需要验证旧记录读取与删除时,可分别设置以下环境变量:
| AList | OpenList | 说明 |
|---|---|---|
| `DYSYNC_TEST_ALIST_ENDPOINT` | `DYSYNC_TEST_OPENLIST_ENDPOINT` | WebDAV 地址,例如 `https://host/dav` |
| `DYSYNC_TEST_ALIST_BASE_PATH` | `DYSYNC_TEST_OPENLIST_BASE_PATH` | 专用测试目录,必须是包含 `dysync-test` 的非根路径 |
| `DYSYNC_TEST_ALIST_USERNAME` | `DYSYNC_TEST_OPENLIST_USERNAME` | 测试账号用户名 |
| `DYSYNC_TEST_ALIST_PASSWORD` | `DYSYNC_TEST_OPENLIST_PASSWORD` | 测试账号密码 |
| `DYSYNC_TEST_ALIST_ALLOW_INVALID_CERTIFICATE` | `DYSYNC_TEST_OPENLIST_ALLOW_INVALID_CERTIFICATE` | 可选,仅可信内网自签证书设置为 `true` |
历史兼容测试只会在指定基础目录下创建随机子目录,验证完成后自动删除;不会读取网站中保存的生产配置,也不会输出密码。OpenList 原生写入还依赖应用与 OpenList 共享同一物理中转目录,因此请直接使用“系统配置 → 媒体存储 → 测试完整复制链路”完成部署环境实测。
---
## 3. 默认账号密码(首次登录用)
首次访问后台管理页面时,使用以下默认账号密码:
- **用户名**`douyin`
@@ -301,4 +390,4 @@ services:
## 4. 声明的修改与生效
- 本免责声明的最终解释权归项目开发者所有,开发者保留随时修改本声明的权利。
- 修改后的声明将在项目仓库中更新,建议使用者定期查阅。
- 一旦使用本项目的代码、功能或相关资源,即视为您已充分理解并同意本免责声明的全部条款。如不同意,请立即停止使用。
- 一旦使用本项目的代码、功能或相关资源,即视为您已充分理解并同意本免责声明的全部条款。如不同意,请立即停止使用。
+2 -2
View File
@@ -35,7 +35,7 @@
}
/* 2. Vue 挂载后:#stepin-app 非空(包含 Vue 组件),覆盖为普通块级元素,解除约束 */
#stepin-app:not(:has(.breath-ring)) {
#stepin-app.app-mounted {
/* 核心:取消 flex 布局,改为普通块级元素 */
display: block !important;
/* 覆盖原有 flex 相关属性,解除居中约束 */
@@ -146,4 +146,4 @@
</script>
</body>
</html>
</html>
+2 -1
View File
@@ -1,12 +1,13 @@
{
"name": "stepin-template",
"private": true,
"version": "1.2.0-preview",
"version": "0.2.25",
"type": "module",
"scripts": {
"dev": "vite",
"api": "node service/index.js",
"build": "vue-tsc --noEmit && vite build",
"test:mobile-contract": "node --test tests/mobile-contract.test.mjs",
"preview": "vite preview",
"deploy": "yarn build && gh-pages -d dist -b pages -r https://gitee.com/stepui/stepin-template.git",
"deploy:github": "yarn build --mode github && gh-pages -d dist -b master -r git@github.com:stepui/stepui.github.io.git",
+189 -3
View File
@@ -16,11 +16,13 @@
</stepin-view>
</ThemeProvider>
<my-personal ref="personalRef" />
<MobileTopBar v-if="showMobileNav" @profile="openPersonalSettings" />
<MobileBottomNav v-if="showMobileNav" @profile="openPersonalSettings" />
</template>
<script lang="ts" setup>
import { reactive, ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { reactive, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAccountStore, useMenuStore, useSettingStore, storeToRefs, useApiStore } from '@/store';
import avatar from '@/assets/avatar.png';
import { PageFooter, HeaderActions } from '@/components/layout';
@@ -29,15 +31,34 @@ import { LoginModal } from '@/pages/login';
import { MyPersonal } from '@/pages/personal';
import { configTheme, themeList } from '@/theme';
import { ThemeProvider } from 'stepin';
import MobileBottomNav from '@/components/layout/MobileBottomNav.vue';
import MobileTopBar from '@/components/layout/MobileTopBar.vue';
import { useMobileViewport } from '@/utils/useMobileViewport';
// logout,profile
const { logout } = useAccountStore();
const showPersonalDrawer = ref<boolean>(false);
const personalRef = ref(null);
const personalRef = ref<any>(null);
const emailRef = ref(null);
const open = ref(false);
const showSetting = ref(false);
const router = useRouter();
const route = useRoute();
const { isMobileViewport } = useMobileViewport();
const showMobileNav = computed(() =>
isMobileViewport.value && !['/login', '/init', '/mobile'].includes(route.path)
);
const openPersonalSettings = () => personalRef.value?.show(true);
watch(showMobileNav, (visible) => {
document.body.classList.toggle('has-mobile-bottom-nav', visible);
document.body.classList.toggle('mobile-app-shell', visible);
}, { immediate: true });
watch(() => route.fullPath, () => {
open.value = false;
showSetting.value = false;
});
// useMenuStore().getMenuList();
@@ -88,6 +109,11 @@ onMounted(() => {
}
});
});
onBeforeUnmount(() => {
document.body.classList.remove('has-mobile-bottom-nav');
document.body.classList.remove('mobile-app-shell');
});
</script>
<style lang="less">
@@ -132,6 +158,166 @@ body {
height: 100vh;
overflow-y: hidden;
}
@media (max-width: 768px) {
html,
body,
#stepin-app,
.stepin-view {
width: 100%;
height: var(--app-viewport-height, 100dvh) !important;
min-height: 0;
overflow: hidden !important;
}
body.mobile-app-shell {
--mobile-surface: #f6f7fb;
--mobile-card: #fff;
--mobile-border: #e9eaf2;
--mobile-text: #172033;
--mobile-subtext: #667085;
--mobile-primary: #722ed1;
background: var(--mobile-surface);
}
body.mobile-app-shell .stepin-layout {
grid-template-columns: minmax(0, 1fr) !important;
grid-template-rows: minmax(0, 1fr) !important;
}
body.mobile-app-shell .stepin-layout-header,
body.mobile-app-shell .stepin-layout-side,
body.mobile-app-shell .stepin-tabs-view-head,
body.mobile-app-shell .stepin-tabs-view-content-footer {
display: none !important;
}
body.mobile-app-shell .stepin-layout-content {
grid-row: 1 !important;
grid-column: 1 !important;
height: var(--app-viewport-height, 100dvh);
padding-top: calc(54px + env(safe-area-inset-top));
padding-bottom: calc(66px + env(safe-area-inset-bottom));
background: var(--mobile-surface);
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
body.mobile-app-shell .stepin-view-main,
body.mobile-app-shell .stepin-tabs-view,
body.mobile-app-shell .stepin-tabs-view-content {
height: auto !important;
min-height: 100%;
margin: 0 !important;
padding: 0 !important;
}
body.mobile-app-shell .stepin-tabs-view-content-main {
min-height: 100%;
padding: 10px !important;
border-radius: 0 !important;
background: var(--mobile-surface) !important;
}
body.mobile-app-shell button,
body.mobile-app-shell a,
body.mobile-app-shell .ant-btn,
body.mobile-app-shell .ant-switch,
body.mobile-app-shell .ant-checkbox-wrapper,
body.mobile-app-shell .ant-radio-wrapper {
touch-action: manipulation;
}
body.mobile-app-shell .ant-btn:not(.ant-btn-circle) {
min-height: 44px;
border-radius: 10px;
}
body.mobile-app-shell .ant-modal,
body.mobile-app-shell .ant-drawer-content-wrapper {
max-width: 100vw !important;
}
body.mobile-app-shell .ant-drawer-left .ant-drawer-content-wrapper,
body.mobile-app-shell .ant-drawer-right .ant-drawer-content-wrapper {
width: 100vw !important;
}
body.mobile-app-shell .ant-modal {
width: calc(100vw - 20px) !important;
min-width: 0 !important;
margin: 10px auto;
top: max(10px, env(safe-area-inset-top));
}
body.mobile-app-shell .ant-drawer-header {
padding-top: max(16px, env(safe-area-inset-top));
}
body.mobile-app-shell .ant-drawer-body {
padding-bottom: max(20px, env(safe-area-inset-bottom));
}
body.mobile-app-shell .mobile-full-modal .ant-modal {
top: 0 !important;
width: 100vw !important;
max-width: none !important;
height: var(--app-viewport-height, 100dvh);
margin: 0;
padding: 0;
}
body.mobile-app-shell .mobile-full-modal .ant-modal-content {
display: flex;
flex-direction: column;
height: var(--app-viewport-height, 100dvh);
border-radius: 0;
}
body.mobile-app-shell .mobile-full-modal .ant-modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-bottom: max(20px, env(safe-area-inset-bottom));
}
}
html.dark-mode body.mobile-app-shell {
--mobile-surface: #11121d;
--mobile-card: #1a1b2b;
--mobile-border: #303247;
--mobile-text: rgba(255, 255, 255, 0.9);
--mobile-subtext: rgba(255, 255, 255, 0.58);
}
@media (max-width: 768px) {
html.dark-mode body.mobile-app-shell .mobile-task-card,
html.dark-mode body.mobile-app-shell .mobile-record-card,
html.dark-mode body.mobile-app-shell .mobile-cookie-card,
html.dark-mode body.mobile-app-shell .custom-card,
html.dark-mode body.mobile-app-shell .query-container--mobile,
html.dark-mode body.mobile-app-shell .search-tab-container {
border-color: var(--mobile-border) !important;
background: var(--mobile-card) !important;
color: var(--mobile-text) !important;
}
html.dark-mode body.mobile-app-shell .mobile-record-title,
html.dark-mode body.mobile-app-shell .mobile-cookie-header h3,
html.dark-mode body.mobile-app-shell .mobile-cookie-paths dd,
html.dark-mode body.mobile-app-shell .card-name {
color: var(--mobile-text) !important;
}
html.dark-mode body.mobile-app-shell .filter-switch,
html.dark-mode body.mobile-app-shell .mobile-filter-trigger,
html.dark-mode body.mobile-app-shell .mobile-task-filter-trigger {
border-color: var(--mobile-border) !important;
background: #242538 !important;
color: #c4b5fd !important;
}
}
.stepin-img-checkbox {
@apply transition-transform;
&:hover {
@@ -0,0 +1,205 @@
<template>
<a-modal
v-model:visible="visible"
class="failed-record-modal"
title="删除全部迁移失败视频记录"
ok-text="确认删除记录"
cancel-text="取消"
:confirm-loading="removing"
:ok-button-props="{ danger: true, disabled: loading || !preview?.canExecute }"
destroy-on-close
@ok="submit"
>
<a-spin :spinning="loading">
<div class="failed-record-content">
<a-alert
type="warning"
show-icon
message="影响全部历史迁移任务"
description="只删除匹配的视频数据库记录。旧本地文件、已上传的 WebDAV 文件和永久排除设置都不会被删除。"
/>
<div v-if="preview" class="failed-record-stats">
<div><span>失败条目</span><strong>{{ preview.failedItemCount }}</strong></div>
<div><span>涉及视频</span><strong>{{ preview.distinctVideoCount }}</strong></div>
<div class="danger"><span>将删除记录</span><strong>{{ preview.eligibleRecordCount }}</strong></div>
<div><span>记录已不存在</span><strong>{{ preview.alreadyMissingCount }}</strong></div>
<div><span>已变化跳过</span><strong>{{ preview.changedRecordCount }}</strong></div>
<div><span>永久排除跳过</span><strong>{{ preview.permanentlyExcludedCount }}</strong></div>
<div v-if="preview.invalidSnapshotCount"><span>快照损坏跳过</span><strong>{{ preview.invalidSnapshotCount }}</strong></div>
</div>
<a-alert
v-for="error in preview?.errors || []"
:key="`error-${error}`"
type="error"
show-icon
:message="error"
/>
<a-alert
v-for="warning in preview?.warnings || []"
:key="`warning-${warning}`"
type="info"
show-icon
:message="warning"
/>
<p class="failed-record-footnote">
删除后不会立即创建下载任务仍可被当前授权账号和同步范围扫描到的作品会随着系统下一次正常同步重新下载到当前存储
</p>
</div>
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { useApiStore } from '@/store';
interface FailedRecordPreview {
canExecute: boolean;
failedItemCount: number;
distinctVideoCount: number;
eligibleRecordCount: number;
alreadyMissingCount: number;
changedRecordCount: number;
permanentlyExcludedCount: number;
invalidSnapshotCount: number;
confirmationToken?: string;
errors?: string[];
warnings?: string[];
}
const emit = defineEmits<{ (event: 'completed', result: any): void }>();
const api = useApiStore();
const visible = ref(false);
const loading = ref(false);
const removing = ref(false);
const preview = ref<FailedRecordPreview | null>(null);
const requireSuccess = (response: any, fallback: string) => {
if (!response || response.code !== 0) throw new Error(response?.message || fallback);
return response.data;
};
const loadPreview = async () => {
loading.value = true;
try {
preview.value = requireSuccess(await api.StorageMigrationFailedRecordsPreview(), '获取迁移失败记录摘要失败');
} catch (error: any) {
preview.value = null;
message.error(error?.message || '获取迁移失败记录摘要失败');
} finally {
loading.value = false;
}
};
const open = async () => {
visible.value = true;
preview.value = null;
await loadPreview();
};
const submit = async () => {
if (!preview.value?.canExecute || !preview.value.confirmationToken) return;
removing.value = true;
try {
const result = requireSuccess(
await api.RemoveStorageMigrationFailedRecords(preview.value.confirmationToken),
'删除迁移失败视频记录失败',
);
message.success(result?.message || '迁移失败视频记录已处理');
visible.value = false;
emit('completed', result);
} catch (error: any) {
message.error(error?.message || '删除迁移失败视频记录失败');
await loadPreview();
} finally {
removing.value = false;
}
};
defineExpose({ open });
</script>
<style scoped>
.failed-record-content {
display: grid;
gap: 12px;
}
.failed-record-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.failed-record-stats > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
padding: 12px;
border: 1px solid var(--border-color, #e8e8e8);
border-radius: 12px;
background: var(--component-background, #fff);
}
.failed-record-stats span {
color: var(--text-color-secondary, #666);
font-size: 12px;
}
.failed-record-stats strong {
font-size: 21px;
line-height: 1.2;
}
.failed-record-stats .danger strong {
color: #cf1322;
}
.failed-record-footnote {
margin: 0;
color: var(--text-color-secondary, #666);
font-size: 13px;
line-height: 1.65;
}
@media (max-width: 640px) {
.failed-record-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.failed-record-stats > div {
padding: 11px;
}
}
</style>
<style>
.failed-record-modal {
width: min(640px, calc(100vw - 24px)) !important;
max-width: none;
}
@media (max-width: 640px) {
.failed-record-modal .ant-modal-body {
max-height: calc(100dvh - 190px);
overflow-y: auto;
padding: 16px;
overscroll-behavior: contain;
}
.failed-record-modal .ant-modal-footer {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.failed-record-modal .ant-modal-footer .ant-btn {
min-height: 42px;
margin: 0;
}
}
</style>
@@ -0,0 +1,193 @@
<template>
<a-modal
v-model:visible="visible"
:title="mode === 'confirm' ? '确认清理异常目录' : '扫描 OpenList 异常目录'"
:width="680"
:ok-text="mode === 'confirm' ? '清理已确认的空目录' : '创建扫描任务'"
cancel-text="取消"
:confirm-loading="submitting"
:ok-button-props="{ danger: mode === 'confirm', disabled: okDisabled }"
wrap-class-name="directory-repair-modal"
@ok="submit"
>
<a-spin :spinning="loading">
<template v-if="mode === 'preflight'">
<p class="repair-intro">
修复因目录大小写不一致产生的时间后缀目录系统会先逐个检查只把确认为空的目录交给你二次确认
</p>
<a-alert
v-if="preflight?.errors?.length"
type="error"
show-icon
message="当前不能创建扫描任务"
:description="preflight.errors.join('')"
/>
<dl v-if="preflight" class="repair-facts">
<div>
<dt>请求目录</dt>
<dd>{{ preflight.requestedPath || '-' }}</dd>
</div>
<div>
<dt>实际目录</dt>
<dd>{{ preflight.canonicalPath || '尚未识别' }}</dd>
</div>
<div>
<dt>候选目录</dt>
<dd><strong>{{ preflight.candidateCount || 0 }}</strong> </dd>
</div>
</dl>
<a-alert
v-if="preflight?.canStart"
class="repair-notice"
type="info"
show-icon
message="创建任务只会开始检查,不会立即删除"
description="候选名称必须严格匹配时间后缀格式;规范目录、非空目录和不匹配目录都不会删除。扫描完成后还需要再次确认。"
/>
</template>
<template v-else-if="detail?.task">
<p class="repair-intro">
扫描已结束下面只有确认空目录会进入清理每个目录在删除前还会再次检查一次
</p>
<dl class="repair-counts">
<div><dt>候选</dt><dd>{{ detail.task.totalCount }}</dd></div>
<div><dt>确认空目录</dt><dd class="safe-count">{{ detail.task.emptyCount }}</dd></div>
<div><dt>非空保留</dt><dd>{{ detail.task.skippedCount }}</dd></div>
<div><dt>检查失败</dt><dd class="failure-count">{{ detail.task.failedCount }}</dd></div>
<div><dt>已不存在</dt><dd>{{ detail.task.missingCount }}</dd></div>
</dl>
<a-alert
type="warning"
show-icon
message="这是不可逆的目录删除操作"
description="只删除复检后仍为空的时间后缀目录,不删除视频记录、规范目录或任何媒体文件。失败和非空目录会原样保留。"
/>
<label class="confirmation-field">
<span>输入清理空目录以确认</span>
<a-input v-model:value="confirmationText" autocomplete="off" placeholder="清理空目录" />
</label>
</template>
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useApiStore } from '@/store';
const emit = defineEmits<{ (event: 'changed'): void }>();
const api = useApiStore();
const visible = ref(false);
const loading = ref(false);
const submitting = ref(false);
const mode = ref<'preflight' | 'confirm'>('preflight');
const preflight = ref<any>(null);
const detail = ref<any>(null);
const taskId = ref('');
const confirmationText = ref('');
const requireSuccess = (response: any, fallback: string) => {
if (!response || response.code !== 0) throw new Error(response?.message || fallback);
return response.data;
};
const okDisabled = computed(() => loading.value || submitting.value || (mode.value === 'preflight'
? !preflight.value?.canStart
: !detail.value?.canConfirmCleanup || confirmationText.value !== '清理空目录'));
const open = async () => {
visible.value = true;
mode.value = 'preflight';
preflight.value = null;
detail.value = null;
confirmationText.value = '';
loading.value = true;
try {
preflight.value = requireSuccess(
await api.OpenListDirectoryRepairPreflight('/collect/Kk'),
'异常目录预检失败',
);
} catch (error: any) {
message.error(error?.message || '异常目录预检失败');
} finally {
loading.value = false;
}
};
const openConfirm = async (id: string) => {
visible.value = true;
mode.value = 'confirm';
taskId.value = id;
detail.value = null;
confirmationText.value = '';
loading.value = true;
try {
detail.value = requireSuccess(await api.OpenListDirectoryRepairDetail(id), '获取目录扫描结果失败');
} catch (error: any) {
message.error(error?.message || '获取目录扫描结果失败');
} finally {
loading.value = false;
}
};
const submit = async () => {
if (okDisabled.value) return;
submitting.value = true;
try {
if (mode.value === 'preflight') {
const created = requireSuccess(await api.CreateOpenListDirectoryRepair({
logicalPath: preflight.value.requestedPath,
configurationFingerprint: preflight.value.configurationFingerprint,
}), '创建目录扫描任务失败');
message.success(`已创建扫描任务,共 ${created?.task?.totalCount ?? preflight.value.candidateCount} 个候选目录`);
} else {
requireSuccess(await api.ConfirmOpenListDirectoryRepair(
taskId.value,
detail.value.confirmationToken,
), '提交目录清理失败');
message.success('空目录清理任务已提交');
}
visible.value = false;
emit('changed');
} catch (error: any) {
message.error(error?.message || '操作失败');
if (mode.value === 'confirm') await openConfirm(taskId.value);
} finally {
submitting.value = false;
}
};
defineExpose({ open, openConfirm });
</script>
<style scoped lang="less">
.repair-intro { max-width: 68ch; margin: 0 0 18px; color: #475569; line-height: 1.7; }
.repair-facts { display: grid; gap: 0; margin: 16px 0; border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; }
.repair-facts > div { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 12px; padding: 12px 14px; background: #fff; }
.repair-facts > div + div { border-top: 1px solid #eef2f7; }
.repair-facts dt { color: #64748b; }
.repair-facts dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #0f172a; }
.repair-notice { margin-top: 16px; }
.repair-counts { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; margin: 16px 0; }
.repair-counts > div { padding: 11px 8px; border-radius: 12px; background: #f8fafc; text-align: center; }
.repair-counts dt { color: #64748b; font-size: 12px; }
.repair-counts dd { margin: 3px 0 0; color: #0f172a; font-size: 20px; font-weight: 650; }
.repair-counts .safe-count { color: #237804; }
.repair-counts .failure-count { color: #b42318; }
.confirmation-field { display: grid; gap: 7px; margin-top: 18px; color: #334155; font-weight: 600; }
@media (max-width: 600px) {
.repair-facts > div { grid-template-columns: 82px minmax(0, 1fr); padding: 11px 10px; }
.repair-counts { grid-template-columns: repeat(2, 1fr); }
.repair-counts > div:first-child { grid-column: 1 / -1; }
:global(.directory-repair-modal .ant-modal) { width: calc(100% - 20px) !important; max-width: none; margin: 10px auto; top: 0; padding-bottom: 10px; }
:global(.directory-repair-modal .ant-modal-body) { max-height: calc(100vh - 160px); overflow-y: auto; padding: 18px 16px; }
:global(.directory-repair-modal .ant-modal-footer) { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
:global(.directory-repair-modal .ant-modal-footer .ant-btn) { min-height: 44px; margin: 0; }
}
</style>
@@ -0,0 +1,210 @@
<template>
<nav class="mobile-bottom-nav" aria-label="移动端主导航">
<button
v-for="item in primaryItems"
:key="item.path"
type="button"
class="mobile-nav-item"
:class="{ active: route.path === item.path || route.path.startsWith(`${item.path}/`) }"
@click="go(item.path)"
>
<component :is="item.icon" />
<span>{{ item.label }}</span>
</button>
<button type="button" class="mobile-nav-item" :class="{ active: moreActive }" @click="moreVisible = true">
<AppstoreOutlined />
<span>更多</span>
</button>
</nav>
<a-drawer
v-model:visible="moreVisible"
title="更多功能"
placement="bottom"
:height="420"
:z-index="1300"
destroy-on-close
class="mobile-more-drawer"
>
<div class="mobile-more-grid">
<button v-for="item in moreItems" :key="item.path" type="button" @click="go(item.path)">
<component :is="item.icon" />
<span>{{ item.label }}</span>
<RightOutlined />
</button>
<button type="button" @click="openProfile">
<UserOutlined />
<span>个人设置</span>
<RightOutlined />
</button>
<button type="button" class="danger" @click="logoutNow">
<LogoutOutlined />
<span>退出登录</span>
<RightOutlined />
</button>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAccountStore } from '@/store';
import {
AppstoreOutlined,
DashboardOutlined,
HeartOutlined,
LogoutOutlined,
ProfileOutlined,
RightOutlined,
SafetyCertificateOutlined,
SettingOutlined,
SwapOutlined,
UnorderedListOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
const emit = defineEmits<{ (event: 'profile'): void }>();
const route = useRoute();
const router = useRouter();
const moreVisible = ref(false);
const primaryItems = [
{ path: '/dashboard', label: '看板', icon: DashboardOutlined },
{ path: '/workplace', label: '记录', icon: SwapOutlined },
{ path: '/tasks', label: '任务', icon: ProfileOutlined },
{ path: '/follow', label: '关注', icon: HeartOutlined },
];
const moreItems = [
{ path: '/cok', label: '抖音授权', icon: SafetyCertificateOutlined },
{ path: '/set', label: '系统配置与存储', icon: SettingOutlined },
{ path: '/logs', label: '系统日志', icon: UnorderedListOutlined },
];
const moreActive = computed(() => moreItems.some((item) => route.path === item.path || route.path.startsWith(`${item.path}/`)));
watch(() => route.fullPath, () => {
moreVisible.value = false;
});
const go = (path: string) => {
moreVisible.value = false;
if (route.path !== path) router.push(path);
};
const openProfile = () => {
moreVisible.value = false;
emit('profile');
};
const logoutNow = async () => {
moreVisible.value = false;
await useAccountStore().logout();
await router.push('/login');
};
</script>
<style lang="less">
.mobile-bottom-nav {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 900;
display: none;
grid-template-columns: repeat(5, minmax(0, 1fr));
min-height: calc(62px + env(safe-area-inset-bottom));
padding: 5px 8px calc(5px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(15, 23, 42, 0.1);
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 -6px 24px rgba(15, 23, 42, 0.08);
backdrop-filter: blur(14px);
}
.mobile-nav-item {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
gap: 2px;
min-width: 0;
min-height: 52px;
padding: 3px;
border: 0;
background: transparent;
color: #64748b;
font-size: 11px;
}
.mobile-nav-item .anticon {
font-size: 21px;
}
.mobile-nav-item.active {
color: #722ed1;
font-weight: 600;
}
.mobile-more-drawer .ant-drawer-content {
border-radius: 22px 22px 0 0;
}
.mobile-more-drawer .ant-drawer-body {
padding: 4px 16px calc(18px + env(safe-area-inset-bottom));
}
.mobile-more-grid {
display: grid;
gap: 4px;
}
.mobile-more-grid button {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 20px;
align-items: center;
min-height: 52px;
padding: 0 8px;
border: 0;
border-bottom: 1px solid #f1f5f9;
background: transparent;
color: #334155;
text-align: left;
}
.mobile-more-grid button > .anticon:first-child {
color: #722ed1;
font-size: 18px;
}
.mobile-more-grid button > .anticon:last-child {
color: #94a3b8;
font-size: 12px;
}
.mobile-more-grid button.danger,
.mobile-more-grid button.danger > .anticon:first-child {
color: #ff4d4f;
}
@media (max-width: 768px) {
.mobile-bottom-nav {
display: grid;
}
body.has-mobile-bottom-nav .stepin-tabs-view-content-main,
body.has-mobile-bottom-nav .stepin-view-main {
padding-bottom: calc(76px + env(safe-area-inset-bottom)) !important;
}
}
html.dark-mode .mobile-bottom-nav {
border-top-color: #303247;
background: rgba(22, 22, 39, 0.96);
}
html.dark-mode .mobile-more-grid button {
border-bottom-color: #303247;
color: rgba(255, 255, 255, 0.86);
}
</style>
@@ -0,0 +1,68 @@
<template>
<header class="mobile-top-bar">
<div class="mobile-top-bar__brand">
<img src="/logo.png" alt="" aria-hidden="true" />
<div>
<span>抖小云</span>
<strong>{{ title }}</strong>
</div>
</div>
<button type="button" class="mobile-top-bar__profile" aria-label="打开个人设置" @click="$emit('profile')">
<UserOutlined />
</button>
</header>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { UserOutlined } from '@ant-design/icons-vue';
defineEmits<{ (event: 'profile'): void }>();
const route = useRoute();
const title = computed(() => String(route.meta.mobileTitle || route.name || '抖小云'));
</script>
<style lang="less">
.mobile-top-bar {
position: fixed;
z-index: 900;
top: 0;
right: 0;
left: 0;
display: none;
align-items: center;
justify-content: space-between;
min-height: calc(54px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 14px 0;
border-bottom: 1px solid rgba(99, 102, 241, 0.1);
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 5px 18px rgba(15, 23, 42, 0.05);
backdrop-filter: blur(16px);
}
.mobile-top-bar__brand { display: flex; align-items: center; min-width: 0; gap: 10px; }
.mobile-top-bar__brand img { width: 34px; height: 34px; border-radius: 10px; }
.mobile-top-bar__brand div { display: grid; min-width: 0; }
.mobile-top-bar__brand span { color: #8b5cf6; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; }
.mobile-top-bar__brand strong { overflow: hidden; color: #172033; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
.mobile-top-bar__profile {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: 0;
border-radius: 14px;
background: #f4f1ff;
color: #722ed1;
font-size: 20px;
touch-action: manipulation;
}
.mobile-top-bar__profile:active { background: #e9e1ff; transform: scale(0.96); }
@media (max-width: 768px) { .mobile-top-bar { display: flex; } }
html.dark-mode .mobile-top-bar { border-bottom-color: rgba(255, 255, 255, 0.08); background: rgba(22, 22, 39, 0.95); }
html.dark-mode .mobile-top-bar__brand strong { color: rgba(255, 255, 255, 0.9); }
html.dark-mode .mobile-top-bar__profile { background: #2d2545; color: #c4b5fd; }
</style>
+1
View File
@@ -24,3 +24,4 @@ app.config.errorHandler = function (err) {
console.error('未捕获的异常,', err);
};
app.mount('#stepin-app');
document.getElementById('stepin-app')?.classList.add('app-mounted');
+322 -64
View File
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { getBase64 } from '@/utils/file';
import { FormInstance } from 'ant-design-vue';
import { reactive, ref, onMounted, UnwrapRef, watch, nextTick } from 'vue';
import { computed, reactive, ref, onMounted, onBeforeUnmount, UnwrapRef, watch, nextTick } from 'vue';
import dayjs from 'dayjs';
import { Dayjs } from 'dayjs';
import {
@@ -18,20 +18,22 @@ import { useApiStore } from '@/store';
import { message } from 'ant-design-vue';
import { StarOutlined, StarFilled, StarTwoTone } from '@ant-design/icons-vue';
const columns = ref([]);
columns.value = [
{
title: 'Cookie名称',
dataIndex: 'userName',
width: 180,
},
{ title: 'Cookie状态', dataIndex: 'statusMsg' },
{ title: '收藏路径', dataIndex: 'savePath' },
{ title: '喜欢路径', dataIndex: 'favSavePath' },
{ title: '博主路径', dataIndex: 'upSavePath' },
const storageType = ref(0);
const isRemoteStorage = computed(() => storageType.value === 1 || storageType.value === 2);
const columns = computed(() => [
{ title: 'Cookie名称', dataIndex: 'userName', width: 180 },
{ title: 'Cookie状态', dataIndex: 'statusMsg', width: 120 },
{ title: '收藏路径', dataIndex: isRemoteStorage.value ? 'webDavCollectPath' : 'savePath', width: 240 },
{ title: '喜欢路径', dataIndex: isRemoteStorage.value ? 'webDavFavoritePath' : 'favSavePath', width: 240 },
{ title: '博主路径', dataIndex: isRemoteStorage.value ? 'webDavFollowPath' : 'upSavePath', width: 240 },
{ title: '状态', dataIndex: 'status', width: 180 },
{ title: '操作', dataIndex: 'edit', width: 200 },
];
]);
const isMobile = ref(window.innerWidth <= 768);
const updateViewport = () => (isMobile.value = window.innerWidth <= 768);
const drawerWidth = computed(() => (isMobile.value ? '100%' : '800px'));
interface UpSecUserIdItem {
uper?: string;
@@ -47,6 +49,8 @@ type DataItem = {
favSavePath?: string;
secUserId?: string;
status?: number;
statusMsg?: string;
statusCode?: number;
_isNew?: boolean;
upSecUserIdsJson?: UpSecUserIdItem[];
upSecUserIds?: string;
@@ -59,6 +63,16 @@ type DataItem = {
downCollect?: boolean;
downFavorite?: boolean;
downFollowd?: boolean;
webDavCollectPath?: string;
webDavFavoritePath?: string;
webDavFollowPath?: string;
webDavMixPath?: string;
webDavSeriesPath?: string;
sourceCooldownUntil?: string;
sourceRequiresAuthorization?: boolean;
sourceProbePending?: boolean;
lastSourceStatusCode?: number;
lastSourceError?: string;
};
const loading = ref(false);
@@ -125,6 +139,11 @@ const newCookie = (cookie?: DataItem) => {
cookie.downCollect = false;
cookie.downFavorite = false;
cookie.downFollowd = false;
cookie.webDavCollectPath = undefined;
cookie.webDavFavoritePath = undefined;
cookie.webDavFollowPath = undefined;
cookie.webDavMixPath = undefined;
cookie.webDavSeriesPath = undefined;
return cookie;
};
@@ -258,7 +277,7 @@ const switchSyncStatus = (record: DataItem) => {
},
});
};
const StatusDict = {
const StatusDict: Record<number, string> = {
0: '同步已停止',
1: '同步已开启',
};
@@ -277,9 +296,25 @@ const removeRow = (index: number) => {
}
};
const rowCount = 10;
onMounted(() => {
const handlePageChange = (page: number, pageSize: number) => {
pagination.value.current = page;
pagination.value.defaultPageSize = pageSize;
GetRecords();
};
onMounted(async () => {
window.addEventListener('resize', updateViewport);
try {
const res = await useApiStore().StorageConfig();
if (res.code === 0) storageType.value = Number(res.data.storageType ?? 0);
} catch {
message.warning('读取存储模式失败,路径表单暂按本地存储显示');
}
GetRecords();
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport);
});
const showDrawer = ref(false);
@@ -475,8 +510,8 @@ const switchdownCollect = (e: any) => {
</script>
<template>
<a-modal :title="form._isNew ? '新增' : '编辑'" v-model:visible="showModal" @ok="submit" @cancel="cancel" width="100%" wrap-class-name="full-modal">
<a-form ref="formModel" :model="form" :labelCol="{ span: 3 }" :wrapperCol="{ span: 20 }">
<a-modal :title="form._isNew ? '新增授权' : '编辑授权'" v-model:visible="showModal" @ok="submit" @cancel="cancel" width="100%" wrap-class-name="full-modal">
<a-form ref="formModel" :model="form" :labelCol="isMobile ? { span: 24 } : { span: 3 }" :wrapperCol="isMobile ? { span: 24 } : { span: 20 }">
<a-form-item label="Cookie名称" required name="userName">
<a-input v-model:value="form.userName" />
</a-form-item>
@@ -497,99 +532,105 @@ const switchdownCollect = (e: any) => {
</a-form-item>
<a-form-item label="下载收藏视频" name="downCollect">
<div style="display: flex; align-items: center; gap: 12px;">
<div class="sync-option-row">
<div class="form-item-div">
<a-switch v-model:checked="form.downCollect" @change="switchdownCollect" :checked-value="true" :un-checked-value="false" size="default" />
<a-form-item-rest v-if="form.downCollect">
<a-form-item name="savePath" noStyle>
<a-input v-model:value="form.savePath" placeholder='请输入容器路径' class="form-item-div-input" />
<a-form-item :name="isRemoteStorage ? 'webDavCollectPath' : 'savePath'" noStyle>
<a-input v-if="isRemoteStorage" v-model:value="form.webDavCollectPath" placeholder="OpenList 相对路径,留空自动生成" class="form-item-div-input" />
<a-input v-else v-model:value="form.savePath" placeholder="请输入容器路径" class="form-item-div-input" />
</a-form-item>
</a-form-item-rest>
</div>
<a-alert message="开启后自动下载默认收藏夹视频,记得填写映射路径(容器内部路径)" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
<a-alert :message="isRemoteStorage ? '开启后写入目标基础目录下的这个 OpenList 相对路径;留空保存时会自动生成。' : '开启后自动下载默认收藏夹视频,记得填写映射路径(容器内部路径)。'" type="info" size="small" class="path-alert" />
</div>
</a-form-item>
<a-form-item v-if="form.savePath && form.savePath.length>0" label="自定义收藏夹" name="useCollectFolder">
<div style="display: flex; align-items: center; gap: 12px;">
<a-form-item v-if="isRemoteStorage || (form.savePath && form.savePath.length>0)" label="自定义收藏夹" name="useCollectFolder">
<div class="sync-option-row">
<div class="form-item-div">
<a-switch v-model:checked="form.useCollectFolder" :checked-value="true" :un-checked-value="false" size="default" />
<a-form-item-rest v-if="form.useCollectFolder">
<a-input v-model:value="form.savePath" :disabled="form.useCollectFolder&&form.downCollect" placeholder="" class="form-item-div-input" />
<a-input v-if="isRemoteStorage" v-model:value="form.webDavCollectPath" :disabled="form.useCollectFolder&&form.downCollect" placeholder="与收藏路径一致" class="form-item-div-input" />
<a-input v-else v-model:value="form.savePath" :disabled="form.useCollectFolder&&form.downCollect" placeholder="" class="form-item-div-input" />
<a-button @click="openCollectFolderSetModal" shape="circle" type="dashed" style="margin-left:5px;" v-if="form.useCollectFolder">
<star-outlined />
</a-button>
</a-form-item-rest>
</div>
<a-alert message="开启后自动下载自定义分类后的收藏夹,开启后不在下载默认收藏夹视频,存储路径与默认收藏夹存储路径一致" :type="form.useCollectFolder?'error':'info'" size="small" style="flex: 1; margin-bottom: 0;" />
<a-alert message="开启后按收藏夹分类同步,存储路径与默认收藏路径一致" :type="form.useCollectFolder?'error':'info'" size="small" class="path-alert" />
</div>
</a-form-item>
<a-form-item label="下载喜欢视频" name="downFavorite">
<div style="display: flex; align-items: center; gap: 12px;">
<div class="sync-option-row">
<div class="form-item-div">
<a-switch v-model:checked="form.downFavorite" :checked-value="true" :un-checked-value="false" size="default" />
<a-form-item-rest v-if="form.downFavorite">
<a-form-item name="favSavePath" noStyle>
<a-input v-model:value="form.favSavePath" placeholder='请输入容器路径' class="form-item-div-input" />
<a-form-item :name="isRemoteStorage ? 'webDavFavoritePath' : 'favSavePath'" noStyle>
<a-input v-if="isRemoteStorage" v-model:value="form.webDavFavoritePath" placeholder="OpenList 相对路径,留空自动生成" class="form-item-div-input" />
<a-input v-else v-model:value="form.favSavePath" placeholder="请输入容器路径" class="form-item-div-input" />
</a-form-item>
<a-button shape="circle" @click="()=>{message.success('别点了,这只是为了好看的😄')}" type="dashed" style="margin-left:5px;">
<like-outlined />
</a-button>
</a-form-item-rest>
</div>
<a-alert message="开启后自动下载喜欢(点赞)的视频,记得填写映射路径(容器内部路径)" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
<a-alert :message="isRemoteStorage ? '喜欢的视频会写入目标基础目录下的这个 OpenList 相对路径。' : '开启后自动下载喜欢(点赞)的视频,记得填写映射路径(容器内部路径)。'" type="info" size="small" class="path-alert" />
</div>
</a-form-item>
<a-form-item label="下载关注视频" name="downFollowd">
<div style="display: flex; align-items: center; gap: 12px;">
<div class="sync-option-row">
<div class="form-item-div">
<a-switch v-model:checked="form.downFollowd" :checked-value="true" :un-checked-value="false" size="default" />
<a-form-item-rest v-if="form.downFollowd">
<a-form-item name="upSavePath" noStyle>
<a-input v-model:value="form.upSavePath" placeholder='请输入容器路径' class="form-item-div-input" />
<a-form-item :name="isRemoteStorage ? 'webDavFollowPath' : 'upSavePath'" noStyle>
<a-input v-if="isRemoteStorage" v-model:value="form.webDavFollowPath" placeholder="OpenList 相对路径,留空自动生成" class="form-item-div-input" />
<a-input v-else v-model:value="form.upSavePath" placeholder="请输入容器路径" class="form-item-div-input" />
</a-form-item>
<a-button shape="circle" @click="()=>{message.success('别点了,这只是为了好看的😄')}" type="dashed" style="margin-left:5px;">
<heart-outlined />
</a-button>
</a-form-item-rest>
</div>
<a-alert message="开启后自动下载关注的博主视频,记得填写映射路径(容器内部路径)" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
<a-alert :message="isRemoteStorage ? '关注博主的视频会写入目标基础目录下的这个 OpenList 相对路径。' : '开启后自动下载关注的博主视频,记得填写映射路径(容器内部路径)。'" type="info" size="small" class="path-alert" />
</div>
</a-form-item>
<a-form-item label="下载合集视频" name="downMix">
<div style="display: flex; align-items: center; gap: 12px;">
<div class="sync-option-row">
<div class="form-item-div">
<a-switch v-model:checked="form.downMix" :checked-value="true" :un-checked-value="false" size="default" />
<a-form-item-rest v-if="form.downMix">
<a-form-item name="mixPath" noStyle>
<a-input v-model:value="form.mixPath" class="form-item-div-input" placeholder='默认使用收藏夹路径' />
<a-form-item :name="isRemoteStorage ? 'webDavMixPath' : 'mixPath'" noStyle>
<a-input v-if="isRemoteStorage" v-model:value="form.webDavMixPath" class="form-item-div-input" placeholder="OpenList 相对路径,留空自动生成" />
<a-input v-else v-model:value="form.mixPath" class="form-item-div-input" placeholder="默认使用收藏夹路径" />
</a-form-item>
<a-button @click="openMixDownSetModal" shape="circle" type="dashed" style="margin-left:5px;">
<gift-outlined />
</a-button>
</a-form-item-rest>
</div>
<a-alert message="开启后自动下载收藏的合集视频(还需要开启合集同步开关,不填目录径默认存储到收藏目录,设置后记得docker里面加映射,注意:付费视频下载后无法播放" :type="form.downMix?'error':'info'" size="small" style="flex: 1; margin-bottom: 0;" />
<a-alert :message="isRemoteStorage ? '收藏的合集会同步到该 OpenList 相对路径;付费视频下载后可能无法播放。' : '开启后自动下载收藏的合集视频;不填路径默认使用收藏目录,付费视频下载后可能无法播放。'" :type="form.downMix?'error':'info'" size="small" class="path-alert" />
</div>
</a-form-item>
<a-form-item label="下载短剧视频" name="downSeries">
<div style="display: flex; align-items: center; gap: 12px;">
<div class="sync-option-row">
<div class="form-item-div">
<a-switch v-model:checked="form.downSeries" :checked-value="true" :un-checked-value="false" size="default" />
<a-form-item-rest v-if="form.downSeries">
<a-form-item name="seriesPath" noStyle>
<a-input v-model:value="form.seriesPath" placeholder='默认使用收藏夹路径' class="form-item-div-input" />
<a-form-item :name="isRemoteStorage ? 'webDavSeriesPath' : 'seriesPath'" noStyle>
<a-input v-if="isRemoteStorage" v-model:value="form.webDavSeriesPath" placeholder="OpenList 相对路径,留空自动生成" class="form-item-div-input" />
<a-input v-else v-model:value="form.seriesPath" placeholder="默认使用收藏夹路径" class="form-item-div-input" />
</a-form-item>
<a-button @click="openSeriesDownSetModal" shape="circle" type="dashed" style="margin-left:5px;">
<fire-outlined />
</a-button>
</a-form-item-rest>
</div>
<a-alert message="开启后自动下载收藏的短剧视频(还需要开启短剧同步开关,不填目录径默认存储到收藏目录,设置后记得docker里面加映射,注意:付费视频下载后无法播放" :type="form.downSeries?'error':'info'" size="small" style="flex: 1; margin-bottom: 0;" />
<a-alert :message="isRemoteStorage ? '收藏的短剧会同步到该 OpenList 相对路径;付费视频下载后可能无法播放。' : '开启后自动下载收藏的短剧视频;不填路径默认使用收藏目录,付费视频下载后可能无法播放。'" :type="form.downSeries?'error':'info'" size="small" class="path-alert" />
</div>
</a-form-item>
@@ -602,7 +643,7 @@ const switchdownCollect = (e: any) => {
</a-form>
</a-modal>
<a-drawer :title="getDrawerTypeName()+'配置'" v-model:visible="showDrawer" placement="right" width="800px" :z-index="10010" :mask-z-index="10009" @close="closeDrawer" class="common-drawer">
<a-drawer :title="getDrawerTypeName()+'配置'" v-model:visible="showDrawer" placement="right" :width="drawerWidth" :z-index="10010" :mask-z-index="10009" @close="closeDrawer" class="common-drawer">
<template #extra>
<a-button type="primary" :loading="drawerPagination.loading" @click="saveDrawerData" class="drawer-save-btn">
<template #icon>
@@ -654,26 +695,29 @@ const switchdownCollect = (e: any) => {
</div>
</a-drawer>
<a-table v-bind="$attrs" :columns="columns" :dataSource="dataSource" :pagination="false">
<template #title>
<div class="flex justify-end pr-4">
<a-button type="primary" @click="GetRecords()" :loading="formLoading" class="mr-2">
<template #icon>
<SearchOutlined />
</template>
查询
</a-button>
<div class="cookie-toolbar">
<a-button @click="GetRecords()" :loading="loading">
<template #icon><SearchOutlined /></template>
刷新
</a-button>
<a-button type="primary" @click="addNew" :loading="formLoading">
<template #icon><PlusOutlined /></template>
新增授权
</a-button>
</div>
<a-button type="primary" @click="addNew" :loading="formLoading">
<template #icon>
<PlusOutlined />
</template>
新增
</a-button>
</div>
</template>
<a-table v-if="!isMobile" v-bind="$attrs" :columns="columns" :dataSource="dataSource" :pagination="false" :scroll="{ x: isRemoteStorage ? 1400 : 1200 }">
<template #bodyCell="{ column, text, record }">
<template v-if="column.dataIndex === 'status'">
<template v-if="column.dataIndex === 'statusMsg'">
<a-tooltip v-if="record.sourceRequiresAuthorization" :title="record.lastSourceError || '请编辑并保存新的 Cookie'">
<a-tag color="error">需要重新授权</a-tag>
</a-tooltip>
<a-tooltip v-else-if="record.sourceCooldownUntil || record.sourceProbePending" :title="record.lastSourceError || '等待自动探测'">
<a-tag color="warning">{{ record.sourceCooldownUntil ? `冷却至 ${dayjs(record.sourceCooldownUntil).format('HH:mm:ss')}` : '等待来源探测' }}</a-tag>
</a-tooltip>
<a-tag v-else :color="record.statusCode === 0 ? 'success' : 'default'">{{ record.statusMsg || '未知' }}</a-tag>
</template>
<template v-else-if="column.dataIndex === 'status'">
<div style="display: flex; align-items: center; gap: 8px;">
<a-switch v-model:checked="record.status" :checked-value="1" :un-checked-value="0" size="small" :disabled="loading" @change="() => switchSyncStatus(record)" />
<span :style="{
@@ -704,6 +748,54 @@ const switchdownCollect = (e: any) => {
</div>
</template>
</a-table>
<a-spin v-else :spinning="loading">
<a-empty v-if="!loading && dataSource.length === 0" description="暂无抖音授权" />
<div class="mobile-cookie-list">
<article v-for="record in dataSource" :key="record.id" class="mobile-cookie-card">
<div class="mobile-cookie-header">
<div>
<h3>{{ record.userName || '未命名授权' }}</h3>
<span :class="record.status === 1 ? 'status-on' : 'status-off'">{{ record.statusMsg || StatusDict[record.status || 0] }}</span>
<a-tag v-if="record.sourceRequiresAuthorization" color="error">需要重新授权</a-tag>
<a-tag v-else-if="record.sourceCooldownUntil || record.sourceProbePending" color="warning">
{{ record.sourceCooldownUntil ? `冷却至 ${dayjs(record.sourceCooldownUntil).format('HH:mm:ss')}` : '等待来源探测' }}
</a-tag>
</div>
<a-switch v-model:checked="record.status" :checked-value="1" :un-checked-value="0" size="small" :disabled="loading" @change="() => switchSyncStatus(record)" />
</div>
<dl class="mobile-cookie-paths">
<template v-if="isRemoteStorage">
<div><dt>收藏</dt><dd>{{ record.webDavCollectPath || '未设置' }}</dd></div>
<div><dt>喜欢</dt><dd>{{ record.webDavFavoritePath || '未设置' }}</dd></div>
<div><dt>关注</dt><dd>{{ record.webDavFollowPath || '未设置' }}</dd></div>
<div><dt>合集</dt><dd>{{ record.webDavMixPath || '未设置' }}</dd></div>
<div><dt>短剧</dt><dd>{{ record.webDavSeriesPath || '未设置' }}</dd></div>
</template>
<template v-else>
<div><dt>收藏</dt><dd>{{ record.savePath || '未设置' }}</dd></div>
<div><dt>喜欢</dt><dd>{{ record.favSavePath || '未设置' }}</dd></div>
<div><dt>关注</dt><dd>{{ record.upSavePath || '未设置' }}</dd></div>
</template>
</dl>
<div class="mobile-cookie-actions">
<a-button :disabled="showModal || loading" @click="edit(record)"><EditFilled />编辑</a-button>
<a-button :disabled="loading || !record.id" danger @click="record.id && deleted(record.id)"><DeleteOutlined />删除</a-button>
</div>
</article>
</div>
</a-spin>
<a-pagination
v-if="pagination.total > 0"
class="cookie-pagination"
:simple="isMobile"
:current="pagination.current"
:page-size="pagination.defaultPageSize"
:total="pagination.total"
:show-size-changer="false"
@change="handlePageChange"
/>
</template>
<style scoped lang="less">
@@ -711,12 +803,100 @@ const switchdownCollect = (e: any) => {
margin-bottom: 10px;
}
.form-item-div {
width: 300px;
width: 430px;
display: flex;
align-items: center;
}
.form-item-div-input {
width: 180px;
width: 310px;
margin-left: 10px;
}
.sync-option-row {
display: flex;
align-items: center;
gap: 12px;
}
.path-alert {
flex: 1;
margin-bottom: 0;
}
.cookie-toolbar {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 8px 12px 12px;
}
.cookie-pagination {
display: flex;
justify-content: flex-end;
margin: 16px 12px 4px;
}
.mobile-cookie-list {
display: grid;
gap: 12px;
padding: 0 8px;
}
.mobile-cookie-card {
padding: 14px;
border: 1px solid #e8e8e8;
border-radius: 12px;
background: #fff;
box-shadow: 0 3px 14px rgba(15, 23, 42, 0.06);
}
.mobile-cookie-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.mobile-cookie-header h3 {
margin: 0 0 3px;
color: #1f2937;
font-size: 16px;
}
.mobile-cookie-header .status-on {
color: #52c41a;
}
.mobile-cookie-header .status-off {
color: #ff4d4f;
}
.mobile-cookie-paths {
margin: 12px 0;
}
.mobile-cookie-paths > div {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 8px;
padding: 3px 0;
font-size: 12px;
}
.mobile-cookie-paths dt {
color: #8c8c8c;
}
.mobile-cookie-paths dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #334155;
}
.mobile-cookie-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
:deep(.ant-input-textarea-input) {
overflow-y: auto;
@@ -835,6 +1015,68 @@ const switchdownCollect = (e: any) => {
}
@media (max-width: 768px) {
.cookie-toolbar {
position: sticky;
z-index: 20;
top: 0;
padding: 10px;
margin-bottom: 10px;
border: 1px solid var(--mobile-border, #e5e7eb);
border-radius: 16px;
background: var(--mobile-card, #fff);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.05);
}
.cookie-toolbar .ant-btn {
flex: 1;
}
.cookie-pagination {
justify-content: center;
}
.sync-option-row {
align-items: stretch;
flex-direction: column;
gap: 8px;
}
.form-item-div {
width: 100%;
}
.form-item-div-input {
width: 100%;
max-width: none;
}
.mobile-cookie-list { padding: 0; }
.mobile-cookie-card {
border-color: var(--mobile-border, #e8e8e8);
border-radius: 16px;
background: var(--mobile-card, #fff);
}
.mobile-cookie-header h3 { color: var(--mobile-text, #1f2937); }
.mobile-cookie-paths dd { color: var(--mobile-text, #334155); }
:deep(.ant-modal-footer) {
position: sticky;
z-index: 5;
bottom: 0;
padding-bottom: max(10px, env(safe-area-inset-bottom));
background: var(--mobile-card, #fff);
}
:deep(.ant-form-item-label) {
padding-bottom: 4px;
text-align: left;
}
:deep(.ant-form-item-label > label) {
height: auto;
}
:deep(.drawer-card-grid) {
width: calc(50% - 10px) !important;
min-width: 180px;
@@ -901,6 +1143,22 @@ html.dark-mode .drawer-card-container.grid-container .drawer-card-grid .ant-inpu
}
.ant-modal-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
}
@media (max-width: 768px) {
.full-modal {
.ant-modal-header,
.ant-modal-footer {
padding-left: 16px;
padding-right: 16px;
}
.ant-modal-content {
height: 100dvh;
}
}
}
@@ -1015,4 +1273,4 @@ html.dark-mode .drawer-card-container.grid-container .drawer-card-grid .ant-inpu
color: rgba(255, 255, 255, 0.45);
}
}
</style>
</style>
File diff suppressed because it is too large Load Diff
+17 -21
View File
@@ -157,23 +157,9 @@ onMounted(() => {
});
function onLoginSuccess() {
if (isMobileBrowser()) router.push('/mobile');
else router.push('/dashboard');
router.push('/dashboard');
}
const isMobileBrowser = (): boolean => {
if (typeof navigator === 'undefined' || typeof window === 'undefined') {
return false;
}
const userAgent = navigator.userAgent.toLowerCase();
const mobileUA = /android|iphone|ipod|blackberry|windows phone|iemobile|opera mini/i.test(userAgent);
const isTablet = /ipad|tablet|playbook|kindle|android 3\.|android 4\.[0-3]/.test(userAgent);
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const isMobileScreen = window.innerWidth <= 768 && window.innerHeight <= 1024;
return (mobileUA && !isTablet && isTouchDevice) || (isMobileScreen && isTouchDevice);
};
function onLoginFail(reason: string, fields: any) {
console.log('登录失败:', reason, fields);
message.error(reason || '登录失败,请重试', 5);
@@ -182,12 +168,15 @@ function onLoginFail(reason: string, fields: any) {
<style scoped lang="less">
.login {
height: 100vh;
min-height: -webkit-fill-available;
min-height: 100vh;
min-height: 100dvh;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
position: relative;
overflow: hidden;
padding: 20px 0;
overflow-x: hidden;
overflow-y: auto;
padding: max(20px, env(safe-area-inset-top)) 0 max(20px, env(safe-area-inset-bottom));
overscroll-behavior: contain;
&::before {
content: '';
@@ -199,6 +188,7 @@ function onLoginFail(reason: string, fields: any) {
top: 15%;
left: 5%;
filter: blur(60px);
pointer-events: none;
@media (min-width: 768px) {
width: 400px;
height: 400px;
@@ -218,6 +208,7 @@ function onLoginFail(reason: string, fields: any) {
bottom: 5%;
right: 5%;
filter: blur(40px);
pointer-events: none;
@media (min-width: 768px) {
width: 300px;
height: 300px;
@@ -228,10 +219,15 @@ function onLoginFail(reason: string, fields: any) {
}
}
.login > :deep(.login-box) {
position: relative;
z-index: 1;
}
@supports (bottom: env(safe-area-inset-bottom)) {
.login {
padding-bottom: env(safe-area-inset-bottom);
padding-top: env(safe-area-inset-top);
padding-bottom: max(20px, env(safe-area-inset-bottom));
padding-top: max(20px, env(safe-area-inset-top));
}
}
</style>
@@ -320,4 +316,4 @@ function onLoginFail(reason: string, fields: any) {
}
}
}
</style>
</style>
+31 -3
View File
@@ -63,10 +63,11 @@
</div>
<!-- 登录按钮 -->
<a-button htmlType="submit" class="h-[48px] md:h-[52px] w-full rounded-lg transition-all duration-300 hover:bg-primary/90 bg-primary border-primary text-white text-base font-medium shadow-md hover:shadow-lg transform hover:-translate-y-0.5 active:translate-y-0" type="primary" :loading="loading" style="touch-action: manipulation; -webkit-tap-highlight-color: transparent;">
<a-button html-type="submit" class="login-submit h-[48px] md:h-[52px] w-full rounded-lg transition-all duration-300 hover:bg-primary/90 bg-primary border-primary text-white text-base font-medium shadow-md" type="primary" :loading="loading" :disabled="loading" aria-label="登录">
<span v-if="!loading">登录</span>
<span v-else>登录中...</span>
</a-button>
<div class="login-submit-status" aria-live="polite">{{ loading ? '正在登录,请稍候' : '' }}</div>
</a-form>
</div>
</ThemeProvider>
@@ -230,6 +231,8 @@ body {
.login-box {
box-sizing: border-box;
touch-action: manipulation; /* 优化触摸性能 */
position: relative;
z-index: 1;
/* 移动端全屏,PC端保留原max-w-md(默认28rem/448px+ 90vw限制 */
}
@@ -241,11 +244,13 @@ body {
.login-box {
max-width: 100vw !important; /* 移动端全屏 */
min-height: 100vh;
min-height: 100dvh;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
padding: 20px 16px !important;
padding-top: 20vh !important; /* 移动端垂直居中 */
padding-top: clamp(48px, 14vh, 112px) !important;
padding-bottom: max(24px, env(safe-area-inset-bottom)) !important;
}
.third-title {
font-size: 1.25rem !important;
@@ -306,6 +311,29 @@ body {
border-color: #4f46e5 !important;
box-sizing: border-box;
}
.login-submit {
min-height: 48px;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transform: translateZ(0);
}
.login-submit:active {
transform: scale(0.985);
}
.login-submit-status {
min-height: 18px;
padding-top: 4px;
color: #64748b;
font-size: 12px;
text-align: center;
}
@media (hover: none) {
.login-box,
.login-box:hover {
transform: none !important;
}
}
::v-deep(.ant-btn-primary:hover) {
background-color: #4338ca !important;
border-color: #4338ca !important;
@@ -351,4 +379,4 @@ html {
scroll-behavior: smooth;
overflow-x: hidden; /* 防止移动端横向滚动 */
}
</style>
</style>
+45 -13
View File
@@ -164,10 +164,12 @@
<a-input v-model:value="quaryData.followUserName" placeholder="搜索博主名称或抖音号" allow-clear @pressEnter="handleSearch" @search="handleSearch" class="follow-search-input" />
</div>
<div class="follow-actions">
<a-button type="primary" class="follow-btn sync-btn" @click="handleSyncAll" :disabled="isSyncDisabled">
<a-button class="follow-btn sync-btn" @click="handleRefreshFollowList" :loading="isFollowListSyncing" :disabled="isFollowVideoSyncing" title="刷新关注列表" aria-label="刷新关注列表">
<SyncOutlined />
</a-button>
<a-button type="primary" class="follow-btn sync-btn" @click="handleSyncFollowVideos" :loading="isFollowVideoSyncing" :disabled="isFollowListSyncing" title="同步关注视频" aria-label="同步关注视频">
<VideoCameraOutlined />
</a-button>
</div>
</div>
@@ -360,6 +362,7 @@
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted, UnwrapRef, reactive, nextTick, watch } from 'vue';
import { useRouter } from 'vue-router';
import { message, Spin, Empty, Tooltip, Modal, Form, FormInstance } from 'ant-design-vue';
import { useApiStore } from '@/store';
import {
@@ -370,8 +373,11 @@ import {
SaveOutlined,
EditOutlined,
DeleteOutlined,
VideoCameraOutlined,
} from '@ant-design/icons-vue';
const router = useRouter();
// ========== 原有仪表盘类型定义 ==========
interface Author {
name: string;
@@ -468,7 +474,8 @@ const followData = ref<FollowItem[]>([]);
const loading = ref(false);
const noMoreData = ref(false);
const hasMore = ref(true);
const isSyncDisabled = ref(false);
const isFollowListSyncing = ref(false);
const isFollowVideoSyncing = ref(false);
const isAddDisabled = ref(false);
// 搜索参数
@@ -859,16 +866,15 @@ const uploadSyncStatus = (item: FollowItem) => {
item.isSaving = false;
});
};
// 批量同步
const handleSyncAll = () => {
if (isSyncDisabled.value) return;
isSyncDisabled.value = true;
loading.value = true;
// 刷新关注博主列表,不下载视频。
const handleRefreshFollowList = () => {
if (isFollowListSyncing.value) return;
isFollowListSyncing.value = true;
useApiStore()
.StartJobNow()
.SyncFollow()
.then((res) => {
if (res.code === 0) {
message.success('后台开始同步,请注意查收');
message.success(res.data?.message || '关注列表更新任务已启动;这是刷新博主列表,不会下载视频');
} else {
message.error('同步失败:' + (res.message || '未知错误'));
}
@@ -878,8 +884,34 @@ const handleSyncAll = () => {
message.error('同步失败,请重试');
})
.finally(() => {
isSyncDisabled.value = false;
loading.value = false;
isFollowListSyncing.value = false;
});
};
// 只启动关注博主作品下载任务。
const handleSyncFollowVideos = () => {
if (isFollowVideoSyncing.value) return;
isFollowVideoSyncing.value = true;
useApiStore()
.StartTaskSync('dy_follows')
.then((res) => {
if (res.code !== 0) {
message.error('启动关注视频同步失败:' + (res.message || '未知错误'));
return;
}
Modal.success({
title: '关注视频同步已启动',
content: `已创建 ${res.data?.count || 1} 个任务,可在任务中心查看实时进度和失败原因。`,
okText: '查看任务',
onOk: () => router.push('/tasks'),
});
})
.catch((err) => {
console.error('启动关注视频同步失败:', err);
message.error('启动关注视频同步失败,请重试');
})
.finally(() => {
isFollowVideoSyncing.value = false;
});
};
// 文本截断
@@ -2285,4 +2317,4 @@ html.dark-mode :deep(.follow-filter-select .ant-select-item-selected) {
background-color: #4caf50 !important;
color: #fff !important;
}
</style>
</style>
+67 -2
View File
@@ -1,5 +1,6 @@
<template>
<a-form layout="inline" style="margin-top:5px;margin-bottom:5px;align-items: center;">
<div class="logs-page">
<a-form layout="inline" class="logs-toolbar">
<!-- 日期控制 -->
<a-form-item>
@@ -28,6 +29,7 @@
<pre class="card-width-pre">{{ logs }}</pre>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
@@ -149,6 +151,14 @@ body {
box-sizing: border-box;
overflow: hidden;
}
.logs-page {
min-width: 0;
}
.logs-toolbar {
margin: 5px 0;
align-items: center;
}
// pre样式:自适应高度,内容过多时内部滚动
.card-width-pre {
width: 100% !important;
@@ -290,4 +300,59 @@ body {
align-items: center;
width: 100%;
}
</style>
@media (max-width: 768px) {
.logs-page {
display: grid;
gap: 10px;
}
.logs-toolbar {
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto 44px;
gap: 8px;
margin: 0 !important;
padding: 10px !important;
border: 1px solid var(--mobile-border, #e5e7eb);
border-radius: 16px;
background: var(--mobile-card, #fff);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.05);
}
.logs-toolbar :deep(.ant-form-item) {
min-width: 0;
margin: 0 !important;
}
.logs-toolbar :deep(.ant-form-item:last-child) {
margin-left: 0 !important;
}
.date-control-group {
justify-content: space-between;
min-width: 0;
}
.current-date-text { min-width: 72px !important; font-size: 12px !important; }
.container {
margin: 0;
padding: 0;
}
.container :deep(.ant-card) {
border-color: var(--mobile-border, #e5e7eb);
border-radius: 16px;
background: var(--mobile-card, #fff);
}
.card-width-pre {
min-height: 55vh;
max-height: none;
font-size: 12px;
line-height: 1.55;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
}
</style>
File diff suppressed because it is too large Load Diff
+689
View File
@@ -0,0 +1,689 @@
<template>
<div class="task-center-page">
<a-card :bordered="false" class="task-hero">
<div class="task-hero__content">
<div>
<h2>任务中心</h2>
<p>查看同步重新下载恢复迁移和存储维护的真实结果进度按条目阶段和数量展示</p>
</div>
<div class="task-actions">
<a-button :loading="startingSync" @click="startSync"><SyncOutlined />立即同步</a-button>
<a-button
class="storage-probe-action"
:type="summary.storageHealth?.status === 1 ? 'primary' : 'default'"
:danger="summary.storageHealth?.status === 1"
:loading="probingStorage"
@click="probeStorage"
>
<CloudSyncOutlined />
{{ summary.storageHealth?.status === 1 ? '重新检测存储' : '检测存储' }}
</a-button>
<a-button @click="directoryRepairModal?.open()"><ToolOutlined />修复异常目录</a-button>
<a-button class="failed-record-action" danger @click="failedRemovalModal?.open()"><DeleteOutlined />处理迁移失败记录</a-button>
<a-button :loading="loading" @click="refreshAll"><ReloadOutlined />刷新</a-button>
</div>
</div>
<a-alert
v-if="summary.storageHealth?.status === 1"
type="error"
show-icon
class="storage-alert"
message="媒体存储已暂停"
:description="summary.storageHealth.lastError || '连续三次存储失败,新的下载任务已停止写入。'"
/>
<a-alert
v-for="source in blockedSources"
:key="source.cookieId"
:type="source.requiresAuthorization ? 'error' : 'warning'"
show-icon
class="storage-alert"
:message="source.requiresAuthorization ? `${source.cookieName || '抖音账号'}需要重新授权` : `${source.cookieName || '抖音账号'}的媒体来源正在冷却`"
:description="sourceDescription(source)"
>
<template #action>
<router-link to="/cok"><a-button size="small" :danger="source.requiresAuthorization">前往授权</a-button></router-link>
</template>
</a-alert>
</a-card>
<section class="summary-grid" aria-label="任务摘要">
<a-card size="small"><a-statistic title="排队" :value="summary.queued" /></a-card>
<a-card size="small"><a-statistic title="运行中" :value="summary.running" /></a-card>
<a-card size="small"><a-statistic title="等待存储" :value="summary.waitingForStorage" /></a-card>
<a-card size="small"><a-statistic title="等待来源" :value="summary.waitingForSource" /></a-card>
<a-card size="small"><a-statistic title="失败/部分失败" :value="summary.failed" /></a-card>
<a-card size="small"><a-statistic title="已完成" :value="summary.completed" /></a-card>
<a-card size="small" class="storage-health-card">
<div class="storage-health-card__title">存储状态</div>
<a-tag :color="summary.storageHealth?.status === 1 ? 'error' : 'success'">
{{ summary.storageHealth?.status === 1 ? '不可用' : '正常' }}
</a-tag>
<span v-if="summary.storageHealth?.consecutiveFailures" class="storage-health-card__failures">
连续失败 {{ summary.storageHealth.consecutiveFailures }}
</span>
</a-card>
</section>
<a-card :bordered="false" class="task-list-card">
<button v-if="viewportWidth <= 768" type="button" class="mobile-task-filter-trigger" @click="mobileFiltersOpen = !mobileFiltersOpen">
<FilterOutlined />{{ mobileFiltersOpen ? '收起筛选' : '筛选任务' }}
</button>
<div v-show="viewportWidth > 768 || mobileFiltersOpen" class="task-filters">
<a-select v-model:value="filters.type" allow-clear placeholder="全部任务类型" :options="taskTypeOptions" />
<a-select v-model:value="filters.status" allow-clear placeholder="全部状态" :options="taskStatusOptions" />
<a-input-search v-model:value="filters.keyword" allow-clear placeholder="任务名称或错误原因" @search="applyFilters" />
<a-button type="primary" @click="applyFilters">查询</a-button>
</div>
<a-table
class="desktop-task-table"
row-key="id"
:columns="taskColumns"
:data-source="tasks"
:loading="loading"
:pagination="false"
@row-click="openTask"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<button type="button" class="task-title-button" @click="openTask(record)">{{ record.title }}</button>
<div class="task-id">{{ typeText(record.type) }} · {{ formatDate(record.createdAt) }}</div>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'counts'">
<div class="task-counts-inline">
<span class="success">{{ successCountLabel(record) }} {{ record.successCount }}</span>
<span v-if="record.warningCount" class="warning">警告 {{ record.warningCount }}</span>
<span v-if="record.failedCount" class="failure">失败 {{ record.failedCount }}</span>
<span>等待 {{ record.pendingCount }}</span>
<span v-if="record.skippedCount">{{ skippedCountLabel(record) }} {{ record.skippedCount }}</span>
<span v-if="record.removedCount">{{ removedCountLabel(record) }} {{ record.removedCount }}</span>
</div>
</template>
<template v-else-if="column.key === 'operation'">
<a-space @click.stop>
<a-button size="small" @click="openTask(record)">详情</a-button>
<a-button v-if="canRetryTask(record)" size="small" @click="retryFailed(record)">重试失败项</a-button>
</a-space>
</template>
</template>
</a-table>
<div class="mobile-task-list">
<a-spin :spinning="loading">
<a-empty v-if="!loading && tasks.length === 0" description="暂无任务" />
<article v-for="task in tasks" :key="task.id" class="mobile-task-card" @click="openTask(task)">
<header>
<div>
<strong>{{ task.title }}</strong>
<span>{{ typeText(task.type) }} · {{ formatDate(task.createdAt) }}</span>
</div>
<a-tag :color="statusColor(task.status)">{{ statusText(task.status) }}</a-tag>
</header>
<div class="mobile-task-counts">
<span>总数 {{ task.totalCount }}</span><span>{{ successCountLabel(task) }} {{ task.successCount }}</span>
<span :class="{ failure: task.failedCount }">失败 {{ task.failedCount }}</span><span>等待 {{ task.pendingCount }}</span>
<span v-if="task.removedCount">{{ removedCountLabel(task) }} {{ task.removedCount }}</span>
</div>
<div v-if="task.currentFile" class="current-file" :title="task.currentFile">当前{{ task.currentFile }}</div>
<a-button v-if="canRetryTask(task)" size="small" @click.stop="retryFailed(task)">重试失败项</a-button>
</article>
</a-spin>
</div>
<a-pagination
v-if="taskTotal > filters.pageSize"
class="task-pagination"
:current="filters.pageIndex"
:page-size="filters.pageSize"
:total="taskTotal"
show-less-items
@change="changeTaskPage"
/>
</a-card>
<a-drawer
v-model:visible="detailVisible"
class="task-detail-drawer"
placement="right"
:width="drawerWidth"
:title="selectedTask?.title || '任务详情'"
destroy-on-close
>
<template v-if="selectedTask">
<div class="detail-head">
<div>
<a-tag :color="statusColor(selectedTask.status)">{{ statusText(selectedTask.status) }}</a-tag>
<span>{{ typeText(selectedTask.type) }} · {{ formatDate(selectedTask.createdAt) }}</span>
</div>
<a-space wrap>
<a-button v-if="canRetryTask(selectedTask)" size="small" @click="retryFailed(selectedTask)">重试全部失败项</a-button>
<a-button
v-for="action in taskActions(selectedTask)"
:key="action"
size="small"
:danger="action === 'cancel' || action === 'cleanup' || action === 'confirm-cleanup'"
@click="runTaskAction(selectedTask, action)"
>{{ actionText(action) }}</a-button>
</a-space>
</div>
<div class="detail-count-grid">
<div><span>总数</span><strong>{{ selectedTask.totalCount }}</strong></div>
<div><span>{{ successCountLabel(selectedTask) }}</span><strong>{{ selectedTask.successCount }}</strong></div>
<div><span>警告</span><strong>{{ selectedTask.warningCount }}</strong></div>
<div><span>失败</span><strong class="failure">{{ selectedTask.failedCount }}</strong></div>
<div><span>等待</span><strong>{{ selectedTask.pendingCount }}</strong></div>
<div><span>{{ skippedCountLabel(selectedTask) }}</span><strong>{{ selectedTask.skippedCount }}</strong></div>
<div v-if="selectedTask.removedCount"><span>{{ removedCountLabel(selectedTask) }}</span><strong>{{ selectedTask.removedCount }}</strong></div>
</div>
<div v-if="selectedTask.currentFile" class="detail-current" :title="selectedTask.currentFile">当前文件{{ selectedTask.currentFile }}</div>
<a-alert v-if="selectedTask.errorMessage" type="warning" show-icon :message="selectedTask.errorMessage" />
<div class="item-filters">
<a-select v-model:value="itemFilters.stage" allow-clear placeholder="全部阶段" :options="itemStageOptions" @change="resetItemFilters" />
<a-select v-if="selectedTask.type !== 4" v-model:value="itemFilters.errorType" allow-clear placeholder="全部错误" :options="errorTypeOptions" @change="resetItemFilters" />
<a-input-search v-model:value="itemFilters.keyword" allow-clear :placeholder="selectedTask.type === 4 ? '目录名称或错误原因' : '标题、博主或作品 ID'" @search="resetItemFilters" />
<label v-if="selectedTask.type !== 4" class="skipped-switch"><a-switch v-model:checked="itemFilters.includeSkipped" size="small" @change="resetItemFilters" />显示跳过项</label>
</div>
<a-spin :spinning="itemsLoading">
<a-empty v-if="!itemsLoading && taskItems.length === 0" description="没有符合条件的条目" />
<a-list v-else :data-source="taskItems" class="task-item-list">
<template #renderItem="{ item }">
<a-list-item>
<article class="task-item-card">
<header>
<div class="task-item-title">
<a-tag :color="stageColor(item.stage)">{{ stageText(item.stage) }}</a-tag>
<strong :title="item.videoTitle">{{ item.videoTitle || item.awemeId || item.videoId || '未命名条目' }}</strong>
</div>
<a-space class="task-item-actions" wrap>
<a-button v-if="item.canRetry" size="small" @click="retryItem(item)">重试</a-button>
<a-button
v-if="item.canRetryCleanup"
size="small"
:loading="cleanupRetryingId === item.id"
@click="retryCleanup(item)"
>重试清理旧文件</a-button>
<a-button v-if="item.canUnexclude && item.exclusionId" size="small" @click="openUnexclude([item.exclusionId])">取消排除</a-button>
</a-space>
</header>
<div class="task-item-meta">
<span v-if="item.author">博主{{ item.author }}</span>
<span v-if="item.cookieName">账号{{ item.cookieName }}</span>
<span>尝试{{ item.attempts }}</span>
<span v-if="item.awemeId">作品{{ item.awemeId }}</span>
<span v-if="item.sourceHost">来源{{ item.sourceHost }}</span>
<span v-if="item.httpStatusCode">HTTP{{ item.httpStatusCode }}</span>
<span v-if="item.retryAfter">重试{{ formatDate(item.retryAfter) }}</span>
</div>
<div v-if="item.targetPath" class="current-file" :class="{ 'current-file--wrap': selectedTask.type === 4 }" :title="item.targetPath">目标{{ item.targetPath }}</div>
<a-alert v-if="item.errorMessage" type="error" show-icon :message="item.errorMessage" />
<a-alert v-if="item.warningMessage" type="warning" show-icon :message="item.warningMessage" />
<a-alert
v-if="item.cleanupError"
type="warning"
show-icon
message="上次清理未完成"
:description="item.cleanupError"
/>
</article>
</a-list-item>
</template>
</a-list>
</a-spin>
<a-pagination
v-if="itemTotal > itemFilters.pageSize"
class="task-pagination"
:current="itemFilters.pageIndex"
:page-size="itemFilters.pageSize"
:total="itemTotal"
show-less-items
@change="loadItems"
/>
</template>
</a-drawer>
<a-modal
v-model:visible="unexcludeVisible"
title="取消永久排除"
ok-text="确认"
cancel-text="取消"
:confirm-loading="unexcluding"
@ok="submitUnexclude"
>
<a-alert type="info" show-icon message="取消排除不会恢复已经删除的旧文件。" class="unexclude-alert" />
<a-radio-group v-model:value="unexcludeMode" class="unexclude-options">
<a-radio value="only">
<strong>仅取消排除</strong>
<span>视频会在后续正常同步重新发现</span>
</a-radio>
<a-radio value="restore">
<strong>取消排除并创建恢复任务</strong>
<span>立即尝试使用保存的资料和抖音回源下载过旧记录资料不足时仍会保留为已取消排除</span>
</a-radio>
</a-radio-group>
</a-modal>
<FailedMigrationRecordRemovalModal ref="failedRemovalModal" @completed="onFailedRecordsRemoved" />
<OpenListDirectoryRepairModal ref="directoryRepairModal" @changed="onDirectoryRepairChanged" />
</div>
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import dayjs from 'dayjs';
import { CloudSyncOutlined, DeleteOutlined, FilterOutlined, ReloadOutlined, SyncOutlined, ToolOutlined } from '@ant-design/icons-vue';
import { useApiStore } from '@/store';
import FailedMigrationRecordRemovalModal from '@/components/FailedMigrationRecordRemovalModal.vue';
import OpenListDirectoryRepairModal from '@/components/OpenListDirectoryRepairModal.vue';
interface TaskRow {
id: string;
type: number;
status: number;
title: string;
totalCount: number;
pendingCount: number;
runningCount: number;
successCount: number;
warningCount: number;
failedCount: number;
skippedCount: number;
removedCount: number;
currentFile?: string;
errorMessage?: string;
createdAt: string;
updatedAt: string;
availableActions?: string[];
}
interface TaskItem {
id: string;
taskId: string;
videoId?: string;
awemeId?: string;
videoTitle?: string;
author?: string;
cookieName?: string;
targetPath?: string;
stage: number;
attempts: number;
errorMessage?: string;
warningMessage?: string;
sourceHost?: string;
httpStatusCode?: number;
retryAfter?: string;
exclusionId?: string;
canRetry: boolean;
canRetryCleanup: boolean;
cleanupError?: string;
canUnexclude: boolean;
}
const api = useApiStore();
const loading = ref(false);
const itemsLoading = ref(false);
const startingSync = ref(false);
const probingStorage = ref(false);
const cleanupRetryingId = ref('');
const tasks = ref<TaskRow[]>([]);
const taskTotal = ref(0);
const selectedTask = ref<TaskRow | null>(null);
const taskItems = ref<TaskItem[]>([]);
const itemTotal = ref(0);
const detailVisible = ref(false);
const unexcludeVisible = ref(false);
const unexcludeIds = ref<string[]>([]);
const unexcludeMode = ref<'only' | 'restore'>('only');
const unexcluding = ref(false);
const failedRemovalModal = ref<InstanceType<typeof FailedMigrationRecordRemovalModal> | null>(null);
const directoryRepairModal = ref<InstanceType<typeof OpenListDirectoryRepairModal> | null>(null);
const viewportWidth = ref(window.innerWidth);
const mobileFiltersOpen = ref(false);
const drawerWidth = computed(() => viewportWidth.value <= 768 ? '100%' : 780);
let pollTimer: number | undefined;
let pollBusy = false;
const summary = reactive<any>({ queued: 0, running: 0, waitingForStorage: 0, waitingForSource: 0, failed: 0, completed: 0, storageHealth: null, sourceHealth: [] });
const blockedSources = computed(() => (summary.sourceHealth || []).filter((source: any) =>
source.requiresAuthorization || source.cooldownUntil || source.probePending));
const filters = reactive<any>({ type: undefined, status: undefined, keyword: '', pageIndex: 1, pageSize: 15 });
const itemFilters = reactive<any>({ stage: undefined, errorType: undefined, keyword: '', includeSkipped: false, pageIndex: 1, pageSize: 20 });
const taskTypeOptions = [
{ value: 0, label: '同步' }, { value: 1, label: '重新下载' },
{ value: 2, label: '取消排除恢复' }, { value: 3, label: '存储迁移' },
{ value: 4, label: '存储维护' },
];
const taskStatusOptions = [
{ value: 0, label: '排队' }, { value: 1, label: '运行中' }, { value: 2, label: '等待存储' },
{ value: 3, label: '完成' }, { value: 4, label: '部分失败' }, { value: 5, label: '失败' },
{ value: 6, label: '已中断' }, { value: 7, label: '暂停' }, { value: 8, label: '取消' },
{ value: 9, label: '清理中' }, { value: 10, label: '已清理' }, { value: 11, label: '已回滚' },
{ value: 12, label: '等待媒体来源' },
{ value: 13, label: '扫描中' }, { value: 14, label: '等待确认清理' },
];
const itemStageOptions = [
{ value: 0, label: '等待' }, { value: 1, label: '下载/上传' }, { value: 2, label: '校验' },
{ value: 3, label: '提交' }, { value: 4, label: '成功' }, { value: 5, label: '成功但有警告' },
{ value: 6, label: '失败' }, { value: 7, label: '跳过' }, { value: 8, label: '等待存储' },
{ value: 9, label: '已清理' }, { value: 10, label: '已回滚' }, { value: 11, label: '已取消' },
{ value: 12, label: '等待媒体来源' }, { value: 13, label: '记录已删除' },
{ value: 14, label: '检查目录' }, { value: 15, label: '已确认空目录' }, { value: 16, label: '非空,已保留' },
];
const errorTypeOptions = [
{ value: 1, label: '来源不可用' }, { value: 2, label: 'Cookie 失效' }, { value: 3, label: '存储不可用' },
{ value: 4, label: '完整性校验失败' }, { value: 5, label: '数据库提交失败' }, { value: 6, label: '任务中断' },
{ value: 7, label: '配置变化' }, { value: 8, label: '永久排除' }, { value: 9, label: '其他错误' },
{ value: 10, label: '来源返回 403' }, { value: 11, label: '来源请求受限' },
];
const taskColumns = [
{ title: '任务', key: 'title', dataIndex: 'title' },
{ title: '状态', key: 'status', width: 110 },
{ title: '条目统计', key: 'counts', width: 330 },
{ title: '操作', key: 'operation', width: 190 },
];
const typeText = (type: number) => taskTypeOptions.find(x => x.value === type)?.label || '未知任务';
const statusText = (status: number) => taskStatusOptions.find(x => x.value === status)?.label || String(status);
const stageText = (stage: number) => itemStageOptions.find(x => x.value === stage)?.label || String(stage);
const statusColor = (status: number) => status === 3 || status === 10 || status === 11 ? 'success'
: status === 4 || status === 7 || status === 14 ? 'warning' : status === 5 || status === 6 || status === 8 ? 'error'
: status === 2 || status === 12 ? 'orange' : 'processing';
const stageColor = (stage: number) => stage === 4 ? 'success' : stage === 5 ? 'warning'
: stage === 6 ? 'error' : stage === 15 ? 'success' : stage === 7 || stage === 16 || (stage >= 9 && stage <= 13 && stage !== 12) ? 'default' : stage === 8 || stage === 12 ? 'orange' : 'processing';
const formatDate = (value?: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-';
const sourceDescription = (source: any) => {
if (source.requiresAuthorization) return source.lastError || '冷却后的自动探测仍返回 403,请更新 Cookie。保存后会自动安排一次探测。';
const retry = source.cooldownUntil ? `预计 ${formatDate(source.cooldownUntil)} 自动探测。` : '等待自动探测。';
return `${source.lastError || '抖音媒体来源暂时不可用。'} ${retry}`;
};
const successCountLabel = (task: TaskRow) => task.type === 4 ? '安全项' : '成功';
const skippedCountLabel = (task: TaskRow) => task.type === 4 ? '已保留' : '跳过';
const removedCountLabel = (task: TaskRow) => task.type === 4 ? '已删空目录' : '已删记录';
const canRetryTask = (task: TaskRow) => (task.availableActions || []).includes('retry-failed');
const taskActions = (task: TaskRow) => task.type === 3 || task.type === 4
? (task.availableActions || []).filter(x => x !== 'retry-failed') : [];
const actionText = (action: string) => ({ pause: '暂停', resume: '恢复', cancel: '取消', cleanup: '清理旧文件', rollback: '整批回滚', archive: '隐藏历史', 'confirm-cleanup': '确认清理空目录' } as Record<string, string>)[action] || action;
const requireSuccess = (response: any, fallback: string) => {
if (!response || response.code !== 0) throw new Error(response?.message || fallback);
return response.data;
};
const loadSummary = async () => Object.assign(summary, requireSuccess(await api.TaskSummary(), '获取任务摘要失败'));
const loadTasks = async (showLoading = true) => {
if (showLoading) loading.value = true;
try {
const data = requireSuccess(await api.TaskList(filters), '获取任务列表失败');
tasks.value = data.items || [];
taskTotal.value = data.totalCount || 0;
if (selectedTask.value) {
const latest = tasks.value.find(x => x.id === selectedTask.value?.id && x.type === selectedTask.value?.type);
if (latest) selectedTask.value = latest;
}
syncPolling();
} finally { if (showLoading) loading.value = false; }
};
const refreshAll = async () => {
try { await Promise.all([loadSummary(), loadTasks()]); }
catch (error: any) { message.error(error?.message || '刷新失败'); }
};
const applyFilters = () => {
filters.pageIndex = 1;
if (viewportWidth.value <= 768) mobileFiltersOpen.value = false;
loadTasks().catch(showError);
};
const changeTaskPage = (page: number) => { filters.pageIndex = page; loadTasks().catch(showError); };
const openTask = async (task: TaskRow) => {
selectedTask.value = task;
detailVisible.value = true;
Object.assign(itemFilters, { stage: undefined, errorType: undefined, keyword: '', includeSkipped: false, pageIndex: 1 });
try { await Promise.all([loadDetail(), loadItems(1)]); } catch (error: any) { showError(error); }
};
const loadDetail = async () => {
if (!selectedTask.value) return;
selectedTask.value = requireSuccess(await api.TaskDetail(selectedTask.value.type, selectedTask.value.id), '获取任务详情失败');
};
const loadItems = async (page = itemFilters.pageIndex) => {
if (!selectedTask.value) return;
itemFilters.pageIndex = page;
itemsLoading.value = true;
try {
const data = requireSuccess(await api.TaskItems(selectedTask.value.type, selectedTask.value.id, itemFilters), '获取任务条目失败');
taskItems.value = data.items || [];
itemTotal.value = data.totalCount || 0;
} finally { itemsLoading.value = false; }
};
const resetItemFilters = () => loadItems(1).catch(showError);
const startSync = async () => {
startingSync.value = true;
try {
const data = requireSuccess(await api.StartTaskSync(), '启动同步失败');
message.success(`已创建 ${data?.count || 0} 个同步任务`);
await refreshAll();
} catch (error: any) { showError(error); }
finally { startingSync.value = false; }
};
const probeStorage = async () => {
probingStorage.value = true;
try {
const data = requireSuccess(await api.ProbeTaskStorage(), '存储检测失败');
message.success(data?.message || '存储检测通过');
await refreshAll();
} catch (error: any) { showError(error); }
finally { probingStorage.value = false; }
};
const retryFailed = async (task: TaskRow) => {
try {
requireSuccess(await api.RetryTaskFailed(task.type, task.id), '提交重试失败');
message.success('失败项已重新排队');
await refreshAfterAction();
} catch (error: any) { showError(error); }
};
const retryItem = async (item: TaskItem) => {
if (!selectedTask.value) return;
try {
requireSuccess(await api.RetryTaskItem(selectedTask.value.type, selectedTask.value.id, item.id), '提交重试失败');
message.success('条目已重新排队');
await refreshAfterAction();
} catch (error: any) { showError(error); }
};
const retryCleanup = (item: TaskItem) => {
if (!selectedTask.value || cleanupRetryingId.value) return;
const task = selectedTask.value;
const execute = async () => {
cleanupRetryingId.value = item.id;
try {
const data = requireSuccess(await api.RetryTaskItemCleanup(task.type, task.id, item.id), '重试清理失败');
message.success(data?.message || '旧本地文件清理完成');
await refreshAfterAction();
} catch (error: any) { showError(error); }
finally { cleanupRetryingId.value = ''; }
};
Modal.confirm({
title: '确认重试清理旧文件?',
content: '系统会再次验证数据库已指向 OpenList 且远端主媒体有效,只删除不再被本地记录引用的旧文件。不会重新下载视频。',
okText: '验证并清理',
cancelText: '取消',
onOk: execute,
});
};
const runTaskAction = (task: TaskRow, action: string) => {
if (task.type === 4 && action === 'confirm-cleanup') {
directoryRepairModal.value?.openConfirm(task.id);
return;
}
const dangerous = action === 'cleanup' || action === 'rollback' || action === 'cancel' || action === 'archive';
const execute = async () => {
try {
requireSuccess(await api.TaskAction(task.type, task.id, action), '任务操作失败');
message.success(`${actionText(action)}操作已提交`);
if (action === 'archive' && selectedTask.value?.id === task.id) {
detailVisible.value = false;
selectedTask.value = null;
}
await refreshAfterAction();
} catch (error: any) { showError(error); }
};
if (!dangerous) return execute();
const content = action === 'cleanup' ? '仅清理迁移成功项的旧本地文件;清理后不能回滚,失败项会保留。'
: action === 'rollback' ? '将验证旧主媒体、恢复数据库并删除任务独占的远端文件。'
: action === 'archive' ? '仅从任务中心隐藏该迁移历史,不删除视频记录、文件或迁移条目。'
: task.type === 4 ? '取消后已检查和已保留的目录状态不会丢失,可以稍后恢复任务。'
: '已成功的迁移项不会自动回滚。';
Modal.confirm({ title: `确认${actionText(action)}`, content, onOk: execute });
};
const refreshAfterAction = async () => {
await Promise.all([loadSummary(), loadTasks(false)]);
if (selectedTask.value) await Promise.all([loadDetail(), loadItems(itemFilters.pageIndex)]);
};
const onFailedRecordsRemoved = async () => {
await refreshAfterAction();
};
const onDirectoryRepairChanged = async () => {
await refreshAfterAction();
};
const openUnexclude = (ids: string[]) => {
unexcludeIds.value = ids;
unexcludeMode.value = 'only';
unexcludeVisible.value = true;
};
const submitUnexclude = async () => {
unexcluding.value = true;
try {
const data = requireSuccess(await api.UnexcludeVideos(unexcludeIds.value, unexcludeMode.value === 'restore'), '取消排除失败');
message.success(data.message || '已取消永久排除');
unexcludeVisible.value = false;
await refreshAfterAction();
} catch (error: any) { showError(error); }
finally { unexcluding.value = false; }
};
const hasActiveTask = () => tasks.value.some(task => task.status === 0 || task.status === 1 || task.status === 9 || task.status === 12 || task.status === 13);
const syncPolling = () => {
if (hasActiveTask() && pollTimer === undefined) pollTimer = window.setInterval(pollActiveTasks, 3000);
if (!hasActiveTask() && pollTimer !== undefined) { window.clearInterval(pollTimer); pollTimer = undefined; }
};
const pollActiveTasks = async () => {
if (pollBusy || !hasActiveTask()) return;
pollBusy = true;
try { await refreshAfterAction(); } catch { /* foreground refresh reports errors; polling stays quiet */ }
finally { pollBusy = false; }
};
const showError = (error: any) => message.error(error?.message || '操作失败');
const onResize = () => { viewportWidth.value = window.innerWidth; };
onMounted(() => {
window.addEventListener('resize', onResize);
refreshAll();
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
if (pollTimer !== undefined) window.clearInterval(pollTimer);
});
</script>
<style scoped lang="less">
.task-center-page { display: grid; gap: 16px; padding: 16px; }
.task-hero { border-radius: 14px; }
.task-hero__content { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.task-hero h2 { margin: 0 0 6px; font-size: 24px; }
.task-hero p { margin: 0; color: #64748b; }
.task-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.storage-alert { margin-top: 16px; }
.summary-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 12px; }
.summary-grid :deep(.ant-card) { height: 100%; border-radius: 12px; }
.storage-health-card__title { margin-bottom: 8px; color: rgba(0, 0, 0, 0.45); }
.storage-health-card__failures { display: block; margin-top: 8px; color: #cf1322; font-size: 12px; }
.task-list-card { border-radius: 14px; }
.task-filters { display: grid; grid-template-columns: 180px 180px minmax(240px, 1fr) auto; gap: 10px; margin-bottom: 16px; }
.task-title-button { padding: 0; border: 0; background: none; color: #1677ff; cursor: pointer; font-weight: 600; text-align: left; }
.task-id { margin-top: 4px; color: #94a3b8; font-size: 12px; }
.task-counts-inline { display: flex; flex-wrap: wrap; gap: 6px 12px; font-size: 13px; }
.success { color: #389e0d; }.warning { color: #d48806; }.failure { color: #cf1322 !important; }
.mobile-task-list { display: none; }
.task-pagination { margin-top: 18px; text-align: right; }
.detail-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 16px; }
.detail-count-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-bottom: 14px; }
.detail-count-grid > div { display: flex; flex-direction: column; gap: 4px; padding: 10px; border-radius: 10px; background: #f8fafc; text-align: center; }
.detail-count-grid span { color: #64748b; font-size: 12px; }.detail-count-grid strong { font-size: 18px; }
.detail-current,.current-file { overflow: hidden; margin: 8px 0; color: #64748b; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.current-file--wrap { overflow: visible; overflow-wrap: anywhere; text-overflow: clip; white-space: normal; line-height: 1.55; }
.item-filters { display: grid; grid-template-columns: 150px 160px minmax(210px, 1fr) auto; gap: 8px; align-items: center; margin: 20px 0 10px; }
.skipped-switch { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.task-item-list :deep(.ant-list-item) { padding: 8px 0; }
.task-item-card { width: 100%; padding: 12px; border: 1px solid #eef2f7; border-radius: 10px; }
.task-item-card header { display: flex; justify-content: space-between; gap: 12px; }
.task-item-actions { flex: 0 0 auto; }
.task-item-title { display: flex; align-items: center; min-width: 0; gap: 8px; }
.task-item-title strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-item-meta { display: flex; flex-wrap: wrap; gap: 5px 16px; margin: 8px 0; color: #64748b; font-size: 12px; }
.task-item-card :deep(.ant-alert) { margin-top: 8px; }
.unexclude-alert { margin-bottom: 14px; }
.unexclude-options { display: grid; gap: 14px; }
.unexclude-options :deep(.ant-radio-wrapper) { align-items: flex-start; white-space: normal; }
.unexclude-options strong,.unexclude-options span { display: block; }.unexclude-options span { margin-top: 3px; color: #64748b; }
@media (max-width: 1200px) {
.summary-grid { grid-template-columns: repeat(3, 1fr); }
.detail-count-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 768px) {
.task-center-page { padding: 0; gap: 10px; }
.task-hero,
.task-list-card { border-radius: 16px; background: var(--mobile-card, #fff); }
.task-hero :deep(.ant-card-body),
.task-list-card :deep(.ant-card-body) { padding: 14px; }
.task-hero__content { align-items: flex-start; flex-direction: column; }
.task-hero h2 { font-size: 20px; }
.task-hero p { font-size: 12px; line-height: 1.6; }
.task-actions { display: grid; grid-template-columns: 1fr 1fr; width: 100%; }
.task-actions :deep(.ant-btn) { width: 100%; min-height: 44px; }
.storage-probe-action { grid-column: 1 / -1; grid-row: 1; }
.summary-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
.summary-grid :deep(.ant-card-body) { padding: 12px; }
.summary-grid :deep(.ant-statistic-title) { font-size: 11px; }
.summary-grid :deep(.ant-statistic-content) { font-size: 22px; }
.mobile-task-filter-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 44px;
margin-bottom: 10px;
gap: 8px;
border: 1px solid var(--mobile-border, #e5e7eb);
border-radius: 12px;
background: #f7f2ff;
color: #722ed1;
font-weight: 600;
}
.task-filters { grid-template-columns: 1fr 1fr; }
.task-filters :deep(.ant-input-search) { grid-column: 1 / -1; }
.desktop-task-table { display: none; }.mobile-task-list { display: block; }
.mobile-task-card { margin-bottom: 10px; padding: 14px; border: 1px solid var(--mobile-border, #eef2f7); border-radius: 14px; background: var(--mobile-card, #fff); box-shadow: 0 5px 18px rgba(15, 23, 42, 0.05); }
.mobile-task-card header { display: flex; justify-content: space-between; gap: 8px; }
.mobile-task-card header div { display: grid; min-width: 0; gap: 3px; }
.mobile-task-card header strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mobile-task-card header span { color: #94a3b8; font-size: 11px; }
.mobile-task-counts { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin: 12px 0; color: #475569; font-size: 12px; }
.detail-head { align-items: flex-start; flex-direction: column; }
.item-filters { grid-template-columns: 1fr 1fr; }
.item-filters :deep(.ant-input-search),.skipped-switch { grid-column: 1 / -1; }
.task-item-card header { align-items: flex-start; flex-direction: column; }
.task-item-actions { width: 100%; }
.task-item-actions :deep(.ant-btn) { min-height: 44px; }
.task-detail-drawer :deep(.ant-drawer-body) { padding: 12px; }
}
</style>
+615 -37
View File
@@ -1,7 +1,17 @@
<template>
<div>
<div v-if="isMobileView" class="mobile-record-toolbar">
<button type="button" class="mobile-filter-trigger" :class="{ active: mobileFiltersOpen }" @click="mobileFiltersOpen = !mobileFiltersOpen">
<SearchOutlined />
<span>{{ mobileFiltersOpen ? '收起筛选' : '筛选视频' }}</span>
</button>
<button type="button" class="mobile-filter-trigger" @click="handShowDeleteVideos">
<DeleteOutlined />
<span>永久排除</span>
</button>
</div>
<!-- 优化查询区域调整布局时间博主名称标题放一行宽度自适应 -->
<div class="query-container">
<div v-show="!isMobileView || mobileFiltersOpen" class="query-container" :class="{ 'query-container--mobile': isMobileView }">
<a-form layout="inline" :model="quaryData" class="query-form">
<!-- 第一行时间选择器组 + 博主名称 + 标题合并为一行自适应宽度 -->
@@ -16,7 +26,7 @@
</a-form-item>
<a-form-item label="博主" ref="author" name="author" class="form-item form-item-input">
<a-input v-model:value="quaryData.author" class="query-input" placeholder="请输入博主名称" />
<a-input v-model:value="quaryData.author" class="query-input" placeholder="请输入博主名称" @input="handleAuthorInput" />
</a-form-item>
<a-form-item label="标题" ref="title" name="title" class="form-item form-item-input">
<a-input v-model:value="quaryData.title" class="query-input" placeholder="请输入标题" />
@@ -77,31 +87,83 @@
</a-form>
</div>
<div v-if="activeAuthorFilter" class="author-filter-banner">
<div>
<strong>正在查看{{ activeAuthorLabel }}的已同步视频</strong>
<span>{{ quaryData.authorId ? '已按博主 UID 精确筛选' : '旧关注数据缺少 UID,当前按名称筛选' }}</span>
</div>
<a-space>
<a-button size="small" @click="clearAuthorFilter">查看全部</a-button>
<a-button size="small" type="primary" ghost @click="returnToFollows">返回关注列表</a-button>
</a-space>
</div>
<!-- 已删除视频-抽屉 -->
<a-drawer title="已删除视频" size="large" :visible="deleteVideoShow" @close="onDeleteVideoClose">
<template #extra>
</template>
<a-list size="small" bordered :data-source="deleteVideos">
<a-drawer title="永久排除的视频" size="large" :visible="deleteVideoShow" destroy-on-close @close="onDeleteVideoClose">
<a-alert type="info" show-icon message="这里的视频不会被后续同步下载。取消排除不会恢复已经删除的旧文件。" class="deleted-video-alert" />
<div class="deleted-video-toolbar">
<a-input-search v-model:value="exclusionKeyword" allow-clear placeholder="搜索标题、博主或作品 ID" @search="() => getDeleteViedos(1)" />
<a-checkbox
:checked="allExclusionsSelected"
:indeterminate="someExclusionsSelected"
@change="toggleAllExclusions"
>选择本页</a-checkbox>
<a-button :disabled="selectedExclusionIds.length === 0" @click="openUnexclude(selectedExclusionIds)">
批量取消排除{{ selectedExclusionIds.length }}
</a-button>
</div>
<a-spin :spinning="deletedLoading">
<a-empty v-if="!deletedLoading && deleteVideos.length === 0" description="暂无永久排除的视频" />
<a-list v-else size="small" bordered :data-source="deleteVideos">
<template #renderItem="{item, index}">
<a-list-item>
<!-- 新增文本容器用于控制省略号 -->
<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 class="deleted-video-row">
<a-checkbox :checked="selectedExclusionIds.includes(item.id)" @change="(event) => toggleExclusion(item.id, event.target.checked)" />
<div class="delete-video-title-container">
<span class="delete-video-index">{{ (deletedPage - 1) * deletedPageSize + index + 1 }}.</span>
<div class="deleted-video-text">
<span class="delete-video-title" :title="item.videoTitle || '无标题'">{{ item.videoTitle || '无标题' }}</span>
<small>{{ item.author || '未知博主' }} · 作品 {{ item.viedoId || '未知' }} · {{ formatDeletedDate(item.deleteTime) }}</small>
</div>
</div>
<a-button size="small" @click="openUnexclude([item.id])">取消排除</a-button>
</div>
<!-- <a-button type="text" size="small" class="copy-delete-video-btn" @click="(e) => copyVideoPath(item.videoSavePath)">
<CopyOutlined /> 复制
</a-button> -->
</a-list-item>
</template>
</a-list>
</a-list>
</a-spin>
<a-pagination
v-if="deletedTotal > deletedPageSize"
class="deleted-video-pagination"
:current="deletedPage"
:page-size="deletedPageSize"
:total="deletedTotal"
show-less-items
@change="getDeleteViedos"
/>
</a-drawer>
<a-modal
v-model:visible="unexcludeVisible"
title="取消永久排除"
ok-text="确认"
cancel-text="取消"
:confirm-loading="unexcluding"
@ok="submitUnexclude"
>
<a-radio-group v-model:value="unexcludeMode" class="record-unexclude-options">
<a-radio value="only">
<strong>仅取消排除</strong>
<span>等待后续正常同步重新发现视频</span>
</a-radio>
<a-radio value="restore">
<strong>取消排除并创建恢复任务</strong>
<span>立即尝试保存的下载地址和抖音回源资料不足的旧记录仍会成功取消排除</span>
</a-radio>
</a-radio-group>
</a-modal>
<!-- 视频播放弹窗 - 保持原有 -->
<a-modal v-model:visible="isModalOpen" :width="900" :mask-closable="false" :footer="null" @cancel="handleCancel" :body-style="{ padding: '0', overflow: 'hidden', backgroundColor: '#fff' }" :style="{
<a-modal v-model:visible="isModalOpen" wrap-class-name="mobile-full-modal" :width="900" :mask-closable="false" :footer="null" @cancel="handleCancel" :body-style="{ padding: '0', overflow: 'hidden', backgroundColor: '#fff' }" :style="{
borderRadius: '8px',
maxWidth: '85vw',
maxHeight: '80vh',
@@ -152,7 +214,7 @@
</a-modal>
<!-- 表格 - 增加复选框和操作列 -->
<a-table :columns="columns" :data-source="dataSource" bordered :pagination="pagination" @change="handleTableChange" :loading="loading" :row-selection="isBatchMode ? rowSelection : null" row-key="id" :sorter="true">
<a-table v-if="!isMobileView" :columns="columns" :data-source="dataSource" bordered :pagination="pagination" @change="handleTableChange" :loading="loading" :row-selection="isBatchMode ? rowSelection : null" row-key="id" :sorter="true" :locale="{ emptyText: emptyDescription }">
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'videoTitle'">
<a class="video-title-link" :title="record.videoTitle || '无标题'" @click="handleVideoClick(record)" @mouseenter="handleTitleMouseEnter" @mouseleave="handleTitleMouseLeave">
@@ -177,17 +239,62 @@
</template>
</template>
</a-table>
<div v-else class="mobile-record-list">
<a-spin :spinning="loading">
<a-empty v-if="!loading && dataSource.length === 0" :description="emptyDescription" />
<article v-for="record in dataSource" :key="record.id" class="mobile-record-card">
<div class="mobile-record-card__header">
<a-checkbox
v-if="isBatchMode"
:checked="selectedRowKeys.includes(record.id || '')"
:disabled="isSyncing"
@change="(event) => toggleMobileSelection(record.id, event.target.checked)"
/>
<button class="mobile-record-title" type="button" @click="handleVideoClick(record)">
{{ record.videoTitle || '无标题' }}
</button>
</div>
<div class="mobile-record-tags">
<a-tag color="blue">{{ record.viedoTypeStr || '未知类型' }}</a-tag>
<a-tag v-if="record.viedoCate">{{ record.viedoCate }}</a-tag>
<a-tag v-if="record.onlyImgOrOnlyMp3" color="orange">仅图片/音频</a-tag>
</div>
<dl class="mobile-record-meta">
<div><dt>博主</dt><dd>{{ record.author || '未知' }}</dd></div>
<div><dt>发布时间</dt><dd>{{ record.createTimeStr || '未知' }}</dd></div>
<div><dt>同步时间</dt><dd>{{ record.syncTimeStr || '未知' }}</dd></div>
<div><dt>存储路径</dt><dd class="path-value">{{ formatPathSeparator(record.videoSavePath) || '未记录' }}</dd></div>
</dl>
<div class="mobile-record-actions">
<a-button size="small" @click="handleReDownload(record)" :disabled="isSyncing"><SyncOutlined />重新同步</a-button>
<a-button size="small" @click="handleShare(record)" :disabled="!record.id || record.onlyImgOrOnlyMp3"><ShareAltOutlined />分享</a-button>
<a-button size="small" danger @click="handleDelete(record)" :disabled="!record.id"><DeleteOutlined />删除</a-button>
</div>
</article>
<a-pagination
v-if="pagination.total > 0"
class="mobile-record-pagination"
simple
:current="pagination.current"
:page-size="pagination.defaultPageSize"
:total="pagination.total"
@change="handleMobilePageChange"
/>
</a-spin>
</div>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, onMounted, nextTick, watch, computed } from 'vue';
import { reactive, ref, onMounted, onBeforeUnmount, nextTick, watch, computed } 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';
import { message, Modal } from 'ant-design-vue';
import CryptoJS from 'crypto-js';
import { useRoute, useRouter } from 'vue-router';
import {
SearchOutlined,
SyncOutlined,
@@ -197,6 +304,9 @@ import {
DeleteOutlined,
} from '@ant-design/icons-vue';
const route = useRoute();
const router = useRouter();
// 类型定义
type RangeValue = [Dayjs, Dayjs];
interface DataItem {
@@ -212,6 +322,7 @@ interface DataItem {
videoSavePath: string;
createTimeStr?: string; // 发布时间
isMergeVideo?: boolean;
onlyImgOrOnlyMp3?: boolean;
}
// 📌 新增:排序参数类型定义
@@ -242,6 +353,15 @@ dayjs.locale('zh-cn');
// 批量操作相关状态
const isBatchMode = ref(false); // 批量操作开关状态
const selectedRowKeys = ref<string[]>([]); // 选中的行ID集合
const isMobileView = ref(window.innerWidth <= 768);
const updateMobileView = () => (isMobileView.value = window.innerWidth <= 768);
const mobileFiltersOpen = ref(false);
const toggleMobileSelection = (id: string | undefined, checked: boolean) => {
if (!id) return;
selectedRowKeys.value = checked
? Array.from(new Set([...selectedRowKeys.value, id]))
: selectedRowKeys.value.filter((key) => key !== id);
};
// 📌 新增:排序状态管理
const sortParams = ref<SortParam>({
field: 'syncTime', // 默认排序字段(发布时间)
@@ -395,6 +515,9 @@ watch(isBatchMode, (isOpen) => {
const loading = ref(false);
const showImageViedo = ref(true);
const dataSource = ref<DataItem[]>([]); // 直接用 ref 数组存储表格数据,减少响应式嵌套
const routeAuthorName = ref('');
const cookiesReady = ref(false);
let suppressAuthorRouteWatch = false;
// 查询参数
const value1 = ref<RangeValue>();
@@ -420,6 +543,50 @@ const quaryData: UnwrapRef<QuaryParam> = reactive({
sortOrder: 'desc', // 📌 默认降序
cookieId: '',
});
const activeAuthorFilter = computed(() => !!quaryData.authorId || !!routeAuthorName.value);
const activeAuthorLabel = computed(() => routeAuthorName.value || quaryData.author || quaryData.authorId || '该博主');
const emptyDescription = computed(() => activeAuthorFilter.value
? `${activeAuthorLabel.value}」暂无已同步视频`
: '暂无同步记录');
const queryText = (value: unknown) => Array.isArray(value) ? String(value[0] || '') : String(value || '');
const applyAuthorRoute = () => {
const authorId = queryText(route.query.authorId).trim();
const author = queryText(route.query.author).trim();
quaryData.authorId = authorId;
routeAuthorName.value = author;
// 有 UID 时只把名称用于界面提示,避免博主改名后名称条件误伤精确查询。
quaryData.author = authorId ? '' : author;
pagination.value.current = 1;
};
const removeAuthorQuery = async () => {
const query = { ...route.query };
delete query.authorId;
delete query.author;
await router.replace({ path: route.path, query });
};
const handleAuthorInput = () => {
if (!quaryData.authorId && !routeAuthorName.value) return;
quaryData.authorId = '';
routeAuthorName.value = '';
suppressAuthorRouteWatch = true;
void removeAuthorQuery().finally(() => { suppressAuthorRouteWatch = false; });
};
const clearAuthorFilter = async () => {
quaryData.author = '';
quaryData.authorId = '';
routeAuthorName.value = '';
pagination.value.current = 1;
suppressAuthorRouteWatch = true;
try { await removeAuthorQuery(); }
finally { suppressAuthorRouteWatch = false; }
GetRecords();
};
const returnToFollows = () => router.push('/follow');
// 分页配置
const pagination = ref({
@@ -500,6 +667,7 @@ const handleTitleMouseLeave = (e: Event) => {
// -------------------------- 核心业务方法 --------------------------
/** 查询表格数据 */
const GetRecords = () => {
if (isMobileView.value) mobileFiltersOpen.value = false;
loading.value = true;
quaryData.pageIndex = pagination.value.current;
quaryData.pageSize = pagination.value.defaultPageSize;
@@ -577,13 +745,21 @@ const handleTableChange = (paginationObj: any, filters: any, sorter: any) => {
GetRecords();
};
const handleMobilePageChange = (page: number, pageSize: number) => {
pagination.value.current = page;
pagination.value.defaultPageSize = pageSize;
selectedRowKeys.value = [];
GetRecords();
};
const cookies = ref([]);
const getCookies = () => {
useApiStore()
.CookiePageList({})
.then((res) => {
if (res.data.data.length > 0) {
cookies.value = res.data.data.map((item) => {
const records = res.data?.data || [];
if (records.length > 0) {
cookies.value = records.map((item) => {
return {
value: item['id'] ?? '',
label: item['userName'] ?? '',
@@ -593,10 +769,10 @@ const getCookies = () => {
value: '', // 全部对应的 value 为空字符串
label: '全部', // 显示的文本,可根据需求修改
});
quaryData.cookieId = cookies.value[0].value;
GetRecords();
}
quaryData.cookieId = '';
cookiesReady.value = true;
GetRecords();
});
};
@@ -655,8 +831,8 @@ const onViedoTypeChanged = () => {
// -------------------------- 视频播放相关方法 --------------------------
/** 点击视频标题播放 */
const handleVideoClick = (record: DataItem) => {
if (record.isMergeVideo && record.videoSavePath.length == 0) {
message.warning('图文视频配置:不下载视频,所有没有可播放的视频');
if (record.onlyImgOrOnlyMp3 || (record.isMergeVideo && !record.videoSavePath?.length)) {
message.warning('该记录只保存了图片或音频,没有可播放的视频');
return;
}
// 保存当前视频信息
@@ -825,19 +1001,71 @@ const handleBatchDelete = () => {
const deleteVideoShow = ref(false);
const handShowDeleteVideos = () => {
deleteVideoShow.value = true;
getDeleteViedos();
getDeleteViedos(1);
};
const deleteVideos = ref([]);
const getDeleteViedos = () => {
useApiStore()
.GetDeleteViedos()
.then((res) => {
deleteVideos.value = res.data;
});
const deleteVideos = ref<any[]>([]);
const deletedLoading = ref(false);
const deletedPage = ref(1);
const deletedPageSize = 20;
const deletedTotal = ref(0);
const exclusionKeyword = ref('');
const selectedExclusionIds = ref<string[]>([]);
const unexcludeVisible = ref(false);
const unexcludeMode = ref<'only' | 'restore'>('only');
const pendingUnexcludeIds = ref<string[]>([]);
const unexcluding = ref(false);
const allExclusionsSelected = computed(() => deleteVideos.value.length > 0 && deleteVideos.value.every(item => selectedExclusionIds.value.includes(item.id)));
const someExclusionsSelected = computed(() => !allExclusionsSelected.value && deleteVideos.value.some(item => selectedExclusionIds.value.includes(item.id)));
const getDeleteViedos = async (page = deletedPage.value) => {
deletedPage.value = page;
deletedLoading.value = true;
try {
const res = await useApiStore().VideoExclusions({ pageIndex: page, pageSize: deletedPageSize, keyword: exclusionKeyword.value });
if (res.code !== 0) throw new Error(res.message || '获取永久排除列表失败');
deleteVideos.value = res.data.items || [];
deletedTotal.value = res.data.totalCount || 0;
selectedExclusionIds.value = [];
} catch (error: any) {
message.error(error?.message || '获取永久排除列表失败');
} finally {
deletedLoading.value = false;
}
};
const onDeleteVideoClose = (e) => {
const toggleExclusion = (id: string, checked: boolean) => {
selectedExclusionIds.value = checked
? Array.from(new Set([...selectedExclusionIds.value, id]))
: selectedExclusionIds.value.filter(item => item !== id);
};
const toggleAllExclusions = (event: any) => {
const pageIds = deleteVideos.value.map(item => item.id);
selectedExclusionIds.value = event.target.checked
? Array.from(new Set([...selectedExclusionIds.value, ...pageIds]))
: selectedExclusionIds.value.filter(id => !pageIds.includes(id));
};
const openUnexclude = (ids: string[]) => {
pendingUnexcludeIds.value = [...ids];
unexcludeMode.value = 'only';
unexcludeVisible.value = true;
};
const submitUnexclude = async () => {
unexcluding.value = true;
try {
const res = await useApiStore().UnexcludeVideos(pendingUnexcludeIds.value, unexcludeMode.value === 'restore');
if (res.code !== 0) throw new Error(res.message || '取消排除失败');
message.success(`${res.data.message || '已取消永久排除'}${res.data.taskId ? ' 可在任务中心查看进度。' : ''}`);
unexcludeVisible.value = false;
await getDeleteViedos(deletedPage.value);
} catch (error: any) {
message.error(error?.message || '取消排除失败');
} finally {
unexcluding.value = false;
}
};
const formatDeletedDate = (value?: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '未知时间';
const onDeleteVideoClose = () => {
deleteVideoShow.value = false;
selectedExclusionIds.value = [];
};
const reDownload = (param: object) => {
@@ -1037,9 +1265,24 @@ const copyVideoPath = (path?: string) => {
// -------------------------- 页面初始化 --------------------------
onMounted(() => {
window.addEventListener('resize', updateMobileView);
applyAuthorRoute();
// getConfig();
getCookies();
});
watch(
() => [route.query.authorId, route.query.author],
() => {
if (suppressAuthorRouteWatch) return;
applyAuthorRoute();
if (cookiesReady.value) GetRecords();
}
);
onBeforeUnmount(() => {
window.removeEventListener('resize', updateMobileView);
});
</script>
<style>
@@ -1227,6 +1470,90 @@ onMounted(() => {
}
@media (max-width: 768px) {
.mobile-record-toolbar {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 0 0 10px;
}
.mobile-filter-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
gap: 8px;
border: 1px solid var(--mobile-border, #e5e7eb);
border-radius: 12px;
background: var(--mobile-card, #fff);
color: var(--mobile-text, #334155);
font-weight: 600;
}
.mobile-filter-trigger.active {
border-color: #b794f4;
background: #f7f2ff;
color: #722ed1;
}
.query-container--mobile {
margin-bottom: 10px;
padding: 12px;
border: 1px solid var(--mobile-border, #e5e7eb);
border-radius: 14px;
background: var(--mobile-card, #fff);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.05);
}
.query-container--mobile .form-main-row,
.query-container--mobile .form-actions-row {
display: grid;
grid-template-columns: minmax(0, 1fr);
width: 100%;
gap: 8px;
overflow: visible;
}
.query-container--mobile :deep(.ant-form-item),
.query-container--mobile .form-item,
.query-container--mobile .form-item-date,
.query-container--mobile .form-item-input,
.query-container--mobile .radio-group-item,
.query-container--mobile .batch-operation-item,
.query-container--mobile .button-group-item,
.query-container--mobile .delete-btn-2-wrapper {
width: 100% !important;
min-width: 0 !important;
margin: 0 !important;
}
.query-container--mobile .range-picker,
.query-container--mobile .query-input,
.query-container--mobile :deep(.ant-select) {
width: 100% !important;
min-width: 0 !important;
}
.query-container--mobile .video-type-radio {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
}
.query-container--mobile .video-type-radio :deep(.ant-radio-button-wrapper) {
overflow: hidden;
padding: 0 6px;
text-align: center;
text-overflow: ellipsis;
}
.query-container--mobile .query-button,
.query-container--mobile .delete-button-2,
.query-container--mobile .button-group {
width: 100%;
margin: 0 !important;
}
.form-item-date,
.form-item-input {
flex: 1 1 100%; /* 占满整行 */
@@ -1521,6 +1848,65 @@ onMounted(() => {
}
/* 已删除视频 - 列表项布局优化 */
.deleted-video-alert {
margin-bottom: 14px;
}
.deleted-video-toolbar {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto auto;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.deleted-video-row {
display: flex;
align-items: center;
width: 100%;
gap: 10px;
}
.deleted-video-text {
display: grid;
min-width: 0;
flex: 1;
}
.deleted-video-text small {
overflow: hidden;
margin-top: 2px;
color: #8c8c8c;
text-overflow: ellipsis;
white-space: nowrap;
}
.deleted-video-pagination {
margin-top: 16px;
text-align: right;
}
.record-unexclude-options {
display: grid;
gap: 16px;
width: 100%;
}
.record-unexclude-options .ant-radio-wrapper {
align-items: flex-start;
white-space: normal;
}
.record-unexclude-options strong,
.record-unexclude-options span {
display: block;
}
.record-unexclude-options span {
margin-top: 3px;
color: #64748b;
}
:deep(.ant-list-item) {
display: flex !important;
align-items: center !important;
@@ -1578,8 +1964,181 @@ onMounted(() => {
border-radius: 4px !important;
}
.mobile-record-list {
padding: 0 10px 24px;
}
.mobile-record-card {
margin-bottom: 12px;
padding: 14px;
border: 1px solid #e8e8e8;
border-radius: 16px;
background: var(--mobile-card, #fff);
box-shadow: 0 3px 14px rgba(15, 23, 42, 0.06);
}
.mobile-record-card__header {
display: flex;
align-items: flex-start;
gap: 10px;
}
.mobile-record-title {
flex: 1;
padding: 0;
border: 0;
background: transparent;
color: var(--mobile-text, #172033);
font-size: 15px;
font-weight: 600;
line-height: 1.45;
text-align: left;
}
.mobile-record-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.mobile-record-meta {
margin: 12px 0;
}
.mobile-record-meta > div {
display: grid;
grid-template-columns: 68px minmax(0, 1fr);
gap: 8px;
padding: 3px 0;
font-size: 12px;
}
.mobile-record-meta dt {
color: #8c8c8c;
}
.mobile-record-meta dd {
min-width: 0;
margin: 0;
color: #334155;
}
.mobile-record-meta .path-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-record-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
@media (max-width: 380px) {
.mobile-record-actions { grid-template-columns: 1fr; }
}
.mobile-record-pagination {
display: flex;
justify-content: center;
margin-top: 16px;
}
.author-filter-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 0 0 12px;
padding: 10px 14px;
border: 1px solid #91caff;
border-radius: 8px;
background: #e6f4ff;
}
.author-filter-banner > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.author-filter-banner span {
color: #64748b;
font-size: 12px;
}
/* 可选:适配移动端,优化小屏幕显示 */
@media (max-width: 768px) {
.author-filter-banner {
align-items: stretch;
flex-direction: column;
}
.author-filter-banner :deep(.ant-space) {
display: grid;
grid-template-columns: 1fr 1fr;
}
.deleted-video-toolbar {
grid-template-columns: 1fr;
}
.deleted-video-row {
align-items: flex-start;
}
.deleted-video-row > .ant-btn {
flex: 0 0 auto;
}
.query-container {
margin: 8px 0 12px;
padding: 10px;
}
.form-main-row,
.form-actions-row {
flex-direction: column;
align-items: stretch;
}
.form-item,
.form-item-date,
.form-item-input,
.radio-group-item,
.batch-operation-item,
.button-group-item,
.delete-btn-2-wrapper {
width: 100% !important;
min-width: 0 !important;
margin: 0 0 8px !important;
}
.range-picker,
.query-input,
.form-actions-row .ant-select {
width: 100% !important;
min-width: 0 !important;
}
.video-type-radio {
flex-wrap: nowrap;
width: 100%;
padding-bottom: 4px;
overflow-x: auto;
}
.video-type-radio .ant-radio-button-wrapper {
flex: 0 0 auto;
}
.query-button,
.delete-button-2 {
width: 100%;
}
.delete-video-title-container {
margin-right: 12px;
}
@@ -1606,4 +2165,23 @@ onMounted(() => {
html.dark-mode .ant-table-column-sort {
background: #161627;
}
</style>
html.dark-mode .mobile-record-card {
border-color: #33354a;
background: #1a1a2e;
}
html.dark-mode .mobile-record-meta dd {
color: rgba(255, 255, 255, 0.86);
}
html.dark-mode .author-filter-banner {
border-color: #15395b;
background: #111d2c;
color: rgba(255, 255, 255, 0.88);
}
html.dark-mode .author-filter-banner span {
color: rgba(255, 255, 255, 0.58);
}
</style>
+12 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="workplace grid grid-rows-none gap-4 mt-xxs">
<div class="project-list grid grid-cols-24 gap-4">
<records class="col-span-12 xlx:col-span-7 xxlx:col-span-8 drop-shadow-sm" />
<records class="record-panel col-span-12 xlx:col-span-7 xxlx:col-span-8 drop-shadow-sm" />
</div>
</div>
</template>
@@ -17,4 +17,15 @@ useUnbounded();
<style scoped lang="less">
.workplace {
}
@media (max-width: 768px) {
.project-list {
display: block;
}
.record-panel {
width: 100%;
}
}
</style>
+11 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="workplace grid grid-rows-none gap-4 mt-xxs">
<div class="project-list grid grid-cols-24 gap-4">
<records class="col-span-12 xlx:col-span-7 xxlx:col-span-8 drop-shadow-sm" />
<records class="record-panel col-span-12 xlx:col-span-7 xxlx:col-span-8 drop-shadow-sm" />
</div>
</div>
</template>
@@ -17,4 +17,14 @@ useUnbounded();
<style scoped lang="less">
.workplace {
}
@media (max-width: 768px) {
.project-list {
display: block;
}
.record-panel {
width: 100%;
}
}
</style>
+103 -3
View File
@@ -124,7 +124,7 @@
<transition name="stats-fade" mode="out-in">
<div v-if="currentTab === 'author'" key="author-view" class="stats-content">
<div class="authors-grid">
<div class="author-card" v-for="(author, index) in authors" :key="index" @dblclick="handleDeleteItem(author)">
<div class="author-card" v-for="(author, index) in authors" :key="index">
<div class="author-info-row">
<div class="author-avatar">
<img :src="author.icon" alt="作者头像" />
@@ -137,6 +137,9 @@
<div class="author-progress">
<div class="progress-bar" :style="{ width: `${(author.count / totalVideos) * 100}%` }"></div>
</div>
<a-button class="author-card-action" type="text" danger aria-label="删除该博主的全部视频" @click="handleDeleteItem(author)">
<DeleteOutlined />删除视频
</a-button>
</div>
</div>
</div>
@@ -181,7 +184,7 @@ import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { useApiStore } from '@/store';
import { message, Modal } from 'ant-design-vue';
import * as echarts from 'echarts';
import { FullscreenOutlined } from '@ant-design/icons-vue'; // 确保导入图标
import { DeleteOutlined, FullscreenOutlined } from '@ant-design/icons-vue'; // 确保导入图标
// 类型接口不变
interface Author {
@@ -715,6 +718,97 @@ const handleDeleteItem = (item: Author) => {
grid-template-columns: 1fr;
}
}
@media (max-width: 575px) {
.stats-dashboard {
min-height: auto;
padding: 0 0 16px;
background: transparent;
}
.dashboard-container {
max-width: 100%;
padding: 0;
}
.stats-left,
.stats-right {
padding: 14px !important;
border-color: var(--mobile-border, #e0e0e0);
border-radius: 16px;
background: var(--mobile-card, #fff);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.05);
}
.stat-value {
font-size: 24px;
}
.stat-subitems {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.subitem {
min-width: 0;
padding: 8px 6px;
background: var(--mobile-card, #fff);
}
.subitem-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chart-container {
min-height: 240px;
padding-right: 0;
}
.detailed-stats {
padding: 14px 10px;
border: 1px solid var(--mobile-border, #e5e7eb);
border-radius: 16px;
background: var(--mobile-card, #fff);
}
.stats-header {
margin-bottom: 16px;
}
.tab-btn {
padding: 7px 10px;
}
:deep(.ant-modal) {
width: calc(100vw - 20px) !important;
max-width: none;
margin: 10px auto;
}
.authors-grid,
.categories-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.author-card,
.category-card {
min-width: 0;
padding: 10px;
}
.author-avatar {
width: 40px;
height: 40px;
}
.main-card:hover,
.author-card:hover,
.category-card:hover {
transform: none;
}
}
.main-card {
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
@@ -865,6 +959,12 @@ const handleDeleteItem = (item: Author) => {
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
}
.author-card-action {
align-self: flex-end;
min-height: 32px !important;
padding: 0 6px !important;
font-size: 12px;
}
.author-card:hover {
transform: translateY(-3px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12);
@@ -1104,4 +1204,4 @@ html.dark-mode .fullscreen-btn:hover {
color: #42a5f5;
border-color: #42a5f5;
}
</style>
</style>
+4 -142
View File
@@ -1,10 +1,9 @@
import { NavigationGuard, NavigationHookAfter } from 'vue-router';
import http from '@/store/http';
import { useAccountStore, useMenuStore, useApiStore } from '@/store';
import { useAccountStore, useMenuStore } from '@/store';
import { useAuthStore } from '@/plugins';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import router from '@/router';
NProgress.configure({ showSpinner: false });
@@ -13,116 +12,16 @@ interface NaviGuard {
after?: NavigationHookAfter;
}
/**
* 检测是否为移动端设备(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;
};
// 标记是否已跳转到移动端路由,防止无限循环
let hasRedirectedToMobile = false;
// 新增:标记是否已从/mobile跳转到/dashboard,防止无限循环
let hasRedirectedToDashboard = false;
// 优化后的移动端跳转守卫(登录优先)
const MobileRedirectGuard: NavigationGuard = function (to, from, next) {
// 1. 排除移动端路由和登录页,避免逻辑干扰
const isMobileRoute = to.path === '/mobile';
const isLoginRoute = to.path === '/login';
// 新增:检测当前是否为非移动端设备
const isNonMobile = !isMobile();
// 【新增核心逻辑】:当前路由是/mobile,但不是移动端设备,跳转到/dashboard
if (isMobileRoute && isNonMobile) {
if (!hasRedirectedToDashboard) {
hasRedirectedToDashboard = true;
// 重置移动端跳转标记,避免后续干扰
hasRedirectedToMobile = false;
next({ path: '/dashboard' });
} else {
next();
}
return;
}
if (isMobileRoute) {
hasRedirectedToMobile = true;
// 重置dashboard跳转标记,避免后续干扰
hasRedirectedToDashboard = false;
next();
return;
}
if (isLoginRoute) {
// 登录页无需移动端跳转,直接放行
hasRedirectedToMobile = false; // 重置标记,登录后可正常跳转移动端
hasRedirectedToDashboard = false; // 重置dashboard跳转标记
next();
return;
}
// 2. 移动端检测
if (isMobile()) {
const isAuthorized = http.checkAuthorization();
if (!isAuthorized) {
// 未登录:优先跳登录(保持原有优先级)
next('/login');
} else {
// 已登录:跳移动端(防止重复跳转)
if (!hasRedirectedToMobile) {
hasRedirectedToMobile = true;
hasRedirectedToDashboard = false; // 重置dashboard跳转标记
next({ path: '/mobile' });
} else {
next();
}
}
} else {
// 非移动端:重置所有标记,不影响后续操作
hasRedirectedToMobile = false;
hasRedirectedToDashboard = false;
next();
}
};
const loginGuard: NavigationGuard = function (to, from, next) {
if (!http.checkAuthorization() && !/^\/(init|login|home|mobile)?$/.test(to.fullPath)) {
if (!http.checkAuthorization() && !/^\/(init|login|home)?$/.test(to.fullPath)) {
console.log(to.fullPath)
const account = useAccountStore();
account.setLogged(false);
// 重置dashboard跳转标记
hasRedirectedToDashboard = false;
next('/login');
} else {
next();
}
};
const dynamicinitRoute = {
path: '/',
name: 'login',
redirect: '/login',
meta: {
title: '登录',
renderMenu: false,
icon: 'CreditCardOutlined',
},
children: null,
component: () => import('@/pages/login'),
};
const InitGuard: NavigationGuard = function (to, from, next) {
if (to.fullPath != '/login') {
if (!router.hasRoute('login')) {
router.addRoute(dynamicinitRoute);
}
next('/login');
} else {
next();
}
};
// 进度条
const ProgressGuard: NaviGuard = {
before(to, from, next) {
@@ -172,44 +71,7 @@ const NotFoundGuard: NaviGuard = {
},
};
// 优化后的页面刷新移动端检测(登录优先)
window.addEventListener('load', () => {
const currentPath = window.location.pathname;
const isNonMobile = !isMobile();
// 【新增】:刷新后如果是/mobile路由且非移动端,跳转到/dashboard
if (currentPath === '/mobile' && isNonMobile) {
router.push('/dashboard').catch(err => {
if (!err.message.includes('NavigationDuplicated')) {
console.error('刷新时从/mobile跳转到/dashboard失败:', err);
}
});
return;
}
if (isMobile() && currentPath !== '/mobile') {
const isAuthorized = http.checkAuthorization();
if (!isAuthorized) {
// 未登录:跳登录(避免重复跳转)
if (currentPath !== '/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, MobileRedirectGuard, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before],
before: [ProgressGuard.before, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before],
after: [ProgressGuard.after],
};
};
+23 -17
View File
@@ -15,16 +15,15 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/login'),
},
{
path: '/',
name: 'mobile',
redirect: '/mobile',
path: '/mobile',
name: 'mobile-legacy',
redirect: '/dashboard',
meta: {
title: '移动端首页',
title: '移动端兼容入口',
renderMenu: false,
icon: 'CreditCardOutlined',
},
children: null,
component: () => import('@/pages/mobile/MobileDashboard.vue'),
},
{
@@ -71,18 +70,6 @@ const routes: RouteRecordRaw[] = [
},
component: () => import('@/pages/login'),
},
{
path: '/mobile',
name: 'mobile',
meta: {
icon: 'LoginOutlined',
view: 'blank',
target: '_blank',
cacheable: false,
},
children: null,
component: () => import('@/pages/mobile/MobileDashboard.vue'),
},
{
path: '/init',
name: 'init',
@@ -137,6 +124,7 @@ const routes: RouteRecordRaw[] = [
name: '数据看板',
meta: {
icon: 'RadarChartOutlined',
mobileTitle: '数据看板',
view: 'self',
target: '_self',
renderMenu: true,
@@ -149,6 +137,7 @@ const routes: RouteRecordRaw[] = [
name: '同步记录',
meta: {
icon: 'SwapOutlined',
mobileTitle: '同步记录',
view: 'self',
target: '_self',
renderMenu: true,
@@ -156,11 +145,25 @@ const routes: RouteRecordRaw[] = [
},
component: () => import('@/pages/workplace/Workplace.vue'),
},
{
path: '/tasks',
name: '任务中心',
meta: {
icon: 'ProfileOutlined',
mobileTitle: '任务中心',
view: 'self',
target: '_self',
renderMenu: true,
cacheable: false,
},
component: () => import('@/pages/tasks/index.vue'),
},
{
path: '/follow',
name: '关注列表',
meta: {
icon: 'HeartOutlined',
mobileTitle: '关注博主',
view: 'self',
target: '_self',
renderMenu: true,
@@ -173,6 +176,7 @@ const routes: RouteRecordRaw[] = [
name: '抖音授权',
meta: {
icon: 'SafetyCertificateOutlined',
mobileTitle: '抖音授权',
view: 'self',
target: '_self',
renderMenu: true,
@@ -186,6 +190,7 @@ const routes: RouteRecordRaw[] = [
name: '系统配置',
meta: {
icon: 'SettingOutlined',
mobileTitle: '系统设置',
view: 'self',
target: '_self',
renderMenu: true,
@@ -213,6 +218,7 @@ const routes: RouteRecordRaw[] = [
name: '系统日志',
meta: {
icon: 'UnorderedListOutlined',
mobileTitle: '系统日志',
view: 'self',
target: '_self',
renderMenu: true,
+206
View File
@@ -62,6 +62,154 @@ export const useApiStore = defineStore('coreapi', () => {
});
}
async function StorageConfig() {
return http.request<any, Response<any>>('/api/storage/config', 'get');
}
async function StorageInventory() {
return http.request<any, Response<any>>('/api/storage/inventory', 'get');
}
async function TestStorage(request: object) {
return http.request<any, Response<any>>('/api/storage/test', 'post_json', request);
}
async function OpenListDirectories(request: object) {
return http.request<any, Response<any>>('/api/storage/openlist/directories', 'post_json', request);
}
async function UpdateStorage(request: object) {
return http.request<any, Response<any>>('/api/storage/config', 'put_json', request);
}
async function EmailConfig() {
return http.request<any, Response<any>>('/api/email/config', 'get');
}
async function UpdateEmailConfig(request: object) {
return http.request<any, Response<any>>('/api/email/config', 'put_json', request);
}
async function TestEmailConfig(request: object) {
return http.request<any, Response<any>>('/api/email/test', 'post_json', request);
}
async function StorageMigrationPreflight() {
return http.request<any, Response<any>>('/api/storage/migrations/preflight', 'post_json', { verifyCapabilities: true });
}
async function CreateStorageMigration(request: object) {
return http.request<any, Response<any>>('/api/storage/migrations', 'post_json', request);
}
async function LatestStorageMigration() {
return http.request<any, Response<any>>('/api/storage/migrations/latest', 'get');
}
async function StorageMigrationDetail(id: string) {
return http.request<any, Response<any>>(`/api/storage/migrations/${id}`, 'get');
}
async function StorageMigrationItems(id: string, pageIndex: number, pageSize: number, stage?: number) {
const filter = stage === undefined ? '' : `&stage=${stage}`;
return http.request<any, Response<any>>(`/api/storage/migrations/${id}/items?pageIndex=${pageIndex}&pageSize=${pageSize}${filter}`, 'get');
}
async function StorageMigrationAction(id: string, action: string) {
return http.request<any, Response<any>>(`/api/storage/migrations/${id}/${action}`, 'post_json', {});
}
async function StorageMigrationFailedRecordsPreview() {
return http.request<any, Response<any>>('/api/storage/migrations/failed-records/preview', 'get');
}
async function RemoveStorageMigrationFailedRecords(confirmationToken: string) {
return http.request<any, Response<any>>('/api/storage/migrations/failed-records/remove', 'post_json', { confirmationToken });
}
async function ArchiveStorageMigrationHistory() {
return http.request<any, Response<any>>('/api/storage/migrations/history/archive', 'post_json', {});
}
async function OpenListDirectoryRepairPreflight(logicalPath = '/collect/Kk') {
return http.request<any, Response<any>>('/api/storage/openlist/directory-repairs/preflight', 'post_json', { logicalPath });
}
async function CreateOpenListDirectoryRepair(request: object) {
return http.request<any, Response<any>>('/api/storage/openlist/directory-repairs', 'post_json', request);
}
async function OpenListDirectoryRepairDetail(id: string) {
return http.request<any, Response<any>>(`/api/storage/openlist/directory-repairs/${id}`, 'get');
}
async function ConfirmOpenListDirectoryRepair(id: string, confirmationToken: string) {
return http.request<any, Response<any>>(`/api/storage/openlist/directory-repairs/${id}/confirm-cleanup`, 'post_json', { confirmationToken });
}
async function TaskSummary() {
return http.request<any, Response<any>>('/api/tasks/summary', 'get');
}
async function TaskList(params: Record<string, any> = {}) {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') query.set(key, String(value));
});
return http.request<any, Response<any>>(`/api/tasks?${query.toString()}`, 'get');
}
async function TaskDetail(type: number, id: string) {
return http.request<any, Response<any>>(`/api/tasks/${type}/${id}`, 'get');
}
async function TaskItems(type: number, id: string, params: Record<string, any> = {}) {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') query.set(key, String(value));
});
return http.request<any, Response<any>>(`/api/tasks/${type}/${id}/items?${query.toString()}`, 'get');
}
async function RetryTaskFailed(type: number, id: string) {
return http.request<any, Response<any>>(`/api/tasks/${type}/${id}/retry-failed`, 'post_json', {});
}
async function RetryTaskItem(type: number, taskId: string, itemId: string) {
return http.request<any, Response<any>>(`/api/tasks/${type}/${taskId}/items/${itemId}/retry`, 'post_json', {});
}
async function RetryTaskItemCleanup(type: number, taskId: string, itemId: string) {
return http.request<any, Response<any>>(`/api/tasks/${type}/${taskId}/items/${itemId}/retry-cleanup`, 'post_json', {});
}
async function TaskAction(type: number, id: string, action: string) {
return http.request<any, Response<any>>(`/api/tasks/${type}/${id}/actions/${action}`, 'post_json', {});
}
async function ProbeTaskStorage() {
return http.request<any, Response<any>>('/api/tasks/storage-health/probe', 'post_json', {});
}
async function StartTaskSync(videoType?: string | number) {
const query = videoType === undefined || videoType === null || videoType === ''
? ''
: `?videoType=${encodeURIComponent(String(videoType))}`;
return http.request<any, Response<any>>(`/api/tasks/sync${query}`, 'post_json', {});
}
async function VideoExclusions(params: Record<string, any> = {}) {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') query.set(key, String(value));
});
return http.request<any, Response<any>>(`/api/video/exclusions?${query.toString()}`, 'get');
}
async function UnexcludeVideos(ids: string[], createDownloadTask: boolean) {
return http.request<any, Response<any>>('/api/video/exclusions/unexclude', 'post_json', { ids, createDownloadTask });
}
//后台日志
async function apiGetLogs(param: string) {
return http.request<any, Response<any>>('/api/logs/GetLog/' + param, 'get').then(r => {
@@ -200,6 +348,18 @@ export const useApiStore = defineStore('coreapi', () => {
});
}
async function UpdateFollowLiveMonitor(param: object) {
return http.request<any, Response<any>>('/api/follow/live-monitor', 'post_json', param).then(r => r);
}
async function RefreshFollowLiveStatus(param: object) {
return http.request<any, Response<any>>('/api/follow/live-status/refresh', 'post_json', param).then(r => r);
}
async function QueryFollowLiveStatus(param: object) {
return http.request<any, Response<any>>('/api/follow/live-status/query', 'post_json', param).then(r => r);
}
async function UpdateFollowLiveEmail(param: object) {
return http.request<any, Response<any>>('/api/follow/live-email', 'post_json', param).then(r => r);
}
//重新下载
async function ReDownViedos(param: object) {
return http.request<any, Response<any>>('/api/video/redown', 'post_json', param).then(r => {
@@ -284,6 +444,14 @@ export const useApiStore = defineStore('coreapi', () => {
});
}
// 使用抖音资料页展示的抖音号查找博主,确认结果后再调用 AddFollow
async function ResolveFollowByDouyinNo(param: { cookieId: string; douyinNo: string }) {
return http.request<any, Response<any>>('/api/follow/resolve-by-douyin-no', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
//删除非关注的博主
async function DelFollow(param: object) {
return http.request<any, Response<any>>('/api/follow/delete', 'post_json', param).then(r => {
@@ -387,6 +555,39 @@ export const useApiStore = defineStore('coreapi', () => {
// }
return {
StorageConfig,
StorageInventory,
TestStorage,
OpenListDirectories,
UpdateStorage,
EmailConfig,
UpdateEmailConfig,
TestEmailConfig,
StorageMigrationPreflight,
CreateStorageMigration,
LatestStorageMigration,
StorageMigrationDetail,
StorageMigrationItems,
StorageMigrationAction,
StorageMigrationFailedRecordsPreview,
RemoveStorageMigrationFailedRecords,
ArchiveStorageMigrationHistory,
OpenListDirectoryRepairPreflight,
CreateOpenListDirectoryRepair,
OpenListDirectoryRepairDetail,
ConfirmOpenListDirectoryRepair,
TaskSummary,
TaskList,
TaskDetail,
TaskItems,
RetryTaskFailed,
RetryTaskItem,
RetryTaskItemCleanup,
TaskAction,
ProbeTaskStorage,
StartTaskSync,
VideoExclusions,
UnexcludeVideos,
VideoChart,
BatchSaveCate,
CatePageList,
@@ -407,6 +608,7 @@ export const useApiStore = defineStore('coreapi', () => {
GetDeleteViedos,
DelFollow,
AddFollow,
ResolveFollowByDouyinNo,
CheckTag,
deleteCookie,
UpdateConfig,
@@ -424,6 +626,10 @@ export const useApiStore = defineStore('coreapi', () => {
SyncFollow,
OpenOrCloseSync,
OpenOrCloseFullSync,
UpdateFollowLiveMonitor,
RefreshFollowLiveStatus,
QueryFollowLiveStatus,
UpdateFollowLiveEmail,
ReDownViedos,
DeleteVideo
};
+31
View File
@@ -0,0 +1,31 @@
import { onBeforeUnmount, onMounted, ref } from 'vue';
const MOBILE_BREAKPOINT = 768;
export function useMobileViewport() {
const isMobileViewport = ref(false);
const updateViewport = () => {
isMobileViewport.value = window.innerWidth <= MOBILE_BREAKPOINT;
const height = window.visualViewport?.height || window.innerHeight;
document.documentElement.style.setProperty('--app-viewport-height', `${Math.round(height)}px`);
};
onMounted(() => {
updateViewport();
window.addEventListener('resize', updateViewport, { passive: true });
window.addEventListener('orientationchange', updateViewport, { passive: true });
window.visualViewport?.addEventListener('resize', updateViewport, { passive: true });
window.visualViewport?.addEventListener('scroll', updateViewport, { passive: true });
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport);
window.removeEventListener('orientationchange', updateViewport);
window.visualViewport?.removeEventListener('resize', updateViewport);
window.visualViewport?.removeEventListener('scroll', updateViewport);
document.documentElement.style.removeProperty('--app-viewport-height');
});
return { isMobileViewport, updateViewport };
}
+78
View File
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const source = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8');
test('login decorations cannot intercept touch input', async () => {
const login = await source('src/pages/login/Login.vue');
const loginBox = await source('src/pages/login/LoginBox.vue');
assert.match(login, /&::before[\s\S]*?pointer-events:\s*none/);
assert.match(login, /&::after[\s\S]*?pointer-events:\s*none/);
assert.match(loginBox, /html-type="submit"/);
assert.match(loginBox, /class="login-submit/);
});
test('legacy WebViews do not depend on the :has selector to finish mounting', async () => {
const html = await source('index.html');
const main = await source('src/main.ts');
assert.doesNotMatch(html, /:has\(/);
assert.match(html, /#stepin-app\.app-mounted/);
assert.match(main, /classList\.add\('app-mounted'\)/);
});
test('mobile overlays stay above fixed navigation', async () => {
const nav = await source('src/components/layout/MobileBottomNav.vue');
assert.match(nav, /z-index:\s*900/);
assert.match(nav, /:z-index="1300"/);
assert.match(nav, /destroy-on-close/);
});
test('every primary mobile page provides an explicit title', async () => {
const routes = await source('src/router/routes.ts');
for (const title of ['数据看板', '同步记录', '任务中心', '关注博主', '抖音授权', '系统设置', '系统日志']) {
assert.match(routes, new RegExp(`mobileTitle: '${title}'`));
}
});
test('failed migration record removal is explicit, responsive, and never starts sync automatically', async () => {
const modal = await source('src/components/FailedMigrationRecordRemovalModal.vue');
assert.match(modal, /影响全部历史迁移任务/);
assert.match(modal, /旧本地文件、已上传的 WebDAV 文件/);
assert.match(modal, /下一次正常同步/);
assert.match(modal, /RemoveStorageMigrationFailedRecords/);
assert.doesNotMatch(modal, /StartTaskSync/);
assert.match(modal, /max-height:\s*calc\(100dvh - 190px\)/);
assert.match(modal, /grid-template-columns:\s*1fr 1fr/);
});
test('live monitoring and email notification stay per-blogger and touch friendly', async () => {
const follow = await source('src/pages/followd/index.vue');
const settings = await source('src/pages/set/AppSet.vue');
assert.match(follow, /直播监测/);
assert.match(follow, /开播邮件/);
assert.match(follow, /UpdateFollowLiveMonitor/);
assert.match(follow, /UpdateFollowLiveEmail/);
assert.match(follow, /每 5 分钟自动检查/);
assert.match(follow, /\.live-monitor-row[\s\S]*?min-height:\s*44px/);
assert.match(follow, /\.live-refresh-btn[\s\S]*?width:\s*44px/);
assert.match(follow, /@media \(max-width:\s*768px\)[\s\S]*?\.card-main-content\s*\{[\s\S]*?display:\s*grid/);
assert.match(follow, /\.card-content\s*\{[\s\S]*?display:\s*contents/);
assert.match(follow, /\.live-monitor-row\s*\{[\s\S]*?grid-column:\s*1 \/ -1/);
assert.match(follow, /\.card-path-sync-container\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) max-content/);
assert.match(follow, /\.sync-switch-wrapper\s*\{[\s\S]*?flex-shrink:\s*0/);
assert.match(settings, /邮箱通知/);
assert.match(settings, /发送测试邮件/);
assert.match(settings, /SSL\/TLS(常用 465/);
assert.match(settings, /STARTTLS(常用 587/);
});
test('storage capability detection is always visible and touch friendly in task center', async () => {
const tasks = await source('src/pages/tasks/index.vue');
assert.match(tasks, /class="storage-probe-action"/);
assert.match(tasks, /summary\.storageHealth\?\.status === 1 \? '重新检测存储' : '检测存储'/);
assert.match(tasks, /CloudSyncOutlined/);
assert.doesNotMatch(tasks, /v-if="summary\.storageHealth\?\.status === 1"[\s\S]{0,500}#action/);
assert.match(tasks, /\.storage-probe-action\s*\{[\s\S]*?grid-column:\s*1 \/ -1/);
assert.match(tasks, /\.task-actions :deep\(\.ant-btn\)\s*\{[\s\S]*?min-height:\s*44px/);
});
+1 -1
View File
@@ -5,7 +5,7 @@ import Components from 'unplugin-vue-components/vite';
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
import { AntdvLessPlugin, AntdvModifyVars } from 'stepin/lib/style/plugins';
import viteCompression from 'vite-plugin-compression';
const timestamp = new Date().getTime();
const timestamp = Number(process.env.VITE_BUILD_TIMESTAMP || Date.now());
const prodRollupOptions = {
output: {
chunkFileNames: (chunk) => {
+7
View File
@@ -0,0 +1,7 @@
{
"deploy": "fn",
"dbconn": "",
"tagName": "0.2.25",
"dbtype": "Sqlite",
"fnVersion": "0.2.25"
}
+11 -2
View File
@@ -2,6 +2,9 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Version>0.2.25</Version>
<AssemblyVersion>0.2.25.0</AssemblyVersion>
<FileVersion>0.2.25.0</FileVersion>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
@@ -27,24 +30,28 @@
<Compile Remove="db\**" />
<Compile Remove="expand\**" />
<Compile Remove="logs\**" />
<Compile Remove="tests\**" />
<Content Remove="app\**" />
<Content Remove="db\**" />
<Content Remove="expand\**" />
<Content Remove="logs\**" />
<Content Remove="tests\**" />
<EmbeddedResource Remove="app\**" />
<EmbeddedResource Remove="db\**" />
<EmbeddedResource Remove="expand\**" />
<EmbeddedResource Remove="logs\**" />
<EmbeddedResource Remove="tests\**" />
<None Remove="app\**" />
<None Remove="db\**" />
<None Remove="expand\**" />
<None Remove="logs\**" />
<None Remove="tests\**" />
</ItemGroup>
<ItemGroup>
<Compile Include="**/*.cs" Exclude="app\**;db\**;expand\**;logs\**;obj\**;bin\**" />
<Content Include="**/*.json;**/*.xml;**/*.config" Exclude="app\**;db\**;expand\**;logs\**;obj\**;bin\**" />
<Compile Include="**/*.cs" Exclude="app\**;db\**;expand\**;logs\**;tests\**;obj\**;bin\**" />
<Content Include="**/*.json;**/*.xml;**/*.config" Exclude="app\**;db\**;expand\**;logs\**;tests\**;obj\**;bin\**" />
</ItemGroup>
@@ -58,10 +65,12 @@
<PackageReference Include="ClockSnowFlake" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.16" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="6.0.10" />
<PackageReference Include="MailKit" Version="4.7.1" />
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.AspNetCore" Version="3.8.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
<PackageReference Include="Quartz.Serialization.SystemTextJson" Version="3.15.1" />
<PackageReference Include="SqlSugarCore" Version="5.1.4.128" />
<!--<PackageReference Include="SqlSugarCoreNoDrive" Version="5.1.4.124" />-->
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
+6
View File
@@ -5,6 +5,8 @@ VisualStudioVersion = 18.0.11010.61
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dy.net", "dy.net.csproj", "{680660EF-ACAE-43A9-AB6C-B75532E758AD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dy.net.Tests", "tests\dy.net.Tests\dy.net.Tests.csproj", "{8F3105A5-45E2-4DFB-884A-1D92E331D287}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -15,6 +17,10 @@ Global
{680660EF-ACAE-43A9-AB6C-B75532E758AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{680660EF-ACAE-43A9-AB6C-B75532E758AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{680660EF-ACAE-43A9-AB6C-B75532E758AD}.Release|Any CPU.Build.0 = Release|Any CPU
{8F3105A5-45E2-4DFB-884A-1D92E331D287}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8F3105A5-45E2-4DFB-884A-1D92E331D287}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8F3105A5-45E2-4DFB-884A-1D92E331D287}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8F3105A5-45E2-4DFB-884A-1D92E331D287}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+76 -22
View File
@@ -20,6 +20,7 @@ using System.Collections.Concurrent;
//using Swashbuckle.AspNetCore.SwaggerGen;
//using Swashbuckle.AspNetCore.SwaggerUI;
using System.IO.Compression;
using System.Net;
using System.Net.Security;
using System.Reflection;
using System.Text;
@@ -242,27 +243,35 @@ namespace dy.net.extension
{
// 提前创建连接字符串,避免每次创建ISqlSugarClient都调用(减少重复计算)
string sqliteConn = CreateSqliteDBConn(dbpath);
// Schema initialization is a one-time startup gate. Running CodeFirst in every scoped client
// caused concurrent requests/workers to race schema changes and made upgrade failures non-fatal.
using (var initializer = CreateSqlSugarClient(sqliteConn, initializeSchema: false))
{
initializer.DbMaintenance.CreateDatabase();
initializer.CodeFirst.InitTables(_entityTypes);
}
services.AddScoped<ISqlSugarClient>(db =>
{
var sqlSugar = new SqlSugarClient(new ConnectionConfig
{
ConnectionString = sqliteConn,
InitKeyType = InitKeyType.Attribute,
DbType = DbType.Sqlite,
IsAutoCloseConnection = true
}, db =>
{
// 移除空的Debug日志委托,避免空委托的内存占用
db.Aop.OnError = (e) =>
{
Serilog.Log.Error(e, $"SqlSugar执行错误:{e.Message}SQL{e.Sql}");
};
return CreateSqlSugarClient(sqliteConn, initializeSchema: false);
});
}
private static SqlSugarClient CreateSqlSugarClient(string connectionString, bool initializeSchema)
{
return new SqlSugarClient(new ConnectionConfig
{
ConnectionString = connectionString,
InitKeyType = InitKeyType.Attribute,
DbType = DbType.Sqlite,
IsAutoCloseConnection = true
}, db =>
{
db.Aop.OnError = e => Serilog.Log.Error(e, $"SqlSugar执行错误:{e.Message}SQL{e.Sql}");
if (initializeSchema)
{
db.DbMaintenance.CreateDatabase();
// 核心优化:使用缓存的实体类型,避免每次都反射(减少GC和内存)
db.CodeFirst.InitTables(_entityTypes);
});
return sqlSugar;
}
});
}
@@ -279,6 +288,7 @@ namespace dy.net.extension
services.AddScoped<DouyinCollectCustomSyncJob>();
services.AddScoped<DouyinMixSyncJob>();
services.AddScoped<DouyinSeriesSyncJob>();
services.AddScoped<DouyinLiveStatusJob>();
// 提前创建Quartz的SQLite连接字符串,避免重复调用
string quartzConn = CreateSqliteDBConn(dbPath);
@@ -297,8 +307,11 @@ namespace dy.net.extension
config.ConnectionString = quartzConn; // 使用提前创建的连接字符串
config.TablePrefix = "QRTZ_";
});
s.UseProperties = false;
s.UseBinarySerializer();
// All Quartz job data used by this application consists of strings. Persist it as
// properties and use the supported JSON serializer. BinaryFormatter is disabled on
// current .NET runtimes and prevented manual triggers from being stored.
s.UseProperties = true;
s.UseSystemTextJsonSerializer();
});
});
@@ -317,7 +330,7 @@ namespace dy.net.extension
public static void AddHttpClients(this IServiceCollection services)
{
// 通用忽略SSL的Handler工厂:提取为局部方法,避免重复创建逻辑
static HttpMessageHandler IgnoreSslHandlerFactory()
static HttpMessageHandler IgnoreSslHandlerFactory(bool allowAutoRedirect = true)
{
var handler = new SocketsHttpHandler
{
@@ -326,6 +339,7 @@ namespace dy.net.extension
ConnectTimeout = TimeSpan.FromSeconds(30), // 核心修复:移除无限超时,避免请求挂起泄漏
PooledConnectionLifetime = TimeSpan.FromMinutes(5), // 优化:连接池生命周期,自动释放闲置连接
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), // 优化:闲置连接超时,减少内存占用
AllowAutoRedirect = allowAutoRedirect,
SslOptions = new SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = (_, __, ___, ____) => true
@@ -340,7 +354,7 @@ namespace dy.net.extension
client.DefaultRequestHeaders.UserAgent.ParseAdd(DouyinRequestParamManager.DY_USER_AGENT);
client.BaseAddress = new Uri(DouyinRequestParamManager.DouyinHost);
client.Timeout = TimeSpan.FromSeconds(60); // 设置请求超时,避免无限等待
}).ConfigurePrimaryHttpMessageHandler(IgnoreSslHandlerFactory);
}).ConfigurePrimaryHttpMessageHandler(() => IgnoreSslHandlerFactory());
// 抖音下载客户端
services.AddHttpClient(DouyinRequestParamManager.DY_HTTP_CLIENT_DOWN, client =>
@@ -348,7 +362,41 @@ namespace dy.net.extension
client.DefaultRequestHeaders.UserAgent.ParseAdd(DouyinRequestParamManager.DY_USER_AGENT);
client.DefaultRequestHeaders.Referrer = new Uri(DouyinRequestParamManager.DouyinHost);
client.Timeout = TimeSpan.FromMinutes(5); // 下载超时设为5分钟,合理且不泄漏
}).ConfigurePrimaryHttpMessageHandler(IgnoreSslHandlerFactory);
}).ConfigurePrimaryHttpMessageHandler(() => IgnoreSslHandlerFactory(false));
// WebDAV 默认严格校验证书;仅当用户在界面明确开启自签证书兼容时使用 insecure 客户端。
services.AddHttpClient("webdav", client =>
{
client.Timeout = TimeSpan.FromMinutes(10);
client.DefaultRequestHeaders.UserAgent.ParseAdd("dysync.net-webdav/1.0");
});
services.AddHttpClient("webdav-insecure", client =>
{
client.Timeout = TimeSpan.FromMinutes(10);
client.DefaultRequestHeaders.UserAgent.ParseAdd("dysync.net-webdav/1.0");
}).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
// OpenList (AList) 原生 API 客户端
services.AddHttpClient("openlist", client =>
{
// Cloud-backed OpenList drivers can legitimately need more than 30 seconds to
// refresh a large directory. A 30 second HttpClient timeout surfaced as the
// unhelpful "Operation canceled" and incorrectly tripped storage health.
client.Timeout = TimeSpan.FromMinutes(2);
client.DefaultRequestVersion = HttpVersion.Version11;
client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
}).ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
PooledConnectionLifetime = TimeSpan.FromMinutes(10),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 4,
ConnectTimeout = TimeSpan.FromSeconds(10),
UseCookies = false
});
}
/// <summary>
@@ -368,6 +416,7 @@ namespace dy.net.extension
type.IsClass &&
!type.IsAbstract &&
!type.IsGenericTypeDefinition &&
!typeof(IHostedService).IsAssignableFrom(type) &&
type.Namespace != null &&
(includeSubNamespaces
? type.Namespace.StartsWith(@namespace, StringComparison.Ordinal)
@@ -527,7 +576,12 @@ namespace dy.net.extension
.Enrich.FromLogContext()
.Filter.ByExcluding(e => e.Level == LogEventLevel.Information) // 排除Info级别的日志
.Filter.ByExcluding(Matching.FromSource("Microsoft"))
.Filter.ByExcluding(Matching.FromSource("Quartz"))
// Quartz 的 Debug 日志非常密集,但 Warning/Error 必须保留,否则任务在进入
// 业务代码前失败时,任务中心还没有记录,用户也看不到任何错误原因。
.Filter.ByExcluding(e =>
e.Level < LogEventLevel.Warning
&& e.Properties.TryGetValue("SourceContext", out var source)
&& source.ToString().Contains("Quartz", StringComparison.OrdinalIgnoreCase))
.WriteTo.Console(new RenderedCompactJsonFormatter(), LogEventLevel.Debug)
//.WriteTo.MySQL(connectionString: builder.Configuration.GetConnectionString("DbConnectionString"), tableName: "Logs") // 输出到数据库
.WriteTo.Logger(configure => configure
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exit 0
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exit 0
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exit 0
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exit 0
Executable
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
set -u
LOG_FILE="${TRIM_PKGVAR}/info.log"
PID_FILE="${TRIM_PKGVAR}/app.pid"
CMD="${TRIM_APPDEST}/server/dy.net"
DATA_ROOT="${TRIM_APPDEST_VOL}/@appshare/dy.net"
log_msg() {
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >> "${LOG_FILE}"
}
running_pid() {
if [ -f "${PID_FILE}" ]; then
pid=$(head -n 1 "${PID_FILE}" | tr -d '[:space:]')
if [ -n "${pid}" ] && kill -0 "${pid}" 2>/dev/null; then
printf '%s' "${pid}"
return 0
fi
fi
return 1
}
start_process() {
if [ ! -x "${CMD}" ]; then
log_msg "程序不存在或不可执行:${CMD}"
exit 1
fi
mkdir -p "${TRIM_PKGVAR}" "${TRIM_PKGVAR}/.net-bundle" "${DATA_ROOT}"
if pid=$(running_pid); then
log_msg "程序已运行,PID ${pid}"
return 0
fi
(
export LC_ALL=C.UTF-8
export LANG=C.UTF-8
export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
# fnOS package accounts may not have HOME. Keep any future single-file native
# extraction in the package's writable var directory instead of ~/.net.
export DOTNET_BUNDLE_EXTRACT_BASE_DIR="${TRIM_PKGVAR}/.net-bundle"
cd "$(dirname "${CMD}")" || exit 1
exec "${CMD}" "${DATA_ROOT}"
) >> "${LOG_FILE}" 2>&1 &
pid=$!
sleep 1
if kill -0 "${pid}" 2>/dev/null; then
printf '%s' "${pid}" > "${PID_FILE}"
log_msg "程序启动成功,PID ${pid}"
else
log_msg "程序启动失败,请检查日志"
exit 1
fi
}
stop_process() {
if ! pid=$(running_pid); then
rm -f "${PID_FILE}"
return 0
fi
kill "${pid}" 2>/dev/null || true
count=0
while kill -0 "${pid}" 2>/dev/null && [ "${count}" -lt 20 ]; do
sleep 0.5
count=$((count + 1))
done
if kill -0 "${pid}" 2>/dev/null; then kill -9 "${pid}" 2>/dev/null || true; fi
rm -f "${PID_FILE}"
}
case "${1:-}" in
start) start_process ;;
stop) stop_process ;;
status) running_pid >/dev/null && exit 0 || exit 3 ;;
*) echo "用法:$0 {start|stop|status}"; exit 1 ;;
esac
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exit 0
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exit 0
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
# Database changes are performed by the application after its own pre-schema backup.
# Storage migration is intentionally never started by an fnOS upgrade hook.
exit 0
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
set -eu
DATA_ROOT="${TRIM_APPDEST_VOL}/@appshare/dy.net"
DB_ROOT="${DATA_ROOT}/db"
DB_FILE="${DB_ROOT}/dy.sqlite"
BACKUP_ROOT="${DB_ROOT}/upgrade-backups"
if [ ! -f "${DB_FILE}" ]; then exit 0; fi
mkdir -p "${BACKUP_ROOT}"
DEST="${BACKUP_ROOT}/fpk-pre-0.2.25-$(date '+%Y%m%d-%H%M%S')"
mkdir -p "${DEST}"
cp -p "${DB_FILE}" "${DEST}/dy.sqlite"
if [ -f "${DB_FILE}-wal" ]; then cp -p "${DB_FILE}-wal" "${DEST}/dy.sqlite-wal"; fi
if [ -f "${DB_FILE}-shm" ]; then cp -p "${DB_FILE}-shm" "${DEST}/dy.sqlite-shm"; fi
if [ -d "${DB_ROOT}/keys" ]; then cp -a "${DB_ROOT}/keys" "${DEST}/keys"; fi
# Avoid GNU find -printf: fnOS releases may provide the BusyBox find implementation.
ls -1dt -- "${BACKUP_ROOT}"/* 2>/dev/null | tail -n +4 | while IFS= read -r backup; do
if [ -d "${backup}" ]; then rm -rf -- "${backup}"; fi
done
exit 0
+5
View File
@@ -0,0 +1,5 @@
{
"defaults": {
"run-as": "package"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"data-share": {
"shares": [
{ "name": "dy.net", "permission": { "rw": ["dy.net"] } },
{ "name": "dy.net/db", "permission": { "rw": ["dy.net"] } }
]
}
}
+11
View File
@@ -0,0 +1,11 @@
appname = dy.net
version = 0.2.25
display_name = 抖小云
desc = 1. 基于 .NET 6 的抖音媒体同步工具<br>2. OpenList 路径按服务端真实大小写解析,复用既有 KK 目录<br>3. 完整修复新视频目录检查的强制刷新误判,不再把 object not found 当作下载失败<br>4. OpenList 新建目录使用有限可见性轮询,兼容远端云盘刷新延迟<br>5. 提供异常时间后缀空目录扫描、复检、确认清理和失败重试<br>6. 支持本地及历史 WebDAV 记录手动接管到 OpenList<br>7. 迁移任务可识别已由同步完成的并发接管记录<br>8. 任务中心常驻存储检测入口,桌面端和移动端均可直接恢复熔断<br>9. 任务中心展示同步、迁移、存储维护状态及失败原因<br>10. 支持关注博主直播监测、开播邮件和关注视频入口<br>11. 从 0.2.x 升级保留数据库、Cookie、配置、密钥和旧媒体路径<br>12. 默认账号:douyin/douyin2026
arch = x86_64
source = thirdparty
maintainer = jianzhichu
distributor = jianzhichu
desktop_uidir = ui
desktop_applaunchname = dy.net.Application
checksum = @CHECKSUM@
+13
View File
@@ -0,0 +1,13 @@
{
".url": {
"dy.net.Application": {
"title": "抖小云",
"icon": "images/icon_{0}.png",
"type": "url",
"protocol": "",
"port": "10101",
"url": "/",
"allUsers": false
}
}
}
+9 -5
View File
@@ -4,12 +4,13 @@ using dy.net.model.response;
using dy.net.service;
using dy.net.utils;
using System.Threading.Tasks;
using dy.net.storage;
namespace dy.net.job
{
public class DouyinCollectSyncJob : DouyinBasicSyncJob
{
public DouyinCollectSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService)
public DouyinCollectSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService, MediaStorageRouter mediaStorageRouter, VideoTaskService videoTaskService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService, mediaStorageRouter, videoTaskService)
{
}
protected override VideoTypeEnum VideoType => VideoTypeEnum.dy_collects;
@@ -27,7 +28,7 @@ namespace dy.net.job
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
{
return Path.Combine(cookie.SavePath, "author");
return CombineStoragePath(GetStorageRoot(cookie, VideoType), "author");
}
protected override async Task<DouyinVideoInfoResponse> FetchVideoData(DouyinCookie cookie, string cursor, DouyinFollowed followed, DouyinCollectCate cate)
@@ -40,6 +41,8 @@ namespace dy.net.job
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate)
{
if (ActiveStorageType.IsRemote())
return BuildRemotePathPlan(cookie, item, config, followed, cate).DirectoryPath;
// 1. 简化获取博主自定义保存路径(合并空值判断)
string saveFolder = !string.IsNullOrWhiteSpace(item?.Author?.Uid)
? base.douyinCommonService.GetDouyinUpSavePath(item.Author.Uid)
@@ -61,11 +64,12 @@ namespace dy.net.job
string videoFolderName = DouyinFileNameHelper.SanitizeLinuxFileName(item?.Desc, item?.AwemeId, true);
// 4. 简化文件夹路径拼接+存在判断(核心逻辑不变)
string folder = Path.Combine(cookie.SavePath, authorFolder, videoFolderName);
var root = GetStorageRoot(cookie, VideoType);
string folder = CombineStoragePath(root, authorFolder, videoFolderName);
if (Directory.Exists(folder))
{
// 文件夹存在则拼接AwemeId(保留你的原逻辑)
folder = Path.Combine(cookie.SavePath, authorFolder, $"{videoFolderName}_{item.AwemeId}");
folder = CombineStoragePath(root, authorFolder, $"{videoFolderName}_{item.AwemeId}");
}
else
{
@@ -75,4 +79,4 @@ namespace dy.net.job
return folder;
}
}
}
}
+12 -6
View File
@@ -3,12 +3,13 @@ using dy.net.model.entity;
using dy.net.model.response;
using dy.net.service;
using dy.net.utils;
using dy.net.storage;
namespace dy.net.job
{
public class DouyinFavoritSyncJob : DouyinBasicSyncJob
{
public DouyinFavoritSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService)
public DouyinFavoritSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService, MediaStorageRouter mediaStorageRouter, VideoTaskService videoTaskService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService, mediaStorageRouter, videoTaskService)
{
}
@@ -17,12 +18,13 @@ namespace dy.net.job
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
{
return Path.Combine(cookie.FavSavePath, "author");
return CombineStoragePath(GetStorageRoot(cookie, VideoType), "author");
}
protected override async Task<List<DouyinCookie>> GetSyncCookies()
{
return await douyinCookieService.GetOpendCookiesAsync(x => !string.IsNullOrWhiteSpace(x.FavSavePath) && !string.IsNullOrWhiteSpace(x.SecUserId));
var cookies = await douyinCookieService.GetOpendCookiesAsync();
return cookies.Where(x => !string.IsNullOrWhiteSpace(GetStorageRoot(x, VideoType)) && !string.IsNullOrWhiteSpace(x.SecUserId)).ToList();
}
protected override async Task<DouyinVideoInfoResponse> FetchVideoData(DouyinCookie cookie, string cursor, DouyinFollowed followed, DouyinCollectCate cate)
@@ -45,6 +47,8 @@ namespace dy.net.job
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate)
{
if (ActiveStorageType.IsRemote())
return BuildRemotePathPlan(cookie, item, config, followed, cate).DirectoryPath;
string authorFolder;
if (string.IsNullOrWhiteSpace(item.Author?.Nickname) && string.IsNullOrWhiteSpace(item.Author?.Uid))
{
@@ -54,7 +58,9 @@ namespace dy.net.job
{
authorFolder = $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Author?.Nickname, item.Author?.Uid, true)}";
}
var folder = Path.Combine(cookie.FavSavePath, authorFolder, $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true)}");
var root = GetStorageRoot(cookie, VideoType);
var videoFolder = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true);
var folder = CombineStoragePath(root, authorFolder, videoFolder);
if (!Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
@@ -63,9 +69,9 @@ namespace dy.net.job
{
//说明文件夹存在,检查里面有没有文件,如果已经有视频文件了,说明视频标题相同,那么应该重新创建文件夹,+id
folder = Path.Combine(cookie.SavePath, authorFolder, $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true)}" + "_" + item.AwemeId);
folder = CombineStoragePath(root, authorFolder, videoFolder + "_" + item.AwemeId);
}
return folder;
}
}
}
}
+802 -285
View File
File diff suppressed because it is too large Load Diff
+11 -5
View File
@@ -3,12 +3,13 @@ using dy.net.model.entity;
using dy.net.model.response;
using dy.net.service;
using dy.net.utils;
using dy.net.storage;
namespace dy.net.job
{
public class DouyinCollectCustomSyncJob : DouyinBasicSyncJob
{
public DouyinCollectCustomSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService)
public DouyinCollectCustomSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService, MediaStorageRouter mediaStorageRouter, VideoTaskService videoTaskService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService, mediaStorageRouter, videoTaskService)
{
}
@@ -16,9 +17,14 @@ namespace dy.net.job
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate)
{
if (ActiveStorageType.IsRemote())
return BuildRemotePathPlan(cookie, item, config, followed, cate).DirectoryPath;
if (cate != null)
{
var folder = Path.Combine(cookie.SavePath, DouyinFileNameHelper.SanitizeLinuxFileName(cate.SaveFolder, "", true), DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true));
var root = GetStorageRoot(cookie, VideoType);
var category = DouyinFileNameHelper.SanitizeLinuxFileName(cate.SaveFolder, "", true);
var itemFolder = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true);
var folder = CombineStoragePath(root, category, itemFolder);
if (!Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
@@ -27,7 +33,7 @@ namespace dy.net.job
{
//说明文件夹存在,检查里面有没有文件,如果已经有视频文件了,说明视频标题相同,那么应该重新创建文件夹,+id
folder = Path.Combine(cookie.SavePath, DouyinFileNameHelper.SanitizeLinuxFileName(cate.SaveFolder, "", true), DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true)+"_"+item.AwemeId);
folder = CombineStoragePath(root, category, itemFolder + "_" + item.AwemeId);
}
return folder;
}
@@ -38,7 +44,7 @@ namespace dy.net.job
}
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
{
return Path.Combine(cookie.SavePath, "author");
return CombineStoragePath(GetStorageRoot(cookie, VideoType), "author");
}
protected override async Task<DouyinVideoInfoResponse> FetchVideoData(DouyinCookie cookie, string cursor, DouyinFollowed followed, DouyinCollectCate cate)
@@ -53,4 +59,4 @@ namespace dy.net.job
}
}
}
+13 -7
View File
@@ -4,12 +4,13 @@ using dy.net.model.entity;
using dy.net.model.response;
using dy.net.service;
using dy.net.utils;
using dy.net.storage;
namespace dy.net.job
{
public class DouyinFollowedSyncJob : DouyinBasicSyncJob
{
public DouyinFollowedSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService)
public DouyinFollowedSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService, MediaStorageRouter mediaStorageRouter, VideoTaskService videoTaskService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService, mediaStorageRouter, videoTaskService)
{
}
@@ -17,7 +18,8 @@ namespace dy.net.job
protected override async Task<List<DouyinCookie>> GetSyncCookies()
{
return await douyinCookieService.GetOpendCookiesAsync(x => !string.IsNullOrWhiteSpace(x.UpSavePath));
var cookies = await douyinCookieService.GetOpendCookiesAsync();
return cookies.Where(x => !string.IsNullOrWhiteSpace(GetStorageRoot(x, VideoType))).ToList();
}
protected override async Task<DouyinVideoInfoResponse> FetchVideoData(DouyinCookie cookie, string cursor, DouyinFollowed followed, DouyinCollectCate cate)
@@ -31,7 +33,7 @@ namespace dy.net.job
//}
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
{
return Path.Combine(cookie.UpSavePath, "author");
return CombineStoragePath(GetStorageRoot(cookie, VideoType), "author");
}
/// <summary>
@@ -45,6 +47,8 @@ namespace dy.net.job
/// <returns></returns>
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate)
{
if (ActiveStorageType.IsRemote())
return BuildRemotePathPlan(cookie, item, config, followed, cate).DirectoryPath;
#region 使UP主名称作为文件夹名称使
// 1. 优先获取有效的作者名称(遵循原有优先级:followed.UperName > item.Author.Nickname > 默认值)
var rawAuthorName = followed?.UperName ?? item?.Author?.Nickname;
@@ -53,15 +57,15 @@ namespace dy.net.job
: DouyinFileNameHelper.SanitizeLinuxFileName(rawAuthorName, "", true);
// 2. 确定最终文件夹路径(遵循原有优先级:followed.SavePath > authorName > 基础路径)
var targetFolderName = !string.IsNullOrWhiteSpace(followed?.SavePath) ? followed.SavePath : authorName;
var rootFolder = Path.Combine(cookie.UpSavePath, targetFolderName);
var rootFolder = CombineStoragePath(GetStorageRoot(cookie, VideoType), targetFolderName);
if (!Directory.Exists(rootFolder)) Directory.CreateDirectory(rootFolder);
EnsureStorageDirectory(rootFolder);
#endregion
var sampleName = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true);
var (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName);
var fileNameFolder = string.IsNullOrWhiteSpace(existingName) ? sampleName : existingName;
return Path.Combine(rootFolder, fileNameFolder);
return CombineStoragePath(rootFolder, fileNameFolder);
}
/// <summary>
/// 关注的视频,生成文件名称
@@ -73,6 +77,8 @@ namespace dy.net.job
/// <returns></returns>
protected override string GetVideoFileName(DouyinCookie cookie, Aweme item, AppConfig config, DouyinCollectCate cate)
{
if (ActiveStorageType.IsRemote())
return base.GetVideoFileName(cookie, item, config, cate);
string Format = "mp4";
string FileHash = "";
@@ -136,4 +142,4 @@ namespace dy.net.job
}
}
}
+6 -2
View File
@@ -16,6 +16,7 @@ namespace dy.net.job
private readonly DouyinHttpClientService _douyinService;
private readonly DouyinFollowService _followService;
private readonly DouyinCommonService _douyinCommonService;
private readonly DouyinLiveStatusService _douyinLiveStatusService;
// 常量定义
private const string DEFAULT_FOLLOW_COUNT = "20";
@@ -31,13 +32,15 @@ namespace dy.net.job
DouyinHttpClientService douyinService,
DouyinFollowService followService,
DouyinCommonService douyinCommonService,
DouyinCollectCateService douyinCollectCateService)
DouyinCollectCateService douyinCollectCateService,
DouyinLiveStatusService douyinLiveStatusService)
{
_dyCookieService = dyCookieService;
_douyinService = douyinService;
_followService = followService;
_douyinCommonService = douyinCommonService;
_douyinCollectCateService = douyinCollectCateService;
_douyinLiveStatusService = douyinLiveStatusService;
}
public async Task Execute(IJobExecutionContext context)
@@ -239,6 +242,7 @@ namespace dy.net.job
if (followList.Any())
{
var (add, update, succ) = await _followService.Sync(followList, cookie);
await _douyinLiveStatusService.ApplyFollowListStatusesAsync(followList, cookie);
Log.Debug($"[{cookie.UserName}][{LOG_TAG_FOLLOW}]同步完成 新增:{add} 更新:{update} 成功:{succ} 总关注数:{total},这是同步关注列表,这不是同步视频!!!");
}
await _douyinCommonService.SetConfigNotFirstRunning();
@@ -250,4 +254,4 @@ namespace dy.net.job
}
#endregion
}
}
}
+16
View File
@@ -0,0 +1,16 @@
using dy.net.service;
using Quartz;
namespace dy.net.job
{
[DisallowConcurrentExecution]
public sealed class DouyinLiveStatusJob : IJob
{
private readonly DouyinLiveStatusService _service;
public DouyinLiveStatusJob(DouyinLiveStatusService service) => _service = service;
public Task Execute(IJobExecutionContext context) =>
_service.RefreshDueAsync(context.CancellationToken);
}
}
+15 -20
View File
@@ -3,12 +3,13 @@ using dy.net.model.entity;
using dy.net.model.response;
using dy.net.service;
using dy.net.utils;
using dy.net.storage;
namespace dy.net.job
{
public class DouyinMixSyncJob : DouyinBasicSyncJob
{
public DouyinMixSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService)
public DouyinMixSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService, MediaStorageRouter mediaStorageRouter, VideoTaskService videoTaskService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService, mediaStorageRouter, videoTaskService)
{
}
@@ -23,24 +24,22 @@ namespace dy.net.job
protected override Task<List<DouyinCookie>> GetSyncCookies()
{
return douyinCookieService.GetOpendCookiesAsync(x => !string.IsNullOrWhiteSpace(x.MixPath));
return GetCookiesWithPathAsync();
}
private async Task<List<DouyinCookie>> GetCookiesWithPathAsync()
{
var cookies = await douyinCookieService.GetOpendCookiesAsync();
return cookies.Where(x => !string.IsNullOrWhiteSpace(GetStorageRoot(x, VideoType))).ToList();
}
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate)
{
if (ActiveStorageType.IsRemote())
return BuildRemotePathPlan(cookie, item, config, followed, cate).DirectoryPath;
if (cate != null)
{
if (string.IsNullOrWhiteSpace(cookie.MixPath))
{
var folder = Path.Combine(cookie.SavePath, VideoType.GetDesc(), DouyinFileNameHelper.SanitizeLinuxFileName(cate.SaveFolder, cate.Name, true));
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
return folder;
}
else
{
var folder = Path.Combine(cookie.MixPath, DouyinFileNameHelper.SanitizeLinuxFileName(cate.SaveFolder, cate.Name, true));
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
return folder;
}
var folder = CombineStoragePath(GetStorageRoot(cookie, VideoType), DouyinFileNameHelper.SanitizeLinuxFileName(cate.SaveFolder, cate.Name, true));
EnsureStorageDirectory(folder);
return folder;
}
else
{
@@ -50,11 +49,7 @@ namespace dy.net.job
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
{
if (string.IsNullOrEmpty(cookie.MixPath))
return Path.Combine(cookie.SavePath, "author");
else
return Path.Combine(cookie.MixPath, "author");
return CombineStoragePath(GetStorageRoot(cookie, VideoType), "author");
}
}
}
}
+15 -20
View File
@@ -3,12 +3,13 @@ using dy.net.model.entity;
using dy.net.model.response;
using dy.net.service;
using dy.net.utils;
using dy.net.storage;
namespace dy.net.job
{
public class DouyinSeriesSyncJob : DouyinBasicSyncJob
{
public DouyinSeriesSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService)
public DouyinSeriesSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService, MediaStorageRouter mediaStorageRouter, VideoTaskService videoTaskService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService, mediaStorageRouter, videoTaskService)
{
}
@@ -22,24 +23,22 @@ namespace dy.net.job
protected override Task<List<DouyinCookie>> GetSyncCookies()
{
return douyinCookieService.GetOpendCookiesAsync(x => !string.IsNullOrWhiteSpace(x.SeriesPath));
return GetCookiesWithPathAsync();
}
private async Task<List<DouyinCookie>> GetCookiesWithPathAsync()
{
var cookies = await douyinCookieService.GetOpendCookiesAsync();
return cookies.Where(x => !string.IsNullOrWhiteSpace(GetStorageRoot(x, VideoType))).ToList();
}
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate)
{
if (ActiveStorageType.IsRemote())
return BuildRemotePathPlan(cookie, item, config, followed, cate).DirectoryPath;
if (cate != null)
{
if (string.IsNullOrWhiteSpace(cookie.SeriesPath))
{
var folder = Path.Combine(cookie.SavePath, VideoType.GetDesc(), DouyinFileNameHelper.SanitizeLinuxFileName(cate.SaveFolder, cate.Name, true));
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
return folder;
}
else
{
var folder = Path.Combine(cookie.SeriesPath, DouyinFileNameHelper.SanitizeLinuxFileName(cate.SaveFolder, cate.Name, true));
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
return folder;
}
var folder = CombineStoragePath(GetStorageRoot(cookie, VideoType), DouyinFileNameHelper.SanitizeLinuxFileName(cate.SaveFolder, cate.Name, true));
EnsureStorageDirectory(folder);
return folder;
}
else
{
@@ -49,11 +48,7 @@ namespace dy.net.job
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
{
if (string.IsNullOrEmpty(cookie.SeriesPath))
return Path.Combine(cookie.SavePath, "author");
else
return Path.Combine(cookie.SeriesPath, "author");
return CombineStoragePath(GetStorageRoot(cookie, VideoType), "author");
}
}
}
}
+8
View File
@@ -8,8 +8,16 @@
public string Key { get; set; }
public string CookieId { get; set; }
public int Total { get; set; }
public string Name { get; set; }
public int Status { get; set; }
public int StatusCode { get; set; }
public string StatusMessage { get; set; }
}
}
+43
View File
@@ -0,0 +1,43 @@
namespace dy.net.model.dto
{
public sealed class DouyinFollowLookupRequest
{
public string CookieId { get; set; }
public string DouyinNo { get; set; }
}
public sealed class DouyinFollowLookupResult
{
public string Query { get; set; }
public List<DouyinFollowCandidate> Candidates { get; set; } = new();
}
public sealed class DouyinFollowCandidate
{
public string SecUid { get; set; }
public string UperId { get; set; }
public string DouyinNo { get; set; }
public string UniqueId { get; set; }
public string ShortId { get; set; }
public string UperName { get; set; }
public string UperAvatar { get; set; }
public string Signature { get; set; }
public string Enterprise { get; set; }
public long FollowerCount { get; set; }
public bool ExactMatch { get; set; }
public bool AlreadyExists { get; set; }
}
}
+53
View File
@@ -0,0 +1,53 @@
namespace dy.net.model.dto
{
public enum DouyinLiveStatusState
{
Unknown = 0,
Offline = 1,
Live = 2
}
public sealed class FollowLiveMonitorUpdateDto
{
public string Id { get; set; }
public bool Enabled { get; set; }
}
public sealed class FollowLiveStatusRefreshDto
{
public string Id { get; set; }
}
public sealed class FollowLiveStatusQueryDto
{
public List<string> Ids { get; set; } = new();
}
public sealed class FollowLiveStatusDto
{
public string Id { get; set; }
public bool LiveMonitorEnabled { get; set; }
public DouyinLiveStatusState LiveStatus { get; set; }
public string LiveRoomId { get; set; }
public string LiveWebRid { get; set; }
public string LiveTitle { get; set; }
public string LiveRoomUrl { get; set; }
public DateTime? LiveCheckedAt { get; set; }
public DateTime? LiveStatusUpdatedAt { get; set; }
public DateTime? LiveStartedAt { get; set; }
public string LiveCheckError { get; set; }
public bool LiveStatusStale { get; set; }
public bool LiveEmailNotificationEnabled { get; set; }
public DateTime? LastLiveNotifiedAt { get; set; }
public string LastLiveNotificationError { get; set; }
}
public sealed class DouyinLiveStatusProbe
{
public DouyinLiveStatusState Status { get; set; }
public string RoomId { get; set; }
public string WebRid { get; set; }
public string Title { get; set; }
public DateTime? StartedAt { get; set; }
}
}
+5
View File
@@ -8,6 +8,11 @@
public string? Title { get; set; }
public string? Author { get; set; }
/// <summary>
/// 博主 UID 精确筛选。用于从关注列表直达该博主的已同步视频。
/// </summary>
public string? AuthorId { get; set; }
//public string? Name { get; set; }
public string? ViedoType { get; set; }
+31
View File
@@ -0,0 +1,31 @@
namespace dy.net.model.dto
{
public enum EmailSecurityMode
{
None = 0,
StartTls = 1,
SslOnConnect = 2
}
public sealed class EmailNotificationSettingsDto
{
public bool Enabled { get; set; }
public string Host { get; set; }
public int Port { get; set; } = 465;
public EmailSecurityMode SecurityMode { get; set; } = EmailSecurityMode.SslOnConnect;
public string UserName { get; set; }
public string Password { get; set; }
public bool HasPassword { get; set; }
public string FromAddress { get; set; }
public string FromName { get; set; }
public string Recipients { get; set; }
public DateTime? LastTestedAt { get; set; }
public string LastTestMessage { get; set; }
}
public sealed class FollowLiveEmailUpdateDto
{
public string Id { get; set; }
public bool Enabled { get; set; }
}
}
+99
View File
@@ -0,0 +1,99 @@
using System.Net;
namespace dy.net.model.dto
{
public enum MediaDownloadFailureKind
{
None = 0,
SourceUnavailable = 1,
SourceForbidden = 2,
SourceUnauthorized = 3,
SourceNotFound = 4,
SourceRateLimited = 5,
SourceInvalidContent = 6,
StorageUnavailable = 7,
IntegrityCheckFailed = 8,
Cancelled = 9
}
public sealed class MediaDownloadResult
{
public bool Success { get; init; }
public string ActualSavePath { get; init; }
public MediaDownloadFailureKind FailureKind { get; init; }
public int? HttpStatusCode { get; init; }
public string SourceHost { get; init; }
public int AttemptedUrlCount { get; init; }
public DateTime? RetryAfter { get; init; }
public string Message { get; init; }
public void Deconstruct(out bool success, out string actualSavePath)
{
success = Success;
actualSavePath = ActualSavePath;
}
public Exception ToException() => FailureKind switch
{
MediaDownloadFailureKind.StorageUnavailable => new MediaStorageException(Message ?? "媒体存储写入失败。"),
MediaDownloadFailureKind.IntegrityCheckFailed => new MediaIntegrityException(Message ?? "媒体完整性校验失败。"),
MediaDownloadFailureKind.Cancelled => new OperationCanceledException(Message ?? "媒体下载已取消。"),
_ => new MediaSourceException(FailureKind, Message ?? "媒体来源不可用。", HttpStatusCode, SourceHost, RetryAfter)
};
public static MediaDownloadResult Succeeded(string path, string host, int attempted) => new()
{
Success = true,
ActualSavePath = path,
SourceHost = host,
AttemptedUrlCount = attempted
};
}
public sealed class MediaSourceException : Exception
{
public MediaSourceException(
MediaDownloadFailureKind kind,
string message,
int? statusCode = null,
string sourceHost = null,
DateTime? retryAfter = null,
Exception innerException = null) : base(message, innerException)
{
FailureKind = kind;
StatusCode = statusCode;
SourceHost = sourceHost;
RetryAfter = retryAfter;
}
public MediaDownloadFailureKind FailureKind { get; }
public int? StatusCode { get; }
public string SourceHost { get; }
public DateTime? RetryAfter { get; }
}
public sealed class MediaStorageException : IOException
{
public MediaStorageException(string message, Exception innerException = null) : base(message, innerException) { }
}
public sealed class MediaIntegrityException : IOException
{
public MediaIntegrityException(string message, Exception innerException = null) : base(message, innerException) { }
}
public enum SourceFailureDisposition
{
Continue = 0,
WaitForSource = 1,
RequiresAuthorization = 2
}
public sealed class SourceAccessDecision
{
public bool Allowed { get; init; }
public bool IsProbe { get; init; }
public string Message { get; init; }
public DateTime? RetryAt { get; init; }
}
}
@@ -0,0 +1,81 @@
using dy.net.model.entity;
namespace dy.net.model.dto
{
public enum OpenListDirectoryRepairStatus
{
Queued = 0,
Scanning = 1,
AwaitingConfirmation = 2,
Cleaning = 3,
Completed = 4,
PartiallyFailed = 5,
Cancelled = 6,
Paused = 7
}
public enum OpenListDirectoryRepairItemStatus
{
Pending = 0,
Inspecting = 1,
EmptyConfirmed = 2,
SkippedNonEmpty = 3,
Deleting = 4,
Deleted = 5,
Failed = 6,
Missing = 7
}
public sealed class OpenListDirectoryRepairPreflightRequest
{
public string LogicalPath { get; set; } = "/collect/Kk";
}
public sealed class OpenListDirectoryRepairPreflightResult
{
public bool CanStart { get; set; }
public string RequestedPath { get; set; }
public string CanonicalPath { get; set; }
public string CandidatePattern { get; set; }
public string ConfigurationFingerprint { get; set; }
public int CandidateCount { get; set; }
public List<string> Errors { get; set; } = new();
public List<string> Warnings { get; set; } = new();
}
public sealed class CreateOpenListDirectoryRepairRequest
{
public string LogicalPath { get; set; } = "/collect/Kk";
public string ConfigurationFingerprint { get; set; }
}
public sealed class ConfirmOpenListDirectoryRepairRequest
{
public string ConfirmationToken { get; set; }
}
public sealed class OpenListDirectoryRepairTaskDetail
{
public OpenListDirectoryRepairTask Task { get; set; }
public string StatusText { get; set; }
public string ConfirmationToken { get; set; }
public bool CanConfirmCleanup { get; set; }
public bool CanCancel { get; set; }
public bool CanResume { get; set; }
public bool CanRetryFailed { get; set; }
}
public sealed class OpenListDirectoryRepairItemPageRequest
{
public int PageIndex { get; set; } = 1;
public int PageSize { get; set; } = 20;
public OpenListDirectoryRepairItemStatus? Status { get; set; }
public string Keyword { get; set; }
}
public sealed class OpenListDirectoryRepairItemPage
{
public int TotalCount { get; set; }
public List<OpenListDirectoryRepairItem> Items { get; set; } = new();
}
}
+47
View File
@@ -0,0 +1,47 @@
namespace dy.net.model.dto
{
public class OpenListSettingsDto
{
public StorageType StorageType { get; set; }
public string Endpoint { get; set; }
public string BasePath { get; set; }
public string LocalStagingPath { get; set; }
public string SourcePath { get; set; }
public string UserName { get; set; }
public string Password { get; set; }
public bool HasPassword { get; set; }
public DateTime? LastTestedAt { get; set; }
public string LastTestMessage { get; set; }
public bool RequiresCutover { get; set; }
public int LegacyWebDavRecordCount { get; set; }
public bool SuggestedFromLegacy { get; set; }
}
public class OpenListTestRequest
{
public string Endpoint { get; set; }
public string BasePath { get; set; }
public string LocalStagingPath { get; set; }
public string SourcePath { get; set; }
public string UserName { get; set; }
public string Password { get; set; }
}
public sealed class OpenListDirectoryRequest : OpenListTestRequest
{
public string Path { get; set; }
}
public sealed class OpenListDirectoryItemDto
{
public string Name { get; set; }
public string Path { get; set; }
}
public sealed class OpenListDirectoryListDto
{
public string Path { get; set; }
public bool CanWrite { get; set; }
public List<OpenListDirectoryItemDto> Directories { get; set; } = new();
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace dy.net.model.dto
{
public enum OpenListTransferStatus
{
Queued = 0,
WaitingForSource = 1,
Copying = 2,
Verifying = 3,
Promoting = 4,
WaitingRetry = 5,
Succeeded = 6,
Failed = 7,
Cancelled = 8
}
}
+144
View File
@@ -0,0 +1,144 @@
using dy.net.model.entity;
namespace dy.net.model.dto
{
public enum StorageMigrationTaskStatus
{
Queued = 0,
Running = 1,
Paused = 2,
Completed = 3,
PartiallyFailed = 4,
Cancelled = 5,
Cleaning = 6,
Cleaned = 7,
RolledBack = 8
}
public enum StorageMigrationItemStage
{
Pending = 0,
Uploading = 1,
Verifying = 2,
Committing = 3,
Succeeded = 4,
SucceededWithWarnings = 5,
Failed = 6,
Cleaned = 7,
RolledBack = 8,
RecordRemoved = 9
}
public sealed class StorageMigrationPreflightRequest
{
public bool VerifyCapabilities { get; set; } = true;
}
public sealed class StorageMigrationPreflightResult
{
public bool CanStart { get; set; }
public string ConfigurationFingerprint { get; set; }
public int RecordCount { get; set; }
public int LocalRecordCount { get; set; }
public int LegacyWebDavRecordCount { get; set; }
public int AdoptableCount { get; set; }
public int TransferRequiredCount { get; set; }
public int ReadableFileCount { get; set; }
public int MissingFileCount { get; set; }
public long TotalBytes { get; set; }
public int InvalidCookieCount { get; set; }
public int MissingTargetPathCount { get; set; }
public int ConflictCount { get; set; }
public string Capacity { get; set; } = "unknown";
public List<string> Errors { get; set; } = new();
public List<string> Warnings { get; set; } = new();
}
public sealed class CreateStorageMigrationRequest
{
public int Concurrency { get; set; } = 1;
public string ConfigurationFingerprint { get; set; }
}
public sealed class StorageMigrationItemPageRequest
{
public int PageIndex { get; set; } = 1;
public int PageSize { get; set; } = 20;
public StorageMigrationItemStage? Stage { get; set; }
}
public sealed class StorageMigrationTaskDetail
{
public StorageMigrationTask Task { get; set; }
public string StatusText { get; set; }
public string CurrentFile { get; set; }
public bool CanPause { get; set; }
public bool CanResume { get; set; }
public bool CanCancel { get; set; }
public bool CanRetryFailed { get; set; }
public bool CanCleanup { get; set; }
public bool CanRollback { get; set; }
public bool CanArchive { get; set; }
}
public sealed class StorageRecordInventory
{
public StorageType CurrentStorageType { get; set; }
public int TotalRecordCount { get; set; }
public int LocalRecordCount { get; set; }
public int WebDavRecordCount { get; set; }
public int OpenListRecordCount { get; set; }
public int CurrentStorageRecordCount { get; set; }
public int OtherStorageRecordCount { get; set; }
public long LocalDeclaredBytes { get; set; }
public long WebDavDeclaredBytes { get; set; }
public long OpenListDeclaredBytes { get; set; }
public bool AllRecordsOnCurrentStorage => TotalRecordCount == CurrentStorageRecordCount;
}
public sealed class StorageMigrationArchiveResult
{
public int ArchivedCount { get; set; }
public int RequiresCleanupCount { get; set; }
public string Message { get; set; }
}
public sealed class StorageMigrationItemPage
{
public int TotalCount { get; set; }
public List<StorageMigrationItem> Items { get; set; } = new();
}
public sealed class FailedMigrationRecordPreview
{
public bool CanExecute { get; set; }
public int ActiveMigrationCount { get; set; }
public int FailedItemCount { get; set; }
public int DistinctVideoCount { get; set; }
public int EligibleRecordCount { get; set; }
public int AlreadyMissingCount { get; set; }
public int ChangedRecordCount { get; set; }
public int PermanentlyExcludedCount { get; set; }
public int InvalidSnapshotCount { get; set; }
public string ConfirmationToken { get; set; }
public List<string> Errors { get; set; } = new();
public List<string> Warnings { get; set; } = new();
}
public sealed class RemoveFailedMigrationRecordsRequest
{
public string ConfirmationToken { get; set; }
}
public sealed class RemoveFailedMigrationRecordsResult
{
public int DeletedRecordCount { get; set; }
public int AlreadyMissingCount { get; set; }
public int RemovedItemCount { get; set; }
public int AffectedTaskCount { get; set; }
public int SkippedChangedCount { get; set; }
public int SkippedExcludedCount { get; set; }
public int InvalidSnapshotCount { get; set; }
public string Message { get; set; }
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace dy.net.model.dto
{
/// <summary>
/// 媒体实际保存的位置。数值 0 必须保留为本地存储,以兼容历史数据库记录。
/// </summary>
public enum StorageType
{
Local = 0,
WebDav = 1,
OpenList = 2
}
/// <summary>
/// StorageType 扩展方法。
/// </summary>
public static class StorageTypeExtensions
{
/// <summary>
/// 判断是否为远程存储(WebDAV 或 OpenList),与本地存储相对。
/// </summary>
public static bool IsRemote(this StorageType type) =>
type is StorageType.WebDav or StorageType.OpenList;
}
}
+233
View File
@@ -0,0 +1,233 @@
using dy.net.model.entity;
namespace dy.net.model.dto
{
public enum VideoTaskType
{
Sync = 0,
Redownload = 1,
ExclusionRestore = 2,
StorageMigration = 3,
StorageMaintenance = 4
}
public enum VideoTaskTrigger
{
Scheduled = 0,
Manual = 1,
UserAction = 2,
UpgradeRecovery = 3
}
public enum VideoTaskStatus
{
Queued = 0,
Running = 1,
WaitingForStorage = 2,
Completed = 3,
PartiallyFailed = 4,
Failed = 5,
Interrupted = 6,
Paused = 7,
Cancelled = 8,
Cleaning = 9,
Cleaned = 10,
RolledBack = 11,
WaitingForSource = 12,
Scanning = 13,
AwaitingConfirmation = 14
}
public enum VideoTaskItemStage
{
Pending = 0,
Downloading = 1,
Verifying = 2,
Committing = 3,
Succeeded = 4,
SucceededWithWarnings = 5,
Failed = 6,
Skipped = 7,
WaitingForStorage = 8,
Cleaned = 9,
RolledBack = 10,
Cancelled = 11,
WaitingForSource = 12,
RecordRemoved = 13,
Inspecting = 14,
EmptyConfirmed = 15,
SkippedNonEmpty = 16
}
public enum VideoTaskErrorType
{
None = 0,
SourceUnavailable = 1,
CookieInvalid = 2,
StorageUnavailable = 3,
IntegrityCheckFailed = 4,
DatabaseCommitFailed = 5,
Interrupted = 6,
ConfigurationChanged = 7,
Excluded = 8,
Unknown = 9,
SourceForbidden = 10,
SourceRateLimited = 11
}
public enum VideoTaskSkipReason
{
None = 0,
AlreadyExists = 1,
Deduplicated = 2,
PermanentlyExcluded = 3,
ConfigurationExcluded = 4,
NoMediaSource = 5
}
public enum MediaStorageHealthStatus
{
Healthy = 0,
Unavailable = 1
}
public sealed class VideoTaskPageRequest : PageRequestDto
{
public VideoTaskType? Type { get; set; }
public VideoTaskStatus? Status { get; set; }
public VideoTaskTrigger? Trigger { get; set; }
public string Keyword { get; set; }
public DateTime? From { get; set; }
public DateTime? To { get; set; }
}
public sealed class VideoTaskItemPageRequest : PageRequestDto
{
public VideoTaskItemStage? Stage { get; set; }
public VideoTaskErrorType? ErrorType { get; set; }
public string Keyword { get; set; }
public bool IncludeSkipped { get; set; }
}
public sealed class VideoTaskListPage
{
public int TotalCount { get; set; }
public List<UnifiedVideoTaskDto> Items { get; set; } = new();
}
public sealed class VideoTaskItemPage
{
public int TotalCount { get; set; }
public List<UnifiedVideoTaskItemDto> Items { get; set; } = new();
}
public sealed class UnifiedVideoTaskDto
{
public string Id { get; set; }
public VideoTaskType Type { get; set; }
public VideoTaskTrigger Trigger { get; set; }
public VideoTaskStatus Status { get; set; }
public string Title { get; set; }
public VideoTypeEnum? VideoType { get; set; }
public StorageType StorageType { get; set; }
public int TotalCount { get; set; }
public int PendingCount { get; set; }
public int RunningCount { get; set; }
public int SuccessCount { get; set; }
public int WarningCount { get; set; }
public int FailedCount { get; set; }
public int SkippedCount { get; set; }
public int RemovedCount { get; set; }
public string CurrentFile { get; set; }
public string ErrorMessage { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public List<string> AvailableActions { get; set; } = new();
}
public sealed class UnifiedVideoTaskItemDto
{
public string Id { get; set; }
public string TaskId { get; set; }
public string VideoId { get; set; }
public string AwemeId { get; set; }
public string CookieName { get; set; }
public VideoTypeEnum? VideoType { get; set; }
public string VideoTitle { get; set; }
public string Author { get; set; }
public string TargetPath { get; set; }
public VideoTaskItemStage Stage { get; set; }
public VideoTaskErrorType ErrorType { get; set; }
public VideoTaskSkipReason SkipReason { get; set; }
public int Attempts { get; set; }
public long ExpectedLength { get; set; }
public long ActualLength { get; set; }
public string ErrorMessage { get; set; }
public string WarningMessage { get; set; }
public string SourceHost { get; set; }
public int? HttpStatusCode { get; set; }
public DateTime? RetryAfter { get; set; }
public string ExclusionId { get; set; }
public DateTime? ExclusionReleasedAt { get; set; }
public string RelatedTaskId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public bool CanRetry { get; set; }
public bool CanRetryCleanup { get; set; }
public string CleanupError { get; set; }
public bool CanUnexclude { get; set; }
}
public sealed class VideoTaskSummaryDto
{
public int Queued { get; set; }
public int Running { get; set; }
public int WaitingForStorage { get; set; }
public int WaitingForSource { get; set; }
public int Failed { get; set; }
public int Completed { get; set; }
public MediaStorageHealth StorageHealth { get; set; }
public List<DouyinSourceHealthDto> SourceHealth { get; set; } = new();
}
public sealed class DouyinSourceHealthDto
{
public string CookieId { get; set; }
public string CookieName { get; set; }
public int ConsecutiveForbidden { get; set; }
public DateTime? CooldownUntil { get; set; }
public bool RequiresAuthorization { get; set; }
public bool ProbePending { get; set; }
public int? LastStatusCode { get; set; }
public string LastError { get; set; }
public DateTime? UpdatedAt { get; set; }
}
public sealed class UnexcludeVideosRequest
{
public List<string> Ids { get; set; } = new();
public bool CreateDownloadTask { get; set; }
}
public sealed class UnexcludeVideosResult
{
public int ReleasedCount { get; set; }
public int QueuedCount { get; set; }
public string TaskId { get; set; }
public List<string> CannotQueueIds { get; set; } = new();
public string Message { get; set; }
}
public sealed class VideoExclusionPageRequest : PageRequestDto
{
public string Keyword { get; set; }
}
public sealed class VideoExclusionPage
{
public int TotalCount { get; set; }
public List<DouyinVideoDelete> Items { get; set; } = new();
}
}
+5
View File
@@ -47,6 +47,11 @@
/// </summary>
dy_followuser_once = 2000,
/// <summary>
/// 独立直播状态监测任务,不属于视频下载任务。
/// </summary>
dy_live_monitor = 3000,
}
}
+50
View File
@@ -0,0 +1,50 @@
namespace dy.net.model.dto
{
public class WebDavSettingsDto
{
public StorageType StorageType { get; set; }
public string Endpoint { get; set; }
public string BasePath { get; set; }
public string UserName { get; set; }
public string Password { get; set; }
public bool HasPassword { get; set; }
public bool AllowInvalidCertificate { get; set; }
public DateTime? LastTestedAt { get; set; }
public string LastTestMessage { get; set; }
}
public class WebDavTestRequest
{
public string Endpoint { get; set; }
public string BasePath { get; set; }
public string UserName { get; set; }
public string Password { get; set; }
public bool AllowInvalidCertificate { get; set; }
}
/// <summary>统一的存储连接测试请求;新远端存储仅支持 OpenList。</summary>
public class StorageTestRequest
{
public StorageType StorageType { get; set; }
public string Endpoint { get; set; }
public string BasePath { get; set; }
public string LocalStagingPath { get; set; }
public string SourcePath { get; set; }
public string UserName { get; set; }
public string Password { get; set; }
public bool AllowInvalidCertificate { get; set; }
}
/// <summary>统一的存储配置保存请求;WebDAV 字段仅为数据库升级兼容保留。</summary>
public class StorageSettingsDto
{
public StorageType StorageType { get; set; }
public string Endpoint { get; set; }
public string BasePath { get; set; }
public string LocalStagingPath { get; set; }
public string SourcePath { get; set; }
public string UserName { get; set; }
public string Password { get; set; }
public bool AllowInvalidCertificate { get; set; }
}
}
+5
View File
@@ -24,6 +24,11 @@ namespace dy.net.model.entity
/// </summary>
public int BatchCount { get; set; } = 18;
/// <summary>
/// 新同步任务使用的全局存储。历史记录始终使用自身保存的存储类型。
/// </summary>
public dy.net.model.dto.StorageType StorageType { get; set; } = dy.net.model.dto.StorageType.Local;
/// <summary>
/// 博主视频是否 直接用标题做文件名
/// </summary>
+46
View File
@@ -145,5 +145,51 @@ namespace dy.net.model.entity
/// </summary>
[SugarColumn(Length = 500, IsNullable = true)]
public string SeriesPath { get; set; }
[SugarColumn(Length = 1000, IsNullable = true)]
public string WebDavCollectPath { get; set; }
[SugarColumn(Length = 1000, IsNullable = true)]
public string WebDavFavoritePath { get; set; }
[SugarColumn(Length = 1000, IsNullable = true)]
public string WebDavFollowPath { get; set; }
[SugarColumn(Length = 1000, IsNullable = true)]
public string WebDavMixPath { get; set; }
[SugarColumn(Length = 1000, IsNullable = true)]
public string WebDavSeriesPath { get; set; }
public int ConsecutiveSourceForbidden { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? SourceCooldownUntil { get; set; }
public bool SourceRequiresAuthorization { get; set; }
public bool SourceProbePending { get; set; }
public bool SourceProbeInProgress { get; set; }
[SugarColumn(IsNullable = true)]
public int? LastSourceStatusCode { get; set; }
[SugarColumn(Length = 1000, IsNullable = true)]
public string LastSourceError { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? SourceHealthUpdatedAt { get; set; }
public int ConsecutiveLiveCheckFailures { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LiveCheckCooldownUntil { get; set; }
[SugarColumn(Length = 1000, IsNullable = true)]
public string LastLiveCheckError { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LiveCheckHealthUpdatedAt { get; set; }
}
}
+54 -1
View File
@@ -1,4 +1,5 @@
using SqlSugar;
using dy.net.model.dto;
using SqlSugar;
namespace dy.net.model.entity
{
@@ -72,5 +73,57 @@ namespace dy.net.model.entity
[SugarColumn(IsNullable = true, Length = 100)]
public string DouyinNo { get; set; }
/// <summary>
/// 直播状态监测独立开关,不影响作品同步开关。
/// </summary>
public bool LiveMonitorEnabled { get; set; }
public DouyinLiveStatusState LiveStatus { get; set; }
[SugarColumn(IsNullable = true, Length = 100)]
public string LiveRoomId { get; set; }
[SugarColumn(IsNullable = true, Length = 100)]
public string LiveWebRid { get; set; }
[SugarColumn(IsNullable = true, Length = 500)]
public string LiveTitle { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LiveCheckedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LiveStatusUpdatedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LiveStartedAt { get; set; }
[SugarColumn(IsNullable = true, Length = 1000)]
public string LiveCheckError { get; set; }
public bool LiveEmailNotificationEnabled { get; set; }
[SugarColumn(IsNullable = true, Length = 200)]
public string LastLiveNotificationKey { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LastLiveNotificationAttemptAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LastLiveNotifiedAt { get; set; }
[SugarColumn(IsNullable = true, Length = 1000)]
public string LastLiveNotificationError { get; set; }
[SugarColumn(IsIgnore = true)]
public bool LiveStatusStale => LiveMonitorEnabled &&
(!LiveStatusUpdatedAt.HasValue || DateTime.Now - LiveStatusUpdatedAt.Value > TimeSpan.FromMinutes(10) ||
!string.IsNullOrWhiteSpace(LiveCheckError));
[SugarColumn(IsIgnore = true)]
public string LiveRoomUrl => LiveStatus == DouyinLiveStatusState.Live && !string.IsNullOrWhiteSpace(LiveWebRid)
? "https://live.douyin.com/" + Uri.EscapeDataString(LiveWebRid)
: null;
}
}
+9
View File
@@ -25,6 +25,8 @@ namespace dy.net.model.entity
public string SavePath { get; set; }
public string CookieId { get; set; }
[SugarColumn(IsNullable = true, Length = 100)]
public string VideoRecordId { get; set; }
/// <summary>
/// 0-未下载 1-下载成功 2-下载失败
/// </summary>
@@ -33,5 +35,12 @@ namespace dy.net.model.entity
public DateTime CreateTime { get; set; }
public DateTime DownTime { get; set; }
public DateTime UpdateTime { get; set; }
public int Attempts { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string ErrorMessage { get; set; }
[SugarColumn(Length = 50, IsNullable = true)]
public string TaskId { get; set; }
[SugarColumn(Length = 50, IsNullable = true)]
public string TaskItemId { get; set; }
}
}
+24
View File
@@ -1,4 +1,5 @@
using dy.net.model.dto;
using MediaStorageType = dy.net.model.dto.StorageType;
using dy.net.utils;
using SqlSugar;
@@ -77,6 +78,11 @@ namespace dy.net.model.entity
/// </summary>
[SugarColumn(Length = 2000, IsNullable = true)]
public string VideoSavePath { get; set; }
/// <summary>
/// 该记录实际所在存储。旧数据默认值 0 表示本地文件。
/// </summary>
public MediaStorageType StorageType { get; set; } = MediaStorageType.Local;
/// <summary>
/// 视频封面地址
/// </summary>
@@ -171,5 +177,23 @@ namespace dy.net.model.entity
/// </summary>
[SugarColumn(Length = 200, IsNullable = true)]
public string CateXId { get; set; }
[SugarColumn(IsIgnore = true)]
public string PendingStoragePath { get; set; }
[SugarColumn(IsIgnore = true)]
public string TemporaryDirectory { get; set; }
[SugarColumn(IsIgnore = true)]
public string TaskItemId { get; set; }
[SugarColumn(IsIgnore = true)]
public string TaskWarning { get; set; }
/// <summary>
/// 普通同步将旧存储记录安全替换到当前存储时使用的旧记录快照,不写入数据库。
/// </summary>
[SugarColumn(IsIgnore = true)]
public DouyinVideo SupersededStorageSnapshot { get; set; }
}
}
+20 -1
View File
@@ -1,4 +1,5 @@
using SqlSugar;
using dy.net.model.dto;
using SqlSugar;
namespace dy.net.model.entity
{
@@ -25,5 +26,23 @@ namespace dy.net.model.entity
public string VideoTitle { get; set; }
[SugarColumn(IsNullable = true, Length = 1000)]
public string VideoSavePath { get; set; }
[SugarColumn(IsNullable = true, Length = 200)]
public string CookieId { get; set; }
[SugarColumn(IsNullable = true)]
public VideoTypeEnum? VideoType { get; set; }
[SugarColumn(IsNullable = true, Length = 200)]
public string AuthorId { get; set; }
[SugarColumn(IsNullable = true, Length = 500)]
public string Author { get; set; }
[SugarColumn(IsNullable = true, Length = 2000)]
public string VideoUrl { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string RestoreSnapshotJson { get; set; }
}
}
+42
View File
@@ -0,0 +1,42 @@
using dy.net.model.dto;
using SqlSugar;
namespace dy.net.model.entity
{
[SugarTable(TableName = "dy_email_notification_settings")]
public sealed class EmailNotificationSettings
{
[SugarColumn(IsPrimaryKey = true, Length = 50)]
public string Id { get; set; } = "default";
public bool Enabled { get; set; }
[SugarColumn(Length = 500, IsNullable = true)]
public string Host { get; set; }
public int Port { get; set; } = 465;
public EmailSecurityMode SecurityMode { get; set; } = EmailSecurityMode.SslOnConnect;
[SugarColumn(Length = 500, IsNullable = true)]
public string UserName { get; set; }
[SugarColumn(Length = -1, IsNullable = true)]
public string ProtectedPassword { get; set; }
[SugarColumn(Length = 500, IsNullable = true)]
public string FromAddress { get; set; }
[SugarColumn(Length = 200, IsNullable = true)]
public string FromName { get; set; }
[SugarColumn(Length = 4000, IsNullable = true)]
public string Recipients { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LastTestedAt { get; set; }
[SugarColumn(Length = 1000, IsNullable = true)]
public string LastTestMessage { get; set; }
}
}
+25
View File
@@ -0,0 +1,25 @@
using dy.net.model.dto;
using SqlSugar;
using MediaStorageType = dy.net.model.dto.StorageType;
namespace dy.net.model.entity
{
[SugarTable(TableName = "media_storage_health")]
public class MediaStorageHealth
{
[SugarColumn(IsPrimaryKey = true, Length = 50)]
public string Id { get; set; } = "default";
public MediaStorageHealthStatus Status { get; set; }
public MediaStorageType StorageType { get; set; }
[SugarColumn(Length = 128, IsNullable = true)]
public string ConfigurationFingerprint { get; set; }
public int ConsecutiveFailures { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string LastError { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LastFailedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LastProbedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
}
@@ -0,0 +1,28 @@
using dy.net.model.dto;
using SqlSugar;
namespace dy.net.model.entity
{
[SugarTable(TableName = "openlist_directory_repair_item")]
[SugarIndex("idx_openlist_repair_item_task_status", nameof(TaskId), OrderByType.Asc,
nameof(Status), OrderByType.Asc)]
public sealed class OpenListDirectoryRepairItem
{
[SugarColumn(IsPrimaryKey = true, Length = 50)]
public string Id { get; set; }
[SugarColumn(Length = 50)]
public string TaskId { get; set; }
[SugarColumn(Length = 500)]
public string DirectoryName { get; set; }
[SugarColumn(Length = 2000)]
public string ActualPath { get; set; }
public OpenListDirectoryRepairItemStatus Status { get; set; }
public int Attempts { get; set; }
public int EntryCount { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string ErrorMessage { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
[SugarColumn(IsNullable = true)] public DateTime? CompletedAt { get; set; }
}
}
@@ -0,0 +1,42 @@
using dy.net.model.dto;
using SqlSugar;
namespace dy.net.model.entity
{
[SugarTable(TableName = "openlist_directory_repair_task")]
[SugarIndex("idx_openlist_repair_created", nameof(CreatedAt), OrderByType.Desc)]
public sealed class OpenListDirectoryRepairTask
{
[SugarColumn(IsPrimaryKey = true, Length = 50)]
public string Id { get; set; }
public OpenListDirectoryRepairStatus Status { get; set; }
[SugarColumn(Length = 128)]
public string ConfigurationFingerprint { get; set; }
[SugarColumn(Length = 2000)]
public string RequestedPath { get; set; }
[SugarColumn(Length = 2000)]
public string CanonicalPath { get; set; }
[SugarColumn(Length = 2000)]
public string ActualParentPath { get; set; }
[SugarColumn(Length = 500)]
public string RequestedName { get; set; }
[SugarColumn(Length = 1000)]
public string CandidatePattern { get; set; }
public int TotalCount { get; set; }
public int PendingCount { get; set; }
public int EmptyCount { get; set; }
public int DeletedCount { get; set; }
public int SkippedCount { get; set; }
public int FailedCount { get; set; }
public int MissingCount { get; set; }
[SugarColumn(Length = 2000, IsNullable = true)]
public string CurrentDirectory { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string ErrorMessage { get; set; }
public DateTime CreatedAt { get; set; }
[SugarColumn(IsNullable = true)] public DateTime? StartedAt { get; set; }
public DateTime UpdatedAt { get; set; }
[SugarColumn(IsNullable = true)] public DateTime? ScanCompletedAt { get; set; }
[SugarColumn(IsNullable = true)] public DateTime? CompletedAt { get; set; }
}
}
+41
View File
@@ -0,0 +1,41 @@
using SqlSugar;
namespace dy.net.model.entity
{
[SugarTable(TableName = "dy_openlist_settings")]
public class OpenListSettings
{
[SugarColumn(IsPrimaryKey = true, Length = 50)]
public string Id { get; set; } = "default";
[SugarColumn(Length = 1000, IsNullable = true)]
public string Endpoint { get; set; }
[SugarColumn(Length = 1000, IsNullable = true)]
public string BasePath { get; set; } = "/dysync";
/// <summary>本应用写入中转文件的本地绝对路径。</summary>
[SugarColumn(Length = 1000, IsNullable = true)]
public string LocalStagingPath { get; set; } = string.Empty;
/// <summary>OpenList 中映射到 LocalStagingPath 的源挂载根目录。</summary>
[SugarColumn(Length = 1000, IsNullable = true)]
public string SourcePath { get; set; } = string.Empty;
/// <summary>旧草稿字段,仅用于平滑升级;新版本统一使用 BasePath 作为目标目录。</summary>
[SugarColumn(Length = 1000, IsNullable = true)]
public string DestinationPath { get; set; } = string.Empty;
[SugarColumn(Length = 500, IsNullable = true)]
public string UserName { get; set; }
[SugarColumn(Length = -1, IsNullable = true)]
public string ProtectedPassword { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LastTestedAt { get; set; }
[SugarColumn(Length = 1000, IsNullable = true)]
public string LastTestMessage { get; set; }
}
}
+50
View File
@@ -0,0 +1,50 @@
using dy.net.model.dto;
using SqlSugar;
namespace dy.net.model.entity
{
[SugarTable(TableName = "openlist_transfer_job")]
[SugarIndex("idx_openlist_transfer_target", nameof(ConfigurationFingerprint), OrderByType.Asc,
nameof(LogicalTargetPath), OrderByType.Asc, nameof(Status), OrderByType.Asc)]
public sealed class OpenListTransferJob
{
[SugarColumn(IsPrimaryKey = true, Length = 50)]
public string Id { get; set; }
public OpenListTransferStatus Status { get; set; }
[SugarColumn(Length = 128)]
public string ConfigurationFingerprint { get; set; }
[SugarColumn(Length = 2000)]
public string LogicalTargetPath { get; set; }
[SugarColumn(Length = 2000)]
public string ActualTargetPath { get; set; }
[SugarColumn(Length = 2000)]
public string LocalSourcePath { get; set; }
[SugarColumn(Length = 2000)]
public string OpenListSourcePath { get; set; }
[SugarColumn(Length = 2000)]
public string StagedTargetPath { get; set; }
[SugarColumn(Length = 2000, IsNullable = true)]
public string BackupTargetPath { get; set; }
public long ExpectedLength { get; set; }
[SugarColumn(Length = 200, IsNullable = true)]
public string ExternalTaskId { get; set; }
public double ExternalProgress { get; set; }
public int Attempts { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? ExternalTaskStartedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LastProgressAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? NextAttemptAt { get; set; }
[SugarColumn(Length = 50, IsNullable = true)]
public string LeaseOwner { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LeaseUntil { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string ErrorMessage { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? CompletedAt { get; set; }
}
}
+46
View File
@@ -0,0 +1,46 @@
using dy.net.model.dto;
using SqlSugar;
using AppStorageType = dy.net.model.dto.StorageType;
namespace dy.net.model.entity
{
[SugarTable(TableName = "storage_migration_item")]
[SugarIndex("idx_migration_item_task_stage", nameof(TaskId), OrderByType.Asc, nameof(Stage), OrderByType.Asc)]
public class StorageMigrationItem
{
[SugarColumn(IsPrimaryKey = true, Length = 50)]
public string Id { get; set; }
[SugarColumn(Length = 50)]
public string TaskId { get; set; }
[SugarColumn(Length = 200)]
public string VideoId { get; set; }
[SugarColumn(IsNullable = true)]
public AppStorageType? SourceStorageType { get; set; }
public bool AdoptExistingTarget { get; set; }
public StorageMigrationItemStage Stage { get; set; }
public int Attempts { get; set; }
public long ExpectedLength { get; set; }
[SugarColumn(Length = 2000, IsNullable = true)]
public string OldVideoPath { get; set; }
[SugarColumn(Length = 2000, IsNullable = true)]
public string TargetVideoPath { get; set; }
[SugarColumn(Length = 2000, IsNullable = true)]
public string TargetCoverPath { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1)]
public string OldSnapshotJson { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string AttachmentPlanJson { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string UploadedPathsJson { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string OwnedRemotePathsJson { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string ErrorMessage { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string WarningMessage { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? CompletedAt { get; set; }
}
}
+46
View File
@@ -0,0 +1,46 @@
using dy.net.model.dto;
using SqlSugar;
using AppStorageType = dy.net.model.dto.StorageType;
namespace dy.net.model.entity
{
[SugarTable(TableName = "storage_migration_task")]
public class StorageMigrationTask
{
[SugarColumn(IsPrimaryKey = true, Length = 50)]
public string Id { get; set; }
public StorageMigrationTaskStatus Status { get; set; }
[SugarColumn(IsNullable = true)]
public AppStorageType? TargetStorageType { get; set; }
public int Concurrency { get; set; } = 1;
public int TotalCount { get; set; }
public int PendingCount { get; set; }
public int SuccessCount { get; set; }
public int WarningCount { get; set; }
public int FailedCount { get; set; }
public int RemovedCount { get; set; }
public long TotalBytes { get; set; }
[SugarColumn(Length = 128)]
public string ConfigurationFingerprint { get; set; }
[SugarColumn(Length = 200, IsNullable = true)]
public string CurrentVideoId { get; set; }
[SugarColumn(Length = 2000, IsNullable = true)]
public string CurrentFile { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string ErrorMessage { get; set; }
public DateTime CreatedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? StartedAt { get; set; }
public DateTime UpdatedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? CompletedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? CleanedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? RolledBackAt { get; set; }
/// <summary>
/// 已归档的终态任务不再出现在系统配置和任务中心,但保留条目用于审计及失败记录处理。
/// </summary>
public bool IsArchived { get; set; }
}
}
+42
View File
@@ -0,0 +1,42 @@
using dy.net.model.dto;
using SqlSugar;
using MediaStorageType = dy.net.model.dto.StorageType;
namespace dy.net.model.entity
{
[SugarTable(TableName = "video_download_task")]
[SugarIndex("idx_video_task_created", nameof(CreatedAt), OrderByType.Desc)]
public class VideoDownloadTask
{
[SugarColumn(IsPrimaryKey = true, Length = 50)]
public string Id { get; set; }
public VideoTaskType Type { get; set; }
public VideoTaskTrigger Trigger { get; set; }
public VideoTaskStatus Status { get; set; }
[SugarColumn(IsNullable = true)]
public VideoTypeEnum? VideoType { get; set; }
public MediaStorageType StorageType { get; set; }
[SugarColumn(Length = 500)]
public string Title { get; set; }
[SugarColumn(Length = 128, IsNullable = true)]
public string StorageFingerprint { get; set; }
public int TotalCount { get; set; }
public int PendingCount { get; set; }
public int RunningCount { get; set; }
public int SuccessCount { get; set; }
public int WarningCount { get; set; }
public int FailedCount { get; set; }
public int SkippedCount { get; set; }
public bool IsArchived { get; set; }
[SugarColumn(Length = 2000, IsNullable = true)]
public string CurrentFile { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string ErrorMessage { get; set; }
public DateTime CreatedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? StartedAt { get; set; }
public DateTime UpdatedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? CompletedAt { get; set; }
}
}
+65
View File
@@ -0,0 +1,65 @@
using dy.net.model.dto;
using SqlSugar;
namespace dy.net.model.entity
{
[SugarTable(TableName = "video_download_task_item")]
[SugarIndex("idx_video_task_item_task_stage", nameof(TaskId), OrderByType.Asc, nameof(Stage), OrderByType.Asc)]
public class VideoDownloadTaskItem
{
[SugarColumn(IsPrimaryKey = true, Length = 50)]
public string Id { get; set; }
[SugarColumn(Length = 50)]
public string TaskId { get; set; }
[SugarColumn(Length = 200, IsNullable = true)]
public string VideoId { get; set; }
[SugarColumn(Length = 200, IsNullable = true)]
public string AwemeId { get; set; }
[SugarColumn(Length = 200, IsNullable = true)]
public string CookieId { get; set; }
[SugarColumn(Length = 500, IsNullable = true)]
public string CookieName { get; set; }
[SugarColumn(IsNullable = true)]
public VideoTypeEnum? VideoType { get; set; }
[SugarColumn(Length = 2000, IsNullable = true)]
public string VideoTitle { get; set; }
[SugarColumn(Length = 500, IsNullable = true)]
public string Author { get; set; }
[SugarColumn(Length = 2000, IsNullable = true)]
public string TargetPath { get; set; }
public VideoTaskItemStage Stage { get; set; }
public VideoTaskErrorType ErrorType { get; set; }
public VideoTaskSkipReason SkipReason { get; set; }
public int Attempts { get; set; }
public bool WorkerManaged { get; set; }
public long ExpectedLength { get; set; }
public long ActualLength { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string SourceUrlsJson { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string RetrySnapshotJson { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string ErrorMessage { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string WarningMessage { get; set; }
public bool CleanupPending { get; set; }
[SugarColumn(ColumnDataType = "TEXT", Length = -1, IsNullable = true)]
public string CleanupError { get; set; }
[SugarColumn(Length = 255, IsNullable = true)]
public string SourceHost { get; set; }
[SugarColumn(IsNullable = true)]
public int? HttpStatusCode { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? RetryAfter { get; set; }
[SugarColumn(Length = 50, IsNullable = true)]
public string ExclusionId { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? ExclusionReleasedAt { get; set; }
[SugarColumn(Length = 50, IsNullable = true)]
public string RelatedTaskId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? CompletedAt { get; set; }
}
}
+31
View File
@@ -0,0 +1,31 @@
using SqlSugar;
namespace dy.net.model.entity
{
[SugarTable(TableName = "dy_webdav_settings")]
public class WebDavSettings
{
[SugarColumn(IsPrimaryKey = true, Length = 50)]
public string Id { get; set; } = "default";
[SugarColumn(Length = 1000, IsNullable = true)]
public string Endpoint { get; set; }
[SugarColumn(Length = 1000, IsNullable = true)]
public string BasePath { get; set; } = "/dysync";
[SugarColumn(Length = 500, IsNullable = true)]
public string UserName { get; set; }
[SugarColumn(Length = -1, IsNullable = true)]
public string ProtectedPassword { get; set; }
public bool AllowInvalidCertificate { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LastTestedAt { get; set; }
[SugarColumn(Length = 1000, IsNullable = true)]
public string LastTestMessage { get; set; }
}
}
@@ -1,4 +1,5 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace dy.net.model.response
{
@@ -115,6 +116,12 @@ namespace dy.net.model.response
[JsonProperty("short_id")]
public string ShortId { get; set; }
/// <summary>
/// 用户主页展示的自定义抖音号。short_id 只是没有自定义抖音号时的数字兜底。
/// </summary>
[JsonProperty("unique_id")]
public string UniqueId { get; set; }
/// <summary>
/// 签名
/// </summary>
@@ -123,6 +130,18 @@ namespace dy.net.model.response
[JsonProperty("uid")]
public string UperId { get; set; }
[JsonProperty("live_status")]
public int? LiveStatus { get; set; }
[JsonProperty("room_id")]
public string RoomId { get; set; }
[JsonProperty("room_id_str")]
public string RoomIdStr { get; set; }
[JsonProperty("room_data")]
public JToken RoomData { get; set; }
}
/// <summary>

Some files were not shown because too many files have changed in this diff Show More