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:
yuming
2026-07-02 20:58:08 +08:00
parent c5922f9b08
commit 30060fe29c
9 changed files with 1086 additions and 1 deletions
+580
View File
@@ -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>