11 Commits

Author SHA1 Message Date
yuming 6803b32b1b chore: .gitignore 屏蔽手动部署脚本,统一走 Gitea CI
部署到群晖 / deploy (push) Successful in 1m23s
2026-04-25 11:05:49 +08:00
yuming b328e82eb2 feat: 视频预览/数据看板/下载配置 三项业务功能
- 新增视频预览:VideoPlayer 组件、VideoController/DyCollectVideoService/Repository、VideoPageRequestDTO 对应后端改动
- 新增数据看板:workplace/statics.vue、RecordTable、相关 store/http/axios 改动
- 新增下载配置:DownloadConfigService、ConfigController、AppSet.vue、AppConfig
- 其他:DyHttpClientService / 同步 Job(Collect, Favorit) / Cookie 表 / CommonService / ServiceExtension 的配套调整
- 仓库卫生:.gitignore 追加 feiniu 过滤;运维脚本归档到 docs/scripts/
2026-04-25 00:22:33 +08:00
yuming ca530312d0 fix(ci): 同步 pnpm-lock.yaml 与 package.json,解决 frozen-lockfile 报错
部署到群晖 / deploy (push) Successful in 29s
2026-04-25 00:01:46 +08:00
yuming c441e060b9 fix(ci): .NET 基础镜像改回 mcr.microsoft.com,代理已通且宿主已预拉缓存
部署到群晖 / deploy (push) Failing after 8m13s
2026-04-24 23:45:30 +08:00
yuming 2f091915e7 fix(ci): .NET 基础镜像改走阿里云 dotnet 官方同步源
部署到群晖 / deploy (push) Failing after 31s
2026-04-24 22:33:50 +08:00
yuming 3cc5ae3719 fix(ci): .NET 基础镜像改走 daocloud mcr 代理
部署到群晖 / deploy (push) Failing after 2m29s
2026-04-24 22:23:34 +08:00
yuming 25dad98d8d fix(ci): .dockerignore 改为纯 ASCII
部署到群晖 / deploy (push) Failing after 30s
buildkit 报 "header key exclude-patterns contains value with non-printable ASCII"
根因:原文件含大量中文注释与中文文件名 pattern
修复:注释全部英文化,中文文件名 pattern 被 *.md 覆盖后删除

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:09:23 +08:00
yuming 428fb8aa48 fix(ci): 前端构建镜像源改回 docker.io
部署到群晖 / deploy (push) Failing after 41s
阿里云杭州源 TLS handshake timeout 导致首次 CI 失败,
换回官方 node:18-alpine;mcr.microsoft.com 能通常 docker.io 也能。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:05:06 +08:00
yuming c9ee05afde ci: 新增 Gitea Actions 自动部署到群晖
部署到群晖 / deploy (push) Failing after 42s
push main 触发:docker build Dockerfile.prod → 停旧启新容器
端口 10101:10101,数据卷按 volume1(db/logs)+ volume2(视频)分卷

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:00:36 +08:00
yuming ad97fd3da5 整理仓库:扩展 gitignore、归档文档、清理冗余 Dockerfile
- .gitignore 新增忽略 .idea/、videos/、*.tar.gz、*.csproj.user、db 运行时文件
- 根目录 15 个中文运维 md 归档到 docs/
- 删除无部署脚本引用的 Dockerfile 与 Dockerfile-arm(保留 Dockerfile.prod)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:28:41 +08:00
yuming af40ed69fc 修复密码重置功能的逻辑错误 2025-12-18 09:13:03 +08:00
49 changed files with 7670 additions and 406 deletions
+67
View File
@@ -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
+47
View File
@@ -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
View File
@@ -1,7 +1,30 @@
# 构建产物
obj
bin
.vs
logs
upload
data1
# IDE
.vs
.idea/
*.csproj.user
# 抖音视频下载目录(本地数据,禁止入库)
videos/
# 构建打包产物
*.tar.gz
# SQLite 数据库及日志(仅忽略实例数据,不忽略迁移脚本)
/db/*.sqlite
/db/*.db-journal
/db/*.db-wal
/db/*.db-shm
# 飞牛 NAS 相关(当前只部署群晖,不入库)
*feiniu*
# 手动部署脚本(与 Gitea CI 流程重复,本地保留即可,不入库以免与 CI 冲突)
/deploy.sh
/docker-compose.yml
+50 -1
View File
@@ -136,7 +136,7 @@ namespace dy.net.Controllers
}
/// <summary>
///
/// 立即执行同步任务
/// </summary>
/// <returns></returns>
[HttpGet("ExecuteJobNow")]
@@ -148,6 +148,55 @@ namespace dy.net.Controllers
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() {
var config= commonService.GetConfig();
+268 -2
View File
@@ -1,4 +1,4 @@
using dy.net.dto;
using dy.net.dto;
using dy.net.service;
using dy.net.utils;
using Microsoft.AspNetCore.Authorization;
@@ -25,7 +25,10 @@ namespace dy.net.Controllers
[HttpPost("paged")]
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
{
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>
/// 查询统计数据
@@ -53,6 +71,254 @@ namespace dy.net.Controllers
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]
[HttpGet]
public IActionResult Index()
-10
View File
@@ -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
-10
View File
@@ -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
+69
View File
@@ -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
View File
@@ -1,2 +1 @@
VITE_BASE_URL=/
VITE_API_URL=http://localhost:10101
VITE_API_URL=http://localhost:10101
+35 -37
View File
@@ -11,6 +11,9 @@ importers:
'@ant-design/icons-vue':
specifier: ^6.1.0
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':
specifier: ^10.1.0
version: 10.11.1(vue@3.5.21(typescript@4.9.5))
@@ -89,7 +92,7 @@ importers:
version: 6.14.0
'@vitejs/plugin-vue':
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:
specifier: ^10.4.7
version: 10.4.21(postcss@8.5.6)
@@ -124,11 +127,11 @@ importers:
specifier: ^11.6.0
version: 11.11.1
less:
specifier: ^4.1.3
version: 4.4.1
specifier: 4.1.3
version: 4.1.3
less-loader:
specifier: ^12.3.0
version: 12.3.0(less@4.4.1)
version: 12.3.0(less@4.1.3)
mockjs:
specifier: ^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))
vite:
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:
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:
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:
specifier: ^1.0.13
version: 1.8.27(typescript@4.9.5)
@@ -574,6 +577,10 @@ packages:
cpu: [x64]
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':
resolution: {integrity: sha512-c41YOMzBhl3hp58WJLxT+Qq3UhBd8GZAMkbS8ddlCuIGLW0COGe2YSfOA2+poA8/bxLhUQODRNjAy3KhiAOtzA==}
@@ -656,67 +663,56 @@ packages:
resolution: {integrity: sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.52.0':
resolution: {integrity: sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.52.0':
resolution: {integrity: sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.52.0':
resolution: {integrity: sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.52.0':
resolution: {integrity: sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.52.0':
resolution: {integrity: sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.52.0':
resolution: {integrity: sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.52.0':
resolution: {integrity: sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.52.0':
resolution: {integrity: sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.52.0':
resolution: {integrity: sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.52.0':
resolution: {integrity: sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.52.0':
resolution: {integrity: sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw==}
@@ -2331,9 +2327,9 @@ packages:
webpack:
optional: true
less@4.4.1:
resolution: {integrity: sha512-X9HKyiXPi0f/ed0XhgUlBeFfxrlDP3xR4M7768Zl+WXLUViuL9AOPPJP4nCV0tgRWvTYvpNmN0SFhZOQzy16PA==}
engines: {node: '>=14'}
less@4.1.3:
resolution: {integrity: sha512-w16Xk/Ta9Hhyei0Gpz9m7VS8F28nieJaL/VyShID7cYvP6IL5oHeL6p4TXSDJqZE/lNv0oJ2pGVjJsRkfwm5FA==}
engines: {node: '>=6'}
hasBin: true
liftoff@3.1.0:
@@ -3956,6 +3952,8 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
optional: true
'@fortawesome/fontawesome-free@5.15.4': {}
'@iconify-json/simple-icons@1.2.52':
dependencies:
'@iconify/types': 2.0.0
@@ -4169,14 +4167,14 @@ snapshots:
'@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:
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)
'@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:
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)
'@volar/language-core@1.11.1':
@@ -6063,11 +6061,11 @@ snapshots:
dependencies:
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:
less: 4.4.1
less: 4.1.3
less@4.4.1:
less@4.1.3:
dependencies:
copy-anything: 2.0.6
parse-node-version: 1.0.1
@@ -7311,16 +7309,16 @@ snapshots:
remove-trailing-separator: 1.1.0
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:
chalk: 4.1.2
debug: 4.4.3
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:
- 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:
esbuild: 0.18.20
postcss: 8.5.6
@@ -7328,9 +7326,9 @@ snapshots:
optionalDependencies:
'@types/node': 14.18.63
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:
esbuild: 0.21.5
postcss: 8.5.6
@@ -7338,9 +7336,9 @@ snapshots:
optionalDependencies:
'@types/node': 14.18.63
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:
'@docsearch/css': 3.8.2
'@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/types': 2.5.0
'@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/shared': 3.5.21
'@vueuse/core': 12.8.2(typescript@4.9.5)
@@ -7358,7 +7356,7 @@ snapshots:
mark.js: 8.11.1
minisearch: 7.2.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)
optionalDependencies:
postcss: 8.5.6
+580
View File
@@ -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
View File
@@ -13,17 +13,12 @@ const columns = [
{
title: 'Cookie名',
dataIndex: 'userName',
width: 100,
width: 120,
},
{ title: '状态', dataIndex: 'status', width: 80 },
{ title: '收藏路径', dataIndex: 'savePath', width: 100 },
{ title: '喜欢路径', dataIndex: 'favSavePath', width: 100 },
{ title: 'Up主路径', dataIndex: 'upSavePath', width: 100 },
// { 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 },
{ title: '存储路径', dataIndex: 'paths', width: 300, ellipsis: true },
{ title: 'SecUserId', dataIndex: 'userIds', width: 200, ellipsis: true },
{ title: '操作', dataIndex: 'edit', width: 180, fixed: 'right' },
];
// 定义数组中单个对象的类型:包含uper和uid字段(可选字符串类型,根据实际需求调整是否可选)
@@ -47,16 +42,6 @@ type DataItem = {
upSavePath?: string;
};
// const dataSource = reactive<DataItem[]>([
// {
// userName: 'Li Zhi',
// cookies: '131231',
// savePath: 'x',
// status: 1,
// id: '1',
// },
// ]);
const loading = ref(false);
const datas: UnwrapRef<DataItem[]> = reactive([]);
const pagination = ref({
@@ -153,9 +138,6 @@ function submit() {
} else {
copyObject(editRecord.value, resData);
}
// alert(JSON.stringify(form.upSecUserIdsJson));
// console.log('1', authors);
// console.log('2', res);
useApiStore()
.UpdateConfig(resData)
.then((res) => {
@@ -177,17 +159,15 @@ function submit() {
const editRecord = ref<DataItem>();
import { Modal } from 'ant-design-vue'; // 假设使用Ant Design Vue的Modal组件
import { Modal } from 'ant-design-vue';
const deleted = (id: string) => {
// 显示确认对话框
Modal.confirm({
title: '确认删除',
content: '确定要删除这条记录吗?此操作不可撤销。',
okText: '确认',
cancelText: '取消',
onOk: () => {
// 用户确认后执行删除操作
useApiStore()
.deleteCookie(id)
.then((res) => {
@@ -200,15 +180,11 @@ const deleted = (id: string) => {
});
},
onCancel: () => {
// 用户取消删除,不执行任何操作
console.log('已取消删除');
},
});
};
/**
* 编辑
* @param record
*/
function edit(record: DataItem) {
editRecord.value = record;
copyObject(form, record);
@@ -264,7 +240,7 @@ const rowCount = 4;
<a-form-item label="喜欢的存储路径" name="favSavePath">
<div style="display: flex; align-items: center; gap: 6px;">
<a-input v-model:value="form.favSavePath" style="flex: 1;" />
<a-tooltip title="同步“我喜欢的”视频时,必填!!!">
<a-tooltip title="同步'我喜欢的'视频时,必填!!!">
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px;" />
</a-tooltip>
</div>
@@ -272,7 +248,7 @@ const rowCount = 4;
<a-form-item label="SecUserId" name="secUserId">
<div style="display: flex; align-items: center; gap: 6px;">
<a-input v-model:value="form.secUserId" style="flex: 1;" />
<a-tooltip title="同步“我喜欢的”视频时,必填!!!">
<a-tooltip title="同步'我喜欢的'视频时,必填!!!">
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px;" />
</a-tooltip>
</div>
@@ -287,8 +263,7 @@ const rowCount = 4;
</a-form-item>
<a-form-item label="博主信息" name="upSecUserIdsJson">
<a-form-item-rest> <!-- 用这个包裹所有辅助元素 -->
<!-- 添加行按钮 -->
<a-form-item-rest>
<a-button type="primary" @click="addRow" style="margin-bottom: 12px">
添加抖音Up主
<template #icon>
@@ -296,23 +271,15 @@ const rowCount = 4;
</template>
</a-button>
<!-- 动态渲染所有行 -->
<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" />
<!-- uid输入框 -->
<a-input v-model:value="row.uid" placeholder="博主secUserId" style="flex: 3" />
<!-- syncAll 开关字段 -->
<div style="flex: 1; display: flex; align-items: center;">
<a-tooltip title="默认关闭,仅同步 UP 主最新一页数据;开启将同步全部作品(量大不建议开启)">
<span style="margin-right: 8px; cursor: default;color:#faad14">同步全部作品</span>
</a-tooltip>
<a-switch v-model:checked="row.syncAll" />
</div>
<!-- 删除当前行按钮 -->
<a-button type="text" danger @click="removeRow(index)">
<template #icon>
<DeleteOutlined />
@@ -330,10 +297,8 @@ const rowCount = 4;
</a-form>
</a-modal>
<!-- 成员表格 -->
<a-table v-bind="$attrs" :columns="columns" :dataSource="dataSource" :pagination="false">
<a-table v-bind="$attrs" :columns="columns" :dataSource="dataSource" :pagination="false" :scroll="{ x: 900 }">
<template #title>
<!-- 关键修改:将 justify-between 改为 justify-end(使子元素靠右侧对齐) -->
<div class="flex justify-end pr-4">
<a-button type="primary" @click="GetRecords()" :loading="formLoading" class="mr-2">
<template #icon>
@@ -359,23 +324,51 @@ const rowCount = 4;
</template>
</a-badge>
</template>
<template v-else-if="column.dataIndex === 'cookies'">
<!-- 触发按钮 -->
<a-button @click="showCookies(record)">查看</a-button>
<template v-else-if="column.dataIndex === 'paths'">
<div class="path-info">
<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 v-else-if="column.dataIndex === 'upSecUserIds'">
<!-- 触发按钮 -->
<a-button @click="showUpers(record)">查看</a-button>
<template v-else-if="column.dataIndex === 'userIds'">
<div class="userid-info">
<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 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>
<EditFilled />
</template>
编辑
</a-button>
<a-button type="link" @click="deleted(record.id)" danger>
<a-button type="link" @click="deleted(record.id)" danger size="small">
<template #icon>
<DeleteFilled />
</template>
@@ -388,17 +381,13 @@ const rowCount = 4;
</template>
</a-table>
<!-- Modal弹窗 -->
<a-modal title="Cookie 详情" :visible="showCookiesModal" style="width:1200px;" @cancel="showCookiesModal = false" @ok="showCookiesModal = false">
<!-- 弹窗内容 -->
<div class="cookie-content">
{{ showCookiesData || '无Cookie数据' }}
</div>
</a-modal>
<!-- Modal弹窗 -->
<a-modal title="要同步的Up主信息" :visible="showUpersModal" style="width:1200px;" @cancel="showUpersModal = false" @ok="showUpersModal = false">
<!-- 弹窗内容 -->
<div class="cookie-content">
{{ showUpersData || '未设置' }}
</div>
@@ -414,100 +403,114 @@ const rowCount = 4;
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 {
width: 6px;
}
.cookie-content::-webkit-scrollbar-track {
background: transparent;
}
.cookie-content::-webkit-scrollbar-thumb {
background: rgba(150, 150, 150, 0.2);
border-radius: 3px;
}
.cookie-content::-webkit-scrollbar-thumb:hover {
background: rgba(150, 150, 150, 0.4);
}
.cookie-content::-webkit-scrollbar-corner {
background: transparent;
}
.cookie-content {
scrollbar-width: thin;
scrollbar-color: rgba(150, 150, 150, 0.2) transparent;
}
/* ---------------------- 新增:a-textarea 透明滚动条 ---------------------- */
/* 1. 穿透 scoped,定位 a-textarea 内部的原生 textarea 元素 */
:deep(.ant-input-textarea-input) {
/* 确保内容超出时显示滚动条(a-textarea 默认已配置,可省略) */
overflow-y: auto;
/* Firefox 透明滚动条:thin 细滚动条 + 滑块颜色/轨道颜色 */
scrollbar-width: thin;
scrollbar-color: rgba(150, 150, 150, 0.2) transparent;
}
/* 2. WebKit 浏览器(Chrome/Safari/Edge)透明滚动条 */
/* 滚动条宽度 */
:deep(.ant-input-textarea-input)::-webkit-scrollbar {
width: 6px; /* 与 cookie-content 保持一致的细滚动条 */
height: 6px; /* 横向滚动条(如需) */
width: 6px;
height: 6px;
}
/* 滚动条轨道(完全透明) */
:deep(.ant-input-textarea-input)::-webkit-scrollbar-track {
background: transparent;
}
/* 滚动条滑块(半透明,hover 时加深) */
:deep(.ant-input-textarea-input)::-webkit-scrollbar-thumb {
background: rgba(150, 150, 150, 0.2); /* 浅灰透明,默认几乎看不见 */
border-radius: 3px; /* 圆角优化 */
background: rgba(150, 150, 150, 0.2);
border-radius: 3px;
}
: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 {
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>
+89 -1
View File
@@ -16,6 +16,47 @@
<a-alert message="每次扫描行数,第一次同步完成后,可以适当调高提高效率" type="success" />
</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,留空则不限制&#10;例如:&#10;作者1&#10;作者2&#10;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,留空则不限制&#10;例如:&#10;作者1&#10;作者2&#10;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-space>
<a-button type="primary" @click="onUpdate" v-if="componentDisabled">修改配置</a-button>
@@ -43,11 +84,23 @@ interface FormState {
Cron: string;
Id: string;
BatchCount: number;
EnableAutoDownload?: boolean;
DownloadDaysRange?: number;
AuthorWhitelist?: string;
AuthorBlacklist?: string;
DownloadFavorite?: boolean;
DownloadCollect?: boolean;
}
const formState: UnwrapRef<FormState> = reactive({
Cron: '30',
Id: '0',
BatchCount: 10,
EnableAutoDownload: false,
DownloadDaysRange: 0,
AuthorWhitelist: '',
AuthorBlacklist: '',
DownloadFavorite: false,
DownloadCollect: false,
});
const rules: Record<string, Rule[]> = {
@@ -62,6 +115,21 @@ const getConfig = () => {
formState.Cron = res.data.cron;
formState.Id = res.data.id;
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 {
message.error(res.erro, 8);
}
@@ -79,12 +147,32 @@ const onSubmit = () => {
.validate()
.then(() => {
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()
.apiUpdateConfig(toRaw(formState))
.apiUpdateConfig(submitData)
.then((res) => {
if (res.code === 0) {
message.success('配置修改生效,同步任务将按照新的规则执行');
componentDisabled.value = true;
getConfig(); // 重新加载配置
} else {
message.error(res.erro, 8);
}
+369 -49
View File
@@ -7,6 +7,19 @@
<a-form-item label="抖音作者" ref="author" name="author">
<a-input v-model:value="quaryData.author"></a-input>
</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-radio-group v-model:value="quaryData.viedoType" button-style="solid" @change="onViedoTypeChanged">
<a-radio-button value="*">全部</a-radio-button>
@@ -15,69 +28,165 @@
<a-radio-button value="3">关注的</a-radio-button>
</a-radio-group>
</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-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-space>
</a-form-item>
</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>
<!-- 视频播放器 -->
<VideoPlayer ref="videoPlayerRef" />
</div>
</template>
<script lang="ts" setup>
import { defineComponent, reactive, ref } from 'vue';
import { defineComponent, reactive, ref, watch } from 'vue';
import { useApiStore } from '@/store';
import type { UnwrapRef } from 'vue';
import { onMounted } from 'vue';
import dayjs, { Dayjs } from 'dayjs';
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];
import '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: '用户',
dataIndex: 'dyUser',
// sorter: true,
title: '操作',
key: 'action',
align: 'center',
width: 200,
width: 120,
fixed: 'right',
},
]);
const loading = ref(false);
@@ -90,7 +199,11 @@ interface QuaryParam {
pageSize: number;
author: string;
tag: string;
tags?: string[];
viedoType: string;
isDeleted?: boolean | null;
sortField?: string | null;
sortOrder?: string | null;
}
const value1 = ref<RangeValue>();
@@ -104,26 +217,127 @@ const quaryData: UnwrapRef<QuaryParam> = reactive({
pageSize: 20,
author: '',
tag: '',
tags: undefined,
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 = () => {
loading.value = true;
quaryData.pageIndex = pagination.value.current;
quaryData.pageSize = pagination.value.defaultPageSize;
// 构建请求参数,确保排序参数被包含
const requestParams = {
...quaryData,
sortField: quaryData.sortField || null,
sortOrder: quaryData.sortOrder || null,
};
console.log('发送查询请求,参数:', JSON.stringify(requestParams, null, 2));
useApiStore()
.VideoPageList(quaryData)
.VideoPageList(requestParams)
.then((res) => {
loading.value = false;
console.log('查询响应:', res);
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;
pagination.value.current = res.data.pageIndex;
pagination.value.defaultPageSize = res.data.pageSize;
pagination.value.total = res.data.total;
pagination.value.showTotal = () => `共 ${res.data.total} 条`;
// 数据加载完成后,确保排序状态正确显示
updateColumnSortState();
}
})
.catch((err) => {
loading.value = false;
console.error('查询失败:', err);
});
};
onMounted(() => {
loadCategories(); // 加载视频类型列表
GetRecords();
});
const pagination = ref({
@@ -133,12 +347,88 @@ const pagination = ref({
showTotal: () => `共 ${0} 条`,
});
const handleTableChange = (e) => {
console.log(e);
pagination.value.current = e.current;
pagination.value.defaultPageSize = e.defaultPageSize;
pagination.value.total = e.total;
pagination.value.showTotal = () => `共 ${e.total} 条`;
const handleTableChange = (e: any) => {
console.log('表格变化事件:', e);
console.log('事件对象键:', Object.keys(e));
console.log('sorter 属性:', e.sorter);
// 处理分页变化
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();
};
@@ -159,6 +449,36 @@ const onViedoTypeChanged = (e) => {
pagination.value.current = 1;
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>
<style scoped>
</style>
File diff suppressed because it is too large Load Diff
+41 -1
View File
@@ -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
async function CookiePageList(param: object) {
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 {
deleteCookie,
UpdateConfig,
@@ -176,6 +213,9 @@ export const useApiStore = defineStore('coreapi', () => {
StartJobNow,
VideoStatics,
VideoPageList,
CookiePageList
VideoCategories,
VideoCleanup,
CookiePageList,
DeleteAuthorVideos
};
});
+9 -2
View File
@@ -41,13 +41,20 @@ http.interceptors.response.use(
(error) => {
// console.log(error)
// debugger
if (error.response.status === 401) {
useAccountStore().setLogged(false)
if (error.response && error.response.status === 401) {
useAccountStore().setLogged(false);
return Promise.reject({
message: '未授权,请重新登录',
code: 401,
error: 'Unauthorized',
data: error.response.data,
});
} else {
if (error.response && isAxiosResponse(error.response)) {
return Promise.reject({
message: error.response.statusText,
code: error.response.status,
error: error.response.data?.error || error.response.statusText,
data: error.response.data,
});
}
+29 -13
View File
@@ -83,20 +83,18 @@ function toUrlencoded(params?: Record<string | number, any>) {
function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
const _axios = axios.create(config);
// 添加响应拦截器处理401状态
_axios.interceptors.response.use(
// 成功响应直接返回
response => response,
// 错误响应处理
error => {
// 检查是否是401未授权错误
if (error.response && error.response.status === 401) {
// 调用removeAuthorization方法清除token
http.removeAuthorization();
// 这里可以添加额外的处理,比如跳转到登录页
// 示例: window.location.href = '/login';
// 添加请求拦截器,自动添加Authorization header
_axios.interceptors.request.use(
(config) => {
// 从Cookie中读取Authorization token
const token = Cookie.get(config.xsrfCookieName || 'Authorization');
if (token && config.headers) {
// 将token添加到请求头
config.headers[config.xsrfHeaderName || 'Authorization'] = token;
}
return config;
},
(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;
}
+2 -2
View File
@@ -34,10 +34,10 @@ export default ({ command, mode }) => {
open: false, //自动打开
proxy: {
'/api': {
target: env.VITE_API_URL,
target: env.VITE_API_URL || 'http://localhost:10101',
ws: true,
changeOrigin: true,
rewrite: (path) => path.replace(/^\//, ''),
// 不重写路径,保持 /api 前缀
},
},
hmr: true,
+35
View File
@@ -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
+178
View File
@@ -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 上构建
+394
View File
@@ -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)
+39
View File
@@ -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
+13
View File
@@ -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
+13
View File
@@ -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
+81
View File
@@ -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)
+236
View File
@@ -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
+58
View File
@@ -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
View File
@@ -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/` 目录下的日志文件了解详细信息。
+127
View File
@@ -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
View File
@@ -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 文档测试接口功能
+79
View File
@@ -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/
```
+164
View File
@@ -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 .
```
+202
View File
@@ -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. **关闭模态框** → 视频停止播放,模态框关闭
---
**提示**:如果遇到问题,请按照上述排查步骤逐一检查,并查看浏览器控制台和网络请求的详细信息。
+209
View File
@@ -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
View File
@@ -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
+368
View File
@@ -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
+770
View File
@@ -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`
+251
View File
@@ -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
+18
View File
@@ -7,12 +7,30 @@ namespace dy.net.dto
// 3. 引用类型(string)如果允许为null,显式声明为 string?
public string? Tag { get; set; }
// 支持多个标签筛选
public List<string>? Tags { get; set; }
public string? Author { get; set; }
public string? ViedoType { get; set; }
// 4. 泛型集合(List<string>)允许为null,声明为 List<string>?
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; }
}
+15 -1
View File
@@ -95,8 +95,22 @@ namespace dy.net.extension
{
OnMessageReceived = context =>
{
context.Token = context.Request.Headers["Authorization"]
// 优先从 Header 读取
var token = context.Request.Headers["Authorization"]
.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;
}
};
+76 -61
View File
@@ -82,6 +82,7 @@ namespace dy.net.job
while (hasMore)
{
Serilog.Log.Debug($"collect-正在获取第 {index + 1} 页数据,cursor: {cursor}");
var data = await _douyinService.SyncCollectVideos(cursor, count, cookie.Cookies);
hasMore = data != null && data.HasMore == 1 && cookie.CollHasSyncd == 0;
if (cookie.CollHasSyncd == 1)
@@ -94,11 +95,15 @@ namespace dy.net.job
Serilog.Log.Debug($"collect-Cookie[{cookie.UserName}]获取收藏数据失败,请检查一下Cookie");
break;
}
Serilog.Log.Debug($"collect-API返回: HasMore={data.HasMore}, AwemeList数量={data.AwemeList?.Count ?? 0}");
cursor = data != null && !string.IsNullOrWhiteSpace(data.Cursor) ? data.Cursor : "0";
if (data.AwemeList == null || !data.AwemeList.Any())
{
Serilog.Log.Debug($"collect-第 {index + 1} 页数据为空,结束同步");
break;
}
@@ -127,7 +132,27 @@ namespace dy.net.job
var fileName = $"{item.AwemeId}.{v.Format}"; // 用ID做文件名,避免特殊字符
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)}]开始下载");
await Task.Delay(_random.Next(1, 4) * 1000);
@@ -135,87 +160,77 @@ namespace dy.net.job
if (downVideo)
{
Serilog.Log.Debug($"collect-视频[{SanitizePath(item.Desc)}]下载{(downVideo ? "成功" : "失败")}");
}
else
{
Serilog.Log.Error($"collect-视频[{SanitizePath(item.Desc)}]下载{(downVideo ? "成功" : "失败")}");
}
if (downVideo)
{
Serilog.Log.Debug($"collect-视频[{SanitizePath(item.Desc)}]下载成功");
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);
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;
}
// 构造视频数据
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);
finalSavePath = savePath;
finalCoverSavePath = Path.Combine(saveFolder, "poster.jpg");
var nfoPath = Path.Combine(saveFolder, $"{item.AwemeId}.nfo");
NfoFileGenerator.GenerateNfoFile(new VideoNfo
{
Author = video.Author,
Author = item.Author?.Nickname,
Poster = Path.Combine(saveFolder, "poster.jpg"),
Title = video.VideoTitle,
Title = item.Desc,
Thumbnail = Path.Combine(saveFolder, "fanart.jpg"),
ReleaseDate = video.CreateTime,
ReleaseDate = videoCreateTime,
Genres = new List<string> { tag1, tag2, tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList()
}, 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++;
// 批量保存到数据库(减少数据库操作频率)
if (videos.Any())
{
//Serilog.Log.Debug($"处理Cookie[{cookie.UserName}]的第{index}页数据,共{videos.Count}条");
Serilog.Log.Debug($"collect-处理Cookie[{cookie.UserName}]的第{index}页数据,共{videos.Count}条,准备保存到数据库");
try
{
await _douyinVideoService.batchInsert(videos);
var insertResult = await _douyinVideoService.batchInsert(videos);
Serilog.Log.Debug($"collect-batchInsert返回结果: {insertResult}, 本次处理{videos.Count}条视频");
syncCount += videos.Count;
}
catch (Exception ex)
+77 -61
View File
@@ -79,6 +79,7 @@ namespace dy.net.job
string cursor = "0";
while (hasMore)
{
Serilog.Log.Debug($"favorite-正在获取第 {index + 1} 页数据,cursor: {cursor}, secUserId: {cookie.SecUserId}");
var data = await _douyinService.SyncFavoriteVideos(count,cursor, cookie.SecUserId, cookie.Cookies);
hasMore = data != null && data.HasMore == 1 && cookie.FavHasSyncd == 0;
if (cookie.FavHasSyncd == 1)
@@ -91,10 +92,14 @@ namespace dy.net.job
Serilog.Log.Debug($"favorite-Cookie[{cookie.UserName}]获取喜欢的数据失败,请检查一下Cookie和sec_user_id");
break;
}
Serilog.Log.Debug($"favorite-API返回: HasMore={data.HasMore}, AwemeList数量={data.AwemeList?.Count ?? 0}");
cursor = data != null && !string.IsNullOrWhiteSpace(data.MaxCursor) ? data.MaxCursor : "0";
if (data.AwemeList == null || !data.AwemeList.Any())
{
Serilog.Log.Debug($"favorite-第 {index + 1} 页数据为空,结束同步");
break;
}
@@ -122,7 +127,27 @@ namespace dy.net.job
var fileName = $"{item.AwemeId}.{v.Format}"; // 用ID做文件名,避免特殊字符
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)}]开始下载");
await Task.Delay(_random.Next(1, 4) * 1000);
@@ -130,86 +155,77 @@ namespace dy.net.job
if (downVideo)
{
Serilog.Log.Debug($"favorite-视频[{SanitizePath(item.Desc)}]下载{(downVideo ? "成功" : "失败")}");
}
else
{
Serilog.Log.Error($"favorite-视频[{SanitizePath(item.Desc)}]下载{(downVideo ? "成功" : "失败")}");
}
if (downVideo)
{
Serilog.Log.Debug($"favorite-视频[{SanitizePath(item.Desc)}]下载成功");
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);
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;
}
// 构造视频数据
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);
finalSavePath = savePath;
finalCoverSavePath = Path.Combine(saveFolder, "poster.jpg");
var nfoPath = Path.Combine(saveFolder, $"{item.AwemeId}.nfo");
NfoFileGenerator.GenerateNfoFile(new VideoNfo
{
Author = video.Author,
Author = item.Author?.Nickname,
Poster = Path.Combine(saveFolder, "poster.jpg"),
Title = video.VideoTitle,
Thumbnail= Path.Combine(saveFolder, "fanart.jpg"),
ReleaseDate = video.CreateTime,
Title = item.Desc,
Thumbnail = Path.Combine(saveFolder, "fanart.jpg"),
ReleaseDate = videoCreateTime,
Genres = new List<string> { tag1, tag2, tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList()
}, 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++;
// 批量保存到数据库(减少数据库操作频率)
if (videos.Any())
{
//Serilog.Log.Debug($"处理Cookie[{cookie.UserName}]的第{index}页数据,共{videos.Count}条");
Serilog.Log.Debug($"favorite-处理Cookie[{cookie.UserName}]的第{index}页数据,共{videos.Count}条,准备保存到数据库");
try
{
await _douyinVideoService.batchInsert(videos);
var insertResult = await _douyinVideoService.batchInsert(videos);
Serilog.Log.Debug($"favorite-batchInsert返回结果: {insertResult}, 本次处理{videos.Count}条视频");
syncCount += videos.Count;
}
catch (Exception ex)
+36
View File
@@ -20,5 +20,41 @@ namespace dy.net.model
/// 每次查询数量
/// </summary>
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;
}
}
+201 -6
View File
@@ -1,5 +1,6 @@
using dy.net.model;
using SqlSugar;
using System.Linq.Expressions;
namespace dy.net.repository
{
@@ -17,10 +18,14 @@ namespace dy.net.repository
/// </summary>
/// <param name="pageIndex">页码(从1开始)</param>
/// <param name="pageSize">每页数量</param>
/// <param name="tag">可选标签过滤</param>
/// <param name="tag">可选标签过滤(单个,兼容旧版本)</param>
/// <param name="tags">可选标签过滤(多个)</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>
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;
@@ -35,16 +40,206 @@ namespace dy.net.repository
start = Convert.ToDateTime(dates[0]);
}
var where = this.Db.Queryable<DyCollectVideo>()
.WhereIF(!string.IsNullOrWhiteSpace(tag), x => x.Tag1 == tag)
var where = this.Db.Queryable<DyCollectVideo>();
// 支持多个标签筛选(优先使用 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(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(!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 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()) {
var users= await this.Db.Queryable<DyUserCookies>().ToListAsync();
foreach (var item in list)
+7
View File
@@ -54,6 +54,13 @@ namespace dy.net.service
{
cc.Cron = config.Cron;
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();
+124
View File
@@ -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;
}
}
}
+189 -7
View File
@@ -41,12 +41,18 @@ namespace dy.net.service
.Where(video => !existingAwemeIds.Contains(video.AwemeId))
.ToList();
Serilog.Log.Debug($"batchInsert: 待插入{videos.Count}条,已存在{existingAwemeIds.Count}条,需要插入{videosToInsert.Count}条");
// 4. 如果没有需要插入的新记录,直接返回成功
if (!videosToInsert.Any())
{
Serilog.Log.Debug($"batchInsert: 所有视频都已存在,无需插入");
return true;
}
// 5. 批量插入过滤后的新记录
var insertedCount = await _dyCollectVideoRepository.InsertRangeAsync(videosToInsert);
Serilog.Log.Debug($"batchInsert: 实际插入{insertedCount}条记录");
// 返回是否插入成功(至少插入一条)
return insertedCount > 0;
@@ -55,16 +61,47 @@ namespace dy.net.service
public async Task<VideoStaticsDto> GetStatics()
{
List<DyCollectVideo> list = await this._dyCollectVideoRepository.GetAllAsync();
List<DyCollectVideo> allList = 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())
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
{
AuthorCount = list.Select(x => x.AuthorId).Distinct().Count(),
CategoryCount = list.Select(x => x.Tag1).Distinct().Count(),
CategoryCount = Categories.Count, // 使用实际分类数量
VideoCount = list.Count,
Categories = Categories,
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;
}
}
}
+31 -4
View File
@@ -68,11 +68,24 @@ namespace dy.net.service
if (respose.IsSuccessStatusCode)
{
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
{
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;
}
}
@@ -129,15 +142,29 @@ namespace dy.net.service
// 构建请求URL
var queryString = new FormUrlEncodedContent(dics);
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);
if (respose.IsSuccessStatusCode)
{
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
{
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;
}
}