feat: 将本地独有功能移植到 upstream 最新版本
基于 upstream/master (c5922f9) 新建分支,移植以下功能:
1. 扫码登录(DyQrCodeService + ConfigController 3个接口)
2. 视频预览组件(VideoPlayer.vue)
3. 下载配置服务(DownloadConfigService + AppConfig 6个扩展字段)
4. Gitea CI/CD(.gitea/workflows/deploy.yml)
5. 生产 Dockerfile(Dockerfile.prod,含中国镜像源配置)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
:width="900"
|
||||
:footer="null"
|
||||
:maskClosable="false"
|
||||
@cancel="handleClose"
|
||||
class="video-player-modal"
|
||||
:destroyOnClose="true"
|
||||
>
|
||||
<template #title>
|
||||
<div class="modal-title-wrapper">
|
||||
<span class="modal-title-text">{{ modalTitle }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="video-player-container" v-if="currentVideoId">
|
||||
<!-- 视频信息 -->
|
||||
<div class="video-info" v-if="currentVideoInfo">
|
||||
<div class="info-row">
|
||||
<span class="label">作者:</span>
|
||||
<span class="value">{{ currentVideoInfo.author || '未知' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">分类:</span>
|
||||
<span class="value">{{ currentVideoInfo.viedoCate || '未分类' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">分辨率:</span>
|
||||
<span class="value">{{ currentVideoInfo.resolution || '未知' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">文件大小:</span>
|
||||
<span class="value">{{ formatFileSize(currentVideoInfo.fileSize || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频播放器 -->
|
||||
<div class="video-wrapper" v-if="currentVideoId">
|
||||
<video
|
||||
ref="videoPlayer"
|
||||
:src="videoUrl"
|
||||
controls
|
||||
preload="auto"
|
||||
class="video-element"
|
||||
@error="handleVideoError"
|
||||
@loadstart="handleLoadStart"
|
||||
@loadedmetadata="handleLoadedMetadata"
|
||||
@canplay="handleCanPlay"
|
||||
@canplaythrough="handleCanPlayThrough"
|
||||
@progress="handleProgress"
|
||||
@waiting="handleWaiting"
|
||||
@playing="handlePlaying"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
<div v-else class="video-wrapper">
|
||||
<a-empty description="视频信息加载中..." />
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<a-spin size="large" tip="加载视频中..." />
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="error-message">
|
||||
<a-alert
|
||||
:message="error"
|
||||
type="error"
|
||||
show-icon
|
||||
closable
|
||||
@close="error = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import Cookie from 'js-cookie';
|
||||
|
||||
interface VideoInfo {
|
||||
id: string;
|
||||
videoTitle: string;
|
||||
author: string;
|
||||
viedoCate: string;
|
||||
resolution: string;
|
||||
fileSize: number;
|
||||
videoSavePath?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
videoId?: string;
|
||||
videoInfo?: VideoInfo;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const videoPlayer = ref<HTMLVideoElement | null>(null);
|
||||
const currentVideoId = ref<string>('');
|
||||
const currentVideoInfo = ref<VideoInfo | null>(null);
|
||||
const videoDirectUrl = ref<string>('');
|
||||
|
||||
// 视频URL - 直接使用流式 URL,让浏览器处理 Range 请求
|
||||
const videoUrl = computed(() => videoDirectUrl.value);
|
||||
|
||||
// 标题(完整显示,支持多行)
|
||||
const modalTitle = computed(() => {
|
||||
return currentVideoInfo.value?.videoTitle || '视频预览';
|
||||
});
|
||||
|
||||
// 设置视频流 URL,让浏览器直接处理 Range 请求实现流式播放
|
||||
const loadVideoStream = async (videoId: string) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
// 构建视频流 URL,浏览器会自动发送 Range 请求
|
||||
// 添加时间戳避免缓存问题
|
||||
const url = `/api/video/stream/${videoId}?t=${Date.now()}`;
|
||||
console.log('[VideoPlayer] 设置视频流 URL:', url);
|
||||
|
||||
// 直接使用 URL,让浏览器处理 Range 请求
|
||||
// 浏览器会自动发送 Range 请求头,实现边加载边播放
|
||||
videoDirectUrl.value = url;
|
||||
|
||||
// 确保 video 元素存在,并且等待 DOM 更新
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
if (videoPlayer.value) {
|
||||
// 先清空 src,避免使用旧的 URL
|
||||
videoPlayer.value.src = '';
|
||||
|
||||
// 等待一下,确保清空操作完成
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// 设置新的视频 URL
|
||||
videoPlayer.value.src = videoDirectUrl.value;
|
||||
console.log('[VideoPlayer] 视频 src 已设置:', videoDirectUrl.value);
|
||||
|
||||
// 加载视频元数据(不等待完整下载)
|
||||
// 浏览器会自动发送 Range 请求,只加载必要的部分
|
||||
videoPlayer.value.load();
|
||||
console.log('[VideoPlayer] 调用 video.load(),开始加载视频');
|
||||
|
||||
// 视频元素会自动处理 Range 请求,实现流式播放
|
||||
// 当有足够的数据可以播放时(通常只需要几秒的数据),浏览器就可以开始播放
|
||||
// 不需要等待整个文件下载完成
|
||||
|
||||
// 监听网络状态变化,用于调试
|
||||
const checkNetworkState = () => {
|
||||
const networkState = videoPlayer.value?.networkState;
|
||||
const readyState = videoPlayer.value?.readyState;
|
||||
const buffered = videoPlayer.value?.buffered;
|
||||
|
||||
console.log('[VideoPlayer] 网络状态:', {
|
||||
networkState,
|
||||
readyState,
|
||||
bufferedLength: buffered?.length || 0,
|
||||
bufferedRanges: buffered?.length ? Array.from({ length: buffered.length }, (_, i) => ({
|
||||
start: buffered.start(i),
|
||||
end: buffered.end(i)
|
||||
})) : []
|
||||
});
|
||||
};
|
||||
|
||||
// 定期检查网络状态(用于调试)
|
||||
const networkCheckInterval = setInterval(() => {
|
||||
if (videoPlayer.value && loading.value) {
|
||||
checkNetworkState();
|
||||
} else {
|
||||
clearInterval(networkCheckInterval);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// 5秒后停止检查
|
||||
setTimeout(() => {
|
||||
clearInterval(networkCheckInterval);
|
||||
}, 5000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[VideoPlayer] 设置视频流 URL 失败:', err);
|
||||
loading.value = false;
|
||||
|
||||
// 根据错误类型设置错误消息
|
||||
if (err.message) {
|
||||
error.value = err.message;
|
||||
} else {
|
||||
error.value = '视频加载失败,请稍后重试';
|
||||
}
|
||||
|
||||
// 清空 URL
|
||||
videoDirectUrl.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// 打开播放器
|
||||
const open = async (id?: string, info?: VideoInfo) => {
|
||||
console.log('VideoPlayer.open called', { id, info });
|
||||
if (!id && !info?.id) {
|
||||
message.error('视频ID不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
const videoId = id || info!.id;
|
||||
const videoInfo = info || {
|
||||
id: videoId,
|
||||
videoTitle: '视频预览',
|
||||
author: '未知',
|
||||
viedoCate: '未分类',
|
||||
resolution: '未知',
|
||||
fileSize: 0,
|
||||
};
|
||||
|
||||
console.log('设置视频信息', { videoId, videoInfo });
|
||||
|
||||
// 先清空之前的状态
|
||||
if (videoPlayer.value) {
|
||||
videoPlayer.value.pause();
|
||||
videoPlayer.value.src = '';
|
||||
}
|
||||
videoDirectUrl.value = '';
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
|
||||
// 设置视频信息
|
||||
currentVideoId.value = videoId;
|
||||
currentVideoInfo.value = videoInfo;
|
||||
|
||||
// 先显示模态框
|
||||
visible.value = true;
|
||||
|
||||
// 等待 DOM 渲染完成后再加载视频流
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 加载视频流
|
||||
await loadVideoStream(videoId);
|
||||
};
|
||||
|
||||
// 关闭播放器
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
if (videoPlayer.value) {
|
||||
videoPlayer.value.pause();
|
||||
videoPlayer.value.src = '';
|
||||
}
|
||||
videoDirectUrl.value = '';
|
||||
loading.value = false;
|
||||
error.value = '';
|
||||
currentVideoId.value = '';
|
||||
currentVideoInfo.value = null;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
// 视频加载开始
|
||||
const handleLoadStart = () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
console.log('[VideoPlayer] loadstart 事件触发');
|
||||
};
|
||||
|
||||
// 视频元数据加载完成
|
||||
const handleLoadedMetadata = () => {
|
||||
const video = videoPlayer.value;
|
||||
if (video) {
|
||||
console.log('[VideoPlayer] loadedmetadata 事件触发', {
|
||||
duration: video.duration,
|
||||
videoWidth: video.videoWidth,
|
||||
videoHeight: video.videoHeight,
|
||||
readyState: video.readyState,
|
||||
networkState: video.networkState
|
||||
});
|
||||
}
|
||||
// 不在这里关闭 loading,等待 canplay 事件
|
||||
};
|
||||
|
||||
// 视频可以开始播放(有足够的数据)
|
||||
const handleCanPlay = () => {
|
||||
const video = videoPlayer.value;
|
||||
loading.value = false;
|
||||
if (video) {
|
||||
console.log('[VideoPlayer] canplay 事件触发 - 视频可以开始播放', {
|
||||
duration: video.duration,
|
||||
readyState: video.readyState,
|
||||
networkState: video.networkState,
|
||||
bufferedLength: video.buffered?.length || 0
|
||||
});
|
||||
}
|
||||
// 如果视频还没有播放,可以尝试自动播放(需要用户交互)
|
||||
if (video && video.paused) {
|
||||
// 注意:自动播放可能被浏览器阻止,需要用户交互
|
||||
// 这里不自动播放,让用户点击播放按钮
|
||||
}
|
||||
};
|
||||
|
||||
// 视频可以流畅播放(有足够的数据缓冲)
|
||||
const handleCanPlayThrough = () => {
|
||||
const video = videoPlayer.value;
|
||||
loading.value = false;
|
||||
if (video) {
|
||||
console.log('[VideoPlayer] canplaythrough 事件触发 - 视频可以流畅播放', {
|
||||
duration: video.duration,
|
||||
readyState: video.readyState,
|
||||
networkState: video.networkState,
|
||||
bufferedLength: video.buffered?.length || 0
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 视频加载进度
|
||||
const handleProgress = () => {
|
||||
const video = videoPlayer.value;
|
||||
if (video && video.buffered.length > 0) {
|
||||
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||
const bufferedPercent = video.duration > 0 ? (bufferedEnd / video.duration) * 100 : 0;
|
||||
console.log('[VideoPlayer] progress 事件 - 缓冲进度:', {
|
||||
bufferedPercent: bufferedPercent.toFixed(2) + '%',
|
||||
bufferedEnd,
|
||||
duration: video.duration,
|
||||
readyState: video.readyState
|
||||
});
|
||||
|
||||
// 如果已经有足够的数据可以播放,关闭加载状态
|
||||
if (bufferedEnd > 0 && video.readyState >= 2) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 视频等待数据
|
||||
const handleWaiting = () => {
|
||||
console.log('[VideoPlayer] waiting 事件 - 视频等待数据');
|
||||
loading.value = true;
|
||||
};
|
||||
|
||||
// 视频开始播放
|
||||
const handlePlaying = () => {
|
||||
console.log('[VideoPlayer] playing 事件 - 视频开始播放');
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
// 视频加载错误
|
||||
const handleVideoError = (e: Event) => {
|
||||
const video = e.target as HTMLVideoElement;
|
||||
|
||||
// 如果 src 为空或正在加载中,忽略错误(可能是时序问题)
|
||||
if (!video.src || !videoDirectUrl.value || video.networkState === 0) {
|
||||
console.log('忽略视频加载错误(可能是时序问题):', {
|
||||
src: video.src,
|
||||
directUrl: videoDirectUrl.value,
|
||||
networkState: video.networkState,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('视频加载错误详情:', {
|
||||
error: video.error,
|
||||
errorCode: video.error?.code,
|
||||
errorMessage: video.error?.message,
|
||||
networkState: video.networkState,
|
||||
readyState: video.readyState,
|
||||
src: video.src,
|
||||
directUrl: videoDirectUrl.value,
|
||||
});
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (video.error) {
|
||||
switch (video.error.code) {
|
||||
case 1: // MEDIA_ERR_ABORTED
|
||||
error.value = '视频加载被中止';
|
||||
break;
|
||||
case 2: // MEDIA_ERR_NETWORK
|
||||
// 网络错误可能是认证失败(401)或其他网络问题
|
||||
// 检查是否是认证问题
|
||||
if (video.networkState === 3) { // NETWORK_NO_SOURCE
|
||||
// 可能是认证失败,尝试提示用户
|
||||
error.value = '视频加载失败,可能是认证问题。请检查是否已登录,或刷新页面后重试';
|
||||
} else {
|
||||
error.value = '网络错误,请检查网络连接';
|
||||
}
|
||||
break;
|
||||
case 3: // MEDIA_ERR_DECODE
|
||||
error.value = '视频解码失败,可能是视频格式问题或文件损坏';
|
||||
break;
|
||||
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
// 尝试重新加载视频
|
||||
if (videoDirectUrl.value && video.src === videoDirectUrl.value) {
|
||||
console.log('尝试重新加载视频...');
|
||||
setTimeout(() => {
|
||||
if (videoPlayer.value && videoDirectUrl.value) {
|
||||
videoPlayer.value.load();
|
||||
}
|
||||
}, 500);
|
||||
return; // 不显示错误,等待重试
|
||||
}
|
||||
error.value = '视频格式不支持。请检查:1) 视频文件是否完整 2) 浏览器是否支持该格式 3) 文件扩展名是否正确';
|
||||
break;
|
||||
default:
|
||||
error.value = `视频加载失败 (错误代码: ${video.error.code})`;
|
||||
}
|
||||
} else {
|
||||
error.value = '视频加载失败,请检查网络连接和视频文件';
|
||||
}
|
||||
};
|
||||
|
||||
// 监听视频加载完成
|
||||
watch(
|
||||
() => videoPlayer.value?.readyState,
|
||||
(readyState) => {
|
||||
if (readyState && readyState >= 2) {
|
||||
// HAVE_CURRENT_DATA 或更高
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
if (videoPlayer.value) {
|
||||
videoPlayer.value.pause();
|
||||
videoPlayer.value.src = '';
|
||||
}
|
||||
videoDirectUrl.value = '';
|
||||
});
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
open,
|
||||
close: handleClose,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.video-player-modal {
|
||||
:deep(.ant-modal-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// 确保标题区域不遮挡关闭按钮,支持多行显示
|
||||
:deep(.ant-modal-header) {
|
||||
position: relative;
|
||||
padding-right: 56px !important; // 为关闭按钮留出足够空间
|
||||
min-height: 55px;
|
||||
padding-bottom: 16px; // 增加底部内边距,为多行标题留空间
|
||||
|
||||
.ant-modal-title {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义标题样式
|
||||
.modal-title-wrapper {
|
||||
width: 100%;
|
||||
max-width: calc(100% - 56px); // 减去关闭按钮的宽度
|
||||
padding-right: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-title-text {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
line-height: 1.5;
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
// 确保关闭按钮始终可见且在最上层
|
||||
:deep(.ant-modal-close) {
|
||||
position: absolute !important;
|
||||
right: 0 !important;
|
||||
top: 0 !important;
|
||||
z-index: 1000 !important;
|
||||
width: 56px !important;
|
||||
height: 55px !important;
|
||||
line-height: 55px !important;
|
||||
text-align: center !important;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.ant-modal-close-x {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-player-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
|
||||
.info-row {
|
||||
display: inline-block;
|
||||
margin-right: 24px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.video-element {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 70vh;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user