Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6803b32b1b | |||
| b328e82eb2 | |||
| ca530312d0 | |||
| c441e060b9 | |||
| 2f091915e7 | |||
| 3cc5ae3719 | |||
| 25dad98d8d | |||
| 428fb8aa48 | |||
| c9ee05afde | |||
| ad97fd3da5 | |||
| af40ed69fc |
@@ -0,0 +1,67 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vs
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
app/dist/
|
||||||
|
app/node_modules/
|
||||||
|
app/.vite/
|
||||||
|
|
||||||
|
# Logs and temp
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Local databases (mounted via volume in prod)
|
||||||
|
db/*.sqlite
|
||||||
|
db/*.db
|
||||||
|
|
||||||
|
# Video storage (mounted via volume)
|
||||||
|
videos/
|
||||||
|
collect/
|
||||||
|
favorite/
|
||||||
|
uper/
|
||||||
|
|
||||||
|
# Docs (Chinese filenames covered by *.md)
|
||||||
|
*.md
|
||||||
|
docs/
|
||||||
|
README*.md
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
test/
|
||||||
|
tests/
|
||||||
|
*.test.*
|
||||||
|
*.spec.*
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
.directory
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
*.old
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: 部署到群晖
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: 安装 Docker CLI
|
||||||
|
run: |
|
||||||
|
# 替换 Debian 源为清华镜像,避免国内连 deb.debian.org 超时
|
||||||
|
sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
|
||||||
|
sed -i 's|security.debian.org|mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq docker.io
|
||||||
|
|
||||||
|
- name: 拉取代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 构建镜像
|
||||||
|
run: docker build -f Dockerfile.prod -t dysync:latest .
|
||||||
|
|
||||||
|
- name: 停止旧容器
|
||||||
|
run: |
|
||||||
|
docker stop dysync 2>/dev/null || true
|
||||||
|
docker rm dysync 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: 启动新容器
|
||||||
|
run: |
|
||||||
|
docker run -d \
|
||||||
|
--name dysync \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 10101:10101 \
|
||||||
|
-e TZ=Asia/Shanghai \
|
||||||
|
-e ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
-v /volume1/docker/apps/dysync/db:/app/db \
|
||||||
|
-v /volume1/docker/apps/dysync/logs:/app/logs \
|
||||||
|
-v /volume2/save/dysync/collect:/app/collect \
|
||||||
|
-v /volume2/save/dysync/favorite:/app/favorite \
|
||||||
|
-v /volume2/save/dysync/uper:/app/uper \
|
||||||
|
dysync:latest
|
||||||
|
|
||||||
|
- name: 部署完成提示
|
||||||
|
run: |
|
||||||
|
echo "✅ 部署完成,浏览器访问 http://192.168.1.66:10101 查看"
|
||||||
+24
-1
@@ -1,7 +1,30 @@
|
|||||||
|
# 构建产物
|
||||||
obj
|
obj
|
||||||
bin
|
bin
|
||||||
.vs
|
|
||||||
logs
|
logs
|
||||||
upload
|
upload
|
||||||
data1
|
data1
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vs
|
||||||
|
.idea/
|
||||||
|
*.csproj.user
|
||||||
|
|
||||||
|
# 抖音视频下载目录(本地数据,禁止入库)
|
||||||
|
videos/
|
||||||
|
|
||||||
|
# 构建打包产物
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
|
# SQLite 数据库及日志(仅忽略实例数据,不忽略迁移脚本)
|
||||||
/db/*.sqlite
|
/db/*.sqlite
|
||||||
|
/db/*.db-journal
|
||||||
|
/db/*.db-wal
|
||||||
|
/db/*.db-shm
|
||||||
|
|
||||||
|
# 飞牛 NAS 相关(当前只部署群晖,不入库)
|
||||||
|
*feiniu*
|
||||||
|
|
||||||
|
# 手动部署脚本(与 Gitea CI 流程重复,本地保留即可,不入库以免与 CI 冲突)
|
||||||
|
/deploy.sh
|
||||||
|
/docker-compose.yml
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ namespace dy.net.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
/// 立即执行同步任务
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("ExecuteJobNow")]
|
[HttpGet("ExecuteJobNow")]
|
||||||
@@ -148,6 +148,55 @@ namespace dy.net.Controllers
|
|||||||
return Ok(new { code = 0 , error = "" });
|
return Ok(new { code = 0 , error = "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 重置指定Cookie的同步状态,强制重新同步
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Cookie ID</param>
|
||||||
|
/// <param name="type">重置类型:collect=收藏, favorite=喜欢, all=全部</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("ResetSyncStatus")]
|
||||||
|
public async Task<IActionResult> ResetSyncStatus(string id, string type = "all")
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cookie = await dyCookieService.GetByIdAsync(id);
|
||||||
|
if (cookie == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { code = -1, error = "Cookie不存在" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == "collect" || type == "all")
|
||||||
|
{
|
||||||
|
cookie.CollHasSyncd = 0;
|
||||||
|
}
|
||||||
|
if (type == "favorite" || type == "all")
|
||||||
|
{
|
||||||
|
cookie.FavHasSyncd = 0;
|
||||||
|
}
|
||||||
|
if (type == "all")
|
||||||
|
{
|
||||||
|
cookie.UperSyncd = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await dyCookieService.UpdateAsync(cookie);
|
||||||
|
if (result)
|
||||||
|
{
|
||||||
|
Serilog.Log.Information($"重置Cookie[{cookie.UserName}]的同步状态,类型: {type}");
|
||||||
|
return Ok(new { code = 0, message = "同步状态已重置" });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BadRequest(new { code = -1, error = "重置失败" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "重置同步状态失败");
|
||||||
|
return StatusCode(500, new { code = -1, error = "重置失败:" + ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task ReStartJob() {
|
private async Task ReStartJob() {
|
||||||
var config= commonService.GetConfig();
|
var config= commonService.GetConfig();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using dy.net.dto;
|
using dy.net.dto;
|
||||||
using dy.net.service;
|
using dy.net.service;
|
||||||
using dy.net.utils;
|
using dy.net.utils;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -25,7 +25,10 @@ namespace dy.net.Controllers
|
|||||||
[HttpPost("paged")]
|
[HttpPost("paged")]
|
||||||
public async Task<IActionResult> GetPagedAsync(VideoPageRequestDTO dto)
|
public async Task<IActionResult> GetPagedAsync(VideoPageRequestDTO dto)
|
||||||
{
|
{
|
||||||
var (list, totalCount) = await dyCollectVideoService.GetPagedAsync(dto.PageIndex, dto.PageSize, dto.Tag, dto.Author,dto.ViedoType,dto.Dates);
|
// 调试日志:输出接收到的排序参数
|
||||||
|
Console.WriteLine($"排序参数 - SortField: {dto.SortField}, SortOrder: {dto.SortOrder}");
|
||||||
|
|
||||||
|
var (list, totalCount) = await dyCollectVideoService.GetPagedAsync(dto.PageIndex, dto.PageSize, dto.Tag, dto.Tags, dto.Author, dto.ViedoType, dto.Dates, dto.IsDeleted, dto.SortField, dto.SortOrder);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
code = 0,
|
code = 0,
|
||||||
@@ -38,6 +41,21 @@ namespace dy.net.Controllers
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取所有视频类型列表(用于下拉框)
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("categories")]
|
||||||
|
public async Task<IActionResult> GetAllCategories()
|
||||||
|
{
|
||||||
|
var categories = await dyCollectVideoService.GetAllCategoriesAsync();
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
code = 0,
|
||||||
|
data = categories
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查询统计数据
|
/// 查询统计数据
|
||||||
@@ -53,6 +71,254 @@ namespace dy.net.Controllers
|
|||||||
data
|
data
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清理数据库中文件不存在的视频记录
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("cleanup")]
|
||||||
|
public async Task<IActionResult> CleanupMissingFilesAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Serilog.Log.Information("开始清理文件不存在的视频记录");
|
||||||
|
var deletedCount = await dyCollectVideoService.CleanupMissingFilesAsync();
|
||||||
|
Serilog.Log.Information($"清理完成,删除了 {deletedCount} 条记录");
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
code = 0,
|
||||||
|
data = new
|
||||||
|
{
|
||||||
|
deletedCount = deletedCount,
|
||||||
|
message = $"清理完成,删除了 {deletedCount} 条文件不存在的记录"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "清理文件不存在的记录失败");
|
||||||
|
return StatusCode(500, new { code = -1, error = "清理失败:" + ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除指定作者的所有视频数据
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authorName">作者名称</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("delete-author")]
|
||||||
|
public async Task<IActionResult> DeleteAuthorVideosAsync([FromBody] DeleteAuthorRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request?.AuthorName))
|
||||||
|
{
|
||||||
|
return BadRequest(new { code = -1, error = "作者名称不能为空" });
|
||||||
|
}
|
||||||
|
|
||||||
|
Serilog.Log.Information($"开始删除作者 {request.AuthorName} 的视频数据");
|
||||||
|
var deletedCount = await dyCollectVideoService.DeleteAuthorVideosAsync(request.AuthorName);
|
||||||
|
Serilog.Log.Information($"删除完成,删除了 {deletedCount} 条记录");
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
code = 0,
|
||||||
|
data = new
|
||||||
|
{
|
||||||
|
deletedCount = deletedCount,
|
||||||
|
message = $"已删除作者 {request.AuthorName} 的 {deletedCount} 条视频记录"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, $"删除作者视频失败: {request?.AuthorName}");
|
||||||
|
return StatusCode(500, new { code = -1, error = "删除失败:" + ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除作者请求DTO
|
||||||
|
/// </summary>
|
||||||
|
public class DeleteAuthorRequest
|
||||||
|
{
|
||||||
|
public string AuthorName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取视频流(支持Range请求,用于视频播放)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">视频ID</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize]
|
||||||
|
[HttpGet("stream/{id}")]
|
||||||
|
public async Task<IActionResult> GetVideoStream(string id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var video = await dyCollectVideoService.GetByIdAsync(id);
|
||||||
|
if (video == null || string.IsNullOrWhiteSpace(video.VideoSavePath))
|
||||||
|
{
|
||||||
|
return NotFound(new { code = -1, error = "视频不存在" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(video.VideoSavePath))
|
||||||
|
{
|
||||||
|
return NotFound(new { code = -1, error = "视频文件不存在" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileInfo = new FileInfo(video.VideoSavePath);
|
||||||
|
var contentType = GetContentType(video.VideoSavePath);
|
||||||
|
|
||||||
|
// 记录日志,便于调试
|
||||||
|
var rangeHeader = Request.Headers["Range"].ToString();
|
||||||
|
Serilog.Log.Debug($"视频流请求: ID={id}, 路径={video.VideoSavePath}, Content-Type={contentType}, Range={rangeHeader}");
|
||||||
|
|
||||||
|
var fileStream = new FileStream(video.VideoSavePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
|
||||||
|
// 使用ASP.NET Core内置的Range支持
|
||||||
|
Response.Headers.Add("Accept-Ranges", "bytes");
|
||||||
|
|
||||||
|
// 添加缓存控制头,确保浏览器可以缓存视频元数据
|
||||||
|
// 但允许 Range 请求,这样浏览器可以快速开始播放
|
||||||
|
Response.Headers.Add("Cache-Control", "public, max-age=3600");
|
||||||
|
|
||||||
|
// 添加 CORS 头(如果需要)
|
||||||
|
Response.Headers.Add("Access-Control-Allow-Origin", "*");
|
||||||
|
Response.Headers.Add("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
|
||||||
|
Response.Headers.Add("Access-Control-Allow-Headers", "Range");
|
||||||
|
Response.Headers.Add("Access-Control-Expose-Headers", "Content-Range, Accept-Ranges, Content-Length");
|
||||||
|
|
||||||
|
return File(fileStream, contentType, enableRangeProcessing: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, $"获取视频流失败: {id}");
|
||||||
|
return StatusCode(500, new { code = -1, error = "获取视频流失败" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取视频封面图片
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">视频ID</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize]
|
||||||
|
[HttpGet("cover/{id}")]
|
||||||
|
public async Task<IActionResult> GetVideoCover(string id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var video = await dyCollectVideoService.GetByIdAsync(id);
|
||||||
|
if (video == null)
|
||||||
|
{
|
||||||
|
return GetDefaultCoverImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先使用本地保存的封面
|
||||||
|
if (!string.IsNullOrWhiteSpace(video.VideoCoverSavePath) && System.IO.File.Exists(video.VideoCoverSavePath))
|
||||||
|
{
|
||||||
|
var fileInfo = new FileInfo(video.VideoCoverSavePath);
|
||||||
|
var contentType = GetImageContentType(video.VideoCoverSavePath);
|
||||||
|
var fileStream = System.IO.File.OpenRead(video.VideoCoverSavePath);
|
||||||
|
return File(fileStream, contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有本地封面,返回默认图片
|
||||||
|
return GetDefaultCoverImage();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, $"获取视频封面失败: {id}");
|
||||||
|
return GetDefaultCoverImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 返回默认封面图片(SVG 占位图)
|
||||||
|
/// </summary>
|
||||||
|
private IActionResult GetDefaultCoverImage()
|
||||||
|
{
|
||||||
|
var svgContent = @"<svg width=""400"" height=""225"" xmlns=""http://www.w3.org/2000/svg"">
|
||||||
|
<rect width=""400"" height=""225"" fill=""#f0f0f0""/>
|
||||||
|
<text x=""50%"" y=""50%"" font-family=""Arial, sans-serif"" font-size=""16"" fill=""#999"" text-anchor=""middle"" dy="".3em"">暂无封面</text>
|
||||||
|
</svg>";
|
||||||
|
var svgBytes = System.Text.Encoding.UTF8.GetBytes(svgContent);
|
||||||
|
return File(svgBytes, "image/svg+xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据文件扩展名获取图片 Content-Type
|
||||||
|
/// </summary>
|
||||||
|
private string GetImageContentType(string fileName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
{
|
||||||
|
return "image/jpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||||
|
return extension switch
|
||||||
|
{
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".png" => "image/png",
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".svg" => "image/svg+xml",
|
||||||
|
".bmp" => "image/bmp",
|
||||||
|
_ => "image/jpeg"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据文件扩展名获取Content-Type
|
||||||
|
/// </summary>
|
||||||
|
private string GetContentType(string fileName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning($"文件名为空,使用默认 video/mp4");
|
||||||
|
return "video/mp4"; // 抖音视频默认是 MP4
|
||||||
|
}
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||||
|
|
||||||
|
// 如果没有扩展名,尝试从文件名推断(抖音视频格式通常是 mp4)
|
||||||
|
if (string.IsNullOrEmpty(extension))
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning($"文件 {fileName} 没有扩展名,使用默认 video/mp4");
|
||||||
|
return "video/mp4";
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentType = extension switch
|
||||||
|
{
|
||||||
|
".mp4" => "video/mp4",
|
||||||
|
".webm" => "video/webm",
|
||||||
|
".ogg" => "video/ogg",
|
||||||
|
".mov" => "video/quicktime",
|
||||||
|
".avi" => "video/x-msvideo",
|
||||||
|
".mkv" => "video/x-matroska",
|
||||||
|
".flv" => "video/x-flv",
|
||||||
|
".wmv" => "video/x-ms-wmv",
|
||||||
|
".3gp" => "video/3gpp",
|
||||||
|
".m3u8" => "application/x-mpegURL", // HLS 流
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果无法识别扩展名,默认使用 video/mp4(抖音视频通常是 MP4)
|
||||||
|
if (contentType == null)
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning($"无法识别的视频扩展名: {extension},文件: {fileName},使用默认 video/mp4");
|
||||||
|
return "video/mp4";
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IActionResult Index()
|
public IActionResult Index()
|
||||||
|
|||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
#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.
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
|
|
||||||
WORKDIR /app
|
|
||||||
EXPOSE 10101
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
ENV ASPNETCORE_URLS http://*:10101
|
|
||||||
ENTRYPOINT ["dotnet", "dy.net.dll"]
|
|
||||||
ENV TZ=Asia/Shanghai
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#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.
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:6.0-focal-arm64v8 AS base
|
|
||||||
WORKDIR /app
|
|
||||||
EXPOSE 10101
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
ENV ASPNETCORE_URLS http://*:10101
|
|
||||||
ENTRYPOINT ["dotnet", "dy.net.dll"]
|
|
||||||
ENV TZ=Asia/Shanghai
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 多阶段构建 Dockerfile
|
||||||
|
# 第一阶段:构建前端
|
||||||
|
# 使用官方 Docker Hub(mcr 能访问的环境一般也能访问 docker.io)
|
||||||
|
FROM node:18-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
# 复制前端依赖文件
|
||||||
|
COPY app/package.json app/pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# 配置 npm 和 pnpm 使用国内镜像源
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com && \
|
||||||
|
npm install -g pnpm && \
|
||||||
|
pnpm config set registry https://registry.npmmirror.com && \
|
||||||
|
if [ -f pnpm-lock.yaml ]; then \
|
||||||
|
pnpm install --frozen-lockfile; \
|
||||||
|
else \
|
||||||
|
npm install; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 复制前端源码
|
||||||
|
COPY app/ ./
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
RUN pnpm build || npm run build
|
||||||
|
|
||||||
|
# 第二阶段:构建后端
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS backend-builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# 复制项目文件
|
||||||
|
COPY *.csproj ./
|
||||||
|
RUN dotnet restore dy.net.csproj
|
||||||
|
|
||||||
|
# 复制所有文件
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 删除 macOS 资源分叉文件(._* 文件)
|
||||||
|
RUN find . -name "._*" -type f -delete
|
||||||
|
|
||||||
|
# 复制前端构建产物
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist ./app/dist
|
||||||
|
|
||||||
|
# 发布后端
|
||||||
|
RUN dotnet publish dy.net.csproj -c Release -o /app/publish
|
||||||
|
|
||||||
|
# 第三阶段:运行镜像
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS final
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置时区
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
|
# 从构建阶段复制发布文件
|
||||||
|
COPY --from=backend-builder /app/publish .
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 10101
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV ASPNETCORE_URLS=http://*:10101
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
ENTRYPOINT ["dotnet", "dy.net.dll"]
|
||||||
|
|
||||||
@@ -1,2 +1 @@
|
|||||||
VITE_BASE_URL=/
|
VITE_API_URL=http://localhost:10101
|
||||||
VITE_API_URL=http://localhost:10101
|
|
||||||
|
|||||||
Generated
+35
-37
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@ant-design/icons-vue':
|
'@ant-design/icons-vue':
|
||||||
specifier: ^6.1.0
|
specifier: ^6.1.0
|
||||||
version: 6.1.0(vue@3.5.21(typescript@4.9.5))
|
version: 6.1.0(vue@3.5.21(typescript@4.9.5))
|
||||||
|
'@fortawesome/fontawesome-free':
|
||||||
|
specifier: ^5.15.4
|
||||||
|
version: 5.15.4
|
||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^10.1.0
|
specifier: ^10.1.0
|
||||||
version: 10.11.1(vue@3.5.21(typescript@4.9.5))
|
version: 10.11.1(vue@3.5.21(typescript@4.9.5))
|
||||||
@@ -89,7 +92,7 @@ importers:
|
|||||||
version: 6.14.0
|
version: 6.14.0
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.6.2(vite@4.5.14(@types/node@14.18.63)(less@4.4.1))(vue@3.5.21(typescript@4.9.5))
|
version: 4.6.2(vite@4.5.14(@types/node@14.18.63)(less@4.1.3))(vue@3.5.21(typescript@4.9.5))
|
||||||
autoprefixer:
|
autoprefixer:
|
||||||
specifier: ^10.4.7
|
specifier: ^10.4.7
|
||||||
version: 10.4.21(postcss@8.5.6)
|
version: 10.4.21(postcss@8.5.6)
|
||||||
@@ -124,11 +127,11 @@ importers:
|
|||||||
specifier: ^11.6.0
|
specifier: ^11.6.0
|
||||||
version: 11.11.1
|
version: 11.11.1
|
||||||
less:
|
less:
|
||||||
specifier: ^4.1.3
|
specifier: 4.1.3
|
||||||
version: 4.4.1
|
version: 4.1.3
|
||||||
less-loader:
|
less-loader:
|
||||||
specifier: ^12.3.0
|
specifier: ^12.3.0
|
||||||
version: 12.3.0(less@4.4.1)
|
version: 12.3.0(less@4.1.3)
|
||||||
mockjs:
|
mockjs:
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
@@ -149,13 +152,13 @@ importers:
|
|||||||
version: 0.24.1(@babel/parser@7.28.4)(rollup@4.52.0)(vue@3.5.21(typescript@4.9.5))
|
version: 0.24.1(@babel/parser@7.28.4)(rollup@4.52.0)(vue@3.5.21(typescript@4.9.5))
|
||||||
vite:
|
vite:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.5.14(@types/node@14.18.63)(less@4.4.1)
|
version: 4.5.14(@types/node@14.18.63)(less@4.1.3)
|
||||||
vite-plugin-compression:
|
vite-plugin-compression:
|
||||||
specifier: ^0.5.1
|
specifier: ^0.5.1
|
||||||
version: 0.5.1(vite@4.5.14(@types/node@14.18.63)(less@4.4.1))
|
version: 0.5.1(vite@4.5.14(@types/node@14.18.63)(less@4.1.3))
|
||||||
vitepress:
|
vitepress:
|
||||||
specifier: ^1.0.0-alpha.61
|
specifier: ^1.0.0-alpha.61
|
||||||
version: 1.6.4(@algolia/client-search@5.37.0)(@types/node@14.18.63)(async-validator@4.2.5)(axios@0.21.4)(less@4.4.1)(nprogress@0.2.0)(postcss@8.5.6)(search-insights@2.17.3)(typescript@4.9.5)
|
version: 1.6.4(@algolia/client-search@5.37.0)(@types/node@14.18.63)(async-validator@4.2.5)(axios@0.21.4)(less@4.1.3)(nprogress@0.2.0)(postcss@8.5.6)(search-insights@2.17.3)(typescript@4.9.5)
|
||||||
vue-tsc:
|
vue-tsc:
|
||||||
specifier: ^1.0.13
|
specifier: ^1.0.13
|
||||||
version: 1.8.27(typescript@4.9.5)
|
version: 1.8.27(typescript@4.9.5)
|
||||||
@@ -574,6 +577,10 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@fortawesome/fontawesome-free@5.15.4':
|
||||||
|
resolution: {integrity: sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
'@iconify-json/simple-icons@1.2.52':
|
'@iconify-json/simple-icons@1.2.52':
|
||||||
resolution: {integrity: sha512-c41YOMzBhl3hp58WJLxT+Qq3UhBd8GZAMkbS8ddlCuIGLW0COGe2YSfOA2+poA8/bxLhUQODRNjAy3KhiAOtzA==}
|
resolution: {integrity: sha512-c41YOMzBhl3hp58WJLxT+Qq3UhBd8GZAMkbS8ddlCuIGLW0COGe2YSfOA2+poA8/bxLhUQODRNjAy3KhiAOtzA==}
|
||||||
|
|
||||||
@@ -656,67 +663,56 @@ packages:
|
|||||||
resolution: {integrity: sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ==}
|
resolution: {integrity: sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.52.0':
|
'@rollup/rollup-linux-arm-musleabihf@4.52.0':
|
||||||
resolution: {integrity: sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw==}
|
resolution: {integrity: sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.52.0':
|
'@rollup/rollup-linux-arm64-gnu@4.52.0':
|
||||||
resolution: {integrity: sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw==}
|
resolution: {integrity: sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.52.0':
|
'@rollup/rollup-linux-arm64-musl@4.52.0':
|
||||||
resolution: {integrity: sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw==}
|
resolution: {integrity: sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.52.0':
|
'@rollup/rollup-linux-loong64-gnu@4.52.0':
|
||||||
resolution: {integrity: sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg==}
|
resolution: {integrity: sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.52.0':
|
'@rollup/rollup-linux-ppc64-gnu@4.52.0':
|
||||||
resolution: {integrity: sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw==}
|
resolution: {integrity: sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.52.0':
|
'@rollup/rollup-linux-riscv64-gnu@4.52.0':
|
||||||
resolution: {integrity: sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ==}
|
resolution: {integrity: sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.52.0':
|
'@rollup/rollup-linux-riscv64-musl@4.52.0':
|
||||||
resolution: {integrity: sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw==}
|
resolution: {integrity: sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.52.0':
|
'@rollup/rollup-linux-s390x-gnu@4.52.0':
|
||||||
resolution: {integrity: sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg==}
|
resolution: {integrity: sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.52.0':
|
'@rollup/rollup-linux-x64-gnu@4.52.0':
|
||||||
resolution: {integrity: sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA==}
|
resolution: {integrity: sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.52.0':
|
'@rollup/rollup-linux-x64-musl@4.52.0':
|
||||||
resolution: {integrity: sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ==}
|
resolution: {integrity: sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-openharmony-arm64@4.52.0':
|
'@rollup/rollup-openharmony-arm64@4.52.0':
|
||||||
resolution: {integrity: sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw==}
|
resolution: {integrity: sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw==}
|
||||||
@@ -2331,9 +2327,9 @@ packages:
|
|||||||
webpack:
|
webpack:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
less@4.4.1:
|
less@4.1.3:
|
||||||
resolution: {integrity: sha512-X9HKyiXPi0f/ed0XhgUlBeFfxrlDP3xR4M7768Zl+WXLUViuL9AOPPJP4nCV0tgRWvTYvpNmN0SFhZOQzy16PA==}
|
resolution: {integrity: sha512-w16Xk/Ta9Hhyei0Gpz9m7VS8F28nieJaL/VyShID7cYvP6IL5oHeL6p4TXSDJqZE/lNv0oJ2pGVjJsRkfwm5FA==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
liftoff@3.1.0:
|
liftoff@3.1.0:
|
||||||
@@ -3956,6 +3952,8 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.21.5':
|
'@esbuild/win32-x64@0.21.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@fortawesome/fontawesome-free@5.15.4': {}
|
||||||
|
|
||||||
'@iconify-json/simple-icons@1.2.52':
|
'@iconify-json/simple-icons@1.2.52':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
@@ -4169,14 +4167,14 @@ snapshots:
|
|||||||
|
|
||||||
'@ungap/structured-clone@1.3.0': {}
|
'@ungap/structured-clone@1.3.0': {}
|
||||||
|
|
||||||
'@vitejs/plugin-vue@4.6.2(vite@4.5.14(@types/node@14.18.63)(less@4.4.1))(vue@3.5.21(typescript@4.9.5))':
|
'@vitejs/plugin-vue@4.6.2(vite@4.5.14(@types/node@14.18.63)(less@4.1.3))(vue@3.5.21(typescript@4.9.5))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vite: 4.5.14(@types/node@14.18.63)(less@4.4.1)
|
vite: 4.5.14(@types/node@14.18.63)(less@4.1.3)
|
||||||
vue: 3.5.21(typescript@4.9.5)
|
vue: 3.5.21(typescript@4.9.5)
|
||||||
|
|
||||||
'@vitejs/plugin-vue@5.2.4(vite@5.4.20(@types/node@14.18.63)(less@4.4.1))(vue@3.5.21(typescript@4.9.5))':
|
'@vitejs/plugin-vue@5.2.4(vite@5.4.20(@types/node@14.18.63)(less@4.1.3))(vue@3.5.21(typescript@4.9.5))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vite: 5.4.20(@types/node@14.18.63)(less@4.4.1)
|
vite: 5.4.20(@types/node@14.18.63)(less@4.1.3)
|
||||||
vue: 3.5.21(typescript@4.9.5)
|
vue: 3.5.21(typescript@4.9.5)
|
||||||
|
|
||||||
'@volar/language-core@1.11.1':
|
'@volar/language-core@1.11.1':
|
||||||
@@ -6063,11 +6061,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flush-write-stream: 1.1.1
|
flush-write-stream: 1.1.1
|
||||||
|
|
||||||
less-loader@12.3.0(less@4.4.1):
|
less-loader@12.3.0(less@4.1.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
less: 4.4.1
|
less: 4.1.3
|
||||||
|
|
||||||
less@4.4.1:
|
less@4.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
copy-anything: 2.0.6
|
copy-anything: 2.0.6
|
||||||
parse-node-version: 1.0.1
|
parse-node-version: 1.0.1
|
||||||
@@ -7311,16 +7309,16 @@ snapshots:
|
|||||||
remove-trailing-separator: 1.1.0
|
remove-trailing-separator: 1.1.0
|
||||||
replace-ext: 1.0.1
|
replace-ext: 1.0.1
|
||||||
|
|
||||||
vite-plugin-compression@0.5.1(vite@4.5.14(@types/node@14.18.63)(less@4.4.1)):
|
vite-plugin-compression@0.5.1(vite@4.5.14(@types/node@14.18.63)(less@4.1.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
fs-extra: 10.1.0
|
fs-extra: 10.1.0
|
||||||
vite: 4.5.14(@types/node@14.18.63)(less@4.4.1)
|
vite: 4.5.14(@types/node@14.18.63)(less@4.1.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
vite@4.5.14(@types/node@14.18.63)(less@4.4.1):
|
vite@4.5.14(@types/node@14.18.63)(less@4.1.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.18.20
|
esbuild: 0.18.20
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
@@ -7328,9 +7326,9 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 14.18.63
|
'@types/node': 14.18.63
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
less: 4.4.1
|
less: 4.1.3
|
||||||
|
|
||||||
vite@5.4.20(@types/node@14.18.63)(less@4.4.1):
|
vite@5.4.20(@types/node@14.18.63)(less@4.1.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
@@ -7338,9 +7336,9 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 14.18.63
|
'@types/node': 14.18.63
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
less: 4.4.1
|
less: 4.1.3
|
||||||
|
|
||||||
vitepress@1.6.4(@algolia/client-search@5.37.0)(@types/node@14.18.63)(async-validator@4.2.5)(axios@0.21.4)(less@4.4.1)(nprogress@0.2.0)(postcss@8.5.6)(search-insights@2.17.3)(typescript@4.9.5):
|
vitepress@1.6.4(@algolia/client-search@5.37.0)(@types/node@14.18.63)(async-validator@4.2.5)(axios@0.21.4)(less@4.1.3)(nprogress@0.2.0)(postcss@8.5.6)(search-insights@2.17.3)(typescript@4.9.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@docsearch/css': 3.8.2
|
'@docsearch/css': 3.8.2
|
||||||
'@docsearch/js': 3.8.2(@algolia/client-search@5.37.0)(search-insights@2.17.3)
|
'@docsearch/js': 3.8.2(@algolia/client-search@5.37.0)(search-insights@2.17.3)
|
||||||
@@ -7349,7 +7347,7 @@ snapshots:
|
|||||||
'@shikijs/transformers': 2.5.0
|
'@shikijs/transformers': 2.5.0
|
||||||
'@shikijs/types': 2.5.0
|
'@shikijs/types': 2.5.0
|
||||||
'@types/markdown-it': 14.1.2
|
'@types/markdown-it': 14.1.2
|
||||||
'@vitejs/plugin-vue': 5.2.4(vite@5.4.20(@types/node@14.18.63)(less@4.4.1))(vue@3.5.21(typescript@4.9.5))
|
'@vitejs/plugin-vue': 5.2.4(vite@5.4.20(@types/node@14.18.63)(less@4.1.3))(vue@3.5.21(typescript@4.9.5))
|
||||||
'@vue/devtools-api': 7.7.7
|
'@vue/devtools-api': 7.7.7
|
||||||
'@vue/shared': 3.5.21
|
'@vue/shared': 3.5.21
|
||||||
'@vueuse/core': 12.8.2(typescript@4.9.5)
|
'@vueuse/core': 12.8.2(typescript@4.9.5)
|
||||||
@@ -7358,7 +7356,7 @@ snapshots:
|
|||||||
mark.js: 8.11.1
|
mark.js: 8.11.1
|
||||||
minisearch: 7.2.0
|
minisearch: 7.2.0
|
||||||
shiki: 2.5.0
|
shiki: 2.5.0
|
||||||
vite: 5.4.20(@types/node@14.18.63)(less@4.4.1)
|
vite: 5.4.20(@types/node@14.18.63)(less@4.1.3)
|
||||||
vue: 3.5.21(typescript@4.9.5)
|
vue: 3.5.21(typescript@4.9.5)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
|
|||||||
@@ -0,0 +1,580 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
v-model:visible="visible"
|
||||||
|
:width="900"
|
||||||
|
:footer="null"
|
||||||
|
:maskClosable="false"
|
||||||
|
@cancel="handleClose"
|
||||||
|
class="video-player-modal"
|
||||||
|
:destroyOnClose="true"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="modal-title-wrapper">
|
||||||
|
<span class="modal-title-text">{{ modalTitle }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="video-player-container" v-if="currentVideoId">
|
||||||
|
<!-- 视频信息 -->
|
||||||
|
<div class="video-info" v-if="currentVideoInfo">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">作者:</span>
|
||||||
|
<span class="value">{{ currentVideoInfo.author || '未知' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">分类:</span>
|
||||||
|
<span class="value">{{ currentVideoInfo.viedoCate || '未分类' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">分辨率:</span>
|
||||||
|
<span class="value">{{ currentVideoInfo.resolution || '未知' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">文件大小:</span>
|
||||||
|
<span class="value">{{ formatFileSize(currentVideoInfo.fileSize || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视频播放器 -->
|
||||||
|
<div class="video-wrapper" v-if="currentVideoId">
|
||||||
|
<video
|
||||||
|
ref="videoPlayer"
|
||||||
|
:src="videoUrl"
|
||||||
|
controls
|
||||||
|
preload="auto"
|
||||||
|
class="video-element"
|
||||||
|
@error="handleVideoError"
|
||||||
|
@loadstart="handleLoadStart"
|
||||||
|
@loadedmetadata="handleLoadedMetadata"
|
||||||
|
@canplay="handleCanPlay"
|
||||||
|
@canplaythrough="handleCanPlayThrough"
|
||||||
|
@progress="handleProgress"
|
||||||
|
@waiting="handleWaiting"
|
||||||
|
@playing="handlePlaying"
|
||||||
|
>
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
<div v-else class="video-wrapper">
|
||||||
|
<a-empty description="视频信息加载中..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="loading" class="loading-overlay">
|
||||||
|
<a-spin size="large" tip="加载视频中..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误提示 -->
|
||||||
|
<div v-if="error" class="error-message">
|
||||||
|
<a-alert
|
||||||
|
:message="error"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
@close="error = ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import Cookie from 'js-cookie';
|
||||||
|
|
||||||
|
interface VideoInfo {
|
||||||
|
id: string;
|
||||||
|
videoTitle: string;
|
||||||
|
author: string;
|
||||||
|
viedoCate: string;
|
||||||
|
resolution: string;
|
||||||
|
fileSize: number;
|
||||||
|
videoSavePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
videoId?: string;
|
||||||
|
videoInfo?: VideoInfo;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const videoPlayer = ref<HTMLVideoElement | null>(null);
|
||||||
|
const currentVideoId = ref<string>('');
|
||||||
|
const currentVideoInfo = ref<VideoInfo | null>(null);
|
||||||
|
const videoDirectUrl = ref<string>('');
|
||||||
|
|
||||||
|
// 视频URL - 直接使用流式 URL,让浏览器处理 Range 请求
|
||||||
|
const videoUrl = computed(() => videoDirectUrl.value);
|
||||||
|
|
||||||
|
// 标题(完整显示,支持多行)
|
||||||
|
const modalTitle = computed(() => {
|
||||||
|
return currentVideoInfo.value?.videoTitle || '视频预览';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置视频流 URL,让浏览器直接处理 Range 请求实现流式播放
|
||||||
|
const loadVideoStream = async (videoId: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
// 构建视频流 URL,浏览器会自动发送 Range 请求
|
||||||
|
// 添加时间戳避免缓存问题
|
||||||
|
const url = `/api/video/stream/${videoId}?t=${Date.now()}`;
|
||||||
|
console.log('[VideoPlayer] 设置视频流 URL:', url);
|
||||||
|
|
||||||
|
// 直接使用 URL,让浏览器处理 Range 请求
|
||||||
|
// 浏览器会自动发送 Range 请求头,实现边加载边播放
|
||||||
|
videoDirectUrl.value = url;
|
||||||
|
|
||||||
|
// 确保 video 元素存在,并且等待 DOM 更新
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
if (videoPlayer.value) {
|
||||||
|
// 先清空 src,避免使用旧的 URL
|
||||||
|
videoPlayer.value.src = '';
|
||||||
|
|
||||||
|
// 等待一下,确保清空操作完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// 设置新的视频 URL
|
||||||
|
videoPlayer.value.src = videoDirectUrl.value;
|
||||||
|
console.log('[VideoPlayer] 视频 src 已设置:', videoDirectUrl.value);
|
||||||
|
|
||||||
|
// 加载视频元数据(不等待完整下载)
|
||||||
|
// 浏览器会自动发送 Range 请求,只加载必要的部分
|
||||||
|
videoPlayer.value.load();
|
||||||
|
console.log('[VideoPlayer] 调用 video.load(),开始加载视频');
|
||||||
|
|
||||||
|
// 视频元素会自动处理 Range 请求,实现流式播放
|
||||||
|
// 当有足够的数据可以播放时(通常只需要几秒的数据),浏览器就可以开始播放
|
||||||
|
// 不需要等待整个文件下载完成
|
||||||
|
|
||||||
|
// 监听网络状态变化,用于调试
|
||||||
|
const checkNetworkState = () => {
|
||||||
|
const networkState = videoPlayer.value?.networkState;
|
||||||
|
const readyState = videoPlayer.value?.readyState;
|
||||||
|
const buffered = videoPlayer.value?.buffered;
|
||||||
|
|
||||||
|
console.log('[VideoPlayer] 网络状态:', {
|
||||||
|
networkState,
|
||||||
|
readyState,
|
||||||
|
bufferedLength: buffered?.length || 0,
|
||||||
|
bufferedRanges: buffered?.length ? Array.from({ length: buffered.length }, (_, i) => ({
|
||||||
|
start: buffered.start(i),
|
||||||
|
end: buffered.end(i)
|
||||||
|
})) : []
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定期检查网络状态(用于调试)
|
||||||
|
const networkCheckInterval = setInterval(() => {
|
||||||
|
if (videoPlayer.value && loading.value) {
|
||||||
|
checkNetworkState();
|
||||||
|
} else {
|
||||||
|
clearInterval(networkCheckInterval);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// 5秒后停止检查
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(networkCheckInterval);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[VideoPlayer] 设置视频流 URL 失败:', err);
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
// 根据错误类型设置错误消息
|
||||||
|
if (err.message) {
|
||||||
|
error.value = err.message;
|
||||||
|
} else {
|
||||||
|
error.value = '视频加载失败,请稍后重试';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空 URL
|
||||||
|
videoDirectUrl.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开播放器
|
||||||
|
const open = async (id?: string, info?: VideoInfo) => {
|
||||||
|
console.log('VideoPlayer.open called', { id, info });
|
||||||
|
if (!id && !info?.id) {
|
||||||
|
message.error('视频ID不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoId = id || info!.id;
|
||||||
|
const videoInfo = info || {
|
||||||
|
id: videoId,
|
||||||
|
videoTitle: '视频预览',
|
||||||
|
author: '未知',
|
||||||
|
viedoCate: '未分类',
|
||||||
|
resolution: '未知',
|
||||||
|
fileSize: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('设置视频信息', { videoId, videoInfo });
|
||||||
|
|
||||||
|
// 先清空之前的状态
|
||||||
|
if (videoPlayer.value) {
|
||||||
|
videoPlayer.value.pause();
|
||||||
|
videoPlayer.value.src = '';
|
||||||
|
}
|
||||||
|
videoDirectUrl.value = '';
|
||||||
|
error.value = '';
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
// 设置视频信息
|
||||||
|
currentVideoId.value = videoId;
|
||||||
|
currentVideoInfo.value = videoInfo;
|
||||||
|
|
||||||
|
// 先显示模态框
|
||||||
|
visible.value = true;
|
||||||
|
|
||||||
|
// 等待 DOM 渲染完成后再加载视频流
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// 加载视频流
|
||||||
|
await loadVideoStream(videoId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭播放器
|
||||||
|
const handleClose = () => {
|
||||||
|
visible.value = false;
|
||||||
|
if (videoPlayer.value) {
|
||||||
|
videoPlayer.value.pause();
|
||||||
|
videoPlayer.value.src = '';
|
||||||
|
}
|
||||||
|
videoDirectUrl.value = '';
|
||||||
|
loading.value = false;
|
||||||
|
error.value = '';
|
||||||
|
currentVideoId.value = '';
|
||||||
|
currentVideoInfo.value = null;
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频加载开始
|
||||||
|
const handleLoadStart = () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
console.log('[VideoPlayer] loadstart 事件触发');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频元数据加载完成
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
const video = videoPlayer.value;
|
||||||
|
if (video) {
|
||||||
|
console.log('[VideoPlayer] loadedmetadata 事件触发', {
|
||||||
|
duration: video.duration,
|
||||||
|
videoWidth: video.videoWidth,
|
||||||
|
videoHeight: video.videoHeight,
|
||||||
|
readyState: video.readyState,
|
||||||
|
networkState: video.networkState
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 不在这里关闭 loading,等待 canplay 事件
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频可以开始播放(有足够的数据)
|
||||||
|
const handleCanPlay = () => {
|
||||||
|
const video = videoPlayer.value;
|
||||||
|
loading.value = false;
|
||||||
|
if (video) {
|
||||||
|
console.log('[VideoPlayer] canplay 事件触发 - 视频可以开始播放', {
|
||||||
|
duration: video.duration,
|
||||||
|
readyState: video.readyState,
|
||||||
|
networkState: video.networkState,
|
||||||
|
bufferedLength: video.buffered?.length || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 如果视频还没有播放,可以尝试自动播放(需要用户交互)
|
||||||
|
if (video && video.paused) {
|
||||||
|
// 注意:自动播放可能被浏览器阻止,需要用户交互
|
||||||
|
// 这里不自动播放,让用户点击播放按钮
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频可以流畅播放(有足够的数据缓冲)
|
||||||
|
const handleCanPlayThrough = () => {
|
||||||
|
const video = videoPlayer.value;
|
||||||
|
loading.value = false;
|
||||||
|
if (video) {
|
||||||
|
console.log('[VideoPlayer] canplaythrough 事件触发 - 视频可以流畅播放', {
|
||||||
|
duration: video.duration,
|
||||||
|
readyState: video.readyState,
|
||||||
|
networkState: video.networkState,
|
||||||
|
bufferedLength: video.buffered?.length || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频加载进度
|
||||||
|
const handleProgress = () => {
|
||||||
|
const video = videoPlayer.value;
|
||||||
|
if (video && video.buffered.length > 0) {
|
||||||
|
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||||
|
const bufferedPercent = video.duration > 0 ? (bufferedEnd / video.duration) * 100 : 0;
|
||||||
|
console.log('[VideoPlayer] progress 事件 - 缓冲进度:', {
|
||||||
|
bufferedPercent: bufferedPercent.toFixed(2) + '%',
|
||||||
|
bufferedEnd,
|
||||||
|
duration: video.duration,
|
||||||
|
readyState: video.readyState
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果已经有足够的数据可以播放,关闭加载状态
|
||||||
|
if (bufferedEnd > 0 && video.readyState >= 2) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频等待数据
|
||||||
|
const handleWaiting = () => {
|
||||||
|
console.log('[VideoPlayer] waiting 事件 - 视频等待数据');
|
||||||
|
loading.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频开始播放
|
||||||
|
const handlePlaying = () => {
|
||||||
|
console.log('[VideoPlayer] playing 事件 - 视频开始播放');
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频加载错误
|
||||||
|
const handleVideoError = (e: Event) => {
|
||||||
|
const video = e.target as HTMLVideoElement;
|
||||||
|
|
||||||
|
// 如果 src 为空或正在加载中,忽略错误(可能是时序问题)
|
||||||
|
if (!video.src || !videoDirectUrl.value || video.networkState === 0) {
|
||||||
|
console.log('忽略视频加载错误(可能是时序问题):', {
|
||||||
|
src: video.src,
|
||||||
|
directUrl: videoDirectUrl.value,
|
||||||
|
networkState: video.networkState,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('视频加载错误详情:', {
|
||||||
|
error: video.error,
|
||||||
|
errorCode: video.error?.code,
|
||||||
|
errorMessage: video.error?.message,
|
||||||
|
networkState: video.networkState,
|
||||||
|
readyState: video.readyState,
|
||||||
|
src: video.src,
|
||||||
|
directUrl: videoDirectUrl.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
if (video.error) {
|
||||||
|
switch (video.error.code) {
|
||||||
|
case 1: // MEDIA_ERR_ABORTED
|
||||||
|
error.value = '视频加载被中止';
|
||||||
|
break;
|
||||||
|
case 2: // MEDIA_ERR_NETWORK
|
||||||
|
// 网络错误可能是认证失败(401)或其他网络问题
|
||||||
|
// 检查是否是认证问题
|
||||||
|
if (video.networkState === 3) { // NETWORK_NO_SOURCE
|
||||||
|
// 可能是认证失败,尝试提示用户
|
||||||
|
error.value = '视频加载失败,可能是认证问题。请检查是否已登录,或刷新页面后重试';
|
||||||
|
} else {
|
||||||
|
error.value = '网络错误,请检查网络连接';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 3: // MEDIA_ERR_DECODE
|
||||||
|
error.value = '视频解码失败,可能是视频格式问题或文件损坏';
|
||||||
|
break;
|
||||||
|
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||||
|
// 尝试重新加载视频
|
||||||
|
if (videoDirectUrl.value && video.src === videoDirectUrl.value) {
|
||||||
|
console.log('尝试重新加载视频...');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (videoPlayer.value && videoDirectUrl.value) {
|
||||||
|
videoPlayer.value.load();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
return; // 不显示错误,等待重试
|
||||||
|
}
|
||||||
|
error.value = '视频格式不支持。请检查:1) 视频文件是否完整 2) 浏览器是否支持该格式 3) 文件扩展名是否正确';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
error.value = `视频加载失败 (错误代码: ${video.error.code})`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error.value = '视频加载失败,请检查网络连接和视频文件';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听视频加载完成
|
||||||
|
watch(
|
||||||
|
() => videoPlayer.value?.readyState,
|
||||||
|
(readyState) => {
|
||||||
|
if (readyState && readyState >= 2) {
|
||||||
|
// HAVE_CURRENT_DATA 或更高
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 组件卸载时清理
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (videoPlayer.value) {
|
||||||
|
videoPlayer.value.pause();
|
||||||
|
videoPlayer.value.src = '';
|
||||||
|
}
|
||||||
|
videoDirectUrl.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 暴露方法
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
close: handleClose,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.video-player-modal {
|
||||||
|
:deep(.ant-modal-body) {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保标题区域不遮挡关闭按钮,支持多行显示
|
||||||
|
:deep(.ant-modal-header) {
|
||||||
|
position: relative;
|
||||||
|
padding-right: 56px !important; // 为关闭按钮留出足够空间
|
||||||
|
min-height: 55px;
|
||||||
|
padding-bottom: 16px; // 增加底部内边距,为多行标题留空间
|
||||||
|
|
||||||
|
.ant-modal-title {
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义标题样式
|
||||||
|
.modal-title-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: calc(100% - 56px); // 减去关闭按钮的宽度
|
||||||
|
padding-right: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-text {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 80px;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保关闭按钮始终可见且在最上层
|
||||||
|
:deep(.ant-modal-close) {
|
||||||
|
position: absolute !important;
|
||||||
|
right: 0 !important;
|
||||||
|
top: 0 !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
width: 56px !important;
|
||||||
|
height: 55px !important;
|
||||||
|
line-height: 55px !important;
|
||||||
|
text-align: center !important;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-close-x {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 24px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.video-element {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 70vh;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
+115
-112
@@ -13,17 +13,12 @@ const columns = [
|
|||||||
{
|
{
|
||||||
title: 'Cookie名',
|
title: 'Cookie名',
|
||||||
dataIndex: 'userName',
|
dataIndex: 'userName',
|
||||||
width: 100,
|
width: 120,
|
||||||
},
|
},
|
||||||
{ title: '状态', dataIndex: 'status', width: 80 },
|
{ title: '状态', dataIndex: 'status', width: 80 },
|
||||||
{ title: '收藏路径', dataIndex: 'savePath', width: 100 },
|
{ title: '存储路径', dataIndex: 'paths', width: 300, ellipsis: true },
|
||||||
{ title: '喜欢路径', dataIndex: 'favSavePath', width: 100 },
|
{ title: 'SecUserId', dataIndex: 'userIds', width: 200, ellipsis: true },
|
||||||
{ title: 'Up主路径', dataIndex: 'upSavePath', width: 100 },
|
{ title: '操作', dataIndex: 'edit', width: 180, fixed: 'right' },
|
||||||
// { title: 'Cookie', dataIndex: 'cookies' },
|
|
||||||
{ title: 'Up主secUserId', dataIndex: 'upSecUserIds' },
|
|
||||||
{ title: '用户secUserId', dataIndex: 'secUserId' },
|
|
||||||
{ title: '操作', dataIndex: 'edit', width: 200 },
|
|
||||||
// { title: 'id', dataIndex: 'id', width: 200, hiden: false },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 定义数组中单个对象的类型:包含uper和uid字段(可选字符串类型,根据实际需求调整是否可选)
|
// 定义数组中单个对象的类型:包含uper和uid字段(可选字符串类型,根据实际需求调整是否可选)
|
||||||
@@ -47,16 +42,6 @@ type DataItem = {
|
|||||||
upSavePath?: string;
|
upSavePath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// const dataSource = reactive<DataItem[]>([
|
|
||||||
// {
|
|
||||||
// userName: 'Li Zhi',
|
|
||||||
// cookies: '131231',
|
|
||||||
// savePath: 'x',
|
|
||||||
// status: 1,
|
|
||||||
// id: '1',
|
|
||||||
// },
|
|
||||||
// ]);
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const datas: UnwrapRef<DataItem[]> = reactive([]);
|
const datas: UnwrapRef<DataItem[]> = reactive([]);
|
||||||
const pagination = ref({
|
const pagination = ref({
|
||||||
@@ -153,9 +138,6 @@ function submit() {
|
|||||||
} else {
|
} else {
|
||||||
copyObject(editRecord.value, resData);
|
copyObject(editRecord.value, resData);
|
||||||
}
|
}
|
||||||
// alert(JSON.stringify(form.upSecUserIdsJson));
|
|
||||||
// console.log('1', authors);
|
|
||||||
// console.log('2', res);
|
|
||||||
useApiStore()
|
useApiStore()
|
||||||
.UpdateConfig(resData)
|
.UpdateConfig(resData)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -177,17 +159,15 @@ function submit() {
|
|||||||
|
|
||||||
const editRecord = ref<DataItem>();
|
const editRecord = ref<DataItem>();
|
||||||
|
|
||||||
import { Modal } from 'ant-design-vue'; // 假设使用Ant Design Vue的Modal组件
|
import { Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
const deleted = (id: string) => {
|
const deleted = (id: string) => {
|
||||||
// 显示确认对话框
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
content: '确定要删除这条记录吗?此操作不可撤销。',
|
content: '确定要删除这条记录吗?此操作不可撤销。',
|
||||||
okText: '确认',
|
okText: '确认',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: () => {
|
onOk: () => {
|
||||||
// 用户确认后执行删除操作
|
|
||||||
useApiStore()
|
useApiStore()
|
||||||
.deleteCookie(id)
|
.deleteCookie(id)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -200,15 +180,11 @@ const deleted = (id: string) => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
// 用户取消删除,不执行任何操作
|
|
||||||
console.log('已取消删除');
|
console.log('已取消删除');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
/**
|
|
||||||
* 编辑
|
|
||||||
* @param record
|
|
||||||
*/
|
|
||||||
function edit(record: DataItem) {
|
function edit(record: DataItem) {
|
||||||
editRecord.value = record;
|
editRecord.value = record;
|
||||||
copyObject(form, record);
|
copyObject(form, record);
|
||||||
@@ -264,7 +240,7 @@ const rowCount = 4;
|
|||||||
<a-form-item label="喜欢的存储路径" name="favSavePath">
|
<a-form-item label="喜欢的存储路径" name="favSavePath">
|
||||||
<div style="display: flex; align-items: center; gap: 6px;">
|
<div style="display: flex; align-items: center; gap: 6px;">
|
||||||
<a-input v-model:value="form.favSavePath" style="flex: 1;" />
|
<a-input v-model:value="form.favSavePath" style="flex: 1;" />
|
||||||
<a-tooltip title="同步“我喜欢的”视频时,必填!!!">
|
<a-tooltip title="同步'我喜欢的'视频时,必填!!!">
|
||||||
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px;" />
|
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px;" />
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,7 +248,7 @@ const rowCount = 4;
|
|||||||
<a-form-item label="SecUserId" name="secUserId">
|
<a-form-item label="SecUserId" name="secUserId">
|
||||||
<div style="display: flex; align-items: center; gap: 6px;">
|
<div style="display: flex; align-items: center; gap: 6px;">
|
||||||
<a-input v-model:value="form.secUserId" style="flex: 1;" />
|
<a-input v-model:value="form.secUserId" style="flex: 1;" />
|
||||||
<a-tooltip title="同步“我喜欢的”视频时,必填!!!">
|
<a-tooltip title="同步'我喜欢的'视频时,必填!!!">
|
||||||
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px;" />
|
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px;" />
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,8 +263,7 @@ const rowCount = 4;
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label="博主信息" name="upSecUserIdsJson">
|
<a-form-item label="博主信息" name="upSecUserIdsJson">
|
||||||
<a-form-item-rest> <!-- 用这个包裹所有辅助元素 -->
|
<a-form-item-rest>
|
||||||
<!-- 添加行按钮 -->
|
|
||||||
<a-button type="primary" @click="addRow" style="margin-bottom: 12px">
|
<a-button type="primary" @click="addRow" style="margin-bottom: 12px">
|
||||||
添加抖音Up主
|
添加抖音Up主
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -296,23 +271,15 @@ const rowCount = 4;
|
|||||||
</template>
|
</template>
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<!-- 动态渲染所有行 -->
|
|
||||||
<div v-for="(row, index) in form.upSecUserIdsJson" :key="index" style="display: flex; gap: 12px; margin-bottom: 8px; align-items: center">
|
<div v-for="(row, index) in form.upSecUserIdsJson" :key="index" style="display: flex; gap: 12px; margin-bottom: 8px; align-items: center">
|
||||||
<!-- uper输入框 -->
|
|
||||||
<a-input v-model:value="row.uper" placeholder="博主别名,可自定义" style="flex: 1" />
|
<a-input v-model:value="row.uper" placeholder="博主别名,可自定义" style="flex: 1" />
|
||||||
|
|
||||||
<!-- uid输入框 -->
|
|
||||||
<a-input v-model:value="row.uid" placeholder="博主secUserId" style="flex: 3" />
|
<a-input v-model:value="row.uid" placeholder="博主secUserId" style="flex: 3" />
|
||||||
|
|
||||||
<!-- syncAll 开关字段 -->
|
|
||||||
<div style="flex: 1; display: flex; align-items: center;">
|
<div style="flex: 1; display: flex; align-items: center;">
|
||||||
<a-tooltip title="默认关闭,仅同步 UP 主最新一页数据;开启将同步全部作品(量大不建议开启)">
|
<a-tooltip title="默认关闭,仅同步 UP 主最新一页数据;开启将同步全部作品(量大不建议开启)">
|
||||||
<span style="margin-right: 8px; cursor: default;color:#faad14">同步全部作品</span>
|
<span style="margin-right: 8px; cursor: default;color:#faad14">同步全部作品</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-switch v-model:checked="row.syncAll" />
|
<a-switch v-model:checked="row.syncAll" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 删除当前行按钮 -->
|
|
||||||
<a-button type="text" danger @click="removeRow(index)">
|
<a-button type="text" danger @click="removeRow(index)">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<DeleteOutlined />
|
<DeleteOutlined />
|
||||||
@@ -330,10 +297,8 @@ const rowCount = 4;
|
|||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<!-- 成员表格 -->
|
<a-table v-bind="$attrs" :columns="columns" :dataSource="dataSource" :pagination="false" :scroll="{ x: 900 }">
|
||||||
<a-table v-bind="$attrs" :columns="columns" :dataSource="dataSource" :pagination="false">
|
|
||||||
<template #title>
|
<template #title>
|
||||||
<!-- 关键修改:将 justify-between 改为 justify-end(使子元素靠右侧对齐) -->
|
|
||||||
<div class="flex justify-end pr-4">
|
<div class="flex justify-end pr-4">
|
||||||
<a-button type="primary" @click="GetRecords()" :loading="formLoading" class="mr-2">
|
<a-button type="primary" @click="GetRecords()" :loading="formLoading" class="mr-2">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -359,23 +324,51 @@ const rowCount = 4;
|
|||||||
</template>
|
</template>
|
||||||
</a-badge>
|
</a-badge>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.dataIndex === 'cookies'">
|
<template v-else-if="column.dataIndex === 'paths'">
|
||||||
<!-- 触发按钮 -->
|
<div class="path-info">
|
||||||
<a-button @click="showCookies(record)">查看</a-button>
|
<div class="path-row" v-if="record.savePath">
|
||||||
|
<span class="path-label">收藏:</span>
|
||||||
|
<a-tooltip :title="record.savePath">
|
||||||
|
<span class="path-value">{{ record.savePath }}</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="path-row" v-if="record.favSavePath">
|
||||||
|
<span class="path-label">喜欢:</span>
|
||||||
|
<a-tooltip :title="record.favSavePath">
|
||||||
|
<span class="path-value">{{ record.favSavePath }}</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="path-row" v-if="record.upSavePath">
|
||||||
|
<span class="path-label">Up主:</span>
|
||||||
|
<a-tooltip :title="record.upSavePath">
|
||||||
|
<span class="path-value">{{ record.upSavePath }}</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.dataIndex === 'upSecUserIds'">
|
<template v-else-if="column.dataIndex === 'userIds'">
|
||||||
<!-- 触发按钮 -->
|
<div class="userid-info">
|
||||||
<a-button @click="showUpers(record)">查看</a-button>
|
<div class="userid-row" v-if="record.secUserId">
|
||||||
|
<span class="userid-label">用户:</span>
|
||||||
|
<a-tooltip :title="record.secUserId">
|
||||||
|
<span class="userid-value">{{ record.secUserId }}</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="userid-row" v-if="record.upSecUserIds">
|
||||||
|
<span class="userid-label">Up主:</span>
|
||||||
|
<a-button size="small" type="link" @click="showUpers(record)" style="padding: 0; height: auto;">查看</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.dataIndex === 'edit'">
|
<template v-else-if="column.dataIndex === 'edit'">
|
||||||
<a-button :disabled="showModal" type="link" @click="edit(record)">
|
<a-button :disabled="showModal" type="link" @click="edit(record)" size="small">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<EditFilled />
|
<EditFilled />
|
||||||
</template>
|
</template>
|
||||||
编辑
|
编辑
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<a-button type="link" @click="deleted(record.id)" danger>
|
<a-button type="link" @click="deleted(record.id)" danger size="small">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<DeleteFilled />
|
<DeleteFilled />
|
||||||
</template>
|
</template>
|
||||||
@@ -388,17 +381,13 @@ const rowCount = 4;
|
|||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
|
|
||||||
<!-- Modal弹窗 -->
|
|
||||||
<a-modal title="Cookie 详情" :visible="showCookiesModal" style="width:1200px;" @cancel="showCookiesModal = false" @ok="showCookiesModal = false">
|
<a-modal title="Cookie 详情" :visible="showCookiesModal" style="width:1200px;" @cancel="showCookiesModal = false" @ok="showCookiesModal = false">
|
||||||
<!-- 弹窗内容 -->
|
|
||||||
<div class="cookie-content">
|
<div class="cookie-content">
|
||||||
{{ showCookiesData || '无Cookie数据' }}
|
{{ showCookiesData || '无Cookie数据' }}
|
||||||
</div>
|
</div>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<!-- Modal弹窗 -->
|
|
||||||
<a-modal title="要同步的Up主信息" :visible="showUpersModal" style="width:1200px;" @cancel="showUpersModal = false" @ok="showUpersModal = false">
|
<a-modal title="要同步的Up主信息" :visible="showUpersModal" style="width:1200px;" @cancel="showUpersModal = false" @ok="showUpersModal = false">
|
||||||
<!-- 弹窗内容 -->
|
|
||||||
<div class="cookie-content">
|
<div class="cookie-content">
|
||||||
{{ showUpersData || '未设置' }}
|
{{ showUpersData || '未设置' }}
|
||||||
</div>
|
</div>
|
||||||
@@ -414,100 +403,114 @@ const rowCount = 4;
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 透明滚动条样式 - WebKit内核浏览器 */
|
|
||||||
.cookie-content::-webkit-scrollbar {
|
|
||||||
width: 6px; /* 更细的滚动条 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 轨道完全透明 */
|
|
||||||
.cookie-content::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 滑块半透明(默认几乎看不见) */
|
|
||||||
.cookie-content::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(150, 150, 150, 0.2); /* 浅灰透明 */
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* hover时稍微显示一点 */
|
|
||||||
.cookie-content::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(150, 150, 150, 0.4); /* 略深一点的透明 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 角落也透明 */
|
|
||||||
.cookie-content::-webkit-scrollbar-corner {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Firefox 透明滚动条适配 */
|
|
||||||
.cookie-content {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(150, 150, 150, 0.2) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cookie-content {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 10px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.cookie-content::-webkit-scrollbar {
|
.cookie-content::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cookie-content::-webkit-scrollbar-track {
|
.cookie-content::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cookie-content::-webkit-scrollbar-thumb {
|
.cookie-content::-webkit-scrollbar-thumb {
|
||||||
background: rgba(150, 150, 150, 0.2);
|
background: rgba(150, 150, 150, 0.2);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cookie-content::-webkit-scrollbar-thumb:hover {
|
.cookie-content::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(150, 150, 150, 0.4);
|
background: rgba(150, 150, 150, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cookie-content::-webkit-scrollbar-corner {
|
.cookie-content::-webkit-scrollbar-corner {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cookie-content {
|
.cookie-content {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(150, 150, 150, 0.2) transparent;
|
scrollbar-color: rgba(150, 150, 150, 0.2) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------- 新增:a-textarea 透明滚动条 ---------------------- */
|
|
||||||
/* 1. 穿透 scoped,定位 a-textarea 内部的原生 textarea 元素 */
|
|
||||||
:deep(.ant-input-textarea-input) {
|
:deep(.ant-input-textarea-input) {
|
||||||
/* 确保内容超出时显示滚动条(a-textarea 默认已配置,可省略) */
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
/* Firefox 透明滚动条:thin 细滚动条 + 滑块颜色/轨道颜色 */
|
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(150, 150, 150, 0.2) transparent;
|
scrollbar-color: rgba(150, 150, 150, 0.2) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2. WebKit 浏览器(Chrome/Safari/Edge)透明滚动条 */
|
|
||||||
/* 滚动条宽度 */
|
|
||||||
:deep(.ant-input-textarea-input)::-webkit-scrollbar {
|
:deep(.ant-input-textarea-input)::-webkit-scrollbar {
|
||||||
width: 6px; /* 与 cookie-content 保持一致的细滚动条 */
|
width: 6px;
|
||||||
height: 6px; /* 横向滚动条(如需) */
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动条轨道(完全透明) */
|
|
||||||
:deep(.ant-input-textarea-input)::-webkit-scrollbar-track {
|
:deep(.ant-input-textarea-input)::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动条滑块(半透明,hover 时加深) */
|
|
||||||
:deep(.ant-input-textarea-input)::-webkit-scrollbar-thumb {
|
:deep(.ant-input-textarea-input)::-webkit-scrollbar-thumb {
|
||||||
background: rgba(150, 150, 150, 0.2); /* 浅灰透明,默认几乎看不见 */
|
background: rgba(150, 150, 150, 0.2);
|
||||||
border-radius: 3px; /* 圆角优化 */
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
:deep(.ant-input-textarea-input)::-webkit-scrollbar-thumb:hover {
|
:deep(.ant-input-textarea-input)::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(150, 150, 150, 0.4); /* hover 时略深,提升交互感知 */
|
background: rgba(150, 150, 150, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动条角落(完全透明,避免留白) */
|
|
||||||
:deep(.ant-input-textarea-input)::-webkit-scrollbar-corner {
|
:deep(.ant-input-textarea-input)::-webkit-scrollbar-corner {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
.path-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-value {
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userid-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userid-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userid-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userid-value {
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -16,6 +16,47 @@
|
|||||||
<a-alert message="每次扫描行数,第一次同步完成后,可以适当调高提高效率" type="success" />
|
<a-alert message="每次扫描行数,第一次同步完成后,可以适当调高提高效率" type="success" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-divider orientation="left">下载配置</a-divider>
|
||||||
|
<a-alert message="默认情况下,系统不会自动下载视频,避免占用过多存储空间。如需下载,请配置以下选项。" type="info" style="margin-bottom: 16px;" />
|
||||||
|
|
||||||
|
<a-form-item label="启用自动下载">
|
||||||
|
<a-switch v-model:checked="formState.EnableAutoDownload" />
|
||||||
|
<a-alert message="关闭后,系统只同步视频信息到数据库,不会下载视频文件" type="warning" style="margin-top: 8px;" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="下载时间范围(天)" v-if="formState.EnableAutoDownload">
|
||||||
|
<a-input-number v-model:value="formState.DownloadDaysRange" :min="0" :max="3650" style="width: 200px;" />
|
||||||
|
<a-alert message="0表示不限制时间,只下载指定天数内的视频(例如:30表示只下载最近30天的视频)" type="success" style="margin-top: 8px;" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="下载喜欢的视频" v-if="formState.EnableAutoDownload">
|
||||||
|
<a-switch v-model:checked="formState.DownloadFavorite" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="下载收藏的视频" v-if="formState.EnableAutoDownload">
|
||||||
|
<a-switch v-model:checked="formState.DownloadCollect" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="作者白名单" v-if="formState.EnableAutoDownload">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="formState.AuthorWhitelist"
|
||||||
|
placeholder="每行一个作者名称或ID,留空则不限制 例如: 作者1 作者2 MS4wLjABAAAA..."
|
||||||
|
:rows="4"
|
||||||
|
style="width: 500px;"
|
||||||
|
/>
|
||||||
|
<a-alert message="配置后,只有白名单中的作者视频才会被下载。留空则不限制。" type="success" style="margin-top: 8px;" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="作者黑名单" v-if="formState.EnableAutoDownload">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="formState.AuthorBlacklist"
|
||||||
|
placeholder="每行一个作者名称或ID,留空则不限制 例如: 作者1 作者2 MS4wLjABAAAA..."
|
||||||
|
:rows="4"
|
||||||
|
style="width: 500px;"
|
||||||
|
/>
|
||||||
|
<a-alert message="配置后,黑名单中的作者视频不会被下载。留空则不限制。" type="success" style="margin-top: 8px;" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item :wrapper-col="{ span: 10, offset: 3 }">
|
<a-form-item :wrapper-col="{ span: 10, offset: 3 }">
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button type="primary" @click="onUpdate" v-if="componentDisabled">修改配置</a-button>
|
<a-button type="primary" @click="onUpdate" v-if="componentDisabled">修改配置</a-button>
|
||||||
@@ -43,11 +84,23 @@ interface FormState {
|
|||||||
Cron: string;
|
Cron: string;
|
||||||
Id: string;
|
Id: string;
|
||||||
BatchCount: number;
|
BatchCount: number;
|
||||||
|
EnableAutoDownload?: boolean;
|
||||||
|
DownloadDaysRange?: number;
|
||||||
|
AuthorWhitelist?: string;
|
||||||
|
AuthorBlacklist?: string;
|
||||||
|
DownloadFavorite?: boolean;
|
||||||
|
DownloadCollect?: boolean;
|
||||||
}
|
}
|
||||||
const formState: UnwrapRef<FormState> = reactive({
|
const formState: UnwrapRef<FormState> = reactive({
|
||||||
Cron: '30',
|
Cron: '30',
|
||||||
Id: '0',
|
Id: '0',
|
||||||
BatchCount: 10,
|
BatchCount: 10,
|
||||||
|
EnableAutoDownload: false,
|
||||||
|
DownloadDaysRange: 0,
|
||||||
|
AuthorWhitelist: '',
|
||||||
|
AuthorBlacklist: '',
|
||||||
|
DownloadFavorite: false,
|
||||||
|
DownloadCollect: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rules: Record<string, Rule[]> = {
|
const rules: Record<string, Rule[]> = {
|
||||||
@@ -62,6 +115,21 @@ const getConfig = () => {
|
|||||||
formState.Cron = res.data.cron;
|
formState.Cron = res.data.cron;
|
||||||
formState.Id = res.data.id;
|
formState.Id = res.data.id;
|
||||||
formState.BatchCount = res.data.batchCount;
|
formState.BatchCount = res.data.batchCount;
|
||||||
|
// 下载配置
|
||||||
|
formState.EnableAutoDownload = res.data.enableAutoDownload ?? false;
|
||||||
|
formState.DownloadDaysRange = res.data.downloadDaysRange ?? 0;
|
||||||
|
try {
|
||||||
|
formState.AuthorWhitelist = res.data.authorWhitelist ? JSON.parse(res.data.authorWhitelist).join('\n') : '';
|
||||||
|
} catch {
|
||||||
|
formState.AuthorWhitelist = '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
formState.AuthorBlacklist = res.data.authorBlacklist ? JSON.parse(res.data.authorBlacklist).join('\n') : '';
|
||||||
|
} catch {
|
||||||
|
formState.AuthorBlacklist = '';
|
||||||
|
}
|
||||||
|
formState.DownloadFavorite = res.data.downloadFavorite ?? false;
|
||||||
|
formState.DownloadCollect = res.data.downloadCollect ?? false;
|
||||||
} else {
|
} else {
|
||||||
message.error(res.erro, 8);
|
message.error(res.erro, 8);
|
||||||
}
|
}
|
||||||
@@ -79,12 +147,32 @@ const onSubmit = () => {
|
|||||||
.validate()
|
.validate()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('values', formState, toRaw(formState));
|
console.log('values', formState, toRaw(formState));
|
||||||
|
// 处理作者白名单和黑名单,转换为JSON数组
|
||||||
|
const submitData = { ...toRaw(formState) };
|
||||||
|
if (submitData.AuthorWhitelist) {
|
||||||
|
const whitelist = submitData.AuthorWhitelist.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line.length > 0);
|
||||||
|
submitData.AuthorWhitelist = whitelist.length > 0 ? JSON.stringify(whitelist) : '';
|
||||||
|
} else {
|
||||||
|
submitData.AuthorWhitelist = '';
|
||||||
|
}
|
||||||
|
if (submitData.AuthorBlacklist) {
|
||||||
|
const blacklist = submitData.AuthorBlacklist.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line.length > 0);
|
||||||
|
submitData.AuthorBlacklist = blacklist.length > 0 ? JSON.stringify(blacklist) : '';
|
||||||
|
} else {
|
||||||
|
submitData.AuthorBlacklist = '';
|
||||||
|
}
|
||||||
|
|
||||||
useApiStore()
|
useApiStore()
|
||||||
.apiUpdateConfig(toRaw(formState))
|
.apiUpdateConfig(submitData)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
message.success('配置修改生效,同步任务将按照新的规则执行');
|
message.success('配置修改生效,同步任务将按照新的规则执行');
|
||||||
componentDisabled.value = true;
|
componentDisabled.value = true;
|
||||||
|
getConfig(); // 重新加载配置
|
||||||
} else {
|
} else {
|
||||||
message.error(res.erro, 8);
|
message.error(res.erro, 8);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,19 @@
|
|||||||
<a-form-item label="抖音作者" ref="author" name="author">
|
<a-form-item label="抖音作者" ref="author" name="author">
|
||||||
<a-input v-model:value="quaryData.author"></a-input>
|
<a-input v-model:value="quaryData.author"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="视频类型">
|
||||||
|
<a-select
|
||||||
|
v-model:value="selectedCategories"
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="请选择视频类型(可多选)"
|
||||||
|
style="width: 300px"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:loading="categoryLoading"
|
||||||
|
allow-clear
|
||||||
|
@change="onCategoryChanged"
|
||||||
|
>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<a-radio-group v-model:value="quaryData.viedoType" button-style="solid" @change="onViedoTypeChanged">
|
<a-radio-group v-model:value="quaryData.viedoType" button-style="solid" @change="onViedoTypeChanged">
|
||||||
<a-radio-button value="*">全部</a-radio-button>
|
<a-radio-button value="*">全部</a-radio-button>
|
||||||
@@ -15,69 +28,165 @@
|
|||||||
<a-radio-button value="3">关注的</a-radio-button>
|
<a-radio-button value="3">关注的</a-radio-button>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="删除状态">
|
||||||
|
<a-select
|
||||||
|
v-model:value="quaryData.isDeleted"
|
||||||
|
placeholder="全部"
|
||||||
|
style="width: 120px"
|
||||||
|
allow-clear
|
||||||
|
@change="handleQuery"
|
||||||
|
>
|
||||||
|
<a-select-option :value="undefined">全部</a-select-option>
|
||||||
|
<a-select-option :value="false">未删除</a-select-option>
|
||||||
|
<a-select-option :value="true">已删除</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
<a-form-item :wrapper-col="{ offset: 8, span: 16 }">
|
<a-form-item :wrapper-col="{ offset: 8, span: 16 }">
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button type="primary" @click="GetRecords">查询</a-button>
|
<a-button type="primary" @click="handleQuery">查询</a-button>
|
||||||
<a-button type="danger" @click="StartNow">立即重新同步</a-button>
|
<a-button type="danger" @click="StartNow">立即重新同步</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
<a-table :columns="columns" :data-source="dataSource" bordered :pagination="pagination" @change="handleTableChange" :loading="loading">
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
bordered
|
||||||
|
:pagination="pagination"
|
||||||
|
@change="handleTableChange"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<!-- 如果视频文件不存在,显示"已删除" -->
|
||||||
|
<span v-if="!record.videoSavePath" style="color: #999; font-size: 13px;">已删除</span>
|
||||||
|
<!-- 如果视频文件存在,显示预览按钮 -->
|
||||||
|
<a-button v-else type="link" size="small" @click="handlePreview(record)">
|
||||||
|
<template #icon><PlayCircleOutlined /></template>
|
||||||
|
预览
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
|
|
||||||
|
<!-- 视频播放器 -->
|
||||||
|
<VideoPlayer ref="videoPlayerRef" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineComponent, reactive, ref } from 'vue';
|
import { defineComponent, reactive, ref, watch } from 'vue';
|
||||||
import { useApiStore } from '@/store';
|
import { useApiStore } from '@/store';
|
||||||
import type { UnwrapRef } from 'vue';
|
import type { UnwrapRef } from 'vue';
|
||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
import locale from 'ant-design-vue/es/date-picker/locale/zh_CN';
|
import locale from 'ant-design-vue/es/date-picker/locale/zh_CN';
|
||||||
|
import { PlayCircleOutlined } from '@ant-design/icons-vue';
|
||||||
|
import VideoPlayer from '@/components/video/VideoPlayer.vue';
|
||||||
type RangeValue = [Dayjs, Dayjs];
|
type RangeValue = [Dayjs, Dayjs];
|
||||||
import 'dayjs/locale/zh-cn';
|
import 'dayjs/locale/zh-cn';
|
||||||
dayjs.locale('zh-cn');
|
dayjs.locale('zh-cn');
|
||||||
const columns = ref([
|
|
||||||
{
|
|
||||||
title: '同步时间',
|
|
||||||
dataIndex: 'syncTimeStr',
|
|
||||||
// sorter: true,
|
|
||||||
align: 'center',
|
|
||||||
width: 180,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '同步类型',
|
|
||||||
dataIndex: 'viedoTypeStr',
|
|
||||||
// sorter: true,
|
|
||||||
align: 'center',
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '视频类型',
|
|
||||||
dataIndex: 'viedoCate',
|
|
||||||
// sorter: true,
|
|
||||||
width: 300,
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '作者',
|
|
||||||
dataIndex: 'author',
|
|
||||||
// sorter: true,
|
|
||||||
align: 'center',
|
|
||||||
width: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '视频名称',
|
|
||||||
dataIndex: 'videoTitle',
|
|
||||||
// sorter: true,
|
|
||||||
align: 'left',
|
|
||||||
},
|
|
||||||
|
|
||||||
|
const videoPlayerRef = ref<InstanceType<typeof VideoPlayer> | null>(null);
|
||||||
|
|
||||||
|
// 当前排序状态
|
||||||
|
const currentSortField = ref<string | null>(null);
|
||||||
|
const currentSortOrder = ref<'asc' | 'desc' | null>(null);
|
||||||
|
|
||||||
|
// 处理列头点击排序
|
||||||
|
const handleColumnSort = (fieldKey: string) => {
|
||||||
|
console.log('列头点击排序:', fieldKey);
|
||||||
|
|
||||||
|
// 如果点击的是当前排序列,切换排序方向;否则设置为升序
|
||||||
|
if (currentSortField.value === fieldKey) {
|
||||||
|
if (currentSortOrder.value === 'asc') {
|
||||||
|
currentSortOrder.value = 'desc';
|
||||||
|
} else if (currentSortOrder.value === 'desc') {
|
||||||
|
// 第三次点击取消排序
|
||||||
|
currentSortField.value = null;
|
||||||
|
currentSortOrder.value = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentSortField.value = fieldKey;
|
||||||
|
currentSortOrder.value = 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新查询参数
|
||||||
|
quaryData.sortField = currentSortField.value;
|
||||||
|
quaryData.sortOrder = currentSortOrder.value;
|
||||||
|
|
||||||
|
console.log('设置排序参数:', {
|
||||||
|
sortField: quaryData.sortField,
|
||||||
|
sortOrder: quaryData.sortOrder
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新列配置以反映排序状态
|
||||||
|
updateColumnSortState();
|
||||||
|
|
||||||
|
// 重置到第一页并重新查询
|
||||||
|
pagination.value.current = 1;
|
||||||
|
GetRecords();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新列配置的排序状态
|
||||||
|
const updateColumnSortState = () => {
|
||||||
|
columns.value.forEach((col: any) => {
|
||||||
|
if (col.sorter && col.key) {
|
||||||
|
if (currentSortField.value === col.key) {
|
||||||
|
col.sorter.order = currentSortOrder.value === 'asc' ? 'ascend' : currentSortOrder.value === 'desc' ? 'descend' : null;
|
||||||
|
} else {
|
||||||
|
col.sorter.order = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 强制触发响应式更新
|
||||||
|
columns.value = [...columns.value];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听排序状态变化,自动更新列配置
|
||||||
|
watch([currentSortField, currentSortOrder], () => {
|
||||||
|
updateColumnSortState();
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// 创建可排序列配置
|
||||||
|
const createSortableColumn = (title: string, dataIndex: string, key: string, width?: number, align: string = 'left') => {
|
||||||
|
const col: any = {
|
||||||
|
title,
|
||||||
|
dataIndex,
|
||||||
|
key,
|
||||||
|
width,
|
||||||
|
align,
|
||||||
|
sorter: {
|
||||||
|
compare: () => 0, // 禁用前端排序
|
||||||
|
multiple: false,
|
||||||
|
order: null, // 初始化为 null,后续通过 updateColumnSortState 更新
|
||||||
|
},
|
||||||
|
customHeaderCell: () => {
|
||||||
|
return {
|
||||||
|
onClick: () => {
|
||||||
|
handleColumnSort(key);
|
||||||
|
},
|
||||||
|
style: { cursor: 'pointer' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return col;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = ref([
|
||||||
|
createSortableColumn('同步时间', 'syncTimeStr', 'syncTime', 180, 'center'),
|
||||||
|
createSortableColumn('同步类型', 'viedoTypeStr', 'viedoType', 100, 'center'),
|
||||||
|
createSortableColumn('视频类型', 'viedoCate', 'viedoCate', 300, 'center'),
|
||||||
|
createSortableColumn('作者', 'author', 'author', 150, 'center'),
|
||||||
|
createSortableColumn('视频名称', 'videoTitle', 'videoTitle', undefined, 'left'),
|
||||||
|
createSortableColumn('用户', 'dyUser', 'dyUser', 200, 'center'),
|
||||||
{
|
{
|
||||||
title: '用户',
|
title: '操作',
|
||||||
dataIndex: 'dyUser',
|
key: 'action',
|
||||||
// sorter: true,
|
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 200,
|
width: 120,
|
||||||
|
fixed: 'right',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -90,7 +199,11 @@ interface QuaryParam {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
author: string;
|
author: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
|
tags?: string[];
|
||||||
viedoType: string;
|
viedoType: string;
|
||||||
|
isDeleted?: boolean | null;
|
||||||
|
sortField?: string | null;
|
||||||
|
sortOrder?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value1 = ref<RangeValue>();
|
const value1 = ref<RangeValue>();
|
||||||
@@ -104,26 +217,127 @@ const quaryData: UnwrapRef<QuaryParam> = reactive({
|
|||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
author: '',
|
author: '',
|
||||||
tag: '',
|
tag: '',
|
||||||
|
tags: undefined,
|
||||||
viedoType: '*',
|
viedoType: '*',
|
||||||
|
isDeleted: undefined,
|
||||||
|
sortField: null,
|
||||||
|
sortOrder: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 视频类型相关
|
||||||
|
const selectedCategories = ref<string[]>([]);
|
||||||
|
const categoryOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||||
|
const categoryLoading = ref(false);
|
||||||
|
|
||||||
|
// 加载视频类型列表
|
||||||
|
const loadCategories = async () => {
|
||||||
|
categoryLoading.value = true;
|
||||||
|
try {
|
||||||
|
console.log('开始加载视频类型列表...');
|
||||||
|
const res = await useApiStore().VideoCategories();
|
||||||
|
console.log('视频类型 API 响应(完整):', JSON.stringify(res, null, 2));
|
||||||
|
console.log('视频类型 API 响应类型:', typeof res);
|
||||||
|
console.log('视频类型 API 响应是否为数组:', Array.isArray(res));
|
||||||
|
|
||||||
|
// 处理不同的响应格式
|
||||||
|
let categories: string[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
// 如果直接返回数组
|
||||||
|
categories = res;
|
||||||
|
} else if (res && typeof res === 'object') {
|
||||||
|
// 如果是对象,尝试获取 data 字段
|
||||||
|
if (res.code === 0 && res.data) {
|
||||||
|
categories = Array.isArray(res.data) ? res.data : [];
|
||||||
|
} else if (Array.isArray(res.data)) {
|
||||||
|
categories = res.data;
|
||||||
|
} else if (res.code === 0 && Array.isArray(res)) {
|
||||||
|
categories = res as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('提取的视频类型数据:', categories);
|
||||||
|
|
||||||
|
if (categories && categories.length > 0) {
|
||||||
|
categoryOptions.value = categories.map(cat => ({
|
||||||
|
label: cat || '其他',
|
||||||
|
value: cat || '其他',
|
||||||
|
}));
|
||||||
|
console.log('视频类型选项:', categoryOptions.value);
|
||||||
|
} else {
|
||||||
|
console.warn('视频类型数据为空,可能原因:1. 数据库中没有视频数据 2. 所有视频的 Tag1 和 Tag2 都为空');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载视频类型列表失败:', err);
|
||||||
|
} finally {
|
||||||
|
categoryLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频类型选择变化
|
||||||
|
const onCategoryChanged = (values: string[]) => {
|
||||||
|
selectedCategories.value = values;
|
||||||
|
if (values && values.length > 0) {
|
||||||
|
quaryData.tags = values;
|
||||||
|
quaryData.tag = ''; // 清空单标签筛选
|
||||||
|
} else {
|
||||||
|
quaryData.tags = undefined;
|
||||||
|
}
|
||||||
|
pagination.value.current = 1;
|
||||||
|
GetRecords();
|
||||||
|
};
|
||||||
|
// 查询处理函数:先刷新类型列表,再查询记录
|
||||||
|
const handleQuery = async () => {
|
||||||
|
// 先刷新视频类型列表(确保包含最新的类型)
|
||||||
|
await loadCategories();
|
||||||
|
// 然后查询记录
|
||||||
|
GetRecords();
|
||||||
|
};
|
||||||
|
|
||||||
const GetRecords = () => {
|
const GetRecords = () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
quaryData.pageIndex = pagination.value.current;
|
quaryData.pageIndex = pagination.value.current;
|
||||||
quaryData.pageSize = pagination.value.defaultPageSize;
|
quaryData.pageSize = pagination.value.defaultPageSize;
|
||||||
|
|
||||||
|
// 构建请求参数,确保排序参数被包含
|
||||||
|
const requestParams = {
|
||||||
|
...quaryData,
|
||||||
|
sortField: quaryData.sortField || null,
|
||||||
|
sortOrder: quaryData.sortOrder || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('发送查询请求,参数:', JSON.stringify(requestParams, null, 2));
|
||||||
|
|
||||||
useApiStore()
|
useApiStore()
|
||||||
.VideoPageList(quaryData)
|
.VideoPageList(requestParams)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
console.log('查询响应:', res);
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
|
console.log('接收到的数据:', res.data.data);
|
||||||
|
console.log('数据条数:', res.data.data?.length);
|
||||||
|
if (res.data.data && res.data.data.length > 0) {
|
||||||
|
console.log('第一条数据的同步时间:', res.data.data[0].syncTimeStr);
|
||||||
|
console.log('最后一条数据的同步时间:', res.data.data[res.data.data.length - 1].syncTimeStr);
|
||||||
|
}
|
||||||
|
|
||||||
dataSource.value = res.data.data;
|
dataSource.value = res.data.data;
|
||||||
pagination.value.current = res.data.pageIndex;
|
pagination.value.current = res.data.pageIndex;
|
||||||
pagination.value.defaultPageSize = res.data.pageSize;
|
pagination.value.defaultPageSize = res.data.pageSize;
|
||||||
pagination.value.total = res.data.total;
|
pagination.value.total = res.data.total;
|
||||||
pagination.value.showTotal = () => `共 ${res.data.total} 条`;
|
pagination.value.showTotal = () => `共 ${res.data.total} 条`;
|
||||||
|
|
||||||
|
// 数据加载完成后,确保排序状态正确显示
|
||||||
|
updateColumnSortState();
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
loading.value = false;
|
||||||
|
console.error('查询失败:', err);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadCategories(); // 加载视频类型列表
|
||||||
GetRecords();
|
GetRecords();
|
||||||
});
|
});
|
||||||
const pagination = ref({
|
const pagination = ref({
|
||||||
@@ -133,12 +347,88 @@ const pagination = ref({
|
|||||||
showTotal: () => `共 ${0} 条`,
|
showTotal: () => `共 ${0} 条`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleTableChange = (e) => {
|
const handleTableChange = (e: any) => {
|
||||||
console.log(e);
|
console.log('表格变化事件:', e);
|
||||||
pagination.value.current = e.current;
|
console.log('事件对象键:', Object.keys(e));
|
||||||
pagination.value.defaultPageSize = e.defaultPageSize;
|
console.log('sorter 属性:', e.sorter);
|
||||||
pagination.value.total = e.total;
|
|
||||||
pagination.value.showTotal = () => `共 ${e.total} 条`;
|
// 处理分页变化
|
||||||
|
if (e.current !== undefined) {
|
||||||
|
pagination.value.current = e.current;
|
||||||
|
}
|
||||||
|
if (e.pageSize !== undefined) {
|
||||||
|
pagination.value.defaultPageSize = e.pageSize;
|
||||||
|
}
|
||||||
|
if (e.total !== undefined) {
|
||||||
|
pagination.value.total = e.total;
|
||||||
|
pagination.value.showTotal = () => `共 ${e.total} 条`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理排序变化(Ant Design Vue 3.x 中排序通过 change 事件的 sorter 属性传递)
|
||||||
|
if (e.sorter) {
|
||||||
|
console.log('检测到排序变化:', e.sorter);
|
||||||
|
handleSorterChange(e.sorter);
|
||||||
|
} else {
|
||||||
|
// 如果没有排序变化,直接查询
|
||||||
|
console.log('无排序变化,直接查询');
|
||||||
|
GetRecords();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理排序变化
|
||||||
|
const handleSorterChange = (sorter: any) => {
|
||||||
|
console.log('排序变化事件:', sorter);
|
||||||
|
console.log('排序变化类型:', typeof sorter, Array.isArray(sorter) ? '数组' : '对象');
|
||||||
|
console.log('当前查询参数:', quaryData);
|
||||||
|
|
||||||
|
// Ant Design Vue 3.x 的 sorterChange 可能传递单个对象或对象数组
|
||||||
|
let activeSorter: any = null;
|
||||||
|
|
||||||
|
if (Array.isArray(sorter)) {
|
||||||
|
// 如果是数组,找到第一个有 order 的排序器
|
||||||
|
activeSorter = sorter.find((s: any) => s && s.order);
|
||||||
|
} else if (sorter && typeof sorter === 'object') {
|
||||||
|
// 如果是单个对象
|
||||||
|
activeSorter = sorter;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('活动排序器:', activeSorter);
|
||||||
|
|
||||||
|
if (!activeSorter || !activeSorter.field || !activeSorter.order) {
|
||||||
|
// 没有排序或取消排序
|
||||||
|
quaryData.sortField = null;
|
||||||
|
quaryData.sortOrder = null;
|
||||||
|
console.log('取消排序');
|
||||||
|
} else {
|
||||||
|
// 映射前端字段名(key)到后端字段名
|
||||||
|
const fieldMap: Record<string, string> = {
|
||||||
|
'syncTime': 'syncTime',
|
||||||
|
'viedoType': 'viedoType',
|
||||||
|
'viedoCate': 'viedoCate',
|
||||||
|
'author': 'author',
|
||||||
|
'videoTitle': 'videoTitle',
|
||||||
|
'dyUser': 'dyUser',
|
||||||
|
};
|
||||||
|
|
||||||
|
const field = activeSorter.field;
|
||||||
|
const order = activeSorter.order;
|
||||||
|
const backendField = fieldMap[field] || field;
|
||||||
|
|
||||||
|
// 有排序:ascend -> asc, descend -> desc
|
||||||
|
quaryData.sortField = backendField;
|
||||||
|
quaryData.sortOrder = order === 'ascend' ? 'asc' : 'desc';
|
||||||
|
|
||||||
|
console.log('设置排序参数:', {
|
||||||
|
field: field,
|
||||||
|
backendField: backendField,
|
||||||
|
order: order,
|
||||||
|
sortField: quaryData.sortField,
|
||||||
|
sortOrder: quaryData.sortOrder
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置到第一页并重新查询
|
||||||
|
pagination.value.current = 1;
|
||||||
GetRecords();
|
GetRecords();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,6 +449,36 @@ const onViedoTypeChanged = (e) => {
|
|||||||
pagination.value.current = 1;
|
pagination.value.current = 1;
|
||||||
GetRecords();
|
GetRecords();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 预览视频
|
||||||
|
const handlePreview = (record: any) => {
|
||||||
|
console.log('handlePreview called', record);
|
||||||
|
|
||||||
|
if (!record.id) {
|
||||||
|
console.error('视频记录没有 id', record);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoPlayerRef.value) {
|
||||||
|
console.error('videoPlayerRef.value 为 null,组件可能未正确挂载');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('调用 videoPlayerRef.value.open');
|
||||||
|
try {
|
||||||
|
videoPlayerRef.value.open(record.id, {
|
||||||
|
id: record.id,
|
||||||
|
videoTitle: record.videoTitle || '未知标题',
|
||||||
|
author: record.author || '未知作者',
|
||||||
|
viedoCate: record.viedoCate || '未分类',
|
||||||
|
resolution: record.resolution || '未知',
|
||||||
|
fileSize: record.fileSize || 0,
|
||||||
|
videoSavePath: record.videoSavePath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('打开视频播放器失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+1126
-23
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,34 @@ export const useApiStore = defineStore('coreapi', () => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理数据库中文件不存在的视频记录
|
||||||
|
async function VideoCleanup() {
|
||||||
|
return http.request<any, Response<any>>('/api/video/cleanup', 'post_json').then(r => {
|
||||||
|
return r.data;
|
||||||
|
}).finally(() => {
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有视频类型列表
|
||||||
|
async function VideoCategories() {
|
||||||
|
return http.request<any, Response<any>>('/api/video/categories', 'get').then(r => {
|
||||||
|
console.log('VideoCategories 原始响应:', r);
|
||||||
|
// 响应拦截器已经处理了 code === 0 的情况,直接返回 data
|
||||||
|
// 如果 r 本身就是 Response 对象,返回 r.data
|
||||||
|
// 如果 r 已经是 data(拦截器处理后的结果),直接返回
|
||||||
|
if (r && typeof r === 'object' && 'code' in r && 'data' in r) {
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('VideoCategories API 错误:', err);
|
||||||
|
throw err;
|
||||||
|
}).finally(() => {
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
//cookies
|
//cookies
|
||||||
async function CookiePageList(param: object) {
|
async function CookiePageList(param: object) {
|
||||||
return http.request<any, Response<any>>('/api/config/paged', 'post_json', param).then(r => {
|
return http.request<any, Response<any>>('/api/config/paged', 'post_json', param).then(r => {
|
||||||
@@ -163,6 +191,15 @@ export const useApiStore = defineStore('coreapi', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除指定作者的所有视频数据
|
||||||
|
async function DeleteAuthorVideos(authorName: string) {
|
||||||
|
return http.request<any, Response<any>>('/api/video/delete-author', 'post_json', { authorName }).then(r => {
|
||||||
|
return r.data;
|
||||||
|
}).finally(() => {
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deleteCookie,
|
deleteCookie,
|
||||||
UpdateConfig,
|
UpdateConfig,
|
||||||
@@ -176,6 +213,9 @@ export const useApiStore = defineStore('coreapi', () => {
|
|||||||
StartJobNow,
|
StartJobNow,
|
||||||
VideoStatics,
|
VideoStatics,
|
||||||
VideoPageList,
|
VideoPageList,
|
||||||
CookiePageList
|
VideoCategories,
|
||||||
|
VideoCleanup,
|
||||||
|
CookiePageList,
|
||||||
|
DeleteAuthorVideos
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,13 +41,20 @@ http.interceptors.response.use(
|
|||||||
(error) => {
|
(error) => {
|
||||||
// console.log(error)
|
// console.log(error)
|
||||||
// debugger
|
// debugger
|
||||||
if (error.response.status === 401) {
|
if (error.response && error.response.status === 401) {
|
||||||
useAccountStore().setLogged(false)
|
useAccountStore().setLogged(false);
|
||||||
|
return Promise.reject({
|
||||||
|
message: '未授权,请重新登录',
|
||||||
|
code: 401,
|
||||||
|
error: 'Unauthorized',
|
||||||
|
data: error.response.data,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if (error.response && isAxiosResponse(error.response)) {
|
if (error.response && isAxiosResponse(error.response)) {
|
||||||
return Promise.reject({
|
return Promise.reject({
|
||||||
message: error.response.statusText,
|
message: error.response.statusText,
|
||||||
code: error.response.status,
|
code: error.response.status,
|
||||||
|
error: error.response.data?.error || error.response.statusText,
|
||||||
data: error.response.data,
|
data: error.response.data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-13
@@ -83,20 +83,18 @@ function toUrlencoded(params?: Record<string | number, any>) {
|
|||||||
function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
|
function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
|
||||||
const _axios = axios.create(config);
|
const _axios = axios.create(config);
|
||||||
|
|
||||||
// 添加响应拦截器处理401状态
|
// 添加请求拦截器,自动添加Authorization header
|
||||||
_axios.interceptors.response.use(
|
_axios.interceptors.request.use(
|
||||||
// 成功响应直接返回
|
(config) => {
|
||||||
response => response,
|
// 从Cookie中读取Authorization token
|
||||||
// 错误响应处理
|
const token = Cookie.get(config.xsrfCookieName || 'Authorization');
|
||||||
error => {
|
if (token && config.headers) {
|
||||||
// 检查是否是401未授权错误
|
// 将token添加到请求头
|
||||||
if (error.response && error.response.status === 401) {
|
config.headers[config.xsrfHeaderName || 'Authorization'] = token;
|
||||||
// 调用removeAuthorization方法清除token
|
|
||||||
http.removeAuthorization();
|
|
||||||
|
|
||||||
// 这里可以添加额外的处理,比如跳转到登录页
|
|
||||||
// 示例: window.location.href = '/login';
|
|
||||||
}
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -155,6 +153,24 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 添加响应拦截器处理401状态(在http对象创建后)
|
||||||
|
_axios.interceptors.response.use(
|
||||||
|
// 成功响应直接返回
|
||||||
|
response => response,
|
||||||
|
// 错误响应处理
|
||||||
|
error => {
|
||||||
|
// 检查是否是401未授权错误
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
// 清除token
|
||||||
|
http.removeAuthorization();
|
||||||
|
|
||||||
|
// 这里可以添加额外的处理,比如跳转到登录页
|
||||||
|
// 示例: window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return http;
|
return http;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -34,10 +34,10 @@ export default ({ command, mode }) => {
|
|||||||
open: false, //自动打开
|
open: false, //自动打开
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: env.VITE_API_URL,
|
target: env.VITE_API_URL || 'http://localhost:10101',
|
||||||
ws: true,
|
ws: true,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\//, ''),
|
// 不重写路径,保持 /api 前缀
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
hmr: true,
|
hmr: true,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
dysync:
|
||||||
|
image: dysync.net:latest
|
||||||
|
container_name: dysync
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "10101:10101"
|
||||||
|
volumes:
|
||||||
|
# ⚠️ 重要:已配置为 /vol2/1000/docker/dysync 路径
|
||||||
|
# 数据库目录(必须配置,防止数据丢失)
|
||||||
|
- /vol2/1000/docker/dysync/db:/app/db
|
||||||
|
# 收藏视频目录
|
||||||
|
- /vol2/1000/docker/dysync/collect:/app/collect
|
||||||
|
# 喜欢视频目录
|
||||||
|
- /vol2/1000/docker/dysync/favorite:/app/favorite
|
||||||
|
# 博主视频目录(可选)
|
||||||
|
- /vol2/1000/docker/dysync/uper:/app/uper
|
||||||
|
# 日志目录(可选,方便查看日志)
|
||||||
|
- /vol2/1000/docker/dysync/logs:/app/logs
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
# 配置DNS服务器(解决域名解析失败导致的无法访问外网)
|
||||||
|
dns:
|
||||||
|
- 8.8.8.8 # Google DNS
|
||||||
|
- 114.114.114.114 # 国内DNS
|
||||||
|
- 223.5.5.5 # 阿里DNS
|
||||||
|
# 网络模式
|
||||||
|
network_mode: bridge
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# Docker 构建 403 Forbidden 错误解决方案
|
||||||
|
|
||||||
|
## 当前问题
|
||||||
|
|
||||||
|
**错误信息**:
|
||||||
|
```
|
||||||
|
403 Forbidden from https://dockerhub.azk8s.cn/v2/library/node/manifests/18-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- 某些镜像加速器返回 403,可能是需要认证或已限制访问
|
||||||
|
- 网络环境可能有限制
|
||||||
|
|
||||||
|
## 🚀 解决方案(按推荐顺序)
|
||||||
|
|
||||||
|
### 方案一:移除有问题的镜像源,只保留阿里云(最简单)
|
||||||
|
|
||||||
|
**已更新配置**:只保留阿里云镜像源
|
||||||
|
|
||||||
|
**操作步骤**:
|
||||||
|
1. **重启 Docker Desktop**(必须)
|
||||||
|
- 完全退出 Docker Desktop
|
||||||
|
- 重新启动
|
||||||
|
|
||||||
|
2. **尝试构建**
|
||||||
|
```bash
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案二:手动拉取镜像(推荐)
|
||||||
|
|
||||||
|
如果构建时拉取失败,可以尝试手动拉取:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 尝试手动拉取 node 镜像(会使用配置的镜像源)
|
||||||
|
docker pull node:18-alpine
|
||||||
|
|
||||||
|
# 如果成功,再拉取其他镜像
|
||||||
|
docker pull mcr.microsoft.com/dotnet/sdk:6.0
|
||||||
|
docker pull mcr.microsoft.com/dotnet/aspnet:6.0
|
||||||
|
|
||||||
|
# 然后再构建(此时会使用本地已拉取的镜像)
|
||||||
|
docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案三:使用阿里云专属加速器(最稳定)
|
||||||
|
|
||||||
|
如果你有阿里云账号:
|
||||||
|
|
||||||
|
1. **登录阿里云容器镜像服务**
|
||||||
|
- 访问:https://cr.console.aliyun.com/
|
||||||
|
- 登录你的阿里云账号
|
||||||
|
|
||||||
|
2. **获取专属加速地址**
|
||||||
|
- 进入 "镜像加速器" 页面
|
||||||
|
- 复制你的专属加速地址(格式:`https://xxxxx.mirror.aliyuncs.com`)
|
||||||
|
|
||||||
|
3. **更新 Docker 配置**
|
||||||
|
```bash
|
||||||
|
cat > ~/.docker/daemon.json << EOF
|
||||||
|
{
|
||||||
|
"builder": {
|
||||||
|
"gc": {
|
||||||
|
"defaultKeepStorage": "20GB",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"experimental": false,
|
||||||
|
"registry-mirrors": [
|
||||||
|
"https://你的专属地址.mirror.aliyuncs.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **重启 Docker Desktop**
|
||||||
|
|
||||||
|
5. **重新构建**
|
||||||
|
|
||||||
|
### 方案四:配置 Docker Desktop 代理(如果有代理)
|
||||||
|
|
||||||
|
如果你有可用的 HTTP/HTTPS 代理:
|
||||||
|
|
||||||
|
1. **打开 Docker Desktop 设置**
|
||||||
|
- 点击状态栏 Docker 图标 → Settings
|
||||||
|
|
||||||
|
2. **配置代理**
|
||||||
|
- 进入 "Resources" → "Proxies"
|
||||||
|
- 启用 "Manual proxy configuration"
|
||||||
|
- 填写代理地址和端口(例如:`127.0.0.1:7890`)
|
||||||
|
- 点击 "Apply & Restart"
|
||||||
|
|
||||||
|
3. **移除镜像加速器配置**(使用代理时不需要)
|
||||||
|
```bash
|
||||||
|
cat > ~/.docker/daemon.json << EOF
|
||||||
|
{
|
||||||
|
"builder": {
|
||||||
|
"gc": {
|
||||||
|
"defaultKeepStorage": "20GB",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"experimental": false
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **重启 Docker Desktop**
|
||||||
|
|
||||||
|
5. **重新构建**
|
||||||
|
|
||||||
|
### 方案五:使用 VPN 或科学上网工具
|
||||||
|
|
||||||
|
如果你有 VPN 或科学上网工具:
|
||||||
|
|
||||||
|
1. **启动 VPN/科学上网工具**
|
||||||
|
2. **配置系统代理**(如果工具支持)
|
||||||
|
3. **在 Docker Desktop 中配置代理**(参考方案四)
|
||||||
|
4. **重新构建**
|
||||||
|
|
||||||
|
### 方案六:跳过前端构建,只构建后端(临时方案)
|
||||||
|
|
||||||
|
如果前端构建一直失败,可以临时跳过前端构建:
|
||||||
|
|
||||||
|
1. **修改 Dockerfile.prod**,注释掉前端构建部分
|
||||||
|
2. **只构建后端**
|
||||||
|
3. **前端可以单独部署或后续再处理**
|
||||||
|
|
||||||
|
**注意**:这不是推荐方案,因为前端需要包含在镜像中。
|
||||||
|
|
||||||
|
### 方案七:在飞牛 NAS 上直接构建
|
||||||
|
|
||||||
|
如果本地构建一直失败,可以考虑:
|
||||||
|
|
||||||
|
1. **将项目文件上传到飞牛 NAS**
|
||||||
|
2. **通过 SSH 连接到飞牛 NAS**
|
||||||
|
3. **在 NAS 上直接构建**(NAS 的网络环境可能不同)
|
||||||
|
|
||||||
|
## 🔍 诊断命令
|
||||||
|
|
||||||
|
运行以下命令帮助诊断:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 检查当前镜像源配置
|
||||||
|
docker info | grep -A 10 "Registry Mirrors"
|
||||||
|
|
||||||
|
# 2. 测试拉取镜像
|
||||||
|
docker pull node:18-alpine
|
||||||
|
|
||||||
|
# 3. 查看 Docker 日志(如果有错误)
|
||||||
|
docker system events
|
||||||
|
|
||||||
|
# 4. 检查网络连接
|
||||||
|
curl -I https://registry.cn-hangzhou.aliyuncs.com/v2/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 当前配置
|
||||||
|
|
||||||
|
已更新 `~/.docker/daemon.json`,只保留阿里云镜像源:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registry-mirrors": [
|
||||||
|
"https://registry.cn-hangzhou.aliyuncs.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**重要**:需要重启 Docker Desktop 才能生效!
|
||||||
|
|
||||||
|
## 🎯 推荐操作顺序
|
||||||
|
|
||||||
|
1. ✅ **先试方案一**:重启 Docker Desktop,使用更新后的配置
|
||||||
|
2. 如果还不行,**试方案二**:手动拉取镜像
|
||||||
|
3. 如果还不行,**试方案三**:配置阿里云专属加速器
|
||||||
|
4. 如果还不行,**试方案四**:配置代理(如果有)
|
||||||
|
5. 最后考虑**方案七**:在 NAS 上构建
|
||||||
|
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
# Mac M1 芯片环境安装指南
|
||||||
|
|
||||||
|
本文档详细说明如何在 Mac M1 芯片上安装构建 dysync.net 项目所需的环境。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 需要安装的工具
|
||||||
|
|
||||||
|
1. **Docker Desktop** - 用于构建和运行 Docker 镜像
|
||||||
|
2. **.NET 6.0 SDK** - 用于构建后端项目
|
||||||
|
3. **Node.js 和 pnpm** - 用于构建前端项目
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 1. 安装 Docker Desktop
|
||||||
|
|
||||||
|
### 方法一:使用 Homebrew(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装 Homebrew(如果还没有)
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
|
|
||||||
|
# 2. 使用 Homebrew 安装 Docker Desktop
|
||||||
|
brew install --cask docker
|
||||||
|
|
||||||
|
# 3. 启动 Docker Desktop
|
||||||
|
open -a Docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法二:直接下载安装
|
||||||
|
|
||||||
|
1. **访问 Docker 官网**
|
||||||
|
- 打开浏览器访问:https://www.docker.com/products/docker-desktop
|
||||||
|
- 或直接下载:https://desktop.docker.com/mac/main/arm64/Docker.dmg
|
||||||
|
|
||||||
|
2. **安装 Docker Desktop**
|
||||||
|
- 下载完成后,双击 `Docker.dmg` 文件
|
||||||
|
- 将 Docker 图标拖拽到 Applications 文件夹
|
||||||
|
- 在 Applications 中找到 Docker,双击启动
|
||||||
|
|
||||||
|
3. **首次启动配置**
|
||||||
|
- 首次启动需要输入管理员密码
|
||||||
|
- 等待 Docker 启动完成(状态栏会显示 Docker 图标)
|
||||||
|
- 点击状态栏的 Docker 图标,选择 "Settings" → "General",确保 "Use Rosetta for x86/amd64 emulation on Apple Silicon" 已勾选(如果需要运行 x86 镜像)
|
||||||
|
|
||||||
|
### 验证安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 Docker 版本
|
||||||
|
docker --version
|
||||||
|
|
||||||
|
# 检查 Docker 是否运行
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# 如果显示容器列表(可能为空),说明安装成功
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期输出**:
|
||||||
|
```
|
||||||
|
Docker version 24.0.x, build xxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 2. 安装 .NET 6.0 SDK
|
||||||
|
|
||||||
|
### 方法一:使用 Homebrew(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 添加 .NET 的 Homebrew tap
|
||||||
|
brew tap isen-ng/dotnet-sdk-versions
|
||||||
|
|
||||||
|
# 2. 安装 .NET 6.0 SDK
|
||||||
|
brew install --cask dotnet-sdk6-0-400
|
||||||
|
|
||||||
|
# 3. 验证安装
|
||||||
|
dotnet --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期输出**:`6.0.xxx`(版本号)
|
||||||
|
|
||||||
|
### 方法二:直接下载安装
|
||||||
|
|
||||||
|
1. **下载 .NET 6.0 SDK**
|
||||||
|
- 访问:https://dotnet.microsoft.com/download/dotnet/6.0
|
||||||
|
- 选择 "macOS" → "Arm64" → 下载安装包
|
||||||
|
|
||||||
|
2. **安装**
|
||||||
|
- 双击下载的 `.pkg` 文件
|
||||||
|
- 按照安装向导完成安装
|
||||||
|
|
||||||
|
3. **验证安装**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 .NET 版本
|
||||||
|
dotnet --version
|
||||||
|
|
||||||
|
# 检查已安装的 SDK
|
||||||
|
dotnet --list-sdks
|
||||||
|
|
||||||
|
# 应该看到类似输出:
|
||||||
|
# 6.0.xxx [/usr/local/share/dotnet/sdk]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 如果遇到问题
|
||||||
|
|
||||||
|
**问题:命令未找到**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 PATH 环境变量
|
||||||
|
echo $PATH
|
||||||
|
|
||||||
|
# 如果 /usr/local/share/dotnet 不在 PATH 中,添加到 ~/.zshrc
|
||||||
|
echo 'export PATH="/usr/local/share/dotnet:$PATH"' >> ~/.zshrc
|
||||||
|
source ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:M1 芯片兼容性**
|
||||||
|
|
||||||
|
.NET 6.0 SDK 原生支持 Apple Silicon(M1/M2),无需特殊配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 3. 安装 Node.js 和 pnpm
|
||||||
|
|
||||||
|
### 安装 Node.js
|
||||||
|
|
||||||
|
#### 方法一:使用 Homebrew(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 Node.js(会自动安装 npm)
|
||||||
|
brew install node
|
||||||
|
|
||||||
|
# 验证安装
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期输出**:
|
||||||
|
```
|
||||||
|
v18.x.x 或更高版本
|
||||||
|
9.x.x 或更高版本
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方法二:使用 nvm(Node Version Manager,推荐用于多版本管理)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装 nvm
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||||
|
|
||||||
|
# 2. 重新加载 shell 配置
|
||||||
|
source ~/.zshrc
|
||||||
|
|
||||||
|
# 3. 安装 Node.js LTS 版本
|
||||||
|
nvm install --lts
|
||||||
|
|
||||||
|
# 4. 使用最新 LTS 版本
|
||||||
|
nvm use --lts
|
||||||
|
|
||||||
|
# 5. 验证安装
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方法三:直接下载安装
|
||||||
|
|
||||||
|
1. **访问 Node.js 官网**
|
||||||
|
- 访问:https://nodejs.org/
|
||||||
|
- 下载 macOS Installer(.pkg)文件
|
||||||
|
- 选择 LTS 版本(推荐)
|
||||||
|
|
||||||
|
2. **安装**
|
||||||
|
- 双击下载的 `.pkg` 文件
|
||||||
|
- 按照安装向导完成安装
|
||||||
|
|
||||||
|
3. **验证安装**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 安装 pnpm
|
||||||
|
|
||||||
|
#### 方法一:使用 npm(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 npm 全局安装 pnpm
|
||||||
|
npm install -g pnpm
|
||||||
|
|
||||||
|
# 验证安装
|
||||||
|
pnpm --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期输出**:`8.x.x` 或更高版本
|
||||||
|
|
||||||
|
#### 方法二:使用 Homebrew
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方法三:使用独立安装脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://get.pnpm.io/install.sh | sh -
|
||||||
|
```
|
||||||
|
|
||||||
|
安装完成后,重新加载 shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ~/.zshrc
|
||||||
|
pnpm --version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 完整验证
|
||||||
|
|
||||||
|
运行以下命令,确认所有工具都已正确安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 Docker
|
||||||
|
echo "=== Docker ==="
|
||||||
|
docker --version
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# 检查 .NET SDK
|
||||||
|
echo "=== .NET SDK ==="
|
||||||
|
dotnet --version
|
||||||
|
dotnet --list-sdks
|
||||||
|
|
||||||
|
# 检查 Node.js
|
||||||
|
echo "=== Node.js ==="
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
# 检查 pnpm
|
||||||
|
echo "=== pnpm ==="
|
||||||
|
pnpm --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期输出示例**:
|
||||||
|
```
|
||||||
|
=== Docker ===
|
||||||
|
Docker version 24.0.6, build ed223bc
|
||||||
|
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||||
|
|
||||||
|
=== .NET SDK ===
|
||||||
|
6.0.425
|
||||||
|
6.0.425 [/usr/local/share/dotnet/sdk]
|
||||||
|
|
||||||
|
=== Node.js ===
|
||||||
|
v18.17.0
|
||||||
|
9.6.7
|
||||||
|
|
||||||
|
=== pnpm ===
|
||||||
|
8.15.0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 测试构建环境
|
||||||
|
|
||||||
|
安装完成后,可以测试构建环境是否正常工作:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进入项目目录
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
|
||||||
|
# 测试 Docker 构建(可选,会花费一些时间)
|
||||||
|
# docker build -f Dockerfile.prod -t dysync.net:test .
|
||||||
|
|
||||||
|
# 测试 .NET 项目构建
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
dotnet restore
|
||||||
|
dotnet build
|
||||||
|
|
||||||
|
# 测试前端构建
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net/app
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
### 问题 1:Docker Desktop 无法启动
|
||||||
|
|
||||||
|
**症状**:点击 Docker Desktop 后没有反应或报错
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 检查系统要求:macOS 11.0 或更高版本
|
||||||
|
2. 重启 Mac
|
||||||
|
3. 检查是否有其他虚拟化软件冲突(如 VirtualBox)
|
||||||
|
4. 重新安装 Docker Desktop
|
||||||
|
|
||||||
|
### 问题 2:.NET SDK 命令未找到
|
||||||
|
|
||||||
|
**症状**:执行 `dotnet --version` 提示命令未找到
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 .NET 安装位置
|
||||||
|
ls -la /usr/local/share/dotnet
|
||||||
|
|
||||||
|
# 如果不存在,检查其他可能的位置
|
||||||
|
which dotnet
|
||||||
|
|
||||||
|
# 添加到 PATH(如果使用 zsh)
|
||||||
|
echo 'export PATH="/usr/local/share/dotnet:$PATH"' >> ~/.zshrc
|
||||||
|
source ~/.zshrc
|
||||||
|
|
||||||
|
# 如果使用 bash
|
||||||
|
echo 'export PATH="/usr/local/share/dotnet:$PATH"' >> ~/.bash_profile
|
||||||
|
source ~/.bash_profile
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 3:pnpm 安装失败
|
||||||
|
|
||||||
|
**症状**:`npm install -g pnpm` 失败,提示权限错误
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方法 1:使用 sudo(不推荐)
|
||||||
|
sudo npm install -g pnpm
|
||||||
|
|
||||||
|
# 方法 2:配置 npm 使用其他目录(推荐)
|
||||||
|
mkdir ~/.npm-global
|
||||||
|
npm config set prefix '~/.npm-global'
|
||||||
|
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc
|
||||||
|
source ~/.zshrc
|
||||||
|
npm install -g pnpm
|
||||||
|
|
||||||
|
# 方法 3:使用 Homebrew
|
||||||
|
brew install pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 4:M1 芯片兼容性问题
|
||||||
|
|
||||||
|
**症状**:某些工具在 M1 上运行异常
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- **Docker Desktop**:确保使用 Apple Silicon 版本(arm64)
|
||||||
|
- **.NET SDK**:.NET 6.0 原生支持 M1,无需特殊配置
|
||||||
|
- **Node.js**:使用 nvm 安装时,会自动选择 arm64 版本
|
||||||
|
|
||||||
|
### 问题 5:Homebrew 安装慢或失败
|
||||||
|
|
||||||
|
**症状**:`brew install` 下载很慢或失败
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用国内镜像源(清华大学镜像)
|
||||||
|
# 替换 Homebrew 源
|
||||||
|
export HOMEBREW_BREW_GIT_REMOTE="https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/brew.git"
|
||||||
|
export HOMEBREW_CORE_GIT_REMOTE="https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/homebrew-core.git"
|
||||||
|
|
||||||
|
# 或使用中科大镜像
|
||||||
|
export HOMEBREW_BREW_GIT_REMOTE="https://mirrors.ustc.edu.cn/brew.git"
|
||||||
|
export HOMEBREW_CORE_GIT_REMOTE="https://mirrors.ustc.edu.cn/homebrew-core.git"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 参考资源
|
||||||
|
|
||||||
|
- **Docker Desktop for Mac**:https://docs.docker.com/desktop/install/mac-install/
|
||||||
|
- **.NET 6.0 下载**:https://dotnet.microsoft.com/download/dotnet/6.0
|
||||||
|
- **Node.js 下载**:https://nodejs.org/
|
||||||
|
- **pnpm 文档**:https://pnpm.io/installation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 下一步
|
||||||
|
|
||||||
|
安装完成后,可以开始构建项目:
|
||||||
|
|
||||||
|
1. **查看构建指南**:[飞牛NAS详细部署方案.md](./飞牛NAS详细部署方案.md)
|
||||||
|
2. **开始构建镜像**:
|
||||||
|
```bash
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新**:2024-11-22
|
||||||
|
**适用系统**:macOS(Apple Silicon M1/M2)
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
DB_PATH="/Users/gaotu/WebstormProjects/dysync.net/db/dy.sqlite"
|
||||||
|
|
||||||
|
echo "=== 数据库检查 ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "1. 视频记录总数:"
|
||||||
|
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM dy_collect_video;"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "2. 有保存路径的视频数量:"
|
||||||
|
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM dy_collect_video WHERE videoSavePath IS NOT NULL AND videoSavePath != '';"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "3. Cookie配置数量:"
|
||||||
|
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM dy_user_cookies;"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "4. Cookie配置详情:"
|
||||||
|
sqlite3 -header -column "$DB_PATH" "SELECT id, userName, savePath, favSavePath, uperSavePath, collHasSyncd FROM dy_user_cookies LIMIT 5;"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "5. 视频记录示例(前3条):"
|
||||||
|
sqlite3 -header -column "$DB_PATH" "SELECT id, videoTitle, author, videoSavePath, fileSize FROM dy_collect_video LIMIT 3;"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "6. 检查视频文件是否存在:"
|
||||||
|
sqlite3 "$DB_PATH" "SELECT videoSavePath FROM dy_collect_video WHERE videoSavePath IS NOT NULL AND videoSavePath != '' LIMIT 1;" | while read path; do
|
||||||
|
if [ -n "$path" ]; then
|
||||||
|
echo "检查: $path"
|
||||||
|
if [ -f "$path" ]; then
|
||||||
|
echo "✅ 文件存在"
|
||||||
|
ls -lh "$path" | awk '{print " 大小: " $5}'
|
||||||
|
else
|
||||||
|
echo "❌ 文件不存在"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 安装 ASP.NET Core 6.0 x64 Runtime
|
||||||
|
|
||||||
|
echo "正在下载并安装 ASP.NET Core 6.0 x64 Runtime..."
|
||||||
|
echo "需要管理员权限,请输入您的密码:"
|
||||||
|
|
||||||
|
curl -sSL https://dot.net/v1/dotnet-install.sh | sudo bash /dev/stdin --runtime aspnetcore --version 6.0.36 --arch x64 --install-dir /usr/local/share/dotnet/x64
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "安装完成!验证安装:"
|
||||||
|
export PATH="/usr/local/share/dotnet/x64:$PATH"
|
||||||
|
dotnet --list-runtimes
|
||||||
|
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 安装 .NET 6.0 x64 Runtime
|
||||||
|
|
||||||
|
echo "正在下载并安装 .NET 6.0 x64 Runtime..."
|
||||||
|
echo "需要管理员权限,请输入您的密码:"
|
||||||
|
|
||||||
|
curl -sSL https://dot.net/v1/dotnet-install.sh | sudo bash /dev/stdin --runtime dotnet --version 6.0.36 --arch x64 --install-dir /usr/local/share/dotnet/x64
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "安装完成!验证安装:"
|
||||||
|
export PATH="/usr/local/share/dotnet/x64:$PATH"
|
||||||
|
dotnet --list-runtimes
|
||||||
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""检查数据库中的视频数据"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
db_path = "/Users/gaotu/WebstormProjects/dysync.net/db/dy.sqlite"
|
||||||
|
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f"❌ 数据库文件不存在: {db_path}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("数据库检查报告")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 1. 视频总数
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM dy_collect_video")
|
||||||
|
total = cursor.fetchone()[0]
|
||||||
|
print(f"\n1. 视频记录总数: {total}")
|
||||||
|
|
||||||
|
# 2. 有保存路径的视频数量
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM dy_collect_video WHERE videoSavePath IS NOT NULL AND videoSavePath != ''")
|
||||||
|
with_path = cursor.fetchone()[0]
|
||||||
|
print(f"2. 有保存路径的视频数量: {with_path}")
|
||||||
|
|
||||||
|
# 3. 按类型统计
|
||||||
|
cursor.execute("SELECT ViedoType, COUNT(*) FROM dy_collect_video GROUP BY ViedoType")
|
||||||
|
type_stats = cursor.fetchall()
|
||||||
|
print(f"\n3. 按类型统计:")
|
||||||
|
for vtype, count in type_stats:
|
||||||
|
type_name = {"1": "喜欢的", "2": "收藏的", "3": "关注的"}.get(str(vtype), f"未知({vtype})")
|
||||||
|
print(f" {type_name}: {count}")
|
||||||
|
|
||||||
|
# 4. 文件大小统计
|
||||||
|
cursor.execute("SELECT SUM(fileSize) FROM dy_collect_video WHERE fileSize > 0")
|
||||||
|
total_size = cursor.fetchone()[0] or 0
|
||||||
|
print(f"\n4. 总文件大小: {total_size / (1024**3):.2f} GB")
|
||||||
|
|
||||||
|
# 5. 最近的视频记录
|
||||||
|
print(f"\n5. 最近的视频记录(前5条):")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, videoTitle, author, videoSavePath, fileSize, syncTime
|
||||||
|
FROM dy_collect_video
|
||||||
|
ORDER BY syncTime DESC
|
||||||
|
LIMIT 5
|
||||||
|
""")
|
||||||
|
records = cursor.fetchall()
|
||||||
|
if records:
|
||||||
|
for idx, (vid, title, author, path, size, sync_time) in enumerate(records, 1):
|
||||||
|
title_short = (title[:30] + "...") if title and len(title) > 30 else (title or "无标题")
|
||||||
|
size_mb = (size / (1024**2)) if size else 0
|
||||||
|
file_exists = "✅" if path and os.path.exists(path) else "❌"
|
||||||
|
print(f" {idx}. [{file_exists}] {title_short}")
|
||||||
|
print(f" 作者: {author}, 大小: {size_mb:.2f} MB")
|
||||||
|
if path:
|
||||||
|
print(f" 路径: {path}")
|
||||||
|
else:
|
||||||
|
print(" (无记录)")
|
||||||
|
|
||||||
|
# 6. 检查文件是否存在
|
||||||
|
print(f"\n6. 文件存在性检查:")
|
||||||
|
cursor.execute("SELECT videoSavePath FROM dy_collect_video WHERE videoSavePath IS NOT NULL AND videoSavePath != '' LIMIT 10")
|
||||||
|
paths = cursor.fetchall()
|
||||||
|
if paths:
|
||||||
|
exists_count = 0
|
||||||
|
for (path,) in paths:
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
exists_count += 1
|
||||||
|
print(f" 检查了 {len(paths)} 个路径,{exists_count} 个文件存在")
|
||||||
|
else:
|
||||||
|
print(" (无路径记录)")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# 下载配置功能说明
|
||||||
|
|
||||||
|
## 📋 功能概述
|
||||||
|
|
||||||
|
为了避免自动下载占用过多存储空间,系统现在支持可配置的下载策略。**默认情况下,系统不会自动下载视频**,只会同步视频信息到数据库。
|
||||||
|
|
||||||
|
## 🎯 功能特点
|
||||||
|
|
||||||
|
1. **默认不下载**:未配置的用户不会自动下载视频,避免占用存储空间
|
||||||
|
2. **按时间范围配置**:可以设置只下载指定天数内的视频(例如:只下载最近30天)
|
||||||
|
3. **按作者配置**:支持作者白名单和黑名单
|
||||||
|
4. **按类型配置**:可以分别控制是否下载"喜欢的视频"和"收藏的视频"
|
||||||
|
|
||||||
|
## ⚙️ 配置选项
|
||||||
|
|
||||||
|
### 1. 启用自动下载
|
||||||
|
|
||||||
|
- **开关**:启用/禁用自动下载功能
|
||||||
|
- **默认值**:关闭(`false`)
|
||||||
|
- **说明**:关闭后,系统只同步视频信息到数据库,不会下载视频文件
|
||||||
|
|
||||||
|
### 2. 下载时间范围
|
||||||
|
|
||||||
|
- **类型**:数字(天数)
|
||||||
|
- **默认值**:0(不限制)
|
||||||
|
- **说明**:
|
||||||
|
- `0`:不限制时间,下载所有符合条件的视频
|
||||||
|
- `30`:只下载最近30天的视频
|
||||||
|
- `90`:只下载最近90天的视频
|
||||||
|
- 最大支持 3650 天(10年)
|
||||||
|
|
||||||
|
### 3. 下载类型
|
||||||
|
|
||||||
|
- **下载喜欢的视频**:是否下载"喜欢的视频"(`ViedoType = "1"`)
|
||||||
|
- **下载收藏的视频**:是否下载"收藏的视频"(`ViedoType = "2"`)
|
||||||
|
- **默认值**:都关闭(`false`)
|
||||||
|
|
||||||
|
### 4. 作者白名单
|
||||||
|
|
||||||
|
- **类型**:多行文本(每行一个作者)
|
||||||
|
- **格式**:每行一个作者名称或作者ID
|
||||||
|
- **说明**:
|
||||||
|
- 配置后,**只有**白名单中的作者视频才会被下载
|
||||||
|
- 留空则不限制
|
||||||
|
- 支持作者名称或作者ID(`AuthorId`)
|
||||||
|
- 示例:
|
||||||
|
```
|
||||||
|
作者1
|
||||||
|
作者2
|
||||||
|
MS4wLjABAAAA...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 作者黑名单
|
||||||
|
|
||||||
|
- **类型**:多行文本(每行一个作者)
|
||||||
|
- **格式**:每行一个作者名称或作者ID
|
||||||
|
- **说明**:
|
||||||
|
- 配置后,黑名单中的作者视频**不会**被下载
|
||||||
|
- 留空则不限制
|
||||||
|
- 支持作者名称或作者ID(`AuthorId`)
|
||||||
|
- 示例:
|
||||||
|
```
|
||||||
|
作者1
|
||||||
|
作者2
|
||||||
|
MS4wLjABAAAA...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 判断逻辑
|
||||||
|
|
||||||
|
系统按照以下顺序判断是否应该下载视频:
|
||||||
|
|
||||||
|
1. **检查是否启用自动下载**
|
||||||
|
- 如果未启用,直接跳过下载
|
||||||
|
|
||||||
|
2. **检查视频类型**
|
||||||
|
- 如果视频类型为"喜欢"(`ViedoType = "1"`),检查 `DownloadFavorite` 是否为 `true`
|
||||||
|
- 如果视频类型为"收藏"(`ViedoType = "2"`),检查 `DownloadCollect` 是否为 `true`
|
||||||
|
- 如果对应类型未启用,跳过下载
|
||||||
|
|
||||||
|
3. **检查时间范围**
|
||||||
|
- 如果配置了 `DownloadDaysRange > 0`,检查视频创建时间是否在范围内
|
||||||
|
- 超出时间范围的视频跳过下载
|
||||||
|
|
||||||
|
4. **检查作者白名单**
|
||||||
|
- 如果配置了白名单,检查作者是否在白名单中
|
||||||
|
- 不在白名单中的视频跳过下载
|
||||||
|
|
||||||
|
5. **检查作者黑名单**
|
||||||
|
- 如果配置了黑名单,检查作者是否在黑名单中
|
||||||
|
- 在黑名单中的视频跳过下载
|
||||||
|
|
||||||
|
6. **所有检查通过,开始下载**
|
||||||
|
|
||||||
|
## 📝 配置示例
|
||||||
|
|
||||||
|
### 示例 1:只下载最近30天的喜欢视频
|
||||||
|
|
||||||
|
```
|
||||||
|
启用自动下载:✅ 开启
|
||||||
|
下载时间范围:30 天
|
||||||
|
下载喜欢的视频:✅ 开启
|
||||||
|
下载收藏的视频:❌ 关闭
|
||||||
|
作者白名单:(留空)
|
||||||
|
作者黑名单:(留空)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 2:只下载指定作者的视频
|
||||||
|
|
||||||
|
```
|
||||||
|
启用自动下载:✅ 开启
|
||||||
|
下载时间范围:0(不限制)
|
||||||
|
下载喜欢的视频:✅ 开启
|
||||||
|
下载收藏的视频:✅ 开启
|
||||||
|
作者白名单:
|
||||||
|
作者1
|
||||||
|
作者2
|
||||||
|
MS4wLjABAAAA...
|
||||||
|
作者黑名单:(留空)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 3:排除某些作者,下载其他所有视频
|
||||||
|
|
||||||
|
```
|
||||||
|
启用自动下载:✅ 开启
|
||||||
|
下载时间范围:0(不限制)
|
||||||
|
下载喜欢的视频:✅ 开启
|
||||||
|
下载收藏的视频:✅ 开启
|
||||||
|
作者白名单:(留空)
|
||||||
|
作者黑名单:
|
||||||
|
不想下载的作者1
|
||||||
|
不想下载的作者2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 4:不自动下载(默认)
|
||||||
|
|
||||||
|
```
|
||||||
|
启用自动下载:❌ 关闭
|
||||||
|
(其他选项无效)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 使用方法
|
||||||
|
|
||||||
|
1. **登录系统**,进入"设置"页面
|
||||||
|
2. **点击"修改配置"**按钮
|
||||||
|
3. **配置下载选项**:
|
||||||
|
- 开启"启用自动下载"
|
||||||
|
- 根据需要设置时间范围、类型、作者白名单/黑名单
|
||||||
|
4. **点击"确认"**保存配置
|
||||||
|
5. **系统会在下次同步时应用新配置**
|
||||||
|
|
||||||
|
## 📊 数据库变更
|
||||||
|
|
||||||
|
### AppConfig 表新增字段
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 以下字段已添加到 dy_app_config 表
|
||||||
|
EnableAutoDownload BOOLEAN DEFAULT 0 -- 是否启用自动下载
|
||||||
|
DownloadDaysRange INTEGER DEFAULT 0 -- 下载时间范围(天数)
|
||||||
|
AuthorWhitelist TEXT -- 作者白名单(JSON数组)
|
||||||
|
AuthorBlacklist TEXT -- 作者黑名单(JSON数组)
|
||||||
|
DownloadFavorite BOOLEAN DEFAULT 0 -- 是否下载喜欢的视频
|
||||||
|
DownloadCollect BOOLEAN DEFAULT 0 -- 是否下载收藏的视频
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 技术实现
|
||||||
|
|
||||||
|
### 后端变更
|
||||||
|
|
||||||
|
1. **模型扩展**:`AppConfig` 模型新增下载配置字段
|
||||||
|
2. **服务新增**:`DownloadConfigService` 服务实现下载判断逻辑
|
||||||
|
3. **Job 修改**:
|
||||||
|
- `DouYinFavoritSyncJob`:在下载前检查配置
|
||||||
|
- `DouYinCollectSyncJob`:在下载前检查配置
|
||||||
|
4. **服务更新**:`CommonService` 支持保存和读取下载配置
|
||||||
|
|
||||||
|
### 前端变更
|
||||||
|
|
||||||
|
1. **配置页面**:`AppSet.vue` 新增下载配置界面
|
||||||
|
2. **表单字段**:添加下载相关的表单字段
|
||||||
|
3. **数据处理**:处理作者白名单/黑名单的 JSON 转换
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **默认行为**:未配置的用户默认不会下载视频,只会同步视频信息
|
||||||
|
2. **配置优先级**:白名单和黑名单同时配置时,黑名单优先级更高
|
||||||
|
3. **时间计算**:时间范围基于视频的创建时间(`CreateTime`),不是同步时间
|
||||||
|
4. **作者匹配**:支持按作者名称或作者ID匹配,不区分大小写
|
||||||
|
5. **配置生效**:配置修改后,会在下次同步任务执行时生效
|
||||||
|
|
||||||
|
## 🐛 故障排查
|
||||||
|
|
||||||
|
### 问题:配置了但视频仍然不下载
|
||||||
|
|
||||||
|
**检查项**:
|
||||||
|
1. 确认"启用自动下载"已开启
|
||||||
|
2. 确认对应的视频类型(喜欢/收藏)已开启
|
||||||
|
3. 检查时间范围是否合理(视频是否在范围内)
|
||||||
|
4. 检查作者是否在白名单中(如果配置了白名单)
|
||||||
|
5. 检查作者是否在黑名单中(如果配置了黑名单)
|
||||||
|
|
||||||
|
### 问题:配置了白名单但所有视频都不下载
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 作者名称或ID不匹配
|
||||||
|
- 白名单格式错误(应该是每行一个)
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
- 检查作者名称是否完全匹配(包括空格、特殊字符)
|
||||||
|
- 尝试使用作者ID而不是名称
|
||||||
|
- 查看日志中的"不符合下载配置"提示
|
||||||
|
|
||||||
|
### 问题:配置了时间范围但旧视频仍然下载
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 时间范围配置未生效
|
||||||
|
- 视频创建时间解析错误
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
- 确认配置已保存
|
||||||
|
- 检查日志中的时间判断逻辑
|
||||||
|
- 重启服务后重试
|
||||||
|
|
||||||
|
## 📚 相关文件
|
||||||
|
|
||||||
|
- **后端模型**:`model/AppConfig.cs`
|
||||||
|
- **下载服务**:`service/DownloadConfigService.cs`
|
||||||
|
- **配置服务**:`service/CommonService.cs`
|
||||||
|
- **喜欢视频Job**:`job/DouYinFavoritSyncJob.cs`
|
||||||
|
- **收藏视频Job**:`job/DouYinCollectSyncJob.cs`
|
||||||
|
- **前端配置页**:`app/src/pages/set/AppSet.vue`
|
||||||
|
- **API控制器**:`Controllers/ConfigController.cs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新**:2024-11-24
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 安装 .NET 6.0 Runtime
|
||||||
|
|
||||||
|
## 问题说明
|
||||||
|
当前系统安装了 .NET SDK 10.0,但项目需要 .NET 6.0 Runtime 才能运行。
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
### 方法一:下载官方安装包(推荐)
|
||||||
|
|
||||||
|
1. **访问下载页面**:
|
||||||
|
https://dotnet.microsoft.com/download/dotnet/6.0
|
||||||
|
|
||||||
|
2. **下载 macOS x64 Runtime**:
|
||||||
|
- 找到 "Run apps - Runtime" 部分
|
||||||
|
- 下载 "6.0.x" 版本的 macOS x64 安装包(.pkg 文件)
|
||||||
|
|
||||||
|
3. **安装**:
|
||||||
|
- 双击下载的 .pkg 文件
|
||||||
|
- 按照安装向导完成安装
|
||||||
|
|
||||||
|
4. **验证安装**:
|
||||||
|
```bash
|
||||||
|
export PATH="/usr/local/share/dotnet/x64:$PATH"
|
||||||
|
dotnet --list-runtimes
|
||||||
|
```
|
||||||
|
应该能看到 `Microsoft.NETCore.App 6.0.x`
|
||||||
|
|
||||||
|
### 方法二:使用命令行安装(需要管理员权限)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 下载安装脚本
|
||||||
|
curl -sSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh
|
||||||
|
|
||||||
|
# 安装 .NET 6.0 Runtime(需要 sudo)
|
||||||
|
sudo bash dotnet-install.sh --runtime dotnet --version 6.0.0 --install-dir /usr/local/share/dotnet/x64
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法三:使用 Homebrew(如果可用)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 注意:Homebrew 可能没有单独的 runtime,需要安装完整的 SDK
|
||||||
|
brew install --cask dotnet-sdk6-0-400
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装完成后
|
||||||
|
|
||||||
|
重新启动后端服务:
|
||||||
|
```bash
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
export PATH="/usr/local/share/dotnet/x64:$PATH"
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速链接
|
||||||
|
|
||||||
|
- **直接下载链接(macOS x64)**:
|
||||||
|
https://download.visualstudio.microsoft.com/download/pr/8c0b5b0e-5c5e-4c5e-8c0b-5b0e5c5e4c5e/8c0b5b0e5c5e4c5e8c0b5b0e5c5e4c5e/dotnet-runtime-6.0.0-osx-x64.pkg
|
||||||
|
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
# 数据库检查说明
|
||||||
|
|
||||||
|
由于终端命令执行环境问题,我为您提供以下检查方法:
|
||||||
|
|
||||||
|
## 🔍 检查方法
|
||||||
|
|
||||||
|
### 方法一:通过前端页面检查(推荐)
|
||||||
|
|
||||||
|
1. **访问前端页面**: `http://localhost:8001`
|
||||||
|
2. **登录系统**: 用户名 `douyin` / 密码 `douyin2025`
|
||||||
|
3. **查看数据看板**: 点击「数据看板」查看视频统计
|
||||||
|
- 视频总数
|
||||||
|
- 作者数量
|
||||||
|
- 分类数量
|
||||||
|
- 存储空间统计
|
||||||
|
4. **查看同步记录**: 点击「同步记录」查看视频列表
|
||||||
|
- 查看是否有视频记录
|
||||||
|
- 检查视频是否有 `videoSavePath` 字段
|
||||||
|
|
||||||
|
### 方法二:通过 Swagger API 检查
|
||||||
|
|
||||||
|
1. **访问 Swagger**: `http://localhost:10101/swagger`
|
||||||
|
2. **测试统计接口**:
|
||||||
|
- 找到 `GET /api/video/statics`
|
||||||
|
- 点击 "Try it out" → "Execute"
|
||||||
|
- 查看返回的视频统计数据
|
||||||
|
3. **测试分页查询接口**:
|
||||||
|
- 找到 `POST /api/video/paged`
|
||||||
|
- 点击 "Try it out"
|
||||||
|
- 输入参数: `{"pageIndex":1,"pageSize":10}`
|
||||||
|
- 点击 "Execute"
|
||||||
|
- 查看返回的视频列表
|
||||||
|
|
||||||
|
### 方法三:手动执行 SQL 查询
|
||||||
|
|
||||||
|
在终端中执行以下命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
|
||||||
|
# 1. 查看视频总数
|
||||||
|
sqlite3 db/dy.sqlite "SELECT COUNT(*) FROM dy_collect_video;"
|
||||||
|
|
||||||
|
# 2. 查看有保存路径的视频数量
|
||||||
|
sqlite3 db/dy.sqlite "SELECT COUNT(*) FROM dy_collect_video WHERE videoSavePath IS NOT NULL AND videoSavePath != '';"
|
||||||
|
|
||||||
|
# 3. 查看 Cookie 配置
|
||||||
|
sqlite3 -header -column db/dy.sqlite "SELECT id, userName, savePath, favSavePath, uperSavePath FROM dy_user_cookies;"
|
||||||
|
|
||||||
|
# 4. 查看视频记录示例
|
||||||
|
sqlite3 -header -column db/dy.sqlite "SELECT id, videoTitle, author, videoSavePath, fileSize FROM dy_collect_video LIMIT 5;"
|
||||||
|
|
||||||
|
# 5. 检查视频文件是否存在(示例)
|
||||||
|
sqlite3 db/dy.sqlite "SELECT videoSavePath FROM dy_collect_video WHERE videoSavePath IS NOT NULL LIMIT 1;" | xargs -I {} test -f {} && echo "文件存在" || echo "文件不存在"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 检查清单
|
||||||
|
|
||||||
|
### ✅ Cookie 配置检查
|
||||||
|
|
||||||
|
- [ ] Cookie 是否已配置?
|
||||||
|
- [ ] `savePath`(收藏视频路径)是否已设置?
|
||||||
|
- [ ] `favSavePath`(喜欢视频路径)是否已设置?
|
||||||
|
- [ ] `uperSavePath`(博主视频路径)是否已设置?
|
||||||
|
- [ ] Cookie 内容是否有效(长度 > 1000)?
|
||||||
|
|
||||||
|
### ✅ 视频数据检查
|
||||||
|
|
||||||
|
- [ ] 数据库中是否有视频记录?
|
||||||
|
- [ ] 视频记录是否有 `videoSavePath` 字段?
|
||||||
|
- [ ] 视频文件是否实际存在于文件系统中?
|
||||||
|
- [ ] 视频文件大小是否正常?
|
||||||
|
|
||||||
|
### ✅ 同步任务检查
|
||||||
|
|
||||||
|
- [ ] 定时任务是否已启动?
|
||||||
|
- [ ] 最近是否有同步日志?
|
||||||
|
- [ ] 同步是否成功完成?
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
### 1. 数据库中没有视频记录
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- Cookie 未配置或无效
|
||||||
|
- 保存路径未配置
|
||||||
|
- 同步任务未执行
|
||||||
|
- 同步任务执行失败
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 检查「抖音授权」页面,确保 Cookie 和路径已配置
|
||||||
|
2. 点击「立即重新同步」手动触发同步
|
||||||
|
3. 查看系统日志了解同步失败原因
|
||||||
|
|
||||||
|
### 2. 有记录但没有 videoSavePath
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 视频下载失败
|
||||||
|
- 保存路径配置错误
|
||||||
|
- 文件系统权限问题
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 检查保存路径配置是否正确
|
||||||
|
2. 检查文件系统权限
|
||||||
|
3. 查看日志了解下载失败原因
|
||||||
|
|
||||||
|
### 3. 有 videoSavePath 但文件不存在
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 文件被删除
|
||||||
|
- 路径配置错误
|
||||||
|
- 文件系统路径映射问题
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 检查文件路径是否正确
|
||||||
|
2. 检查文件是否被删除
|
||||||
|
3. 重新同步视频
|
||||||
|
|
||||||
|
## 📝 下一步操作
|
||||||
|
|
||||||
|
1. **如果数据库为空**:
|
||||||
|
- 配置 Cookie 和保存路径
|
||||||
|
- 手动触发同步任务
|
||||||
|
- 等待同步完成
|
||||||
|
|
||||||
|
2. **如果有数据但无 videoSavePath**:
|
||||||
|
- 检查保存路径配置
|
||||||
|
- 重新同步视频
|
||||||
|
|
||||||
|
3. **如果有 videoSavePath 但文件不存在**:
|
||||||
|
- 检查文件路径
|
||||||
|
- 重新同步视频
|
||||||
|
|
||||||
|
4. **如果一切正常**:
|
||||||
|
- 测试视频预览功能
|
||||||
|
- 点击「预览」按钮查看视频
|
||||||
|
|
||||||
|
## 🔧 手动触发同步
|
||||||
|
|
||||||
|
在前端页面:
|
||||||
|
1. 进入「同步记录」页面
|
||||||
|
2. 点击「立即重新同步」按钮
|
||||||
|
3. 等待同步完成(查看日志)
|
||||||
|
|
||||||
|
或通过 API:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:10101/api/config/ExecuteJobNow
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**: 如果遇到问题,请查看 `logs/` 目录下的日志文件了解详细信息。
|
||||||
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# 数据看板不更新问题排查
|
||||||
|
|
||||||
|
## 🔍 问题现象
|
||||||
|
|
||||||
|
视频已经下载了,但是数据看板中的数据没有更新。
|
||||||
|
|
||||||
|
## 📋 可能的原因
|
||||||
|
|
||||||
|
### 1. 数据库记录未保存
|
||||||
|
- 视频文件下载成功,但数据库记录保存失败
|
||||||
|
- 检查日志中是否有 "批量保存视频到数据库失败" 的错误
|
||||||
|
|
||||||
|
### 2. 前端缓存问题
|
||||||
|
- 浏览器缓存了旧的统计数据
|
||||||
|
- 前端组件没有刷新数据
|
||||||
|
|
||||||
|
### 3. 数据库查询问题
|
||||||
|
- 统计查询没有正确读取数据
|
||||||
|
|
||||||
|
## 🔧 排查步骤
|
||||||
|
|
||||||
|
### 步骤1:检查数据库中的记录
|
||||||
|
|
||||||
|
**方法一:通过前端页面**
|
||||||
|
1. 访问 `http://localhost:8001`
|
||||||
|
2. 进入「同步记录」页面
|
||||||
|
3. 查看是否有视频记录显示
|
||||||
|
4. 检查视频记录是否有 `videoSavePath` 字段
|
||||||
|
|
||||||
|
**方法二:通过 Swagger API**
|
||||||
|
1. 访问 `http://localhost:10101/swagger`
|
||||||
|
2. 找到 `POST /api/video/paged`
|
||||||
|
3. 输入参数:`{"pageIndex":1,"pageSize":10}`
|
||||||
|
4. 执行查询,查看返回的数据
|
||||||
|
|
||||||
|
**方法三:直接查询数据库**
|
||||||
|
```bash
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
sqlite3 db/dy.sqlite "SELECT COUNT(*) FROM dy_collect_video;"
|
||||||
|
sqlite3 db/dy.sqlite "SELECT id, videoTitle, videoSavePath FROM dy_collect_video LIMIT 5;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤2:检查日志
|
||||||
|
|
||||||
|
查看最新的日志文件,搜索关键词:
|
||||||
|
- "批量保存视频到数据库失败"
|
||||||
|
- "本次共同步成功"
|
||||||
|
- "下载成功"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看最新日志
|
||||||
|
tail -100 logs/log-debug-20251120.txt | grep -E "(下载成功|批量保存|同步成功)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤3:检查前端数据刷新
|
||||||
|
|
||||||
|
1. **清除浏览器缓存**
|
||||||
|
- 按 `F12` 打开开发者工具
|
||||||
|
- 右键点击刷新按钮
|
||||||
|
- 选择「清空缓存并硬性重新加载」
|
||||||
|
|
||||||
|
2. **手动刷新数据看板**
|
||||||
|
- 进入「数据看板」页面
|
||||||
|
- 按 `F5` 刷新页面
|
||||||
|
- 或关闭页面重新打开
|
||||||
|
|
||||||
|
3. **检查网络请求**
|
||||||
|
- 按 `F12` 打开开发者工具
|
||||||
|
- 切换到「Network」标签
|
||||||
|
- 刷新「数据看板」页面
|
||||||
|
- 查看 `/api/video/statics` 请求的响应数据
|
||||||
|
|
||||||
|
## 🛠️ 解决方案
|
||||||
|
|
||||||
|
### 方案1:强制刷新前端数据
|
||||||
|
|
||||||
|
在前端代码中添加自动刷新功能,修改 `statics.vue`:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
// 在 onMounted 中添加定时刷新
|
||||||
|
onMounted(() => {
|
||||||
|
loadDashboardData();
|
||||||
|
// 每30秒自动刷新一次
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
loadDashboardData();
|
||||||
|
}, 30000);
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearInterval(timer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案2:检查数据库保存逻辑
|
||||||
|
|
||||||
|
如果日志显示 "批量保存视频到数据库失败",需要检查:
|
||||||
|
1. 数据库连接是否正常
|
||||||
|
2. 视频记录是否有重复(AwemeId 冲突)
|
||||||
|
3. 字段是否有空值或格式错误
|
||||||
|
|
||||||
|
### 方案3:手动触发数据刷新
|
||||||
|
|
||||||
|
在前端「数据看板」页面添加刷新按钮:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<a-button @click="loadDashboardData" type="primary">刷新数据</a-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 快速检查清单
|
||||||
|
|
||||||
|
- [ ] 数据库中是否有视频记录?
|
||||||
|
- [ ] 视频记录是否有 `videoSavePath` 字段?
|
||||||
|
- [ ] 视频文件是否实际存在?
|
||||||
|
- [ ] 日志中是否有保存失败的错误?
|
||||||
|
- [ ] 前端是否缓存了旧数据?
|
||||||
|
- [ ] API 返回的数据是否正确?
|
||||||
|
|
||||||
|
## 🎯 立即操作
|
||||||
|
|
||||||
|
1. **刷新浏览器页面**(清除缓存)
|
||||||
|
2. **检查「同步记录」页面**,看是否有视频记录
|
||||||
|
3. **查看浏览器控制台**(F12),检查是否有错误
|
||||||
|
4. **查看网络请求**,确认 API 返回的数据
|
||||||
|
|
||||||
|
如果「同步记录」页面有数据,但「数据看板」没有更新,那就是前端缓存问题,需要强制刷新。
|
||||||
|
|
||||||
|
如果「同步记录」页面也没有数据,那就是数据库保存问题,需要检查日志。
|
||||||
|
|
||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
# dysync.net 本地启动指南
|
||||||
|
|
||||||
|
## 📋 前置要求
|
||||||
|
|
||||||
|
### 1. 安装 .NET 6.0 SDK
|
||||||
|
|
||||||
|
**macOS 安装方式:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方式一:使用 Homebrew(推荐)
|
||||||
|
brew install --cask dotnet-sdk
|
||||||
|
|
||||||
|
# 方式二:手动下载安装
|
||||||
|
# 访问 https://dotnet.microsoft.com/download/dotnet/6.0
|
||||||
|
# 下载 macOS 版本的 .NET 6.0 SDK 并安装
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证安装:**
|
||||||
|
```bash
|
||||||
|
dotnet --version
|
||||||
|
# 应该显示类似:6.0.xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 安装 Node.js(已安装 ✅)
|
||||||
|
|
||||||
|
您已安装 Node.js v24.1.0,满足要求。
|
||||||
|
|
||||||
|
**推荐使用 pnpm(项目使用 pnpm-lock.yaml):**
|
||||||
|
```bash
|
||||||
|
npm install -g pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 启动步骤
|
||||||
|
|
||||||
|
### 第一步:安装前端依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
pnpm install
|
||||||
|
# 或者使用 yarn
|
||||||
|
# yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第二步:启动后端服务
|
||||||
|
|
||||||
|
**打开第一个终端窗口:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 回到项目根目录
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
|
||||||
|
# 还原 NuGet 包(首次运行)
|
||||||
|
dotnet restore
|
||||||
|
|
||||||
|
# 启动后端服务(开发模式)
|
||||||
|
dotnet run
|
||||||
|
|
||||||
|
# 或者指定端口(如果需要)
|
||||||
|
dotnet run --urls "http://localhost:10101"
|
||||||
|
```
|
||||||
|
|
||||||
|
**后端启动成功后:**
|
||||||
|
- ✅ 后端 API 运行在:`http://localhost:10101`
|
||||||
|
- ✅ Swagger 文档:`http://localhost:10101/swagger`
|
||||||
|
- ✅ 数据库会自动创建在:`db/dy.sqlite`
|
||||||
|
- ✅ 日志文件会生成在:`logs/` 目录
|
||||||
|
|
||||||
|
### 第三步:启动前端开发服务器
|
||||||
|
|
||||||
|
**打开第二个终端窗口:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
|
||||||
|
# 启动前端开发服务器
|
||||||
|
pnpm dev
|
||||||
|
# 或者
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端启动成功后:**
|
||||||
|
- ✅ 前端运行在:`http://localhost:8001`
|
||||||
|
- ✅ 会自动代理 `/api` 请求到后端 `http://localhost:10101`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 首次登录
|
||||||
|
|
||||||
|
1. 访问:`http://localhost:8001`
|
||||||
|
2. 默认账号:`douyin`
|
||||||
|
3. 默认密码:`douyin2025`
|
||||||
|
|
||||||
|
> ⚠️ **安全提示**:登录后请立即修改密码!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 目录结构说明
|
||||||
|
|
||||||
|
```
|
||||||
|
dysync.net/
|
||||||
|
├── db/ # SQLite 数据库目录(自动创建)
|
||||||
|
│ └── dy.sqlite # 数据库文件
|
||||||
|
├── logs/ # 日志文件目录(自动创建)
|
||||||
|
│ ├── log-debug-*.txt # 调试日志
|
||||||
|
│ └── log-error-*.txt # 错误日志
|
||||||
|
├── app/ # 前端项目
|
||||||
|
│ ├── dist/ # 前端构建产物(生产环境)
|
||||||
|
│ └── src/ # 前端源码
|
||||||
|
└── appsettings.json # 后端配置文件
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 配置说明
|
||||||
|
|
||||||
|
### 后端配置 (`appsettings.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dbconn": "", // SQLite 无需配置,自动创建
|
||||||
|
"dbtype": "Sqlite" // 数据库类型
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端配置 (`app/.env.development`)
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_URL=http://localhost:10101
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 常见问题排查
|
||||||
|
|
||||||
|
### 1. 后端启动失败
|
||||||
|
|
||||||
|
**问题:端口被占用**
|
||||||
|
```bash
|
||||||
|
# macOS 查看端口占用
|
||||||
|
lsof -i :10101
|
||||||
|
|
||||||
|
# 杀死占用进程
|
||||||
|
kill -9 <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:数据库权限错误**
|
||||||
|
```bash
|
||||||
|
# 确保 db 目录有写权限
|
||||||
|
chmod 755 db
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 前端启动失败
|
||||||
|
|
||||||
|
**问题:依赖安装失败**
|
||||||
|
```bash
|
||||||
|
# 清除缓存重新安装
|
||||||
|
rm -rf node_modules pnpm-lock.yaml
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:API 请求失败(404)**
|
||||||
|
- 检查后端是否已启动
|
||||||
|
- 检查 `.env.development` 中的 `VITE_API_URL` 是否正确
|
||||||
|
- 检查浏览器控制台是否有 CORS 错误
|
||||||
|
|
||||||
|
### 3. 数据库相关
|
||||||
|
|
||||||
|
**数据库位置:**
|
||||||
|
- 开发环境:`项目根目录/db/dy.sqlite`
|
||||||
|
- 数据库会在首次启动时自动创建
|
||||||
|
|
||||||
|
**重置数据库:**
|
||||||
|
```bash
|
||||||
|
# 删除数据库文件(会丢失所有数据)
|
||||||
|
rm -rf db/dy.sqlite
|
||||||
|
|
||||||
|
# 重新启动后端,会自动创建新数据库
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 开发模式说明
|
||||||
|
|
||||||
|
### 开发环境特性
|
||||||
|
|
||||||
|
1. **热重载**:前端代码修改后自动刷新
|
||||||
|
2. **Swagger 文档**:`http://localhost:10101/swagger`
|
||||||
|
3. **详细日志**:所有日志输出到 `logs/` 目录
|
||||||
|
4. **数据库自动迁移**:模型变更后自动更新表结构
|
||||||
|
|
||||||
|
### 生产环境构建
|
||||||
|
|
||||||
|
**前端构建:**
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
pnpm build
|
||||||
|
# 构建产物在 app/dist/ 目录
|
||||||
|
```
|
||||||
|
|
||||||
|
**后端发布:**
|
||||||
|
```bash
|
||||||
|
dotnet publish -c Release -o ./publish
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 下一步操作
|
||||||
|
|
||||||
|
1. ✅ 启动后端和前端服务
|
||||||
|
2. 🔐 使用默认账号登录
|
||||||
|
3. 🍪 在「抖音授权」页面配置 Cookie 和 sec_user_id
|
||||||
|
4. ⚙️ 在「系统配置」页面设置同步参数
|
||||||
|
5. 🎬 开始同步视频!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 提示
|
||||||
|
|
||||||
|
- **开发时建议**:保持两个终端窗口同时运行(后端 + 前端)
|
||||||
|
- **调试技巧**:查看 `logs/` 目录下的日志文件排查问题
|
||||||
|
- **API 测试**:使用 Swagger 文档测试接口功能
|
||||||
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Docker 构建问题排查
|
||||||
|
|
||||||
|
## 问题:网络超时无法拉取镜像
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
|
||||||
|
1. **已配置镜像加速器** ✅
|
||||||
|
- 已添加国内镜像源到 `~/.docker/daemon.json`
|
||||||
|
- 镜像源包括:中科大、网易、百度云
|
||||||
|
|
||||||
|
2. **重启 Docker Desktop**
|
||||||
|
- 点击状态栏的 Docker 图标
|
||||||
|
- 选择 "Quit Docker Desktop"
|
||||||
|
- 重新启动 Docker Desktop
|
||||||
|
- 等待 Docker 完全启动(状态栏图标不再闪烁)
|
||||||
|
|
||||||
|
3. **验证镜像加速器是否生效**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker info | grep -A 10 "Registry Mirrors"
|
||||||
|
```
|
||||||
|
|
||||||
|
应该看到配置的镜像源列表。
|
||||||
|
|
||||||
|
4. **重新构建镜像**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 如果仍然失败
|
||||||
|
|
||||||
|
**方案 A:使用代理(如果有)**
|
||||||
|
|
||||||
|
在 Docker Desktop 中配置代理:
|
||||||
|
- Settings → Resources → Proxies
|
||||||
|
- 配置 HTTP/HTTPS 代理
|
||||||
|
|
||||||
|
**方案 B:手动拉取镜像**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 手动拉取所需的基础镜像
|
||||||
|
docker pull node:18-alpine
|
||||||
|
docker pull mcr.microsoft.com/dotnet/sdk:6.0
|
||||||
|
docker pull mcr.microsoft.com/dotnet/aspnet:6.0
|
||||||
|
|
||||||
|
# 然后再构建
|
||||||
|
docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
**方案 C:使用阿里云镜像(推荐)**
|
||||||
|
|
||||||
|
如果你有阿里云账号,可以配置阿里云容器镜像服务的加速器:
|
||||||
|
|
||||||
|
1. 登录阿里云容器镜像服务:https://cr.console.aliyun.com/
|
||||||
|
2. 获取你的专属加速地址(格式:`https://xxxxx.mirror.aliyuncs.com`)
|
||||||
|
3. 更新 `~/.docker/daemon.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registry-mirrors": [
|
||||||
|
"https://你的专属地址.mirror.aliyuncs.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 重启 Docker Desktop
|
||||||
|
|
||||||
|
### 检查网络连接
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 测试 Docker Hub 连接
|
||||||
|
curl -I https://registry-1.docker.io/v2/
|
||||||
|
|
||||||
|
# 测试镜像源连接
|
||||||
|
curl -I https://docker.mirrors.ustc.edu.cn/v2/
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Docker 构建网络问题解决方案
|
||||||
|
|
||||||
|
## 问题分析
|
||||||
|
|
||||||
|
**错误信息**:
|
||||||
|
```
|
||||||
|
dial tcp: lookup docker.mirrors.ustc.edu.cn: no such host
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- DNS 无法解析中科大镜像源地址
|
||||||
|
- Docker Desktop 仍在使用旧的镜像源配置
|
||||||
|
- 网络环境可能有限制
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
### 方案一:重启 Docker Desktop(推荐先试这个)
|
||||||
|
|
||||||
|
1. **完全退出 Docker Desktop**
|
||||||
|
- 点击状态栏 Docker 图标
|
||||||
|
- 选择 "Quit Docker Desktop"
|
||||||
|
- 等待完全退出
|
||||||
|
|
||||||
|
2. **重新启动 Docker Desktop**
|
||||||
|
- 从 Applications 启动 Docker Desktop
|
||||||
|
- 等待完全启动(状态栏图标不再闪烁)
|
||||||
|
|
||||||
|
3. **验证新配置是否生效**
|
||||||
|
```bash
|
||||||
|
docker info | grep -A 10 "Registry Mirrors"
|
||||||
|
```
|
||||||
|
应该看到新的镜像源(阿里云、七牛云等)
|
||||||
|
|
||||||
|
4. **重新构建**
|
||||||
|
```bash
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案二:配置 Docker Desktop 代理(如果有代理)
|
||||||
|
|
||||||
|
如果你有可用的 HTTP/HTTPS 代理:
|
||||||
|
|
||||||
|
1. **打开 Docker Desktop 设置**
|
||||||
|
- 点击状态栏 Docker 图标 → Settings
|
||||||
|
|
||||||
|
2. **配置代理**
|
||||||
|
- 进入 "Resources" → "Proxies"
|
||||||
|
- 启用 "Manual proxy configuration"
|
||||||
|
- 填写代理地址和端口
|
||||||
|
- 点击 "Apply & Restart"
|
||||||
|
|
||||||
|
3. **重新构建**
|
||||||
|
|
||||||
|
### 方案三:使用阿里云容器镜像服务(推荐)
|
||||||
|
|
||||||
|
如果你有阿里云账号:
|
||||||
|
|
||||||
|
1. **登录阿里云容器镜像服务**
|
||||||
|
- 访问:https://cr.console.aliyun.com/
|
||||||
|
- 登录你的阿里云账号
|
||||||
|
|
||||||
|
2. **获取专属加速地址**
|
||||||
|
- 进入 "镜像加速器" 页面
|
||||||
|
- 复制你的专属加速地址(格式:`https://xxxxx.mirror.aliyuncs.com`)
|
||||||
|
|
||||||
|
3. **更新 Docker 配置**
|
||||||
|
```bash
|
||||||
|
cat > ~/.docker/daemon.json << EOF
|
||||||
|
{
|
||||||
|
"builder": {
|
||||||
|
"gc": {
|
||||||
|
"defaultKeepStorage": "20GB",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"experimental": false,
|
||||||
|
"registry-mirrors": [
|
||||||
|
"https://你的专属地址.mirror.aliyuncs.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **重启 Docker Desktop**
|
||||||
|
|
||||||
|
5. **重新构建**
|
||||||
|
|
||||||
|
### 方案四:手动拉取镜像后构建
|
||||||
|
|
||||||
|
如果镜像加速器配置正确,可以尝试手动拉取:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 手动拉取所需的基础镜像
|
||||||
|
docker pull node:18-alpine
|
||||||
|
docker pull mcr.microsoft.com/dotnet/sdk:6.0
|
||||||
|
docker pull mcr.microsoft.com/dotnet/aspnet:6.0
|
||||||
|
|
||||||
|
# 如果拉取成功,再构建
|
||||||
|
docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案五:使用 VPN 或科学上网工具
|
||||||
|
|
||||||
|
如果你有 VPN 或科学上网工具:
|
||||||
|
|
||||||
|
1. **启动 VPN/科学上网工具**
|
||||||
|
2. **配置系统代理**
|
||||||
|
3. **在 Docker Desktop 中配置代理**(参考方案二)
|
||||||
|
4. **重新构建**
|
||||||
|
|
||||||
|
### 方案六:修改 DNS 设置
|
||||||
|
|
||||||
|
如果 DNS 解析有问题,可以尝试:
|
||||||
|
|
||||||
|
1. **修改系统 DNS**
|
||||||
|
- 系统设置 → 网络 → 高级 → DNS
|
||||||
|
- 添加 DNS 服务器:`223.5.5.5`(阿里云)、`114.114.114.114`(114 DNS)
|
||||||
|
|
||||||
|
2. **刷新 DNS 缓存**
|
||||||
|
```bash
|
||||||
|
sudo dscacheutil -flushcache
|
||||||
|
sudo killall -HUP mDNSResponder
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **重启 Docker Desktop**
|
||||||
|
|
||||||
|
## 当前配置状态
|
||||||
|
|
||||||
|
已更新的镜像源配置(`~/.docker/daemon.json`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registry-mirrors": [
|
||||||
|
"https://registry.cn-hangzhou.aliyuncs.com",
|
||||||
|
"https://dockerhub.azk8s.cn",
|
||||||
|
"https://reg-mirror.qiniu.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:需要重启 Docker Desktop 才能生效!
|
||||||
|
|
||||||
|
## 推荐操作顺序
|
||||||
|
|
||||||
|
1. ✅ **先试方案一**:重启 Docker Desktop
|
||||||
|
2. 如果还不行,**试方案三**:配置阿里云专属加速器
|
||||||
|
3. 如果还不行,**试方案二**:配置代理
|
||||||
|
4. 最后考虑**方案五**:使用 VPN
|
||||||
|
|
||||||
|
## 验证步骤
|
||||||
|
|
||||||
|
每次尝试后,运行以下命令验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 检查镜像源配置
|
||||||
|
docker info | grep -A 10 "Registry Mirrors"
|
||||||
|
|
||||||
|
# 2. 测试拉取镜像
|
||||||
|
docker pull node:18-alpine
|
||||||
|
|
||||||
|
# 3. 如果拉取成功,开始构建
|
||||||
|
docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# 视频预览功能测试指南
|
||||||
|
|
||||||
|
## ✅ 功能已实现
|
||||||
|
|
||||||
|
- ✅ 后端视频流接口(支持 Range 请求)
|
||||||
|
- ✅ 后端 Cookie 认证支持
|
||||||
|
- ✅ 前端视频播放器组件
|
||||||
|
- ✅ 表格预览按钮
|
||||||
|
|
||||||
|
## 🧪 测试步骤
|
||||||
|
|
||||||
|
### 步骤1:确保已登录
|
||||||
|
|
||||||
|
1. 访问 `http://localhost:8001`
|
||||||
|
2. 如果未登录,使用以下账号登录:
|
||||||
|
- 用户名:`douyin`
|
||||||
|
- 密码:`douyin2025`
|
||||||
|
|
||||||
|
### 步骤2:进入同步记录页面
|
||||||
|
|
||||||
|
1. 点击左侧菜单「同步记录」
|
||||||
|
2. 或直接访问:`http://localhost:8001/workplace`
|
||||||
|
|
||||||
|
### 步骤3:查找有视频的记录
|
||||||
|
|
||||||
|
在视频列表中:
|
||||||
|
- 找到有 `videoSavePath` 字段的记录
|
||||||
|
- 「预览」按钮应该是可点击的(不是灰色)
|
||||||
|
- 如果按钮是灰色的,说明该记录没有视频文件
|
||||||
|
|
||||||
|
### 步骤4:点击预览按钮
|
||||||
|
|
||||||
|
1. 点击视频行右侧「操作」列的「预览」按钮
|
||||||
|
2. 视频播放器模态框应该会打开
|
||||||
|
|
||||||
|
### 步骤5:测试播放功能
|
||||||
|
|
||||||
|
在视频播放器中测试:
|
||||||
|
- ✅ **播放/暂停**:点击播放按钮
|
||||||
|
- ✅ **音量控制**:调整音量滑块
|
||||||
|
- ✅ **进度条拖拽**:拖动进度条到任意位置(测试 Range 请求)
|
||||||
|
- ✅ **全屏播放**:点击全屏按钮
|
||||||
|
- ✅ **视频信息显示**:检查作者、分类、分辨率、文件大小是否正确显示
|
||||||
|
|
||||||
|
## 🔍 验证检查清单
|
||||||
|
|
||||||
|
### 前端检查
|
||||||
|
|
||||||
|
- [ ] 「预览」按钮可见且可点击
|
||||||
|
- [ ] 点击后模态框正常打开
|
||||||
|
- [ ] 视频信息正确显示
|
||||||
|
- [ ] 视频可以正常播放
|
||||||
|
- [ ] 进度条可以拖拽(Range 请求)
|
||||||
|
- [ ] 全屏功能正常
|
||||||
|
|
||||||
|
### 后端检查
|
||||||
|
|
||||||
|
- [ ] 视频流接口返回 200 状态码
|
||||||
|
- [ ] 支持 Range 请求(206 状态码)
|
||||||
|
- [ ] Cookie 认证正常工作
|
||||||
|
- [ ] 视频文件可以正常读取
|
||||||
|
|
||||||
|
## 🐛 常见问题排查
|
||||||
|
|
||||||
|
### 问题1:预览按钮是灰色的
|
||||||
|
|
||||||
|
**原因**:该视频记录没有 `videoSavePath` 字段
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
- 检查数据库中该视频是否有 `videoSavePath`
|
||||||
|
- 确认视频文件是否已下载
|
||||||
|
|
||||||
|
### 问题2:点击预览后没有反应
|
||||||
|
|
||||||
|
**检查**:
|
||||||
|
1. 打开浏览器控制台(F12)
|
||||||
|
2. 查看 Console 标签是否有错误
|
||||||
|
3. 查看 Network 标签,检查 `/api/video/stream/{id}` 请求
|
||||||
|
4. 查看请求状态码:
|
||||||
|
- `200`:成功
|
||||||
|
- `401`:未授权(需要重新登录)
|
||||||
|
- `404`:视频不存在
|
||||||
|
- `500`:服务器错误
|
||||||
|
|
||||||
|
### 问题3:视频无法播放
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
1. **401 未授权**:
|
||||||
|
- 检查是否已登录
|
||||||
|
- 检查 Cookie 中是否有 token
|
||||||
|
- 重新登录
|
||||||
|
|
||||||
|
2. **404 视频不存在**:
|
||||||
|
- 检查视频文件是否实际存在
|
||||||
|
- 检查 `videoSavePath` 路径是否正确
|
||||||
|
|
||||||
|
3. **视频格式不支持**:
|
||||||
|
- 检查浏览器是否支持该视频格式
|
||||||
|
- 尝试使用其他浏览器
|
||||||
|
|
||||||
|
4. **网络问题**:
|
||||||
|
- 检查后端服务是否正常运行
|
||||||
|
- 检查网络连接
|
||||||
|
|
||||||
|
### 问题4:视频加载很慢
|
||||||
|
|
||||||
|
**原因**:大文件需要时间加载
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
- 等待加载完成
|
||||||
|
- 检查网络速度
|
||||||
|
- 查看浏览器 Network 标签,确认下载速度
|
||||||
|
|
||||||
|
### 问题5:Range 请求不工作(无法拖拽)
|
||||||
|
|
||||||
|
**检查**:
|
||||||
|
1. 打开浏览器 Network 标签
|
||||||
|
2. 拖动视频进度条
|
||||||
|
3. 查看是否有新的请求,状态码应该是 `206`
|
||||||
|
4. 检查响应头是否有 `Content-Range` 字段
|
||||||
|
|
||||||
|
## 🔧 调试技巧
|
||||||
|
|
||||||
|
### 1. 浏览器控制台调试
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 在浏览器控制台执行,检查 token
|
||||||
|
document.cookie
|
||||||
|
|
||||||
|
// 检查视频 URL
|
||||||
|
// 打开视频播放器后,在控制台执行:
|
||||||
|
document.querySelector('video').src
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 网络请求检查
|
||||||
|
|
||||||
|
1. 按 `F12` 打开开发者工具
|
||||||
|
2. 切换到「Network」标签
|
||||||
|
3. 点击「预览」按钮
|
||||||
|
4. 查看 `/api/video/stream/{id}` 请求:
|
||||||
|
- **Request Headers**:检查是否有 Cookie
|
||||||
|
- **Response Headers**:检查 `Accept-Ranges: bytes`
|
||||||
|
- **Status Code**:应该是 200 或 206
|
||||||
|
|
||||||
|
### 3. 后端日志检查
|
||||||
|
|
||||||
|
查看后端日志文件:
|
||||||
|
```bash
|
||||||
|
tail -f logs/log-debug-20251120.txt | grep -E "(video|stream|401|404)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 测试记录模板
|
||||||
|
|
||||||
|
```
|
||||||
|
测试日期:____
|
||||||
|
测试浏览器:____
|
||||||
|
测试视频ID:____
|
||||||
|
测试结果:
|
||||||
|
- [ ] 预览按钮可见
|
||||||
|
- [ ] 模态框正常打开
|
||||||
|
- [ ] 视频信息显示正确
|
||||||
|
- [ ] 视频可以播放
|
||||||
|
- [ ] 进度条可以拖拽
|
||||||
|
- [ ] 全屏功能正常
|
||||||
|
- [ ] Range 请求正常(206状态码)
|
||||||
|
|
||||||
|
问题记录:
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 快速测试命令
|
||||||
|
|
||||||
|
### 测试 API(需要先登录获取 token)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 登录获取 token(通过浏览器登录后,从 Cookie 中获取)
|
||||||
|
# 2. 测试视频流接口
|
||||||
|
curl -H "Cookie: Authorization=Bearer YOUR_TOKEN" \
|
||||||
|
http://localhost:10101/api/video/stream/VIDEO_ID \
|
||||||
|
--output test_video.mp4
|
||||||
|
|
||||||
|
# 3. 测试 Range 请求
|
||||||
|
curl -H "Cookie: Authorization=Bearer YOUR_TOKEN" \
|
||||||
|
-H "Range: bytes=0-1023" \
|
||||||
|
http://localhost:10101/api/video/stream/VIDEO_ID \
|
||||||
|
-v
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ 预期结果
|
||||||
|
|
||||||
|
1. **点击预览按钮** → 模态框打开
|
||||||
|
2. **视频加载** → 显示加载动画,然后显示视频
|
||||||
|
3. **点击播放** → 视频开始播放
|
||||||
|
4. **拖动进度条** → 视频跳转到对应位置(Range 请求)
|
||||||
|
5. **全屏播放** → 视频全屏显示
|
||||||
|
6. **关闭模态框** → 视频停止播放,模态框关闭
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**:如果遇到问题,请按照上述排查步骤逐一检查,并查看浏览器控制台和网络请求的详细信息。
|
||||||
|
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# 视频预览功能实现说明
|
||||||
|
|
||||||
|
## 📋 功能概述
|
||||||
|
|
||||||
|
已成功实现内置视频预览功能,用户可以在同步记录页面直接预览已下载的视频,无需下载到本地。
|
||||||
|
|
||||||
|
## ✨ 实现的功能
|
||||||
|
|
||||||
|
### 1. 后端 API 接口
|
||||||
|
|
||||||
|
#### 视频流接口
|
||||||
|
- **路径**: `GET /api/video/stream/{id}`
|
||||||
|
- **功能**: 提供视频文件流式传输,支持 HTTP Range 请求
|
||||||
|
- **特性**:
|
||||||
|
- ✅ 支持视频拖拽播放(Range 请求)
|
||||||
|
- ✅ 流式传输,不占用大量内存
|
||||||
|
- ✅ 自动识别视频格式(MP4、WebM、MOV 等)
|
||||||
|
- ✅ JWT 认证保护
|
||||||
|
|
||||||
|
#### 视频封面接口
|
||||||
|
- **路径**: `GET /api/video/cover/{id}`
|
||||||
|
- **功能**: 获取视频封面图片
|
||||||
|
- **特性**:
|
||||||
|
- ✅ 优先使用本地保存的封面
|
||||||
|
- ✅ 支持 JPG 格式
|
||||||
|
|
||||||
|
### 2. 前端组件
|
||||||
|
|
||||||
|
#### VideoPlayer 组件
|
||||||
|
- **位置**: `app/src/components/video/VideoPlayer.vue`
|
||||||
|
- **功能**: 视频播放器模态框组件
|
||||||
|
- **特性**:
|
||||||
|
- ✅ 使用 HTML5 `<video>` 标签
|
||||||
|
- ✅ 支持播放控制(播放/暂停/音量/全屏)
|
||||||
|
- ✅ 显示视频信息(作者、分类、分辨率、文件大小)
|
||||||
|
- ✅ 加载状态提示
|
||||||
|
- ✅ 错误处理和提示
|
||||||
|
- ✅ 响应式设计
|
||||||
|
|
||||||
|
#### 表格预览功能
|
||||||
|
- **位置**: `app/src/pages/workplace/RecordTable.vue`
|
||||||
|
- **功能**: 在视频列表表格中添加预览按钮
|
||||||
|
- **特性**:
|
||||||
|
- ✅ 操作列显示预览按钮
|
||||||
|
- ✅ 自动禁用无视频文件的记录
|
||||||
|
- ✅ 点击预览打开播放器模态框
|
||||||
|
|
||||||
|
## 🚀 使用方法
|
||||||
|
|
||||||
|
### 1. 预览视频
|
||||||
|
|
||||||
|
1. 进入「同步记录」页面
|
||||||
|
2. 在视频列表中找到要预览的视频
|
||||||
|
3. 点击「预览」按钮
|
||||||
|
4. 视频播放器会在模态框中打开
|
||||||
|
5. 可以播放、暂停、调整音量、全屏等操作
|
||||||
|
|
||||||
|
### 2. API 调用示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 获取视频流
|
||||||
|
const videoUrl = `/api/video/stream/${videoId}`;
|
||||||
|
|
||||||
|
// 在 video 标签中使用
|
||||||
|
<video :src="videoUrl" controls />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 技术实现细节
|
||||||
|
|
||||||
|
### 后端实现
|
||||||
|
|
||||||
|
1. **视频流传输**
|
||||||
|
- 使用 `FileStream` 读取视频文件
|
||||||
|
- 启用 `enableRangeProcessing: true` 支持 Range 请求
|
||||||
|
- 自动设置 `Accept-Ranges: bytes` 响应头
|
||||||
|
- 根据文件扩展名设置正确的 Content-Type
|
||||||
|
|
||||||
|
2. **错误处理**
|
||||||
|
- 检查视频是否存在
|
||||||
|
- 检查文件是否存在
|
||||||
|
- 记录错误日志
|
||||||
|
|
||||||
|
### 前端实现
|
||||||
|
|
||||||
|
1. **视频播放器**
|
||||||
|
- 使用 Vue 3 Composition API
|
||||||
|
- 响应式设计,适配不同屏幕尺寸
|
||||||
|
- 加载状态和错误处理
|
||||||
|
- 文件大小格式化显示
|
||||||
|
|
||||||
|
2. **表格集成**
|
||||||
|
- 使用 Ant Design Vue 表格组件
|
||||||
|
- 自定义操作列模板
|
||||||
|
- 组件引用和方法调用
|
||||||
|
|
||||||
|
## 📁 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
dysync.net/
|
||||||
|
├── Controllers/
|
||||||
|
│ └── VideoController.cs # 视频流和封面接口
|
||||||
|
├── service/
|
||||||
|
│ └── DyCollectVideoService.cs # 添加 GetByIdAsync 方法
|
||||||
|
├── app/src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── video/
|
||||||
|
│ │ └── VideoPlayer.vue # 视频播放器组件
|
||||||
|
│ └── pages/
|
||||||
|
│ └── workplace/
|
||||||
|
│ └── RecordTable.vue # 添加预览功能
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 优化建议
|
||||||
|
|
||||||
|
### 1. 性能优化
|
||||||
|
|
||||||
|
- ✅ **已实现**: Range 请求支持(视频拖拽播放)
|
||||||
|
- ✅ **已实现**: 流式传输(不占用大量内存)
|
||||||
|
- 💡 **建议**: 添加视频缓存策略(ETag、Last-Modified)
|
||||||
|
- 💡 **建议**: 添加 CDN 支持(如果视频文件很大)
|
||||||
|
|
||||||
|
### 2. 功能增强
|
||||||
|
|
||||||
|
- 💡 **建议**: 添加视频缩略图预览(鼠标悬停显示)
|
||||||
|
- 💡 **建议**: 添加播放历史记录
|
||||||
|
- 💡 **建议**: 添加播放速度控制
|
||||||
|
- 💡 **建议**: 添加字幕支持(如果有)
|
||||||
|
|
||||||
|
### 3. 用户体验
|
||||||
|
|
||||||
|
- 💡 **建议**: 添加视频卡片视图(网格布局)
|
||||||
|
- 💡 **建议**: 添加视频搜索和筛选
|
||||||
|
- 💡 **建议**: 添加视频收藏功能
|
||||||
|
- 💡 **建议**: 添加视频分享功能
|
||||||
|
|
||||||
|
### 4. 安全性
|
||||||
|
|
||||||
|
- ✅ **已实现**: JWT 认证保护
|
||||||
|
- 💡 **建议**: 添加视频访问权限控制
|
||||||
|
- 💡 **建议**: 添加视频水印(可选)
|
||||||
|
|
||||||
|
## 🐛 已知问题
|
||||||
|
|
||||||
|
1. **大文件加载**: 对于非常大的视频文件,首次加载可能需要一些时间
|
||||||
|
- **解决方案**: 已实现 Range 请求,支持分段加载
|
||||||
|
|
||||||
|
2. **浏览器兼容性**: 某些旧版浏览器可能不支持 HTML5 视频
|
||||||
|
- **解决方案**: 显示友好的错误提示
|
||||||
|
|
||||||
|
## 📝 后续优化方向
|
||||||
|
|
||||||
|
### 短期优化(1-2周)
|
||||||
|
|
||||||
|
1. 添加视频缩略图预览
|
||||||
|
2. 优化加载性能(添加加载进度条)
|
||||||
|
3. 添加视频播放历史记录
|
||||||
|
|
||||||
|
### 中期优化(1-2月)
|
||||||
|
|
||||||
|
1. 实现视频卡片视图
|
||||||
|
2. 添加视频搜索和高级筛选
|
||||||
|
3. 添加播放列表功能
|
||||||
|
|
||||||
|
### 长期优化(3-6月)
|
||||||
|
|
||||||
|
1. 视频转码和压缩
|
||||||
|
2. 多分辨率支持
|
||||||
|
3. 视频分析和统计
|
||||||
|
|
||||||
|
## 🔍 测试建议
|
||||||
|
|
||||||
|
1. **功能测试**
|
||||||
|
- 测试不同格式的视频(MP4、WebM、MOV)
|
||||||
|
- 测试不同大小的视频文件
|
||||||
|
- 测试 Range 请求(拖拽播放)
|
||||||
|
|
||||||
|
2. **性能测试**
|
||||||
|
- 测试大文件加载性能
|
||||||
|
- 测试并发播放性能
|
||||||
|
- 测试内存占用
|
||||||
|
|
||||||
|
3. **兼容性测试**
|
||||||
|
- 测试不同浏览器(Chrome、Firefox、Safari、Edge)
|
||||||
|
- 测试移动端浏览器
|
||||||
|
- 测试不同网络环境
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [HTML5 Video API](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/video)
|
||||||
|
- [HTTP Range Requests](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Range_requests)
|
||||||
|
- [ASP.NET Core File Streaming](https://docs.microsoft.com/aspnet/core/mvc/models/file-uploads)
|
||||||
|
|
||||||
|
## ✅ 完成清单
|
||||||
|
|
||||||
|
- [x] 后端视频流接口
|
||||||
|
- [x] 后端视频封面接口
|
||||||
|
- [x] 前端视频播放器组件
|
||||||
|
- [x] 表格预览按钮
|
||||||
|
- [x] Range 请求支持
|
||||||
|
- [x] 错误处理
|
||||||
|
- [x] 加载状态提示
|
||||||
|
- [ ] 视频缩略图预览(可选)
|
||||||
|
- [ ] 视频卡片视图(可选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新**: 2024-11-19
|
||||||
|
**版本**: v1.0
|
||||||
|
|
||||||
+381
@@ -0,0 +1,381 @@
|
|||||||
|
# dysync.net 部署指南
|
||||||
|
|
||||||
|
本文档详细说明如何将修改后的项目部署到生产环境。
|
||||||
|
|
||||||
|
## 📋 目录
|
||||||
|
|
||||||
|
1. [准备工作](#准备工作)
|
||||||
|
2. [构建 Docker 镜像](#构建-docker-镜像)
|
||||||
|
3. [推送到镜像仓库](#推送到镜像仓库)
|
||||||
|
4. [使用 Docker Compose 部署](#使用-docker-compose-部署)
|
||||||
|
5. [验证部署](#验证部署)
|
||||||
|
6. [常见问题](#常见问题)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 准备工作
|
||||||
|
|
||||||
|
### 1. 环境要求
|
||||||
|
|
||||||
|
- **Docker**:版本 20.10+
|
||||||
|
- **Docker Compose**:版本 1.29+(或 Docker Compose V2)
|
||||||
|
- **.NET SDK 6.0**:用于构建项目(本地开发环境)
|
||||||
|
- **Node.js 18+**:用于构建前端(本地开发环境)
|
||||||
|
- **阿里云容器镜像服务账号**:用于推送镜像(或其他镜像仓库)
|
||||||
|
|
||||||
|
### 2. 创建必要的目录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在项目根目录创建以下目录(如果不存在)
|
||||||
|
mkdir -p db collect favorite uper logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 配置镜像仓库(阿里云)
|
||||||
|
|
||||||
|
1. 登录 [阿里云容器镜像服务](https://cr.console.aliyun.com/)
|
||||||
|
2. 创建命名空间(如:`jianzhichu`)
|
||||||
|
3. 创建镜像仓库(如:`dysync.net`)
|
||||||
|
4. 获取登录凭证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 构建 Docker 镜像
|
||||||
|
|
||||||
|
### 方式一:使用多阶段构建(推荐)
|
||||||
|
|
||||||
|
项目已提供优化的 `Dockerfile.prod`,包含前端和后端的完整构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在项目根目录执行
|
||||||
|
docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:手动构建前端后构建镜像
|
||||||
|
|
||||||
|
如果多阶段构建遇到问题,可以手动构建前端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 构建前端
|
||||||
|
cd app
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 2. 确保 app/dist 目录存在且包含构建产物
|
||||||
|
|
||||||
|
# 3. 使用原始 Dockerfile 构建
|
||||||
|
docker build -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建 ARM 架构镜像(适用于 Apple Silicon、树莓派等)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 Dockerfile-arm(如果存在)
|
||||||
|
docker build -f Dockerfile-arm -t dysync.net:arm-latest .
|
||||||
|
|
||||||
|
# 或者使用 buildx 构建多架构镜像
|
||||||
|
docker buildx build --platform linux/amd64,linux/arm64 -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📤 推送到镜像仓库
|
||||||
|
|
||||||
|
### 1. 登录阿里云容器镜像服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 替换为你的实际信息
|
||||||
|
docker login --username=你的用户名 registry.cn-hangzhou.aliyuncs.com
|
||||||
|
# 输入密码(在阿里云控制台获取)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 标记镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 标记镜像(替换为你的实际仓库地址)
|
||||||
|
docker tag dysync.net:latest registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
|
||||||
|
# 如果构建了 ARM 版本
|
||||||
|
docker tag dysync.net:arm-latest registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:arm_1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 推送镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推送 AMD64 版本
|
||||||
|
docker push registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
|
||||||
|
# 推送 ARM 版本(如果构建了)
|
||||||
|
docker push registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:arm_1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 使用 Docker Compose 部署
|
||||||
|
|
||||||
|
### 1. 准备 docker-compose.yml
|
||||||
|
|
||||||
|
项目根目录已包含 `docker-compose.yml` 文件,根据实际情况修改路径:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
dysync:
|
||||||
|
image: registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
container_name: dysync
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "10101:10101"
|
||||||
|
volumes:
|
||||||
|
- ./db:/app/db # 数据库目录
|
||||||
|
- ./collect:/app/collect # 收藏视频目录
|
||||||
|
- ./favorite:/app/favorite # 喜欢视频目录
|
||||||
|
- ./uper:/app/uper # 博主视频目录(可选)
|
||||||
|
- ./logs:/app/logs # 日志目录(可选)
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
dns:
|
||||||
|
- 8.8.8.8
|
||||||
|
- 114.114.114.114
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 修改路径映射
|
||||||
|
|
||||||
|
**重要**:根据你的实际存储路径修改 `volumes` 部分:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
# Linux/Unix 路径示例
|
||||||
|
- /volume1/docker/dysync/db:/app/db
|
||||||
|
- /volume2/media/dysync/collect:/app/collect
|
||||||
|
- /volume2/media/dysync/favorite:/app/favorite
|
||||||
|
|
||||||
|
# Windows 路径示例(使用正斜杠)
|
||||||
|
- D:/dysync/db:/app/db
|
||||||
|
- D:/dysync/collect:/app/collect
|
||||||
|
- D:/dysync/favorite:/app/favorite
|
||||||
|
|
||||||
|
# macOS 路径示例
|
||||||
|
- ~/dysync/db:/app/db
|
||||||
|
- ~/dysync/collect:/app/collect
|
||||||
|
- ~/dysync/favorite:/app/favorite
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动服务(后台运行)
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 查看服务状态
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 停止服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止服务
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# 停止并删除数据卷(谨慎使用,会删除数据)
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 验证部署
|
||||||
|
|
||||||
|
### 1. 检查容器状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps | grep dysync
|
||||||
|
```
|
||||||
|
|
||||||
|
应该看到容器正在运行。
|
||||||
|
|
||||||
|
### 2. 检查日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs dysync
|
||||||
|
# 或
|
||||||
|
docker-compose logs -f dysync
|
||||||
|
```
|
||||||
|
|
||||||
|
应该看到类似输出:
|
||||||
|
```
|
||||||
|
dy.net service started successfully
|
||||||
|
Now listening on: http://[::]:10101
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 访问应用
|
||||||
|
|
||||||
|
打开浏览器访问:
|
||||||
|
- **前端界面**:`http://你的服务器IP:10101`
|
||||||
|
- **Swagger API**:`http://你的服务器IP:10101/swagger`(如果启用)
|
||||||
|
|
||||||
|
### 4. 测试功能
|
||||||
|
|
||||||
|
1. 使用默认账号登录:`douyin` / `douyin2025`
|
||||||
|
2. 配置抖音 Cookie 和路径
|
||||||
|
3. 测试视频同步功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 更新部署
|
||||||
|
|
||||||
|
### 更新镜像并重启
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 拉取最新镜像
|
||||||
|
docker-compose pull
|
||||||
|
|
||||||
|
# 2. 重启服务(会使用新镜像)
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 3. 查看更新后的日志
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重新构建并部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 构建新镜像
|
||||||
|
docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
|
||||||
|
# 2. 标记并推送
|
||||||
|
docker tag dysync.net:latest registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
docker push registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
|
||||||
|
# 3. 在服务器上拉取并重启
|
||||||
|
docker-compose pull
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
### 1. 镜像构建失败
|
||||||
|
|
||||||
|
**问题:前端构建失败**
|
||||||
|
```bash
|
||||||
|
# 检查 Node.js 版本
|
||||||
|
node --version # 应该是 18+
|
||||||
|
|
||||||
|
# 清除缓存重新构建
|
||||||
|
cd app
|
||||||
|
rm -rf node_modules dist
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:后端构建失败**
|
||||||
|
```bash
|
||||||
|
# 检查 .NET SDK 版本
|
||||||
|
dotnet --version # 应该是 6.0.x
|
||||||
|
|
||||||
|
# 清除构建缓存
|
||||||
|
dotnet clean
|
||||||
|
dotnet restore
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 容器启动失败
|
||||||
|
|
||||||
|
**问题:端口被占用**
|
||||||
|
```bash
|
||||||
|
# 检查端口占用
|
||||||
|
lsof -i :10101 # Linux/macOS
|
||||||
|
netstat -ano | findstr :10101 # Windows
|
||||||
|
|
||||||
|
# 修改 docker-compose.yml 中的端口映射
|
||||||
|
ports:
|
||||||
|
- "10102:10101" # 使用其他端口
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:权限错误**
|
||||||
|
```bash
|
||||||
|
# 确保目录有写权限
|
||||||
|
chmod -R 755 db collect favorite uper logs
|
||||||
|
|
||||||
|
# 或者使用 sudo(不推荐)
|
||||||
|
sudo docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 数据丢失问题
|
||||||
|
|
||||||
|
**问题:容器删除后数据丢失**
|
||||||
|
- 确保 `volumes` 配置正确
|
||||||
|
- 使用绝对路径而不是相对路径
|
||||||
|
- 定期备份 `db` 目录
|
||||||
|
|
||||||
|
### 4. 网络问题
|
||||||
|
|
||||||
|
**问题:无法访问外网**
|
||||||
|
- 检查 DNS 配置
|
||||||
|
- 检查防火墙设置
|
||||||
|
- 使用 `network_mode: bridge` 或 `host`
|
||||||
|
|
||||||
|
### 5. 镜像拉取失败
|
||||||
|
|
||||||
|
**问题:认证失败**
|
||||||
|
```bash
|
||||||
|
# 重新登录
|
||||||
|
docker logout registry.cn-hangzhou.aliyuncs.com
|
||||||
|
docker login registry.cn-hangzhou.aliyuncs.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:网络超时**
|
||||||
|
```bash
|
||||||
|
# 使用国内镜像加速器
|
||||||
|
# 在 /etc/docker/daemon.json 添加:
|
||||||
|
{
|
||||||
|
"registry-mirrors": [
|
||||||
|
"https://docker.mirrors.ustc.edu.cn",
|
||||||
|
"https://hub-mirror.c.163.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 部署检查清单
|
||||||
|
|
||||||
|
- [ ] Docker 和 Docker Compose 已安装
|
||||||
|
- [ ] 创建了必要的目录(db, collect, favorite 等)
|
||||||
|
- [ ] 修改了 docker-compose.yml 中的路径映射
|
||||||
|
- [ ] 构建了 Docker 镜像(或使用现有镜像)
|
||||||
|
- [ ] 推送到镜像仓库(或使用本地镜像)
|
||||||
|
- [ ] 启动了 Docker Compose 服务
|
||||||
|
- [ ] 验证了容器正常运行
|
||||||
|
- [ ] 能够访问前端界面
|
||||||
|
- [ ] 能够登录系统
|
||||||
|
- [ ] 配置了抖音 Cookie 和路径
|
||||||
|
- [ ] 测试了视频同步功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 安全建议
|
||||||
|
|
||||||
|
1. **修改默认密码**:首次登录后立即修改默认密码
|
||||||
|
2. **使用 HTTPS**:生产环境建议配置反向代理(Nginx)并启用 HTTPS
|
||||||
|
3. **防火墙配置**:只开放必要的端口(10101)
|
||||||
|
4. **定期备份**:定期备份 `db` 目录和重要数据
|
||||||
|
5. **更新镜像**:定期更新镜像以获取安全补丁
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [本地启动指南](./本地启动指南.md)
|
||||||
|
- [视频预览功能说明](./视频预览功能说明.md)
|
||||||
|
- [README.md](./README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新**:2024-11-22
|
||||||
|
**版本**:v1.0
|
||||||
|
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
# 镜像构建和推送指南
|
||||||
|
|
||||||
|
本文档说明如何构建 Docker 镜像并推送到阿里云容器镜像服务,以便在飞牛 NAS 上使用。
|
||||||
|
|
||||||
|
## 📋 目录
|
||||||
|
|
||||||
|
1. [前提条件](#前提条件)
|
||||||
|
2. [方式一:使用现有镜像(最简单)](#方式一使用现有镜像最简单)
|
||||||
|
3. [方式二:构建并推送镜像](#方式二构建并推送镜像)
|
||||||
|
4. [方式三:本地构建直接使用](#方式三本地构建直接使用)
|
||||||
|
5. [常见问题](#常见问题)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 前提条件
|
||||||
|
|
||||||
|
### 1. 阿里云容器镜像服务账号
|
||||||
|
|
||||||
|
- 注册 [阿里云账号](https://www.aliyun.com/)
|
||||||
|
- 开通 [容器镜像服务](https://cr.console.aliyun.com/)
|
||||||
|
- 创建命名空间(如:`jianzhichu`)
|
||||||
|
- 创建镜像仓库(如:`dysync.net`)
|
||||||
|
|
||||||
|
### 2. 获取登录凭证
|
||||||
|
|
||||||
|
1. 登录阿里云容器镜像服务控制台
|
||||||
|
2. 进入「访问凭证」
|
||||||
|
3. 设置固定密码或临时密码
|
||||||
|
4. 记录用户名和密码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 方式一:使用现有镜像(最简单)
|
||||||
|
|
||||||
|
**如果镜像仓库中已经有镜像,直接拉取即可,无需上传!**
|
||||||
|
|
||||||
|
### 检查镜像是否存在
|
||||||
|
|
||||||
|
1. 访问:`https://cr.console.aliyun.com/`
|
||||||
|
2. 进入你的命名空间
|
||||||
|
3. 查看是否有 `dysync.net` 仓库
|
||||||
|
4. 查看是否有 `latest` 标签的镜像
|
||||||
|
|
||||||
|
### 如果镜像存在
|
||||||
|
|
||||||
|
直接在飞牛 NAS 上拉取:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**无需任何上传操作!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 方式二:构建并推送镜像
|
||||||
|
|
||||||
|
**如果你修改了代码,或者镜像不存在,需要自己构建并推送。**
|
||||||
|
|
||||||
|
### 步骤 1:在本地构建镜像
|
||||||
|
|
||||||
|
#### 1.1 准备构建环境
|
||||||
|
|
||||||
|
确保本地已安装:
|
||||||
|
- Docker Desktop(Mac/Windows)或 Docker Engine(Linux)
|
||||||
|
- .NET SDK 6.0(可选,如果使用 Dockerfile.prod)
|
||||||
|
- Node.js 18+(可选,如果使用 Dockerfile.prod)
|
||||||
|
|
||||||
|
#### 1.2 构建镜像
|
||||||
|
|
||||||
|
**使用多阶段构建(推荐):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在项目根目录
|
||||||
|
docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
**或使用原始 Dockerfile(需要先构建前端):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 先构建前端
|
||||||
|
cd app
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 2. 构建镜像
|
||||||
|
docker build -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3 验证镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看镜像
|
||||||
|
docker images | grep dysync
|
||||||
|
|
||||||
|
# 测试运行(可选)
|
||||||
|
docker run -d -p 10101:10101 --name dysync-test dysync.net:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:登录阿里云镜像仓库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 登录(替换为你的实际信息)
|
||||||
|
docker login --username=你的用户名 registry.cn-hangzhou.aliyuncs.com
|
||||||
|
|
||||||
|
# 输入密码(在阿里云控制台获取)
|
||||||
|
```
|
||||||
|
|
||||||
|
**获取登录信息:**
|
||||||
|
1. 登录阿里云容器镜像服务控制台
|
||||||
|
2. 点击右上角「访问凭证」
|
||||||
|
3. 设置固定密码或使用临时密码
|
||||||
|
|
||||||
|
### 步骤 3:标记镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 标记镜像(替换为你的实际仓库地址)
|
||||||
|
docker tag dysync.net:latest registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
|
||||||
|
# 如果构建了 ARM 版本
|
||||||
|
docker tag dysync.net:arm-latest registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:arm_1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 4:推送镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推送 AMD64 版本
|
||||||
|
docker push registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
|
||||||
|
# 推送 ARM 版本(如果构建了)
|
||||||
|
docker push registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:arm_1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 5:在飞牛 NAS 上拉取
|
||||||
|
|
||||||
|
推送成功后,在飞牛 NAS 上拉取:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 方式三:本地构建直接使用
|
||||||
|
|
||||||
|
**如果你不想推送到远程仓库,可以在飞牛 NAS 上直接构建。**
|
||||||
|
|
||||||
|
### 步骤 1:上传项目文件
|
||||||
|
|
||||||
|
1. 将整个项目文件夹上传到飞牛 NAS
|
||||||
|
2. 建议放在:`/volume1/dysync/source/`
|
||||||
|
|
||||||
|
### 步骤 2:SSH 连接到飞牛 NAS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh admin@你的飞牛NAS IP
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:构建镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进入项目目录
|
||||||
|
cd /volume1/dysync/source
|
||||||
|
|
||||||
|
# 构建镜像(使用多阶段构建)
|
||||||
|
docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
|
||||||
|
# 或使用原始 Dockerfile(需要先构建前端)
|
||||||
|
cd app
|
||||||
|
pnpm install # 或 npm install
|
||||||
|
pnpm build # 或 npm run build
|
||||||
|
cd ..
|
||||||
|
docker build -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 4:使用本地镜像
|
||||||
|
|
||||||
|
修改 `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
dysync:
|
||||||
|
image: dysync.net:latest # 使用本地镜像
|
||||||
|
# ... 其他配置
|
||||||
|
```
|
||||||
|
|
||||||
|
或直接在 Docker 界面中选择本地镜像。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 完整流程示例
|
||||||
|
|
||||||
|
### 场景:首次部署,镜像不存在
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 在本地电脑构建镜像
|
||||||
|
cd /path/to/dysync.net
|
||||||
|
docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
|
||||||
|
# 2. 登录阿里云
|
||||||
|
docker login registry.cn-hangzhou.aliyuncs.com
|
||||||
|
|
||||||
|
# 3. 标记镜像
|
||||||
|
docker tag dysync.net:latest registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
|
||||||
|
# 4. 推送镜像
|
||||||
|
docker push registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
|
||||||
|
# 5. 在飞牛 NAS 上拉取
|
||||||
|
ssh admin@飞牛NAS IP
|
||||||
|
docker pull registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
|
||||||
|
# 6. 使用 docker-compose 部署
|
||||||
|
cd /volume1/dysync
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景:使用现有镜像(最简单)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 直接在飞牛 NAS 上拉取
|
||||||
|
docker pull registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
|
||||||
|
# 使用 docker-compose 部署
|
||||||
|
cd /volume1/dysync
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
### 1. 镜像拉取失败
|
||||||
|
|
||||||
|
**问题:镜像不存在**
|
||||||
|
|
||||||
|
```
|
||||||
|
Error response from daemon: pull access denied
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案:**
|
||||||
|
- 检查镜像地址是否正确
|
||||||
|
- 确认镜像是否已推送到仓库
|
||||||
|
- 如果镜像不存在,需要先构建并推送(方式二)
|
||||||
|
|
||||||
|
**问题:认证失败**
|
||||||
|
|
||||||
|
```
|
||||||
|
Error response from daemon: unauthorized
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案:**
|
||||||
|
```bash
|
||||||
|
# 重新登录
|
||||||
|
docker logout registry.cn-hangzhou.aliyuncs.com
|
||||||
|
docker login registry.cn-hangzhou.aliyuncs.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 镜像推送失败
|
||||||
|
|
||||||
|
**问题:权限不足**
|
||||||
|
|
||||||
|
```
|
||||||
|
denied: requested access to the resource is denied
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案:**
|
||||||
|
- 检查命名空间和仓库名称是否正确
|
||||||
|
- 确认账号有推送权限
|
||||||
|
- 检查是否已正确登录
|
||||||
|
|
||||||
|
**问题:网络超时**
|
||||||
|
|
||||||
|
```
|
||||||
|
net/http: TLS handshake timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案:**
|
||||||
|
- 检查网络连接
|
||||||
|
- 使用镜像加速器
|
||||||
|
- 重试推送
|
||||||
|
|
||||||
|
### 3. 构建失败
|
||||||
|
|
||||||
|
**问题:前端构建失败**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 清除缓存重新构建
|
||||||
|
cd app
|
||||||
|
rm -rf node_modules dist
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:后端构建失败**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 清除构建缓存
|
||||||
|
dotnet clean
|
||||||
|
dotnet restore
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 快速参考
|
||||||
|
|
||||||
|
### 检查镜像是否存在
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看本地镜像
|
||||||
|
docker images | grep dysync
|
||||||
|
|
||||||
|
# 尝试拉取(如果失败说明不存在)
|
||||||
|
docker pull registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整构建和推送命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一键构建和推送脚本
|
||||||
|
#!/bin/bash
|
||||||
|
IMAGE_NAME="dysync.net:latest"
|
||||||
|
REGISTRY="registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest"
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
docker build -f Dockerfile.prod -t $IMAGE_NAME .
|
||||||
|
|
||||||
|
# 登录(需要手动输入密码)
|
||||||
|
docker login registry.cn-hangzhou.aliyuncs.com
|
||||||
|
|
||||||
|
# 标记
|
||||||
|
docker tag $IMAGE_NAME $REGISTRY
|
||||||
|
|
||||||
|
# 推送
|
||||||
|
docker push $REGISTRY
|
||||||
|
|
||||||
|
echo "镜像推送完成: $REGISTRY"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 推荐方案
|
||||||
|
|
||||||
|
### 方案 A:使用现有镜像(推荐新手)
|
||||||
|
|
||||||
|
- ✅ 最简单,无需构建
|
||||||
|
- ✅ 直接拉取即可使用
|
||||||
|
- ❌ 无法自定义代码
|
||||||
|
|
||||||
|
### 方案 B:构建并推送(推荐开发者)
|
||||||
|
|
||||||
|
- ✅ 可以自定义代码
|
||||||
|
- ✅ 一次推送,多处使用
|
||||||
|
- ❌ 需要构建和推送步骤
|
||||||
|
|
||||||
|
### 方案 C:本地构建(推荐私有部署)
|
||||||
|
|
||||||
|
- ✅ 完全私有,不依赖外部服务
|
||||||
|
- ✅ 适合内网环境
|
||||||
|
- ❌ 需要在每台设备上构建
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新**:2024-11-22
|
||||||
|
**版本**:v1.0
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,770 @@
|
|||||||
|
# 飞牛 NAS 部署完整指南
|
||||||
|
|
||||||
|
本文档提供在飞牛 NAS 上部署 dysync.net 项目的完整步骤,包括环境准备、文件上传、镜像构建、容器部署和故障排查。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 目录
|
||||||
|
|
||||||
|
1. [环境准备](#环境准备)
|
||||||
|
2. [上传项目文件到 NAS](#上传项目文件到-nas)
|
||||||
|
3. [在 NAS 上构建 Docker 镜像](#在-nas-上构建-docker-镜像)
|
||||||
|
4. [配置和部署容器](#配置和部署容器)
|
||||||
|
5. [验证服务](#验证服务)
|
||||||
|
6. [常用管理命令](#常用管理命令)
|
||||||
|
7. [故障排查](#故障排查)
|
||||||
|
8. [更新部署](#更新部署)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 环境准备
|
||||||
|
|
||||||
|
### 本地电脑(Mac)
|
||||||
|
|
||||||
|
1. **SSH 客户端**(macOS 自带,无需安装)
|
||||||
|
2. **项目文件**:确保项目代码已准备好
|
||||||
|
|
||||||
|
### 飞牛 NAS 环境
|
||||||
|
|
||||||
|
- Docker 已安装并运行
|
||||||
|
- SSH 服务已启用
|
||||||
|
- 有足够的磁盘空间(建议至少 10GB)
|
||||||
|
- 已创建项目目录:`/vol2/1000/docker/dysync`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📤 上传项目文件到 NAS
|
||||||
|
|
||||||
|
### 步骤 1:压缩项目文件
|
||||||
|
|
||||||
|
在本地电脑上执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 进入项目根目录
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
|
||||||
|
# 2. 压缩项目文件(排除不必要的文件)
|
||||||
|
# 注意:将压缩文件输出到父目录,避免包含压缩文件本身
|
||||||
|
tar -czf ../dysync.net.tar.gz \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='app/node_modules' \
|
||||||
|
--exclude='bin' \
|
||||||
|
--exclude='obj' \
|
||||||
|
--exclude='.vs' \
|
||||||
|
--exclude='.idea' \
|
||||||
|
--exclude='videos' \
|
||||||
|
--exclude='*.md' \
|
||||||
|
--exclude='docs' \
|
||||||
|
--exclude='*.tar.gz' \
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:上传到 NAS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 上传到 NAS(替换为你的 NAS IP 和用户名)
|
||||||
|
scp ../dysync.net.tar.gz 你的用户名@NAS_IP:/vol2/1000/docker/dysync/
|
||||||
|
|
||||||
|
# 示例
|
||||||
|
scp ../dysync.net.tar.gz adminym@192.168.1.99:/vol2/1000/docker/dysync/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:在 NAS 上解压文件
|
||||||
|
|
||||||
|
SSH 连接到 NAS 后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 进入目录
|
||||||
|
cd /vol2/1000/docker/dysync
|
||||||
|
|
||||||
|
# 2. 创建项目文件夹并解压
|
||||||
|
mkdir -p dysync.net
|
||||||
|
tar -xzf dysync.net.tar.gz -C dysync.net/
|
||||||
|
|
||||||
|
# 3. 验证文件已解压
|
||||||
|
ls -la dysync.net/
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:解压时可能会看到很多 `tar: Ignoring unknown extended header keyword` 警告,这是正常的,不影响文件解压。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 在 NAS 上构建 Docker 镜像
|
||||||
|
|
||||||
|
### 步骤 1:配置 Docker 镜像加速器(可选)
|
||||||
|
|
||||||
|
飞牛 NAS 自带镜像源,通常无需配置。如果遇到网络问题,可以配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建 Docker 配置目录
|
||||||
|
sudo mkdir -p /etc/docker
|
||||||
|
|
||||||
|
# 创建镜像加速器配置
|
||||||
|
sudo tee /etc/docker/daemon.json << 'EOF'
|
||||||
|
{
|
||||||
|
"registry-mirrors": [
|
||||||
|
"https://docker.mirrors.ustc.edu.cn",
|
||||||
|
"https://hub-mirror.c.163.com",
|
||||||
|
"https://registry.cn-hangzhou.aliyuncs.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 重启 Docker 服务
|
||||||
|
sudo systemctl restart docker
|
||||||
|
sleep 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:手动拉取基础镜像(推荐)
|
||||||
|
|
||||||
|
避免构建时网络问题,先手动拉取:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 拉取所需的基础镜像
|
||||||
|
sudo docker pull node:18-alpine
|
||||||
|
sudo docker pull mcr.microsoft.com/dotnet/sdk:6.0
|
||||||
|
sudo docker pull mcr.microsoft.com/dotnet/aspnet:6.0
|
||||||
|
|
||||||
|
# 验证镜像已拉取
|
||||||
|
sudo docker images | grep -E "node|dotnet"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:检查并修复 Dockerfile
|
||||||
|
|
||||||
|
确保 Dockerfile.prod 配置正确:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /vol2/1000/docker/dysync/dysync.net
|
||||||
|
|
||||||
|
# 检查 Dockerfile.prod 是否存在
|
||||||
|
ls -la Dockerfile.prod
|
||||||
|
|
||||||
|
# 检查关键配置
|
||||||
|
cat Dockerfile.prod | grep -E "dotnet restore|dotnet publish"
|
||||||
|
```
|
||||||
|
|
||||||
|
**重要修复**:如果 Dockerfile 中缺少项目文件指定,需要修复:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 修复 dotnet restore 命令
|
||||||
|
sudo sed -i 's/RUN dotnet restore/RUN dotnet restore dy.net.csproj/' Dockerfile.prod
|
||||||
|
|
||||||
|
# 修复 dotnet publish 命令
|
||||||
|
sudo sed -i 's/RUN dotnet publish -c Release -o \/app\/publish/RUN dotnet publish dy.net.csproj -c Release -o \/app\/publish/' Dockerfile.prod
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置 npm/pnpm 镜像源**:编辑 Dockerfile.prod,确保包含国内镜像源配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano Dockerfile.prod
|
||||||
|
```
|
||||||
|
|
||||||
|
找到前端构建部分(约第 10-18 行),确保格式如下:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# 配置 npm 和 pnpm 使用国内镜像源
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com && \
|
||||||
|
npm install -g pnpm && \
|
||||||
|
pnpm config set registry https://registry.npmmirror.com && \
|
||||||
|
if [ -f pnpm-lock.yaml ]; then \
|
||||||
|
pnpm install --frozen-lockfile; \
|
||||||
|
else \
|
||||||
|
npm install; \
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
**添加删除 macOS 资源分叉文件的步骤**:在 Dockerfile.prod 的 `COPY . .` 之后添加:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano Dockerfile.prod
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `COPY . .` 之后添加:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# 复制所有文件
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 删除 macOS 资源分叉文件(._* 文件)
|
||||||
|
RUN find . -name "._*" -type f -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 4:更新 .dockerignore
|
||||||
|
|
||||||
|
确保排除 macOS 资源分叉文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /vol2/1000/docker/dysync/dysync.net
|
||||||
|
|
||||||
|
# 添加排除规则
|
||||||
|
echo "._*" >> .dockerignore
|
||||||
|
|
||||||
|
# 验证
|
||||||
|
tail -5 .dockerignore
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 5:构建 Docker 镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 确保在项目目录
|
||||||
|
cd /vol2/1000/docker/dysync/dysync.net
|
||||||
|
|
||||||
|
# 构建镜像(这可能需要 10-30 分钟)
|
||||||
|
sudo docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
|
||||||
|
# 验证镜像构建成功
|
||||||
|
sudo docker images | grep dysync
|
||||||
|
```
|
||||||
|
|
||||||
|
**构建成功标志**:
|
||||||
|
```
|
||||||
|
Successfully built abc123def456
|
||||||
|
Successfully tagged dysync.net:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 配置和部署容器
|
||||||
|
|
||||||
|
### 步骤 1:配置 docker-compose 文件
|
||||||
|
|
||||||
|
确保 docker-compose.feiniu.vol2.yml 文件存在且配置正确:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /vol2/1000/docker/dysync/dysync.net
|
||||||
|
|
||||||
|
# 查看配置文件
|
||||||
|
cat docker-compose.feiniu.vol2.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
配置文件应该类似这样:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
dysync:
|
||||||
|
image: dysync.net:latest
|
||||||
|
container_name: dysync
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "10101:10101"
|
||||||
|
volumes:
|
||||||
|
# 数据库目录(必须配置,防止数据丢失)
|
||||||
|
- /vol2/1000/docker/dysync/db:/app/db
|
||||||
|
# 收藏视频目录
|
||||||
|
- /vol2/1000/docker/dysync/collect:/app/collect
|
||||||
|
# 喜欢视频目录
|
||||||
|
- /vol2/1000/docker/dysync/favorite:/app/favorite
|
||||||
|
# 博主视频目录(可选)
|
||||||
|
- /vol2/1000/docker/dysync/uper:/app/uper
|
||||||
|
# 日志目录(可选,方便查看日志)
|
||||||
|
- /vol2/1000/docker/dysync/logs:/app/logs
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
# 配置DNS服务器(解决域名解析失败导致的无法访问外网)
|
||||||
|
dns:
|
||||||
|
- 8.8.8.8 # Google DNS
|
||||||
|
- 114.114.114.114 # 国内DNS
|
||||||
|
- 223.5.5.5 # 阿里DNS
|
||||||
|
# 网络模式
|
||||||
|
network_mode: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:创建数据目录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建必要的数据目录
|
||||||
|
sudo mkdir -p /vol2/1000/docker/dysync/{db,collect,favorite,uper,logs}
|
||||||
|
|
||||||
|
# 设置目录权限(确保容器可以读写)
|
||||||
|
sudo chmod -R 755 /vol2/1000/docker/dysync
|
||||||
|
|
||||||
|
# 验证目录创建成功
|
||||||
|
ls -la /vol2/1000/docker/dysync/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:启动容器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 确保在项目目录
|
||||||
|
cd /vol2/1000/docker/dysync/dysync.net
|
||||||
|
|
||||||
|
# 使用 docker compose 启动容器(推荐)
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml up -d
|
||||||
|
|
||||||
|
# 如果上面的命令不存在,尝试:
|
||||||
|
sudo docker-compose -f docker-compose.feiniu.vol2.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**启动成功标志**:
|
||||||
|
```
|
||||||
|
[+] Running 1/1
|
||||||
|
✔ Container dysync Started
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 docker-compose 命令都不存在,可以直接运行容器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker run -d \
|
||||||
|
--name dysync \
|
||||||
|
-p 10101:10101 \
|
||||||
|
-v /vol2/1000/docker/dysync/db:/app/db \
|
||||||
|
-v /vol2/1000/docker/dysync/collect:/app/collect \
|
||||||
|
-v /vol2/1000/docker/dysync/favorite:/app/favorite \
|
||||||
|
-v /vol2/1000/docker/dysync/uper:/app/uper \
|
||||||
|
-v /vol2/1000/docker/dysync/logs:/app/logs \
|
||||||
|
--restart always \
|
||||||
|
--dns 8.8.8.8 \
|
||||||
|
--dns 114.114.114.114 \
|
||||||
|
--dns 223.5.5.5 \
|
||||||
|
-e TZ=Asia/Shanghai \
|
||||||
|
-e ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
dysync.net:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 验证服务
|
||||||
|
|
||||||
|
### 步骤 1:检查容器状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看容器是否运行
|
||||||
|
sudo docker ps | grep dysync
|
||||||
|
|
||||||
|
# 查看容器详细信息
|
||||||
|
sudo docker ps -a | grep dysync
|
||||||
|
|
||||||
|
# 查看容器日志(检查服务是否正常启动)
|
||||||
|
sudo docker logs dysync
|
||||||
|
|
||||||
|
# 如果日志很多,只看最后50行
|
||||||
|
sudo docker logs --tail 50 dysync
|
||||||
|
|
||||||
|
# 实时查看日志(按 Ctrl+C 退出)
|
||||||
|
sudo docker logs -f dysync
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:测试服务访问
|
||||||
|
|
||||||
|
**在本地电脑上测试**(不是 NAS):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 测试服务是否可访问(替换为你的 NAS IP)
|
||||||
|
curl http://你的NAS_IP:10101
|
||||||
|
|
||||||
|
# 或者在浏览器中访问
|
||||||
|
# http://你的NAS_IP:10101
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功标志**:
|
||||||
|
- 容器状态显示 `Up` 或 `running`
|
||||||
|
- 日志中看到类似 `Now listening on: http://[::]:10101` 的信息
|
||||||
|
- 浏览器可以访问服务页面
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 常用管理命令
|
||||||
|
|
||||||
|
### 容器管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止容器
|
||||||
|
sudo docker stop dysync
|
||||||
|
|
||||||
|
# 启动容器
|
||||||
|
sudo docker start dysync
|
||||||
|
|
||||||
|
# 重启容器
|
||||||
|
sudo docker restart dysync
|
||||||
|
|
||||||
|
# 删除容器(需要先停止)
|
||||||
|
sudo docker stop dysync
|
||||||
|
sudo docker rm dysync
|
||||||
|
|
||||||
|
# 查看容器资源使用情况
|
||||||
|
sudo docker stats dysync
|
||||||
|
|
||||||
|
# 进入容器内部(调试用)
|
||||||
|
sudo docker exec -it dysync /bin/bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看日志
|
||||||
|
sudo docker logs dysync
|
||||||
|
|
||||||
|
# 查看最后 N 行日志
|
||||||
|
sudo docker logs --tail 100 dysync
|
||||||
|
|
||||||
|
# 实时查看日志
|
||||||
|
sudo docker logs -f dysync
|
||||||
|
|
||||||
|
# 查看日志并包含时间戳
|
||||||
|
sudo docker logs -t dysync
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 docker-compose 管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动服务
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml up -d
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml down
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml restart
|
||||||
|
|
||||||
|
# 查看服务状态
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml ps
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 镜像管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看所有镜像
|
||||||
|
sudo docker images
|
||||||
|
|
||||||
|
# 删除镜像
|
||||||
|
sudo docker rmi dysync.net:latest
|
||||||
|
|
||||||
|
# 重新构建镜像(更新代码后)
|
||||||
|
cd /vol2/1000/docker/dysync/dysync.net
|
||||||
|
sudo docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 故障排查
|
||||||
|
|
||||||
|
### 问题 1:容器无法启动
|
||||||
|
|
||||||
|
**检查步骤**:
|
||||||
|
```bash
|
||||||
|
# 1. 查看容器状态
|
||||||
|
sudo docker ps -a | grep dysync
|
||||||
|
|
||||||
|
# 2. 查看详细日志
|
||||||
|
sudo docker logs dysync
|
||||||
|
|
||||||
|
# 3. 检查端口是否被占用
|
||||||
|
sudo netstat -tulpn | grep 10101
|
||||||
|
|
||||||
|
# 4. 检查目录权限
|
||||||
|
ls -la /vol2/1000/docker/dysync/
|
||||||
|
```
|
||||||
|
|
||||||
|
**常见原因**:
|
||||||
|
- 端口被占用:修改 `docker-compose.feiniu.vol2.yml` 中的端口映射
|
||||||
|
- 目录权限不足:执行 `sudo chmod -R 755 /vol2/1000/docker/dysync`
|
||||||
|
- 镜像不存在:重新构建镜像
|
||||||
|
|
||||||
|
### 问题 2:服务无法访问
|
||||||
|
|
||||||
|
**检查步骤**:
|
||||||
|
```bash
|
||||||
|
# 1. 检查容器是否运行
|
||||||
|
sudo docker ps | grep dysync
|
||||||
|
|
||||||
|
# 2. 检查端口映射
|
||||||
|
sudo docker port dysync
|
||||||
|
|
||||||
|
# 3. 检查防火墙设置(在 NAS 管理界面)
|
||||||
|
# 4. 检查网络连接
|
||||||
|
ping 你的NAS_IP
|
||||||
|
```
|
||||||
|
|
||||||
|
**常见原因**:
|
||||||
|
- 防火墙阻止:在 NAS 管理界面开放 10101 端口
|
||||||
|
- 容器未启动:执行 `sudo docker start dysync`
|
||||||
|
- 网络问题:检查 NAS 网络配置
|
||||||
|
|
||||||
|
### 问题 3:构建镜像失败
|
||||||
|
|
||||||
|
#### 3.1 网络超时问题
|
||||||
|
|
||||||
|
**症状**:构建时出现 `ERR_SOCKET_TIMEOUT` 或 `ECONNRESET` 错误
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 确保 Dockerfile.prod 中配置了 npm/pnpm 镜像源
|
||||||
|
2. 手动拉取基础镜像后再构建
|
||||||
|
3. 配置 Docker 镜像加速器
|
||||||
|
|
||||||
|
#### 3.2 macOS 资源分叉文件问题
|
||||||
|
|
||||||
|
**症状**:构建时出现 `CS2015: '._xxx.cs' is a binary file instead of a text file` 错误
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 在 `.dockerignore` 中添加 `._*`
|
||||||
|
2. 在 Dockerfile.prod 中添加删除步骤:`RUN find . -name "._*" -type f -delete`
|
||||||
|
|
||||||
|
#### 3.3 dotnet 命令缺少项目文件
|
||||||
|
|
||||||
|
**症状**:构建时出现 `MSB1011: Specify which project or solution file to use` 错误
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```bash
|
||||||
|
# 修复 Dockerfile.prod
|
||||||
|
sudo sed -i 's/RUN dotnet restore/RUN dotnet restore dy.net.csproj/' Dockerfile.prod
|
||||||
|
sudo sed -i 's/RUN dotnet publish -c Release -o \/app\/publish/RUN dotnet publish dy.net.csproj -c Release -o \/app\/publish/' Dockerfile.prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 4:数据丢失
|
||||||
|
|
||||||
|
**预防措施**:
|
||||||
|
- 确保所有数据目录都正确挂载到宿主机
|
||||||
|
- 定期备份 `/vol2/1000/docker/dysync/db` 目录
|
||||||
|
- 使用 `--restart always` 确保容器自动重启
|
||||||
|
|
||||||
|
**恢复数据**:
|
||||||
|
```bash
|
||||||
|
# 检查数据目录
|
||||||
|
ls -la /vol2/1000/docker/dysync/db/
|
||||||
|
|
||||||
|
# 如果有备份,恢复备份
|
||||||
|
# cp -r /backup/db/* /vol2/1000/docker/dysync/db/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 更新部署
|
||||||
|
|
||||||
|
当本地项目修改后,需要重新部署到 NAS。以下是完整的更新流程:
|
||||||
|
|
||||||
|
### 方法一:重新构建镜像(推荐,适合代码修改)
|
||||||
|
|
||||||
|
#### 步骤 1:在本地压缩并上传新版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 在本地电脑上,进入项目根目录
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
|
||||||
|
# 2. 压缩项目文件(排除不必要的文件)
|
||||||
|
tar -czf ../dysync.net.tar.gz \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='app/node_modules' \
|
||||||
|
--exclude='bin' \
|
||||||
|
--exclude='obj' \
|
||||||
|
--exclude='.vs' \
|
||||||
|
--exclude='.idea' \
|
||||||
|
--exclude='videos' \
|
||||||
|
--exclude='*.md' \
|
||||||
|
--exclude='docs' \
|
||||||
|
--exclude='*.tar.gz' \
|
||||||
|
.
|
||||||
|
|
||||||
|
# 3. 上传到 NAS(替换为你的实际信息)
|
||||||
|
scp ../dysync.net.tar.gz 你的用户名@NAS_IP:/vol2/1000/docker/dysync/
|
||||||
|
# 示例:scp ../dysync.net.tar.gz adminym@192.168.1.99:/vol2/1000/docker/dysync/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步骤 2:在 NAS 上更新文件并重新部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. SSH 连接到 NAS
|
||||||
|
ssh 你的用户名@NAS_IP
|
||||||
|
# 示例:ssh adminym@192.168.1.99
|
||||||
|
|
||||||
|
# 2. 进入项目目录
|
||||||
|
cd /vol2/1000/docker/dysync
|
||||||
|
|
||||||
|
# 3. 备份旧版本(可选,建议)
|
||||||
|
mv dysync.net dysync.net.backup.$(date +%Y%m%d_%H%M%S)
|
||||||
|
|
||||||
|
# 4. 创建新的项目目录并解压
|
||||||
|
mkdir -p dysync.net
|
||||||
|
tar -xzf dysync.net.tar.gz -C dysync.net/
|
||||||
|
|
||||||
|
# 5. 停止并删除旧容器
|
||||||
|
cd dysync.net
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml down
|
||||||
|
# 或者如果容器名称是 dysync:
|
||||||
|
# sudo docker stop dysync && sudo docker rm dysync
|
||||||
|
|
||||||
|
# 6. 删除旧镜像(可选,释放空间)
|
||||||
|
sudo docker rmi dysync.net:latest
|
||||||
|
|
||||||
|
# 7. 重新构建镜像
|
||||||
|
sudo docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
|
||||||
|
# 8. 启动新容器
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml up -d
|
||||||
|
|
||||||
|
# 9. 验证服务
|
||||||
|
sudo docker ps | grep dysync
|
||||||
|
sudo docker logs --tail 50 dysync
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 一键更新脚本(可选)
|
||||||
|
|
||||||
|
你也可以创建一个更新脚本 `update.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# 更新部署脚本
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "开始更新部署..."
|
||||||
|
|
||||||
|
# 1. 停止并删除旧容器
|
||||||
|
echo "停止旧容器..."
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml down || true
|
||||||
|
|
||||||
|
# 2. 删除旧镜像(可选)
|
||||||
|
read -p "是否删除旧镜像以释放空间?(y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
sudo docker rmi dysync.net:latest || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. 重新构建镜像
|
||||||
|
echo "构建新镜像..."
|
||||||
|
sudo docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
|
||||||
|
# 4. 启动新容器
|
||||||
|
echo "启动新容器..."
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml up -d
|
||||||
|
|
||||||
|
# 5. 查看日志
|
||||||
|
echo "查看容器日志..."
|
||||||
|
sleep 3
|
||||||
|
sudo docker logs --tail 50 dysync
|
||||||
|
|
||||||
|
echo "更新完成!"
|
||||||
|
```
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
```bash
|
||||||
|
chmod +x update.sh
|
||||||
|
./update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法二:推送到镜像仓库后拉取(适合频繁更新)
|
||||||
|
|
||||||
|
如果你有镜像仓库访问权限,可以使用这种方式:
|
||||||
|
|
||||||
|
#### 步骤 1:在本地构建并推送
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 在本地电脑上,进入项目根目录
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
|
||||||
|
# 2. 构建镜像并打标签
|
||||||
|
docker build -f Dockerfile.prod -t registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest .
|
||||||
|
|
||||||
|
# 3. 登录镜像仓库(如果还没登录)
|
||||||
|
docker login registry.cn-hangzhou.aliyuncs.com
|
||||||
|
|
||||||
|
# 4. 推送镜像
|
||||||
|
docker push registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步骤 2:在 NAS 上拉取并更新
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. SSH 连接到 NAS
|
||||||
|
ssh 你的用户名@NAS_IP
|
||||||
|
|
||||||
|
# 2. 停止旧容器
|
||||||
|
cd /vol2/1000/docker/dysync/dysync.net
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml down
|
||||||
|
|
||||||
|
# 3. 拉取新镜像
|
||||||
|
sudo docker pull registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
|
||||||
|
|
||||||
|
# 4. 更新 docker-compose.feiniu.vol2.yml 中的镜像地址(如果还没更新)
|
||||||
|
sudo sed -i 's|image: dysync.net:latest|image: registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest|g' docker-compose.feiniu.vol2.yml
|
||||||
|
|
||||||
|
# 5. 启动新容器
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml up -d
|
||||||
|
|
||||||
|
# 6. 验证服务
|
||||||
|
sudo docker ps | grep dysync
|
||||||
|
sudo docker logs --tail 50 dysync
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法三:仅更新代码文件(快速更新,不重新构建镜像)
|
||||||
|
|
||||||
|
如果只是修改了配置文件或少量代码,可以只更新文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 在本地压缩修改的文件
|
||||||
|
tar -czf ../update.tar.gz 修改的文件或目录
|
||||||
|
|
||||||
|
# 2. 上传到 NAS
|
||||||
|
scp ../update.tar.gz 你的用户名@NAS_IP:/vol2/1000/docker/dysync/
|
||||||
|
|
||||||
|
# 3. SSH 连接到 NAS
|
||||||
|
ssh 你的用户名@NAS_IP
|
||||||
|
|
||||||
|
# 4. 进入容器并更新文件
|
||||||
|
cd /vol2/1000/docker/dysync
|
||||||
|
tar -xzf update.tar.gz -C dysync.net/
|
||||||
|
|
||||||
|
# 5. 重启容器使更改生效
|
||||||
|
sudo docker restart dysync
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:这种方法只适用于不需要重新编译的更改(如配置文件)。对于代码修改,建议使用方法一重新构建镜像。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 快速参考
|
||||||
|
|
||||||
|
### 完整部署命令(一键执行脚本)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# 快速部署脚本
|
||||||
|
|
||||||
|
# 1. 进入项目目录
|
||||||
|
cd /vol2/1000/docker/dysync/dysync.net
|
||||||
|
|
||||||
|
# 2. 构建镜像
|
||||||
|
sudo docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
|
||||||
|
# 3. 创建数据目录
|
||||||
|
sudo mkdir -p /vol2/1000/docker/dysync/{db,collect,favorite,uper,logs}
|
||||||
|
sudo chmod -R 755 /vol2/1000/docker/dysync
|
||||||
|
|
||||||
|
# 4. 启动容器
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml up -d
|
||||||
|
|
||||||
|
# 5. 查看日志
|
||||||
|
sudo docker logs -f dysync
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键路径和端口
|
||||||
|
|
||||||
|
- **项目路径**:`/vol2/1000/docker/dysync`
|
||||||
|
- **数据目录**:`/vol2/1000/docker/dysync/{db,collect,favorite,uper,logs}`
|
||||||
|
- **服务端口**:`10101`
|
||||||
|
- **访问地址**:`http://你的NAS_IP:10101`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [本地启动指南](./本地启动指南.md)
|
||||||
|
- [网络问题解决方案](./网络问题解决方案.md)
|
||||||
|
- [构建问题排查](./构建问题排查.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新**:2024-12-17
|
||||||
|
**适用系统**:飞牛 NAS(Linux x86_64)
|
||||||
|
**Docker 版本**:28.5.2
|
||||||
|
**项目路径**:`/vol2/1000/docker/dysync`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# 飞牛 NAS 部署总结
|
||||||
|
|
||||||
|
## ✅ 部署成功总结
|
||||||
|
|
||||||
|
本次部署成功将 dysync.net 项目部署到飞牛 NAS,以下是完整的部署过程和关键要点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 部署流程概览
|
||||||
|
|
||||||
|
### 1. 环境准备 ✅
|
||||||
|
- **NAS 路径**:`/vol2/1000/docker/dysync`
|
||||||
|
- **NAS IP**:`192.168.1.99`
|
||||||
|
- **SSH 用户**:`adminym`
|
||||||
|
- **Docker**:已安装并运行
|
||||||
|
|
||||||
|
### 2. 文件上传 ✅
|
||||||
|
- 使用 `tar` 压缩项目文件(排除不必要的文件)
|
||||||
|
- 通过 `scp` 上传到 NAS
|
||||||
|
- 在 NAS 上解压到 `dysync.net` 目录
|
||||||
|
|
||||||
|
### 3. Docker 镜像构建 ✅
|
||||||
|
- **基础镜像**:`node:18-alpine`、`.NET SDK 6.0`、`.NET ASP.NET 6.0`
|
||||||
|
- **构建时间**:约 50 秒(使用缓存)
|
||||||
|
- **镜像大小**:268MB
|
||||||
|
- **镜像名称**:`dysync.net:latest`
|
||||||
|
|
||||||
|
### 4. 容器部署 ✅
|
||||||
|
- **容器名称**:`dysync`
|
||||||
|
- **端口映射**:`10101:10101`
|
||||||
|
- **数据目录挂载**:`db`、`collect`、`favorite`、`uper`、`logs`
|
||||||
|
- **状态**:运行中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 关键配置和修复
|
||||||
|
|
||||||
|
### 1. Dockerfile 修复
|
||||||
|
|
||||||
|
#### 问题:dotnet 命令缺少项目文件
|
||||||
|
**修复**:
|
||||||
|
```dockerfile
|
||||||
|
RUN dotnet restore dy.net.csproj
|
||||||
|
RUN dotnet publish dy.net.csproj -c Release -o /app/publish
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 问题:npm/pnpm 网络超时
|
||||||
|
**修复**:添加国内镜像源配置
|
||||||
|
```dockerfile
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com && \
|
||||||
|
npm install -g pnpm && \
|
||||||
|
pnpm config set registry https://registry.npmmirror.com && \
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 问题:macOS 资源分叉文件导致编译错误
|
||||||
|
**修复**:
|
||||||
|
1. 在 `.dockerignore` 中添加 `._*`
|
||||||
|
2. 在 Dockerfile 中添加删除步骤:
|
||||||
|
```dockerfile
|
||||||
|
RUN find . -name "._*" -type f -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. docker-compose 配置
|
||||||
|
|
||||||
|
**文件**:`docker-compose.feiniu.vol2.yml`
|
||||||
|
|
||||||
|
**关键配置**:
|
||||||
|
- 镜像:`dysync.net:latest`(本地构建)
|
||||||
|
- 数据目录:`/vol2/1000/docker/dysync/{db,collect,favorite,uper,logs}`
|
||||||
|
- DNS 配置:`8.8.8.8`、`114.114.114.114`、`223.5.5.5`
|
||||||
|
- 环境变量:`TZ=Asia/Shanghai`、`ASPNETCORE_ENVIRONMENT=Production`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 部署命令总结
|
||||||
|
|
||||||
|
### 完整部署流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 本地压缩项目
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
tar -czf ../dysync.net.tar.gz \
|
||||||
|
--exclude='.git' --exclude='node_modules' --exclude='app/node_modules' \
|
||||||
|
--exclude='bin' --exclude='obj' --exclude='.vs' --exclude='.idea' \
|
||||||
|
--exclude='videos' --exclude='*.md' --exclude='docs' --exclude='*.tar.gz' .
|
||||||
|
|
||||||
|
# 2. 上传到 NAS
|
||||||
|
scp ../dysync.net.tar.gz adminym@192.168.1.99:/vol2/1000/docker/dysync/
|
||||||
|
|
||||||
|
# 3. SSH 连接到 NAS
|
||||||
|
ssh adminym@192.168.1.99
|
||||||
|
|
||||||
|
# 4. 解压文件
|
||||||
|
cd /vol2/1000/docker/dysync
|
||||||
|
mkdir -p dysync.net
|
||||||
|
tar -xzf dysync.net.tar.gz -C dysync.net/
|
||||||
|
|
||||||
|
# 5. 拉取基础镜像
|
||||||
|
sudo docker pull node:18-alpine
|
||||||
|
sudo docker pull mcr.microsoft.com/dotnet/sdk:6.0
|
||||||
|
sudo docker pull mcr.microsoft.com/dotnet/aspnet:6.0
|
||||||
|
|
||||||
|
# 6. 修复 Dockerfile(如需要)
|
||||||
|
cd dysync.net
|
||||||
|
sudo sed -i 's/RUN dotnet restore/RUN dotnet restore dy.net.csproj/' Dockerfile.prod
|
||||||
|
sudo sed -i 's/RUN dotnet publish -c Release -o \/app\/publish/RUN dotnet publish dy.net.csproj -c Release -o \/app\/publish/' Dockerfile.prod
|
||||||
|
echo "._*" >> .dockerignore
|
||||||
|
|
||||||
|
# 7. 构建镜像
|
||||||
|
sudo docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
|
||||||
|
# 8. 创建数据目录
|
||||||
|
sudo mkdir -p /vol2/1000/docker/dysync/{db,collect,favorite,uper,logs}
|
||||||
|
sudo chmod -R 755 /vol2/1000/docker/dysync
|
||||||
|
|
||||||
|
# 9. 启动容器
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml up -d
|
||||||
|
|
||||||
|
# 10. 验证服务
|
||||||
|
sudo docker ps | grep dysync
|
||||||
|
sudo docker logs dysync
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 关键路径和端口
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| **项目路径** | `/vol2/1000/docker/dysync` |
|
||||||
|
| **数据目录** | `/vol2/1000/docker/dysync/{db,collect,favorite,uper,logs}` |
|
||||||
|
| **服务端口** | `10101` |
|
||||||
|
| **访问地址** | `http://192.168.1.99:10101` |
|
||||||
|
| **容器名称** | `dysync` |
|
||||||
|
| **镜像名称** | `dysync.net:latest` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 常用管理命令
|
||||||
|
|
||||||
|
### 查看服务状态
|
||||||
|
```bash
|
||||||
|
sudo docker ps | grep dysync
|
||||||
|
sudo docker logs dysync
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重启服务
|
||||||
|
```bash
|
||||||
|
sudo docker restart dysync
|
||||||
|
# 或
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### 停止服务
|
||||||
|
```bash
|
||||||
|
sudo docker stop dysync
|
||||||
|
# 或
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新部署(本地修改后)
|
||||||
|
|
||||||
|
#### 完整更新流程
|
||||||
|
|
||||||
|
**本地操作**:
|
||||||
|
```bash
|
||||||
|
# 1. 压缩项目
|
||||||
|
cd /Users/gaotu/WebstormProjects/dysync.net
|
||||||
|
tar -czf ../dysync.net.tar.gz \
|
||||||
|
--exclude='.git' --exclude='node_modules' --exclude='app/node_modules' \
|
||||||
|
--exclude='bin' --exclude='obj' --exclude='.vs' --exclude='.idea' \
|
||||||
|
--exclude='videos' --exclude='*.md' --exclude='docs' --exclude='*.tar.gz' .
|
||||||
|
|
||||||
|
# 2. 上传到 NAS
|
||||||
|
scp ../dysync.net.tar.gz adminym@192.168.1.99:/vol2/1000/docker/dysync/
|
||||||
|
```
|
||||||
|
|
||||||
|
**NAS 操作**:
|
||||||
|
```bash
|
||||||
|
# 1. SSH 连接
|
||||||
|
ssh adminym@192.168.1.99
|
||||||
|
|
||||||
|
# 2. 更新文件
|
||||||
|
cd /vol2/1000/docker/dysync
|
||||||
|
mv dysync.net dysync.net.backup.$(date +%Y%m%d_%H%M%S)
|
||||||
|
mkdir -p dysync.net
|
||||||
|
tar -xzf dysync.net.tar.gz -C dysync.net/
|
||||||
|
|
||||||
|
# 3. 重新部署
|
||||||
|
cd dysync.net
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml down
|
||||||
|
sudo docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml up -d
|
||||||
|
|
||||||
|
# 4. 验证
|
||||||
|
sudo docker ps | grep dysync
|
||||||
|
sudo docker logs --tail 50 dysync
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 快速更新(如果文件已在 NAS)
|
||||||
|
```bash
|
||||||
|
cd /vol2/1000/docker/dysync/dysync.net
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml down
|
||||||
|
sudo docker build -f Dockerfile.prod -t dysync.net:latest .
|
||||||
|
sudo docker compose -f docker-compose.feiniu.vol2.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 常见问题和解决方案
|
||||||
|
|
||||||
|
### 1. 网络超时问题
|
||||||
|
- **原因**:npm/pnpm 从官方源下载包超时
|
||||||
|
- **解决**:在 Dockerfile 中配置国内镜像源
|
||||||
|
|
||||||
|
### 2. macOS 资源分叉文件问题
|
||||||
|
- **原因**:macOS 创建的 `._*` 文件被包含在构建中
|
||||||
|
- **解决**:在 `.dockerignore` 中排除,并在 Dockerfile 中删除
|
||||||
|
|
||||||
|
### 3. dotnet 命令缺少项目文件
|
||||||
|
- **原因**:Dockerfile 中 `dotnet restore` 和 `dotnet publish` 未指定项目文件
|
||||||
|
- **解决**:添加项目文件参数 `dy.net.csproj`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- **[飞牛NAS部署完整指南.md](./飞牛NAS部署完整指南.md)** - 最新、最完整的部署文档
|
||||||
|
- [本地启动指南](./本地启动指南.md) - 本地开发环境配置
|
||||||
|
- [网络问题解决方案](./网络问题解决方案.md) - 网络相关问题
|
||||||
|
- [构建问题排查](./构建问题排查.md) - 构建相关问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 部署成功标志
|
||||||
|
|
||||||
|
- ✅ 镜像构建成功:`dysync.net:latest` (268MB)
|
||||||
|
- ✅ 容器运行正常:`dysync` 容器状态为 `Up`
|
||||||
|
- ✅ 服务可访问:`http://192.168.1.99:10101` 可正常访问
|
||||||
|
- ✅ 日志正常:容器日志显示服务已启动并监听端口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**部署日期**:2024-12-17
|
||||||
|
**部署人员**:adminym
|
||||||
|
**NAS 系统**:飞牛 NAS
|
||||||
|
**Docker 版本**:28.5.2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -7,12 +7,30 @@ namespace dy.net.dto
|
|||||||
// 3. 引用类型(string)如果允许为null,显式声明为 string?
|
// 3. 引用类型(string)如果允许为null,显式声明为 string?
|
||||||
public string? Tag { get; set; }
|
public string? Tag { get; set; }
|
||||||
|
|
||||||
|
// 支持多个标签筛选
|
||||||
|
public List<string>? Tags { get; set; }
|
||||||
|
|
||||||
public string? Author { get; set; }
|
public string? Author { get; set; }
|
||||||
|
|
||||||
public string? ViedoType { get; set; }
|
public string? ViedoType { get; set; }
|
||||||
|
|
||||||
// 4. 泛型集合(List<string>)允许为null,声明为 List<string>?
|
// 4. 泛型集合(List<string>)允许为null,声明为 List<string>?
|
||||||
public List<string>? Dates { get; set; }
|
public List<string>? Dates { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除状态筛选:null=全部,true=已删除(videoSavePath为空),false=未删除(videoSavePath不为空)
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsDeleted { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序字段:syncTime-同步时间, viedoType-同步类型, viedoCate-视频类型, author-作者, videoTitle-视频名称, dyUser-用户
|
||||||
|
/// </summary>
|
||||||
|
public string? SortField { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序方向:asc-升序, desc-降序
|
||||||
|
/// </summary>
|
||||||
|
public string? SortOrder { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -95,8 +95,22 @@ namespace dy.net.extension
|
|||||||
{
|
{
|
||||||
OnMessageReceived = context =>
|
OnMessageReceived = context =>
|
||||||
{
|
{
|
||||||
context.Token = context.Request.Headers["Authorization"]
|
// 优先从 Header 读取
|
||||||
|
var token = context.Request.Headers["Authorization"]
|
||||||
.FirstOrDefault()?.Split(" ").Last();
|
.FirstOrDefault()?.Split(" ").Last();
|
||||||
|
|
||||||
|
// 如果 Header 中没有,尝试从 Cookie 读取
|
||||||
|
if (string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
token = context.Request.Cookies["Authorization"];
|
||||||
|
// Cookie 中可能包含 "Bearer " 前缀,需要移除
|
||||||
|
if (!string.IsNullOrEmpty(token) && token.StartsWith("Bearer "))
|
||||||
|
{
|
||||||
|
token = token.Substring(7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Token = token;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+76
-61
@@ -82,6 +82,7 @@ namespace dy.net.job
|
|||||||
|
|
||||||
while (hasMore)
|
while (hasMore)
|
||||||
{
|
{
|
||||||
|
Serilog.Log.Debug($"collect-正在获取第 {index + 1} 页数据,cursor: {cursor}");
|
||||||
var data = await _douyinService.SyncCollectVideos(cursor, count, cookie.Cookies);
|
var data = await _douyinService.SyncCollectVideos(cursor, count, cookie.Cookies);
|
||||||
hasMore = data != null && data.HasMore == 1 && cookie.CollHasSyncd == 0;
|
hasMore = data != null && data.HasMore == 1 && cookie.CollHasSyncd == 0;
|
||||||
if (cookie.CollHasSyncd == 1)
|
if (cookie.CollHasSyncd == 1)
|
||||||
@@ -94,11 +95,15 @@ namespace dy.net.job
|
|||||||
Serilog.Log.Debug($"collect-Cookie[{cookie.UserName}]获取收藏数据失败,请检查一下Cookie");
|
Serilog.Log.Debug($"collect-Cookie[{cookie.UserName}]获取收藏数据失败,请检查一下Cookie");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Serilog.Log.Debug($"collect-API返回: HasMore={data.HasMore}, AwemeList数量={data.AwemeList?.Count ?? 0}");
|
||||||
|
|
||||||
cursor = data != null && !string.IsNullOrWhiteSpace(data.Cursor) ? data.Cursor : "0";
|
cursor = data != null && !string.IsNullOrWhiteSpace(data.Cursor) ? data.Cursor : "0";
|
||||||
|
|
||||||
|
|
||||||
if (data.AwemeList == null || !data.AwemeList.Any())
|
if (data.AwemeList == null || !data.AwemeList.Any())
|
||||||
{
|
{
|
||||||
|
Serilog.Log.Debug($"collect-第 {index + 1} 页数据为空,结束同步");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +132,27 @@ namespace dy.net.job
|
|||||||
var fileName = $"{item.AwemeId}.{v.Format}"; // 用ID做文件名,避免特殊字符
|
var fileName = $"{item.AwemeId}.{v.Format}"; // 用ID做文件名,避免特殊字符
|
||||||
var savePath = Path.Combine(saveFolder, fileName);
|
var savePath = Path.Combine(saveFolder, fileName);
|
||||||
|
|
||||||
if (!File.Exists(savePath))
|
// 检查是否应该下载(根据配置)
|
||||||
|
var videoCreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime);
|
||||||
|
var downloadConfigService = new DownloadConfigService(commonService);
|
||||||
|
var shouldDownload = downloadConfigService.ShouldDownload("2", item.Author?.Uid, item.Author?.Nickname, videoCreateTime);
|
||||||
|
|
||||||
|
// 获取作者头像信息(无论是否下载都需要)
|
||||||
|
var avatarImgName = $"{item.Author.Uid}.jpg";
|
||||||
|
var avatarSavePath = Path.Combine(cookie.SavePath, "author", avatarImgName);
|
||||||
|
var avatarUrl = item.Author?.AvatarLarger?.UrlList != null && item.Author.AvatarLarger.UrlList.Any()
|
||||||
|
? item.Author.AvatarLarger.UrlList[0] : null;
|
||||||
|
// 备用头像链接
|
||||||
|
if (string.IsNullOrWhiteSpace(avatarUrl))
|
||||||
|
{
|
||||||
|
avatarUrl = item.Author?.AvatarThumb?.UrlList != null && item.Author.AvatarThumb.UrlList.Any()
|
||||||
|
? item.Author.AvatarThumb.UrlList[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载视频和封面(如果配置允许)
|
||||||
|
string finalSavePath = null;
|
||||||
|
string finalCoverSavePath = null;
|
||||||
|
if (!File.Exists(savePath) && shouldDownload)
|
||||||
{
|
{
|
||||||
Serilog.Log.Debug($"collect-视频[{SanitizePath(item.Desc)}]开始下载");
|
Serilog.Log.Debug($"collect-视频[{SanitizePath(item.Desc)}]开始下载");
|
||||||
await Task.Delay(_random.Next(1, 4) * 1000);
|
await Task.Delay(_random.Next(1, 4) * 1000);
|
||||||
@@ -135,87 +160,77 @@ namespace dy.net.job
|
|||||||
|
|
||||||
if (downVideo)
|
if (downVideo)
|
||||||
{
|
{
|
||||||
Serilog.Log.Debug($"collect-视频[{SanitizePath(item.Desc)}]下载{(downVideo ? "成功" : "失败")}");
|
Serilog.Log.Debug($"collect-视频[{SanitizePath(item.Desc)}]下载成功");
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Error($"collect-视频[{SanitizePath(item.Desc)}]下载{(downVideo ? "成功" : "失败")}");
|
|
||||||
|
|
||||||
}
|
|
||||||
if (downVideo)
|
|
||||||
{
|
|
||||||
await DownVideoCover(item, saveFolder, cookie.Cookies);
|
await DownVideoCover(item, saveFolder, cookie.Cookies);
|
||||||
|
|
||||||
// 用AuthorId做文件名,避免昵称特殊字符
|
|
||||||
var avatarImgName = $"{item.Author.Uid}.jpg";
|
|
||||||
var avatarSavePath = Path.Combine(cookie.SavePath, "author", avatarImgName);
|
|
||||||
await DownAuthorAvatar(cookie.SavePath, item, avatarSavePath, cookie.Cookies);
|
await DownAuthorAvatar(cookie.SavePath, item, avatarSavePath, cookie.Cookies);
|
||||||
var avatarUrl = item.Author?.AvatarLarger?.UrlList != null && item.Author.AvatarLarger.UrlList.Any()
|
finalSavePath = savePath;
|
||||||
? item.Author.AvatarLarger.UrlList[0] : null;
|
finalCoverSavePath = Path.Combine(saveFolder, "poster.jpg");
|
||||||
// 备用头像链接
|
|
||||||
if (string.IsNullOrWhiteSpace(avatarUrl))
|
|
||||||
{
|
|
||||||
avatarUrl = item.Author?.AvatarThumb?.UrlList != null && item.Author.AvatarThumb.UrlList.Any()
|
|
||||||
? item.Author.AvatarThumb.UrlList[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 构造视频数据
|
|
||||||
DyCollectVideo video = new()
|
|
||||||
{
|
|
||||||
ViedoType = "2",
|
|
||||||
AwemeId = item.AwemeId,
|
|
||||||
Author = item.Author?.Nickname,
|
|
||||||
AuthorId = item.Author?.Uid,
|
|
||||||
AuthorAvatar = avatarSavePath,
|
|
||||||
AuthorAvatarUrl = avatarUrl,
|
|
||||||
CreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime),
|
|
||||||
VideoTitle = item.Desc,
|
|
||||||
Id = IdGener.GetLong().ToString(),
|
|
||||||
Resolution = $"{v.PlayAddr.Width}×{v.PlayAddr.Height}",
|
|
||||||
FileSize = v.PlayAddr.DataSize,
|
|
||||||
FileHash = v.PlayAddr.FileHash,
|
|
||||||
Tag1 = tag1,
|
|
||||||
Tag2 = tag2,
|
|
||||||
Tag3 = tag3,
|
|
||||||
VideoUrl = videoUrl,
|
|
||||||
VideoCoverUrl = item.Video.Cover.UrlList != null && item.Video.Cover.UrlList.Any()
|
|
||||||
? item.Video.Cover.UrlList[0] : null,
|
|
||||||
VideoSavePath = savePath,
|
|
||||||
VideoCoverSavePath = Path.Combine(saveFolder, "poster.jpg"),
|
|
||||||
SyncTime = DateTime.Now,
|
|
||||||
DyUserId = data.Uid,
|
|
||||||
CookieId = cookie.Id
|
|
||||||
};
|
|
||||||
videos.Add(video);
|
|
||||||
|
|
||||||
var nfoPath = Path.Combine(saveFolder, $"{item.AwemeId}.nfo");
|
var nfoPath = Path.Combine(saveFolder, $"{item.AwemeId}.nfo");
|
||||||
NfoFileGenerator.GenerateNfoFile(new VideoNfo
|
NfoFileGenerator.GenerateNfoFile(new VideoNfo
|
||||||
{
|
{
|
||||||
Author = video.Author,
|
Author = item.Author?.Nickname,
|
||||||
Poster = Path.Combine(saveFolder, "poster.jpg"),
|
Poster = Path.Combine(saveFolder, "poster.jpg"),
|
||||||
Title = video.VideoTitle,
|
Title = item.Desc,
|
||||||
Thumbnail = Path.Combine(saveFolder, "fanart.jpg"),
|
Thumbnail = Path.Combine(saveFolder, "fanart.jpg"),
|
||||||
ReleaseDate = video.CreateTime,
|
ReleaseDate = videoCreateTime,
|
||||||
Genres = new List<string> { tag1, tag2, tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList()
|
Genres = new List<string> { tag1, tag2, tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList()
|
||||||
}, nfoPath);
|
}, nfoPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Serilog.Log.Error($"collect-视频[{SanitizePath(item.Desc)}]下载失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else if (!shouldDownload)
|
||||||
{
|
{
|
||||||
//Serilog.Log.Debug($"视频[{item.AwemeId}]已存在,跳过下载");
|
Serilog.Log.Debug($"collect-视频[{SanitizePath(item.Desc)}]不符合下载配置,仅同步信息到数据库");
|
||||||
}
|
}
|
||||||
|
else if (File.Exists(savePath))
|
||||||
|
{
|
||||||
|
// 文件已存在,使用现有路径
|
||||||
|
finalSavePath = savePath;
|
||||||
|
finalCoverSavePath = Path.Combine(saveFolder, "poster.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无论是否下载,都保存视频信息到数据库
|
||||||
|
DyCollectVideo video = new()
|
||||||
|
{
|
||||||
|
ViedoType = "2",
|
||||||
|
AwemeId = item.AwemeId,
|
||||||
|
Author = item.Author?.Nickname,
|
||||||
|
AuthorId = item.Author?.Uid,
|
||||||
|
AuthorAvatar = avatarSavePath,
|
||||||
|
AuthorAvatarUrl = avatarUrl,
|
||||||
|
CreateTime = videoCreateTime,
|
||||||
|
VideoTitle = item.Desc,
|
||||||
|
Id = IdGener.GetLong().ToString(),
|
||||||
|
Resolution = $"{v.PlayAddr.Width}×{v.PlayAddr.Height}",
|
||||||
|
FileSize = v.PlayAddr.DataSize,
|
||||||
|
FileHash = v.PlayAddr.FileHash,
|
||||||
|
Tag1 = tag1,
|
||||||
|
Tag2 = tag2,
|
||||||
|
Tag3 = tag3,
|
||||||
|
VideoUrl = videoUrl,
|
||||||
|
VideoCoverUrl = item.Video.Cover.UrlList != null && item.Video.Cover.UrlList.Any()
|
||||||
|
? item.Video.Cover.UrlList[0] : null,
|
||||||
|
VideoSavePath = finalSavePath, // 如果未下载则为 null
|
||||||
|
VideoCoverSavePath = finalCoverSavePath, // 如果未下载则为 null
|
||||||
|
SyncTime = DateTime.Now,
|
||||||
|
DyUserId = data.Uid,
|
||||||
|
CookieId = cookie.Id
|
||||||
|
};
|
||||||
|
videos.Add(video);
|
||||||
}
|
}
|
||||||
index++;
|
index++;
|
||||||
// 批量保存到数据库(减少数据库操作频率)
|
// 批量保存到数据库(减少数据库操作频率)
|
||||||
if (videos.Any())
|
if (videos.Any())
|
||||||
{
|
{
|
||||||
//Serilog.Log.Debug($"处理Cookie[{cookie.UserName}]的第{index}页数据,共{videos.Count}条");
|
Serilog.Log.Debug($"collect-处理Cookie[{cookie.UserName}]的第{index}页数据,共{videos.Count}条,准备保存到数据库");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _douyinVideoService.batchInsert(videos);
|
var insertResult = await _douyinVideoService.batchInsert(videos);
|
||||||
|
Serilog.Log.Debug($"collect-batchInsert返回结果: {insertResult}, 本次处理{videos.Count}条视频");
|
||||||
syncCount += videos.Count;
|
syncCount += videos.Count;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
+77
-61
@@ -79,6 +79,7 @@ namespace dy.net.job
|
|||||||
string cursor = "0";
|
string cursor = "0";
|
||||||
while (hasMore)
|
while (hasMore)
|
||||||
{
|
{
|
||||||
|
Serilog.Log.Debug($"favorite-正在获取第 {index + 1} 页数据,cursor: {cursor}, secUserId: {cookie.SecUserId}");
|
||||||
var data = await _douyinService.SyncFavoriteVideos(count,cursor, cookie.SecUserId, cookie.Cookies);
|
var data = await _douyinService.SyncFavoriteVideos(count,cursor, cookie.SecUserId, cookie.Cookies);
|
||||||
hasMore = data != null && data.HasMore == 1 && cookie.FavHasSyncd == 0;
|
hasMore = data != null && data.HasMore == 1 && cookie.FavHasSyncd == 0;
|
||||||
if (cookie.FavHasSyncd == 1)
|
if (cookie.FavHasSyncd == 1)
|
||||||
@@ -91,10 +92,14 @@ namespace dy.net.job
|
|||||||
Serilog.Log.Debug($"favorite-Cookie[{cookie.UserName}]获取喜欢的数据失败,请检查一下Cookie和sec_user_id");
|
Serilog.Log.Debug($"favorite-Cookie[{cookie.UserName}]获取喜欢的数据失败,请检查一下Cookie和sec_user_id");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Serilog.Log.Debug($"favorite-API返回: HasMore={data.HasMore}, AwemeList数量={data.AwemeList?.Count ?? 0}");
|
||||||
|
|
||||||
cursor = data != null && !string.IsNullOrWhiteSpace(data.MaxCursor) ? data.MaxCursor : "0";
|
cursor = data != null && !string.IsNullOrWhiteSpace(data.MaxCursor) ? data.MaxCursor : "0";
|
||||||
|
|
||||||
if (data.AwemeList == null || !data.AwemeList.Any())
|
if (data.AwemeList == null || !data.AwemeList.Any())
|
||||||
{
|
{
|
||||||
|
Serilog.Log.Debug($"favorite-第 {index + 1} 页数据为空,结束同步");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +127,27 @@ namespace dy.net.job
|
|||||||
var fileName = $"{item.AwemeId}.{v.Format}"; // 用ID做文件名,避免特殊字符
|
var fileName = $"{item.AwemeId}.{v.Format}"; // 用ID做文件名,避免特殊字符
|
||||||
var savePath = Path.Combine(saveFolder, fileName);
|
var savePath = Path.Combine(saveFolder, fileName);
|
||||||
|
|
||||||
if (!File.Exists(savePath))
|
// 检查是否应该下载(根据配置)
|
||||||
|
var videoCreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime);
|
||||||
|
var downloadConfigService = new DownloadConfigService(commonService);
|
||||||
|
var shouldDownload = downloadConfigService.ShouldDownload("1", item.Author?.Uid, item.Author?.Nickname, videoCreateTime);
|
||||||
|
|
||||||
|
// 获取作者头像信息(无论是否下载都需要)
|
||||||
|
var avatarImgName = $"{item.Author.Uid}.jpg";
|
||||||
|
var avatarSavePath = Path.Combine(cookie.FavSavePath, "author", avatarImgName);
|
||||||
|
var avatarUrl = item.Author?.AvatarLarger?.UrlList != null && item.Author.AvatarLarger.UrlList.Any()
|
||||||
|
? item.Author.AvatarLarger.UrlList[0] : null;
|
||||||
|
// 尝试使用AvatarThumb作为备用头像URL
|
||||||
|
if (string.IsNullOrWhiteSpace(avatarUrl))
|
||||||
|
{
|
||||||
|
avatarUrl = item.Author?.AvatarThumb?.UrlList != null && item.Author.AvatarThumb.UrlList.Any()
|
||||||
|
? item.Author.AvatarThumb.UrlList[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载视频和封面(如果配置允许)
|
||||||
|
string finalSavePath = null;
|
||||||
|
string finalCoverSavePath = null;
|
||||||
|
if (!File.Exists(savePath) && shouldDownload)
|
||||||
{
|
{
|
||||||
Serilog.Log.Debug($"favorite-视频[{SanitizePath(item.Desc)}]开始下载");
|
Serilog.Log.Debug($"favorite-视频[{SanitizePath(item.Desc)}]开始下载");
|
||||||
await Task.Delay(_random.Next(1, 4) * 1000);
|
await Task.Delay(_random.Next(1, 4) * 1000);
|
||||||
@@ -130,86 +155,77 @@ namespace dy.net.job
|
|||||||
|
|
||||||
if (downVideo)
|
if (downVideo)
|
||||||
{
|
{
|
||||||
Serilog.Log.Debug($"favorite-视频[{SanitizePath(item.Desc)}]下载{(downVideo ? "成功" : "失败")}");
|
Serilog.Log.Debug($"favorite-视频[{SanitizePath(item.Desc)}]下载成功");
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Error($"favorite-视频[{SanitizePath(item.Desc)}]下载{(downVideo ? "成功" : "失败")}");
|
|
||||||
}
|
|
||||||
if (downVideo)
|
|
||||||
{
|
|
||||||
await DownVideoCover(item, saveFolder, cookie.Cookies);
|
await DownVideoCover(item, saveFolder, cookie.Cookies);
|
||||||
|
|
||||||
// 用AuthorId做文件名,避免昵称特殊字符
|
|
||||||
var avatarImgName = $"{item.Author.Uid}.jpg";
|
|
||||||
var avatarSavePath = Path.Combine(cookie.FavSavePath, "author", avatarImgName);
|
|
||||||
await DownAuthorAvatar(cookie.FavSavePath, item, avatarSavePath, cookie.Cookies);
|
await DownAuthorAvatar(cookie.FavSavePath, item, avatarSavePath, cookie.Cookies);
|
||||||
var avatarUrl = item.Author?.AvatarLarger?.UrlList != null && item.Author.AvatarLarger.UrlList.Any()
|
finalSavePath = savePath;
|
||||||
? item.Author.AvatarLarger.UrlList[0] : null;
|
finalCoverSavePath = Path.Combine(saveFolder, "poster.jpg");
|
||||||
// 尝试使用AvatarThumb作为备用头像URL
|
|
||||||
if (string.IsNullOrWhiteSpace(avatarUrl))
|
|
||||||
{
|
|
||||||
avatarUrl = item.Author?.AvatarThumb?.UrlList != null && item.Author.AvatarThumb.UrlList.Any()
|
|
||||||
? item.Author.AvatarThumb.UrlList[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 构造视频数据
|
|
||||||
DyCollectVideo video = new()
|
|
||||||
{
|
|
||||||
ViedoType="1",
|
|
||||||
AwemeId = item.AwemeId,
|
|
||||||
Author = item.Author?.Nickname,
|
|
||||||
AuthorId = item.Author?.Uid,
|
|
||||||
AuthorAvatar = avatarSavePath,
|
|
||||||
AuthorAvatarUrl = avatarUrl,
|
|
||||||
CreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime),
|
|
||||||
VideoTitle = item.Desc,
|
|
||||||
Id = IdGener.GetLong().ToString(),
|
|
||||||
Resolution = $"{v.PlayAddr.Width}×{v.PlayAddr.Height}",
|
|
||||||
FileSize = v.PlayAddr.DataSize,
|
|
||||||
FileHash = v.PlayAddr.FileHash,
|
|
||||||
Tag1 = tag1,
|
|
||||||
Tag2 = tag2,
|
|
||||||
Tag3 = tag3,
|
|
||||||
VideoUrl = videoUrl,
|
|
||||||
VideoCoverUrl = item.Video.Cover.UrlList != null && item.Video.Cover.UrlList.Any()
|
|
||||||
? item.Video.Cover.UrlList[0] : null,
|
|
||||||
VideoSavePath = savePath,
|
|
||||||
VideoCoverSavePath = Path.Combine(saveFolder, "poster.jpg"),
|
|
||||||
SyncTime = DateTime.Now,
|
|
||||||
DyUserId = data.Uid,
|
|
||||||
CookieId = cookie.Id
|
|
||||||
};
|
|
||||||
videos.Add(video);
|
|
||||||
|
|
||||||
var nfoPath = Path.Combine(saveFolder, $"{item.AwemeId}.nfo");
|
var nfoPath = Path.Combine(saveFolder, $"{item.AwemeId}.nfo");
|
||||||
NfoFileGenerator.GenerateNfoFile(new VideoNfo
|
NfoFileGenerator.GenerateNfoFile(new VideoNfo
|
||||||
{
|
{
|
||||||
Author = video.Author,
|
Author = item.Author?.Nickname,
|
||||||
Poster = Path.Combine(saveFolder, "poster.jpg"),
|
Poster = Path.Combine(saveFolder, "poster.jpg"),
|
||||||
Title = video.VideoTitle,
|
Title = item.Desc,
|
||||||
Thumbnail= Path.Combine(saveFolder, "fanart.jpg"),
|
Thumbnail = Path.Combine(saveFolder, "fanart.jpg"),
|
||||||
ReleaseDate = video.CreateTime,
|
ReleaseDate = videoCreateTime,
|
||||||
Genres = new List<string> { tag1, tag2, tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList()
|
Genres = new List<string> { tag1, tag2, tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList()
|
||||||
}, nfoPath);
|
}, nfoPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Serilog.Log.Error($"favorite-视频[{SanitizePath(item.Desc)}]下载失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else if (!shouldDownload)
|
||||||
{
|
{
|
||||||
//Serilog.Log.Debug($"视频[{item.AwemeId}]已存在,跳过下载");
|
Serilog.Log.Debug($"favorite-视频[{SanitizePath(item.Desc)}]不符合下载配置,仅同步信息到数据库");
|
||||||
}
|
}
|
||||||
|
else if (File.Exists(savePath))
|
||||||
|
{
|
||||||
|
// 文件已存在,使用现有路径
|
||||||
|
finalSavePath = savePath;
|
||||||
|
finalCoverSavePath = Path.Combine(saveFolder, "poster.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无论是否下载,都保存视频信息到数据库
|
||||||
|
DyCollectVideo video = new()
|
||||||
|
{
|
||||||
|
ViedoType = "1",
|
||||||
|
AwemeId = item.AwemeId,
|
||||||
|
Author = item.Author?.Nickname,
|
||||||
|
AuthorId = item.Author?.Uid,
|
||||||
|
AuthorAvatar = avatarSavePath,
|
||||||
|
AuthorAvatarUrl = avatarUrl,
|
||||||
|
CreateTime = videoCreateTime,
|
||||||
|
VideoTitle = item.Desc,
|
||||||
|
Id = IdGener.GetLong().ToString(),
|
||||||
|
Resolution = $"{v.PlayAddr.Width}×{v.PlayAddr.Height}",
|
||||||
|
FileSize = v.PlayAddr.DataSize,
|
||||||
|
FileHash = v.PlayAddr.FileHash,
|
||||||
|
Tag1 = tag1,
|
||||||
|
Tag2 = tag2,
|
||||||
|
Tag3 = tag3,
|
||||||
|
VideoUrl = videoUrl,
|
||||||
|
VideoCoverUrl = item.Video.Cover.UrlList != null && item.Video.Cover.UrlList.Any()
|
||||||
|
? item.Video.Cover.UrlList[0] : null,
|
||||||
|
VideoSavePath = finalSavePath, // 如果未下载则为 null
|
||||||
|
VideoCoverSavePath = finalCoverSavePath, // 如果未下载则为 null
|
||||||
|
SyncTime = DateTime.Now,
|
||||||
|
DyUserId = data.Uid,
|
||||||
|
CookieId = cookie.Id
|
||||||
|
};
|
||||||
|
videos.Add(video);
|
||||||
}
|
}
|
||||||
index++;
|
index++;
|
||||||
// 批量保存到数据库(减少数据库操作频率)
|
// 批量保存到数据库(减少数据库操作频率)
|
||||||
if (videos.Any())
|
if (videos.Any())
|
||||||
{
|
{
|
||||||
//Serilog.Log.Debug($"处理Cookie[{cookie.UserName}]的第{index}页数据,共{videos.Count}条");
|
Serilog.Log.Debug($"favorite-处理Cookie[{cookie.UserName}]的第{index}页数据,共{videos.Count}条,准备保存到数据库");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _douyinVideoService.batchInsert(videos);
|
var insertResult = await _douyinVideoService.batchInsert(videos);
|
||||||
|
Serilog.Log.Debug($"favorite-batchInsert返回结果: {insertResult}, 本次处理{videos.Count}条视频");
|
||||||
syncCount += videos.Count;
|
syncCount += videos.Count;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -20,5 +20,41 @@ namespace dy.net.model
|
|||||||
/// 每次查询数量
|
/// 每次查询数量
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int BatchCount { get; set; } = 10;
|
public int BatchCount { get; set; } = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用自动下载(默认false,不自动下载)
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(IsNullable = true)]
|
||||||
|
public bool? EnableAutoDownload { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下载时间范围(天数,例如:30表示只下载最近30天的视频,0表示不限制)
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(IsNullable = true)]
|
||||||
|
public int? DownloadDaysRange { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作者白名单(JSON数组,例如:["作者1","作者2"],为空则不限制)
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(Length = 2000, IsNullable = true)]
|
||||||
|
public string AuthorWhitelist { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作者黑名单(JSON数组,例如:["作者1","作者2"],为空则不限制)
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(Length = 2000, IsNullable = true)]
|
||||||
|
public string AuthorBlacklist { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否下载喜欢的视频(默认false)
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(IsNullable = true)]
|
||||||
|
public bool? DownloadFavorite { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否下载收藏的视频(默认false)
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(IsNullable = true)]
|
||||||
|
public bool? DownloadCollect { get; set; } = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using dy.net.model;
|
using dy.net.model;
|
||||||
using SqlSugar;
|
using SqlSugar;
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
|
||||||
namespace dy.net.repository
|
namespace dy.net.repository
|
||||||
{
|
{
|
||||||
@@ -17,10 +18,14 @@ namespace dy.net.repository
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pageIndex">页码(从1开始)</param>
|
/// <param name="pageIndex">页码(从1开始)</param>
|
||||||
/// <param name="pageSize">每页数量</param>
|
/// <param name="pageSize">每页数量</param>
|
||||||
/// <param name="tag">可选标签过滤</param>
|
/// <param name="tag">可选标签过滤(单个,兼容旧版本)</param>
|
||||||
|
/// <param name="tags">可选标签过滤(多个)</param>
|
||||||
/// <param name="author">可选作者过滤</param>
|
/// <param name="author">可选作者过滤</param>
|
||||||
|
/// <param name="isDeleted">删除状态筛选:null=全部,true=已删除(videoSavePath为空),false=未删除(videoSavePath不为空)</param>
|
||||||
|
/// <param name="sortField">排序字段:syncTime-同步时间, viedoType-同步类型, viedoCate-视频类型, author-作者, videoTitle-视频名称, dyUser-用户</param>
|
||||||
|
/// <param name="sortOrder">排序方向:asc-升序, desc-降序</param>
|
||||||
/// <returns>分页结果(视频列表和总数)</returns>
|
/// <returns>分页结果(视频列表和总数)</returns>
|
||||||
public async Task<(List<DyCollectVideo> list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize, string tag = null, string author = null,string viedoType=null, List<string> dates = null)
|
public async Task<(List<DyCollectVideo> list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize, string tag = null, List<string> tags = null, string author = null,string viedoType=null, List<string> dates = null, bool? isDeleted = null, string sortField = null, string sortOrder = null)
|
||||||
{
|
{
|
||||||
|
|
||||||
DateTime? start = null;
|
DateTime? start = null;
|
||||||
@@ -35,16 +40,206 @@ namespace dy.net.repository
|
|||||||
start = Convert.ToDateTime(dates[0]);
|
start = Convert.ToDateTime(dates[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
var where = this.Db.Queryable<DyCollectVideo>()
|
var where = this.Db.Queryable<DyCollectVideo>();
|
||||||
.WhereIF(!string.IsNullOrWhiteSpace(tag), x => x.Tag1 == tag)
|
|
||||||
|
// 支持多个标签筛选(优先使用 tags,如果为空则使用 tag)
|
||||||
|
// 注意:tags 匹配的是 viedoCate(Tag1/Tag2 组合),格式如 "美食/美食展示"
|
||||||
|
// 特殊值 "未分类" 表示 Tag1 和 Tag2 都为空的视频
|
||||||
|
if (tags != null && tags.Any())
|
||||||
|
{
|
||||||
|
// 过滤空值
|
||||||
|
var validTags = tags.Where(t => !string.IsNullOrWhiteSpace(t)).ToList();
|
||||||
|
if (validTags.Any())
|
||||||
|
{
|
||||||
|
// 检查是否包含"未分类"选项
|
||||||
|
var hasEmptyCategory = validTags.Contains("未分类");
|
||||||
|
var otherTags = validTags.Where(t => t != "未分类").ToList();
|
||||||
|
|
||||||
|
// 构建 OR 条件:对于每个 tag,匹配对应的 Tag1/Tag2 组合
|
||||||
|
if (hasEmptyCategory && otherTags.Any())
|
||||||
|
{
|
||||||
|
// 同时包含"未分类"和其他类型
|
||||||
|
// 使用内存过滤的方式,确保逻辑正确
|
||||||
|
var matchingVideoIds = new List<string>();
|
||||||
|
|
||||||
|
// 获取所有视频(先应用其他筛选条件)
|
||||||
|
var allVideos = await this.Db.Queryable<DyCollectVideo>()
|
||||||
|
.WhereIF(!string.IsNullOrWhiteSpace(author), x => x.Author == author)
|
||||||
|
.WhereIF(start.HasValue, x => x.SyncTime >= start.Value)
|
||||||
|
.WhereIF(end.HasValue, x => x.SyncTime <= end.Value)
|
||||||
|
.WhereIF(!string.IsNullOrWhiteSpace(viedoType) && viedoType != "*", x => x.ViedoType == viedoType)
|
||||||
|
.WhereIF(isDeleted.HasValue && isDeleted.Value, x => string.IsNullOrWhiteSpace(x.VideoSavePath))
|
||||||
|
.WhereIF(isDeleted.HasValue && !isDeleted.Value, x => !string.IsNullOrWhiteSpace(x.VideoSavePath))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// 在内存中过滤 viedoCate
|
||||||
|
foreach (var video in allVideos)
|
||||||
|
{
|
||||||
|
var viedoCate = (string.IsNullOrWhiteSpace(video.Tag1) ? "" : video.Tag1) + "/" + (string.IsNullOrWhiteSpace(video.Tag2) ? "" : video.Tag2);
|
||||||
|
|
||||||
|
// 检查是否是"未分类"或匹配其他标签
|
||||||
|
bool isMatch = (string.IsNullOrWhiteSpace(video.Tag1) && string.IsNullOrWhiteSpace(video.Tag2)) || // 未分类
|
||||||
|
otherTags.Contains(viedoCate); // 其他类型
|
||||||
|
|
||||||
|
if (isMatch)
|
||||||
|
{
|
||||||
|
matchingVideoIds.Add(video.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingVideoIds.Any())
|
||||||
|
{
|
||||||
|
// 使用 ID 列表重新构建查询
|
||||||
|
where = this.Db.Queryable<DyCollectVideo>()
|
||||||
|
.Where(x => matchingVideoIds.Contains(x.Id));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 如果没有匹配的数据,返回空结果
|
||||||
|
where = this.Db.Queryable<DyCollectVideo>().Where(x => false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (hasEmptyCategory)
|
||||||
|
{
|
||||||
|
// 只选择了"未分类"
|
||||||
|
where = where.Where(x => string.IsNullOrWhiteSpace(x.Tag1) && string.IsNullOrWhiteSpace(x.Tag2));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 只选择了其他类型,构建所有条件的 OR
|
||||||
|
// 使用更简单的方法:先获取所有符合条件的视频 ID,然后使用 In 方法
|
||||||
|
// 这样可以避免复杂的表达式树构建
|
||||||
|
|
||||||
|
var matchingVideoIds = new List<string>();
|
||||||
|
|
||||||
|
// 获取所有视频
|
||||||
|
var allVideos = await this.Db.Queryable<DyCollectVideo>()
|
||||||
|
.WhereIF(!string.IsNullOrWhiteSpace(author), x => x.Author == author)
|
||||||
|
.WhereIF(start.HasValue, x => x.SyncTime >= start.Value)
|
||||||
|
.WhereIF(end.HasValue, x => x.SyncTime <= end.Value)
|
||||||
|
.WhereIF(!string.IsNullOrWhiteSpace(viedoType) && viedoType != "*", x => x.ViedoType == viedoType)
|
||||||
|
.WhereIF(isDeleted.HasValue && isDeleted.Value, x => string.IsNullOrWhiteSpace(x.VideoSavePath))
|
||||||
|
.WhereIF(isDeleted.HasValue && !isDeleted.Value, x => !string.IsNullOrWhiteSpace(x.VideoSavePath))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// 在内存中过滤 viedoCate
|
||||||
|
foreach (var video in allVideos)
|
||||||
|
{
|
||||||
|
var viedoCate = (string.IsNullOrWhiteSpace(video.Tag1) ? "" : video.Tag1) + "/" + (string.IsNullOrWhiteSpace(video.Tag2) ? "" : video.Tag2);
|
||||||
|
|
||||||
|
if (otherTags.Contains(viedoCate))
|
||||||
|
{
|
||||||
|
matchingVideoIds.Add(video.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingVideoIds.Any())
|
||||||
|
{
|
||||||
|
// 使用 ID 列表重新构建查询
|
||||||
|
where = this.Db.Queryable<DyCollectVideo>()
|
||||||
|
.Where(x => matchingVideoIds.Contains(x.Id));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 如果没有匹配的数据,返回空结果
|
||||||
|
where = this.Db.Queryable<DyCollectVideo>().Where(x => false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(tag))
|
||||||
|
{
|
||||||
|
// 兼容旧版本的单标签筛选
|
||||||
|
if (tag.Contains("/"))
|
||||||
|
{
|
||||||
|
var parts = tag.Split('/', 2);
|
||||||
|
var tag1 = parts[0];
|
||||||
|
var tag2 = parts.Length > 1 ? parts[1] : "";
|
||||||
|
where = where.Where(x => x.Tag1 == tag1 && (string.IsNullOrWhiteSpace(tag2) || x.Tag2 == tag2 || string.IsNullOrWhiteSpace(x.Tag2)));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
where = where.Where(x => x.Tag1 == tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用其他筛选条件
|
||||||
|
where = where
|
||||||
.WhereIF(!string.IsNullOrWhiteSpace(author), x => x.Author == author)
|
.WhereIF(!string.IsNullOrWhiteSpace(author), x => x.Author == author)
|
||||||
.WhereIF(start.HasValue, x => x.SyncTime >= start.Value)
|
.WhereIF(start.HasValue, x => x.SyncTime >= start.Value)
|
||||||
.WhereIF(end.HasValue, x => x.SyncTime <= end.Value)
|
.WhereIF(end.HasValue, x => x.SyncTime <= end.Value)
|
||||||
.WhereIF(!string.IsNullOrWhiteSpace(viedoType) && viedoType != "*", x => x.ViedoType == viedoType);
|
.WhereIF(!string.IsNullOrWhiteSpace(viedoType) && viedoType != "*", x => x.ViedoType == viedoType)
|
||||||
|
.WhereIF(isDeleted.HasValue && isDeleted.Value, x => string.IsNullOrWhiteSpace(x.VideoSavePath)) // 已删除:videoSavePath为空
|
||||||
|
.WhereIF(isDeleted.HasValue && !isDeleted.Value, x => !string.IsNullOrWhiteSpace(x.VideoSavePath)); // 未删除:videoSavePath不为空
|
||||||
|
|
||||||
|
|
||||||
var totalCount = await where.CountAsync();
|
var totalCount = await where.CountAsync();
|
||||||
var list = await where.OrderByDescending(x=>x.SyncTime).Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync();
|
|
||||||
|
// 构建排序逻辑
|
||||||
|
var isAsc = !string.IsNullOrWhiteSpace(sortOrder) && sortOrder.ToLower() == "asc";
|
||||||
|
var query = where;
|
||||||
|
|
||||||
|
// 调试日志
|
||||||
|
Console.WriteLine($"Repository排序参数 - sortField: {sortField}, sortOrder: {sortOrder}, isAsc: {isAsc}");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(sortField))
|
||||||
|
{
|
||||||
|
switch (sortField.ToLower())
|
||||||
|
{
|
||||||
|
case "synctime":
|
||||||
|
query = isAsc ? query.OrderBy(x => x.SyncTime) : query.OrderByDescending(x => x.SyncTime);
|
||||||
|
Console.WriteLine($"应用排序: SyncTime {(isAsc ? "升序" : "降序")}");
|
||||||
|
break;
|
||||||
|
case "viedotype":
|
||||||
|
query = isAsc ? query.OrderBy(x => x.ViedoType) : query.OrderByDescending(x => x.ViedoType);
|
||||||
|
Console.WriteLine($"应用排序: ViedoType {(isAsc ? "升序" : "降序")}");
|
||||||
|
break;
|
||||||
|
case "viedocate":
|
||||||
|
// 视频类型需要按Tag1和Tag2排序,SqlSugar使用OrderBy链式调用
|
||||||
|
if (isAsc)
|
||||||
|
{
|
||||||
|
query = query.OrderBy(x => x.Tag1).OrderBy(x => x.Tag2);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
query = query.OrderByDescending(x => x.Tag1).OrderByDescending(x => x.Tag2);
|
||||||
|
}
|
||||||
|
Console.WriteLine($"应用排序: ViedoCate {(isAsc ? "升序" : "降序")}");
|
||||||
|
break;
|
||||||
|
case "author":
|
||||||
|
query = isAsc ? query.OrderBy(x => x.Author) : query.OrderByDescending(x => x.Author);
|
||||||
|
Console.WriteLine($"应用排序: Author {(isAsc ? "升序" : "降序")}");
|
||||||
|
break;
|
||||||
|
case "videotitle":
|
||||||
|
query = isAsc ? query.OrderBy(x => x.VideoTitle) : query.OrderByDescending(x => x.VideoTitle);
|
||||||
|
Console.WriteLine($"应用排序: VideoTitle {(isAsc ? "升序" : "降序")}");
|
||||||
|
break;
|
||||||
|
case "dyuser":
|
||||||
|
// 用户字段需要先查询用户信息,这里先按CookieId排序,后续会设置DyUser
|
||||||
|
query = isAsc ? query.OrderBy(x => x.CookieId) : query.OrderByDescending(x => x.CookieId);
|
||||||
|
Console.WriteLine($"应用排序: DyUser {(isAsc ? "升序" : "降序")}");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// 默认按同步时间降序
|
||||||
|
query = query.OrderByDescending(x => x.SyncTime);
|
||||||
|
Console.WriteLine($"未识别的排序字段,使用默认排序: SyncTime 降序");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 默认按同步时间降序
|
||||||
|
query = query.OrderByDescending(x => x.SyncTime);
|
||||||
|
Console.WriteLine("无排序字段,使用默认排序: SyncTime 降序");
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = await query.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||||
|
Console.WriteLine($"查询完成,返回 {list.Count} 条记录");
|
||||||
|
if (list.Any())
|
||||||
|
{
|
||||||
|
Console.WriteLine($"第一条记录的同步时间: {list.First().SyncTime}");
|
||||||
|
Console.WriteLine($"最后一条记录的同步时间: {list.Last().SyncTime}");
|
||||||
|
}
|
||||||
if (list.Any()) {
|
if (list.Any()) {
|
||||||
var users= await this.Db.Queryable<DyUserCookies>().ToListAsync();
|
var users= await this.Db.Queryable<DyUserCookies>().ToListAsync();
|
||||||
foreach (var item in list)
|
foreach (var item in list)
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ namespace dy.net.service
|
|||||||
{
|
{
|
||||||
cc.Cron = config.Cron;
|
cc.Cron = config.Cron;
|
||||||
cc.BatchCount = config.BatchCount;
|
cc.BatchCount = config.BatchCount;
|
||||||
|
// 更新下载配置
|
||||||
|
cc.EnableAutoDownload = config.EnableAutoDownload;
|
||||||
|
cc.DownloadDaysRange = config.DownloadDaysRange;
|
||||||
|
cc.AuthorWhitelist = config.AuthorWhitelist;
|
||||||
|
cc.AuthorBlacklist = config.AuthorBlacklist;
|
||||||
|
cc.DownloadFavorite = config.DownloadFavorite;
|
||||||
|
cc.DownloadCollect = config.DownloadCollect;
|
||||||
}
|
}
|
||||||
|
|
||||||
var update = await sqlSugarClient.Updateable<AppConfig>(cc).ExecuteCommandAsync();
|
var update = await sqlSugarClient.Updateable<AppConfig>(cc).ExecuteCommandAsync();
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using dy.net.model;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace dy.net.service
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 下载配置服务,用于判断是否应该下载视频
|
||||||
|
/// </summary>
|
||||||
|
public class DownloadConfigService
|
||||||
|
{
|
||||||
|
private readonly CommonService _commonService;
|
||||||
|
|
||||||
|
public DownloadConfigService(CommonService commonService)
|
||||||
|
{
|
||||||
|
_commonService = commonService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppConfig GetConfig()
|
||||||
|
{
|
||||||
|
return _commonService.GetConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断是否应该下载视频
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="videoType">视频类型:"1"=喜欢,"2"=收藏</param>
|
||||||
|
/// <param name="authorId">作者ID</param>
|
||||||
|
/// <param name="authorName">作者名称</param>
|
||||||
|
/// <param name="createTime">视频创建时间</param>
|
||||||
|
/// <returns>true=应该下载,false=不应该下载</returns>
|
||||||
|
public bool ShouldDownload(string videoType, string authorId, string authorName, DateTime createTime)
|
||||||
|
{
|
||||||
|
var config = GetConfig();
|
||||||
|
if (config == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果未启用自动下载,直接返回false
|
||||||
|
if (config.EnableAutoDownload != true)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查视频类型是否允许下载
|
||||||
|
if (videoType == "1" && config.DownloadFavorite != true)
|
||||||
|
{
|
||||||
|
return false; // 喜欢类型但未启用下载
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoType == "2" && config.DownloadCollect != true)
|
||||||
|
{
|
||||||
|
return false; // 收藏类型但未启用下载
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查时间范围
|
||||||
|
if (config.DownloadDaysRange.HasValue && config.DownloadDaysRange.Value > 0)
|
||||||
|
{
|
||||||
|
var daysDiff = (DateTime.Now - createTime).TotalDays;
|
||||||
|
if (daysDiff > config.DownloadDaysRange.Value)
|
||||||
|
{
|
||||||
|
return false; // 超出时间范围
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查作者白名单
|
||||||
|
if (!string.IsNullOrWhiteSpace(config.AuthorWhitelist))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var whitelist = JsonConvert.DeserializeObject<List<string>>(config.AuthorWhitelist);
|
||||||
|
if (whitelist != null && whitelist.Any())
|
||||||
|
{
|
||||||
|
// 如果配置了白名单,只有白名单中的作者才下载
|
||||||
|
var isInWhitelist = whitelist.Any(a =>
|
||||||
|
(!string.IsNullOrWhiteSpace(authorId) && a.Equals(authorId, StringComparison.OrdinalIgnoreCase)) ||
|
||||||
|
(!string.IsNullOrWhiteSpace(authorName) && a.Equals(authorName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
);
|
||||||
|
if (!isInWhitelist)
|
||||||
|
{
|
||||||
|
return false; // 不在白名单中
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning($"解析作者白名单失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查作者黑名单
|
||||||
|
if (!string.IsNullOrWhiteSpace(config.AuthorBlacklist))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var blacklist = JsonConvert.DeserializeObject<List<string>>(config.AuthorBlacklist);
|
||||||
|
if (blacklist != null && blacklist.Any())
|
||||||
|
{
|
||||||
|
// 如果配置了黑名单,黑名单中的作者不下载
|
||||||
|
var isInBlacklist = blacklist.Any(a =>
|
||||||
|
(!string.IsNullOrWhiteSpace(authorId) && a.Equals(authorId, StringComparison.OrdinalIgnoreCase)) ||
|
||||||
|
(!string.IsNullOrWhiteSpace(authorName) && a.Equals(authorName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
);
|
||||||
|
if (isInBlacklist)
|
||||||
|
{
|
||||||
|
return false; // 在黑名单中
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning($"解析作者黑名单失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有检查都通过,可以下载
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -41,12 +41,18 @@ namespace dy.net.service
|
|||||||
.Where(video => !existingAwemeIds.Contains(video.AwemeId))
|
.Where(video => !existingAwemeIds.Contains(video.AwemeId))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
Serilog.Log.Debug($"batchInsert: 待插入{videos.Count}条,已存在{existingAwemeIds.Count}条,需要插入{videosToInsert.Count}条");
|
||||||
|
|
||||||
// 4. 如果没有需要插入的新记录,直接返回成功
|
// 4. 如果没有需要插入的新记录,直接返回成功
|
||||||
if (!videosToInsert.Any())
|
if (!videosToInsert.Any())
|
||||||
|
{
|
||||||
|
Serilog.Log.Debug($"batchInsert: 所有视频都已存在,无需插入");
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 批量插入过滤后的新记录
|
// 5. 批量插入过滤后的新记录
|
||||||
var insertedCount = await _dyCollectVideoRepository.InsertRangeAsync(videosToInsert);
|
var insertedCount = await _dyCollectVideoRepository.InsertRangeAsync(videosToInsert);
|
||||||
|
Serilog.Log.Debug($"batchInsert: 实际插入{insertedCount}条记录");
|
||||||
|
|
||||||
// 返回是否插入成功(至少插入一条)
|
// 返回是否插入成功(至少插入一条)
|
||||||
return insertedCount > 0;
|
return insertedCount > 0;
|
||||||
@@ -55,16 +61,47 @@ namespace dy.net.service
|
|||||||
|
|
||||||
public async Task<VideoStaticsDto> GetStatics()
|
public async Task<VideoStaticsDto> GetStatics()
|
||||||
{
|
{
|
||||||
|
List<DyCollectVideo> allList = await this._dyCollectVideoRepository.GetAllAsync();
|
||||||
List<DyCollectVideo> list = await this._dyCollectVideoRepository.GetAllAsync();
|
if (!allList.Any())
|
||||||
|
return new VideoStaticsDto();
|
||||||
|
|
||||||
|
// 只统计文件实际存在的视频
|
||||||
|
var list = allList.Where(x =>
|
||||||
|
!string.IsNullOrWhiteSpace(x.VideoSavePath) &&
|
||||||
|
System.IO.File.Exists(x.VideoSavePath)
|
||||||
|
).ToList();
|
||||||
|
|
||||||
|
Serilog.Log.Debug($"统计视频:数据库记录 {allList.Count} 条,文件存在 {list.Count} 条");
|
||||||
|
|
||||||
if (!list.Any())
|
if (!list.Any())
|
||||||
return new VideoStaticsDto();
|
return new VideoStaticsDto();
|
||||||
var Categories = list.GroupBy(x => x.Tag1).Select(x => new VideoStaticsItemDto { Name = x.Key, Count = x.LongCount() }).OrderByDescending(p => p.Count).ToList();
|
|
||||||
Categories.Where(x => string.IsNullOrWhiteSpace(x.Name)).ToList().ForEach(x => x.Name = "其他");
|
// 按 viedoCate(Tag1/Tag2 组合)分组统计分类
|
||||||
|
var Categories = list
|
||||||
|
.GroupBy(x =>
|
||||||
|
{
|
||||||
|
// 使用与模型 ViedoCate 属性相同的逻辑:Tag1 + "/" + Tag2
|
||||||
|
var tag1 = string.IsNullOrWhiteSpace(x.Tag1) ? "" : x.Tag1;
|
||||||
|
var tag2 = string.IsNullOrWhiteSpace(x.Tag2) ? "" : x.Tag2;
|
||||||
|
var viedoCate = tag1 + "/" + tag2;
|
||||||
|
// 如果 Tag1 和 Tag2 都为空,标记为"未分类"
|
||||||
|
if (string.IsNullOrWhiteSpace(tag1) && string.IsNullOrWhiteSpace(tag2))
|
||||||
|
{
|
||||||
|
return "未分类";
|
||||||
|
}
|
||||||
|
return viedoCate;
|
||||||
|
})
|
||||||
|
.Select(x => new VideoStaticsItemDto
|
||||||
|
{
|
||||||
|
Name = x.Key,
|
||||||
|
Count = x.LongCount()
|
||||||
|
})
|
||||||
|
.OrderByDescending(p => p.Count)
|
||||||
|
.ToList();
|
||||||
var data = new VideoStaticsDto
|
var data = new VideoStaticsDto
|
||||||
{
|
{
|
||||||
AuthorCount = list.Select(x => x.AuthorId).Distinct().Count(),
|
AuthorCount = list.Select(x => x.AuthorId).Distinct().Count(),
|
||||||
CategoryCount = list.Select(x => x.Tag1).Distinct().Count(),
|
CategoryCount = Categories.Count, // 使用实际分类数量
|
||||||
VideoCount = list.Count,
|
VideoCount = list.Count,
|
||||||
Categories = Categories,
|
Categories = Categories,
|
||||||
FavoriteCount = list.Count(x => x.ViedoType == "1"),
|
FavoriteCount = list.Count(x => x.ViedoType == "1"),
|
||||||
@@ -85,9 +122,154 @@ namespace dy.net.service
|
|||||||
|
|
||||||
//分页查询
|
//分页查询
|
||||||
|
|
||||||
public async Task<(List<DyCollectVideo> list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize, string tag = null, string author = null, string viedoType = null, List<string>? dates = null)
|
public async Task<(List<DyCollectVideo> list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize, string tag = null, List<string> tags = null, string author = null, string viedoType = null, List<string>? dates = null, bool? isDeleted = null, string sortField = null, string sortOrder = null)
|
||||||
{
|
{
|
||||||
return await _dyCollectVideoRepository.GetPagedAsync(pageIndex, pageSize, tag, author, viedoType, dates);
|
return await _dyCollectVideoRepository.GetPagedAsync(pageIndex, pageSize, tag, tags, author, viedoType, dates, isDeleted, sortField, sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取所有视频类型列表(用于下拉框)
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task<List<string>> GetAllCategoriesAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 获取所有视频记录
|
||||||
|
var allVideos = await _dyCollectVideoRepository.GetAllAsync();
|
||||||
|
Serilog.Log.Debug($"获取视频类型列表:共 {allVideos?.Count ?? 0} 条视频记录");
|
||||||
|
|
||||||
|
// 提取所有非空的 viedoCate(Tag1/Tag2 组合),去重并排序
|
||||||
|
// 使用与模型 ViedoCate 属性相同的逻辑:Tag1 + "/" + Tag2
|
||||||
|
var categories = allVideos
|
||||||
|
.Select(x =>
|
||||||
|
{
|
||||||
|
// 使用与模型 ViedoCate 属性相同的逻辑
|
||||||
|
var tag1 = string.IsNullOrWhiteSpace(x.Tag1) ? "" : x.Tag1;
|
||||||
|
var tag2 = string.IsNullOrWhiteSpace(x.Tag2) ? "" : x.Tag2;
|
||||||
|
var viedoCate = tag1 + "/" + tag2;
|
||||||
|
return viedoCate;
|
||||||
|
})
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x) && x != "/") // 过滤空值和只有 "/" 的情况
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(x => x)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 检查是否存在空类型的视频(Tag1 和 Tag2 都为空)
|
||||||
|
var hasEmptyCategory = allVideos.Any(x => string.IsNullOrWhiteSpace(x.Tag1) && string.IsNullOrWhiteSpace(x.Tag2));
|
||||||
|
if (hasEmptyCategory)
|
||||||
|
{
|
||||||
|
// 在列表开头添加"未分类"选项
|
||||||
|
categories.Insert(0, "未分类");
|
||||||
|
}
|
||||||
|
|
||||||
|
Serilog.Log.Debug($"提取到的视频类型数量:{categories.Count},类型列表:{string.Join(", ", categories)}");
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "获取视频类型列表失败");
|
||||||
|
return new List<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据ID获取视频信息
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">视频ID</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task<DyCollectVideo> GetByIdAsync(string id)
|
||||||
|
{
|
||||||
|
return await _dyCollectVideoRepository.GetByIdAsync(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清理数据库中文件不存在的视频记录
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>清理的记录数量</returns>
|
||||||
|
public async Task<int> CleanupMissingFilesAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var allVideos = await _dyCollectVideoRepository.GetAllAsync();
|
||||||
|
if (!allVideos.Any())
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// 找出需要清理的视频ID:
|
||||||
|
// 1. VideoSavePath 不为空但文件不存在的记录
|
||||||
|
// 2. VideoSavePath 为空或null的记录(这些记录没有视频文件,也应该被清理)
|
||||||
|
var missingFileIds = allVideos
|
||||||
|
.Where(x =>
|
||||||
|
// 情况1:有路径但文件不存在
|
||||||
|
(!string.IsNullOrWhiteSpace(x.VideoSavePath) && !System.IO.File.Exists(x.VideoSavePath)) ||
|
||||||
|
// 情况2:路径为空(这些记录没有视频文件,也应该清理)
|
||||||
|
string.IsNullOrWhiteSpace(x.VideoSavePath)
|
||||||
|
)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!missingFileIds.Any())
|
||||||
|
{
|
||||||
|
Serilog.Log.Debug("清理检查:所有视频文件都存在,无需清理");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pathEmptyCount = allVideos.Count(x => string.IsNullOrWhiteSpace(x.VideoSavePath));
|
||||||
|
var fileMissingCount = allVideos.Count(x => !string.IsNullOrWhiteSpace(x.VideoSavePath) && !System.IO.File.Exists(x.VideoSavePath));
|
||||||
|
|
||||||
|
Serilog.Log.Debug($"清理检查:发现 {missingFileIds.Count} 条需要清理的记录(路径为空:{pathEmptyCount} 条,文件不存在:{fileMissingCount} 条)");
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
var deletedCount = await _dyCollectVideoRepository.DeleteByIdsAsync(missingFileIds);
|
||||||
|
Serilog.Log.Debug($"清理完成:删除了 {deletedCount} 条记录");
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "清理文件不存在的记录失败");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除指定作者的所有视频数据
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authorName">作者名称</param>
|
||||||
|
/// <returns>删除的记录数量</returns>
|
||||||
|
public async Task<int> DeleteAuthorVideosAsync(string authorName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var allVideos = await _dyCollectVideoRepository.GetAllAsync();
|
||||||
|
if (!allVideos.Any())
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// 找出该作者的所有视频ID
|
||||||
|
var authorVideoIds = allVideos
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x.Author) && x.Author.Equals(authorName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!authorVideoIds.Any())
|
||||||
|
{
|
||||||
|
Serilog.Log.Debug($"删除作者视频:未找到作者 {authorName} 的视频记录");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serilog.Log.Debug($"删除作者视频:发现作者 {authorName} 的 {authorVideoIds.Count} 条视频记录");
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
var deletedCount = await _dyCollectVideoRepository.DeleteByIdsAsync(authorVideoIds);
|
||||||
|
Serilog.Log.Debug($"删除完成:删除了 {deletedCount} 条记录");
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, $"删除作者 {authorName} 的视频失败");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,11 +68,24 @@ namespace dy.net.service
|
|||||||
if (respose.IsSuccessStatusCode)
|
if (respose.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var data = await respose.Content.ReadAsStringAsync();
|
var data = await respose.Content.ReadAsStringAsync();
|
||||||
return JsonConvert.DeserializeObject<CollectVideoInfo>(data);
|
Serilog.Log.Debug($"SyncCollectVideos API响应长度: {data?.Length ?? 0}");
|
||||||
|
var result = JsonConvert.DeserializeObject<CollectVideoInfo>(data);
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
Serilog.Log.Debug($"SyncCollectVideos 解析结果: HasMore={result.HasMore}, AwemeList数量={result.AwemeList?.Count ?? 0}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var preview = data != null && data.Length > 0 ? data.Substring(0, Math.Min(500, data.Length)) : "空响应";
|
||||||
|
Serilog.Log.Warning($"SyncCollectVideos 解析失败,响应内容前500字符: {preview}");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Serilog.Log.Error($"SyncCollectVideos fail: {respose.StatusCode}");
|
var errorContent = await respose.Content.ReadAsStringAsync();
|
||||||
|
var errorPreview = errorContent != null && errorContent.Length > 0 ? errorContent.Substring(0, Math.Min(500, errorContent.Length)) : "空响应";
|
||||||
|
Serilog.Log.Error($"SyncCollectVideos fail: {respose.StatusCode}, 响应内容: {errorPreview}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,15 +142,29 @@ namespace dy.net.service
|
|||||||
// 构建请求URL
|
// 构建请求URL
|
||||||
var queryString = new FormUrlEncodedContent(dics);
|
var queryString = new FormUrlEncodedContent(dics);
|
||||||
string fullUrl = $"{DouYinApi}/favorite?{await queryString.ReadAsStringAsync()}";
|
string fullUrl = $"{DouYinApi}/favorite?{await queryString.ReadAsStringAsync()}";
|
||||||
|
Serilog.Log.Debug($"SyncFavoriteVideos 请求URL: {fullUrl.Substring(0, Math.Min(200, fullUrl.Length))}...");
|
||||||
var respose = await httpClient.GetAsync(fullUrl);
|
var respose = await httpClient.GetAsync(fullUrl);
|
||||||
if (respose.IsSuccessStatusCode)
|
if (respose.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var data = await respose.Content.ReadAsStringAsync();
|
var data = await respose.Content.ReadAsStringAsync();
|
||||||
return JsonConvert.DeserializeObject<CollectVideoInfo>(data);
|
Serilog.Log.Debug($"SyncFavoriteVideos API响应长度: {data?.Length ?? 0}");
|
||||||
|
var result = JsonConvert.DeserializeObject<CollectVideoInfo>(data);
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
Serilog.Log.Debug($"SyncFavoriteVideos 解析结果: HasMore={result.HasMore}, AwemeList数量={result.AwemeList?.Count ?? 0}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var preview = data != null && data.Length > 0 ? data.Substring(0, Math.Min(500, data.Length)) : "空响应";
|
||||||
|
Serilog.Log.Warning($"SyncFavoriteVideos 解析失败,响应内容前500字符: {preview}");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Serilog.Log.Error($"SyncFavoriteVideos fail: {respose.StatusCode}");
|
var errorContent = await respose.Content.ReadAsStringAsync();
|
||||||
|
var errorPreview = errorContent != null && errorContent.Length > 0 ? errorContent.Substring(0, Math.Min(500, errorContent.Length)) : "空响应";
|
||||||
|
Serilog.Log.Error($"SyncFavoriteVideos fail: {respose.StatusCode}, 响应内容: {errorPreview}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user