Compare commits
223 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93945ce25d | |||
| 93abc85cff | |||
| a7741b9de2 | |||
| 9038c39afa | |||
| 1899f90b5c | |||
| 45d3a3de4d | |||
| e6c5587637 | |||
| 30060fe29c | |||
| c5922f9b08 | |||
| e83ad8d85c | |||
| cc96e03cb9 | |||
| 522fc6f699 | |||
| c88afc2fd0 | |||
| e48bada605 | |||
| fdd19cd22b | |||
| 83e185a1f8 | |||
| 5e89b9c66f | |||
| 432ab60dd0 | |||
| a21f745eac | |||
| dd22d46f26 | |||
| 4a7aeeed8c | |||
| b1bf488e84 | |||
| a67093405a | |||
| 01073fce52 | |||
| fc29ee21ea | |||
| 63f44adeca | |||
| 8f738f1714 | |||
| 77dbb8048e | |||
| 2aef300ab0 | |||
| 36d301d32b | |||
| 3a47b1e028 | |||
| 0900d2d754 | |||
| c87bfcd3a7 | |||
| 1fa67daab1 | |||
| 21e1aca7b5 | |||
| dbf98c781d | |||
| e82e3a9140 | |||
| 5c57a2b040 | |||
| cd24804273 | |||
| 9784114c90 | |||
| 650e1ac3fc | |||
| 3439a7ad38 | |||
| 8b98abc94d | |||
| c1d5db18a9 | |||
| 46bec7a0d4 | |||
| 77f227a6c5 | |||
| a3e6af672e | |||
| 8fd06cf2a0 | |||
| 3578962a4c | |||
| 8af041cf91 | |||
| edbcc13086 | |||
| 85986cdaa3 | |||
| f13d73e023 | |||
| 66093d3ed0 | |||
| b63621593b | |||
| e92e290a4e | |||
| 3f6b1874eb | |||
| 60f6e3847d | |||
| c609eb4e6f | |||
| 36f11d4d79 | |||
| bdd70f73e4 | |||
| 8390511675 | |||
| e011bd089f | |||
| b331e73bd3 | |||
| 357b20d211 | |||
| b53a41d3f1 | |||
| a8c3201c1d | |||
| eb0769d595 | |||
| 33888aabdd | |||
| e270397334 | |||
| 7175b3858a | |||
| 7c6ee0a8a6 | |||
| 65e6b48d8e | |||
| f5449f3ace | |||
| d9cb721f64 | |||
| dc38a25239 | |||
| 41a21e33fd | |||
| f376498fe5 | |||
| ff80250012 | |||
| 8da82889d1 | |||
| 1f96f31c69 | |||
| 186179bf14 | |||
| 0727999cd6 | |||
| e32d783de4 | |||
| 858a21ec9e | |||
| 12b9190f45 | |||
| 70c3dc71e5 | |||
| f4f87a8634 | |||
| 00e28fc334 | |||
| a943a13347 | |||
| 46b6a0269c | |||
| 3d9a5fddfb | |||
| aa5d4878cb | |||
| 1006de4a0d | |||
| 4e0169da90 | |||
| b866cab3d8 | |||
| 0d35e4665c | |||
| e34a1d3666 | |||
| 00b9ec3013 | |||
| e8852868cd | |||
| 66718ff478 | |||
| 0532ef1006 | |||
| 5a907e24f7 | |||
| 87f154a8b7 | |||
| b9b3731bbe | |||
| 214d1259f4 | |||
| d02a784c1b | |||
| 66bd441378 | |||
| 96a66a9f54 | |||
| 727dba6e08 | |||
| 3db406f699 | |||
| 220c3ff140 | |||
| 59b454023d | |||
| 8ae0506d96 | |||
| e717672e1e | |||
| c3e9e9c17d | |||
| 9e5e51fea8 | |||
| cc6906ea63 | |||
| 44e3962093 | |||
| 413f19c537 | |||
| 7666ab109d | |||
| 0549688e6f | |||
| 086a0ea5af | |||
| 20e399b2e2 | |||
| 9391ace8c7 | |||
| 6c34e7e163 | |||
| 936e4a8ca1 | |||
| c99a195ad4 | |||
| 6fd92a6662 | |||
| ad9769c07c | |||
| 1574a7ceeb | |||
| 34910bb121 | |||
| d0c4322907 | |||
| fc7886a76e | |||
| 1a9af56e05 | |||
| 90a1115c2e | |||
| 5c05e6465b | |||
| 2550a1fde8 | |||
| 772859eb63 | |||
| dba6bcdb36 | |||
| 26380b9b4d | |||
| 7b5d535f6b | |||
| 41e2bf6ce0 | |||
| 123124bb51 | |||
| 72497c5fae | |||
| c60f7bdf57 | |||
| 91056d9cfa | |||
| b58a091b53 | |||
| a36c815861 | |||
| c03dc5beff | |||
| 385e53561a | |||
| 18d1c4100d | |||
| f5c4459ad9 | |||
| 4df896b30f | |||
| ceb08a5136 | |||
| edd0e71f8b | |||
| 29d34201bd | |||
| 25e2a22dd2 | |||
| bd3a103f8f | |||
| 4663a03483 | |||
| 4b2f9aaa7c | |||
| de5813a874 | |||
| 6c99825548 | |||
| 4e561a8e94 | |||
| 2fedbafca8 | |||
| b599880d7c | |||
| 072314fc52 | |||
| 4804a82967 | |||
| 13dfe09d93 | |||
| b6b915b0c5 | |||
| d947393b1d | |||
| 830b6769f9 | |||
| c09b30de63 | |||
| 8744d280c7 | |||
| 77628c9833 | |||
| 89d311901d | |||
| 635d1c003b | |||
| 50149a280b | |||
| e42a004765 | |||
| d4b3e36591 | |||
| ea8f8948f8 | |||
| 3de4b60005 | |||
| e456982334 | |||
| e6e9ff3cc3 | |||
| 63b51d211e | |||
| 3093f2c78a | |||
| e59f3031da | |||
| 02200a4595 | |||
| f501b218ca | |||
| 6bedb30602 | |||
| 22a65b9833 | |||
| 4ff41dbaa0 | |||
| 12532f8128 | |||
| a9bb8f5f48 | |||
| ac51f7a50f | |||
| eecc0be509 | |||
| 639a457b17 | |||
| dd2b8da08a | |||
| def3c442a5 | |||
| 6bde9b75ff | |||
| 5b41e5eaeb | |||
| 975baf80cd | |||
| b52acfdc62 | |||
| 898e4c115b | |||
| 00aa82e763 | |||
| 2db61f4958 | |||
| 609649057f | |||
| 116aa0c3e0 | |||
| ef5cb09cb1 | |||
| 225e1614d7 | |||
| 578131ac55 | |||
| 309a0c1350 | |||
| bd616e9b4f | |||
| eadb3bb20d | |||
| 0fd7938901 | |||
| b4cee4f358 | |||
| db008b2c45 | |||
| 73d612fe50 | |||
| e5f22e45c3 | |||
| 85d74c7de3 | |||
| 8cef13c926 | |||
| 7cacdaedd4 | |||
| 8048f7e1e6 |
@@ -0,0 +1,15 @@
|
||||
**/node_modules
|
||||
**/.git
|
||||
logs
|
||||
videos
|
||||
db
|
||||
bin
|
||||
obj
|
||||
.claude
|
||||
.playwright-mcp
|
||||
.idea
|
||||
.codepilot-uploads
|
||||
*.tar.gz
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
smoke-*.png
|
||||
@@ -0,0 +1,57 @@
|
||||
name: 部署到群晖
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 安装 Docker CLI
|
||||
run: |
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
echo "Docker 已预装:$(docker --version)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Docker 未预装,换 apt 源到阿里云再安装(gitea act_runner 实际是 Debian 11)"
|
||||
# Debian 源(当前 runner 实际用的)
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g;s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g;s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/*.list 2>/dev/null || true
|
||||
# Ubuntu 源兼容(万一以后 runner 镜像换成 Ubuntu)
|
||||
sed -i 's|archive.ubuntu.com|mirrors.aliyun.com|g;s|security.ubuntu.com|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
sed -i 's|archive.ubuntu.com|mirrors.aliyun.com|g;s|security.ubuntu.com|mirrors.aliyun.com|g' /etc/apt/sources.list.d/*.sources 2>/dev/null || true
|
||||
timeout 90 apt-get update -qq || { echo "apt-get update 超时/失败"; exit 1; }
|
||||
timeout 240 apt-get install -y -qq docker.io || { echo "docker.io 安装超时/失败"; exit 1; }
|
||||
docker --version
|
||||
|
||||
- name: 拉取代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 构建镜像
|
||||
# --no-cache:绕开 buildx 旧 layer 缓存(曾出现 CI 用几天前缓存的 statics.vue 导致 vue-tsc 报孤儿引用错误)
|
||||
run: docker build --no-cache --pull -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 查看"
|
||||
@@ -5,3 +5,6 @@ logs
|
||||
upload
|
||||
data1
|
||||
/db/*.sqlite
|
||||
/appsettings.json
|
||||
/Properties/PublishProfiles/fn.pubxml.user
|
||||
/Properties/PublishProfiles/*.user
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Configuration.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Configuration.Json;
|
||||
|
||||
namespace dy.net
|
||||
{
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using ClockSnowFlake;
|
||||
using dy.net.model.dto;
|
||||
using dy.net.service;
|
||||
using dy.net.utils;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using dy.net.service;
|
||||
using dy.net.utils;
|
||||
using dy.net.dto;
|
||||
|
||||
namespace dy.net.Controllers
|
||||
{
|
||||
@@ -17,10 +17,10 @@ namespace dy.net.Controllers
|
||||
{
|
||||
private readonly IWebHostEnvironment webHostEnvironment;
|
||||
|
||||
private readonly UserService _userService;
|
||||
public AuthController(UserService userService, IWebHostEnvironment webHostEnvironment )
|
||||
private readonly AdminUserService _userService;
|
||||
public AuthController(AdminUserService userService, IWebHostEnvironment webHostEnvironment)
|
||||
{
|
||||
_userService=userService;
|
||||
_userService = userService;
|
||||
this.webHostEnvironment = webHostEnvironment;
|
||||
}
|
||||
|
||||
@@ -35,66 +35,21 @@ namespace dy.net.Controllers
|
||||
public async Task<IActionResult> UpdatePwd(UpdatePwdRequest user)
|
||||
{
|
||||
var (code, erro) = await _userService.UpdatePwd(user);
|
||||
return Ok(new { code, erro });
|
||||
return ApiResult.Success("", erro);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Authorize]
|
||||
/// <summary>
|
||||
/// 获取头像
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetUserAvatar()
|
||||
{
|
||||
var user = await _userService.GetUser();
|
||||
return Ok(new { code = 0, error = "", data = new { user?.Avatar, user?.Id,user?.UserName } });
|
||||
return ApiResult.Success(new { user?.Avatar, user?.Id, user?.UserName });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改用户头像
|
||||
/// </summary>
|
||||
/// <param name="file"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize]
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> UpdateUserAvatar(IFormFile file)
|
||||
{
|
||||
if (file != null && file.Length > 0)
|
||||
{
|
||||
long maxFileSize = 5 * 1024 * 1024; // 限制文件大小为5MB
|
||||
if (file.Length > maxFileSize)
|
||||
{
|
||||
return Ok(new { code = -1, erro = "文件最大只能上传5M" });
|
||||
}
|
||||
var fileName = $"{IdGener.GetGuid()}_{file.FileName}";
|
||||
var filePath = webHostEnvironment.IsProduction() ?
|
||||
Path.Combine(Md5Util.UPLOAD_PATH_PRO, fileName) : Path.Combine(Md5Util.UPLOAD_PATH_DEV, fileName);
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream);
|
||||
|
||||
try
|
||||
{
|
||||
// 问了节约空间,删除文件夹下的所有文件
|
||||
string[] files = Directory.GetFiles(webHostEnvironment.IsProduction() ? Md5Util.UPLOAD_PATH_PRO : Md5Util.UPLOAD_PATH_DEV);
|
||||
foreach (string mfile in files)
|
||||
{
|
||||
if (!mfile.Contains(fileName))
|
||||
System.IO.File.Delete(mfile);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error($"delete file error ,{ex.Message}");
|
||||
}
|
||||
var update = await _userService.UpdateAvatar( fileName);
|
||||
|
||||
return Ok(new { code = update ? 0 : -1, erro = update ? "" : "上传失败", data = update ? fileName : "" });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return Ok(new { code = -1, erro = "空文件" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -108,7 +63,7 @@ namespace dy.net.Controllers
|
||||
{
|
||||
if (loginUserInfo == null)
|
||||
{
|
||||
return Ok(new { code = -1, erro = "参数不能为空" });
|
||||
return ApiResult.Fail("参数不能为空");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -116,7 +71,7 @@ namespace dy.net.Controllers
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return Ok(new { code = -1, erro = "用户名或密码不正确" });
|
||||
return ApiResult.Fail("用户名或密码不正确");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -124,19 +79,18 @@ namespace dy.net.Controllers
|
||||
{
|
||||
|
||||
var tokenString = GenerateJwtToken(user.UserName);
|
||||
|
||||
return Ok(new { code = 0, erro = "", token = tokenString, expires = 24 * 60 * 60 * 1000 });
|
||||
return Ok(new { code = 0, erro = "", token = tokenString, expires = 24 * 60 * 60 * 1000, data = user.UserName });
|
||||
}
|
||||
else
|
||||
{
|
||||
return Ok(new { code = -1, erro = "用户名或密码不正确" });
|
||||
return ApiResult.Fail("用户名或密码不正确");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private string GenerateJwtToken(string username)
|
||||
private static string GenerateJwtToken(string username)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
@@ -147,7 +101,7 @@ namespace dy.net.Controllers
|
||||
var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(k));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var expires = DateTime.Now.AddDays(1);
|
||||
var expires = DateTime.Now.AddDays(7);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: IdGener.GetLong().ToString(),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using dy.net.model.dto;
|
||||
using dy.net.service;
|
||||
using dy.net.utils;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace dy.net.Controllers
|
||||
{
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class CateController : ControllerBase
|
||||
{
|
||||
private readonly DouyinCollectCateService _douyinCollectCateService;
|
||||
private readonly DouyinQuartzJobService _douyinQuartzJobService;
|
||||
|
||||
public CateController(DouyinCollectCateService douyinCollectCateService, DouyinQuartzJobService douyinQuartzJobService)
|
||||
{
|
||||
this._douyinCollectCateService = douyinCollectCateService;
|
||||
_douyinQuartzJobService = douyinQuartzJobService;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询
|
||||
/// </summary>
|
||||
/// <returns>分页结果</returns>
|
||||
[HttpPost("paged")]
|
||||
public async Task<IActionResult> GetPagedAsync(
|
||||
DouyinCollectCateRequestDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.cookieId))
|
||||
{
|
||||
return ApiResult.Success(new { });
|
||||
}
|
||||
var (list, totalCount) = await _douyinCollectCateService.GetPagedAsync(dto);
|
||||
|
||||
return ApiResult.Success(new
|
||||
{
|
||||
data = list,
|
||||
total = totalCount,
|
||||
pageIndex = dto.PageIndex,
|
||||
pageSize = dto.PageSize
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改关注同步状态
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("BatchSave")]
|
||||
public async Task<IActionResult> BatchSave(List<DouyinCollectCateSwitchDto> dto)
|
||||
{
|
||||
|
||||
if (dto.Any(x => !DouyinFileNameHelper.IsValidWithoutSpecialChars(x.SaveFolder)))
|
||||
{
|
||||
return ApiResult.Fail("有部分文件名不符合要求,请检查");
|
||||
}
|
||||
|
||||
var result = await _douyinCollectCateService.BatchSwitchSync(dto);
|
||||
return ApiResult.SuccOrFail(result, result);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,138 +1,344 @@
|
||||
using ClockSnowFlake;
|
||||
using dy.net.dto;
|
||||
using dy.net.model;
|
||||
using dy.net.extension;
|
||||
using dy.net.model.dto;
|
||||
using dy.net.model.entity;
|
||||
using dy.net.service;
|
||||
using dy.net.utils;
|
||||
using jzc.http.lib;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using static Dm.net.buffer.ByteArrayBuffer;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace dy.net.Controllers
|
||||
{
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ConfigController : ControllerBase
|
||||
{
|
||||
private readonly DyCookieService dyCookieService;
|
||||
private readonly DouyinCookieService dyCookieService;
|
||||
|
||||
private readonly CommonService commonService;
|
||||
private readonly QuartzJobService quartzJobService;
|
||||
private readonly DouyinCommonService commonService;
|
||||
private readonly DouyinQuartzJobService quartzJobService;
|
||||
private readonly DouyinFollowService douyinFollowService;
|
||||
private readonly DouyinCookieService douyinCookieService;
|
||||
private readonly DouyinHttpClientService httpClientService;
|
||||
|
||||
public ConfigController(DyCookieService dyCookieService, CommonService commonService,QuartzJobService quartzJobService)
|
||||
public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService)
|
||||
{
|
||||
this.dyCookieService = dyCookieService;
|
||||
this.commonService = commonService;
|
||||
this.quartzJobService = quartzJobService;
|
||||
this.douyinFollowService = douyinFollowService;
|
||||
this.douyinCookieService = douyinCookieService;
|
||||
this.httpClientService = httpClientService;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 导出配置
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("exportConf")]
|
||||
public async Task<IActionResult> ExportConf()
|
||||
{
|
||||
|
||||
// 1. 组装导出数据
|
||||
var dto = new AppConfigImportDto()
|
||||
{
|
||||
follows = await douyinFollowService.GetHandFollows(),
|
||||
conf = commonService.GetConfig(),
|
||||
cookies = await douyinCookieService.GetAllAsync()
|
||||
};
|
||||
|
||||
return ApiResult.Success(dto);
|
||||
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("importConf")]
|
||||
public async Task<IActionResult> ImportConf(AppConfigImportDto dto)
|
||||
{
|
||||
if (dto == null)
|
||||
return ApiResult.Fail("json数据为空");
|
||||
|
||||
await HandleFollowsImport(dto.follows);
|
||||
await HandleConfigImport(dto.conf);
|
||||
await HandleCookiesImport(dto.cookies);
|
||||
|
||||
return ApiResult.Success();
|
||||
}
|
||||
|
||||
private async Task HandleFollowsImport(List<DouyinFollowed> follows)
|
||||
{
|
||||
if (follows?.Count > 0)
|
||||
{
|
||||
var added = await douyinFollowService.AddHandFollows(follows);
|
||||
if (added)
|
||||
Serilog.Log.Debug("关注列表导入成功");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleConfigImport(AppConfig conf)
|
||||
{
|
||||
if (conf == null) return;
|
||||
|
||||
if (conf.BatchCount > 30)
|
||||
{
|
||||
Serilog.Log.Debug("对不起,为了项目能长久稳定运行,还是最大不要超过30吧。。。");
|
||||
conf.BatchCount = 30;
|
||||
}
|
||||
|
||||
var updated = await commonService.UpdateConfig(conf);
|
||||
if (updated)
|
||||
Serilog.Log.Debug("系统配置导入成功");
|
||||
}
|
||||
|
||||
private async Task HandleCookiesImport(List<DouyinCookie> cookies)
|
||||
{
|
||||
if (cookies?.Count > 0)
|
||||
{
|
||||
var imported = await douyinCookieService.ImportCookies(cookies);
|
||||
if (imported)
|
||||
Serilog.Log.Debug("抖音Cookie配置导入成功");
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 分页查询
|
||||
/// </summary>
|
||||
/// <returns>分页结果(视频列表和总数)</returns>
|
||||
/// <returns>分页结果</returns>
|
||||
[HttpPost("paged")]
|
||||
public async Task<IActionResult> GetPagedAsync(
|
||||
PageRequestDto dto)
|
||||
{
|
||||
var (list, totalCount) = await dyCookieService.GetPagedAsync(dto.PageIndex, dto.PageSize);
|
||||
return Ok(new
|
||||
{
|
||||
code = 0,
|
||||
data = new
|
||||
|
||||
return ApiResult.Success(new
|
||||
{
|
||||
data = list,
|
||||
total=totalCount,
|
||||
pageIndex= dto.PageIndex,
|
||||
pageSize= dto.PageSize
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
/// <summary>
|
||||
/// 新增用户Cookie
|
||||
/// </summary>
|
||||
[HttpPost("add")]
|
||||
public async Task<IActionResult> AddAsync([FromBody] DyUserCookies dyUserCookies)
|
||||
{
|
||||
if(dyUserCookies.UpSecUserIdsJson!=null)
|
||||
{
|
||||
dyUserCookies.UpSecUserIds = JsonConvert.SerializeObject( dyUserCookies.UpSecUserIdsJson);
|
||||
}
|
||||
var result = await dyCookieService.Add(dyUserCookies);
|
||||
if (result)
|
||||
{
|
||||
await ReStartJob();
|
||||
return Ok(new { code = 0 });
|
||||
}
|
||||
return BadRequest(new { code=-1, message = "添加失败" });
|
||||
total = totalCount,
|
||||
pageIndex = dto.PageIndex,
|
||||
pageSize = dto.PageSize
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 查询所有用户Cookie
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("list")]
|
||||
public async Task<IActionResult> GetAllList()
|
||||
{
|
||||
var follows = await douyinFollowService.GetGroupByCookieAsync();
|
||||
return ApiResult.Success(follows);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否已经初始化了
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("isInit")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> IsInit()
|
||||
{
|
||||
var init = await dyCookieService.IsInit();
|
||||
return ApiResult.Success(init);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 配合工具自动设置cookie
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("FastResetCookie")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> FastResetCookie([FromBody] DouyinCookieResetDto dto)
|
||||
{
|
||||
var cookieValid = await httpClientService.CheckCookie(new DouyinCookie { Cookies = dto.cookie });
|
||||
if (!cookieValid)
|
||||
return ApiResult.Fail("Cookie无效或已过期,请按照文档提示重新获取有效Cookie,不要使用插件获取cookie");
|
||||
var result = await dyCookieService.FastResetCookie(dto.id, dto.cookie);
|
||||
|
||||
return result ? ApiResult.Success() : ApiResult.Fail("cookie设置失败");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配合工具自动设置cookie,获取当前设置的所有Cookie
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("Cookies")]
|
||||
public async Task<IActionResult> GetAllCookies()
|
||||
{
|
||||
var cookies = await dyCookieService.GetAllAsync();
|
||||
if (cookies != null)
|
||||
return ApiResult.Success(cookies.Select(x => new { id= x.Id,name= x.UserName, status=x.StatusMsg }));
|
||||
return ApiResult.Success();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 非docker初始化
|
||||
/// </summary>
|
||||
[HttpPost("deskinit")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> DeskInitAsync([FromBody] DouyinCookie dyUserCookies)
|
||||
{
|
||||
// 1. 基础赋值
|
||||
dyUserCookies.Id = IdGener.GetLong().ToString();
|
||||
|
||||
// 2. 路径权限校验
|
||||
var (Success, Message) = ValidatePaths(dyUserCookies);
|
||||
if (!Success)
|
||||
return ApiResult.Fail(Message);
|
||||
|
||||
RemoveCookieLineString(dyUserCookies);
|
||||
|
||||
// 3. Cookie 有效性校验
|
||||
var cookieValid = await httpClientService.CheckCookie(dyUserCookies);
|
||||
if (!cookieValid)
|
||||
return ApiResult.Fail("Cookie无效或已过期,请按照文档提示重新获取有效Cookie,不要使用插件获取cookie");
|
||||
|
||||
dyUserCookies.StatusCode = 0;
|
||||
dyUserCookies.StatusMsg = "正常";
|
||||
// 4. 保存到数据库
|
||||
var saved = await dyCookieService.Add(dyUserCookies);
|
||||
return saved ? ApiResult.Success() : ApiResult.Fail("添加失败");
|
||||
}
|
||||
|
||||
private static (bool Success, string Message) ValidatePaths(DouyinCookie cookie)
|
||||
{
|
||||
var pathsToCheck = new Dictionary<string, string>
|
||||
{
|
||||
{ "收藏存储路径", cookie.SavePath },
|
||||
{ "喜欢视频存储路径", cookie.FavSavePath },
|
||||
{ "上传视频存储路径", cookie.UpSavePath },
|
||||
// { "图片存储路径", cookie.ImgSavePath } // 可随时启用
|
||||
};
|
||||
|
||||
foreach (var (label, path) in pathsToCheck)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
if (!DouyinFileUtils.HasDirectoryReadWritePermission(path))
|
||||
{
|
||||
return (false, $"请在飞牛应用设置里面将 {path} 添加读写权限({label})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cookie.SavePath))
|
||||
{
|
||||
return (false, "收藏存储路径不能为空");
|
||||
}
|
||||
|
||||
return (true, string.Empty);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 快速开启或停止
|
||||
/// </summary>
|
||||
[HttpPost("switch")]
|
||||
public async Task<IActionResult> SwitchAsync([FromBody] DouyinCookieSwitchDto dto)
|
||||
{
|
||||
var result = await dyCookieService.Switch(dto);
|
||||
if (result)
|
||||
{
|
||||
ReStartJob();
|
||||
return ApiResult.Success();
|
||||
}
|
||||
return ApiResult.Fail("添加失败");
|
||||
}
|
||||
/// <summary>
|
||||
/// 更新用户Cookie
|
||||
/// </summary>
|
||||
[HttpPost("update")]
|
||||
public async Task<IActionResult> UpdateAsync([FromBody] DyUserCookies dyUserCookies)
|
||||
public async Task<IActionResult> AddOrUpdateAsync([FromBody] DouyinCookie dyUserCookies)
|
||||
{
|
||||
RemoveCookieLineString(dyUserCookies);
|
||||
|
||||
if (dyUserCookies.UpSecUserIdsJson != null )
|
||||
var checkCk = await httpClientService.CheckCookie(dyUserCookies);
|
||||
if (!checkCk)
|
||||
{
|
||||
dyUserCookies.UpSecUserIds = JsonConvert.SerializeObject(dyUserCookies.UpSecUserIdsJson);
|
||||
return ApiResult.Fail("Cookie无效或已过期,请按照文档提示重新获取有效Cookie,不要使用插件获取cookie");
|
||||
}
|
||||
|
||||
dyUserCookies.StatusCode = 0;
|
||||
dyUserCookies.StatusMsg = "正常";
|
||||
if (dyUserCookies.Id == "0")
|
||||
{
|
||||
dyUserCookies.Id=IdGener.GetLong().ToString();
|
||||
dyUserCookies.Id = IdGener.GetLong().ToString();
|
||||
var result = await dyCookieService.Add(dyUserCookies);
|
||||
if (result)
|
||||
{
|
||||
await ReStartJob();
|
||||
return Ok(new { code = 0 });
|
||||
ReStartJob();
|
||||
return ApiResult.Success();
|
||||
}
|
||||
return BadRequest(new { code = -1, message = "添加失败" });
|
||||
return ApiResult.Fail("添加失败");
|
||||
}
|
||||
else {
|
||||
var result = await dyCookieService.UpdateAsync(dyUserCookies);
|
||||
else
|
||||
{
|
||||
var result = await dyCookieService.UpdateCookieAsync(dyUserCookies);
|
||||
if (result)
|
||||
{
|
||||
await ReStartJob();
|
||||
return Ok(new { code = 0 });
|
||||
ReStartJob();
|
||||
return ApiResult.Success();
|
||||
}
|
||||
return BadRequest(new { code = -1, message = "更新失败" });
|
||||
return ApiResult.Fail("更新失败");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static void RemoveCookieLineString(DouyinCookie dyUserCookies)
|
||||
{
|
||||
if (dyUserCookies != null && !string.IsNullOrWhiteSpace(dyUserCookies.Cookies))
|
||||
{
|
||||
var s = dyUserCookies.Cookies.Replace("\\r\\n", "\r\n");
|
||||
var ss = s.Trim(new char[] { '\r', '\n' });
|
||||
dyUserCookies.Cookies = ss;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量删除用户Cookie
|
||||
/// </summary>
|
||||
[HttpGet("delete")]
|
||||
public async Task<IActionResult> DeleteAsync(string id)
|
||||
{
|
||||
var count = await dyCookieService.DeleteByIdsAsync(new List<string> { id});
|
||||
if (count > 0) {
|
||||
await ReStartJob();
|
||||
var count = await dyCookieService.DeleteByIdsAsync(new List<string> { id });
|
||||
if (count > 0)
|
||||
{
|
||||
ReStartJob();
|
||||
}
|
||||
return Ok(new { code = 0, deletedCount = count });
|
||||
return ApiResult.Success(count);
|
||||
}
|
||||
|
||||
[HttpGet("GetConfig")]
|
||||
public IActionResult GetConfig()
|
||||
{
|
||||
var data = commonService.GetConfig();
|
||||
return Ok(new { code = 0, data = data });
|
||||
return ApiResult.Success(data);
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("UpdateConfig")]
|
||||
public async Task<IActionResult> UpdateConfig(AppConfig config)
|
||||
{
|
||||
var data = await commonService.UpdateConfig(config);
|
||||
if (data) {
|
||||
await ReStartJob();
|
||||
var update = await commonService.UpdateConfig(config);
|
||||
if (update)
|
||||
{
|
||||
if (config.OnlySyncNew)
|
||||
{
|
||||
Serilog.Log.Debug("仅同步新视频配置已生效,后续所有类型的视频同步将只会读取最近一页约20条数据");
|
||||
}
|
||||
return Ok(new { code = 0, data = data });
|
||||
|
||||
ReStartJob();
|
||||
}
|
||||
return ApiResult.Success(update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -140,31 +346,142 @@ namespace dy.net.Controllers
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("ExecuteJobNow")]
|
||||
[Authorize]
|
||||
//[Authorize]
|
||||
public async Task<IActionResult> ExecuteJobNow()
|
||||
{
|
||||
var config = commonService.GetConfig();
|
||||
await quartzJobService.StartJob(config.Cron);
|
||||
return Ok(new { code = 0 , error = "" });
|
||||
if (config != null)
|
||||
await quartzJobService.InitOrReStartAllJobs(config.Cron.ToString());
|
||||
return ApiResult.Success();
|
||||
}
|
||||
|
||||
|
||||
private async Task ReStartJob() {
|
||||
var config= commonService.GetConfig();
|
||||
|
||||
//var cookies = await dyCookieService.GetAllCookies();
|
||||
//重置同步状态
|
||||
//foreach (var cookie in cookies)
|
||||
//{
|
||||
// cookie.CollHasSyncd = 0;
|
||||
// cookie.FavHasSyncd = 0;
|
||||
// cookie.UperSyncd = 0;
|
||||
// await dyCookieService.UpdateAsync(cookie);
|
||||
//}
|
||||
|
||||
if (config!=null)
|
||||
quartzJobService.StartJob(config.Cron);
|
||||
private void ReStartJob()
|
||||
{
|
||||
var config = commonService.GetConfig();
|
||||
if (config != null)
|
||||
quartzJobService.InitOrReStartAllJobs(config.Cron.ToString());
|
||||
//避免前端等待
|
||||
}
|
||||
/// <summary>
|
||||
/// 镜像标签
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("mytag")]
|
||||
public async Task<IActionResult> GetMyTag()
|
||||
{
|
||||
var deploy = Appsettings.Get("deploy");
|
||||
var tag = Appsettings.Get("tagName");
|
||||
tag = deploy == "fn" ? "fn_" + Appsettings.Get("fnVersion") : tag;
|
||||
deploy = deploy == "fn" ? "fnos" : "docker";
|
||||
return ApiResult.Success(new { tag, deploy });
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("checktag")]
|
||||
public async Task<IActionResult> CheckTag()
|
||||
{
|
||||
|
||||
var deploy = Appsettings.Get("deploy");
|
||||
if (string.IsNullOrWhiteSpace(deploy))
|
||||
{
|
||||
return await GetDockerTagVersions();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (deploy == "fn")//飞牛
|
||||
{
|
||||
return ApiResult.Success(new List<string> { "fn_" + Appsettings.Get("fnVersion") });
|
||||
}
|
||||
else
|
||||
{
|
||||
return await GetDockerTagVersions();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private static async Task<IActionResult> GetDockerTagVersions()
|
||||
{
|
||||
var data = await JianZhiChuHttpHelper.GetTenImage(Appsettings.Get("tagName"));
|
||||
if (data.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await data.Content.ReadAsStringAsync();
|
||||
|
||||
var tagData = JsonConvert.DeserializeObject<DouyinApiResponse<List<string>>>(content);
|
||||
if (tagData != null && tagData.Data != null && tagData.Data.Count > 0)
|
||||
return ApiResult.Success(tagData.Data);
|
||||
return ApiResult.Fail();
|
||||
}
|
||||
else
|
||||
{
|
||||
return ApiResult.Fail("请求失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询mp3目录下有没有音频文件
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("mp3List")]
|
||||
public async Task<IActionResult> GetExistMps()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "mp3");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ServiceExtension.FnDataFolder))
|
||||
{
|
||||
path = ServiceExtension.FnDataFolder;
|
||||
}
|
||||
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
var allowedExtensions = new HashSet<string> { ".mp3", ".wav" };
|
||||
|
||||
var customMusics = Directory.GetFiles(path)
|
||||
.Where(filePath =>
|
||||
allowedExtensions.Contains(Path.GetExtension(filePath).ToLowerInvariant()) &&
|
||||
Path.GetFileNameWithoutExtension(filePath) != "silent_10")
|
||||
.ToList();
|
||||
|
||||
var fileNames = customMusics.Select(f => new { filename = Path.GetFileName(f) }).Where(x => x.filename != "silent_10.mp3").ToList();
|
||||
return ApiResult.Success(fileNames);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ApiResult.Fail("没有找到默认音频文件");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放音频流
|
||||
/// </summary>
|
||||
/// <param name="name"></param>
|
||||
/// <returns></returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("getmp3")]
|
||||
public async Task<IActionResult> GetMp3([FromQuery] string name)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "mp3", name);
|
||||
if (!string.IsNullOrWhiteSpace(ServiceExtension.FnDataFolder))
|
||||
{
|
||||
path = Path.Combine(ServiceExtension.FnDataFolder, name);
|
||||
}
|
||||
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
//返回mp3文件流
|
||||
var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read);
|
||||
return new FileStreamResult(fileStream, "audio/mpeg")
|
||||
{
|
||||
FileDownloadName = name
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return ApiResult.Fail("文件不存在");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using dy.net.model.dto;
|
||||
using dy.net.model.entity;
|
||||
using dy.net.service;
|
||||
using dy.net.utils;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace dy.net.Controllers
|
||||
{
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class FollowController : ControllerBase
|
||||
{
|
||||
private readonly DouyinFollowService _douyinFollowService;
|
||||
private readonly DouyinQuartzJobService _douyinQuartzJobService;
|
||||
|
||||
public FollowController(DouyinFollowService douyinFollowService, DouyinQuartzJobService douyinQuartzJobService)
|
||||
{
|
||||
this._douyinFollowService = douyinFollowService;
|
||||
_douyinQuartzJobService = douyinQuartzJobService;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询
|
||||
/// </summary>
|
||||
/// <returns>分页结果</returns>
|
||||
[HttpPost("paged")]
|
||||
public async Task<IActionResult> GetPagedAsync(
|
||||
FollowRequestDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.MySelfId))
|
||||
{
|
||||
return ApiResult.Success(new { });
|
||||
}
|
||||
var (list, totalCount) = await _douyinFollowService.GetPagedAsync(dto);
|
||||
|
||||
return ApiResult.Success(new
|
||||
{
|
||||
data = list,
|
||||
total = totalCount,
|
||||
pageIndex = dto.PageIndex,
|
||||
pageSize = dto.PageSize
|
||||
});
|
||||
}
|
||||
/// <summary>
|
||||
/// 重新同步-单次
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("sync")]
|
||||
public async Task<IActionResult> SyncFollowList()
|
||||
{
|
||||
//后台异步
|
||||
_douyinQuartzJobService.StartFollowJobOnceAsync();
|
||||
await Task.Delay(1000);
|
||||
return ApiResult.Success();
|
||||
}
|
||||
[HttpPost("add")]
|
||||
public async Task<IActionResult> AddFollow(DouyinFollowed followed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(followed.mySelfId))
|
||||
{
|
||||
return ApiResult.Fail("请先配置抖音授权信息,抖音授权配置里面填写你的uid");
|
||||
}
|
||||
var res = await _douyinFollowService.AddAsync(followed);
|
||||
return ApiResult.SuccOrFail(res, "", res ? "" : "添加失败,或者已存在相同secuid和uid");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改关注同步状态
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("openOrCloseSync")]
|
||||
public async Task<IActionResult> OpenOrCloseSync(FollowUpdateDto dto)
|
||||
{
|
||||
|
||||
if (dto.OpenSync)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(dto.SavePath))
|
||||
{
|
||||
if (!DouyinFileNameHelper.IsValidWithoutSpecialChars(dto.SavePath))
|
||||
{
|
||||
return ApiResult.Fail("请输入有效文件夹名称(字母数字中文简体)");
|
||||
}
|
||||
|
||||
if (dto.SavePath.Length > 20)
|
||||
{
|
||||
return ApiResult.Fail("请输入有效文件夹名称(最长20)");
|
||||
}
|
||||
}
|
||||
}
|
||||
var result = await _douyinFollowService.OpenOrCloseSync(dto);
|
||||
return ApiResult.SuccOrFail(result, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改关注全量同步状态
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("openOrCloseFullSync")]
|
||||
public async Task<IActionResult> OpenOrCloseFullSync(FollowUpdateDto dto)
|
||||
{
|
||||
return await OpenOrCloseSync(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除关注对象
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("delete")]
|
||||
public async Task<IActionResult> DeleteFollow(FollowUpdateDto dto)
|
||||
{
|
||||
var result = await _douyinFollowService.DeleteFollow(dto);
|
||||
return ApiResult.SuccOrFail(result, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using dy.net.model.dto;
|
||||
using dy.net.service;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace dy.net.Controllers
|
||||
{
|
||||
@@ -9,19 +9,21 @@ namespace dy.net.Controllers
|
||||
public class LogsController : ControllerBase
|
||||
{
|
||||
private readonly IWebHostEnvironment webHostEnvironment;
|
||||
private readonly LogInfoService logInfoService;
|
||||
|
||||
public LogsController(IWebHostEnvironment webHostEnvironment)
|
||||
public LogsController(IWebHostEnvironment webHostEnvironment, LogInfoService logInfoService)
|
||||
{
|
||||
this.webHostEnvironment = webHostEnvironment;
|
||||
this.logInfoService = logInfoService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetLog(string type, string date)
|
||||
[HttpGet("/api/logs/GetLog/{type}/{date}")]
|
||||
public async Task<IActionResult> GetLog([FromRoute] string type, [FromRoute] string date)
|
||||
{
|
||||
var filePath = Path.Combine(webHostEnvironment.IsDevelopment() ? Directory.GetCurrentDirectory() : AppDomain.CurrentDomain.BaseDirectory, "logs", $"log-{type}-{date}.txt");
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
{
|
||||
var msg = $"Log file log-{type}-{date}.txt not found.";
|
||||
var msg = $"{date},没有发现{type}的日志";
|
||||
//Serilog.Log.Error(msg);
|
||||
return Ok(msg);
|
||||
}
|
||||
@@ -35,5 +37,57 @@ namespace dy.net.Controllers
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 获取最近10天的日志文件列表
|
||||
/// </summary>
|
||||
[HttpGet("/api/logs/list")]
|
||||
public IActionResult GetLogFiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
var _logDirectory = Path.Combine(Directory.GetCurrentDirectory(), "logs");
|
||||
var files = logInfoService.GetLogFiles(_logDirectory);
|
||||
return ApiResult.Success(files);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ApiResult.Fail("获取日志列表失败," + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 获取日志文件内容流
|
||||
/// </summary>
|
||||
[HttpGet("/api/logs/content")]
|
||||
public IActionResult GetLogContent([FromQuery] LogContentRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
try
|
||||
{
|
||||
var _logDirectory = Path.Combine(Directory.GetCurrentDirectory(), "logs");
|
||||
var stream = logInfoService.GetLogFileStream(_logDirectory, request.Type, request.Date);
|
||||
// 返回文件流,指定MIME类型为文本
|
||||
return File(stream, "text/plain", $"log-{request.Type}-{request.Date}.txt");
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { message = ex.Message });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { message = "读取日志文件失败", error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using dy.net.dto;
|
||||
using dy.net.model.dto;
|
||||
using dy.net.model.entity;
|
||||
using dy.net.service;
|
||||
using dy.net.utils;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using System.Drawing.Printing;
|
||||
|
||||
namespace dy.net.Controllers
|
||||
{
|
||||
@@ -12,30 +11,29 @@ namespace dy.net.Controllers
|
||||
[ApiController]
|
||||
public class VideoController : ControllerBase
|
||||
{
|
||||
private readonly DyCollectVideoService dyCollectVideoService;
|
||||
private readonly DouyinVideoService douyinVideoService;
|
||||
private readonly DouyinCommonService douyinCommonService;
|
||||
|
||||
public VideoController(DyCollectVideoService dyCollectVideoService)
|
||||
public VideoController(DouyinVideoService dyCollectVideoService, DouyinCommonService douyinCommonService)
|
||||
{
|
||||
this.dyCollectVideoService = dyCollectVideoService;
|
||||
this.douyinVideoService = dyCollectVideoService;
|
||||
this.douyinCommonService = douyinCommonService;
|
||||
}
|
||||
/// <summary>
|
||||
/// 分页查询收藏视频
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
[Authorize]
|
||||
[HttpPost("paged")]
|
||||
public async Task<IActionResult> GetPagedAsync(VideoPageRequestDTO dto)
|
||||
public async Task<IActionResult> GetPagedAsync(DouyinVideoPageRequestDto dto)
|
||||
{
|
||||
var (list, totalCount) = await dyCollectVideoService.GetPagedAsync(dto.PageIndex, dto.PageSize, dto.Tag, dto.Author,dto.ViedoType,dto.Dates);
|
||||
return Ok(new
|
||||
{
|
||||
code = 0,
|
||||
data = new
|
||||
var (list, totalCount) = await douyinVideoService.GetPagedAsync(dto);
|
||||
return ApiResult.Success(new
|
||||
{
|
||||
data = list,
|
||||
total=totalCount,
|
||||
pageIndex=dto.PageIndex,
|
||||
pageSize=dto.PageSize
|
||||
}
|
||||
total = totalCount,
|
||||
pageIndex = dto.PageIndex,
|
||||
pageSize = dto.PageSize
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,38 +41,503 @@ namespace dy.net.Controllers
|
||||
/// 查询统计数据
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[Authorize]
|
||||
[HttpGet("statics")]
|
||||
public async Task<IActionResult> GetStaticsAsync()
|
||||
{
|
||||
var data = await dyCollectVideoService.GetStatics();
|
||||
return Ok(new
|
||||
{
|
||||
code = 0,
|
||||
data
|
||||
});
|
||||
return ApiResult.Success(await douyinVideoService.GetStatics());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放视频
|
||||
/// </summary>
|
||||
/// <param name="vid"></param>
|
||||
/// <returns></returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
public IActionResult Index()
|
||||
[HttpGet("play/{vid}")]
|
||||
public async Task<IActionResult> StreamVideo([FromRoute] string vid)
|
||||
{
|
||||
List<MyClass> myClasses = new List<MyClass>();
|
||||
foreach (var drive in DriveInfo.GetDrives())
|
||||
try
|
||||
{
|
||||
myClasses.Add(new MyClass { name=drive.Name, x1= drive.TotalSize, x2= drive.TotalFreeSpace });
|
||||
Serilog.Log.Debug($"Drive: {drive.Name}, Total Size: {drive.TotalSize}, Free Space: {drive.TotalFreeSpace}");
|
||||
var viedo = await douyinVideoService.GetById(vid);
|
||||
|
||||
if (viedo == null)
|
||||
{
|
||||
return ApiResult.Fail($"视频不存在:{vid}");
|
||||
}
|
||||
return await PlayVideoAsync(viedo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ApiResult.Fail($"视频加载失败:{ex.Message}");
|
||||
}
|
||||
//var disk=DiskInfoHelper.GetDockerHostTotalDiskSpaceGB();
|
||||
return Ok(JsonConvert.SerializeObject(myClasses)+"\r\n"+JsonConvert.SerializeObject(myClasses.Sum(x=>x.x1)+"\r\n"+ JsonConvert.SerializeObject(myClasses.Sum(x => x.x2))));
|
||||
}
|
||||
|
||||
|
||||
public class MyClass
|
||||
/// <summary>
|
||||
/// 抖音视频播放接口(优化版)
|
||||
/// 解决内存泄漏、资源释放不及时、鲁棒性不足等问题
|
||||
/// </summary>
|
||||
/// <param name="video">视频实体(修正拼写错误:viedo -> video)</param>
|
||||
/// <returns>视频流响应</returns>
|
||||
public async Task<IActionResult> PlayVideoAsync(DouyinVideo video)
|
||||
{
|
||||
public string name { get; set; }
|
||||
// 空值校验
|
||||
if (video == null)
|
||||
{
|
||||
return ApiResult.Fail("视频信息不能为空");
|
||||
}
|
||||
|
||||
public long x1 { get; set; }
|
||||
// 1. 获取完整物理路径并校验
|
||||
string videoFullPath = video.VideoSavePath;
|
||||
if (string.IsNullOrWhiteSpace(videoFullPath))
|
||||
{
|
||||
return ApiResult.Fail("视频保存路径不能为空");
|
||||
}
|
||||
|
||||
public long x2 { get; set; }
|
||||
// 安全校验:防止路径遍历攻击
|
||||
try
|
||||
{
|
||||
videoFullPath = Path.GetFullPath(videoFullPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ApiResult.Fail("视频路径格式非法");
|
||||
}
|
||||
|
||||
// 2. 验证文件是否存在
|
||||
if (!System.IO.File.Exists(videoFullPath))
|
||||
{
|
||||
return ApiResult.Fail($"视频文件不存在:{videoFullPath}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 3. 获取文件信息(使用using确保FileInfo资源释放)
|
||||
var fileInfo = new FileInfo(videoFullPath);
|
||||
long fileSize = fileInfo.Length;
|
||||
|
||||
// 校验空文件
|
||||
if (fileSize == 0)
|
||||
{
|
||||
return ApiResult.Fail($"视频文件为空:{videoFullPath}");
|
||||
}
|
||||
|
||||
string contentType = GetContentType(videoFullPath);
|
||||
|
||||
// 4. 处理分片请求(Range)
|
||||
if (Request.Headers.ContainsKey("Range"))
|
||||
{
|
||||
return await HandleRangeRequestAsync(videoFullPath, fileSize, contentType);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 完整文件请求(启用分片处理,兼容前端断点续传)
|
||||
return PhysicalFile(videoFullPath, contentType, enableRangeProcessing: true);
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return ApiResult.Fail($"没有权限访问视频文件:{videoFullPath}");
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
// 记录日志(建议添加日志框架,如Serilog/NLog)
|
||||
// _logger.LogError(ex, "读取视频文件失败:{Path}", videoFullPath);
|
||||
return ApiResult.Fail($"读取视频文件失败:{ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// _logger.LogError(ex, "视频播放接口异常:{Path}", videoFullPath);
|
||||
return ApiResult.Fail($"服务器内部错误:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理分片请求(Range),异步安全处理流
|
||||
/// </summary>
|
||||
/// <param name="filePath">文件路径</param>
|
||||
/// <param name="fileSize">文件总大小</param>
|
||||
/// <param name="contentType">内容类型</param>
|
||||
/// <returns>分片响应</returns>
|
||||
private async Task<IActionResult> HandleRangeRequestAsync(string filePath, long fileSize, string contentType)
|
||||
{
|
||||
string rangeHeader = Request.Headers["Range"].ToString();
|
||||
|
||||
// 安全解析Range头
|
||||
if (!rangeHeader.StartsWith("bytes="))
|
||||
{
|
||||
// 416 - 请求的范围无法满足
|
||||
Response.StatusCode = StatusCodes.Status416RequestedRangeNotSatisfiable;
|
||||
Response.Headers.Add("Content-Range", $"bytes */{fileSize}");
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
// 解析起始位置
|
||||
string[] rangeParts = rangeHeader.Split('=')[1].Split('-');
|
||||
if (!long.TryParse(rangeParts[0], out long start) || start < 0 || start >= fileSize)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status416RequestedRangeNotSatisfiable;
|
||||
Response.Headers.Add("Content-Range", $"bytes */{fileSize}");
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
// 计算分片结束位置(每片2MB,可配置)
|
||||
long end = Math.Min(start + 1024 * 1024 * 2, fileSize - 1);
|
||||
long chunkSize = end - start + 1;
|
||||
|
||||
// 设置206分片响应头
|
||||
Response.StatusCode = StatusCodes.Status206PartialContent;
|
||||
Response.Headers.Add("Content-Range", $"bytes {start}-{end}/{fileSize}");
|
||||
Response.Headers.Add("Accept-Ranges", "bytes");
|
||||
Response.Headers.Add("Content-Length", chunkSize.ToString());
|
||||
Response.ContentType = contentType;
|
||||
|
||||
// 核心改进:使用异步流 + using确保释放(通过管道直接写入响应流)
|
||||
await using var fileStream = new FileStream(
|
||||
filePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 8192, // 增大缓冲区提升性能,减少IO次数
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan); // 顺序扫描优化
|
||||
|
||||
// 定位到分片起始位置
|
||||
fileStream.Seek(start, SeekOrigin.Begin);
|
||||
|
||||
// 直接写入响应流,避免FileStreamResult的延迟释放问题
|
||||
var buffer = new byte[8192];
|
||||
long remainingBytes = chunkSize;
|
||||
|
||||
while (remainingBytes > 0)
|
||||
{
|
||||
int bytesRead = await fileStream.ReadAsync(buffer, 0, (int)Math.Min(remainingBytes, buffer.Length));
|
||||
if (bytesRead == 0) break;
|
||||
|
||||
await Response.Body.WriteAsync(buffer.AsMemory(0, bytesRead));
|
||||
remainingBytes -= bytesRead;
|
||||
}
|
||||
|
||||
await Response.Body.FlushAsync();
|
||||
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取视频封面图片
|
||||
/// </summary>
|
||||
/// <param name="id">视频ID</param>
|
||||
[Authorize]
|
||||
[HttpGet("cover/{id}")]
|
||||
public async Task<IActionResult> GetVideoCover([FromRoute] string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var video = await douyinVideoService.GetById(id);
|
||||
if (video == null)
|
||||
{
|
||||
return GetDefaultCoverImage();
|
||||
}
|
||||
|
||||
// 优先使用本地保存的封面
|
||||
if (!string.IsNullOrWhiteSpace(video.VideoCoverSavePath) && System.IO.File.Exists(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>
|
||||
/// 播放视频
|
||||
/// </summary>
|
||||
/// <param name="vid"></param>
|
||||
/// <param name="k"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("/share/{vid}/{k}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Share([FromRoute] string vid, [FromRoute] string k)
|
||||
{
|
||||
try
|
||||
{
|
||||
var viedo = await douyinVideoService.GetById(vid);
|
||||
|
||||
if (viedo == null)
|
||||
{
|
||||
return ApiResult.Fail($"视频不存在:{vid}");
|
||||
}
|
||||
|
||||
var expectedKey = (viedo.FileHash + viedo.AuthorId).Md5();
|
||||
if (expectedKey != k)
|
||||
{
|
||||
return ApiResult.Fail($"视频地址无效");
|
||||
}
|
||||
|
||||
return await PlayVideoAsync(viedo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ApiResult.Fail($"视频加载失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 辅助方法:根据文件名获取 MIME 类型(确保前端正确识别视频格式)
|
||||
/// </summary>
|
||||
private static string GetContentType(string filename)
|
||||
{
|
||||
string extension = Path.GetExtension(filename).ToLowerInvariant();
|
||||
return extension switch
|
||||
{
|
||||
".mp4" => "video/mp4",
|
||||
_ => "application/octet-stream" // 默认二进制流
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 重新下载
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("redown")]
|
||||
public async Task<IActionResult> ReDownload(ReDownViedoDto dto)
|
||||
{
|
||||
if (dto == null)
|
||||
{
|
||||
return ApiResult.Fail("参数错误");
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = await douyinVideoService.ReDownloadViedoAsync(dto);
|
||||
if (result)
|
||||
{
|
||||
return ApiResult.Success(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ApiResult.Fail("错误");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量删除
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("vdelete/batch")]
|
||||
public async Task<IActionResult> BathRealDelete(ReDownViedoDto dto)
|
||||
{
|
||||
var result = await douyinVideoService.RealDeleteVideos(dto.Ids);
|
||||
if (result)
|
||||
{
|
||||
return ApiResult.Success(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ApiResult.Fail("错误");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 删除视频-不再下载
|
||||
/// </summary>
|
||||
/// <param name="vid"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("vdelete/{vid}")]
|
||||
public async Task<IActionResult> DeleteVideo([FromRoute] string vid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vid))
|
||||
{
|
||||
return ApiResult.Fail("参数错误");
|
||||
}
|
||||
else
|
||||
{
|
||||
var res = await douyinVideoService.RealDeleteVideos(new List<string> { vid });
|
||||
if (res)
|
||||
{
|
||||
return ApiResult.Success("删除成功");
|
||||
}
|
||||
else
|
||||
{
|
||||
return ApiResult.Fail("删除失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询已删除视频列表
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("vdelete/get")]
|
||||
public async Task<IActionResult> GetDeleteVideo()
|
||||
{
|
||||
return ApiResult.Success(await douyinCommonService.GetDouyinDeleteVideos());
|
||||
}
|
||||
/// <summary>
|
||||
/// 根据博主id删除博主所有视频
|
||||
/// </summary>
|
||||
/// <param name="uperUid"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("vdelete/byauthor/{uperUid}")]
|
||||
public async Task<IActionResult> DeleteByAuthor([FromRoute] string uperUid)
|
||||
{
|
||||
var videos = await douyinVideoService.GetByAuthorId(uperUid);
|
||||
if (videos != null && videos.Any())
|
||||
{
|
||||
var res = await douyinVideoService.RealDeleteVideos(videos.Select(x => x.Id).ToList());
|
||||
if (res)
|
||||
{
|
||||
return ApiResult.Success("删除成功");
|
||||
}
|
||||
else
|
||||
{
|
||||
return ApiResult.Fail("删除失败");
|
||||
}
|
||||
}
|
||||
return ApiResult.Fail("未找到该博主视频");
|
||||
}
|
||||
|
||||
//private async Task<(bool flowControl, IActionResult value)> BatchDeleteVideos(List<DouyinVideo> videos)
|
||||
//{
|
||||
|
||||
// return (flowControl: true, value: null);
|
||||
//}
|
||||
|
||||
/// <summary>
|
||||
/// 查询最新N条数据
|
||||
/// </summary>
|
||||
/// <param name="top"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("top{top}")]
|
||||
public async Task<IActionResult> GetLastSyncTop([FromRoute] int top = 5)
|
||||
{
|
||||
return ApiResult.Success(await douyinVideoService.GetLastSyncTop(top));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除无效视频记录
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("removeInvalid")]
|
||||
public async Task<IActionResult> RemoveInvalidVideo()
|
||||
{
|
||||
var data = await douyinVideoService.DeleteInvalidVideo();
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
///// <summary>
|
||||
///// 所有视频重新生成nfo文件
|
||||
///// </summary>
|
||||
///// <returns></returns>
|
||||
//[HttpGet("renfo")]
|
||||
//public async Task<IActionResult> ReCreateNfo()
|
||||
//{
|
||||
// var videos = await douyinVideoService.GetAllAsync();
|
||||
// if (videos == null || videos.Count == 0)
|
||||
// {
|
||||
// return ApiResult.Success("暂无视频数据需要生成NFO文件");
|
||||
// }
|
||||
// _ = Task.Run(async () =>
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// var totalCount = videos.Count;
|
||||
// foreach (var video in videos)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// NfoFileGenerator.GenerateVideoNfoFile(video);
|
||||
// Serilog.Log.Debug($"刮削视频(Path:{video.VideoSavePath})生成NFO成功!");
|
||||
// await Task.Delay(50);
|
||||
// }
|
||||
// catch (Exception singleEx)
|
||||
// {
|
||||
// Serilog.Log.Error($"刮削视频(Path:{video.VideoSavePath})生成NFO失败:{singleEx.Message}");
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Serilog.Log.Error($"NFO文件生成任务执行异常:{ex.Message}\n{ex.StackTrace}");
|
||||
// }
|
||||
// });
|
||||
|
||||
// return ApiResult.Success();
|
||||
//}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 获取7天视频同步趋势数据(曲线图)
|
||||
/// </summary>
|
||||
/// <returns>7天图表数据列表</returns>
|
||||
[HttpGet("chart/{day}")]
|
||||
public async Task<IActionResult> Chart([FromRoute] int day = 7)
|
||||
{
|
||||
try
|
||||
{
|
||||
var chartData = await douyinVideoService.GetChartData(day);
|
||||
return ApiResult.Success(chartData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ApiResult.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("/Move")]
|
||||
public async Task<IActionResult> Move()
|
||||
{
|
||||
await douyinVideoService.HandOldFolderVideos();
|
||||
|
||||
return ApiResult.Success(DateTime.Now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,18 @@ FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 10101
|
||||
|
||||
|
||||
RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main non-free contrib" > /etc/apt/sources.list && \
|
||||
echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main" >> /etc/apt/sources.list && \
|
||||
echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main non-free contrib" >> /etc/apt/sources.list && \
|
||||
echo "deb http://mirrors.aliyun.com/debian/ bookworm-backports main non-free contrib" >> /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN ffmpeg -version
|
||||
|
||||
COPY . .
|
||||
ENV ASPNETCORE_URLS http://*:10101
|
||||
ENTRYPOINT ["dotnet", "dy.net.dll"]
|
||||
ENV ASPNETCORE_URLS=http://*:10101
|
||||
ENV TZ=Asia/Shanghai
|
||||
ENTRYPOINT ["dotnet", "dy.net.dll"]
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
#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
|
||||
# 使用 Ubuntu 20.04 的 .NET 6 运行时镜像
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:6.0-focal AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 10101
|
||||
|
||||
# 安装 ffmpeg (Ubuntu 的 universe 仓库包含 ffmpeg)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN ffmpeg -version
|
||||
|
||||
COPY . .
|
||||
ENV ASPNETCORE_URLS http://*:10101
|
||||
ENTRYPOINT ["dotnet", "dy.net.dll"]
|
||||
|
||||
ENV ASPNETCORE_URLS=http://*:10101
|
||||
ENV TZ=Asia/Shanghai
|
||||
ENTRYPOINT ["dotnet", "dy.net.dll"]
|
||||
@@ -0,0 +1,52 @@
|
||||
# 多阶段构建 Dockerfile
|
||||
# 第一阶段:构建前端
|
||||
FROM node:18-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY app/package.json app/pnpm-lock.yaml* ./
|
||||
|
||||
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 --no-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 . .
|
||||
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
|
||||
|
||||
# 第三阶段:运行镜像(Alpine 版:apk 包更小、代理下更易通过)
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine AS final
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN apk add --no-cache tzdata ffmpeg && \
|
||||
cp /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,14 +1,7 @@
|
||||
using ClockSnowFlake;
|
||||
using dy.net.dto;
|
||||
using dy.net.extension;
|
||||
using dy.net.model;
|
||||
using dy.net.extension;
|
||||
using dy.net.service;
|
||||
using dy.net.utils;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Serilog;
|
||||
using SqlSugar;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
@@ -16,136 +9,190 @@ namespace dy.net
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
// 常量定义
|
||||
private const string DefaultListenUrl = "http://*:10101";
|
||||
// 常量定义
|
||||
private static readonly string DefaultListenUrl = "http://*:10101";
|
||||
private const string SpaRootPath = "app/dist";
|
||||
private const string SpaSourcePath = "app/";
|
||||
private const string SwaggerDocTitle = "dy.net WebApi Docs";
|
||||
private const string SwaggerDocTitle = "dysync.net WebApi Docs";
|
||||
|
||||
public static void Main(string[] args)
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
// 初始化编码提供器
|
||||
// 初始化编码提供器
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
|
||||
// 构建Web应用
|
||||
// 构建Web应用
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var isDevelopment = builder.Environment.IsDevelopment();
|
||||
|
||||
// 配置主机
|
||||
// 配置主机
|
||||
ConfigureHost(builder, isDevelopment);
|
||||
|
||||
// 配置服务
|
||||
var services = builder.Services;
|
||||
ConfigureServices(services, builder.Configuration, isDevelopment);
|
||||
|
||||
// 构建应用
|
||||
// 配置服务--数据库保存路径从命令行参数传入的第一个参数
|
||||
string dbPath = args.Length > 0 ? args[0] : "";
|
||||
ConfigureServices(builder.Services, builder.Configuration, builder.Environment, dbPath);
|
||||
// 构建应用
|
||||
var app = builder.Build();
|
||||
// 配置中间件
|
||||
ConfigureMiddleware(app, builder.Environment);
|
||||
Log.Debug($"dy.sync app is started successfully on {DefaultListenUrl}");
|
||||
// 初始化应用服务
|
||||
await InitApplicationServices(app, isDevelopment, dbPath);
|
||||
Console.WriteLine();
|
||||
|
||||
// 配置中间件
|
||||
ConfigureMiddleware(app, isDevelopment);
|
||||
|
||||
// 初始化应用服务
|
||||
InitApplicationServices(app);
|
||||
|
||||
// 启动应用
|
||||
Serilog.Log.Debug("dy.net service started successfully");
|
||||
app.Run();
|
||||
await app.RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置主机设置
|
||||
/// 配置主机设置
|
||||
/// </summary>
|
||||
private static void ConfigureHost(WebApplicationBuilder builder, bool isDevelopment)
|
||||
{
|
||||
// 设置监听地址
|
||||
InitAppsettings(builder);
|
||||
//from docker yaml file 环境变量 或者 dockerfile 或appsettings.json
|
||||
// 设置监听地址
|
||||
builder.WebHost.UseUrls(DefaultListenUrl);
|
||||
|
||||
// 配置配置文件
|
||||
builder.Host.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
config.SetBasePath(Directory.GetCurrentDirectory())
|
||||
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
|
||||
.AddEnvironmentVariables();
|
||||
});
|
||||
|
||||
// 配置日志
|
||||
builder.Host.ConfigureLogging(logging => logging.ClearProviders())
|
||||
.UseSerilog();
|
||||
|
||||
// 配置日志
|
||||
builder.ConfigureLogging();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置依赖注入服务
|
||||
/// </summary>
|
||||
private static void ConfigureServices(IServiceCollection services, IConfiguration config, bool isDevelopment)
|
||||
private static void InitAppsettings(WebApplicationBuilder builder)
|
||||
{
|
||||
// 雪花ID生成器
|
||||
services.AddSnowFlakeId(options => options.WorkId = new Random().Next(1, 127));
|
||||
// 配置配置文件
|
||||
builder.Host.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
// 定义配置文件名(统一小写,适配Linux大小写敏感特性)
|
||||
const string configFileName = "appsettings.json";
|
||||
|
||||
// MVC控制器
|
||||
services.AddControllers();
|
||||
// 步骤1:获取多维度的候选基础路径(覆盖不同部署场景)
|
||||
var candidateBasePaths = new List<string>
|
||||
{
|
||||
// 候选1:程序集所在目录(优先,解决工作目录不一致问题)
|
||||
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location),
|
||||
// 候选2:当前工作目录(兜底)
|
||||
Directory.GetCurrentDirectory(),
|
||||
// 候选3:应用根目录(针对单文件发布/容器部署场景)
|
||||
AppContext.BaseDirectory,
|
||||
// 候选4:自定义环境变量指定的配置目录(灵活性扩展)
|
||||
Environment.GetEnvironmentVariable("APP_CONFIG_DIR")
|
||||
}
|
||||
// 过滤空值和无效路径
|
||||
.Where(path => !string.IsNullOrEmpty(path) && Directory.Exists(path))
|
||||
.Distinct() // 去重
|
||||
.ToList();
|
||||
|
||||
// HTTP客户端
|
||||
// 步骤2:遍历候选路径,查找存在的配置文件
|
||||
string configFilePath = null;
|
||||
foreach (var basePath in candidateBasePaths)
|
||||
{
|
||||
var tempPath = Path.Combine(basePath, configFileName);
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
configFilePath = tempPath;
|
||||
break; // 找到第一个存在的配置文件即可
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤3:配置加载(增加容错和日志提示)
|
||||
if (!string.IsNullOrEmpty(configFilePath))
|
||||
{
|
||||
// 找到配置文件,正常加载
|
||||
var basePath = Path.GetDirectoryName(configFilePath);
|
||||
config.SetBasePath(basePath)
|
||||
.AddJsonFile(configFileName, optional: false, reloadOnChange: true)
|
||||
.AddEnvironmentVariables();
|
||||
|
||||
// 可选:输出日志,确认配置文件加载路径(方便排查)
|
||||
//Console.WriteLine($"成功加载配置文件:{configFilePath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 未找到配置文件,抛出明确异常(或根据需求调整为兜底逻辑)
|
||||
throw new FileNotFoundException(
|
||||
$"未找到配置文件 {configFileName},已检查以下路径:{string.Join("; ", candidateBasePaths)}",
|
||||
configFileName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置依赖注入服务
|
||||
/// </summary>
|
||||
private static void ConfigureServices(IServiceCollection services, IConfiguration config, IWebHostEnvironment environment, string dbPath)
|
||||
{
|
||||
|
||||
//打印logo
|
||||
//PrintApp();
|
||||
|
||||
services.AddSingleton(new Appsettings(config));
|
||||
// 雪花ID生成器
|
||||
services.AddSnowFlakeId(options => options.WorkId = new Random().Next(1, 100));
|
||||
|
||||
// MVC控制器+异常拦截器
|
||||
services.AddControllers().AddGlobalExceptionFilter();
|
||||
|
||||
// HTTP客户端
|
||||
services.AddHttpClients();
|
||||
|
||||
// 数据库
|
||||
services.AddSqlsugar(config);
|
||||
// 数据库
|
||||
services.AddSqlsugar(dbPath);
|
||||
|
||||
// 定时任务
|
||||
services.AddQuartzService(config);
|
||||
// 定时任务
|
||||
services.AddQuartzService(dbPath);
|
||||
|
||||
// 仓储和服务注册
|
||||
// 仓储和服务注册
|
||||
services.AddServicesFromNamespace("dy.net.repository")
|
||||
.AddServicesFromNamespace("dy.net.service");
|
||||
|
||||
// SPA静态文件支持
|
||||
services.AddScoped<FFmpegHelper>();
|
||||
|
||||
// SPA静态文件支持
|
||||
services.AddSpaStaticFiles(options => options.RootPath = SpaRootPath);
|
||||
|
||||
// 开发环境启用Swagger
|
||||
if (isDevelopment)
|
||||
{
|
||||
services.AddSwagger();
|
||||
}
|
||||
// 开发环境启用Swagger
|
||||
//if (environment.IsDevelopment())
|
||||
//{
|
||||
// services.AddSwagger();
|
||||
//}
|
||||
|
||||
// 响应压缩
|
||||
// 响应压缩
|
||||
services.AddResponseCompression();
|
||||
|
||||
// JWT认证
|
||||
// JWT认证
|
||||
services.ConfigureJwtAuthentication();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 配置中间件
|
||||
/// 配置中间件
|
||||
/// </summary>
|
||||
private static void ConfigureMiddleware(WebApplication app, bool isDevelopment)
|
||||
private static void ConfigureMiddleware(WebApplication app, IWebHostEnvironment environment)
|
||||
{
|
||||
// 响应压缩
|
||||
// 响应压缩
|
||||
app.UseResponseCompression();
|
||||
|
||||
// 开发环境启用SwaggerUI
|
||||
if (isDevelopment)
|
||||
{
|
||||
app.UseCustomSwaggerUI(options => options.Title = SwaggerDocTitle);
|
||||
}
|
||||
// 开发环境启用SwaggerUI
|
||||
//if (environment.IsDevelopment())
|
||||
//{
|
||||
// app.UseCustomSwaggerUI(options => options.Title = SwaggerDocTitle);
|
||||
//}
|
||||
|
||||
// 路由
|
||||
// 路由
|
||||
app.UseRouting();
|
||||
|
||||
// 认证授权
|
||||
// 认证授权
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// 配置文件上传路径
|
||||
// 配置文件上传路径
|
||||
//ConfigureUploadPath(app, isDevelopment);
|
||||
|
||||
// API路由映射
|
||||
// API路由映射
|
||||
app.MapControllers();
|
||||
|
||||
// 生产环境启用SPA
|
||||
if (!isDevelopment)
|
||||
app.UseStaticFiles();
|
||||
// 生产环境启用SPA
|
||||
if (!environment.IsDevelopment())
|
||||
{
|
||||
app.UseSpaStaticFiles();
|
||||
app.UseSpa(spa => spa.Options.SourcePath = SpaSourcePath);
|
||||
@@ -153,47 +200,71 @@ namespace dy.net
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化应用服务数据
|
||||
/// 初始化应用服务数据
|
||||
/// </summary>
|
||||
private static void InitApplicationServices(WebApplication app)
|
||||
private static async Task InitApplicationServices(WebApplication app, bool isDevelopment, string dbPath)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var services = scope.ServiceProvider;
|
||||
|
||||
try
|
||||
{
|
||||
// 初始化用户
|
||||
var userService = services.GetRequiredService<UserService>();
|
||||
userService.InitUser();
|
||||
// 初始化用户
|
||||
var userService = services.GetRequiredService<AdminUserService>();
|
||||
userService.InitUser("douyin", "douyin2026");
|
||||
|
||||
// 初始化Cookie
|
||||
var cookieService = services.GetRequiredService<DyCookieService>();
|
||||
cookieService.Init();
|
||||
var pwdTxt = Path.Combine(AppContext.BaseDirectory, "db", "pwd.txt");
|
||||
if (!string.IsNullOrWhiteSpace(dbPath))
|
||||
{
|
||||
pwdTxt = Path.Combine(dbPath, "pwd.txt");
|
||||
}
|
||||
|
||||
// 初始化配置
|
||||
var commonService = services.GetRequiredService<CommonService>();
|
||||
if (File.Exists(pwdTxt))
|
||||
{
|
||||
var pwd = File.ReadAllText(pwdTxt);
|
||||
var reset = userService.ResetPwd(pwd);
|
||||
File.Delete(pwdTxt);
|
||||
}
|
||||
|
||||
var commonService = services.GetRequiredService<DouyinCommonService>();
|
||||
|
||||
// 更新视频类型--兼容老版本--不再需要
|
||||
//commonService.UpdateCollectViedoType();
|
||||
// 重置博主作品同步状态为未同步
|
||||
//commonService.UpdateAllCookieSyncedToZero();
|
||||
|
||||
//await commonService.UpdateFollowedSavePathAsync();
|
||||
//await commonService.UpdateImageVideoSavePath();
|
||||
|
||||
// 初始化Cookie
|
||||
var cookieService = services.GetRequiredService<DouyinCookieService>();
|
||||
var deploy = Appsettings.Get("deploy");
|
||||
if (deploy != null && deploy == "docker")//docker环境直接初始化一个默认的配置
|
||||
{
|
||||
cookieService.InitCookie();
|
||||
}
|
||||
// 初始化配置
|
||||
var config = commonService.InitConfig();
|
||||
|
||||
// 更新收藏视频类型--兼容老版本-原来的旧数据没有这个类型字段
|
||||
commonService.UpdateCollectViedoType();
|
||||
// 重置博主作品同步状态为未同步
|
||||
commonService.UpdateAllCookieSyncedToZero();
|
||||
// 启动定时任务
|
||||
var quartzJobService = services.GetRequiredService<QuartzJobService>();
|
||||
quartzJobService.StartJob(config?.Cron ?? "30");
|
||||
//Serilog.Log.Debug("isRestart1=" + config.IsFirstRunning);
|
||||
//await cookieService.UpdateCookieToSupportOldVersionAsync();
|
||||
|
||||
Serilog.Log.Debug("系统初始化完成,会默认将-博主作品同步功能-同步全部作品重置为关闭(若要开启,可以到抖音授权页面中修改)");
|
||||
Serilog.Log.Debug($"默认设置的每次读取行数为:{config.BatchCount},可前往系统配置页修改");
|
||||
if (!isDevelopment)
|
||||
{
|
||||
var cookie = await cookieService.GetOpendCookies();
|
||||
if (cookie != null && cookie.Any())
|
||||
{
|
||||
// 启动定时任务
|
||||
var quartzJobService = services.GetRequiredService<DouyinQuartzJobService>();
|
||||
await quartzJobService.InitOrReStartAllJobs(config?.Cron <= 0 ? "30" : config.Cron.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = services.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogError(ex, "Failed to initialize services on startup");
|
||||
Serilog.Log.Error(ex, "Failed to initialize services on startup");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\</_PublishTargetUrl>
|
||||
<History>True|2025-11-12T15:06:38.7834109Z||;False|2025-11-12T23:01:02.2269478+08:00||;True|2025-10-22T22:08:11.6423148+08:00||;True|2025-10-22T21:54:24.7180547+08:00||;True|2025-10-22T21:40:03.5194315+08:00||;True|2025-10-22T21:28:28.8962766+08:00||;True|2025-10-22T21:22:47.9631689+08:00||;True|2025-10-22T21:18:24.5274318+08:00||;True|2025-10-22T21:14:51.6386326+08:00||;False|2025-10-22T21:14:07.6282769+08:00||;True|2025-10-22T21:03:48.9892860+08:00||;True|2025-10-22T21:00:56.3617243+08:00||;False|2025-10-22T21:00:30.5472941+08:00||;True|2025-10-22T20:51:29.6155916+08:00||;False|2025-10-22T20:50:47.1882956+08:00||;True|2025-10-22T15:01:04.1668366+08:00||;True|2025-10-22T14:49:43.6340569+08:00||;True|2025-10-22T14:38:39.4685603+08:00||;True|2025-10-21T18:35:28.8392541+08:00||;True|2025-10-20T10:31:05.5865212+08:00||;True|2025-10-20T10:20:41.5717101+08:00||;True|2025-10-19T10:08:33.6669332+08:00||;False|2025-10-19T10:07:22.3337545+08:00||;False|2025-10-19T10:05:22.9484805+08:00||;True|2025-10-10T16:54:27.1472888+08:00||;False|2025-10-10T16:53:44.1700030+08:00||;False|2025-10-10T16:52:48.1740453+08:00||;False|2025-10-10T16:51:21.5067253+08:00||;False|2025-10-10T16:50:19.2140597+08:00||;False|2025-10-10T16:49:21.2213290+08:00||;False|2025-10-10T16:48:47.7229948+08:00||;False|2025-10-10T16:48:15.1258700+08:00||;True|2025-09-30T13:29:42.2354235+08:00||;True|2025-09-25T11:44:52.6858389+08:00||;True|2025-09-25T11:08:27.8810109+08:00||;True|2025-09-25T09:28:50.2563374+08:00||;True|2025-09-24T16:14:02.3072184+08:00||;True|2025-09-23T22:38:52.4414757+08:00||;True|2025-09-23T22:19:07.1006356+08:00||;True|2025-09-23T22:18:01.5945225+08:00||;True|2025-09-23T22:06:15.6293613+08:00||;True|2025-09-23T21:53:14.2977444+08:00||;True|2025-09-23T21:46:55.2322411+08:00||;False|2025-09-23T21:45:23.7858854+08:00||;False|2025-09-23T21:03:51.8325689+08:00||;True|2025-09-23T10:03:32.2251920+08:00||;False|2025-09-23T09:38:08.2372201+08:00||;False|2025-09-23T09:37:49.9390545+08:00||;True|2024-03-11T21:31:45.8102398+08:00||;True|2024-03-11T07:26:24.7660541+08:00||;True|2024-03-08T22:08:40.0154831+08:00||;True|2024-03-03T10:14:36.8109114+08:00||;True|2024-03-02T18:44:57.3288537+08:00||;True|2024-01-24T17:51:37.9164415+08:00||;True|2024-01-24T16:36:50.5612157+08:00||;True|2024-01-24T15:51:35.7556653+08:00||;True|2024-01-17T23:40:40.7526618+08:00||;True|2024-01-17T23:36:10.3692844+08:00||;True|2024-01-17T23:22:03.2378834+08:00||;True|2024-01-03T11:35:44.7118292+08:00||;True|2024-01-03T11:11:23.4270453+08:00||;True|2024-01-03T11:04:35.2081526+08:00||;True|2024-01-03T10:57:03.7053107+08:00||;True|2024-01-03T10:51:50.7463989+08:00||;False|2024-01-03T10:50:24.9775312+08:00||;True|2024-01-03T10:47:30.1128183+08:00||;True|2024-01-03T10:42:55.8640657+08:00||;True|2024-01-03T09:24:24.3436056+08:00||;True|2024-01-02T23:40:38.2001198+08:00||;True|2024-01-02T23:08:36.7230444+08:00||;True|2024-01-02T22:53:43.9658255+08:00||;True|2024-01-02T22:25:38.5545279+08:00||;False|2024-01-02T22:24:42.6577609+08:00||;True|2024-01-02T22:04:33.4708889+08:00||;True|2024-01-02T21:49:57.9249898+08:00||;True|2023-12-28T22:42:58.5976180+08:00||;True|2023-12-28T22:24:09.2051205+08:00||;True|2023-12-28T22:05:48.4865497+08:00||;True|2023-12-28T21:50:57.4549336+08:00||;True|2023-12-28T21:45:30.4557939+08:00||;True|2023-12-28T21:19:59.3614594+08:00||;False|2023-12-28T21:17:04.3069097+08:00||;False|2023-12-28T21:15:56.7788810+08:00||;False|2023-12-28T21:14:57.3617402+08:00||;False|2023-12-28T21:10:45.4382469+08:00||;True|2023-12-28T19:45:33.1431914+08:00||;True|2023-12-26T11:30:36.6418716+08:00||;True|2023-12-26T11:17:40.6433651+08:00||;True|2023-12-26T09:13:56.0741559+08:00||;True|2023-12-26T09:10:55.5358737+08:00||;True|2023-12-25T23:15:34.6534359+08:00||;True|2023-12-25T23:09:47.1566176+08:00||;False|2023-12-25T23:07:32.1118803+08:00||;True|2023-12-25T22:59:50.4643459+08:00||;True|2023-12-25T22:50:14.3584798+08:00||;False|2023-12-25T22:49:20.0279513+08:00||;True|2023-12-25T22:44:07.4183973+08:00||;True|2023-12-25T22:35:27.3847085+08:00||;True|2023-12-25T22:23:21.3542658+08:00||;True|2023-12-25T22:12:00.5219522+08:00||;</History>
|
||||
<LastFailureDetails />
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,7 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<DeleteExistingFiles>true</DeleteExistingFiles>
|
||||
@@ -17,5 +15,8 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ProjectGuid>680660ef-acae-43a9-ab6c-b75532e758ad</ProjectGuid>
|
||||
<SelfContained>false</SelfContained>
|
||||
<!--<DockerArch>x86</DockerArch>-->
|
||||
<!--<DockerArch>arm</DockerArch> -->
|
||||
<!--<DockerCoreVersion>2.2.0</DockerCoreVersion>-->
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<DeleteExistingFiles>true</DeleteExistingFiles>
|
||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||
<PublishProvider>FileSystem</PublishProvider>
|
||||
<PublishUrl>bin\Release\net6.0\publish\</PublishUrl>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<_TargetId>Folder</_TargetId>
|
||||
<SiteUrlToLaunchAfterPublish />
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ProjectGuid>680660ef-acae-43a9-ab6c-b75532e758ad</ProjectGuid>
|
||||
<SelfContained>true</SelfContained>
|
||||
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<DebugType>none</DebugType>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<!-- 单文件压缩 -->
|
||||
<Optimize>true</Optimize>
|
||||
<!-- 编译级优化,减少生成代码体积 -->
|
||||
<TrimUnusedDependencies>true</TrimUnusedDependencies>
|
||||
<!-- 裁剪未使用的NuGet依赖 -->
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -2,13 +2,13 @@
|
||||
"profiles": {
|
||||
"dy.net": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
//"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "http://localhost:5193"
|
||||
"applicationUrl": "http://localhost:10101"
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
|
||||
@@ -1,122 +1,191 @@
|
||||
# dysync.net - 抖音同步工具(收藏/喜欢/指定博主,无缝对接Emby/Jellyfin)
|
||||
# dysync.net - 抖音同步工具(抖小云)
|
||||
|
||||
`dysync.net` 是一款基于 **.NET Core 6.0** 和 **Vue** 开发的工具,用于同步抖音收藏夹、「我喜欢」的视频及指定博主作品,解决个人收藏视频易失效的问题。支持多账号同步,内置视频信息刮削功能,同步后的视频可直接在 Emby 或 Jellyfin 中播放。
|
||||
`dysync.net` 是一款基于 **.NET Core ** 和 **Vue** 开发的工具,用于同步抖音收藏夹、「我喜欢」的视频及指定博主作品,支持多账号同步,内置视频信息刮削功能,同步后的视频可直接在 Emby 或 Jellyfin 中播放。
|
||||
|
||||
> 🔧 问题反馈:使用中遇到任何问题,欢迎添加 QQ:279225040 咨询
|
||||
> 🔧 **问题反馈**:如使用中遇到任何问题,欢迎进Q群反馈:759876963,不一定及时回复,见谅。
|
||||
|
||||
---
|
||||
|
||||
### 📺 Emby 播放效果预览
|
||||
## 📺 Emby 效果预览
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 📌 1. 获取抖音关键信息(必做!同步核心凭证)
|
||||
Cookie 及 `sec_user_id` 是同步功能的核心,需严格按步骤获取,避免遗漏或错误。
|
||||
|
||||
### 1.1 提取抖音 Cookie
|
||||
1. 打开 **抖音网页版** (https://www.douyin.com/) 并登录目标账号;
|
||||
2. 进入「我的收藏」页面,确保页面加载完成;
|
||||
3. 按 `F12` 打开浏览器「开发者工具」,切换到「Network (网络)」标签;
|
||||
4. 刷新页面,在搜索框中输入 `v1/web/aweme/listcollection` 筛选请求;
|
||||
5. 点击任意一条筛选结果,在右侧「Headers (标头)」中找到 `Cookie` 字段,**完整复制整段内容**(不可删减字符)。
|
||||
|
||||

|
||||

|
||||
|
||||
### 1.2 提取 `sec_user_id`(个人/指定博主)
|
||||
- **个人 sec_user_id**(同步自己的收藏/喜欢用):
|
||||
进入自己的抖音主页,浏览器地址栏中 `sec_user_id=` 后的字符串即为个人 ID(如 `https://www.douyin.com/user/sec_user_id=xxx`)。
|
||||
- **博主 sec_user_id**(同步指定博主作品用):
|
||||
1. 进入目标博主主页;
|
||||
2. 方式1:直接复制地址栏中 `user/` 到 `?from_tab_name`中间部分内容即是博主的 `sec_user_id`;
|
||||
3. 方式2:按 `F12` → 「Network」→ 任意请求 → 「Headers」→ 提取 `sec_user_id` 字段值。
|
||||
|
||||
> ⚠️ 风控提示:同步博主作品时,**慎用开启全量同步**(一次性下载过多易被抖音限制访问)。
|
||||
|
||||

|
||||
## 📋 目录
|
||||
- [dysync.net - 抖音同步工具(抖小云)](#dysyncnet---抖音同步工具抖小云)
|
||||
- [📺 Emby 播放效果预览](#-emby-播放效果预览)
|
||||
- [📋 目录](#-目录)
|
||||
- [1. 获取抖音关键信息(必做!同步核心凭证)](#1-获取抖音关键信息必做同步核心凭证)
|
||||
- [1.1 提取抖音 Cookie](#11-提取抖音-cookie)
|
||||
- [1.2 提取 `sec_user_id`(个人/指定博主)](#12-提取-sec_user_id个人指定博主)
|
||||
- [2. 路径映射规则(核心!错配会导致无法访问/数据丢失)](#2-路径映射规则核心错配会导致无法访问数据丢失)
|
||||
- [3. 默认账号密码(首次登录用)](#3-默认账号密码首次登录用)
|
||||
- [4. 运行方式(推荐 Docker Compose)](#4-运行方式推荐-docker-compose)
|
||||
- [镜像版本](#镜像版本)
|
||||
- [构建命令示例](#构建命令示例)
|
||||
- [方式一:Docker 命令行](#方式一docker-命令行)
|
||||
- [方式二:Docker Compose 运行(推荐)](#方式二docker-compose-运行推荐)
|
||||
- [🚀 5. 软件截图](#-5-软件截图)
|
||||
- [🚀 5. 已有功能与计划](#-5-已有功能与计划)
|
||||
|
||||
---
|
||||
|
||||
## 📁 2. 路径映射规则(核心!错配会导致无法访问/数据丢失)
|
||||
## 1. 获取抖音关键信息(必做!同步核心凭证)
|
||||
|
||||
Cookie 及 `sec_user_id` 是同步功能的核心,需严格按步骤获取,避免遗漏或错误。
|
||||
|
||||
|
||||
### 1.1 提取`Cookie`以及 `sec_uer_id`
|
||||
1. 打开 **抖音网页版** (https://www.douyin.com/) 并登录目标账号;
|
||||
2. 进入个人主页,按下F12进入开发者模式、并切换到 `网络`(也可能叫`network`).
|
||||
3. 在筛选框中输入`/follow`.
|
||||
4. 点击自己头像边上的 `关注` 按钮、会弹出你的关注列表,然后在右侧网络请求里面会出现多个请求,随便选一个.
|
||||
4. 在请求的标签里面切换到`负载`(也可能叫`payload`)
|
||||
5. 找到sec_user_id,复制值即可
|
||||
6. 在请求的标签里面切换到`标头`(也可能叫`Headers`)
|
||||
7. 往下拉,直到出现`Cookie` ,复制完整的值,注意前后不要带换行符,很多人会多复制个换行符出来.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
### 1.2 提取 博主的`sec_user_id`以及博主的uid
|
||||
- **对于想下载博主视频,但是又不想关注博主,需要用到**
|
||||
- 1.进入博主主页,按`F12` 点击`Network` 或`网络` 筛选器里面填`/web/aweme/post` 然后切到`预览`或`preview` 展开 json数据结果 找到`aweme_list` 然后随便点开其中一个子项 即可找到`aweme_list[0].author.sec_uid` 这便是博主的uid 。后续在关注列表中,需要手动添加非关注博主同步视频时将会要用到。
|
||||

|
||||
---
|
||||
|
||||
## 2. 路径映射规则(核心!错配会导致无法访问/数据丢失)
|
||||
为实现视频在 Emby/Jellyfin 中正常播放及数据持久化,需正确配置本地路径与容器路径的映射:
|
||||
|
||||
| 存储类型 | 容器内路径 | 本地路径配置要求 | 用途说明 |
|
||||
|------------------|------------------|----------------------------------------------------------------------------------|------------------------------|
|
||||
| ------------ | --------------- | --------------------------------------------------------------------- | -------------------------- |
|
||||
| 个人收藏视频 | `/app/collect` | 映射到本地目录(如 NAS:`/volume1/抖音/收藏`、电脑:`D:/抖音/收藏`) | 存储同步后的收藏视频 |
|
||||
| 个人喜欢视频 | `/app/favorite` | 映射到本地目录(建议与收藏视频路径区分,如 `/volume1/抖音/喜欢`) | 存储同步后的「我喜欢」视频 |
|
||||
| 图文视频 | `/app/images` | 映射到本地目录(建议与收藏视频路径区分,如 `/volume1/抖音/图文视频`) | 存储同步后的「图文视频」 |
|
||||
| 指定博主视频 | `/app/uper` | 映射到本地目录(如 `/volume1/抖音/博主作品`) | 存储同步后的博主视频 |
|
||||
| 数据库文件 | `/app/db` | 映射到本地稳定目录(如 `/volume1/抖音/工具数据库`) | 持久化配置、同步记录(防止容器删除后数据丢失) |
|
||||
| 多账号视频(可选)| `/app/user1`/`/app/user2` 等 | 为每个账号分配独立本地路径(如 `/volume1/抖音/账号A`、`/volume1/抖音/账号B`) | 实现多账号隔离同步 |
|
||||
| 指定博主视频 | `/app/mix` | 映射到本地目录(如 `/volume1/抖音/合集`) | 存储同步后的合集视频 |
|
||||
| 指定博主视频 | `/app/series` | 映射到本地目录(如 `/volume1/抖音/短剧`) | 存储同步后的短剧视频 |
|
||||
| 数据库文件 | `/app/db` | 映射到本地稳定目录(如 `/volume1/抖音/db`) | 持久化配置、同步记录 |
|
||||
| 数据库文件 | `/app/mp3` | 映射到本地稳定目录(如 `/volume1/抖音/mp3`) | 图文视频自定义音频(用于原视频音频带版权的情况) |
|
||||
|
||||
> ✅ 关键注意:
|
||||
> 1. 本地路径需与后续后台「抖音授权」页面配置的「文件存储路径」**完全一致**;
|
||||
> 2. 未配置路径映射时,文件仅存于容器内部,Emby/Jellyfin 无法访问,且容器删除后数据丢失。
|
||||
> ✅ **关键注意**:
|
||||
> 1. 抖音授权时配置的路径为上面 表格里面 容器内路径 如 `/app/collect` 且注意 必须是`/`开头,路径可以自己随便命名,只要保证 授权里面配的路径有在docker-compose里面添加映射即可
|
||||
> 2. 未配置路径映射时,文件仅存于容器内部,且容器删除后数据丢失。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🔑 3. 默认账号密码(首次登录用)
|
||||
## 3. 默认账号密码(首次登录用)
|
||||
首次访问后台管理页面时,使用以下默认账号密码:
|
||||
- **用户名**:`douyin`
|
||||
- **密码**:`douyin2025`
|
||||
- **密码**:`douyin2026`
|
||||
|
||||
> ⚠️ 安全建议:登录后修改密码。
|
||||
> ⚠️ **安全建议**:登录后修改密码。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 4. 运行方式(推荐 Docker Compose,更易维护)
|
||||
## 4. 运行方式(推荐 Docker Compose)
|
||||
|
||||
### 镜像版本
|
||||
|
||||
| 镜像标签 | 架构 |
|
||||
| ----------------- | -------------- |
|
||||
| `beta_2.2.0` | x86_64 (amd64) |
|
||||
| `arm_2.2.0` | ARM64 |
|
||||
| `latest` | x86_64 (amd64) |
|
||||
|
||||
|
||||
### 构建命令示例
|
||||
|
||||
将下方命令中的「本地路径」替换为你的实际路径,终端执行即可:
|
||||
```bash
|
||||
|
||||
### Arm设备请使用 registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:arm_1.0 版本镜像
|
||||
### 方式一:Docker 命令行
|
||||
docker run -d --restart=always \
|
||||
-v /你的/本地/收藏视频路径:/app/collect \
|
||||
-v /你的/本地/喜欢视频路径:/app/favorite \
|
||||
-v /你的/本地/数据库路径:/app/db \
|
||||
-v /你的/本地/博主视频路径:/app/uper \
|
||||
-p 10101:10101 \
|
||||
--name dysync2025 \
|
||||
registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net
|
||||
# 注意:-p 后面的容器端口必须为 10101(源码固定)
|
||||
|
||||
|
||||
### 方式二:Docker Compose 运行(推荐)
|
||||
### Arm设备请使用 registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:arm_1.0 版本镜像
|
||||
### 方式一:Docker Compose 运行(推荐)
|
||||
创建 docker-compose.yml 文件,复制以下内容,替换「本地路径」后执行 docker-compose up -d:
|
||||
|
||||
version: '3.8'
|
||||
<span style="color: red; font-weight: bold;">需要注意:unraid玩家需要增加 user配置 --user 1000:100</span>
|
||||
|
||||
```yaml
|
||||
|
||||
services:
|
||||
dysync:
|
||||
image: registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net
|
||||
container_name: dysync2025
|
||||
restart: always
|
||||
image: ccr.ccs.tencentyun.com/jianzhichu/dysync:beta_2.2.0
|
||||
container_name: dysync2026 # 容器名称
|
||||
restart: unless-stopped # 始终重启容器,除非容器被手动停止或Docker服务停止
|
||||
ports:
|
||||
- "10101:10101"
|
||||
volumes:
|
||||
- /volume1/docker/dysync/db:/app/db
|
||||
# 基础路径映射
|
||||
- /vol2/1000/media/dysync/db:/app/db # 数据库目录(持久化配置和同步记录)
|
||||
# 默认音频目录(用于图文/动态视频合成,版权受限音频的替代)
|
||||
- /vol2/1000/media/dysync/mp3:/app/mp3
|
||||
# 账号1 - 视频存储目录
|
||||
- /vol2/1000/media/dysync/dy1/coll:/app/collect # 个人收藏视频
|
||||
- /vol2/1000/media/dysync/dy1/fav:/app/favorite # 个人喜欢视频
|
||||
- /vol2/1000/media/dysync/dy1/up:/app/uper # 指定博主视频
|
||||
- /vol2/1000/media/dysync/dy1/mix:/app/mix # 合集(不需要可删除)
|
||||
- /vol2/1000/media/dysync/dy1/series:/app/series # 短剧(不需要可删除)
|
||||
|
||||
# 账号2 - 多账号路径映射示例(可选:授权时可复用账号1路径)
|
||||
- /vol2/1000/media/dysync/dy2/collect:/app/collect2
|
||||
- /vol2/1000/media/dysync/dy2/fav:/app/favorite2
|
||||
- /vol2/1000/media/dysync/dy2/up:/app/uper2
|
||||
|
||||
- /volume2/mediay/dysync:/app/collect
|
||||
- /volume2/mediay/dysync_fav:/app/favorite
|
||||
- /volume2/mediay/dysync_up:/app/uper
|
||||
# 下面是多账号路径映射示例(可选)
|
||||
- /volume2/mediay/dysync2:/app/yeyeye
|
||||
- /volume2/mediay/dysync2_fav:/app/yeyeye_fav
|
||||
- /volume2/mediay/dysync2_up:/app/yeyeye_up
|
||||
# 配置DNS服务器(解决域名解析失败导致的无法访问外网)
|
||||
network_mode: bridge
|
||||
dns:
|
||||
- 8.8.8.8 # Google DNS
|
||||
- 114.114.114.114 # 国内DNS
|
||||
- 223.5.5.5
|
||||
|
||||
- 223.5.5.5 # 阿里云DNS(备用)
|
||||
- 114.114.114.114 # 国内114 DNS(提升国内访问稳定性)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 250m # 限制内存占用≤250MB(图文视频下载场景不建议设置)
|
||||
|
||||
```
|
||||
|
||||
另一个版本去掉了所有视频存储路径设置项,docker只需要映射3个路径就可以,但是!!!
|
||||
不兼容老的版本,如果觉得这种方式好一些可以重新部署。其他没什么变化。
|
||||
所有路径都变成 根目录/cookie名字/视频类型/(如果是合集或者短剧这里还有一层)/合集或短剧/名称/视频文件
|
||||
|
||||
```
|
||||
services:
|
||||
dysync:
|
||||
image: ccr.ccs.tencentyun.com/jianzhichu/dysync:latest
|
||||
container_name: dysync_latest # 容器自定义名称
|
||||
restart: unless-stopped # 容器非手动停止则自动重启
|
||||
ports:
|
||||
- "10110:10101" # 端口映射
|
||||
environment:
|
||||
# 限制GC堆最大内存为100MB(100*1024*1024=104857600,可根据需求调整)
|
||||
- COMPlus_GCHeapHardLimit=154857600
|
||||
# 禁用服务器GC,改用工作站GC(轻量应用更友好)
|
||||
- COMPlus_GCServer=0
|
||||
# 可选:强制GC回收后释放内存给操作系统(Linux专属)
|
||||
- COMPlus_GCConserveMemory=1
|
||||
# 可选:显示GC日志(排查内存问题时用)
|
||||
# - COMPlus_GCLogFile=/app/gc.log
|
||||
|
||||
volumes:
|
||||
# 基础路径映射 - 主账号(账号1)
|
||||
- /vol2/1000/media/dy001/db:/app/db # 数据库目录(持久化配置和同步记录)
|
||||
- /vol2/1000/media/dy001/mp3:/app/mp3 # 备用音频目录(版权音频无法下载时使用)
|
||||
- /vol2/1000/media/dy001/:/app/data # 视频存储目录
|
||||
|
||||
|
||||
network_mode: bridge
|
||||
dns:
|
||||
- 223.5.5.5 # 阿里云DNS(备用)
|
||||
- 114.114.114.114 # 国内114 DNS(提升国内访问稳定性)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 211m # 限制内存占用≤250MB(图文视频下载场景不建议设置)
|
||||
```
|
||||
|
||||
## 🚀 5. 软件截图
|
||||
|
||||

|
||||
@@ -125,8 +194,111 @@ services:
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
<div align="center">
|
||||
<img src="docs/mobile-home.png" width="30%" />
|
||||
<img src="docs/mobile-log.png" width="30%" />
|
||||
<img src="docs/mobile-logdetail.jpg" width="30%" />
|
||||
</div>
|
||||
|
||||
## 🚀 5. 已有功能与计划
|
||||
|
||||
|
||||
1. ✅ 支持多账号同步(每个账号可单独配置存储路径)
|
||||
|
||||
2. ✅ 收藏的视频
|
||||
|
||||
3. ✅ 喜欢的视频(点赞的视频)
|
||||
|
||||
4. ✅ 图文视频(需要将图片 + mp3 合成视频)
|
||||
|
||||
5. ✅ 指定博主的视频,可配置是否单独存放一个总文件夹,是否直接用视频标题做文件名
|
||||
|
||||
6. ✅ 清除日志(防止容器被日志占用太多空间,可配置保留天数)
|
||||
|
||||
7. ✅ 将项目名称改成 "抖小云" 灵感来源于哪吒电影那坨云
|
||||
|
||||
8. ✅ Cookie 过期提醒,在DY授权页可查看
|
||||
|
||||
9. ✅ 自动根据去重规则进行去重(可设置去重优先级,同一个视频出现再多个分类时适用)
|
||||
|
||||
10. ✅ 在同步记录页面中直接播放视频
|
||||
|
||||
11. ✅ 关注列表同步。
|
||||
|
||||
11. ✅ 选中记录,批量重新同步。以及分享(比较简陋)
|
||||
|
||||
12. ✅ 关注列表支持新增,非关注的博主
|
||||
|
||||
13. ✅ 增加永久删除功能,删除后,以后不会再同步该视频
|
||||
|
||||
14. ✅ 增加配置项及关注列表(手动添加部分)导出导入功能
|
||||
|
||||
15. ✅ 支持移动端
|
||||
|
||||
16. ✅ 增加开关配置是否仅同步最近视频
|
||||
|
||||
17. ✅ 完成飞牛fpk打包:https://github.com/jianzhichu/FnDepot
|
||||
|
||||
18. ✅ 合集、短剧、自定义收藏夹下载。
|
||||
|
||||
19. ✅ 增加一个工具dy.cookie.exe在源码根目录,可以快速重置失效cookie,操作说明(一步一步操作即可)。
|
||||
|
||||
|
||||
## 💖 支持项目
|
||||
|
||||
如果这个项目对你有帮助,欢迎通过以下方式支持:
|
||||
|
||||
### ⭐ Star 项目
|
||||
点击右上角的 ⭐ Star 按钮,让更多人发现这个项目
|
||||
|
||||
### 💰 赞赏支持
|
||||
|
||||
- 扫码请作者喝杯奶茶 ☕
|
||||
|
||||
| 微信支付 | 支付宝 |
|
||||
| :-------------------------------------: | :--------------------------------------: |
|
||||
| <img src="docs/wx.jpg" width="300" /> | <img src="docs/zfb.jpg" width="300" /> |
|
||||
|
||||
### 🎁 其他支持方式
|
||||
- 分享给更多需要的朋友
|
||||
- 提交 Bug 报告和功能建议
|
||||
- 贡献代码和文档
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<strong>感谢你的支持!❤️</strong>
|
||||
</p>
|
||||
|
||||
# 免责声明
|
||||
|
||||
本项目(dysync.net)仅作为技术研究与学习交流的开源项目,其所有代码、功能及相关内容均不代表任何第三方平台的官方服务或立场。使用本项目前,请仔细阅读以下条款:
|
||||
|
||||
## 1. 授权与使用限制
|
||||
- 本项目未获得抖音(Douyin)等任何第三方平台的官方授权、合作或认可,与该等平台无任何关联。
|
||||
- 项目功能仅允许用于个人技术研究和学习目的,严禁用于商业用途、非法活动或违反任何第三方平台规则的行为。
|
||||
|
||||
## 2. 知识产权说明
|
||||
- 项目中涉及的第三方平台(如抖音)的名称、商标、接口、内容等均为其各自所有者的知识产权。
|
||||
- 本项目仅对技术实现方式进行研究,不涉及对第三方知识产权的侵占或滥用。如因使用本项目引发知识产权争议,相关责任由使用者自行承担。
|
||||
|
||||
## 3. 风险提示与责任豁免
|
||||
- 使用本项目可能导致违反第三方平台的用户协议或相关规定,进而导致账号限制、功能封禁等风险,此类风险由使用者自行承担。
|
||||
- 项目开发者不对使用本项目产生的任何直接或间接损失(包括但不限于数据丢失、设备损坏、账号问题等)承担责任。
|
||||
- 使用者应确保自身行为符合《网络安全法》《著作权法》等相关法律法规,不得利用本项目从事侵权、违法或违背公序良俗的活动。
|
||||
|
||||
## 4. 声明的修改与生效
|
||||
- 本免责声明的最终解释权归项目开发者所有,开发者保留随时修改本声明的权利。
|
||||
- 修改后的声明将在项目仓库中更新,建议使用者定期查阅。
|
||||
- 一旦使用本项目的代码、功能或相关资源,即视为您已充分理解并同意本免责声明的全部条款。如不同意,请立即停止使用。
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_BASE_URL=/
|
||||
VITE_API_URL=http://localhost
|
||||
VITE_API_URL=/
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_BASE_URL=/
|
||||
VITE_API_URL=http://localhost:10101
|
||||
VITE_API_URL=http://localhost:10101/
|
||||
@@ -4,112 +4,140 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>抖音同步小帮手</title>
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>抖小云</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
/* ######### 核心修改:区分 #stepin-app 的两个状态 ######### */
|
||||
/* 1. 加载阶段:#stepin-app 仅包含加载动画(或为空),应用 flex 居中,保留原有样式 */
|
||||
#stepin-app {
|
||||
/* 加载阶段默认样式:flex 居中,保留呼吸圆环效果 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 35px;
|
||||
}
|
||||
|
||||
/* 2. Vue 挂载后:#stepin-app 非空(包含 Vue 组件),覆盖为普通块级元素,解除约束 */
|
||||
#stepin-app:not(:has(.breath-ring)) {
|
||||
/* 核心:取消 flex 布局,改为普通块级元素 */
|
||||
display: block !important;
|
||||
/* 覆盖原有 flex 相关属性,解除居中约束 */
|
||||
align-items: unset !important;
|
||||
justify-content: unset !important;
|
||||
/* 确保页面撑满横向宽度 */
|
||||
width: 100% !important;
|
||||
/* 保留高度,确保 Vue 页面正常占满视口 */
|
||||
height: 100% !important;
|
||||
/* 移除 gap,避免影响 Vue 页面布局 */
|
||||
gap: 0 !important;
|
||||
/* 恢复页面滚动功能,解除 overflow: hidden 的限制 */
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
/* 核心呼吸圆环容器(原有样式保留,无修改) */
|
||||
.breath-ring {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 圆环基础样式(原有样式保留,无修改) */
|
||||
.breath-ring::before,
|
||||
.breath-ring::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 内层圆环:细圆环 + 渐变(原有样式保留,无修改) */
|
||||
.breath-ring::before {
|
||||
border: 3px solid transparent;
|
||||
border-top-color: #1890ff;
|
||||
border-right-color: #722ed1;
|
||||
animation: ringRotate 1.5s linear infinite;
|
||||
}
|
||||
|
||||
/* 外层圆环:呼吸扩散动画(原有样式保留,无修改) */
|
||||
.breath-ring::after {
|
||||
border: 2px solid #1890ff;
|
||||
animation: ringBreath 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 加载文字:极简高级(原有样式保留,无修改) */
|
||||
.loading-text {
|
||||
color: #222222;
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.loading-tip {
|
||||
color: #aaaaaa;
|
||||
font-size: 13px;
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
/* 圆环旋转动画(原有样式保留,无修改) */
|
||||
@keyframes ringRotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 圆环呼吸扩散动画(原有样式保留,无修改) */
|
||||
@keyframes ringBreath {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="stepin-app">
|
||||
<style>
|
||||
html {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100vh;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
background-color: #011627;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loader {
|
||||
color: rgb(201, 195, 195);
|
||||
font-family: "Poppins", sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
-webkit-box-sizing: content-box;
|
||||
box-sizing: content-box;
|
||||
height: 40px;
|
||||
padding: 10px 10px;
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.words {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.word {
|
||||
display: block;
|
||||
height: 100%;
|
||||
padding-left: 6px;
|
||||
color: #ffca29;
|
||||
animation: spin_4991 4s infinite;
|
||||
}
|
||||
|
||||
@keyframes spin_4991 {
|
||||
10% {
|
||||
-webkit-transform: translateY(-105%);
|
||||
transform: translateY(-105%);
|
||||
}
|
||||
|
||||
25% {
|
||||
-webkit-transform: translateY(-100%);
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
35% {
|
||||
-webkit-transform: translateY(-205%);
|
||||
transform: translateY(-205%);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translateY(-200%);
|
||||
transform: translateY(-200%);
|
||||
}
|
||||
|
||||
60% {
|
||||
-webkit-transform: translateY(-305%);
|
||||
transform: translateY(-305%);
|
||||
}
|
||||
|
||||
75% {
|
||||
-webkit-transform: translateY(-300%);
|
||||
transform: translateY(-300%);
|
||||
}
|
||||
|
||||
85% {
|
||||
-webkit-transform: translateY(-405%);
|
||||
transform: translateY(-405%);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translateY(-400%);
|
||||
transform: translateY(-400%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- <img style="margin-bottom: 8px;" width="64px" src="/vite.svg" /> -->
|
||||
<div class="loader">
|
||||
<!-- <img src="/vite.svg"> -->
|
||||
<div> loading ...</div>
|
||||
<!-- <div class="words">
|
||||
<span class="word">buttons</span>
|
||||
<span class="word">forms</span>
|
||||
<span class="word">switches</span>
|
||||
<span class="word">cards</span>
|
||||
<span class="word">buttons</span>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="" style="font-size: 15px; color: rgba(249, 244, 244, 0.55)">首次加载可能较慢,请耐心等待...</div>
|
||||
<!-- <div class="loading-text">正在加载资源,请稍候...</div> -->
|
||||
<!-- 呼吸圆环加载组件 -->
|
||||
<div class="breath-ring"></div>
|
||||
<!-- <div class="loading-tip">首次加载较慢,请耐心等待...</div> -->
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script>
|
||||
if (!global) {
|
||||
@@ -118,11 +146,4 @@
|
||||
</script>
|
||||
</body>
|
||||
|
||||
<!-- <link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-awesome/5.15.4/css/all.min.css"> -->
|
||||
<style>
|
||||
html {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
</html>
|
||||
@@ -32,11 +32,13 @@
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.0.33",
|
||||
"qs": "^6.10.1",
|
||||
"sortablejs": "^1.15.6",
|
||||
"stepin": "2.1.42-beta",
|
||||
"vue": "^3.3.4",
|
||||
"vue-json-editor": "^1.4.3",
|
||||
"vue-request": "^2.0.4",
|
||||
"vue-router": "^4.1.6"
|
||||
"vue-router": "^4.1.6",
|
||||
"vue-sortablejs": "^1.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/container-queries": "^0.1.0",
|
||||
@@ -50,8 +52,9 @@
|
||||
"autoprefixer": "^10.4.7",
|
||||
"babel-preset-es2015": "^6.24.1",
|
||||
"babel-register": "^6.26.0",
|
||||
"baseline-browser-mapping": "^2.9.15",
|
||||
"body-parser": "^1.20.1",
|
||||
"crypto-js": "^4.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"gh-pages": "^3.1.0",
|
||||
"gulp": "^4.0.2",
|
||||
"gulp-clean": "^0.4.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ThemeProvider is-root v-bind="themeConfig" :apply-style="false">
|
||||
<stepin-view system-name="抖音小帮手" :class="`${contentClass}`" :user="user" :navMode="navigation" :useTabs="useTabs" :themeList="themeList" v-model:show-setting="showSetting" v-model:theme="theme" @themeSelect="configTheme" logo-src="@/assets/logo.png">
|
||||
<stepin-view system-name="抖小云" :class="`${contentClass}`" :user="user" :navMode="navigation" :useTabs="useTabs" :themeList="themeList" v-model:show-setting="showSetting" v-model:theme="theme" @themeSelect="configTheme" logo-src="@/assets/logo.png" v-model:collapsed="open">
|
||||
<template #headerActions>
|
||||
<HeaderActions @showSetting="showSetting = true" />
|
||||
</template>
|
||||
@@ -16,8 +16,6 @@
|
||||
</stepin-view>
|
||||
</ThemeProvider>
|
||||
<my-personal ref="personalRef" />
|
||||
<email-set ref="emailRef" />
|
||||
<!-- <login-modal :unless="['/login']" /> -->
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -28,7 +26,7 @@ import avatar from '@/assets/avatar.png';
|
||||
import { PageFooter, HeaderActions } from '@/components/layout';
|
||||
import Setting from './components/setting';
|
||||
import { LoginModal } from '@/pages/login';
|
||||
import { MyPersonal, EmailSet } from '@/pages/personal';
|
||||
import { MyPersonal } from '@/pages/personal';
|
||||
import { configTheme, themeList } from '@/theme';
|
||||
import { ThemeProvider } from 'stepin';
|
||||
// logout,profile
|
||||
@@ -36,6 +34,7 @@ const { logout } = useAccountStore();
|
||||
const showPersonalDrawer = ref<boolean>(false);
|
||||
const personalRef = ref(null);
|
||||
const emailRef = ref(null);
|
||||
const open = ref(false);
|
||||
|
||||
const showSetting = ref(false);
|
||||
const router = useRouter();
|
||||
@@ -82,19 +81,10 @@ const user = reactive({
|
||||
|
||||
onMounted(() => {
|
||||
useApiStore()
|
||||
.apiCheckInitStatus()
|
||||
.apiUserInfo()
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
// useApiStore()
|
||||
// .apiUserInfo()
|
||||
// .then((res) => {
|
||||
// if (res.code === 0 && res.code !== '') {
|
||||
// if (res.data.avatar && res.data.avatar != null) {
|
||||
// user.avatar = `/upload/${res.data.avatar}`;
|
||||
// }
|
||||
// user.name = res.data.userName;
|
||||
// }
|
||||
// });
|
||||
user.name = res.data?.userName;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -124,7 +114,11 @@ onMounted(() => {
|
||||
background: theme('backgroundColor.layout');
|
||||
}
|
||||
.system-name {
|
||||
// color: #16213e !important;
|
||||
color: #722ed1 !important;
|
||||
margin-left: 0em !important;
|
||||
}
|
||||
.ant-tabs-extra-content {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +132,97 @@ body {
|
||||
height: 100vh;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
// 小屏下 Modal / Drawer 全局滚动策略
|
||||
// 挂载到 document.body 的元素 scoped 样式无法命中,此处非 scoped 全局生效
|
||||
// 策略:让 wrap 承担滚动,不强制 body 内滚——这样鼠标在 modal 任意位置滚轮都能滚,避免 Mac overlay scrollbar 手感"滑不动"
|
||||
.ant-modal-wrap {
|
||||
overflow-y: auto !important; // wrap 是 fixed 定位滚动容器;antd 默认就是 auto,此处 !important 兜底
|
||||
-webkit-overflow-scrolling: touch; // 移动端惯性滚动
|
||||
|
||||
// 强制显示滚动条(Mac overlay 默认隐藏,用户误以为"滑不动")
|
||||
&::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 5px;
|
||||
&:hover { background: rgba(0, 0, 0, 0.55); }
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal {
|
||||
top: 20px !important; // 覆盖 antd 通过 inline style 设的 top: 100px
|
||||
padding-bottom: 20px !important;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
// body 承担滚动:max-height 硬约束,内容超出时滚,内容少时自然收缩(modal 不会撑成一大块留白)
|
||||
// 100vh - 150px = 减去 top(20) + margin-bottom(20) + header(约 55) + padding(约 40) + 保险 15
|
||||
.ant-modal .ant-modal-body {
|
||||
max-height: calc(100vh - 150px) !important;
|
||||
overflow-y: auto !important;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 5px;
|
||||
&:hover { background: rgba(0, 0, 0, 0.55); }
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
// 抖音授权 Cookie 编辑用 full-modal(全屏抽屉式,高度 100vh、footer 常驻底部)
|
||||
.full-modal .ant-modal {
|
||||
top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
margin: 0 !important;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
.full-modal .ant-modal-content {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0;
|
||||
}
|
||||
.full-modal .ant-modal-header,
|
||||
.full-modal .ant-modal-footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.full-modal .ant-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: none !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
// 登录页整个界面本身就是一个 Modal(LoginModal.vue),必须恢复 antd 默认布局,不能被上面的全局规则改
|
||||
.login-modal .ant-modal {
|
||||
top: 100px !important;
|
||||
padding-bottom: 24px !important;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// 忘记密码引导 Modal(LoginBox.vue 里的 forgot-modal)同样保留默认布局
|
||||
.forgot-modal .ant-modal {
|
||||
top: 100px !important;
|
||||
padding-bottom: 24px !important;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// Drawer 组件(抽屉)也保证内容可滚
|
||||
.ant-drawer-body {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.stepin-img-checkbox {
|
||||
@apply transition-transform;
|
||||
&:hover {
|
||||
|
||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="90px" height="90px" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 61.2 (89653) - https://sketch.com -->
|
||||
<title>logo-black</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="LOGO" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Artboard" transform="translate(-532.000000, -80.000000)">
|
||||
<g id="logo-black" transform="translate(532.000000, 80.000000)">
|
||||
<g id="Group">
|
||||
<circle id="Combined-Shape" fill="#C71D23" cx="44.8544363" cy="44.8544363" r="44.8544363"></circle>
|
||||
<path d="M67.558546,39.8714292 L42.0857966,39.8714292 C40.8627004,39.8720094 39.8710953,40.8633548 39.8701949,42.0864508 L39.8687448,47.623783 C39.867826,48.8471055 40.8592652,49.8390642 42.0825879,49.8393845 C42.0827874,49.8393846 42.0829869,49.8393846 42.0831864,49.8387862 L57.5909484,49.838657 C58.8142711,49.8386283 59.8059783,50.830319 59.8059885,52.0536417 C59.8059885,52.0536479 59.8059885,52.053654 59.8059701,52.0536602 L59.8059701,52.6073539 L59.8059701,52.6073539 L59.8059701,53.161115 C59.8059701,56.8310831 56.8308731,59.80618 53.160905,59.80618 L32.1165505,59.80618 C30.8934034,59.806119 29.9018373,58.8145802 29.9017425,57.5914331 L29.9011625,36.5491188 C29.9008781,32.8791508 32.8758931,29.9039718 36.5458611,29.9038706 C36.5459222,29.9038706 36.5459833,29.9038706 36.5460443,29.9040538 L67.5523638,29.9040538 C68.77515,29.9026795 69.7666266,28.9118177 69.7687593,27.6890325 L69.7721938,22.1516997 C69.774326,20.928378 68.7832423,19.9360642 67.5599198,19.9353054 C67.5594619,19.9353051 67.5590039,19.935305 67.558546,19.9366784 L36.5479677,19.9366784 C27.3730474,19.9366784 19.935305,27.3744208 19.935305,36.549341 L19.935305,67.558546 C19.935305,68.7818687 20.927004,69.7735676 22.1503267,69.7735676 L54.8224984,69.7735676 C63.079746,69.7735676 69.7735676,63.079746 69.7735676,54.8224984 L69.7735676,42.0864509 C69.7735676,40.8631282 68.7818687,39.8714292 67.558546,39.8714292 Z" id="G" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
@@ -49,13 +49,13 @@ const navList = [
|
||||
];
|
||||
</script>
|
||||
<template>
|
||||
<ThemeProvider :color="{ middle: { 'bg-base': '#1896ff' }, primary: { DEFAULT: '#1896ff' } }" :autoAdapt="true">
|
||||
<ThemeProvider :color="{ middle: { 'bg-base': '#fff' }, primary: { DEFAULT: '#fff' } }" :autoAdapt="true">
|
||||
<div class="front-view flex flex-col">
|
||||
<div class="text-text flex-1">
|
||||
<div class="front-header flex items-baseline py-md px-xl">
|
||||
<div class="text-xxl text-text hover:text-text">
|
||||
<!-- <img src="@/assets/png.svg" /> -->
|
||||
<!-- 抖音同步小帮手 -->
|
||||
<!-- 抖小云 -->
|
||||
</div>
|
||||
<!-- <div style="width: calc(100% - 430px)" class="front-navigation mx-xl flex overflow-hidden items-center text-lg overflow-ellipsis whitespace-nowrap">
|
||||
<div :class="`front-nav-item flex items-center cursor-pointer mx-base ${nav.children ? 'with-list' : ''}`" v-for="nav in navList">
|
||||
|
||||
@@ -1,45 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
import { LogoutOutlined } from '@ant-design/icons-vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { onMounted, onUnmounted, computed } from 'vue';
|
||||
import { ThemeProvider, alert } from 'stepin';
|
||||
import http from '@/store/http';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
// console.log(router.getRoutes());
|
||||
|
||||
onMounted(() => {
|
||||
// alert.info(
|
||||
// `<div class="text-text">
|
||||
// Stepin is a fast, light framework to Vue3 – try it out today with the
|
||||
// <span class="underline">Stepin Template Beta</span>.
|
||||
// </div>`,
|
||||
// { renderRaw: true, duration: -1 }
|
||||
// );
|
||||
// console.log(router.getRoutes());
|
||||
if (http.checkAuthorization()) {
|
||||
// console.log(22222);
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
router.push('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ThemeProvider :color="{ middle: { 'bg-base': '#fff','bg-container':'#fff','bg-container-light':'#fff' }, primary: { DEFAULT: '#1896ff' } }" :autoAdapt="true">
|
||||
<ThemeProvider :color="{
|
||||
middle: { 'bg-base': '#fff','bg-container':'#fff','bg-container-light':'#fff' },
|
||||
primary: { DEFAULT: '#1896ff' }
|
||||
}" :autoAdapt="true">
|
||||
<div class="front-view flex flex-col" style="background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)">
|
||||
<!-- <div class="text-xxl text-text hover:text-text" style="margin-left:20px;color:#722ed1;"> -->
|
||||
<!-- <img src="/logo.png" /> -->
|
||||
<!-- 抖音同步小帮手 -->
|
||||
<!-- </div> -->
|
||||
<div class="front-content ">
|
||||
<div class="front-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.front-view {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
|
||||
.front-content {
|
||||
height: 100vh;
|
||||
overflow: auto; // 防止mobile页面内容溢出
|
||||
}
|
||||
|
||||
// 原样式保留(无冲突)
|
||||
.front-header {
|
||||
.front-nav-item {
|
||||
&.with-list .front-nav-item-content {
|
||||
@@ -55,11 +45,5 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
.front-content {
|
||||
height: 100vh;
|
||||
}
|
||||
.front-view {
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,85 +1,360 @@
|
||||
<script lang="ts" setup>
|
||||
import { reactive } from 'vue';
|
||||
// 步骤2:声明父组件传递的 showSetting 事件(关键!)
|
||||
const emits = defineEmits(['showSetting']);
|
||||
import { ref, onMounted } from 'vue'; // 移除 h、VNode 导入
|
||||
import { StepinHeaderAction } from 'stepin';
|
||||
import Notice from '@/components/notice/Notice.vue';
|
||||
import DayNightSwitch from '@/components/switch/DayNightSwitch.vue';
|
||||
import { BellOutlined } from '@ant-design/icons-vue';
|
||||
import { TagsOutlined, CopyOutlined, GithubOutlined } from '@ant-design/icons-vue';
|
||||
import Fullscreen from '../fullscreen/Fullscreen.vue';
|
||||
import { useApiStore } from '@/store';
|
||||
import { message, Popover, Badge } from 'ant-design-vue'; // 新增:导入 Badge 组件用于气泡提示
|
||||
|
||||
defineEmits<{
|
||||
(e: 'showSetting'): void;
|
||||
}>();
|
||||
// 版本数据和当前版本状态
|
||||
const dyVersions = ref<string[]>([]);
|
||||
const copyLoading = ref<Record<string, boolean>>({});
|
||||
// 版本 popover 显隐控制
|
||||
const versionPopoverVisible = ref<boolean>(false);
|
||||
// 新增:是否有新版本(用于气泡提示)
|
||||
const hasNewVersion = ref<boolean>(false);
|
||||
// 新增:localStorage 缓存 key(统一命名,方便维护)
|
||||
const VERSION_CACHE_KEY = 'dysync_version_cache';
|
||||
|
||||
const noticeList = reactive([
|
||||
// 仓库地址常量
|
||||
const gitRepos = ref([
|
||||
{
|
||||
title: '消息',
|
||||
list: [
|
||||
{
|
||||
title: '影佑',
|
||||
content: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
img: 'src/assets/avatar/face-1.jpg',
|
||||
time: 0,
|
||||
name: 'Gitee',
|
||||
url: 'https://gitee.com/deathvicky/dysync.net',
|
||||
color: '#FF6600',
|
||||
},
|
||||
],
|
||||
{
|
||||
name: 'GitHub',
|
||||
url: 'https://github.com/jianzhichu/dysync.net',
|
||||
color: '#6c35de',
|
||||
},
|
||||
]);
|
||||
const gitCopyLoading = ref<Record<string, boolean>>({});
|
||||
const popoverVisible = ref<boolean>(false);
|
||||
|
||||
// 复制版本号方法(保留原有兼容逻辑,无改动)
|
||||
const copyVersion = (version: string) => {
|
||||
const pureVersion = version.replace('(当前版本)', '').replace('(最新版)', '').trim();
|
||||
copyLoading.value[version] = true;
|
||||
|
||||
const doCopy = async () => {
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(pureVersion);
|
||||
message.success(`已复制版本: ${pureVersion}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.style.position = 'absolute';
|
||||
textarea.style.top = '-9999px';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.value = pureVersion;
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
textarea.select();
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
|
||||
if (success) {
|
||||
message.success(`已复制版本: ${pureVersion}`);
|
||||
} else {
|
||||
throw new Error('execCommand 复制失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.warning(`复制失败,请手动复制:${pureVersion}`);
|
||||
console.warn('复制版本号失败:', error);
|
||||
} finally {
|
||||
copyLoading.value[version] = false;
|
||||
}
|
||||
};
|
||||
|
||||
doCopy();
|
||||
};
|
||||
|
||||
// 辅助方法:判断是否为当前版本(模板中使用)
|
||||
const isCurrentVersion = (version: string) => {
|
||||
return version.includes('(当前版本)');
|
||||
};
|
||||
|
||||
// 辅助方法:判断是否为最新版本(模板中使用)
|
||||
const isLatestVersion = (version: string) => {
|
||||
return version.includes('(最新版)');
|
||||
};
|
||||
|
||||
// 复制仓库地址方法(保留原有逻辑,无改动)
|
||||
const copyGitUrl = (repo: { name: string; url: string; color: string }) => {
|
||||
gitCopyLoading.value[repo.url] = true;
|
||||
const doCopy = async () => {
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(repo.url);
|
||||
message.success(`已复制 ${repo.name} 地址`);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.style.position = 'absolute';
|
||||
textarea.style.top = '-9999px';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.value = repo.url;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
|
||||
if (success) {
|
||||
message.success(`已复制 ${repo.name} 地址`);
|
||||
} else {
|
||||
throw new Error('execCommand 复制失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.warning(`复制 ${repo.name} 地址失败,请手动复制`);
|
||||
console.warn(`复制 ${repo.name} 地址失败:`, error);
|
||||
} finally {
|
||||
gitCopyLoading.value[repo.url] = false;
|
||||
}
|
||||
};
|
||||
|
||||
doCopy();
|
||||
};
|
||||
|
||||
// 新增:统一处理版本数据的方法(提取公共逻辑,避免冗余)
|
||||
const handleVersionData = (rawData: string[]) => {
|
||||
const newVersions = [...rawData];
|
||||
const versionLen = newVersions.length;
|
||||
|
||||
if (versionLen > 0) {
|
||||
if (versionLen === 1) {
|
||||
newVersions[0] = `${newVersions[0]}(最新版)`;
|
||||
hasNewVersion.value = false; // 只有一个版本,无新版
|
||||
} else {
|
||||
newVersions[0] = `${newVersions[0]}(当前版本)`;
|
||||
const lastIndex = versionLen - 1;
|
||||
newVersions[lastIndex] = `${newVersions[lastIndex]}(最新版)`;
|
||||
// 判断是否有新版本:当前版本 !== 最新版本(去除备注后对比)
|
||||
const currentVersion = newVersions[0].replace('(当前版本)', '').trim();
|
||||
const latestVersion = newVersions[lastIndex].replace('(最新版)', '').trim();
|
||||
hasNewVersion.value = currentVersion !== latestVersion;
|
||||
}
|
||||
} else {
|
||||
hasNewVersion.value = false;
|
||||
}
|
||||
|
||||
return newVersions;
|
||||
};
|
||||
|
||||
// 新增:保存版本数据到 localStorage
|
||||
const saveVersionToLocalStorage = (versionData: string[]) => {
|
||||
try {
|
||||
localStorage.setItem(VERSION_CACHE_KEY, JSON.stringify(versionData));
|
||||
} catch (error) {
|
||||
console.warn('版本数据缓存到 localStorage 失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 新增:从 localStorage 获取缓存的版本数据
|
||||
const getVersionFromLocalStorage = (): string[] => {
|
||||
try {
|
||||
const cacheData = localStorage.getItem(VERSION_CACHE_KEY);
|
||||
if (cacheData) {
|
||||
return JSON.parse(cacheData) as string[];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('从 localStorage 读取版本缓存失败:', error);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// 改造:版本查看方法(获取数据后更新缓存,控制 popover 显隐)
|
||||
const showVersionNotification = () => {
|
||||
useApiStore()
|
||||
.CheckTag()
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
// 处理版本数据(添加备注、判断新版)
|
||||
const processedVersions = handleVersionData(res.data);
|
||||
dyVersions.value = processedVersions;
|
||||
// 点击后更新 localStorage 缓存
|
||||
saveVersionToLocalStorage(processedVersions);
|
||||
// 打开版本 popover
|
||||
versionPopoverVisible.value = true;
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
message.error('获取版本列表失败');
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
// 新增:页面加载时自动调用接口获取版本并缓存
|
||||
const fetchVersionOnMount = () => {
|
||||
useApiStore()
|
||||
.CheckTag()
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
// 处理版本数据(添加备注、判断新版)
|
||||
const processedVersions = handleVersionData(res.data);
|
||||
dyVersions.value = processedVersions;
|
||||
// 页面加载时缓存到 localStorage
|
||||
saveVersionToLocalStorage(processedVersions);
|
||||
} else {
|
||||
// 接口失败时,尝试读取本地缓存
|
||||
const cacheVersions = getVersionFromLocalStorage();
|
||||
if (cacheVersions.length > 0) {
|
||||
dyVersions.value = handleVersionData(cacheVersions);
|
||||
}
|
||||
message.error(res.message);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
// 接口异常时,尝试读取本地缓存
|
||||
const cacheVersions = getVersionFromLocalStorage();
|
||||
if (cacheVersions.length > 0) {
|
||||
dyVersions.value = handleVersionData(cacheVersions);
|
||||
}
|
||||
message.error('获取版本列表失败,已加载本地缓存');
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
// 全局注入加载动画样式
|
||||
onMounted(() => {
|
||||
// 页面加载时自动调用版本接口
|
||||
fetchVersionOnMount();
|
||||
|
||||
if (!document.querySelector('#custom-spin-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'custom-spin-style';
|
||||
style.textContent = `
|
||||
@keyframes custom-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
});
|
||||
|
||||
// Git 弹窗控制方法
|
||||
const showGit = () => {
|
||||
popoverVisible.value = !popoverVisible.value;
|
||||
console.log('打开开源地址弹窗');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- <StepinHeaderAction>
|
||||
<a-input placeholder="开始搜索...">
|
||||
<template #prefix>
|
||||
<search-outlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</StepinHeaderAction> -->
|
||||
<StepinHeaderAction>
|
||||
<DayNightSwitch />
|
||||
</StepinHeaderAction>
|
||||
<!-- <StepinHeaderAction> -->
|
||||
<!-- <a-tooltip> -->
|
||||
<!-- placement="rightTop" -->
|
||||
<!-- <template #title><span style="color:yellow">Swagger Api</span></template> -->
|
||||
<!-- <a class="action-item" href="/swagger" target="_blank"> -->
|
||||
<!-- <ApiOutlined /> -->
|
||||
<!-- <img class="gitee-logo" alt="swagger api" src="@/assets/swagger.png" /> -->
|
||||
<!-- </a> -->
|
||||
<!-- </a-tooltip> -->
|
||||
|
||||
<!-- </StepinHeaderAction> -->
|
||||
<!-- <StepinHeaderAction>
|
||||
<a class="action-item" href="http://github.com/stepui/stepin-template" target="_blank">
|
||||
<GithubOutlined />
|
||||
</a>
|
||||
</StepinHeaderAction>
|
||||
<!-- 版本查看:改造为 Badge 包裹图标,实现气泡提示 -->
|
||||
<StepinHeaderAction>
|
||||
<a class="action-item" href="http://gitee.com/stepui/stepin-template" target="_blank">
|
||||
<img class="gitee-logo" src="@/assets/gitee.svg" />
|
||||
</a>
|
||||
</StepinHeaderAction> -->
|
||||
<!-- <StepinHeaderAction>
|
||||
<div class="action-item setting" @click="$emit('showSetting')">
|
||||
<SettingOutlined />
|
||||
</div>
|
||||
</StepinHeaderAction> -->
|
||||
|
||||
<!-- <a-popover placement="bottomRight">
|
||||
<StepinHeaderAction>
|
||||
<div class="action-item notice">
|
||||
<BellOutlined />
|
||||
</div>
|
||||
</StepinHeaderAction>
|
||||
<div class="action-item">
|
||||
<a-popover v-model:visible="versionPopoverVisible" placement="bottom" trigger="click" overlay-class="version-popover-overlay" @visible-change="(visible) => versionPopoverVisible = visible">
|
||||
<template #content>
|
||||
<Notice :data-source="noticeList" />
|
||||
<div class="version-popover-content">
|
||||
<!-- 版本列表:纯模板 v-for 实现,替代 VNode 渲染 -->
|
||||
<div class="custom-version-list" v-if="dyVersions.length > 0">
|
||||
<div class="custom-version-item" :class="{
|
||||
'custom-current-version': isCurrentVersion(version),
|
||||
'custom-latest-version': isLatestVersion(version)
|
||||
}" v-for="(version, index) in dyVersions" :key="index" :title="isCurrentVersion(version)
|
||||
? '当前使用版本 - 点击右侧按钮复制版本号'
|
||||
: isLatestVersion(version)
|
||||
? '最新版本 - 点击右侧按钮复制版本号'
|
||||
: '点击右侧按钮复制版本号'">
|
||||
<!-- 版本文本 -->
|
||||
<div class="version-text">
|
||||
<span :style="{
|
||||
color: isCurrentVersion(version) ? '#1890ff' : isLatestVersion(version) ? '#2e7d32' : '#1f2937'
|
||||
}">
|
||||
{{ version }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 复制按钮 -->
|
||||
<button class="version-copy-btn" :disabled="copyLoading[version]" @click.stop="copyVersion(version)" :title="copyLoading[version] ? '复制中...' : '复制版本号'">
|
||||
<CopyOutlined :style="{
|
||||
fontSize: '14px',
|
||||
color: copyLoading[version]
|
||||
? '#d1d5db'
|
||||
: isCurrentVersion(version)
|
||||
? '#1890ff'
|
||||
: isLatestVersion(version)
|
||||
? '#2e7d32'
|
||||
: '#9ca3af',
|
||||
animation: copyLoading[version] ? 'custom-spin 1s linear infinite' : 'none'
|
||||
}" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 暂无版本数据 -->
|
||||
<div class="no-version-data" v-else>
|
||||
暂无版本数据
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-popover> -->
|
||||
<!-- <StepinHeaderAction>
|
||||
<!-- <a-tooltip placement="bottom"> -->
|
||||
<template #title>
|
||||
<!-- <span>版本查看</span> -->
|
||||
</template>
|
||||
<!-- 改造:用 Badge 包裹图标,有新版时显示气泡提示 -->
|
||||
<a-badge :dot="hasNewVersion">
|
||||
<TagsOutlined class="action-icon" @click="showVersionNotification" />
|
||||
</a-badge>
|
||||
<!-- </a-tooltip> -->
|
||||
</a-popover>
|
||||
</div>
|
||||
</StepinHeaderAction>
|
||||
|
||||
<!-- Git 开源地址弹窗(保留原有实现) -->
|
||||
<StepinHeaderAction>
|
||||
<div class="action-item">
|
||||
<a-popover v-model:visible="popoverVisible" placement="bottom" trigger="click" overlay-class="git-popover-overlay" @visible-change="(visible) => popoverVisible = visible">
|
||||
<template #content>
|
||||
<div class="git-popover-content">
|
||||
<div class="git-repo-item" v-for="repo in gitRepos" :key="repo.url">
|
||||
<div class="git-repo-name" :style="{ color: repo.color }">
|
||||
<span class="git-repo-tag" :style="{ backgroundColor: repo.color }"></span>
|
||||
{{ repo.name }}
|
||||
</div>
|
||||
<div class="git-repo-url-wrapper">
|
||||
<a :href="repo.url" target="_blank" class="git-repo-url" :style="{ color: repo.color }" title="点击打开仓库地址">
|
||||
{{ repo.url }}
|
||||
</a>
|
||||
<button class="git-copy-btn" :disabled="gitCopyLoading[repo.url]" @click.stop="copyGitUrl(repo)" title="复制仓库地址">
|
||||
<CopyOutlined :style="{
|
||||
fontSize: '12px',
|
||||
color: gitCopyLoading[repo.url] ? '#d1d5db' : repo.color,
|
||||
animation: gitCopyLoading[repo.url] ? 'custom-spin 1s linear infinite' : 'none'
|
||||
}" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tooltip placement="bottom">
|
||||
<template #title>
|
||||
<span>项目开源地址</span>
|
||||
</template>
|
||||
<GithubOutlined class="action-icon" @click="showGit" />
|
||||
</a-tooltip>
|
||||
</a-popover>
|
||||
</div>
|
||||
</StepinHeaderAction>
|
||||
|
||||
<StepinHeaderAction>
|
||||
<Fullscreen class="-mx-xs -my-sm h-[56px] px-xs py-sm flex items-center" target=".stepin-layout" />
|
||||
</StepinHeaderAction> -->
|
||||
</StepinHeaderAction>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.gitee-logo {
|
||||
width: 20px;
|
||||
}
|
||||
/* 顶部按钮样式 */
|
||||
.action-item {
|
||||
font-size: 20px;
|
||||
height: 100%;
|
||||
@@ -88,13 +363,225 @@ const noticeList = reactive([
|
||||
line-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
&.setting {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&.notice {
|
||||
font-size: 18px;
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* 版本 Popover 样式(纯模板适配) */
|
||||
:deep(.version-popover-overlay) {
|
||||
.ant-popover-inner {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
width: 520px;
|
||||
}
|
||||
|
||||
.ant-popover-arrow-content {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.version-popover-content {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 版本列表(模板对应样式) */
|
||||
.custom-version-list {
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.custom-version-item {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
white-space: nowrap;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5fafe;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 当前版本/最新版本高亮 */
|
||||
.custom-current-version {
|
||||
background-color: rgba(24, 144, 255, 0.05) !important;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(24, 144, 255, 0.1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-latest-version {
|
||||
background-color: rgba(46, 125, 50, 0.05) !important;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(46, 125, 50, 0.1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 版本文本容器 */
|
||||
.version-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: calc(100% - 40px); /* 修正:原 maxwidth 拼写错误 */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 版本复制按钮 */
|
||||
.version-copy-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(24, 144, 255, 0.1);
|
||||
color: #1890ff !important;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暂无版本数据 */
|
||||
.no-version-data {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 20px 0;
|
||||
font-size: 14px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 8px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
/* Git Popover 样式 */
|
||||
:deep(.git-popover-overlay) {
|
||||
.ant-popover-inner {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
.ant-popover-arrow-content {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.git-popover-content {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.git-repo-item {
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.git-repo-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.git-repo-tag {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.git-repo-url-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.git-repo-url {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.git-copy-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* 根容器样式 */
|
||||
.header-actions-root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,41 +1,137 @@
|
||||
<script lang="ts" setup></script>
|
||||
<script lang="ts" setup>
|
||||
import { GithubOutlined, CopyrightOutlined } from '@ant-design/icons-vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useApiStore } from '@/store';
|
||||
|
||||
// 响应式变量存储年份文本
|
||||
const yearText = ref('');
|
||||
const currentVersion = ref(); // 已存在的版本号响应式变量
|
||||
onMounted(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const startYear = 2025;
|
||||
|
||||
if (currentYear <= startYear) {
|
||||
yearText.value = startYear.toString();
|
||||
} else {
|
||||
yearText.value = `${startYear}-${currentYear}`;
|
||||
}
|
||||
useApiStore()
|
||||
.getVer()
|
||||
.then((r) => {
|
||||
if (r.code == 0) {
|
||||
currentVersion.value = r.data; // 接口返回后赋值版本号
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-footer">
|
||||
<div class="links">
|
||||
<!-- <a class="link" href="https://github.com/stepui/stepin-template" target="_blank"> Stepin 首页 </a> -->
|
||||
<!-- <a class="link" href="https://github.com/stepui/stepin-template" target="_blank">
|
||||
<GithubOutlined />
|
||||
</a> -->
|
||||
<!-- <a class="link" href="https://www.antdv.com/docs/vue/introduce-cn/" target="_blank"> Ant Design </a> -->
|
||||
</div>
|
||||
<footer class="page-footer">
|
||||
<div class="footer-content">
|
||||
<!-- 版权信息容器 -->
|
||||
<div class="copyright">
|
||||
Copyright
|
||||
<CopyrightOutlined class="icon-copyright" />
|
||||
2023 dy.net
|
||||
<span>{{ yearText }} 抖小云 dysync.net. </span>
|
||||
|
||||
<!-- 将社交链接移动到这里 -->
|
||||
<!-- <div class="social-links">
|
||||
<a href="https://github.com/jianzhichu/dysync.net" target="_blank" rel="noopener noreferrer" aria-label="访问我们的 GitHub 仓库" class="social-link">
|
||||
<GithubOutlined />
|
||||
</a>
|
||||
<a href="https://gitee.com/deathvicky/dysync.net" target="_blank" rel="noopener noreferrer" aria-label="访问我们的 Gitee 仓库" class="social-link">
|
||||
<img class="gitee-icon" src="@/assets/gitee.svg" alt="Gitee" />
|
||||
</a>
|
||||
</div> -->
|
||||
|
||||
<!-- 新增:显示当前版本号,增加样式分隔提升美观度 -->
|
||||
<span class="version-text" v-if="currentVersion">{{currentVersion.deploy}}_{{ currentVersion.tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.page-footer {
|
||||
text-align: center;
|
||||
@apply text-gray-400;
|
||||
// 使用 :root 变量,便于主题切换
|
||||
--footer-text-color: var(--text-color-secondary, #999);
|
||||
--footer-hover-color: var(--primary-color, #1890ff);
|
||||
--version-text-color: var(--text-color-tertiary, #666);
|
||||
|
||||
.links {
|
||||
width: 100%;
|
||||
color: var(--footer-text-color);
|
||||
padding: 24px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
|
||||
.footer-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 版权信息现在是一个父 flex 容器
|
||||
.copyright {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
// 增大 gap 以分隔文字和社交链接组
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.version-text {
|
||||
// 核心修改1:白色文字(醒目,与绿底形成强烈对比)
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px; // 微调内边距,让绿底区域更饱满,视觉更舒适
|
||||
// 核心修改2:柔和绿色背景(两种可选方案,不刺眼、显高级)
|
||||
// 方案1:推荐!清新柔和绿(适配大多数页面,不张扬,高级感足)
|
||||
background-color: #722ed1;
|
||||
// 方案2:稍浅薄荷绿(更明亮,醒目度稍高,适合活泼风格页面)
|
||||
// background-color: #73d13d;
|
||||
border-radius: 4px; // 适当增大圆角,更显圆润柔和
|
||||
// 核心修改3:调整光效为白色光晕(匹配白字,提升质感,不突兀)
|
||||
// text-shadow: 0 0 3px rgba(255, 255, 255, 0.8), 0 0 6px rgba(255, 255, 255, 0.4);
|
||||
// 可选优化:轻微暗角阴影,让绿底更有层次感,不扁平
|
||||
// box-shadow: 0 1px 2px rgba(82, 196, 26, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
// 社交链接组自身也是一个 flex 容器
|
||||
.social-links {
|
||||
display: flex;
|
||||
// 为两个图标之间设置间距
|
||||
gap: 16px;
|
||||
// 移除了原来的 margin-bottom
|
||||
|
||||
.link {
|
||||
@apply hover:text-gray-400 pl-4 pr-4;
|
||||
.social-link {
|
||||
color: inherit;
|
||||
transition: color 0.3s ease, transform 0.2s ease;
|
||||
display: inline-flex;
|
||||
opacity: 0.8;
|
||||
align-items: center;
|
||||
// 移除了图标和文字的 gap,因为现在没有文字了
|
||||
|
||||
&:hover {
|
||||
color: var(--footer-hover-color);
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
svg,
|
||||
img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.copyright {
|
||||
margin-top: 8px;
|
||||
|
||||
.icon-copyright {
|
||||
margin: 0;
|
||||
}
|
||||
.gitee-icon {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<!-- components/VersionDrawer.vue -->
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { Drawer } from 'ant-design-vue';
|
||||
import { CloseOutlined, CopyOutlined, CheckCircleOutlined } from '@ant-design/icons-vue';
|
||||
import { useApiStore } from '@/store';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
// ✅ 移除 defineProps/defineEmits 导入,直接使用
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
currentVersion?: string; // 接收当前版本号
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
// 组件挂载时获取配置
|
||||
onMounted(() => {});
|
||||
|
||||
const getVersions = () => {
|
||||
useApiStore()
|
||||
.CheckTag()
|
||||
.then((res) => {
|
||||
if (res.code == 1) {
|
||||
dyVersions.value = res.data;
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 版本数据
|
||||
const dyVersions = ref<string[]>([]);
|
||||
|
||||
// 关闭抽屉
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
// 复制版本号
|
||||
const copyVersion = (version: string) => {
|
||||
navigator.clipboard
|
||||
.writeText(version)
|
||||
.then(() => {
|
||||
message.success(`已复制版本: ${version}`);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error('复制失败,请手动复制');
|
||||
});
|
||||
};
|
||||
|
||||
// 调试:监听 visible 变化,确认父组件传递的状态是否正确
|
||||
console.log('抽屉初始状态:', props.visible);
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
console.log('抽屉 visible 变化:', val);
|
||||
if (val) {
|
||||
getVersions();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 强制添加样式,确保不被隐藏 -->
|
||||
<a-drawer title="版本列表" placement="right" :visible="props.visible" @close="handleClose" :width="450" :mask="true" :mask-closable="true" :z-index="9999" class="version-drawer">
|
||||
<div class="version-list">
|
||||
<div v-for="(tag, index) in dyVersions" :key="index" class="version-item">
|
||||
<div class="version-info">
|
||||
{{ tag }}
|
||||
<!-- 当前版本标记 -->
|
||||
<span v-if="props.currentVersion === tag" class="current-version-tag">
|
||||
<CheckCircleOutlined class="current-icon" />
|
||||
当前版本
|
||||
</span>
|
||||
</div>
|
||||
<!-- 复制按钮 -->
|
||||
<a-tooltip title="复制版本号" placement="top">
|
||||
<div class="copy-btn" @click.stop="copyVersion(tag)">
|
||||
<CopyOutlined />
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div v-if="dyVersions.length === 0" class="empty-tip">暂无版本数据</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="close-btn" @click="handleClose">
|
||||
<CloseOutlined /> 关闭版本列表
|
||||
</div> -->
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
/* 强制显示抽屉容器,避免被全局样式隐藏 */
|
||||
.version-drawer {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
:deep(.ant-drawer-content-wrapper) {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
z-index: 9999 !important; /* 确保层级最高 */
|
||||
}
|
||||
|
||||
.version-list {
|
||||
padding: 20px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.version-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 当前版本标记样式 */
|
||||
.current-version-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.current-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 复制按钮样式 */
|
||||
.copy-btn {
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 调整tooltip样式,避免被遮挡 */
|
||||
:deep(.ant-tooltip) {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,6 @@ export type Type = 'day' | 'night';
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: String as PropType<Type> },
|
||||
// nightColor: { type: String, default: '#1D1D1D' },
|
||||
nightColor: { type: String, default: '#1a1a2e' },
|
||||
});
|
||||
|
||||
@@ -17,37 +16,34 @@ const emit = defineEmits<{
|
||||
(e: 'update:value', value: Type): void;
|
||||
}>();
|
||||
|
||||
const STORAGE_KEY = 'theme-mode';
|
||||
|
||||
const getInitialValue = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const cachedValue = localStorage.getItem(STORAGE_KEY);
|
||||
if (cachedValue === 'day' || cachedValue === 'night') {
|
||||
return cachedValue;
|
||||
}
|
||||
}
|
||||
return props.value || 'day';
|
||||
};
|
||||
|
||||
const { value: _value } = useModelValue(
|
||||
() => props.value,
|
||||
(val) => emit('update:value', val),
|
||||
'day'
|
||||
getInitialValue()
|
||||
);
|
||||
|
||||
const switcher: { [key in Type]: Type } = {
|
||||
day: 'night',
|
||||
night: 'day',
|
||||
};
|
||||
|
||||
const { theme } = storeToRefs(useThemeStore());
|
||||
let cachedMiddleColors = {};
|
||||
|
||||
// 监听主题色变换,更新缓存
|
||||
let cachedMiddleColors = cloneDeep(theme.value.color.middle);
|
||||
watch(
|
||||
theme,
|
||||
(val) => {
|
||||
if (val.color.middle['bg-base'] !== props.nightColor) {
|
||||
cachedMiddleColors = cloneDeep(val.color.middle);
|
||||
_value.value = 'day';
|
||||
} else {
|
||||
_value.value = 'night';
|
||||
}
|
||||
console.log('theme', _value.value);
|
||||
updateHtmlClass(_value.value);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 在你的主题store中添加切换逻辑
|
||||
const updateHtmlClass = (mode) => {
|
||||
// --- 修正点 1: 函数定义移到 watch 之前 ---
|
||||
const updateHtmlClass = (mode: Type) => {
|
||||
const html = document.documentElement;
|
||||
if (mode === 'night') {
|
||||
html.classList.add('dark-mode');
|
||||
@@ -55,14 +51,46 @@ const updateHtmlClass = (mode) => {
|
||||
html.classList.remove('dark-mode');
|
||||
}
|
||||
};
|
||||
// 主题颜色配置
|
||||
|
||||
// 监听 theme bg-base 变化,用于同步外部修改
|
||||
watch(
|
||||
() => theme.value?.color?.middle?.['bg-base'],
|
||||
(newBgColor) => {
|
||||
if (newBgColor) {
|
||||
if (newBgColor === props.nightColor) {
|
||||
_value.value = 'night';
|
||||
} else {
|
||||
_value.value = 'day';
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// --- 修正点 2: watch 现在可以安全地调用 updateHtmlClass ---
|
||||
watch(
|
||||
_value,
|
||||
(newMode) => {
|
||||
// console.log('mode switched to:', newMode);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, newMode);
|
||||
}
|
||||
updateHtmlClass(newMode);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// --- 修正点 3: 惰性缓存白天模式颜色 ---
|
||||
const colorCfg = computed(() => {
|
||||
if (_value.value === 'day') {
|
||||
if (Object.keys(cachedMiddleColors).length === 0) {
|
||||
cachedMiddleColors = cloneDeep(theme.value?.color?.middle || {});
|
||||
}
|
||||
return { middle: cachedMiddleColors };
|
||||
}
|
||||
return { middle: { 'bg-base': props.nightColor } };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ThemeProvider is-root :color="colorCfg">
|
||||
<div @click="() => (_value = switcher[_value])" class="bg-fill-2 day-night-switch hover:border-border relative border-border-2 text-lg rounded-full border border-solid flex items-center">
|
||||
@@ -72,6 +100,7 @@ const colorCfg = computed(() => {
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.day-night-switch {
|
||||
.spot {
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
<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/play/${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: 45vh;
|
||||
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>
|
||||
|
||||
<style lang="less">
|
||||
// 长视频描述作为标题时限 2 行显示,避免撑爆 modal header
|
||||
.video-player-modal .modal-title-text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref, watch, UnwrapRef, onMounted } from 'vue';
|
||||
import { FormInstance } from 'ant-design-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useApiStore, useAccountStore } from '@/store';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { ExclamationCircleOutlined, SyncOutlined, SaveOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
interface UpSecUserIdItem {
|
||||
uper?: string;
|
||||
uid?: string;
|
||||
syncAll: boolean;
|
||||
}
|
||||
|
||||
type ConfigItem = {
|
||||
id?: string;
|
||||
userName: string; // 改为非可选,强制初始化
|
||||
cookies: string;
|
||||
savePath: string;
|
||||
favSavePath: string;
|
||||
secUserId: string;
|
||||
status: number;
|
||||
upSavePath: string;
|
||||
// imgSavePath: string;
|
||||
useSinglePath: boolean; // 非可选
|
||||
};
|
||||
|
||||
// 强制初始化所有字段为非undefined值
|
||||
const newConfig = (config?: ConfigItem): ConfigItem => {
|
||||
return {
|
||||
id: '0',
|
||||
userName: '', // 强制空字符串
|
||||
cookies: '',
|
||||
savePath: '',
|
||||
favSavePath: '',
|
||||
secUserId: '',
|
||||
status: 0,
|
||||
upSavePath: '',
|
||||
// imgSavePath: '',
|
||||
useSinglePath: false,
|
||||
};
|
||||
};
|
||||
|
||||
// 改用 ref 而非 reactive(解决深层响应式问题)
|
||||
const form = ref<ConfigItem>(newConfig());
|
||||
const formModel = ref<FormInstance>();
|
||||
const formLoading = ref(false);
|
||||
|
||||
// 重置表单:强制重置 + 清空校验状态
|
||||
const resetConfig = () => {
|
||||
form.value = newConfig();
|
||||
formModel.value?.resetFields(); // 必须调用,清空校验提示
|
||||
message.info('表单已重置为默认值');
|
||||
};
|
||||
|
||||
// 监听统一路径(改用 ref 的取值方式)
|
||||
watch(
|
||||
[() => form.value.savePath, () => form.value.useSinglePath],
|
||||
([newSavePath, useSinglePath]) => {
|
||||
if (useSinglePath && newSavePath) {
|
||||
form.value.favSavePath = newSavePath;
|
||||
form.value.upSavePath = newSavePath;
|
||||
// form.value.imgSavePath = newSavePath;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 校验规则:明确trigger + 不依赖组件自动触发
|
||||
const formRules = reactive({
|
||||
userName: [{ required: true, message: '请输入Cookie名称', trigger: 'change' }],
|
||||
savePath: [{ required: true, message: '请输入收藏存储路径', trigger: 'change' }],
|
||||
secUserId: [{ required: false, message: '请输入我的secUserId', trigger: 'change' }],
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
// 定义rowCount并暴露到模板(之前定义在onMounted里,模板访问不到)
|
||||
const rowCount = ref(6);
|
||||
|
||||
onMounted(() => {
|
||||
const accountStore = useAccountStore(); // 移到内部避免提前引用
|
||||
useApiStore()
|
||||
.AppisInit()
|
||||
.then((res) => {
|
||||
if (res.code == 0 && res.data) {
|
||||
if (accountStore.logged) {
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
router.push('/login');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const accountStore = useAccountStore();
|
||||
|
||||
// 提交:手动指定校验字段 + 强制取值
|
||||
const submitConfig = async () => {
|
||||
formLoading.value = true;
|
||||
try {
|
||||
// 手动获取表单实例,防止null
|
||||
const formInstance = formModel.value;
|
||||
if (!formInstance) {
|
||||
message.error('表单实例未初始化');
|
||||
formLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 手动校验(兜底方案)
|
||||
const checkResult = manualCheckForm();
|
||||
if (!checkResult.pass) {
|
||||
message.error(checkResult.msg);
|
||||
formLoading.value = false;
|
||||
return;
|
||||
}
|
||||
// 提交时强制解构,确保传递最新值
|
||||
const res = await useApiStore().DeskInitAsync({ ...form.value });
|
||||
if (res.code === 0) {
|
||||
message.success('配置保存成功,即将跳转登录页...');
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 500);
|
||||
} else {
|
||||
message.error(`保存失败:${res.message || '未知错误'}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('校验失败:', err);
|
||||
const firstErr = err.errorFields?.[0]?.errors?.[0] || '请检查所有必填项';
|
||||
message.error(firstErr);
|
||||
} finally {
|
||||
formLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 手动校验函数(兜底方案)
|
||||
const manualCheckForm = (): { pass: boolean; msg: string } => {
|
||||
if (!form.value.userName.trim()) {
|
||||
return { pass: false, msg: '请输入Cookie名称' };
|
||||
}
|
||||
if (!form.value.savePath.trim()) {
|
||||
return { pass: false, msg: '请输入收藏存储路径' };
|
||||
}
|
||||
return { pass: true, msg: '' };
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="init-config-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="logo-container">
|
||||
<img src="/logo.png" alt="抖小云Logo" class="logo-img" />
|
||||
</div>
|
||||
<h1 class="page-title">抖小云初始化配置信息</h1>
|
||||
<!-- <p class="page-desc">第一次运行,请花两分钟完成初始配置</p> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card">
|
||||
<a-form ref="formModel" :model="form" :rules="formRules" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }" layout="horizontal" class="config-form" @submit.prevent="submitConfig">
|
||||
|
||||
<!-- Cookie名称 -->
|
||||
<a-form-item label="Cookie名" name="userName">
|
||||
<a-input v-model:value="form.userName" placeholder="随便填个名称,作为唯一标识" @input="() => {}" />
|
||||
</a-form-item>
|
||||
|
||||
<!-- Cookie值:现在rows能正常生效了 -->
|
||||
<a-form-item label="Cookie值" name="cookies">
|
||||
<div class="cookie-wrapper">
|
||||
<a-textarea v-model:value="form.cookies" :rows="rowCount" placeholder="粘贴完整Cookie值,也可以先不填,保存配置后,后续可以在“抖音授权”修改" />
|
||||
<div class="cookie-tip">
|
||||
<a href="https://gitee.com/deathvicky/dysync.net" target="_blank" class="cookie-link">
|
||||
查看Cookie以及secUserId获取方式
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- secUserId -->
|
||||
<a-form-item label="我的secUserId" name="secUserId">
|
||||
<div class="input-with-tip">
|
||||
<a-input v-model:value="form.secUserId" placeholder="同步喜欢视频和关注博主视频必填" @input="() => {}" />
|
||||
<a-tooltip title="同步喜欢视频和关注博主视频必填">
|
||||
<ExclamationCircleOutlined style="color: #faad14; margin-left: 8px; font-size: 16px;" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 收藏路径 -->
|
||||
<a-form-item label="收藏存储路径" name="savePath">
|
||||
<div class="input-with-tip">
|
||||
<a-input v-model:value="form.savePath" placeholder="/app/collect" @input="() => {}" />
|
||||
<a-tooltip title="必填">
|
||||
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px; margin-left: 8px; " />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 统一路径开关 -->
|
||||
<a-form-item label="统一存储路径" name="useSinglePath">
|
||||
<div class="switch-with-desc">
|
||||
<a-switch v-model:checked="form.useSinglePath" class="custom-large-switch" @change="() => {}" />
|
||||
<div class="switch-desc">
|
||||
<span class="main-desc">{{ form.useSinglePath ? '开启' : '关闭' }}</span>
|
||||
<span class="sub-desc">{{ form.useSinglePath ? '所有类型视频(收藏的、喜欢的、关注博主的)共用一个路径' : '各类型视频(收藏的、喜欢的、关注博主的)文件路径独立配置' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<!-- 其他路径 -->
|
||||
<a-form-item v-if="!form.useSinglePath" label="喜欢存储路径" name="favSavePath">
|
||||
<a-input v-model:value="form.favSavePath" placeholder="喜欢视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="!form.useSinglePath" label="关注存储路径" name="upSavePath">
|
||||
<a-input v-model:value="form.upSavePath" placeholder="关注视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" />
|
||||
</a-form-item>
|
||||
|
||||
<!-- <a-form-item v-if="!form.useSinglePath" label="图文存储路径" name="imgSavePath">
|
||||
<a-input v-model:value="form.imgSavePath" placeholder="图文视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" />
|
||||
</a-form-item> -->
|
||||
|
||||
<!-- 同步状态开关 -->
|
||||
<a-form-item label="同步状态" name="status">
|
||||
<div class="switch-with-desc">
|
||||
<a-switch v-model:checked="form.status" :checked-value="1" :un-checked-value="0" class="custom-large-switch" />
|
||||
<div class="switch-desc">
|
||||
<span class="main-desc">{{ form.status === 1 ? '开启' : '关闭' }}</span>
|
||||
<span class="sub-desc">建议初始不开启,保存设置后,进入系统配置完成其他设置后再手动开启!</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<a-form-item :wrapper-col="{ span: 24 }" class="form-actions">
|
||||
<div class="btn-center-wrapper">
|
||||
<a-button type="primary" html-type="submit" :loading="formLoading" class="save-btn">
|
||||
<template #icon>
|
||||
<SaveOutlined />
|
||||
</template>保存配置
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.init-config-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f7fa;
|
||||
padding: 10px 50px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.page-header {
|
||||
margin-bottom: 10px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.header-content {
|
||||
text-align: center;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
}
|
||||
.logo-container {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.logo-img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: contain;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.page-desc {
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
.config-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
padding: 20px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.config-form {
|
||||
--ant-form-item-margin-bottom: 24px;
|
||||
}
|
||||
.input-with-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.switch-with-desc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.switch-desc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.switch-desc .main-desc {
|
||||
font-size: 15px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
.switch-desc .sub-desc {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
:deep(.custom-large-switch) {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
.form-actions {
|
||||
margin-top: 30px !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.btn-center-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 16px;
|
||||
}
|
||||
.save-btn {
|
||||
padding: 0 40px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
background-color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
.save-btn:hover {
|
||||
background-color: #4096ff;
|
||||
border-color: #4096ff;
|
||||
}
|
||||
|
||||
/* 文本域恢复默认,让rows生效 */
|
||||
:deep(.ant-input-textarea) {
|
||||
font-size: 15px;
|
||||
height: auto !important; /* 取消固定高度 */
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
/* Cookie容器和链接样式 */
|
||||
.cookie-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.cookie-tip {
|
||||
text-align: left;
|
||||
}
|
||||
.cookie-link {
|
||||
color: #1677ff;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cookie-link:hover {
|
||||
color: #4096ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.init-config-page {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.config-card {
|
||||
padding: 20px;
|
||||
}
|
||||
.logo-img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.switch-with-desc {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.switch-desc {
|
||||
gap: 4px;
|
||||
}
|
||||
:deep(.custom-large-switch) {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
:deep(.ant-input-textarea-input) {
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(150, 150, 150, 0.2) transparent;
|
||||
}
|
||||
:deep(.ant-input-textarea-input)::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
:deep(.ant-input-textarea-input)::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
:deep(.ant-input-textarea-input)::-webkit-scrollbar-thumb {
|
||||
background: rgba(150, 150, 150, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
:deep(.ant-input-disabled) {
|
||||
background-color: #f9fafb !important;
|
||||
color: #6b7280 !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -1,129 +0,0 @@
|
||||
<template>
|
||||
<a-card :bordered="false" :bodyStyle='{"padding-top":"0px","padding-bottom":"100px"}'>
|
||||
<a-form :model="formState" :label-col="labelCol" :rules="rules" :wrapper-col="wrapperCol" ref="formRef">
|
||||
<a-divider orientation="left"></a-divider>
|
||||
|
||||
<a-form-item has-feedback label="同步周期(分钟)" ref="Cron" name="Cron">
|
||||
<a-input v-model:value="formState.Cron" placeholder="1:数字-例如20-表示20分钟执行一次;2:cron表达式,根据表达式周期执行" />
|
||||
</a-form-item>
|
||||
<a-form-item label="在线Cron表达式">
|
||||
<a target="_blank" href="https://www.bejson.com/othertools/cron/">查看示例</a>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :wrapper-col="{ span: 10, offset: 3 }">
|
||||
<a-button type="primary" danger @click="onSubmit">进入系统</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { reactive, toRaw, ref, watch, createVNode, h } from 'vue';
|
||||
import type { UnwrapRef } from 'vue';
|
||||
import { Form } from 'ant-design-vue';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import { useApiStore } from '@/store';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import { checkDomain, checksubDomainPrefix, checkPass, checkUserName } from '@/utils/regexHelper';
|
||||
const formRef = ref<FormInstance>();
|
||||
const SK = ref(null);
|
||||
interface FormState {
|
||||
cloudName: string;
|
||||
domainName: string;
|
||||
recordType: string;
|
||||
ipv6Prefix: string;
|
||||
domainRecord: string;
|
||||
AK: string;
|
||||
SK: string;
|
||||
DbType: number;
|
||||
ConnString: string;
|
||||
Cron: string;
|
||||
UserName: string;
|
||||
UswePwd: string;
|
||||
}
|
||||
interface CloudInfo {
|
||||
key: string;
|
||||
value: string;
|
||||
doc: string;
|
||||
}
|
||||
interface DbTypeInfo {
|
||||
key: number;
|
||||
value: string;
|
||||
conn: string;
|
||||
}
|
||||
interface showDBconn {
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
const showDBconn: UnwrapRef<showDBconn> = reactive({
|
||||
value: false,
|
||||
});
|
||||
|
||||
const formState: UnwrapRef<FormState> = reactive({
|
||||
cloudName: 'aliyun',
|
||||
domainName: '',
|
||||
recordType: '',
|
||||
ipv6Prefix: '',
|
||||
domainRecord: '',
|
||||
AK: '',
|
||||
SK: '',
|
||||
DbType: 2,
|
||||
ConnString: '',
|
||||
Cron: '30',
|
||||
UserName: '',
|
||||
UswePwd: '',
|
||||
});
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
Cron: [{ required: true, message: '请输入任务调度周期', trigger: 'change' }],
|
||||
};
|
||||
|
||||
watch(
|
||||
() => formState.DbType,
|
||||
() => {
|
||||
formRef.value.validateFields(['ConnString']);
|
||||
},
|
||||
{ flush: 'post' }
|
||||
);
|
||||
|
||||
//检查数据库连接
|
||||
import { message } from 'ant-design-vue';
|
||||
import router from '@/router';
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
|
||||
const isFirst = ref(false);
|
||||
|
||||
//提交初始化
|
||||
|
||||
const onSubmit = () => {
|
||||
// console.log('submit!', toRaw(formState));
|
||||
formRef.value
|
||||
.validate()
|
||||
.then(() => {
|
||||
console.log('values', formState, toRaw(formState));
|
||||
useApiStore()
|
||||
.apiInit(toRaw(formState))
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
message.success('初始化成功');
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 1000);
|
||||
} else {
|
||||
message.error(res.erro, 8);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('error', error);
|
||||
});
|
||||
};
|
||||
|
||||
const labelCol = { style: { width: '150px' } };
|
||||
const wrapperCol = { span: 4 };
|
||||
</script>
|
||||
<style scoped>
|
||||
.ant-card-body {
|
||||
padding: 5px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import Init from './Init.vue';
|
||||
|
||||
export default Init;
|
||||
@@ -3,54 +3,321 @@
|
||||
<login-box class="shadow-lg" @success="onLoginSuccess" @failure="onLoginFail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import LoginBox from './LoginBox.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
// import http from '@/store/http';
|
||||
import { message, notification, Modal } from 'ant-design-vue'; // 引入 Modal 用于确认提示
|
||||
import { onMounted, ref, h } from 'vue';
|
||||
import { useApiStore } from '@/store';
|
||||
import { CopyOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function onLoginSuccess() {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
import { message } from 'ant-design-vue';
|
||||
// 版本数据存储
|
||||
const currentTag = ref<string>('');
|
||||
const latestTag = ref<string>('未知版本');
|
||||
|
||||
function onLoginFail(status, res) {
|
||||
console.log(res);
|
||||
if (res.code === 0) {
|
||||
} else {
|
||||
message.error(res.erro, 5);
|
||||
// 复制版本号到剪贴板核心逻辑
|
||||
const copyToClipboard = async (content: string, type: string) => {
|
||||
if (!content || content === '未知版本') {
|
||||
message.warning(`${type}无有效内容可复制`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(content);
|
||||
message.success(`${type}已复制到剪贴板 ✅`);
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = content;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
message.success(`${type}已复制到剪贴板 ✅`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
message.error('复制失败,请手动选中复制');
|
||||
}
|
||||
};
|
||||
|
||||
// 统一的关闭处理:标记缓存 + 关闭通知
|
||||
const handleNoticeClose = (noticeKey: string) => {
|
||||
console.log('版本提醒通知已关闭,后续不再提醒');
|
||||
// 标记为已提醒,存入缓存(确保版本通知和确认弹窗都不再出现)
|
||||
localStorage.setItem('maintain_notice_shown', 'true');
|
||||
// 关闭版本通知
|
||||
notification.close(noticeKey);
|
||||
};
|
||||
|
||||
// 关闭前的确认提示弹窗
|
||||
const showCloseConfirm = (noticeKey: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认关闭',
|
||||
content: '关闭后该提醒将不再弹出,确定要关闭吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
// 确认关闭:执行统一处理
|
||||
handleNoticeClose(noticeKey);
|
||||
message.success('提醒已关闭,后续不再弹出');
|
||||
},
|
||||
onCancel: () => {
|
||||
// 取消关闭:不执行任何操作,保留版本通知
|
||||
message.info('已取消关闭');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 打开右上角 notification 通知
|
||||
const openVersionNotice = () => {
|
||||
const noticeKey = `version_notice_${Date.now()}`;
|
||||
notification.open({
|
||||
message: '温馨提示一下',
|
||||
key: noticeKey,
|
||||
duration: 0,
|
||||
placement: 'topRight',
|
||||
// 通知描述内容
|
||||
description: h('div', { class: 'notice-content' }, [
|
||||
h('p', { class: 'notice-desc' }, '当前您使用的docker镜像为阿里云镜像,已停止维护。'),
|
||||
h('p', { class: 'notice-version-item' }, [
|
||||
h('strong', { class: 'notice-version-label' }, '当前版本:'),
|
||||
h('span', { style: { color: '#ff4d4f', fontWeight: '500' } }, currentTag.value.replace('[不再维护]', '')),
|
||||
h(CopyOutlined, {
|
||||
class: 'notice-copy-icon',
|
||||
title: '复制当前版本',
|
||||
onClick: () => copyToClipboard(currentTag.value.replace('[不再维护]', ''), '当前版本'),
|
||||
}),
|
||||
]),
|
||||
h('p', { class: 'notice-version-item' }, [
|
||||
h('strong', { class: 'notice-version-label' }, '最新版本:'),
|
||||
h('span', { style: { color: '#52c41a', fontWeight: '500' } }, latestTag.value),
|
||||
h(CopyOutlined, {
|
||||
class: 'notice-copy-icon',
|
||||
title: '复制最新版本',
|
||||
onClick: () => copyToClipboard(latestTag.value, '最新版本'),
|
||||
}),
|
||||
]),
|
||||
h('p', { class: 'notice-tip' }, '建议升级到最新版本!'),
|
||||
]),
|
||||
// 「我已知晓」按钮:点击触发确认弹窗
|
||||
btn: () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'notice-confirm-btn',
|
||||
onClick: () => {
|
||||
// 不直接关闭,先弹出确认提示
|
||||
showCloseConfirm(noticeKey);
|
||||
},
|
||||
},
|
||||
'我已知晓'
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
useApiStore()
|
||||
.AppisInit()
|
||||
.then((res) => {
|
||||
if (res.code == 0 && res.data) {
|
||||
} else {
|
||||
router.push('/init');
|
||||
}
|
||||
});
|
||||
|
||||
useApiStore()
|
||||
.CheckTag()
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
if (res.data.length > 0) {
|
||||
const tag = res.data[0];
|
||||
if (tag.indexOf('不再维护') !== -1) {
|
||||
const hasShown = localStorage.getItem('maintain_notice_shown');
|
||||
if (!hasShown) {
|
||||
currentTag.value = tag;
|
||||
latestTag.value = res.data.length >= 2 ? res.data[1] : '未知版本';
|
||||
openVersionNotice();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
|
||||
function onLoginSuccess() {
|
||||
if (isMobileBrowser()) router.push('/mobile');
|
||||
else router.push('/dashboard');
|
||||
}
|
||||
|
||||
const isMobileBrowser = (): boolean => {
|
||||
if (typeof navigator === 'undefined' || typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const mobileUA = /android|iphone|ipod|blackberry|windows phone|iemobile|opera mini/i.test(userAgent);
|
||||
const isTablet = /ipad|tablet|playbook|kindle|android 3\.|android 4\.[0-3]/.test(userAgent);
|
||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
const isMobileScreen = window.innerWidth <= 768 && window.innerHeight <= 1024;
|
||||
|
||||
return (mobileUA && !isTablet && isTouchDevice) || (isMobileScreen && isTouchDevice);
|
||||
};
|
||||
|
||||
function onLoginFail(reason: string, fields: any) {
|
||||
console.log('登录失败:', reason, fields);
|
||||
message.error(reason || '登录失败,请重试', 5);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.login {
|
||||
height: 100vh;
|
||||
// 与深色登录框搭配的渐变背景
|
||||
min-height: -webkit-fill-available;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px 0;
|
||||
|
||||
// 可以添加一些装饰性背景元素
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
border-radius: 50%;
|
||||
background: rgba(54, 191, 250, 0.1);
|
||||
top: 15%;
|
||||
left: 5%;
|
||||
filter: blur(60px);
|
||||
@media (min-width: 768px) {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
top: 20%;
|
||||
left: 15%;
|
||||
filter: blur(80px);
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: 50%;
|
||||
background: rgba(54, 191, 250, 0.08);
|
||||
bottom: 5%;
|
||||
right: 5%;
|
||||
filter: blur(40px);
|
||||
@media (min-width: 768px) {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
bottom: 10%;
|
||||
right: 10%;
|
||||
filter: blur(60px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@supports (bottom: env(safe-area-inset-bottom)) {
|
||||
.login {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
.ant-notification {
|
||||
.ant-notification-notice {
|
||||
width: 500px !important;
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.notice-content {
|
||||
font-size: 14px !important;
|
||||
line-height: 1.8 !important;
|
||||
color: #334155 !important;
|
||||
width: 480px !important;
|
||||
|
||||
.notice-desc {
|
||||
margin-bottom: 12px !important;
|
||||
padding-left: 2px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.notice-version-item {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
margin: 8px 0 !important;
|
||||
padding: 6px 10px !important;
|
||||
background-color: #f8fafc !important;
|
||||
border-radius: 6px !important;
|
||||
transition: background-color 0.2s ease !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #f1f5f9 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-version-label {
|
||||
color: #1e293b !important;
|
||||
width: 70px !important;
|
||||
flex-shrink: 0 !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.notice-copy-icon {
|
||||
flex-shrink: 0 !important;
|
||||
color: #165dff !important;
|
||||
font-size: 16px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.2s ease !important;
|
||||
margin-left: 8px !important;
|
||||
|
||||
&:hover {
|
||||
color: #0d47a1 !important;
|
||||
transform: scale(1.1) !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-tip {
|
||||
margin-top: 12px !important;
|
||||
color: #64748b !important;
|
||||
padding-left: 2px !important;
|
||||
font-style: italic !important;
|
||||
font-size: 13px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-confirm-btn {
|
||||
background-color: #165dff !important;
|
||||
color: #ffffff !important;
|
||||
border: none !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 4px 12px !important;
|
||||
font-size: 13px !important;
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.2s ease !important;
|
||||
margin-top: 12px !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #0d47a1 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +1,26 @@
|
||||
<template>
|
||||
<div class="login-page min-h-screen flex items-center justify-center p-4 md:p-8">
|
||||
<ThemeProvider :color="{
|
||||
middle: { 'bg-base': '#f8fafc' },
|
||||
middle: { 'bg-base': '#fff' },
|
||||
primary: { DEFAULT: '#4f46e5', hover: '#4338ca' }
|
||||
}">
|
||||
<!-- 登录卡片 -->
|
||||
<div class="login-box rounded-2xl shadow-lg p-8 md:p-10 max-w-md w-full border border-gray-200 transition-all duration-500 hover:shadow-xl transform hover:-translate-y-1">
|
||||
<!-- 顶部Logo区域 -->
|
||||
<div class="flex flex-col items-center mb-8">
|
||||
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
|
||||
<img src="/public/logo.png">
|
||||
<!-- 登录卡片 - 移动端全屏 + PC端保留原宽度 -->
|
||||
<div class="login-box rounded-2xl shadow-lg p-6 md:p-8 lg:p-10 max-w-md w-full border border-gray-200 transition-all duration-500 hover:shadow-xl transform hover:-translate-y-1 mx-auto" style="box-sizing: border-box; max-width: 90vw;">
|
||||
<!-- 顶部Logo区域 - 边框圈精准贴合Logo内容 -->
|
||||
<div class="flex flex-col items-center mb-6 md:mb-8">
|
||||
<div class="w-30 h-30 md:w-36 md:h-36 bg-primary/10 rounded-full flex items-center justify-center mb-4 md:mb-5">
|
||||
<div class="w-24 h-24 md:w-28 h-28 rounded-full flex items-center justify-center">
|
||||
<img src="/logo.png" alt="抖小云logo" class="w-18 h-18 md:w-20 h-20 object-contain" />
|
||||
</div>
|
||||
<h2 class="third-title text-2xl font-bold text-gray-800 tracking-tight">抖音同步小帮手</h2>
|
||||
</div>
|
||||
<h2 class="third-title text-xl md:text-2xl font-bold text-gray-800 tracking-tight">抖小云</h2>
|
||||
</div>
|
||||
|
||||
<a-form :model="form" :wrapperCol="{ span: 24 }" @finish="login" class="login-form w-full text-gray-700">
|
||||
<!-- 用户名输入框 -->
|
||||
<a-form-item :required="true" name="username" class="mb-5" :validate-status="usernameStatus">
|
||||
<a-input v-model:value="form.username" autocomplete="new-username" placeholder="请输入用户名" class="login-input h-[50px] rounded-lg bg-gray-50 border-gray-200 text-gray-800 placeholder:text-gray-400 focus:border-primary focus:bg-white text-base transition-all duration-300 focus:ring-2 focus:ring-primary/20">
|
||||
<a-form-item :required="true" name="username" class="mb-4 md:mb-5" :validate-status="usernameStatus">
|
||||
<a-input v-model:value="form.username" autocomplete="new-username" placeholder="请输入用户名" class="login-input h-[46px] md:h-[50px] rounded-lg bg-gray-50 border-gray-200 text-gray-800 placeholder:text-gray-400 focus:border-primary focus:bg-white text-base transition-all duration-300 focus:ring-2 focus:ring-primary/20" style="padding: 0 12px;">
|
||||
<template #prefix>
|
||||
<svg class="w-5 h-5 text-gray-400 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg class="w-4 h-4 md:w-5 md:h-5 text-gray-400 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
@@ -29,50 +30,74 @@
|
||||
|
||||
<!-- 密码输入框 -->
|
||||
<a-form-item :required="true" name="password" class="mb-2" :validate-status="passwordStatus">
|
||||
<a-input v-model:value="form.password" autocomplete="new-password" placeholder="请输入密码" class="login-input h-[50px] rounded-lg bg-gray-50 border-gray-200 text-gray-800 placeholder:text-gray-400 focus:border-primary focus:bg-white text-base transition-all duration-300 focus:ring-2 focus:ring-primary/20" :type="showPassword ? 'text' : 'password'">
|
||||
<a-input v-model:value="form.password" autocomplete="new-password" placeholder="请输入密码" class="login-input h-[46px] md:h-[50px] rounded-lg bg-gray-50 border-gray-200 text-gray-800 placeholder:text-gray-400 focus:border-primary focus:bg-white text-base transition-all duration-300 focus:ring-2 focus:ring-primary/20" :type="showPassword ? 'text' : 'password'" style="padding: 0 12px;">
|
||||
<template #prefix>
|
||||
<svg class="w-5 h-5 text-gray-400 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg class="w-4 h-4 md:w-5 md:h-5 text-gray-400 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
</template>
|
||||
<!-- 密码显示/隐藏图标 -->
|
||||
<template #suffix>
|
||||
<EyeOutlined v-if="showPassword" @click="showPassword = !showPassword" class="text-gray-400 hover:text-primary cursor-pointer transition-colors duration-300 text-lg" aria-label="隐藏密码" />
|
||||
<EyeInvisibleOutlined v-else @click="showPassword = !showPassword" class="text-gray-400 hover:text-primary cursor-pointer transition-colors duration-300 text-lg" aria-label="显示密码" />
|
||||
<div class="w-8 h-8 flex items-center justify-center" @click="showPassword = !showPassword">
|
||||
<EyeOutlined v-if="showPassword" class="text-gray-400 hover:text-primary cursor-pointer transition-colors duration-300 text-lg" aria-label="隐藏密码" />
|
||||
<EyeInvisibleOutlined v-else class="text-gray-400 hover:text-primary cursor-pointer transition-colors duration-300 text-lg" aria-label="显示密码" />
|
||||
</div>
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 记住密码与忘记密码 -->
|
||||
<div class="flex items-center justify-between mb-6 px-1">
|
||||
<!-- 记住密码 -->
|
||||
<div class="flex items-center justify-between mb-5 md:mb-6 px-1">
|
||||
<a-form-item class="mb-0">
|
||||
<a-checkbox v-model:checked="rememberPassword" class="text-gray-600 hover:text-primary transition-colors duration-300 cursor-pointer">
|
||||
<span class="text-sm">记住密码</span>
|
||||
<span class="text-xs md:text-sm">记住密码</span>
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
<!-- <a href="#" class="text-primary hover:text-primary/80 text-sm font-medium transition-colors duration-300" @click.prevent="handleForgotPassword">
|
||||
忘记密码?
|
||||
</a> -->
|
||||
|
||||
<a-form-item class="mb-0">
|
||||
<div class="text-gray-600 hover:text-primary transition-colors duration-300 cursor-pointer" @click="handleForgotPassword">
|
||||
<span class="text-xs md:text-sm">忘记密码</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<a-button htmlType="submit" class="h-[52px] w-full rounded-lg transition-all duration-300 hover:bg-primary/90 bg-primary border-primary text-white text-base font-medium shadow-md hover:shadow-lg transform hover:-translate-y-0.5 active:translate-y-0" type="primary" :loading="loading">
|
||||
<a-button htmlType="submit" class="h-[48px] md:h-[52px] w-full rounded-lg transition-all duration-300 hover:bg-primary/90 bg-primary border-primary text-white text-base font-medium shadow-md hover:shadow-lg transform hover:-translate-y-0.5 active:translate-y-0" type="primary" :loading="loading" style="touch-action: manipulation; -webkit-tap-highlight-color: transparent;">
|
||||
<span v-if="!loading">登录</span>
|
||||
<span v-else>登录中...</span>
|
||||
</a-button>
|
||||
|
||||
<!-- 注册入口 -->
|
||||
<!-- <div class="text-center mt-6 text-gray-500 text-sm">
|
||||
还没有账号?
|
||||
<a href="#" class="text-primary hover:text-primary/80 font-medium transition-colors duration-300 ml-1" @click.prevent="handleRegister">
|
||||
立即注册
|
||||
</a>
|
||||
</div> -->
|
||||
</a-form>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
|
||||
<!-- 忘记密码弹窗 -->
|
||||
<a-modal v-model:visible="forgotModalVisible" title="密码重置教程" width="50%" :max-width="500" centered :mask-closable="false" :footer="null" class="forgot-modal">
|
||||
<div class="p-2">
|
||||
<!-- 步骤说明 -->
|
||||
<div class="space-y-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center text-sm mr-3 mt-0.5">1</div>
|
||||
<div class="flex-1 text-gray-700 text-sm leading-relaxed">在 db 目录下新建一个文本文件,命名为 <span class="px-2 py-0.5 bg-gray-100 text-primary font-medium rounded text-sm">pwd.txt</span></div>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center text-sm mr-3 mt-0.5">2</div>
|
||||
<div class="flex-1 text-gray-700 text-sm leading-relaxed">打开文件写入你要设置的新密码,留空则自动重置为默认密码:<span class="px-2 py-0.5 bg-gray-100 text-primary font-medium rounded text-sm">douyin2026</span></div>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center text-sm mr-3 mt-0.5">3</div>
|
||||
<div class="flex-1 text-gray-700 text-sm leading-relaxed">重启 docker服务或者飞牛安装的抖小云应用 ,使用新密码登录即可</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 知道了按钮 -->
|
||||
<div class="flex justify-center">
|
||||
<a-button type="primary" class="h-10 px-8 rounded-lg" @click="forgotModalVisible = false">
|
||||
我知道了
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -88,8 +113,14 @@ const showPassword = ref(false);
|
||||
const rememberPassword = ref(false);
|
||||
// 加载状态
|
||||
const loading = ref(false);
|
||||
// 忘记密码弹窗显示状态
|
||||
const forgotModalVisible = ref(false);
|
||||
|
||||
// 表单状态(用于输入框验证反馈)
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const usernameStatus = computed(() => (form.username ? 'success' : ''));
|
||||
const passwordStatus = computed(() => (form.password ? 'success' : ''));
|
||||
|
||||
@@ -98,11 +129,6 @@ export interface LoginFormProps {
|
||||
password: string;
|
||||
}
|
||||
|
||||
const form = reactive<LoginFormProps>({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'success', fields: LoginFormProps): void;
|
||||
(e: 'failure', reason: string, fields: LoginFormProps): void;
|
||||
@@ -158,19 +184,20 @@ async function login(params: LoginFormProps) {
|
||||
try {
|
||||
const res = await accountStore.login(params.username, params.password);
|
||||
emit('success', params);
|
||||
console.log(res);
|
||||
message.success('登录成功!');
|
||||
} catch (e: any) {
|
||||
// emit('failure', e.message, e.data);
|
||||
message.error(e.data.erro || '登录失败,请重试');
|
||||
emit('failure', e.message || '登录失败', params);
|
||||
message.error(e.data?.erro || '登录失败,请重试');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 忘记密码处理
|
||||
// 忘记密码处理 - 打开弹窗
|
||||
function handleForgotPassword() {
|
||||
forgotModalVisible.value = true;
|
||||
emit('forgot-password');
|
||||
message.info('忘记密码功能即将上线');
|
||||
}
|
||||
|
||||
// 注册处理
|
||||
@@ -180,15 +207,85 @@ function handleRegister() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 页面基础样式 */
|
||||
.login-page {
|
||||
/* 全局布局适配 - 替代原外层容器的样式 */
|
||||
:root {
|
||||
-webkit-text-size-adjust: 100%; /* 禁止iOS文本缩放 */
|
||||
-webkit-font-smoothing: antialiased; /* 优化字体渲染 */
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
/* 全局居中布局 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* 登录卡片核心样式 */
|
||||
.login-box {
|
||||
box-sizing: border-box;
|
||||
touch-action: manipulation; /* 优化触摸性能 */
|
||||
/* 移动端全屏,PC端保留原max-w-md(默认28rem/448px)+ 90vw限制 */
|
||||
}
|
||||
|
||||
/* 移动端适配(<768px) */
|
||||
@media (max-width: 767.98px) {
|
||||
body {
|
||||
padding: 0;
|
||||
}
|
||||
.login-box {
|
||||
max-width: 100vw !important; /* 移动端全屏 */
|
||||
min-height: 100vh;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
padding: 20px 16px !important;
|
||||
padding-top: 20vh !important; /* 移动端垂直居中 */
|
||||
}
|
||||
.third-title {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
::v-deep(.ant-input) {
|
||||
font-size: 14px; /* 移动端字体适配 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏手机额外适配 */
|
||||
@media (max-width: 480px) {
|
||||
.login-box {
|
||||
padding-top: 15vh !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* PC端样式(保留原有宽度,仅调小最大宽度) */
|
||||
@media (min-width: 768px) {
|
||||
body {
|
||||
padding: 8px;
|
||||
}
|
||||
.login-box {
|
||||
/* 原宽度是 min(448px, 90vw),现调整为更小的数值,比如 380px(可根据需求修改) */
|
||||
max-width: min(340px, 90vw) !important;
|
||||
min-height: auto !important;
|
||||
border: 1px solid #e5e7eb !important;
|
||||
border-radius: 1rem !important;
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1) !important;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 输入框聚焦动画优化 */
|
||||
::v-deep(.ant-input) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
::v-deep(.ant-input:focus) {
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2) !important;
|
||||
border-color: #4f46e5 !important;
|
||||
outline: none; /* 移除默认轮廓 */
|
||||
}
|
||||
|
||||
/* 复选框样式优化 */
|
||||
@@ -196,29 +293,26 @@ function handleRegister() {
|
||||
background-color: #4f46e5 !important;
|
||||
border-color: #4f46e5 !important;
|
||||
}
|
||||
|
||||
::v-deep(.ant-checkbox:hover .ant-checkbox-inner) {
|
||||
border-color: #4f46e5 !important;
|
||||
}
|
||||
::v-deep(.ant-checkbox) {
|
||||
transform: scale(0.9); /* 移动端复选框适当缩小 */
|
||||
}
|
||||
|
||||
/* 按钮样式优化 */
|
||||
::v-deep(.ant-btn-primary) {
|
||||
background-color: #4f46e5 !important;
|
||||
border-color: #4f46e5 !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
::v-deep(.ant-btn-primary:hover) {
|
||||
background-color: #4338ca !important;
|
||||
border-color: #4338ca !important;
|
||||
}
|
||||
|
||||
::v-deep(.ant-btn-primary:focus) {
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.3) !important;
|
||||
}
|
||||
|
||||
/* 分割线样式优化 */
|
||||
::v-deep(.ant-divider) {
|
||||
background-color: #e5e7eb !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 加载状态动画优化 */
|
||||
@@ -226,24 +320,35 @@ function handleRegister() {
|
||||
margin-right: 8px !important;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 375px) {
|
||||
.login-box {
|
||||
padding: 60px 20px !important;
|
||||
}
|
||||
/* 弹窗样式美化 */
|
||||
::v-deep(.forgot-modal .ant-modal-header) {
|
||||
border-radius: 12px 12px 0 0;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
::v-deep(.forgot-modal .ant-modal-title) {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
::v-deep(.forgot-modal .ant-modal-body) {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
::v-deep(.forgot-modal .ant-modal-content) {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.third-title {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
/* 防止点击闪烁和触摸高亮 */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* 平滑滚动 */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 防止点击闪烁 */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overflow-x: hidden; /* 防止移动端横向滚动 */
|
||||
}
|
||||
</style>
|
||||
@@ -1,79 +1,293 @@
|
||||
<template>
|
||||
<a-form layout="inline" style="margin-top:5px;margin-bottom:5px;">
|
||||
<a-form layout="inline" style="margin-top:5px;margin-bottom:5px;align-items: center;">
|
||||
|
||||
<!-- 日期控制 -->
|
||||
<a-form-item>
|
||||
<a-date-picker v-model:value="dateValue" format="YYYYMMDD" :locale="locale" @change="datePickChange" />
|
||||
<div class="date-control-group">
|
||||
<a-button type="text" @click="handleDateMinus" class="date-btn"><left-outlined /></a-button>
|
||||
<span class="current-date-text">{{ currentDateText }}</span>
|
||||
<a-button type="text" @click="handleDatePlus" class="date-btn" :disabled="isToday"><right-outlined /></a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<!-- debug/error单选按钮 -->
|
||||
<a-form-item>
|
||||
<a-radio-group v-model:value="typeValue" button-style="solid" @change="typeChange">
|
||||
<a-radio-group class="log-type-radio-group" v-model:value="typeValue" @change="typeChange" button-style="solid">
|
||||
<a-radio-button value="debug">debug</a-radio-button>
|
||||
<a-radio-button value="error">error</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<!-- 复制按钮 - 同一行最右侧 -->
|
||||
<a-form-item style="margin-left: auto;">
|
||||
<a-button type="text" size="small" @click="copyLogs" class="copy-btn">
|
||||
<copy-outlined />
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<div class="container">
|
||||
<a-card title="" :bordered="true">
|
||||
<pre>{{ logs }}</pre>
|
||||
<a-card title="" :bordered="true" :class="{ 'log-error': typeValue === 'error' }">
|
||||
<pre class="card-width-pre">{{ logs }}</pre>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineComponent, reactive, ref, watch, onMounted } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useApiStore } from '@/store';
|
||||
import type { UnwrapRef } from 'vue';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import locale from 'ant-design-vue/es/date-picker/locale/zh_CN';
|
||||
type RangeValue = [Dayjs, Dayjs];
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
const dateValue = ref<Dayjs>(dayjs(Date()));
|
||||
|
||||
const currentDate = ref<Dayjs>(dayjs());
|
||||
const typeValue = ref<string>('debug');
|
||||
const iframeUrl = ref<string>('');
|
||||
const dateValue1 = ref<string>();
|
||||
const logs = ref<string>('');
|
||||
dateValue1.value = dayjs(Date()).format('YYYYMMDD');
|
||||
iframeUrl.value = `type=${typeValue.value}&date=${dateValue1.value}`;
|
||||
const datePickChange = (e, dateStr) => {
|
||||
dateValue1.value = dateStr;
|
||||
iframeUrl.value = `type=${typeValue.value}&date=${dateValue1.value}`;
|
||||
console.log(iframeUrl);
|
||||
|
||||
const currentDateText = computed(() => {
|
||||
return currentDate.value.format('YYYYMMDD');
|
||||
});
|
||||
|
||||
const isToday = computed(() => {
|
||||
return currentDate.value.isSame(dayjs(), 'day');
|
||||
});
|
||||
|
||||
const handleDateMinus = () => {
|
||||
currentDate.value = currentDate.value.subtract(1, 'day');
|
||||
updateIframeUrlAndLoadLogs();
|
||||
};
|
||||
|
||||
const handleDatePlus = () => {
|
||||
if (isToday.value) return;
|
||||
currentDate.value = currentDate.value.add(1, 'day');
|
||||
updateIframeUrlAndLoadLogs();
|
||||
};
|
||||
|
||||
const updateIframeUrlAndLoadLogs = () => {
|
||||
iframeUrl.value = `${typeValue.value}/${currentDateText.value}`;
|
||||
loadLogs();
|
||||
};
|
||||
|
||||
const typeChange = (e) => {
|
||||
console.log(e.target);
|
||||
iframeUrl.value = `type=${e.target.value}&date=${dateValue1.value}`;
|
||||
loadLogs();
|
||||
const typeChange = (e: any) => {
|
||||
typeValue.value = e.target.value;
|
||||
updateIframeUrlAndLoadLogs();
|
||||
};
|
||||
const mIfrm = ref<any>(null);
|
||||
onMounted(() => {
|
||||
// console.log(mIfrm.value);
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
const formatLogTime = (logContent: string) => {
|
||||
const timeRegex = /(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.\d+ \+08:00/g;
|
||||
return logContent.replace(timeRegex, '$1');
|
||||
};
|
||||
|
||||
const loadLogs = () => {
|
||||
useApiStore()
|
||||
.apiGetLogs(iframeUrl.value)
|
||||
.then((log) => {
|
||||
// console.log(log);
|
||||
const lines = log.split('\n'); // 将文本按换行符分割为行数组
|
||||
const reversedLines = lines.reverse(); // 对行数组进行倒序操作
|
||||
const reversedText = reversedLines.join('\n'); // 将行数组重新连接为一个字符串
|
||||
|
||||
log = formatLogTime(log);
|
||||
const lines = log.split('\n');
|
||||
const reversedLines = lines.reverse();
|
||||
const reversedText = reversedLines.join('\n');
|
||||
logs.value = reversedText;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('加载日志失败:', err);
|
||||
logs.value = '日志加载失败,请稍后重试';
|
||||
});
|
||||
};
|
||||
|
||||
// 复制日志内容的核心函数
|
||||
const copyLogs = async () => {
|
||||
try {
|
||||
// 空内容判断
|
||||
if (!logs.value || logs.value === '日志加载失败,请稍后重试') {
|
||||
message.warning('暂无可复制的日志内容');
|
||||
return;
|
||||
}
|
||||
// 使用浏览器剪贴板API复制内容
|
||||
await navigator.clipboard.writeText(logs.value);
|
||||
message.success('日志内容复制成功!');
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
// 降级方案:兼容不支持Clipboard API的浏览器
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = logs.value;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
// 提示反馈
|
||||
message.success('日志内容复制成功!');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateIframeUrlAndLoadLogs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang='less' scoped>
|
||||
html {
|
||||
// 基础样式:仅清理默认margin/padding,不设置全屏
|
||||
html,
|
||||
body {
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// 表单容器:正常宽度,保留少量间距
|
||||
:deep(.ant-form) {
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 日志容器:自适应高度,无固定上限,保留上下留白
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// max-height: 400px;
|
||||
iframe {
|
||||
.word-wrap {
|
||||
color: white !important;
|
||||
padding: 0 8px;
|
||||
margin: 16px 0; // 上下留白,远离屏幕边缘
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
// pre样式:自适应高度,内容过多时内部滚动
|
||||
.card-width-pre {
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
padding: 8px;
|
||||
margin: 0 !important;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
// 核心:最小高度保证容器不塌陷,最大高度限制不超视口一半
|
||||
min-height: 200px;
|
||||
max-height: calc(67vh); // 最多占视口一半,绝对不会满屏
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
// 滚动条美化(保留)
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: 3px;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
.date-control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
.date-btn {
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.current-date-text {
|
||||
font-size: 14px;
|
||||
color: #1f2329;
|
||||
font-weight: 500;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// 复制按钮样式
|
||||
.copy-btn {
|
||||
// height: 32px; /* 和日期按钮/单选按钮高度一致 */
|
||||
// padding: 0 16px;
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
// card-body样式:恢复默认内边距,作为pre的宽度基准
|
||||
:deep(.ant-card-body) {
|
||||
padding: 4px; // 恢复合理内边距,作为pre的宽度容器
|
||||
height: 100%; // 全屏继承容器高度
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden; // 新增:隐藏卡片内溢出,确保pre独占滚动区域
|
||||
}
|
||||
|
||||
// error状态下pre文字颜色
|
||||
.log-error {
|
||||
.card-width-pre {
|
||||
color: #f5222d !important;
|
||||
}
|
||||
}
|
||||
|
||||
// radio-button样式(保留之前的修正版)
|
||||
:deep(.log-type-radio-group) {
|
||||
.ant-radio-button-wrapper:first-child {
|
||||
&.ant-radio-button-wrapper-checked {
|
||||
background-color: #52c41a !important;
|
||||
border-color: #52c41a !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background-color: #389e0d !important;
|
||||
border-color: #389e0d !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.ant-radio-button-wrapper-checked):hover {
|
||||
border-color: #73d13d !important;
|
||||
color: #52c41a !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-radio-button-wrapper:last-child {
|
||||
&.ant-radio-button-wrapper-checked {
|
||||
background-color: #f5222d !important;
|
||||
border-color: #f5222d !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background-color: #cf1322 !important;
|
||||
border-color: #cf1322 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.ant-radio-button-wrapper-checked):hover {
|
||||
border-color: #ff4d4f !important;
|
||||
color: #f5222d !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-radio-button-wrapper {
|
||||
&:first-child {
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
}
|
||||
&:last-child {
|
||||
border-radius: 0 6px 6px 0 !important;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: -1px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-radio-button-wrapper-checked {
|
||||
&:first-child {
|
||||
&::before {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调整form布局,让复制按钮靠右
|
||||
:deep(.ant-form-inline) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,173 +0,0 @@
|
||||
<template>
|
||||
<a-drawer v-model:visible="visible" class="custom-class" title="邮件配置" placement="right" @after-visible-change="afterVisibleChange" :maskClosable="true">
|
||||
<a-tabs v-model:activeKey="activeKey">
|
||||
<a-tab-pane key="1">
|
||||
<template #tab>
|
||||
<span>
|
||||
<!-- <BellOutlined /> -->
|
||||
邮件STMP配置
|
||||
</span>
|
||||
</template>
|
||||
<a-form ref="emailFromRef" :model="EmailFormData" name="basic" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }" autocomplete="off" @finish="onFinish" @finishFailed="onFinishFailed">
|
||||
<a-form-item label="是否开启" ref="Open">
|
||||
<a-checkbox v-model:checked="EmailFormData.Open">解析结果邮件通知</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="EmailFormData.Open" label="Stmp地址" ref="Stmp" name="Stmp" :rules="[{ required: EmailFormData.Open, message: '请输入stmp服务地址!',validator:validateStmp }]">
|
||||
<a-input v-model:value="EmailFormData.Stmp" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="EmailFormData.Open" label="Stmp端口" ref="Port" name="Port" :rules="[{ required: EmailFormData.Open, message: '请输入stmp服务端口!',validator:validatePort }]">
|
||||
<a-input v-model:value="EmailFormData.Port" placeholder="默认465,yeah-587" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="EmailFormData.Open" label="收件Email" type='email' ref="From" name="From" :rules="[{ required: EmailFormData.Open, message: '请输入收件Email地址!',validator:validateEmail }]">
|
||||
<a-input v-model:value="EmailFormData.From" />
|
||||
<span style="color:#888">此处收件人亦是发件人</span>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="EmailFormData.Open" label="Stmp授权码" ref="Code" name="Code" :rules="[{ required: EmailFormData.Open, message: '请输入Stmp授权码!' }]">
|
||||
<a-input v-model:value="EmailFormData.Code" />
|
||||
<span style="color:#888">邮箱设置开启stmp服务会提示</span>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{ offset: 8, span: 16 }">
|
||||
<a-button type="primary" html-type="submit">确认</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-drawer>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { message } from 'ant-design-vue';
|
||||
import { defineComponent, ref, onMounted, reactive, watch } from 'vue';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import { useApiStore, useAccountStore } from '@/store';
|
||||
import http from '@/store/http';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import { checkEmail, checkDomain, checkPort } from '@/utils/regexHelper';
|
||||
const activeKey = ref<string>('1');
|
||||
const visible = ref<boolean>(false);
|
||||
|
||||
function showEmail(vis: boolean) {
|
||||
visible.value = vis;
|
||||
if (vis) loadStmpInfo();
|
||||
}
|
||||
const emailFromRef = ref<FormInstance>();
|
||||
const afterVisibleChange = (bool: boolean) => {
|
||||
if (!bool) {
|
||||
emailFromRef.value.resetFields();
|
||||
}
|
||||
};
|
||||
|
||||
const loadStmpInfo = () => {
|
||||
// useApiStore()
|
||||
// .apiGetStmpConfig()
|
||||
// .then((res) => {
|
||||
// if (res.code === 0) {
|
||||
// EmailFormData.Open = res.data.open;
|
||||
// EmailFormData.Stmp = res.data.stmp;
|
||||
// EmailFormData.Port = res.data.port;
|
||||
// EmailFormData.Code = res.data.code;
|
||||
// EmailFormData.From = res.data.from;
|
||||
// EmailFormData.Id = res.data.id;
|
||||
// }
|
||||
// });
|
||||
};
|
||||
|
||||
interface EmailFormState {
|
||||
Open: boolean;
|
||||
Stmp: string;
|
||||
Port: number;
|
||||
Code: string;
|
||||
From: string;
|
||||
Id: string;
|
||||
To: string;
|
||||
}
|
||||
const EmailFormData = reactive<EmailFormState>({
|
||||
Open: false,
|
||||
Stmp: 'smtp.qq.com',
|
||||
Port: 465,
|
||||
Code: '',
|
||||
From: '',
|
||||
Id: '',
|
||||
To: '',
|
||||
});
|
||||
const onFinish = (values: EmailFormState) => {
|
||||
EmailFormData.To = EmailFormData.From;
|
||||
// useApiStore()
|
||||
// .apiSaveStmpConfig(EmailFormData)
|
||||
// .then((res) => {
|
||||
// console.log(res);
|
||||
// if (res.code === 0) {
|
||||
// message.success('修改配置成功');
|
||||
// visible.value = false;
|
||||
// } else {
|
||||
// message.error(res.erro, 8);
|
||||
// }
|
||||
// });
|
||||
};
|
||||
|
||||
//验证邮箱
|
||||
const validateEmail = async (_rule: Rule, value: string) => {
|
||||
if (checkEmail(value)) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
return Promise.reject('请输入正确的邮箱地址');
|
||||
}
|
||||
};
|
||||
|
||||
//验证stmp服务
|
||||
const validateStmp = async (_rule: Rule, value: string) => {
|
||||
if (checkDomain(value)) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
return Promise.reject('请输入正确的stmp-server地址');
|
||||
}
|
||||
};
|
||||
//验证端口
|
||||
const validatePort = async (_rule: Rule, value: string) => {
|
||||
if (checkPort(value)) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
return Promise.reject('请输入正确的端口');
|
||||
}
|
||||
};
|
||||
const onFinishFailed = (errorInfo: any) => {
|
||||
console.log('Failed:', errorInfo);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => EmailFormData.Open,
|
||||
() => {
|
||||
emailFromRef.value.validateFields(['Stmp', 'Port', 'From', 'Code']);
|
||||
},
|
||||
{ flush: 'post' }
|
||||
);
|
||||
|
||||
// 将updateMessage方法暴露给父组件调用
|
||||
defineExpose({
|
||||
showEmail,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.avatar-uploader > .ant-upload {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
.ant-upload-picture-card-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
.ant-upload-select-picture-card i {
|
||||
font-size: 32px;
|
||||
color: #999;
|
||||
margin-top: 50px !important;
|
||||
}
|
||||
.ant-upload-select-picture-card {
|
||||
margin-top: 50px !important;
|
||||
}
|
||||
.ant-upload-select-picture-card .ant-upload-text {
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
}
|
||||
.ant-tabs-content {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +1,6 @@
|
||||
<template>
|
||||
<a-drawer v-model:visible="visible" class="custom-class" title="个人设置" placement="right" @after-visible-change="afterVisibleChange" :maskClosable="true">
|
||||
<a-tabs v-model:activeKey="activeKey">
|
||||
<!-- <a-tab-pane key="1" style="height:100%">
|
||||
<template #tab>
|
||||
<span>
|
||||
<user-outlined />
|
||||
修改头像
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<a-upload style="" v-model:file-list="fileList" name="file" list-type="picture-card" class="avatar-uploader" :show-upload-list="false" :action="uploadAction" :before-upload="beforeUpload" @change="handleChange" accept=".jpg, .jpeg, .png">
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="avatar" style="height: 112px; width: 112px; border-radius: 50%;" />
|
||||
<div v-else>
|
||||
<loading-outlined v-if="loading"></loading-outlined>
|
||||
<plus-outlined v-else></plus-outlined>
|
||||
<div class="ant-upload-text">选择图片</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<div style="margin-top:20px;">
|
||||
上传成功即修改成功
|
||||
</div>
|
||||
</a-tab-pane> -->
|
||||
<a-tab-pane key="2">
|
||||
<template #tab>
|
||||
<span>
|
||||
@@ -60,7 +40,6 @@ import { checkPass } from '@/utils/regexHelper';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
|
||||
const activeKey = ref<string>('2');
|
||||
const uploadAction = ref<string>('');
|
||||
const visible = ref<boolean>(false);
|
||||
|
||||
function show(vis: boolean) {
|
||||
@@ -122,7 +101,6 @@ const loadUserInfo = () => {
|
||||
if (res.code === 0) {
|
||||
if (res.data.avatar != null && res.data.avatar !== '') imageUrl.value = `/upload/${res.data.avatar}`;
|
||||
passwordFormData.UserId = res.data.id;
|
||||
uploadAction.value = `/api/auth/UpdateUserAvatar?Uid=${res.data.id}`;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -150,7 +128,7 @@ const onFinish = (values: PassFormState) => {
|
||||
visible.value = false;
|
||||
http.removeAuthorization();
|
||||
} else {
|
||||
message.error(res.erro, 8);
|
||||
message.error(res.message, 8);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import MyPersonal from './MyPersonal.vue';
|
||||
import EmailSet from './EmailSet.vue';
|
||||
// import EmailSet from './EmailSet.vue';
|
||||
|
||||
export { MyPersonal, EmailSet };
|
||||
export { MyPersonal };
|
||||
|
||||
@@ -1,745 +0,0 @@
|
||||
<template>
|
||||
<div class="stats-dashboard">
|
||||
<div class="dashboard-container">
|
||||
<!-- 页面标题 -->
|
||||
|
||||
<!-- 核心统计概览 -->
|
||||
<section class="stats-overview">
|
||||
<div class="stat-card primary-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-meta">总视频数</span>
|
||||
<div class="stat-icon video-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">{{ totalVideos }}</div>
|
||||
<div class="stat-trend">
|
||||
<!-- <span class="trend-up">较上月 +5.2%</span> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card secondary-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-meta">占用空间</span>
|
||||
<div class="stat-icon size-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 12H2v8h20v-8z" />
|
||||
<path d="M6 18h.01" />
|
||||
<path d="M10 18h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">{{ fileSizeTotal }} G</div>
|
||||
<div class="stat-trend">
|
||||
<!-- <span class="trend-up">较上月 +12.8%</span> -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 次级统计卡片组 -->
|
||||
<section class="stats-grid">
|
||||
<!-- 我喜欢的数量卡片 -->
|
||||
<div class="stat-card mini-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-meta">我喜欢的</span>
|
||||
<div class="stat-icon like-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">{{ favoriteCount }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 我收藏的数量卡片 -->
|
||||
<div class="stat-card mini-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-meta">我收藏的</span>
|
||||
<div class="stat-icon collect-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">{{ collectCount }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 作者总数卡片 -->
|
||||
<div class="stat-card mini-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-meta">作者总数</span>
|
||||
<div class="stat-icon author-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">{{ totalAuthors }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类总数卡片 -->
|
||||
<div class="stat-card mini-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-meta">分类总数</span>
|
||||
<div class="stat-icon cate-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<path d="M17 5v4" />
|
||||
<path d="M7 5v4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">{{ categoryTotal }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 详细分类统计 -->
|
||||
<section class="detailed-stats">
|
||||
<div class="stats-header">
|
||||
<h2>详细统计</h2>
|
||||
<div class="tab-controls">
|
||||
<button class="tab-btn" :class="{ active: currentTab === 'author' }" @click="currentTab = 'author'">
|
||||
视频作者
|
||||
</button>
|
||||
<button class="tab-btn" :class="{ active: currentTab === 'type' }" @click="currentTab = 'type'">
|
||||
视频分类
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="stats-fade" mode="out-in">
|
||||
<!-- 作者统计 -->
|
||||
<div v-if="currentTab === 'author'" key="author-view" class="stats-content">
|
||||
<div class="authors-grid">
|
||||
<div class="author-card" v-for="(author, index) in authors" :key="index">
|
||||
<div class="author-avatar">
|
||||
<img :src="author.icon" alt="作者头像" />
|
||||
</div>
|
||||
<div class="author-info">
|
||||
<h3 class="author-name">{{ author.name }}</h3>
|
||||
<p class="author-stats">作品数: {{ author.count }}</p>
|
||||
</div>
|
||||
<div class="author-progress">
|
||||
<div class="progress-bar" :style="{ width: `${(author.count / totalVideos) * 100}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类统计 -->
|
||||
<div v-else key="category-view" class="stats-content">
|
||||
<div class="categories-grid">
|
||||
<div class="category-card" v-for="(category, index) in categories" :key="index" :style="{ '--category-color': category.color }">
|
||||
<div class="category-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 12 12" fill="white">
|
||||
<use :xlink:href="`#${category.icon}`"></use>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="category-info">
|
||||
<h3 class="category-name">{{ category.name }}</h3>
|
||||
<p class="category-stats">作品数: {{ category.count }}</p>
|
||||
</div>
|
||||
<div class="category-percentage">
|
||||
{{ Math.round((category.count / totalVideos) * 100) }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- SVG图标定义 -->
|
||||
<svg style="display: none;">
|
||||
<!-- 所有图标定义保持不变 -->
|
||||
<symbol id="cup" viewBox="0 0 12 12">
|
||||
<path d="M18 4h2v16h-2zM4 4h14v2H4zM4 8h10v2H4zM4 12h10v2H4zM4 16h6v2H4zM4 20h6v2H4z" />
|
||||
</symbol>
|
||||
<!-- 其他图标省略,保持原样 -->
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useApiStore } from '@/store';
|
||||
|
||||
// 类型接口
|
||||
interface Author {
|
||||
name: string;
|
||||
count: number;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
name: string;
|
||||
count: number;
|
||||
color: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// 状态管理
|
||||
const totalVideos = ref<number>(0);
|
||||
const totalAuthors = ref<number>(0);
|
||||
const categoryTotal = ref<number>(0);
|
||||
const fileSizeTotal = ref<number>(0);
|
||||
const favoriteCount = ref<number>(0);
|
||||
const collectCount = ref<number>(0);
|
||||
const categories = ref<Category[]>([]);
|
||||
const authors = ref<Author[]>([]);
|
||||
const currentTab = ref<string>('author');
|
||||
|
||||
// 组件名称
|
||||
defineOptions({
|
||||
name: 'StatsDashboard',
|
||||
});
|
||||
|
||||
// 加载数据
|
||||
onMounted(() => {
|
||||
loadDashboardData();
|
||||
});
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
const res = await useApiStore().VideoStatics();
|
||||
totalVideos.value = res.data.videoCount;
|
||||
totalAuthors.value = res.data.authorCount;
|
||||
categoryTotal.value = res.data.categoryCount;
|
||||
favoriteCount.value = res.data.favoriteCount;
|
||||
collectCount.value = res.data.collectCount;
|
||||
categories.value = res.data.categories;
|
||||
fileSizeTotal.value = res.data.viedoSizeTotal; // 修复拼写错误
|
||||
authors.value = res.data.authors;
|
||||
|
||||
// 分类图标和颜色随机分配
|
||||
const cates = getRandomElements(categoriessss.value, categories.value.length);
|
||||
const colors = getRandomElements(colorArray, categories.value.length);
|
||||
categories.value.forEach((item, index) => {
|
||||
item.icon = cates[index];
|
||||
item.color = colors[index];
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('加载仪表盘数据失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 随机获取数组元素方法
|
||||
const getRandomElements = (arr: any[], n: number) => {
|
||||
if (n <= 0) return [];
|
||||
if (n >= arr.length) return [...arr];
|
||||
return [...arr].sort(() => Math.random() - 0.5).slice(0, n);
|
||||
};
|
||||
|
||||
// 图标列表和颜色数组
|
||||
const categoriessss = ref<any[]>([
|
||||
'cup',
|
||||
'spoon',
|
||||
'fork',
|
||||
'knife',
|
||||
'plate',
|
||||
'bottle',
|
||||
'toothbrush',
|
||||
'comb',
|
||||
'mirror',
|
||||
'soap',
|
||||
'mobile',
|
||||
'tablet',
|
||||
'camera',
|
||||
'headphones',
|
||||
'speaker',
|
||||
'tv',
|
||||
'watch',
|
||||
'charger',
|
||||
]);
|
||||
|
||||
const colorArray = [
|
||||
'#3A7CA5',
|
||||
'#D9A566',
|
||||
'#6B4226',
|
||||
'#829399',
|
||||
'#A63446',
|
||||
'#4F6367',
|
||||
'#FE5F55',
|
||||
'#C7EFCF',
|
||||
'#78C0E0',
|
||||
'#49BEAA',
|
||||
'#49DCB1',
|
||||
'#FF9999',
|
||||
'#66B2FF',
|
||||
'#99FF99',
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 白天模式默认样式(无dark-mode类时生效) */
|
||||
.stats-dashboard {
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff; /* 白天背景:白色 */
|
||||
color: #333333; /* 白天文本:深灰 */
|
||||
font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 加宽整体宽度并减少两侧留白 */
|
||||
.dashboard-container {
|
||||
max-width: 1400px; /* 从1200px加宽至1600px */
|
||||
margin: 0 auto;
|
||||
padding: 0 15px; /* 两侧留白从20px减至15px */
|
||||
}
|
||||
|
||||
/* 响应式适配:避免宽屏溢出 */
|
||||
@media (max-width: 1700px) {
|
||||
.dashboard-container {
|
||||
max-width: 95%; /* 中等屏幕用百分比控制宽度 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 头部样式 */
|
||||
.dashboard-header {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #e0e0e0; /* 白天边框:浅灰 */
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 10px 0;
|
||||
color: #333333; /* 白天标题:深灰 */
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 16px;
|
||||
color: #666666; /* 白天副标题:中灰 */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 统计卡片样式(增强白天阴影) */
|
||||
.stat-card {
|
||||
background: #f5f5f5; /* 白天卡片背景:浅灰 */
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); /* 增强阴影 */
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15); /* 增强hover阴影 */
|
||||
}
|
||||
|
||||
.stat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.stat-meta {
|
||||
font-size: 14px;
|
||||
color: #666666; /* 白天元数据:中灰 */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.video-icon {
|
||||
background-color: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.size-icon {
|
||||
background-color: rgba(33, 150, 243, 0.2);
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.like-icon {
|
||||
background-color: rgba(233, 30, 99, 0.2);
|
||||
color: #e91e63;
|
||||
}
|
||||
|
||||
.collect-icon {
|
||||
background-color: rgba(255, 152, 0, 0.2);
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.author-icon {
|
||||
background-color: rgba(156, 39, 176, 0.2);
|
||||
color: #9c27b0;
|
||||
}
|
||||
|
||||
.cate-icon {
|
||||
background-color: rgba(0, 188, 212, 0.2);
|
||||
color: #00bcd4;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 10px 0;
|
||||
color: #333333;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
color: #4caf50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trend-up::before {
|
||||
content: '↑';
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 概览区域布局 */
|
||||
.stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.stats-overview {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.primary-card {
|
||||
border-top: 4px solid #4caf50;
|
||||
}
|
||||
|
||||
.secondary-card {
|
||||
border-top: 4px solid #2196f3;
|
||||
}
|
||||
|
||||
/* 次级统计网格布局 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.mini-card .stat-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
/* 详细统计区域 */
|
||||
.detailed-stats {
|
||||
background: #f5f5f5;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.stats-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.tab-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666666;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-btn:hover:not(.active) {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
/* 统计内容区域 */
|
||||
.stats-content {
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
.authors-grid,
|
||||
.categories-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.authors-grid,
|
||||
.categories-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.authors-grid,
|
||||
.categories-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 作者卡片(增强白天阴影) */
|
||||
.author-card {
|
||||
background: #eeeeee;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); /* 新增阴影 */
|
||||
}
|
||||
|
||||
.author-card:hover {
|
||||
transform: translateX(5px);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12); /* 增强hover阴影 */
|
||||
}
|
||||
|
||||
.author-avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.author-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.author-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 16px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.author-stats {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.author-progress {
|
||||
height: 6px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: #4caf50;
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
/* 分类卡片(增强白天阴影) */
|
||||
.category-card {
|
||||
background: #eeeeee;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); /* 新增阴影 */
|
||||
}
|
||||
|
||||
.category-card:hover {
|
||||
transform: translateX(5px);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12); /* 增强hover阴影 */
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--category-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
margin: 0 0 3px 0;
|
||||
font-size: 16px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.category-stats {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.category-percentage {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--category-color);
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.stats-fade-enter-from,
|
||||
.stats-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.stats-fade-enter-active,
|
||||
.stats-fade-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* 晚上模式样式 */
|
||||
html.dark-mode .stats-dashboard {
|
||||
background-color: #1a1a2e;
|
||||
color: #eaeaea;
|
||||
}
|
||||
|
||||
html.dark-mode .dashboard-header {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
html.dark-mode .dashboard-header h1 {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html.dark-mode .header-subtitle {
|
||||
color: #b0b0c3;
|
||||
}
|
||||
|
||||
html.dark-mode .stat-card {
|
||||
background: rgba(30, 30, 50, 0.8);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
html.dark-mode .stat-card:hover {
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
html.dark-mode .stat-meta {
|
||||
color: #b0b0c3;
|
||||
}
|
||||
|
||||
html.dark-mode .stat-value {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html.dark-mode .detailed-stats {
|
||||
background: rgba(30, 30, 50, 0.8);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
html.dark-mode .stats-header h2 {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html.dark-mode .tab-btn {
|
||||
color: #b0b0c3;
|
||||
}
|
||||
|
||||
html.dark-mode .tab-btn:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html.dark-mode .author-card {
|
||||
background: rgba(40, 40, 65, 0.6);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
html.dark-mode .author-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html.dark-mode .author-stats {
|
||||
color: #b0b0c3;
|
||||
}
|
||||
|
||||
html.dark-mode .author-progress {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
html.dark-mode .category-card {
|
||||
background: rgba(40, 40, 65, 0.6);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
html.dark-mode .category-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html.dark-mode .category-stats {
|
||||
color: #b0b0c3;
|
||||
}
|
||||
</style>
|
||||
@@ -13,19 +13,93 @@ interface NaviGuard {
|
||||
after?: NavigationHookAfter;
|
||||
}
|
||||
|
||||
const loginGuard: NavigationGuard = function (to, from) {
|
||||
// console.log('Authorization', http.checkAuthorization())
|
||||
const account = useAccountStore();
|
||||
if (!http.checkAuthorization() && !/^\/(login|home|init)?$/.test(to.fullPath)) {
|
||||
account.setLogged(false)
|
||||
return '/login';
|
||||
} else {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为移动端设备(UA + 屏幕宽度双检测)
|
||||
*/
|
||||
const isMobile = (): boolean => {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const mobileUaReg = /iphone|android|ipad|ipod|mobile|wap|symbian|windows ce|blackberry|webos|ucbrowser/i;
|
||||
const isSmallScreen = window.innerWidth < 768;
|
||||
return mobileUaReg.test(userAgent) || isSmallScreen;
|
||||
};
|
||||
|
||||
const dynamicinitRoute =
|
||||
{
|
||||
// 标记是否已跳转到移动端路由,防止无限循环
|
||||
let hasRedirectedToMobile = false;
|
||||
// 新增:标记是否已从/mobile跳转到/dashboard,防止无限循环
|
||||
let hasRedirectedToDashboard = false;
|
||||
// 优化后的移动端跳转守卫(登录优先)
|
||||
const MobileRedirectGuard: NavigationGuard = function (to, from, next) {
|
||||
// 1. 排除移动端路由和登录页,避免逻辑干扰
|
||||
const isMobileRoute = to.path === '/mobile';
|
||||
const isLoginRoute = to.path === '/login';
|
||||
// 新增:检测当前是否为非移动端设备
|
||||
const isNonMobile = !isMobile();
|
||||
|
||||
// 【新增核心逻辑】:当前路由是/mobile,但不是移动端设备,跳转到/dashboard
|
||||
if (isMobileRoute && isNonMobile) {
|
||||
if (!hasRedirectedToDashboard) {
|
||||
hasRedirectedToDashboard = true;
|
||||
// 重置移动端跳转标记,避免后续干扰
|
||||
hasRedirectedToMobile = false;
|
||||
next({ path: '/dashboard' });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMobileRoute) {
|
||||
hasRedirectedToMobile = true;
|
||||
// 重置dashboard跳转标记,避免后续干扰
|
||||
hasRedirectedToDashboard = false;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoginRoute) {
|
||||
// 登录页无需移动端跳转,直接放行
|
||||
hasRedirectedToMobile = false; // 重置标记,登录后可正常跳转移动端
|
||||
hasRedirectedToDashboard = false; // 重置dashboard跳转标记
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 移动端检测
|
||||
if (isMobile()) {
|
||||
const isAuthorized = http.checkAuthorization();
|
||||
if (!isAuthorized) {
|
||||
// 未登录:优先跳登录(保持原有优先级)
|
||||
next('/login');
|
||||
} else {
|
||||
// 已登录:跳移动端(防止重复跳转)
|
||||
if (!hasRedirectedToMobile) {
|
||||
hasRedirectedToMobile = true;
|
||||
hasRedirectedToDashboard = false; // 重置dashboard跳转标记
|
||||
next({ path: '/mobile' });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 非移动端:重置所有标记,不影响后续操作
|
||||
hasRedirectedToMobile = false;
|
||||
hasRedirectedToDashboard = false;
|
||||
next();
|
||||
}
|
||||
};
|
||||
const loginGuard: NavigationGuard = function (to, from, next) {
|
||||
if (!http.checkAuthorization() && !/^\/(init|login|home|mobile)?$/.test(to.fullPath)) {
|
||||
console.log(to.fullPath)
|
||||
const account = useAccountStore();
|
||||
account.setLogged(false);
|
||||
// 重置dashboard跳转标记
|
||||
hasRedirectedToDashboard = false;
|
||||
next('/login');
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
||||
const dynamicinitRoute = {
|
||||
path: '/',
|
||||
name: 'login',
|
||||
redirect: '/login',
|
||||
@@ -38,31 +112,22 @@ const dynamicinitRoute =
|
||||
component: () => import('@/pages/login'),
|
||||
};
|
||||
|
||||
|
||||
const InitGuard: NavigationGuard = function (to, from) {
|
||||
|
||||
const InitGuard: NavigationGuard = function (to, from, next) {
|
||||
if (to.fullPath != '/login') {
|
||||
useApiStore()
|
||||
.apiCheckInitStatus()
|
||||
.then((res) => {
|
||||
// console.log(to.fullPath)
|
||||
if (res.code === 0) {
|
||||
} else {
|
||||
if (!router.hasRoute('login')) {
|
||||
router.addRoute(dynamicinitRoute)
|
||||
router.addRoute(dynamicinitRoute);
|
||||
}
|
||||
router.push('/login')
|
||||
// return '/init'
|
||||
}
|
||||
});
|
||||
next('/login');
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 进度条
|
||||
const ProgressGuard: NaviGuard = {
|
||||
before(to, from) {
|
||||
before(to, from, next) {
|
||||
NProgress.start();
|
||||
next();
|
||||
},
|
||||
after(to, from) {
|
||||
NProgress.done();
|
||||
@@ -70,16 +135,18 @@ const ProgressGuard: NaviGuard = {
|
||||
};
|
||||
|
||||
const AuthGuard: NaviGuard = {
|
||||
before(to, from) {
|
||||
before(to, from, next) {
|
||||
const { hasAuthority } = useAuthStore();
|
||||
if (to.meta?.permission && !hasAuthority(to.meta?.permission)) {
|
||||
return { name: '403', query: { permission: to.meta.permission, path: to.fullPath } };
|
||||
next({ name: '403', query: { permission: to.meta.permission, path: to.fullPath } });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const ForbiddenGuard: NaviGuard = {
|
||||
before(to) {
|
||||
before(to, from, next) {
|
||||
if (to.name === '403' && (to.query.permission || to.query.path)) {
|
||||
to.fullPath = to.fullPath
|
||||
.replace(/permission=[^&=]*&?/, '')
|
||||
@@ -90,21 +157,59 @@ const ForbiddenGuard: NaviGuard = {
|
||||
delete to.query.permission;
|
||||
delete to.query.path;
|
||||
}
|
||||
next();
|
||||
},
|
||||
};
|
||||
|
||||
// 404 not found
|
||||
const NotFoundGuard: NaviGuard = {
|
||||
before(to, from) {
|
||||
before(to, from, next) {
|
||||
const { loading } = useMenuStore();
|
||||
if (to.meta._is404Page && loading) {
|
||||
to.params.loading = true as any;
|
||||
}
|
||||
next();
|
||||
},
|
||||
};
|
||||
|
||||
// 优化后的页面刷新移动端检测(登录优先)
|
||||
window.addEventListener('load', () => {
|
||||
const currentPath = window.location.pathname;
|
||||
const isNonMobile = !isMobile();
|
||||
|
||||
// 【新增】:刷新后如果是/mobile路由且非移动端,跳转到/dashboard
|
||||
if (currentPath === '/mobile' && isNonMobile) {
|
||||
router.push('/dashboard').catch(err => {
|
||||
if (!err.message.includes('NavigationDuplicated')) {
|
||||
console.error('刷新时从/mobile跳转到/dashboard失败:', err);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMobile() && currentPath !== '/mobile') {
|
||||
const isAuthorized = http.checkAuthorization();
|
||||
if (!isAuthorized) {
|
||||
// 未登录:跳登录(避免重复跳转)
|
||||
if (currentPath !== '/login') {
|
||||
router.push('/login').catch(err => {
|
||||
if (!err.message.includes('NavigationDuplicated')) {
|
||||
console.error('刷新时跳转登录页失败:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 已登录:跳移动端
|
||||
router.push('/mobile').catch(err => {
|
||||
if (!err.message.includes('NavigationDuplicated')) {
|
||||
console.error('刷新时跳转移动端路由失败:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default {
|
||||
// before: [ProgressGuard.before, InitGuard, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before],
|
||||
before: [ProgressGuard.before, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before],
|
||||
before: [ProgressGuard.before, MobileRedirectGuard, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before],
|
||||
after: [ProgressGuard.after],
|
||||
};
|
||||
@@ -9,7 +9,7 @@ const router = createRouter(
|
||||
routes,
|
||||
}
|
||||
);
|
||||
console.log(router)
|
||||
// console.log(router)
|
||||
// 注册导航守卫
|
||||
guards.before.forEach(router.beforeEach);
|
||||
guards.after.forEach(router.afterEach);
|
||||
|
||||
@@ -14,6 +14,31 @@ const routes: RouteRecordRaw[] = [
|
||||
children: null,
|
||||
component: () => import('@/pages/login'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'mobile',
|
||||
redirect: '/mobile',
|
||||
meta: {
|
||||
title: '移动端首页',
|
||||
renderMenu: false,
|
||||
icon: 'CreditCardOutlined',
|
||||
},
|
||||
children: null,
|
||||
component: () => import('@/pages/mobile/MobileDashboard.vue'),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/',
|
||||
name: 'init',
|
||||
redirect: '/init',
|
||||
meta: {
|
||||
title: '初始化',
|
||||
renderMenu: false,
|
||||
icon: 'CreditCardOutlined',
|
||||
},
|
||||
children: null,
|
||||
component: () => import('@/pages/desk/index.vue'),
|
||||
},
|
||||
// {
|
||||
// path: '/',
|
||||
// name: 'init',
|
||||
@@ -26,6 +51,7 @@ const routes: RouteRecordRaw[] = [
|
||||
// children: null,
|
||||
// component: () => import('@/pages/init'),
|
||||
// },
|
||||
|
||||
{
|
||||
path: '/front',
|
||||
name: '前端',
|
||||
@@ -45,7 +71,30 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
component: () => import('@/pages/login'),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/mobile',
|
||||
name: 'mobile',
|
||||
meta: {
|
||||
icon: 'LoginOutlined',
|
||||
view: 'blank',
|
||||
target: '_blank',
|
||||
cacheable: false,
|
||||
},
|
||||
children: null,
|
||||
component: () => import('@/pages/mobile/MobileDashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: '/init',
|
||||
name: 'init',
|
||||
meta: {
|
||||
icon: 'LoginOutlined',
|
||||
view: 'blank',
|
||||
target: '_blank',
|
||||
cacheable: false,
|
||||
},
|
||||
children: null,
|
||||
component: () => import('@/pages/desk/index.vue'),
|
||||
},
|
||||
// {
|
||||
// path: '/init',
|
||||
// name: '初始化',
|
||||
@@ -107,6 +156,18 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
component: () => import('@/pages/workplace/Workplace.vue'),
|
||||
},
|
||||
{
|
||||
path: '/follow',
|
||||
name: '关注列表',
|
||||
meta: {
|
||||
icon: 'HeartOutlined',
|
||||
view: 'self',
|
||||
target: '_self',
|
||||
renderMenu: true,
|
||||
cacheable: false,
|
||||
},
|
||||
component: () => import('@/pages/followd/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/cok',
|
||||
name: '抖音授权',
|
||||
@@ -119,6 +180,7 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
component: () => import('@/pages/cok/Table.vue'),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/set',
|
||||
name: '系统配置',
|
||||
|
||||
@@ -39,7 +39,7 @@ export const useAccountStore = defineStore('account', {
|
||||
if (response.code === 200 && response.data.code === 0) {
|
||||
this.logged = true;
|
||||
this.logged2 = true
|
||||
console.log(response)
|
||||
// console.log(response)
|
||||
http.setAuthorization(`Bearer ${response.data.token}`, response.data.expires);
|
||||
// await useMenuStore().getMenuList();
|
||||
return response.data;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore, storeToRefs } from 'pinia';
|
||||
import http from './http';
|
||||
import { ref, watch } from 'vue';
|
||||
import { Response } from '@/types';
|
||||
|
||||
// import { RouteOption } from '@/router/interface';
|
||||
// import { addRoutes, removeRoute } from '@/router/dynamicRoutes';
|
||||
// import { useSettingStore } from './setting';
|
||||
@@ -28,62 +29,34 @@ import { Response } from '@/types';
|
||||
// }
|
||||
|
||||
export const useApiStore = defineStore('coreapi', () => {
|
||||
//检查是否已经初始化过了
|
||||
async function apiCheckInitStatus() {
|
||||
|
||||
return { code: 0 }
|
||||
// const initStatus = localStorage.getItem('ddns-init');
|
||||
// if (initStatus && initStatus === '1') {
|
||||
// return { code: 0 }
|
||||
// } else {
|
||||
|
||||
async function apiGetConfig() {
|
||||
return http.request<any, Response<any>>('/api/config/GetConfig', 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
// //获取配置
|
||||
// async function apiGetConfig() {
|
||||
// return http
|
||||
// .request<any, Response<any>>('/api/init/Check', 'GET')
|
||||
// .request<any, Response<any>>('/api/config/GetConfig', 'GET')
|
||||
// .then((res) => {
|
||||
// // console.log(res)
|
||||
// if (res.data.code === 0) {
|
||||
// // localStorage.setItem('ddns-init', '1')
|
||||
// }
|
||||
// return res.data;
|
||||
// return res;
|
||||
// })
|
||||
// .finally(() => {
|
||||
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
//初始化系统配置
|
||||
async function apiInit(request: object) {
|
||||
console.log(request)
|
||||
return http
|
||||
.request<any, Response<any>>('/api/init/Init', 'post_json', request)
|
||||
.then((res) => {
|
||||
// console.log(res)
|
||||
|
||||
return res.data;
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//获取配置
|
||||
async function apiGetConfig() {
|
||||
return http
|
||||
.request<any, Response<any>>('/api/config/GetConfig', 'GET')
|
||||
.then((res) => {
|
||||
console.log(res)
|
||||
return res.data;
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//修改配置
|
||||
async function apiUpdateConfig(request: object) {
|
||||
return http
|
||||
.request<any, Response<any>>('/api/config/UpdateConfig', 'post_json', request)
|
||||
.then((res) => {
|
||||
console.log(res)
|
||||
return res.data;
|
||||
return res;
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -91,7 +64,7 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
}
|
||||
//后台日志
|
||||
async function apiGetLogs(param: string) {
|
||||
return http.request<any, Response<any>>('/api/logs/GetLog?' + param, 'get').then(r => {
|
||||
return http.request<any, Response<any>>('/api/logs/GetLog/' + param, 'get').then(r => {
|
||||
// console.log(r)
|
||||
return r.data;
|
||||
}).finally(() => {
|
||||
@@ -101,7 +74,7 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
//用户信息-头像
|
||||
async function apiUserInfo() {
|
||||
return http.request<any, Response<any>>('/api/auth/GetUserAvatar', 'get').then(r => {
|
||||
return r.data;
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
@@ -109,7 +82,7 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
//密码修改
|
||||
async function apiChangePwd(param: object) {
|
||||
return http.request<any, Response<any>>('/api/auth/UpdatePwd', 'post_json', param).then(r => {
|
||||
return r.data;
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
@@ -117,14 +90,23 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
//StartJobNow
|
||||
async function StartJobNow() {
|
||||
return http.request<any, Response<any>>('/api/config/ExecuteJobNow', 'get').then(r => {
|
||||
return r.data;
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//视频统计
|
||||
async function VideoStatics() {
|
||||
return http.request<any, Response<any>>('/api/video/statics', 'get').then(r => {
|
||||
return r.data;
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//视频曲线
|
||||
async function VideoChart(day: number) {
|
||||
return http.request<any, Response<any>>(`/api/video/chart/${day}`, 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
@@ -133,7 +115,7 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
//视频查询
|
||||
async function VideoPageList(param: object) {
|
||||
return http.request<any, Response<any>>('/api/video/paged', 'post_json', param).then(r => {
|
||||
return r.data;
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
@@ -141,7 +123,14 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
//cookies
|
||||
async function CookiePageList(param: object) {
|
||||
return http.request<any, Response<any>>('/api/config/paged', 'post_json', param).then(r => {
|
||||
return r.data;
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
async function CookieList() {
|
||||
return http.request<any, Response<any>>('/api/config/list', 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
@@ -149,25 +138,277 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
|
||||
async function UpdateConfig(param: object) {
|
||||
return http.request<any, Response<any>>('/api/config/update', 'post_json', param).then(r => {
|
||||
return r.data;
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
async function DeskInitAsync(param: object) {
|
||||
return http.request<any, Response<any>>('/api/config/deskinit', 'post_json', param).then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
async function AppisInit() {
|
||||
return http.request<any, Response<any>>('/api/config/isInit', 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function deleteCookie(id: string) {
|
||||
return http.request<any, Response<any>>('/api/config/delete?id=' + id, 'get').then(r => {
|
||||
return r.data;
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//follows
|
||||
async function FollowList(param: object) {
|
||||
return http.request<any, Response<any>>('/api/follow/paged', 'post_json', param).then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//同步关注列表
|
||||
async function SyncFollow() {
|
||||
return http.request<any, Response<any>>('/api/follow/sync', 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//更新同步关注者状态
|
||||
async function OpenOrCloseSync(param: object) {
|
||||
return http.request<any, Response<any>>('/api/follow/openOrCloseSync', 'post_json', param).then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//更新同步关注者状态
|
||||
async function OpenOrCloseFullSync(param: object) {
|
||||
return http.request<any, Response<any>>('/api/follow/openOrCloseFullSync', 'post_json', param).then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//重新下载
|
||||
async function ReDownViedos(param: object) {
|
||||
return http.request<any, Response<any>>('/api/video/redown', 'post_json', param).then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//批量删除
|
||||
async function BathRealDelete(param: object) {
|
||||
return http.request<any, Response<any>>('/api/video/vdelete/batch', 'post_json', param).then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//删除
|
||||
async function DeleteVideo(param: string) {
|
||||
return http.request<any, Response<any>>('/api/video/vdelete/' + param, 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//查询已删除
|
||||
async function GetDeleteViedos() {
|
||||
return http.request<any, Response<any>>('/api/video/vdelete/get', 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//删除博主全部视频
|
||||
async function DeleteByAuthor(param: string) {
|
||||
return http.request<any, Response<any>>('/api/video/vdelete/byauthor/' + param, 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
//检查版本
|
||||
async function getVer() {
|
||||
return http.request<any, Response<any>>('/api/config/mytag', 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//检查版本
|
||||
async function CheckTag() {
|
||||
return http.request<any, Response<any>>('/api/config/checktag', 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
async function mp3List() {
|
||||
return http.request<any, Response<any>>('/api/config/mp3List', 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
//快速停止或启动cookie配置
|
||||
async function SwitchCookieStatus(param: object) {
|
||||
return http.request<any, Response<any>>('/api/config/switch', 'post_json', param).then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
//添加非关注的博主
|
||||
async function AddFollow(param: object) {
|
||||
return http.request<any, Response<any>>('/api/follow/add', 'post_json', param).then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//删除非关注的博主
|
||||
async function DelFollow(param: object) {
|
||||
return http.request<any, Response<any>>('/api/follow/delete', 'post_json', param).then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
//导出配置
|
||||
async function ExportConf() {
|
||||
return http.request<any, Response<any>>('/api/config/exportConf', 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//导入配置
|
||||
async function ImportConf(param: object) {
|
||||
return http.request<any, Response<any>>('/api/config/importConf', 'post_json', param).then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
//移动端获取日志列表
|
||||
async function MobileLogs() {
|
||||
return http.request<any, Response<any>>('/api/logs/list', 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
//移动端获取日志详情
|
||||
async function LogDetail(type: string, date: string) {
|
||||
return http.request<any, Response<any>>('/api/logs/content?type=' + type + "&Date=" + date, 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
//TOP
|
||||
async function TopVideo(param: number) {
|
||||
return http.request<any, Response<any>>('/api/Video/top' + param, 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
//Renfo
|
||||
async function Renfo() {
|
||||
return http.request<any, Response<any>>('/api/Video/renfo', 'get').then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
//合集、自定义收藏夹、短剧列表
|
||||
async function CatePageList(param: object) {
|
||||
return http.request<any, Response<any>>('/api/cate/paged', 'post_json', param).then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//批量修改 合集、自定义收藏夹、短剧
|
||||
async function BatchSaveCate(param: object) {
|
||||
return http.request<any, Response<any>>('/api/cate/BatchSave', 'post_json', param).then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
// // 音频文件上传接口
|
||||
// async function apiUploadAudio(formData: FormData, options?: { onUploadProgress?: (progressEvent: ProgressEvent) => void }) {
|
||||
// return http
|
||||
// .request<any, Response<any>>(
|
||||
// '/api/config/UploadAudio', // 请求地址
|
||||
// 'post_form', // 使用新增的 post_form 类型
|
||||
// formData, // FormData 参数(文件+其他参数)
|
||||
// {
|
||||
// onUploadProgress: options?.onUploadProgress, // 上传进度回调(原生 ProgressEvent)
|
||||
// timeout: 120000 // 上传文件超时时间设为2分钟(可选)
|
||||
// }
|
||||
// )
|
||||
// .then((res) => {
|
||||
// // console.log('音频上传结果:', res);
|
||||
// // 适配你的响应格式(如果响应是包裹层,取 data)
|
||||
// return res;
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// console.error('音频上传失败:', err);
|
||||
// throw err; // 抛出错误让前端捕获
|
||||
// });
|
||||
// }
|
||||
|
||||
return {
|
||||
VideoChart,
|
||||
BatchSaveCate,
|
||||
CatePageList,
|
||||
getVer,
|
||||
mp3List,
|
||||
BathRealDelete,
|
||||
DeleteByAuthor,
|
||||
Renfo,
|
||||
// apiUploadAudio,
|
||||
AppisInit,
|
||||
DeskInitAsync,
|
||||
SwitchCookieStatus,
|
||||
TopVideo,
|
||||
LogDetail,
|
||||
MobileLogs,
|
||||
ExportConf,
|
||||
ImportConf,
|
||||
GetDeleteViedos,
|
||||
DelFollow,
|
||||
AddFollow,
|
||||
CheckTag,
|
||||
deleteCookie,
|
||||
UpdateConfig,
|
||||
apiCheckInitStatus,
|
||||
apiInit,
|
||||
apiGetConfig,
|
||||
apiUpdateConfig,
|
||||
apiGetLogs,
|
||||
@@ -176,6 +417,13 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
StartJobNow,
|
||||
VideoStatics,
|
||||
VideoPageList,
|
||||
CookiePageList
|
||||
CookiePageList,
|
||||
CookieList,
|
||||
FollowList,
|
||||
SyncFollow,
|
||||
OpenOrCloseSync,
|
||||
OpenOrCloseFullSync,
|
||||
ReDownViedos,
|
||||
DeleteVideo
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import createHttp from '@/utils/axiosHttp';
|
||||
import { isResponse } from '@/types';
|
||||
import NProgress from 'nprogress';
|
||||
import { useAccountStore } from '@/store';
|
||||
import { message } from 'ant-design-vue';
|
||||
import router from '@/router';
|
||||
|
||||
const http = createHttp({
|
||||
timeout: 60000,
|
||||
@@ -16,7 +18,10 @@ const isAxiosResponse = (obj: any): obj is AxiosResponse => {
|
||||
return typeof obj === 'object' && obj.status && obj.statusText && obj.headers && obj.config;
|
||||
};
|
||||
|
||||
// progress 进度条 -- 开启
|
||||
// 仅新增这一行:跳转锁
|
||||
let isRedirecting = false;
|
||||
|
||||
// progress 进度条 -- 开启(和你原本一致)
|
||||
http.interceptors.request.use((req: AxiosRequestConfig) => {
|
||||
if (!NProgress.isStarted()) {
|
||||
NProgress.start();
|
||||
@@ -24,25 +29,44 @@ http.interceptors.request.use((req: AxiosRequestConfig) => {
|
||||
return req;
|
||||
});
|
||||
|
||||
// 解析响应结果
|
||||
// 解析响应结果(完全和你原本一致,一字未改)
|
||||
http.interceptors.response.use(
|
||||
(rep: AxiosResponse<String>) => {
|
||||
const { data } = rep;
|
||||
// if (data.code != null)
|
||||
// console.log(data)
|
||||
// if (data.code === 401) {
|
||||
// console.log(data)
|
||||
// }
|
||||
if (isResponse(data)) {
|
||||
return data.code === 0 ? data : Promise.reject(data);
|
||||
}
|
||||
return Promise.reject({ message: rep.statusText, code: rep.status, data });
|
||||
},
|
||||
(error) => {
|
||||
// console.log(error)
|
||||
// debugger
|
||||
if (error.response.status === 401) {
|
||||
useAccountStore().setLogged(false)
|
||||
if (error.response?.status === 401) {
|
||||
// 仅新增:加锁判断(这是唯一改动)
|
||||
if (!isRedirecting) {
|
||||
isRedirecting = true;
|
||||
|
||||
const accountStore = useAccountStore();
|
||||
accountStore.setLogged(false);
|
||||
message.warning('登录状态已过期,请重新登录');
|
||||
setTimeout(() => {
|
||||
|
||||
const redirectPath = router.currentRoute.value.fullPath;
|
||||
if (redirectPath !== '/login') {
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: { redirect: redirectPath }
|
||||
}).then(() => {
|
||||
console.log('跳转登录页成功');
|
||||
}).catch((err) => {
|
||||
console.error('跳转登录页失败:', err);
|
||||
}).finally(() => {
|
||||
isRedirecting = false;
|
||||
});
|
||||
} else {
|
||||
isRedirecting = false;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
// 新增结束
|
||||
} else {
|
||||
if (error.response && isAxiosResponse(error.response)) {
|
||||
return Promise.reject({
|
||||
@@ -57,7 +81,7 @@ http.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
// progress 进度条 -- 关闭
|
||||
// progress 进度条 -- 关闭(改回你原本的逻辑,不碰返回值)
|
||||
http.interceptors.response.use(
|
||||
(rep) => {
|
||||
if (NProgress.isStarted()) {
|
||||
@@ -69,6 +93,7 @@ http.interceptors.response.use(
|
||||
if (NProgress.isStarted()) {
|
||||
NProgress.done();
|
||||
}
|
||||
// 改回你原本的返回值:return error(之前改这个导致登录异常)
|
||||
return error;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ref } from 'vue';
|
||||
export type Navigation = 'side' | 'head' | 'mix';
|
||||
|
||||
export const useSettingStore = defineStore('setting', () => {
|
||||
const navigation = ref<Navigation>('head');
|
||||
const navigation = ref<Navigation>('side');
|
||||
const useTabs = ref<boolean>(true);
|
||||
const theme = ref('header-night');
|
||||
const contentClass = ref('common');
|
||||
|
||||
@@ -21,13 +21,13 @@ export function configTheme(key: string) {
|
||||
setBgSeriesColors({ 'bg-base': '#1a1a2e' });
|
||||
break;
|
||||
case 'header-dark':
|
||||
setNavigation('head');
|
||||
// setNavigation('head');
|
||||
break;
|
||||
default:
|
||||
setNavigation('side');
|
||||
// setNavigation('side');
|
||||
}
|
||||
if (key.indexOf('header') !== -1) {
|
||||
setNavigation('head');
|
||||
// setNavigation('head');
|
||||
setTheme(key.split('-')[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, Method as _Method, AxiosResponse } from 'axios';
|
||||
|
||||
import qs from 'qs';
|
||||
import Cookie from 'js-cookie';
|
||||
|
||||
@@ -9,13 +8,13 @@ declare interface _AxiosExtend {
|
||||
* @param url 请求地址
|
||||
* @param method 请求方法
|
||||
* @param params 请求参数
|
||||
* @param config 请求配置
|
||||
* @param config 请求配置(新增上传进度回调)
|
||||
*/
|
||||
request<T = any, R = AxiosResponse<T>>(
|
||||
url: string,
|
||||
method: Method,
|
||||
params?: Record<string | number, any>,
|
||||
config?: AxiosRequestConfig
|
||||
params?: Record<string | number, any> | FormData,
|
||||
config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void }
|
||||
): Promise<R>;
|
||||
/**
|
||||
* 设置token
|
||||
@@ -41,7 +40,7 @@ declare interface _AxiosExtend {
|
||||
|
||||
export interface AxiosHttp extends Omit<AxiosInstance, 'request'>, _AxiosExtend { }
|
||||
|
||||
export type Method = _Method | 'POST_JSON' | 'post_json' | 'PUT_JSON' | 'put_json';
|
||||
export type Method = _Method | 'POST_JSON' | 'post_json' | 'PUT_JSON' | 'put_json' | 'POST_FORM' | 'post_form';
|
||||
|
||||
/**
|
||||
* 转表单格式
|
||||
@@ -83,22 +82,10 @@ function toUrlencoded(params?: Record<string | number, any>) {
|
||||
function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
|
||||
const _axios = axios.create(config);
|
||||
|
||||
// 添加响应拦截器处理401状态
|
||||
// 移除 401 处理,仅保留基础的响应拦截器(可选,也可直接删除)
|
||||
_axios.interceptors.response.use(
|
||||
// 成功响应直接返回
|
||||
response => response,
|
||||
// 错误响应处理
|
||||
error => {
|
||||
// 检查是否是401未授权错误
|
||||
if (error.response && error.response.status === 401) {
|
||||
// 调用removeAuthorization方法清除token
|
||||
http.removeAuthorization();
|
||||
|
||||
// 这里可以添加额外的处理,比如跳转到登录页
|
||||
// 示例: window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
const http: AxiosHttp = {
|
||||
@@ -106,10 +93,15 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
|
||||
request<T = any, R = AxiosResponse<T>>(
|
||||
url: string,
|
||||
method: Method,
|
||||
params?: Record<string | number, any>,
|
||||
config?: AxiosRequestConfig
|
||||
params?: Record<string | number, any> | FormData,
|
||||
config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void }
|
||||
): Promise<R> {
|
||||
const _method = method.toUpperCase();
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
...config,
|
||||
onUploadProgress: config?.onUploadProgress,
|
||||
};
|
||||
|
||||
switch (_method) {
|
||||
case 'GET':
|
||||
return _axios.get(url, {
|
||||
@@ -117,24 +109,35 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
|
||||
paramsSerializer: (data) => {
|
||||
return qs.stringify(data, { indices: false, skipNulls: true });
|
||||
},
|
||||
...config,
|
||||
...requestConfig,
|
||||
});
|
||||
case 'POST':
|
||||
return _axios.post(url, toUrlencoded(params), config);
|
||||
return _axios.post(url, toUrlencoded(params as Record<string | number, any>), requestConfig);
|
||||
case 'POST_JSON':
|
||||
return _axios.post(url, params, config);
|
||||
return _axios.post(url, params, {
|
||||
...requestConfig,
|
||||
headers: { 'Content-Type': 'application/json', ...requestConfig.headers },
|
||||
});
|
||||
case 'POST_FORM':
|
||||
return _axios.post(url, params, {
|
||||
...requestConfig,
|
||||
headers: { ...requestConfig.headers },
|
||||
});
|
||||
case 'PUT':
|
||||
return _axios.put(url, toFormData(params), config);
|
||||
return _axios.put(url, toFormData(params as Record<string | number, any>), requestConfig);
|
||||
case 'PUT_JSON':
|
||||
return _axios.put(url, params, config);
|
||||
return _axios.put(url, params, {
|
||||
...requestConfig,
|
||||
headers: { 'Content-Type': 'application/json', ...requestConfig.headers },
|
||||
});
|
||||
case 'DELETE':
|
||||
return _axios.delete(url, { data: toFormData(params), ...config });
|
||||
return _axios.delete(url, { data: toFormData(params as Record<string | number, any>), ...requestConfig });
|
||||
case 'HEAD':
|
||||
return _axios.head(url, { params, ...config });
|
||||
return _axios.head(url, { params, ...requestConfig });
|
||||
case 'OPTIONS':
|
||||
return _axios.options(url, { params, ...config });
|
||||
return _axios.options(url, { params, ...requestConfig });
|
||||
case 'PATCH':
|
||||
return _axios.patch(url, { params, ...config });
|
||||
return _axios.patch(url, { params, ...requestConfig });
|
||||
case 'PURGE':
|
||||
case 'LINK':
|
||||
case 'UNLINK':
|
||||
|
||||
@@ -23,6 +23,8 @@ const prodRollupOptions = {
|
||||
},
|
||||
};
|
||||
// vite 配置
|
||||
// net stop winnat
|
||||
// net start winnat
|
||||
export default ({ command, mode }) => {
|
||||
// 获取环境变量
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
@@ -32,6 +34,9 @@ export default ({ command, mode }) => {
|
||||
cors: true,
|
||||
port: 8001,
|
||||
open: false, //自动打开
|
||||
watch: {
|
||||
usePolling: true, // 绕过 fsevents 架构不兼容问题(arm64 Mac)
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: env.VITE_API_URL,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"dbconn": "",
|
||||
"dbtype": "Sqlite"
|
||||
}
|
||||
|
After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 412 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 895 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 877 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 708 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 448 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 127 KiB |
@@ -1,126 +0,0 @@
|
||||
namespace dy.net.dto
|
||||
{
|
||||
public class VideoNFOInfo
|
||||
{
|
||||
|
||||
// 基本信息
|
||||
/// <summary>
|
||||
/// 视频标题(通常是本地化标题)
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始标题(通常是影片的原名,如外语片的原名)
|
||||
/// </summary>
|
||||
public string OriginalTitle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序标题,用于媒体库排序时使用
|
||||
/// </summary>
|
||||
public string SortTitle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发行年份
|
||||
/// </summary>
|
||||
public int Year { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 详细剧情简介
|
||||
/// </summary>
|
||||
public string Plot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 剧情大纲(比Plot更简短的描述)
|
||||
/// </summary>
|
||||
public string Outline { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 宣传语、标语(影片的简短宣传句子)
|
||||
/// </summary>
|
||||
public string Tagline { get; set; }
|
||||
|
||||
// 人员信息
|
||||
/// <summary>
|
||||
/// 导演姓名
|
||||
/// </summary>
|
||||
public string Director { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 演员列表
|
||||
/// </summary>
|
||||
public List<string> Actors { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 编剧列表
|
||||
/// </summary>
|
||||
public List<string> Writers { get; set; } = new List<string>();
|
||||
|
||||
// 媒体信息
|
||||
/// <summary>
|
||||
/// 类型(如动作、喜剧、科幻等,多个类型用逗号分隔)
|
||||
/// </summary>
|
||||
public string Genre { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 评分(通常是10分制)
|
||||
/// </summary>
|
||||
public double Rating { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 评分人数
|
||||
/// </summary>
|
||||
public int Votes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 制作公司
|
||||
/// </summary>
|
||||
public string Studio { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 首映日期
|
||||
/// </summary>
|
||||
public DateTime? Premiered { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 片长(通常以分钟为单位,如"120分钟")
|
||||
/// </summary>
|
||||
public string Runtime { get; set; }
|
||||
|
||||
// 文件信息
|
||||
/// <summary>
|
||||
/// 文件名及路径
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小(以字节为单位)
|
||||
/// </summary>
|
||||
public long FileSize { get; set; }
|
||||
|
||||
// 可根据需要添加更多字段
|
||||
/// <summary>
|
||||
/// 国家/地区(制作国家或地区)
|
||||
/// </summary>
|
||||
public string Country { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 语言(影片使用的语言)
|
||||
/// </summary>
|
||||
public string Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 视频编码格式(如H.264, H.265等)
|
||||
/// </summary>
|
||||
public string VideoCodec { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 音频编码格式(如AC3, DTS等)
|
||||
/// </summary>
|
||||
public string AudioCodec { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分辨率(如1920x1080, 3840x2160等)
|
||||
/// </summary>
|
||||
public string Resolution { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace dy.net.dto
|
||||
{
|
||||
public class VideoPageRequestDTO: PageRequestDto
|
||||
{
|
||||
// 3. 引用类型(string)如果允许为null,显式声明为 string?
|
||||
public string? Tag { get; set; }
|
||||
|
||||
public string? Author { get; set; }
|
||||
|
||||
public string? ViedoType { get; set; }
|
||||
|
||||
// 4. 泛型集合(List<string>)允许为null,声明为 List<string>?
|
||||
public List<string>? Dates { get; set; }
|
||||
}
|
||||
|
||||
|
||||
public class PageRequestDto
|
||||
{
|
||||
|
||||
public int PageIndex { get; set; }
|
||||
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,6 @@
|
||||
<SpaRoot>app\</SpaRoot>
|
||||
<DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules\**</DefaultItemExcludes>
|
||||
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
||||
|
||||
|
||||
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
|
||||
<ServerGarbageCollection>false</ServerGarbageCollection>
|
||||
<RetainVMGarbageCollection>true</RetainVMGarbageCollection>
|
||||
@@ -19,49 +17,66 @@
|
||||
<TieredCompilation>true</TieredCompilation>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<DockerfileContext>.</DockerfileContext>
|
||||
<!-- 2. 强制禁用默认编译项(旧版VS关键配置) -->
|
||||
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||
<EnableDefaultContentItems>false</EnableDefaultContentItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="app\**" />
|
||||
<Compile Remove="db\**" />
|
||||
<Compile Remove="expand\**" />
|
||||
<Compile Remove="logs\**" />
|
||||
<Content Remove="app\**" />
|
||||
<Content Remove="db\**" />
|
||||
<Content Remove="expand\**" />
|
||||
<Content Remove="logs\**" />
|
||||
<EmbeddedResource Remove="app\**" />
|
||||
<EmbeddedResource Remove="db\**" />
|
||||
<EmbeddedResource Remove="expand\**" />
|
||||
<EmbeddedResource Remove="logs\**" />
|
||||
<None Remove="app\**" />
|
||||
<None Remove="db\**" />
|
||||
<None Remove="expand\**" />
|
||||
<None Remove="logs\**" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**/*.cs" Exclude="app\**;db\**;expand\**;logs\**;obj\**;bin\**" />
|
||||
<Content Include="**/*.json;**/*.xml;**/*.config" Exclude="app\**;db\**;expand\**;logs\**;obj\**;bin\**" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Content Remove="wwwroot\index.html" />
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ClockSnowFlake" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.16" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="6.0.10" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
||||
<PackageReference Include="Quartz" Version="3.8.0" />
|
||||
<PackageReference Include="Quartz" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.AspNetCore" Version="3.8.0" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.8.0" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.8.0" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
|
||||
<PackageReference Include="SqlSugarCore" Version="5.1.4.128" />
|
||||
<PackageReference Include="SqlSugarCoreNoDrive" Version="5.1.4.124" />
|
||||
<!--<PackageReference Include="SqlSugarCoreNoDrive" Version="5.1.4.124" />-->
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="5.6.3" />
|
||||
<!--<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="5.6.3" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="6.3.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="5.6.3" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="5.6.3" />-->
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Async" Version="1.4.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog" Version="3.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||
<PackageReference Include="BouncyCastle.NetCore" Version="2.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="db\" />
|
||||
|
||||
<!--<PackageReference Include="BouncyCastle.NetCore" Version="2.2.1" />-->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -69,15 +84,35 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="db\dy.sqlite">
|
||||
<None Update="Dockerfile">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Dockerfile-arm">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Dockerfile">
|
||||
<None Update="mp3\silent_10.mp3">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="mp3_0\silent_10.mp3">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="db\" />
|
||||
<Folder Include="wwwroot\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="jzc.http.lib">
|
||||
<HintPath>lib\jzc.http.lib.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Update="appsettings.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
|
||||
<!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
|
||||
@@ -96,5 +131,11 @@
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<!-- 核心配置:仅用核心版本号2.2.0,自动拼接前缀 -->
|
||||
<!--<Target Name="DockerBuildAndPushAfterPublish" AfterTargets="Publish">
|
||||
<Exec Command="docker build -t jianzhichu/dysync.net:beta_$(DockerCoreVersion) . && docker tag jianzhichu/dysync.net:beta_$(DockerCoreVersion) ccr.ccs.tencentyun.com/jianzhichu/dysync:beta_$(DockerCoreVersion) && docker push ccr.ccs.tencentyun.com/jianzhichu/dysync:beta_$(DockerCoreVersion)"
|
||||
Condition="'$(DockerArch)' == 'x86'" />
|
||||
</Target>-->
|
||||
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<ActiveDebugProfile>tiny.ddns</ActiveDebugProfile>
|
||||
<NameOfLastUsedPublishProfile>E:\code\dysync\Properties\PublishProfiles\FolderProfile.pubxml</NameOfLastUsedPublishProfile>
|
||||
<ActiveDebugProfile>dy.net</ActiveDebugProfile>
|
||||
<NameOfLastUsedPublishProfile>E:\code\dysync\Properties\PublishProfiles\docker.pubxml</NameOfLastUsedPublishProfile>
|
||||
<Controller_SelectedScaffolderID>ApiControllerEmptyScaffolder</Controller_SelectedScaffolderID>
|
||||
<Controller_SelectedScaffolderCategoryPath>root/Common/Api</Controller_SelectedScaffolderCategoryPath>
|
||||
</PropertyGroup>
|
||||
|
||||