From e6c5587637fd71b2e008be89c15acedde717c5fb Mon Sep 17 00:00:00 2001 From: yuming Date: Fri, 3 Jul 2026 18:03:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Modal=20=E5=B0=8F=E5=B1=8F=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=20+=20=E6=95=B0=E6=8D=AE=E7=9C=8B=E6=9D=BF=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=92=AD=E6=94=BE=20+=20Docker=20Alpine=20=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据看板作者视频播放接口从 /api/video/stream 改为 /api/Video/play(后端只有 Play action,之前笔误导致 404) - 全局 Modal 小屏适配:body max-height + 自定义 10px 深色滚动条,兼容 full-modal 和登录页 login-modal - Docker 镜像换 Alpine + 内置 ffmpeg,避开 Ubuntu apt 网络问题 - 新增 .dockerignore 排除 node_modules / logs / videos / db 等构建噪音 - 移除已废弃的 DyQrCodeService(旧扫码登录) Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 15 ++ Controllers/ConfigController.cs | 65 +----- Dockerfile.prod | 50 +---- app/src/App.vue | 77 +++++++ app/src/components/video/VideoPlayer.vue | 17 +- app/src/pages/cok/CookieTable.vue | 54 +++-- app/src/pages/workplace/statics.vue | 262 ++++++++++++++++++++++- app/src/store/coreapi.ts | 1 - app/vite.config.ts | 3 + dy.net.csproj | 1 - extension/ServiceExtension.cs | 2 - service/DyQrCodeService.cs | 153 ------------- 12 files changed, 411 insertions(+), 289 deletions(-) create mode 100644 .dockerignore delete mode 100644 service/DyQrCodeService.cs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c1b0b47 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/Controllers/ConfigController.cs b/Controllers/ConfigController.cs index 0d8ec41..ce71c9c 100644 --- a/Controllers/ConfigController.cs +++ b/Controllers/ConfigController.cs @@ -25,11 +25,8 @@ namespace dy.net.Controllers private readonly DouyinFollowService douyinFollowService; private readonly DouyinCookieService douyinCookieService; private readonly DouyinHttpClientService httpClientService; - private readonly DyQrCodeService _qrCodeService; - - - public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService, DyQrCodeService qrCodeService) + public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService) { this.dyCookieService = dyCookieService; this.commonService = commonService; @@ -37,7 +34,6 @@ namespace dy.net.Controllers this.douyinFollowService = douyinFollowService; this.douyinCookieService = douyinCookieService; this.httpClientService = httpClientService; - _qrCodeService = qrCodeService; } @@ -487,64 +483,5 @@ namespace dy.net.Controllers } } - /// - /// 获取抖音扫码登录二维码 - /// - [AllowAnonymous] - [HttpGet("qrcode")] - public async Task GetQrCode() - { - try - { - var (token, qrBase64) = await _qrCodeService.GetQrCodeAsync(); - return Ok(new { code = 0, data = new { token, qrBase64 } }); - } - catch (Exception ex) - { - return Ok(new { code = -1, error = ex.Message }); - } - } - - /// - /// 轮询扫码状态(前端每2秒调一次) - /// status: 1=待扫码, 2=已扫码待确认, 3=登录成功, -1=已过期 - /// - [AllowAnonymous] - [HttpGet("qrstatus")] - public async Task GetQrStatus([FromQuery] string token) - { - try - { - var (status, cookies, secUserId) = await _qrCodeService.CheckStatusAsync(token); - return Ok(new { code = 0, data = new { status, cookies, secUserId } }); - } - catch (Exception ex) - { - Serilog.Log.Error($"查询扫码状态失败: {ex.Message}"); - return Ok(new { code = -1, error = ex.Message }); - } - } - - /// - /// 扫码成功后,将 Cookie 绑定到指定账号 - /// - [HttpPost("qrbind")] - public async Task BindQrCode([FromQuery] string token, [FromQuery] string id) - { - try - { - var (cookies, secUserId) = _qrCodeService.GetSessionResult(token); - if (string.IsNullOrEmpty(cookies)) - return Ok(new { code = -1, error = "扫码尚未完成或已过期" }); - - var result = await douyinCookieService.FastResetCookie(id, cookies); - _qrCodeService.RemoveSession(token); - return Ok(new { code = result ? 0 : -1, secUserId }); - } - catch (Exception ex) - { - return Ok(new { code = -1, error = ex.Message }); - } - } } } diff --git a/Dockerfile.prod b/Dockerfile.prod index 6f955b9..d4a7e08 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -10,7 +10,7 @@ RUN npm config set registry https://registry.npmmirror.com && \ npm install -g pnpm && \ pnpm config set registry https://registry.npmmirror.com && \ if [ -f pnpm-lock.yaml ]; then \ - pnpm install --frozen-lockfile; \ + pnpm install --no-frozen-lockfile; \ else \ npm install; \ fi @@ -18,17 +18,11 @@ RUN npm config set registry https://registry.npmmirror.com && \ COPY app/ ./ RUN pnpm build || npm run build -# 第二阶段:构建后端 + 下载 Playwright Chromium -FROM mcr.azurecr.cn/dotnet/sdk:6.0 AS backend-builder +# 第二阶段:构建后端 +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS backend-builder WORKDIR /src -# 安装 Node.js 18(用于 npx playwright install,最可靠的方式) -RUN apt-get update && apt-get install -y curl && \ - curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ - apt-get install -y nodejs && \ - rm -rf /var/lib/apt/lists/* - COPY *.csproj ./ RUN dotnet restore dy.net.csproj @@ -39,46 +33,18 @@ COPY --from=frontend-builder /app/frontend/dist ./app/dist RUN dotnet publish dy.net.csproj -c Release -o /app/publish -# 用 npx 下载 Playwright Chromium 到固定路径 -ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright -RUN npx -y playwright@1.44.0 install chromium - -# 第三阶段:运行镜像 -FROM mcr.azurecr.cn/dotnet/aspnet:6.0 AS final +# 第三阶段:运行镜像(Alpine 版:apk 包更小、代理下更易通过) +FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine AS final WORKDIR /app ENV TZ=Asia/Shanghai -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -# 切换阿里云镜像源,解决国内网络无法访问 deb.debian.org 的问题 -RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list && \ - sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list - -# 安装 Chromium 运行时依赖(Debian Bullseye) -RUN apt-get update && apt-get install -y --no-install-recommends \ - libnss3 \ - libatk1.0-0 \ - libatk-bridge2.0-0 \ - libcups2 \ - libdrm2 \ - libxkbcommon0 \ - libxcomposite1 \ - libxdamage1 \ - libxfixes3 \ - libxrandr2 \ - libgbm1 \ - libasound2 \ - libpango-1.0-0 \ - libpangocairo-1.0-0 \ - libgtk-3-0 \ - fonts-liberation \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache tzdata ffmpeg && \ + cp /usr/share/zoneinfo/$TZ /etc/localtime && \ + echo $TZ > /etc/timezone COPY --from=backend-builder /app/publish . -COPY --from=backend-builder /ms-playwright /ms-playwright -ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright EXPOSE 10101 ENV ASPNETCORE_URLS=http://*:10101 ENV ASPNETCORE_ENVIRONMENT=Production diff --git a/app/src/App.vue b/app/src/App.vue index b2d021c..200fbf1 100644 --- a/app/src/App.vue +++ b/app/src/App.vue @@ -132,6 +132,83 @@ 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(顶部对齐的宽 modal) +.full-modal .ant-modal { + top: 0 !important; + padding-bottom: 0 !important; + margin-bottom: 0; +} +// full-modal 没有 top 留白,body max-height 减去的常量更小 +.full-modal .ant-modal-body { + max-height: calc(100vh - 110px) !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 { diff --git a/app/src/components/video/VideoPlayer.vue b/app/src/components/video/VideoPlayer.vue index f5f6bed..41a70fe 100644 --- a/app/src/components/video/VideoPlayer.vue +++ b/app/src/components/video/VideoPlayer.vue @@ -125,7 +125,7 @@ const loadVideoStream = async (videoId: string) => { // 构建视频流 URL,浏览器会自动发送 Range 请求 // 添加时间戳避免缓存问题 - const url = `/api/video/stream/${videoId}?t=${Date.now()}`; + const url = `/api/Video/play/${videoId}?t=${Date.now()}`; console.log('[VideoPlayer] 设置视频流 URL:', url); // 直接使用 URL,让浏览器处理 Range 请求 @@ -555,11 +555,12 @@ defineExpose({ .video-element { width: 100%; height: auto; - max-height: 70vh; + max-height: 45vh; display: block; } } + .loading-overlay { position: absolute; top: 0; @@ -578,3 +579,15 @@ defineExpose({ } + + diff --git a/app/src/pages/cok/CookieTable.vue b/app/src/pages/cok/CookieTable.vue index 6f04a75..3d588a7 100644 --- a/app/src/pages/cok/CookieTable.vue +++ b/app/src/pages/cok/CookieTable.vue @@ -20,17 +20,12 @@ import { message } from 'ant-design-vue'; import { StarOutlined, StarFilled, StarTwoTone } from '@ant-design/icons-vue'; const columns = ref([]); columns.value = [ - { - title: 'Cookie名称', - dataIndex: 'userName', - width: 180, - }, - { title: 'Cookie状态', dataIndex: 'statusMsg' }, - { title: '收藏路径', dataIndex: 'savePath' }, - { title: '喜欢路径', dataIndex: 'favSavePath' }, - { title: '博主路径', dataIndex: 'upSavePath' }, - { title: '状态', dataIndex: 'status', width: 180 }, - { title: '操作', dataIndex: 'edit', width: 200 }, + { title: 'Cookie名称', dataIndex: 'userName', width: 140 }, + { title: '收藏路径', dataIndex: 'savePath', ellipsis: true, minWidth: 120 }, + { title: '喜欢路径', dataIndex: 'favSavePath', ellipsis: true, minWidth: 120 }, + { title: '博主路径', dataIndex: 'upSavePath', ellipsis: true, minWidth: 120 }, + { title: '状态', dataIndex: 'status', width: 160 }, + { title: '操作', dataIndex: 'edit', width: 220 }, ]; interface UpSecUserIdItem { @@ -276,7 +271,7 @@ const removeRow = (index: number) => { form.upSecUserIdsJson.splice(index, 1); } }; -const rowCount = 10; +const rowCount = 6; onMounted(() => { GetRecords(); @@ -472,10 +467,12 @@ const saveDrawerData = () => { const switchdownCollect = (e: any) => { if (!e) form.useCollectFolder = e; }; + \ No newline at end of file diff --git a/app/src/store/coreapi.ts b/app/src/store/coreapi.ts index c6d20fb..cdab716 100644 --- a/app/src/store/coreapi.ts +++ b/app/src/store/coreapi.ts @@ -136,7 +136,6 @@ export const useApiStore = defineStore('coreapi', () => { }); } - async function UpdateConfig(param: object) { return http.request>('/api/config/update', 'post_json', param).then(r => { return r; diff --git a/app/vite.config.ts b/app/vite.config.ts index a82a39a..6fdf1ee 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -34,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, diff --git a/dy.net.csproj b/dy.net.csproj index ab4ff19..537dc8a 100644 --- a/dy.net.csproj +++ b/dy.net.csproj @@ -76,7 +76,6 @@ - diff --git a/extension/ServiceExtension.cs b/extension/ServiceExtension.cs index bdc2eb3..28dab04 100644 --- a/extension/ServiceExtension.cs +++ b/extension/ServiceExtension.cs @@ -309,8 +309,6 @@ namespace dy.net.extension }); services.AddScoped(); - // 扫码登录服务(Playwright,全局单例避免重复初始化) - services.AddSingleton(); services.AddScoped(); } diff --git a/service/DyQrCodeService.cs b/service/DyQrCodeService.cs deleted file mode 100644 index 57480e8..0000000 --- a/service/DyQrCodeService.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Playwright; - -namespace dy.net.service -{ - public class DyQrCodeService - { - private static readonly ConcurrentDictionary _sessions = new(); - - private class QrSession - { - public IPlaywright Playwright { get; set; } - public IBrowser Browser { get; set; } - public IBrowserContext Context { get; set; } - public IPage Page { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.Now; - public string FinalCookies { get; set; } - public string SecUserId { get; set; } - } - - private const string LoginUrl = "https://creator.douyin.com/creator-micro/login"; - // QR 码图片的 class 名含 qrcode_img(后缀随版本变化,用属性选择器匹配) - private const string QrSelector = "img[class*='qrcode_img']"; - - public async Task<(string token, string qrBase64)> GetQrCodeAsync() - { - CleanExpiredSessions(); - - var playwright = await Playwright.CreateAsync(); - var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions - { - Headless = true, - Args = new[] { "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage" } - }); - var context = await browser.NewContextAsync(new BrowserNewContextOptions - { - UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" - }); - var page = await context.NewPageAsync(); - - await page.GotoAsync(LoginUrl, new PageGotoOptions { Timeout = 15000 }); - await page.WaitForSelectorAsync(QrSelector, new PageWaitForSelectorOptions { Timeout = 10000 }); - - // img.src 已经是 data:image/png;base64,... 直接取用 - var src = await page.EvaluateAsync($"() => document.querySelector(\"{QrSelector}\").src"); - var qrBase64 = src.Replace("data:image/png;base64,", ""); - - var token = Guid.NewGuid().ToString("N"); - _sessions[token] = new QrSession - { - Playwright = playwright, - Browser = browser, - Context = context, - Page = page - }; - - return (token, qrBase64); - } - - public async Task<(int status, string cookies, string secUserId)> CheckStatusAsync(string token) - { - if (!_sessions.TryGetValue(token, out var session)) - return (-1, null, null); - - if (DateTime.Now - session.CreatedAt > TimeSpan.FromMinutes(5)) - { - await CloseSessionAsync(token, session); - return (-1, null, null); - } - - // 已经登录过,直接返回缓存 - if (session.FinalCookies != null) - return (3, session.FinalCookies, session.SecUserId); - - var url = session.Page.Url; - - // 还在登录页 - if (url.Contains("/creator-micro/login") || url.Contains("sso.douyin.com")) - { - // 判断是否已扫码(页面出现"确认"或"已扫码"文字) - var scanned = await session.Page.EvaluateAsync( - "() => { const t = document.body.innerText; return t.includes('已扫码') || t.includes('确认登录'); }" - ); - return (scanned ? 2 : 1, null, null); - } - - // URL 已离开登录页,说明登录成功 - var browserCookies = await session.Context.CookiesAsync(new[] - { - "https://creator.douyin.com", - "https://www.douyin.com", - "https://sso.douyin.com" - }); - var cookieStr = string.Join("; ", browserCookies.Select(c => $"{c.Name}={c.Value}")); - - // 尝试从页面获取 sec_user_id - var secUserId = await GetSecUserIdAsync(session.Page); - - session.FinalCookies = cookieStr; - session.SecUserId = secUserId; - - // 登录完成后关闭浏览器释放资源 - _ = Task.Run(async () => await CloseSessionAsync(token, session)); - - return (3, cookieStr, secUserId); - } - - public (string cookies, string secUserId) GetSessionResult(string token) - { - if (_sessions.TryGetValue(token, out var session)) - return (session.FinalCookies ?? "", session.SecUserId ?? ""); - return ("", ""); - } - - public void RemoveSession(string token) - { - if (_sessions.TryRemove(token, out var session)) - _ = Task.Run(async () => await CloseSessionAsync(token, session)); - } - - private static async Task GetSecUserIdAsync(IPage page) - { - try - { - // 从页面 URL 或 API 提取 sec_user_id - var resp = await page.EvaluateAsync( - "async () => { const r = await fetch('/web/api/userinfo/'); const j = await r.json(); return j?.data?.sec_uid || j?.sec_uid || ''; }" - ); - return resp ?? ""; - } - catch - { - return ""; - } - } - - private static async Task CloseSessionAsync(string token, QrSession session) - { - _sessions.TryRemove(token, out _); - try { await session.Browser?.CloseAsync(); } catch { } - try { session.Playwright?.Dispose(); } catch { } - } - - private static void CleanExpiredSessions() - { - var expired = _sessions - .Where(kv => DateTime.Now - kv.Value.CreatedAt > TimeSpan.FromMinutes(6)) - .ToList(); - foreach (var kv in expired) - _ = Task.Run(async () => await CloseSessionAsync(kv.Key, kv.Value)); - } - } -}