feat: Modal 小屏滚动 + 数据看板视频播放 + Docker Alpine 化
- 数据看板作者视频播放接口从 /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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -25,11 +25,8 @@ namespace dy.net.Controllers
|
|||||||
private readonly DouyinFollowService douyinFollowService;
|
private readonly DouyinFollowService douyinFollowService;
|
||||||
private readonly DouyinCookieService douyinCookieService;
|
private readonly DouyinCookieService douyinCookieService;
|
||||||
private readonly DouyinHttpClientService httpClientService;
|
private readonly DouyinHttpClientService httpClientService;
|
||||||
private readonly DyQrCodeService _qrCodeService;
|
|
||||||
|
|
||||||
|
public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService)
|
||||||
|
|
||||||
public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService, DyQrCodeService qrCodeService)
|
|
||||||
{
|
{
|
||||||
this.dyCookieService = dyCookieService;
|
this.dyCookieService = dyCookieService;
|
||||||
this.commonService = commonService;
|
this.commonService = commonService;
|
||||||
@@ -37,7 +34,6 @@ namespace dy.net.Controllers
|
|||||||
this.douyinFollowService = douyinFollowService;
|
this.douyinFollowService = douyinFollowService;
|
||||||
this.douyinCookieService = douyinCookieService;
|
this.douyinCookieService = douyinCookieService;
|
||||||
this.httpClientService = httpClientService;
|
this.httpClientService = httpClientService;
|
||||||
_qrCodeService = qrCodeService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -487,64 +483,5 @@ namespace dy.net.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取抖音扫码登录二维码
|
|
||||||
/// </summary>
|
|
||||||
[AllowAnonymous]
|
|
||||||
[HttpGet("qrcode")]
|
|
||||||
public async Task<IActionResult> 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 轮询扫码状态(前端每2秒调一次)
|
|
||||||
/// status: 1=待扫码, 2=已扫码待确认, 3=登录成功, -1=已过期
|
|
||||||
/// </summary>
|
|
||||||
[AllowAnonymous]
|
|
||||||
[HttpGet("qrstatus")]
|
|
||||||
public async Task<IActionResult> 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 扫码成功后,将 Cookie 绑定到指定账号
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost("qrbind")]
|
|
||||||
public async Task<IActionResult> 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-42
@@ -10,7 +10,7 @@ RUN npm config set registry https://registry.npmmirror.com && \
|
|||||||
npm install -g pnpm && \
|
npm install -g pnpm && \
|
||||||
pnpm config set registry https://registry.npmmirror.com && \
|
pnpm config set registry https://registry.npmmirror.com && \
|
||||||
if [ -f pnpm-lock.yaml ]; then \
|
if [ -f pnpm-lock.yaml ]; then \
|
||||||
pnpm install --frozen-lockfile; \
|
pnpm install --no-frozen-lockfile; \
|
||||||
else \
|
else \
|
||||||
npm install; \
|
npm install; \
|
||||||
fi
|
fi
|
||||||
@@ -18,17 +18,11 @@ RUN npm config set registry https://registry.npmmirror.com && \
|
|||||||
COPY app/ ./
|
COPY app/ ./
|
||||||
RUN pnpm build || npm run build
|
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
|
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 ./
|
COPY *.csproj ./
|
||||||
RUN dotnet restore dy.net.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
|
RUN dotnet publish dy.net.csproj -c Release -o /app/publish
|
||||||
|
|
||||||
# 用 npx 下载 Playwright Chromium 到固定路径
|
# 第三阶段:运行镜像(Alpine 版:apk 包更小、代理下更易通过)
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine AS final
|
||||||
RUN npx -y playwright@1.44.0 install chromium
|
|
||||||
|
|
||||||
# 第三阶段:运行镜像
|
|
||||||
FROM mcr.azurecr.cn/dotnet/aspnet:6.0 AS final
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
RUN apk add --no-cache tzdata ffmpeg && \
|
||||||
|
cp /usr/share/zoneinfo/$TZ /etc/localtime && \
|
||||||
# 切换阿里云镜像源,解决国内网络无法访问 deb.debian.org 的问题
|
echo $TZ > /etc/timezone
|
||||||
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/*
|
|
||||||
|
|
||||||
COPY --from=backend-builder /app/publish .
|
COPY --from=backend-builder /app/publish .
|
||||||
COPY --from=backend-builder /ms-playwright /ms-playwright
|
|
||||||
|
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
|
||||||
EXPOSE 10101
|
EXPOSE 10101
|
||||||
ENV ASPNETCORE_URLS=http://*:10101
|
ENV ASPNETCORE_URLS=http://*:10101
|
||||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
|||||||
@@ -132,6 +132,83 @@ body {
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow-y: hidden;
|
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 {
|
.stepin-img-checkbox {
|
||||||
@apply transition-transform;
|
@apply transition-transform;
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ const loadVideoStream = async (videoId: string) => {
|
|||||||
|
|
||||||
// 构建视频流 URL,浏览器会自动发送 Range 请求
|
// 构建视频流 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);
|
console.log('[VideoPlayer] 设置视频流 URL:', url);
|
||||||
|
|
||||||
// 直接使用 URL,让浏览器处理 Range 请求
|
// 直接使用 URL,让浏览器处理 Range 请求
|
||||||
@@ -555,11 +555,12 @@ defineExpose({
|
|||||||
.video-element {
|
.video-element {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 70vh;
|
max-height: 45vh;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.loading-overlay {
|
.loading-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -578,3 +579,15 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
</style>
|
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -20,17 +20,12 @@ import { message } from 'ant-design-vue';
|
|||||||
import { StarOutlined, StarFilled, StarTwoTone } from '@ant-design/icons-vue';
|
import { StarOutlined, StarFilled, StarTwoTone } from '@ant-design/icons-vue';
|
||||||
const columns = ref([]);
|
const columns = ref([]);
|
||||||
columns.value = [
|
columns.value = [
|
||||||
{
|
{ title: 'Cookie名称', dataIndex: 'userName', width: 140 },
|
||||||
title: 'Cookie名称',
|
{ title: '收藏路径', dataIndex: 'savePath', ellipsis: true, minWidth: 120 },
|
||||||
dataIndex: 'userName',
|
{ title: '喜欢路径', dataIndex: 'favSavePath', ellipsis: true, minWidth: 120 },
|
||||||
width: 180,
|
{ title: '博主路径', dataIndex: 'upSavePath', ellipsis: true, minWidth: 120 },
|
||||||
},
|
{ title: '状态', dataIndex: 'status', width: 160 },
|
||||||
{ title: 'Cookie状态', dataIndex: 'statusMsg' },
|
{ title: '操作', dataIndex: 'edit', width: 220 },
|
||||||
{ title: '收藏路径', dataIndex: 'savePath' },
|
|
||||||
{ title: '喜欢路径', dataIndex: 'favSavePath' },
|
|
||||||
{ title: '博主路径', dataIndex: 'upSavePath' },
|
|
||||||
{ title: '状态', dataIndex: 'status', width: 180 },
|
|
||||||
{ title: '操作', dataIndex: 'edit', width: 200 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface UpSecUserIdItem {
|
interface UpSecUserIdItem {
|
||||||
@@ -276,7 +271,7 @@ const removeRow = (index: number) => {
|
|||||||
form.upSecUserIdsJson.splice(index, 1);
|
form.upSecUserIdsJson.splice(index, 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const rowCount = 10;
|
const rowCount = 6;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
GetRecords();
|
GetRecords();
|
||||||
@@ -472,10 +467,12 @@ const saveDrawerData = () => {
|
|||||||
const switchdownCollect = (e: any) => {
|
const switchdownCollect = (e: any) => {
|
||||||
if (!e) form.useCollectFolder = e;
|
if (!e) form.useCollectFolder = e;
|
||||||
};
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a-modal :title="form._isNew ? '新增' : '编辑'" v-model:visible="showModal" @ok="submit" @cancel="cancel" width="100%" wrap-class-name="full-modal">
|
<a-modal :title="form._isNew ? '新增' : '编辑'" v-model:visible="showModal" @ok="submit" @cancel="cancel" width="100%" wrap-class-name="full-modal">
|
||||||
|
<div class="modal-scroll-body">
|
||||||
<a-form ref="formModel" :model="form" :labelCol="{ span: 3 }" :wrapperCol="{ span: 20 }">
|
<a-form ref="formModel" :model="form" :labelCol="{ span: 3 }" :wrapperCol="{ span: 20 }">
|
||||||
<a-form-item label="Cookie名称" required name="userName">
|
<a-form-item label="Cookie名称" required name="userName">
|
||||||
<a-input v-model:value="form.userName" />
|
<a-input v-model:value="form.userName" />
|
||||||
@@ -600,6 +597,7 @@ const switchdownCollect = (e: any) => {
|
|||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
|
</div>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<a-drawer :title="getDrawerTypeName()+'配置'" v-model:visible="showDrawer" placement="right" width="800px" :z-index="10010" :mask-z-index="10009" @close="closeDrawer" class="common-drawer">
|
<a-drawer :title="getDrawerTypeName()+'配置'" v-model:visible="showDrawer" placement="right" width="800px" :z-index="10010" :mask-z-index="10009" @close="closeDrawer" class="common-drawer">
|
||||||
@@ -654,7 +652,7 @@ const switchdownCollect = (e: any) => {
|
|||||||
</div>
|
</div>
|
||||||
</a-drawer>
|
</a-drawer>
|
||||||
|
|
||||||
<a-table v-bind="$attrs" :columns="columns" :dataSource="dataSource" :pagination="false">
|
<a-table v-bind="$attrs" :columns="columns" :dataSource="dataSource" :pagination="false" :scroll="{ x: 900 }">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex justify-end pr-4">
|
<div class="flex justify-end pr-4">
|
||||||
<a-button type="primary" @click="GetRecords()" :loading="formLoading" class="mr-2">
|
<a-button type="primary" @click="GetRecords()" :loading="formLoading" class="mr-2">
|
||||||
@@ -704,9 +702,31 @@ const switchdownCollect = (e: any) => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
|
.modal-scroll-body {
|
||||||
|
max-height: calc(100vh - 160px);
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
.modal-scroll-body::-webkit-scrollbar {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
.modal-scroll-body::-webkit-scrollbar-track {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.modal-scroll-body::-webkit-scrollbar-thumb {
|
||||||
|
background: #bbb;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.modal-scroll-body::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #888;
|
||||||
|
}
|
||||||
.ant-form-item {
|
.ant-form-item {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
@@ -894,14 +914,6 @@ html.dark-mode .drawer-card-container.grid-container .drawer-card-grid .ant-inpu
|
|||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.ant-modal-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: calc(100vh);
|
|
||||||
}
|
|
||||||
.ant-modal-body {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-alert {
|
.ant-alert {
|
||||||
|
|||||||
@@ -124,7 +124,12 @@
|
|||||||
<transition name="stats-fade" mode="out-in">
|
<transition name="stats-fade" mode="out-in">
|
||||||
<div v-if="currentTab === 'author'" key="author-view" class="stats-content">
|
<div v-if="currentTab === 'author'" key="author-view" class="stats-content">
|
||||||
<div class="authors-grid">
|
<div class="authors-grid">
|
||||||
<div class="author-card" v-for="(author, index) in authors" :key="index" @dblclick="handleDeleteItem(author)">
|
<div class="author-card" v-for="(author, index) in authors" :key="index"
|
||||||
|
@click="handleAuthorClick(author)"
|
||||||
|
@dblclick="handleAuthorDblClick(author)">
|
||||||
|
<div class="author-delete-btn" @click.stop="handleAuthorDeleteBtnClick(author)">
|
||||||
|
<span style="font-size:12px;">删除</span>
|
||||||
|
</div>
|
||||||
<div class="author-info-row">
|
<div class="author-info-row">
|
||||||
<div class="author-avatar">
|
<div class="author-avatar">
|
||||||
<img :src="author.icon" alt="作者头像" />
|
<img :src="author.icon" alt="作者头像" />
|
||||||
@@ -168,6 +173,48 @@
|
|||||||
<div class="full-chart-container" ref="fullChartRef" style="width: 100%; height: 400px; min-height: 400px; background: #fff;"></div>
|
<div class="full-chart-container" ref="fullChartRef" style="width: 100%; height: 400px; min-height: 400px; background: #fff;"></div>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- 作者视频预览弹窗 -->
|
||||||
|
<a-modal v-model:visible="authorVideoModalVisible"
|
||||||
|
:title="`${selectedAuthor?.name || ''} 的视频列表`"
|
||||||
|
:width="1200" :footer="null"
|
||||||
|
@cancel="handleCloseAuthorModal"
|
||||||
|
class="author-video-modal">
|
||||||
|
<div class="author-video-content">
|
||||||
|
<div v-if="authorVideoLoading" class="video-list-loading">
|
||||||
|
<a-spin size="large" tip="加载中..." />
|
||||||
|
</div>
|
||||||
|
<div v-else class="video-list-grid">
|
||||||
|
<div v-for="video in authorVideoList" :key="video.id" class="video-card" @click="handlePreviewVideo(video)">
|
||||||
|
<div class="video-cover-wrapper">
|
||||||
|
<img v-if="coverImageCache[video.id]" :src="coverImageCache[video.id]" :alt="video.videoTitle"
|
||||||
|
class="video-cover" @error="handleCoverError" loading="lazy" />
|
||||||
|
<div v-else class="video-cover-placeholder">
|
||||||
|
<PlayCircleOutlined class="placeholder-icon" />
|
||||||
|
<span class="placeholder-text">{{ coverImageLoading.has(video.id) ? '加载中...' : '暂无封面' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="video-play-overlay"><PlayCircleOutlined class="play-icon" /></div>
|
||||||
|
<div v-if="video.duration" class="video-duration">{{ formatDuration(video.duration) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-info">
|
||||||
|
<div class="video-title" :title="video.videoTitle">{{ video.videoTitle || '未命名视频' }}</div>
|
||||||
|
<div class="video-meta">
|
||||||
|
<span class="video-category">{{ video.viedoCate || '未分类' }}</span>
|
||||||
|
<span class="video-time">{{ video.syncTimeStr }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-list-pagination">
|
||||||
|
<a-pagination v-model:current="authorVideoPagination.current" v-model:page-size="authorVideoPagination.pageSize"
|
||||||
|
:total="authorVideoPagination.total" :show-total="authorVideoPagination.showTotal"
|
||||||
|
:show-size-changer="true" :page-size-options="['10', '20', '50', '100']"
|
||||||
|
@change="handleAuthorVideoTableChange" @showSizeChange="handleAuthorVideoTableChange" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<VideoPlayer ref="videoPlayerRef" />
|
||||||
|
|
||||||
<svg style="display: none;">
|
<svg style="display: none;">
|
||||||
<symbol id="cup" viewBox="0 0 12 12">
|
<symbol id="cup" viewBox="0 0 12 12">
|
||||||
<path d="M18 4h2v16h-2zM4 4h14v2H4zM4 8h10v2H4zM4 12h10v2H4zM4 16h6v2H4zM4 20h6v2H4z" />
|
<path d="M18 4h2v16h-2zM4 4h14v2H4zM4 8h10v2H4zM4 12h10v2H4zM4 16h6v2H4zM4 20h6v2H4z" />
|
||||||
@@ -177,11 +224,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
import { ref, onMounted, onUnmounted, watch, nextTick, reactive } from 'vue';
|
||||||
import { useApiStore } from '@/store';
|
import { useApiStore } from '@/store';
|
||||||
import { message, Modal } from 'ant-design-vue';
|
import { message, Modal } from 'ant-design-vue';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import { FullscreenOutlined } from '@ant-design/icons-vue'; // 确保导入图标
|
import { FullscreenOutlined, PlayCircleOutlined } from '@ant-design/icons-vue';
|
||||||
|
import VideoPlayer from '@/components/video/VideoPlayer.vue';
|
||||||
|
import Cookie from 'js-cookie';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
// 类型接口不变
|
// 类型接口不变
|
||||||
interface Author {
|
interface Author {
|
||||||
@@ -242,6 +292,17 @@ const showFullChartModal = ref<boolean>(false); // 控制模态窗口显示
|
|||||||
const fullChartRef = ref<HTMLDivElement | null>(null); // 全屏图表容器
|
const fullChartRef = ref<HTMLDivElement | null>(null); // 全屏图表容器
|
||||||
let fullChartInstance: echarts.ECharts | null = null; // 全屏图表实例
|
let fullChartInstance: echarts.ECharts | null = null; // 全屏图表实例
|
||||||
|
|
||||||
|
// 作者视频预览相关
|
||||||
|
const authorVideoModalVisible = ref<boolean>(false);
|
||||||
|
const selectedAuthor = ref<Author | null>(null);
|
||||||
|
const authorVideoList = ref<any[]>([]);
|
||||||
|
const authorVideoLoading = ref<boolean>(false);
|
||||||
|
const authorVideoPagination = ref({ current: 1, pageSize: 10, total: 0, showTotal: () => `共 ${0} 条` });
|
||||||
|
const coverImageCache = reactive<Record<string, string>>({});
|
||||||
|
const coverImageLoading = ref<Set<string>>(new Set());
|
||||||
|
const videoPlayerRef = ref<InstanceType<typeof VideoPlayer> | null>(null);
|
||||||
|
let clickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'StatsDashboard',
|
name: 'StatsDashboard',
|
||||||
});
|
});
|
||||||
@@ -589,6 +650,133 @@ const loadDashboardData = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 单击打开预览(防抖区分单击/双击)
|
||||||
|
const handleAuthorDblClick = (author: Author) => {
|
||||||
|
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
|
||||||
|
handleDeleteItem(author);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthorDeleteBtnClick = (author: Author) => {
|
||||||
|
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
|
||||||
|
handleDeleteItem(author);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthorClick = (author: Author) => {
|
||||||
|
if (clickTimer) clearTimeout(clickTimer);
|
||||||
|
clickTimer = setTimeout(() => {
|
||||||
|
Object.keys(coverImageCache).forEach((id) => {
|
||||||
|
const url = coverImageCache[id];
|
||||||
|
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||||
|
delete coverImageCache[id];
|
||||||
|
});
|
||||||
|
coverImageLoading.value.clear();
|
||||||
|
authorVideoList.value = [];
|
||||||
|
selectedAuthor.value = author;
|
||||||
|
authorVideoModalVisible.value = true;
|
||||||
|
authorVideoPagination.value.current = 1;
|
||||||
|
loadAuthorVideos();
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载作者视频列表
|
||||||
|
const loadAuthorVideos = async () => {
|
||||||
|
if (!selectedAuthor.value) return;
|
||||||
|
authorVideoLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await useApiStore().VideoPageList({
|
||||||
|
pageIndex: authorVideoPagination.value.current,
|
||||||
|
pageSize: authorVideoPagination.value.pageSize,
|
||||||
|
author: selectedAuthor.value.name,
|
||||||
|
tag: '', viedoType: '*', dates: [],
|
||||||
|
});
|
||||||
|
if (res?.code === 0 && res.data) {
|
||||||
|
authorVideoList.value = res.data.data || [];
|
||||||
|
authorVideoPagination.value.total = res.data.total || 0;
|
||||||
|
authorVideoPagination.value.showTotal = () => `共 ${res.data.total || 0} 条`;
|
||||||
|
setTimeout(() => {
|
||||||
|
(res.data.data || []).forEach((v: any) => v.id && preloadCoverImage(v.id));
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载作者视频失败:', err);
|
||||||
|
} finally {
|
||||||
|
authorVideoLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthorVideoTableChange = (current?: number, pageSize?: number) => {
|
||||||
|
if (current !== undefined) authorVideoPagination.value.current = current;
|
||||||
|
if (pageSize !== undefined) authorVideoPagination.value.pageSize = pageSize;
|
||||||
|
loadAuthorVideos();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseAuthorModal = () => {
|
||||||
|
authorVideoModalVisible.value = false;
|
||||||
|
selectedAuthor.value = null;
|
||||||
|
authorVideoList.value = [];
|
||||||
|
setTimeout(() => {
|
||||||
|
Object.keys(coverImageCache).forEach((id) => {
|
||||||
|
const url = coverImageCache[id];
|
||||||
|
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||||
|
delete coverImageCache[id];
|
||||||
|
});
|
||||||
|
coverImageLoading.value.clear();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 预览视频
|
||||||
|
const handlePreviewVideo = (record: any) => {
|
||||||
|
if (!record.id || !videoPlayerRef.value) return;
|
||||||
|
videoPlayerRef.value.open(record.id, {
|
||||||
|
id: record.id, videoTitle: record.videoTitle || '未知标题',
|
||||||
|
author: record.author || '', viedoCate: record.viedoCate || '',
|
||||||
|
resolution: record.resolution || '', fileSize: record.fileSize || 0,
|
||||||
|
videoSavePath: record.videoSavePath,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 预加载封面图片
|
||||||
|
const preloadCoverImage = async (videoId: string) => {
|
||||||
|
if (!videoId || coverImageCache[videoId] || coverImageLoading.value.has(videoId)) return;
|
||||||
|
coverImageLoading.value.add(videoId);
|
||||||
|
try {
|
||||||
|
const token = Cookie.get('Authorization') || '';
|
||||||
|
const response = await axios.get(`/api/video/cover/${videoId}?t=${Date.now()}`, {
|
||||||
|
responseType: 'blob', withCredentials: true,
|
||||||
|
headers: token ? { Authorization: token } : {},
|
||||||
|
});
|
||||||
|
if (response.status === 200 && response.data) {
|
||||||
|
coverImageCache[videoId] = URL.createObjectURL(response.data as Blob);
|
||||||
|
} else {
|
||||||
|
coverImageCache[videoId] = getDefaultCoverUrl();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
coverImageCache[videoId] = getDefaultCoverUrl();
|
||||||
|
} finally {
|
||||||
|
coverImageLoading.value.delete(videoId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDefaultCoverUrl = (): string =>
|
||||||
|
'data:image/svg+xml;base64,' + btoa(`<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" font-size="16" fill="#999" text-anchor="middle" dy=".3em">暂无封面</text></svg>`);
|
||||||
|
|
||||||
|
const handleCoverError = (e: Event) => {
|
||||||
|
const img = e.target as HTMLImageElement;
|
||||||
|
img.src = getDefaultCoverUrl();
|
||||||
|
img.onerror = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
if (!seconds) return '';
|
||||||
|
return `${Math.floor(seconds / 60)}:${Math.floor(seconds % 60).toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(authorVideoList, (newList) => {
|
||||||
|
if (authorVideoModalVisible.value && newList?.length > 0) {
|
||||||
|
setTimeout(() => newList.forEach((v: any) => v.id && preloadCoverImage(v.id)), 100);
|
||||||
|
}
|
||||||
|
}, { immediate: false });
|
||||||
|
|
||||||
const handleDeleteItem = (item: Author) => {
|
const handleDeleteItem = (item: Author) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
@@ -1104,4 +1292,72 @@ html.dark-mode .fullscreen-btn:hover {
|
|||||||
color: #42a5f5;
|
color: #42a5f5;
|
||||||
border-color: #42a5f5;
|
border-color: #42a5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 作者卡片删除按钮(悬浮显示) */
|
||||||
|
.author-card {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.author-delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 77, 79, 0.9);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: scale(0.8);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.author-card:hover .author-delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视频预览弹窗 */
|
||||||
|
.author-video-modal :deep(.ant-modal-body) { padding: 20px; }
|
||||||
|
.author-video-content { min-height: 400px; }
|
||||||
|
.video-list-loading { display: flex; justify-content: center; align-items: center; min-height: 400px; }
|
||||||
|
.video-list-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.video-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.1);
|
||||||
|
transition: all .3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.video-card:hover { transform: translateY(-4px); box-shadow: 0 4px 16px rgba(0,0,0,.15); }
|
||||||
|
.video-card:hover .video-play-overlay { opacity: 1; }
|
||||||
|
.video-cover-wrapper { position: relative; width: 100%; padding-top: 56.25%; background: #f5f5f5; overflow: hidden; }
|
||||||
|
.video-cover { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.video-cover-placeholder { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #f5f5f5; color: #999; }
|
||||||
|
.placeholder-icon { font-size: 32px; margin-bottom: 8px; opacity: .5; }
|
||||||
|
.placeholder-text { font-size: 12px; }
|
||||||
|
.video-play-overlay { position: absolute; inset: 0; background: rgba(0,0,0,.4); display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .3s ease; }
|
||||||
|
.play-icon { font-size: 48px; color: #fff; }
|
||||||
|
.video-duration { position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,.7); color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
|
||||||
|
.video-info { padding: 12px; flex: 1; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.video-title { font-size: 14px; font-weight: 500; color: #333; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: 40px; }
|
||||||
|
.video-meta { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #999; }
|
||||||
|
.video-category { background: #f0f0f0; padding: 2px 8px; border-radius: 4px; color: #666; }
|
||||||
|
.video-list-pagination { display: flex; justify-content: center; margin-top: 20px; padding-top: 20px; border-top: 1px solid #f0f0f0; }
|
||||||
|
html.dark-mode .video-card { background: rgba(40,40,65,.8); }
|
||||||
|
html.dark-mode .video-title { color: #fff; }
|
||||||
|
html.dark-mode .video-category { background: rgba(255,255,255,.1); color: #ccc; }
|
||||||
</style>
|
</style>
|
||||||
@@ -136,7 +136,6 @@ export const useApiStore = defineStore('coreapi', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function UpdateConfig(param: object) {
|
async function UpdateConfig(param: object) {
|
||||||
return http.request<any, Response<any>>('/api/config/update', 'post_json', param).then(r => {
|
return http.request<any, Response<any>>('/api/config/update', 'post_json', param).then(r => {
|
||||||
return r;
|
return r;
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export default ({ command, mode }) => {
|
|||||||
cors: true,
|
cors: true,
|
||||||
port: 8001,
|
port: 8001,
|
||||||
open: false, //自动打开
|
open: false, //自动打开
|
||||||
|
watch: {
|
||||||
|
usePolling: true, // 绕过 fsevents 架构不兼容问题(arm64 Mac)
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: env.VITE_API_URL,
|
target: env.VITE_API_URL,
|
||||||
|
|||||||
@@ -76,7 +76,6 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||||
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
||||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||||
<PackageReference Include="Microsoft.Playwright" Version="1.44.0" />
|
|
||||||
<!--<PackageReference Include="BouncyCastle.NetCore" Version="2.2.1" />-->
|
<!--<PackageReference Include="BouncyCastle.NetCore" Version="2.2.1" />-->
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -309,8 +309,6 @@ namespace dy.net.extension
|
|||||||
});
|
});
|
||||||
|
|
||||||
services.AddScoped<DouyinQuartzJobService>();
|
services.AddScoped<DouyinQuartzJobService>();
|
||||||
// 扫码登录服务(Playwright,全局单例避免重复初始化)
|
|
||||||
services.AddSingleton<DyQrCodeService>();
|
|
||||||
services.AddScoped<DownloadConfigService>();
|
services.AddScoped<DownloadConfigService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace dy.net.service
|
|
||||||
{
|
|
||||||
public class DyQrCodeService
|
|
||||||
{
|
|
||||||
private static readonly ConcurrentDictionary<string, QrSession> _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<string>($"() => 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<bool>(
|
|
||||||
"() => { 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<string> GetSecUserIdAsync(IPage page)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 从页面 URL 或 API 提取 sec_user_id
|
|
||||||
var resp = await page.EvaluateAsync<string>(
|
|
||||||
"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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user