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:
yuming
2026-07-03 18:03:57 +08:00
parent 30060fe29c
commit e6c5587637
12 changed files with 411 additions and 289 deletions
+33 -21
View File
@@ -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;
};
</script>
<template>
<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-item label="Cookie名称" required name="userName">
<a-input v-model:value="form.userName" />
@@ -600,6 +597,7 @@ const switchdownCollect = (e: any) => {
</div>
</a-form-item>
</a-form>
</div>
</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">
@@ -654,7 +652,7 @@ const switchdownCollect = (e: any) => {
</div>
</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>
<div class="flex justify-end pr-4">
<a-button type="primary" @click="GetRecords()" :loading="formLoading" class="mr-2">
@@ -704,9 +702,31 @@ const switchdownCollect = (e: any) => {
</div>
</template>
</a-table>
</template>
<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 {
margin-bottom: 10px;
}
@@ -894,14 +914,6 @@ html.dark-mode .drawer-card-container.grid-container .drawer-card-grid .ant-inpu
padding-bottom: 0;
margin: 0;
}
.ant-modal-content {
display: flex;
flex-direction: column;
height: calc(100vh);
}
.ant-modal-body {
flex: 1;
}
}
.ant-alert {
+259 -3
View File
@@ -124,7 +124,12 @@
<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" @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-avatar">
<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>
</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;">
<symbol id="cup" viewBox="0 0 12 12">
<path d="M18 4h2v16h-2zM4 4h14v2H4zM4 8h10v2H4zM4 12h10v2H4zM4 16h6v2H4zM4 20h6v2H4z" />
@@ -177,11 +224,14 @@
</template>
<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 { message, Modal } from 'ant-design-vue';
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 {
@@ -242,6 +292,17 @@ const showFullChartModal = ref<boolean>(false); // 控制模态窗口显示
const fullChartRef = ref<HTMLDivElement | 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({
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) => {
Modal.confirm({
title: '确认删除',
@@ -1104,4 +1292,72 @@ html.dark-mode .fullscreen-btn:hover {
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>