1、nfo刮削文件优化,修复演员信息(视频作者),建议emby媒体库选影视,或者混合类型-右上角有一个兼容旧数据的按钮一键重置所有刮削文件

2、图文视频 以及动态视频,遇到因版权原因无法下载音频时,需要额外的音频文件,现在放到mp3目录了,docker-compose增加一个映射路径即可 例如: - /vol2/1000/media/dysync/mp3:/app/mp3
3、增加动态视频合成配置(不建议保留原视频,没有额外刮削的,因为有些视频是几十个很短的动图一样的视频拼接成的,保留原视频emby会出现一堆没有封面的视频)
4、容器重启后,手机端会连续跳转登录页很多次的bug修复
5、关注博主保存文件夹路径长度增加,允许_
6、批量永久删除选中的视频(不再下载,慎重)
7、看板页面 隐藏一个功能:双击作者头像,可以删除作者所有视频(永久删除,不再下载,慎重)
8、手机端 视频播放优化
This commit is contained in:
jianzhichu
2026-01-15 00:41:47 +08:00
parent 8da82889d1
commit ff80250012
22 changed files with 795 additions and 543 deletions
+126 -107
View File
@@ -4,114 +4,140 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo.png" />
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" /> -->
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>抖小云</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
background-color: #ffffff;
height: 100vh;
overflow: hidden;
font-family: "Microsoft YaHei", sans-serif;
}
/* ######### 核心修改:区分 #stepin-app 的两个状态 ######### */
/* 1. 加载阶段:#stepin-app 仅包含加载动画(或为空),应用 flex 居中,保留原有样式 */
#stepin-app {
/* 加载阶段默认样式:flex 居中,保留呼吸圆环效果 */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 35px;
}
/* 2. Vue 挂载后:#stepin-app 非空(包含 Vue 组件),覆盖为普通块级元素,解除约束 */
#stepin-app:not(:has(.breath-ring)) {
/* 核心:取消 flex 布局,改为普通块级元素 */
display: block !important;
/* 覆盖原有 flex 相关属性,解除居中约束 */
align-items: unset !important;
justify-content: unset !important;
/* 确保页面撑满横向宽度 */
width: 100% !important;
/* 保留高度,确保 Vue 页面正常占满视口 */
height: 100% !important;
/* 移除 gap,避免影响 Vue 页面布局 */
gap: 0 !important;
/* 恢复页面滚动功能,解除 overflow: hidden 的限制 */
overflow: auto !important;
}
/* 核心呼吸圆环容器(原有样式保留,无修改) */
.breath-ring {
width: 60px;
height: 60px;
position: relative;
}
/* 圆环基础样式(原有样式保留,无修改) */
.breath-ring::before,
.breath-ring::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
opacity: 0.6;
}
/* 内层圆环:细圆环 + 渐变(原有样式保留,无修改) */
.breath-ring::before {
border: 3px solid transparent;
border-top-color: #1890ff;
border-right-color: #722ed1;
animation: ringRotate 1.5s linear infinite;
}
/* 外层圆环:呼吸扩散动画(原有样式保留,无修改) */
.breath-ring::after {
border: 2px solid #1890ff;
animation: ringBreath 2s ease-in-out infinite;
}
/* 加载文字:极简高级(原有样式保留,无修改) */
.loading-text {
color: #222222;
font-size: 17px;
font-weight: 400;
letter-spacing: 0.5px;
}
.loading-tip {
color: #aaaaaa;
font-size: 13px;
margin-top: -10px;
}
/* 圆环旋转动画(原有样式保留,无修改) */
@keyframes ringRotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 圆环呼吸扩散动画(原有样式保留,无修改) */
@keyframes ringBreath {
0% {
transform: scale(0.8);
opacity: 0.8;
}
50% {
transform: scale(1.2);
opacity: 0.2;
}
100% {
transform: scale(0.8);
opacity: 0.8;
}
}
</style>
</head>
<body>
<div id="stepin-app">
<style>
html {
overflow: hidden;
}
body {
height: 100vh;
text-align: center;
align-items: center;
background-color: #011627;
display: flex;
justify-content: center;
overflow: hidden;
}
.loader {
color: rgb(201, 195, 195);
font-family: "Poppins", sans-serif;
font-weight: 500;
font-size: 20px;
-webkit-box-sizing: content-box;
box-sizing: content-box;
height: 40px;
padding: 10px 10px;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
text-align: center;
justify-content: center;
border-radius: 8px;
}
.words {
overflow: hidden;
}
.word {
display: block;
height: 100%;
padding-left: 6px;
color: #ffca29;
animation: spin_4991 4s infinite;
}
@keyframes spin_4991 {
10% {
-webkit-transform: translateY(-105%);
transform: translateY(-105%);
}
25% {
-webkit-transform: translateY(-100%);
transform: translateY(-100%);
}
35% {
-webkit-transform: translateY(-205%);
transform: translateY(-205%);
}
50% {
-webkit-transform: translateY(-200%);
transform: translateY(-200%);
}
60% {
-webkit-transform: translateY(-305%);
transform: translateY(-305%);
}
75% {
-webkit-transform: translateY(-300%);
transform: translateY(-300%);
}
85% {
-webkit-transform: translateY(-405%);
transform: translateY(-405%);
}
100% {
-webkit-transform: translateY(-400%);
transform: translateY(-400%);
}
}
</style>
<!-- <img style="margin-bottom: 8px;" width="64px" src="/vite.svg" /> -->
<div class="loader">
<!-- <img src="/vite.svg"> -->
<div> loading ...</div>
<!-- <div class="words">
<span class="word">buttons</span>
<span class="word">forms</span>
<span class="word">switches</span>
<span class="word">cards</span>
<span class="word">buttons</span>
</div> -->
</div>
<div class="" style="font-size: 15px; color: rgba(249, 244, 244, 0.55)">首次加载可能较慢,请耐心等待...</div>
<!-- <div class="loading-text">正在加载资源,请稍候...</div> -->
<!-- 呼吸圆环加载组件 -->
<div class="breath-ring"></div>
<!-- <div class="loading-tip">首次加载较慢,请耐心等待...</div> -->
</div>
<script type="module" src="/src/main.ts"></script>
<script>
if (!global) {
@@ -120,11 +146,4 @@
</script>
</body>
<!-- <link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-awesome/5.15.4/css/all.min.css"> -->
<style>
html {
overflow: hidden;
}
</style>
</html>
+4 -4
View File
@@ -54,9 +54,9 @@
{{ item.uperName }}
<!-- 非关注小标记 -->
<span v-if="item.isNoFollowed" class="no-followed-badge">非关注</span>
<!-- 删除按钮仅非关注项显示放在名字+非关注后面 -->
<a-button v-if="item.isNoFollowed" type="text" class="delete-btn" @click="(e) => { e.stopPropagation(); handleDeleteItem(item); }" :disabled="item.isSaving" title="删除该非关注博主">
<DeleteOutlined />
<!-- 删除按钮仅非关注项显示放在名字+非关注后面v-if="item.isNoFollowed" -->
<a-button type="text" class="delete-btn" @click="(e) => { e.stopPropagation(); handleDeleteItem(item); }" :disabled="item.isSaving" title="删除该非关注博主">
<close-outlined />
</a-button>
</div>
<!-- 签名多行显示高度控制 + 溢出截断 + Tooltip气泡 -->
@@ -842,7 +842,7 @@ const goDouyinUp = (item) => {
height: 24px !important;
width: 24px !important;
color: #ef4444 !important;
border-radius: 4px !important;
border-radius: 50% !important;
transition: all 0.2s ease;
vertical-align: middle;
}
+123 -53
View File
@@ -185,23 +185,24 @@
</div>
</div>
<!-- 新增视频播放弹窗移动端适配 -->
<!-- 新增视频播放弹窗全屏适配+暂停显示删除按钮 -->
<div v-if="showVideoPlayer" class="video-modal-mask" @click="closeVideoPlayer">
<div class="video-modal-content" @click.stop>
<div class="video-modal-header">
<h3 class="video-title-text"></h3>
<button class="video-close-btn" @click="closeVideoPlayer">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<!-- 移除原有标题栏将关闭按钮直接放在内容容器内实现悬浮 -->
<button class="video-close-btn floating-close-btn" @click="closeVideoPlayer">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div class="video-modal-body">
<!-- 加载中状态 -->
<div v-if="videoLoading" class="video-loading">
<div class="loading-spinner"></div>
<p>加载视频中...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="videoError" class="video-error">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#f44336" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
@@ -211,15 +212,24 @@
<p>视频加载失败</p>
<button class="retry-btn" @click="loadVideo(currentVideo)">重试</button>
</div>
<video v-else class="video-player" controls autoplay playsinline :src="videoPlayUrl" @error="handleVideoError">
您的浏览器不支持HTML5视频播放
</video>
<!-- 视频播放区域添加ref用于获取视频实例绑定暂停/播放事件 -->
<div v-else class="video-player-wrapper">
<video ref="videoPlayerRef" class="video-player" controls autoplay playsinline :src="videoPlayUrl" @error="handleVideoError" @pause="onVideoPause" @play="onVideoPlay">
您的浏览器不支持HTML5视频播放
</video>
<!-- 悬浮透明删除X按钮仅暂停时显示 -->
<button class="video-delete-btn" v-show="isVideoPaused" @click.stop="deleteCurrentVideo" title="删除该视频">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue';
import { useApiStore } from '@/store';
@@ -286,6 +296,9 @@ const currentVideo = ref<TopVideoItem | null>(null);
const videoPlayUrl = ref<string>('');
const videoLoading = ref<boolean>(false);
const videoError = ref<boolean>(false);
// 新增:视频实例引用和暂停状态
const videoPlayerRef = ref<HTMLVideoElement | null>(null);
const isVideoPaused = ref<boolean>(false); // 控制删除按钮显示
// 过滤后的日志列表
const filteredLogs = computed(() => {
@@ -453,7 +466,12 @@ const openVideoPlayer = (video: TopVideoItem) => {
currentVideo.value = video;
showVideoPlayer.value = true;
loadVideo(video);
loadVideo(video).then(() => {
// 视频加载完成后触发播放,避免浏览器拦截
if (videoPlayerRef.value) {
videoPlayerRef.value.play().catch((err) => console.log('自动播放被拦截:', err));
}
});
};
// 新增:加载视频播放地址
@@ -479,6 +497,7 @@ const closeVideoPlayer = () => {
videoPlayUrl.value = '';
videoLoading.value = false;
videoError.value = false;
isVideoPaused.value = false; // 重置暂停状态
};
// 新增:处理视频播放错误
@@ -486,6 +505,23 @@ const handleVideoError = () => {
videoError.value = true;
console.error('视频播放出错');
};
// 新增:视频暂停回调
const onVideoPause = () => {
isVideoPaused.value = true;
};
// 新增:视频播放回调
const onVideoPlay = () => {
isVideoPaused.value = false;
};
const deleteCurrentVideo = async () => {
// 关闭播放弹窗,刷新视频列表
closeVideoPlayer();
TopVideo(); // 重新加载最新视频列表
loadDashboardData(); // 刷新仪表盘统计数据
};
</script>
<style scoped>
@@ -1003,68 +1039,58 @@ const handleVideoError = () => {
.copy-btn:hover:not(:disabled) {
background-color: #43a047;
}
/* 新增:视频播放弹窗样式(移动端适配) */
/* 视频播放弹窗样式(纯全屏+悬浮右上角关闭按钮) */
.video-modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
background-color: #000; /* 纯黑背景更贴合全屏播放 */
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 0px;
padding: 0;
box-sizing: border-box;
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
}
.video-modal-content {
width: 100%;
max-width: 100%;
width: 100vw; /* 视口宽度100% */
height: 100vh; /* 视口高度100% */
max-width: none; /* 移除原有最大宽度限制 */
max-height: none; /* 移除原有最大高度限制 */
background-color: #111;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
max-height: 80vh;
border-radius: 0; /* 全屏移除圆角 */
box-shadow: none; /* 全屏无需阴影 */
display: flex;
flex-direction: column;
overflow: hidden;
position: relative; /* 为悬浮关闭按钮提供定位上下文 */
}
.video-modal-header {
padding: 5px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #222;
}
.video-title-text {
font-size: 14px;
color: #fff;
font-weight: 500;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 80%;
}
.video-close-btn {
background: transparent;
/* 悬浮右上角关闭按钮样式 */
.floating-close-btn {
position: absolute;
top: 20px; /* 右上角间距,可调整 */
right: 20px; /* 右上角间距,可调整 */
width: 40px;
height: 40px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.5);
border: none;
cursor: pointer;
color: #fff;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 2010; /* 确保在视频上方 */
backdrop-filter: blur(2px);
}
.video-close-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
.floating-close-btn:hover {
background-color: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.video-modal-body {
padding: 0;
@@ -1074,13 +1100,44 @@ const handleVideoError = () => {
justify-content: center;
background-color: #000;
position: relative;
width: 100%;
height: 100%; /* 完全填充父容器,无高度损耗 */
}
/* 新增:视频播放器容器(用于定位删除按钮) */
.video-player-wrapper {
position: relative;
width: 100vw;
height: 100vh;
}
.video-player {
width: 100%;
height: 100%;
min-height: 200px;
max-height: 60vh;
object-fit: contain;
object-fit: contain; /* 保持视频比例,全屏填充无黑边(也可改为 cover 强制填充,可能裁剪视频) */
}
/* 悬浮透明删除X按钮样式 */
.video-delete-btn {
position: fixed;
top: 50%;
right: 20px;
transform: translateY(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.3);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
transition: all 0.2s ease;
z-index: 2010;
backdrop-filter: blur(2px);
}
.video-delete-btn:hover,
.video-delete-btn:active {
background-color: rgba(244, 67, 54, 0.5);
transform: translateY(-50%) scale(1.1);
}
/* 视频加载状态 */
.video-loading {
@@ -1090,6 +1147,8 @@ const handleVideoError = () => {
justify-content: center;
color: #fff;
padding: 40px 20px;
width: 100%;
height: 100%;
}
.loading-spinner {
width: 40px;
@@ -1114,6 +1173,8 @@ const handleVideoError = () => {
color: #fff;
padding: 40px 20px;
text-align: center;
width: 100%;
height: 100%;
}
.video-error p {
margin: 16px 0 24px;
@@ -1277,6 +1338,15 @@ html.dark-mode .close-btn:hover {
color: #ffffff;
}
/* 夜间模式 - 视频弹窗样式适配 */
html.dark-mode .video-delete-btn {
background-color: rgba(0, 0, 0, 0.4);
}
html.dark-mode .video-delete-btn:hover,
html.dark-mode .video-delete-btn:active {
background-color: rgba(244, 67, 54, 0.6);
}
.ant-message {
z-index: 10000 !important;
}
+128 -162
View File
@@ -25,7 +25,7 @@
<a-switch v-model:checked="formState.OnlySyncNew" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>开启后仅同步最近收藏的20条以及未来新收藏的不会去同步之前的视频默认开启 避免突然大量下载导致风控</span>
<span>开启后仅同步最近的20条以及未来新加入收藏喜欢关注的视频不会去同步之前的视频默认开启 避免突然大量下载导致风控</span>
</div>
</a-form-item>
</div>
@@ -113,32 +113,44 @@
</div>
</a-form-item>
<a-form-item v-if="formState.DownImageVideo" has-feedback label="默认音频" name="AudioFile" :wrapper-col="{ span: 20 }">
<!-- 音频上传与播放器容器 -->
<div class="audio-upload-player-wrapper" style="display: flex; align-items: center; gap: 16px;">
<a-upload :before-upload="beforeUpload" :custom-request="customUpload" :show-upload-list="false" accept=".mp3,.wav">
<a-button type="default">
<UploadOutlined /> 选择文件
</a-button>
</a-upload>
<!-- 新增启用原生完整控件controls属性自带可拖拽进度条 -->
<div class="audio-player" v-if="audioUrl" style="flex: 1; max-width: 500px;">
<audio ref="audioInstance" :src="audioUrl" controls controlsList="nodownload" @ended="() => isPlaying = false" @pause="() => isPlaying = false" @play="() => isPlaying = true" class="native-audio-player">
您的浏览器不支持HTML5音频播放请升级至现代浏览器
</audio>
</div>
<a-form-item has-feedback label="默认音频" name="AudioFile" :wrapper-col="{ span: 20 }">
<!-- 仅保留检测按钮 -->
<div class="audio-check-wrapper" style="display: flex; align-items: center; gap: 16px;">
<a-button type="primary" @click="checkAudioList">
<play-circle-outlined /> 音频列表
</a-button>
</div>
<!-- 提示文本调整 -->
<div class="flex items-start mt-1 text-sm text-gray-500" style="color:red">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>当下载图文视频时音频因版权原因无法下载时将用该音频文件作为合成视频的音频</span>
</div>
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>支持格式MP3WAVAACFLACOGGM4AWMA单个文件最大20MB</span>
</div>
</a-form-item>
<a-modal v-model:visible="audioListModalVisible" title="发现音频" width="666px" destroyOnClose @ok="closeAudioListModal" @cancel="closeAudioListModal">
<div class="audio-list-container" style="max-height: 500px; overflow-y: auto; padding: 10px 0;">
<!-- 空列表提示 -->
<div v-if="audioList.length === 0" style="text-align: center; padding: 40px; color: #999;">
暂无可用音频文件
</div>
<!-- 音频列表嵌入原生audio播放器 -->
<a-list v-else bordered :data-source="audioList" item-layout="horizontal">
<template #renderItem="{item}">
<a-list-item>
<a-list-item-meta :title="item.filename " />
<template #actions>
<!-- 替换原播放按钮嵌入原生HTML音频播放器 -->
<audio class="native-audio-player" controls :src="'/api/config/getmp3?name='+item.filename" preload="none" @play="handleAudioPlay($event.target)">
您的浏览器不支持HTML5音频播放器请升级浏览器后重试
</audio>
</template>
</a-list-item>
</template>
</a-list>
</div>
</a-modal>
</div>
<div class="form-section">
@@ -226,7 +238,7 @@
<input ref="importFileInput" type="file" accept=".json" class="import-file-input" @change="handleImportFile">
</div>
<div class="top-right-float-btn-container">
<a-tooltip title="重新对同步好的视频文件进行刮削" placement="bottom">
<a-tooltip title="重置所有刮削" placement="bottom">
<a-button class="top-right-float-btn" type="primary" shape="circle" @click="renfo">
<bulb-outlined />
</a-button>
@@ -276,9 +288,6 @@ const downImgVideo = ref(true);
const floatMenuVisible = ref(false);
const importFileInput = ref<HTMLInputElement | null>(null);
// 新增:文件上传相关状态(已删除 uploadFileList,仅保留 isUploading 可选)
const isUploading = ref(false);
//开启或关闭合成视频
const downImageVideoHandler = () => {
if (formState.DownImageVideo) {
@@ -405,7 +414,11 @@ const getConfig = () => {
tagData.value = JSON.parse(res.data.priorityLevel || '[]');
} else {
message.error(res.message || '获取配置失败', 8);
if (res.message.indexOf('401') == -1) {
message.error(res.message || '获取配置失败', 8);
} else {
console.error('获取配置失败:', res);
}
}
})
.catch((error) => {
@@ -443,135 +456,65 @@ const updateTagSort = () => {
});
};
// ========== 文件上传相关方法(已修改:移除进度条逻辑) ==========
// 音频列表相关状态
const audioListModalVisible = ref(false); // 弹窗显隐
const audioList = ref<any[]>([]); // 存储接口返回的音频列表
const currentPlayingAudio = ref<HTMLAudioElement | null>(null); // 核心:存储当前正在播放的音频
/**
* 上传前校验
* 音频播放互斥处理(核心:同一时间只播放一个音频)
* @param target 事件目标元素(EventTarget 类型)
*/
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
// 1. 校验文件大小(10MB
const isLt20M = file.size / 1024 / 1024 < 20;
if (!isLt20M) {
message.error('文件大小不能超过20MB!');
return false;
const handleAudioPlay = (target: EventTarget | null) => {
// 1. 校验元素是否存在,且是 HTMLAudioElement 类型
if (!target || !(target instanceof HTMLAudioElement)) {
return; // 非音频元素,直接返回,避免报错
}
// 2. 校验文件类型
const acceptTypes = ['.mp3', '.wav', '.aac', '.flac', '.ogg', '.m4a', '.wma'];
const fileExt = '.' + file.name.split('.').pop()?.toLowerCase();
if (!acceptTypes.includes(fileExt || '')) {
message.error('仅支持MP3、WAV、AAC、FLAC、OGG、M4A、WMA格式的音频文件!');
return false;
// 2. 此时 target 已被确认是 HTMLAudioElement 类型,可安全操作
const audioEl = target;
// 3. 原有互斥逻辑
if (currentPlayingAudio.value && currentPlayingAudio.value !== audioEl) {
currentPlayingAudio.value.pause();
}
return true;
};
// 1. 新增音频播放相关状态(放在现有状态定义区域,如 isUploading 下方)
const audioUrl = ref('/api/config/defaudio'); // 上传成功后的音频文件URL
const isPlaying = ref(false); // 音频是否正在播放
const audioInstance = ref<HTMLAudioElement | null>(null); // 音频播放器实例
// 2. 改造原有 customUpload 方法,保存上传成功后的音频URL
const customUpload: UploadProps['customRequest'] = (options) => {
const { file, onSuccess, onError } = options;
isUploading.value = true;
// 构造FormData
const formData = new FormData();
formData.append('file', file);
useApiStore()
.apiUploadAudio(formData)
.then((res) => {
console.log(res);
if (res.code === 0) {
// message.success('音频文件上传成功!');
audioUrl.value = `/api/config/defaudio?t=${Date.now()}`;
onSuccess(res);
} else {
message.error(res.message || '文件上传失败!');
onError(new Error(res.message || '上传失败'), file);
}
})
.catch((error) => {
console.error('文件上传失败:', error);
message.error('文件上传失败,请稍后重试!');
onError(error, file);
})
.finally(() => {
isUploading.value = false;
});
currentPlayingAudio.value = audioEl;
};
// 新增:封装 load() 为 Promise (可复用)
const audioLoadPromise = (audio: HTMLAudioElement): Promise<void> => {
return new Promise((resolve, reject) => {
// 加载成功回调
const onLoadSuccess = () => {
audio.removeEventListener('canplaythrough', onLoadSuccess);
audio.removeEventListener('error', onLoadError);
resolve();
};
// 加载失败回调
const onLoadError = () => {
audio.removeEventListener('canplaythrough', onLoadSuccess);
audio.removeEventListener('error', onLoadError);
reject(new Error('音频加载失败'));
};
audio.addEventListener('canplaythrough', onLoadSuccess);
audio.addEventListener('error', onLoadError);
audio.load();
});
};
// 修改 refreshAudioPlayer 为 async 方法
const refreshAudioPlayer = async () => {
if (!audioInstance.value) return;
/**
* 检测音频列表:调用mp3List接口并展示弹窗
*/
const checkAudioList = async () => {
try {
// 1. 暂停播放、重置状态
audioInstance.value.pause();
audioInstance.value.currentTime = 0;
isPlaying.value = false;
// 2. 等待加载完成(核心:解决时序冲突)
await audioLoadPromise(audioInstance.value);
// 3. 加载完成后,安全调用 play()
await audioInstance.value.play();
isPlaying.value = true;
// message.success('音频已自动播放');
} catch (err) {
if ((err as Error).message !== '音频加载失败') {
// 区分加载失败和自动播放失败
message.warning('自动播放失败,请手动点击播放按钮(浏览器限制)');
} else {
message.error('音频加载失败,无法自动播放');
// 步骤1:打开弹窗前,先暂停上一次可能残留的播放音频
if (currentPlayingAudio.value) {
currentPlayingAudio.value.pause();
currentPlayingAudio.value = null;
}
console.log('错误详情:', err);
isPlaying.value = false;
// 步骤2:调用接口获取音频列表
const res = await useApiStore().mp3List();
if (res.code === 0) {
audioList.value = res.data || [];
audioListModalVisible.value = true;
} else {
message.error(res.message || '获取音频列表失败');
}
} catch (error) {
console.error('检测音频列表失败:', error);
message.error('获取音频列表失败,请稍后重试');
}
};
/**
* 关闭音频列表弹窗
*/
const closeAudioListModal = () => {
audioListModalVisible.value = false;
// 清理当前播放的音频,避免弹窗关闭后仍在播放或残留实例
if (currentPlayingAudio.value) {
currentPlayingAudio.value.pause();
currentPlayingAudio.value = null;
}
};
// 监听 audioUrl 变化,重置播放状态(可选,优化用户体验)
watch(
audioUrl,
(newVal, oldVal) => {
// 排除初始值(仅当 url 发生有效变更时刷新)
if (newVal && newVal !== oldVal) {
isPlaying.value = false;
// 核心:调用刷新方法,强制加载新音频
refreshAudioPlayer();
} else if (!newVal) {
// 若 url 为空,仅重置状态
isPlaying.value = false;
if (audioInstance.value) {
audioInstance.value.currentTime = 0;
}
}
},
{ immediate: false }
); // 关闭 immediate,避免初始加载时触发
// 组件挂载时获取配置
onMounted(async () => {
getConfig();
@@ -938,29 +881,24 @@ const renfo = () => {
:deep(.ant-upload.ant-upload-select) {
display: inline-block;
}
// 音频列表弹窗样式优化
:deep(.audio-list-container .ant-list) {
.ant-list-item {
padding: 12px 16px;
&:hover {
background-color: #fafafa;
}
}
// 音频上传与播放器容器样式
.audio-upload-player-wrapper {
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
.ant-list-item-meta-title {
font-weight: 500;
color: #333;
}
}
// 音频播放器样式优化
:deep(.audio-player audio) {
// border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 2px;
&:hover {
border-color: #1890ff;
}
}
// 播放/清空按钮 hover 效果
:deep(.audio-player .ant-btn-text:hover) {
background-color: #f5f5f5;
// 检测按钮样式
.audio-check-wrapper {
padding: 8px 0;
}
// 右上角悬浮按钮容器
@@ -987,4 +925,32 @@ const renfo = () => {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); // 悬浮时阴影加深(可选,可删除)
}
}
// 原生音频播放器样式优化
.native-audio-player {
width: 200px; // 固定宽度,适配列表布局
height: 32px; // 统一高度,与列表项对齐
outline: none; // 移除聚焦轮廓
border: 1px solid #d9d9d9; // 添边框,与AntD风格统一
border-radius: 4px; // 圆角,与AntD风格统一
background-color: #fafafa; // 浅背景色,提升质感
// 优化播放器内部控件样式(部分浏览器支持)
&::-webkit-media-controls {
background-color: #fafafa;
}
// 悬浮效果
&:hover {
border-color: #1890ff; // 悬浮时边框变主色,提升交互感
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1); // 轻微发光效果
}
}
// 音频列表项适配,保证播放器与内容对齐
:deep(.ant-list-item-actions) {
display: flex;
align-items: center;
padding-right: 8px;
}
</style>
+62 -9
View File
@@ -44,18 +44,24 @@
<a-space size="middle" class="button-group">
<a-button success @click="handleBatchShare" class="delete-button" v-if="isBatchMode" :disabled="selectedRowKeys.length === 0 || isSyncing">
<ShareAltOutlined />
分享选中
批量分享
</a-button>
<a-button type="danger" @click="handleBatchDelete" class="delete-button" v-if="isBatchMode" :disabled="selectedRowKeys.length === 0 || isSyncing">
<a-button danger @click="handleBatchSync" class="delete-button" v-if="isBatchMode" :disabled="selectedRowKeys.length === 0 || isSyncing">
<SyncOutlined />
重新同步
重新下载
</a-button>
<a-button danger @click="handleBatchDelete" class="delete-button" v-if="isBatchMode" :disabled="selectedRowKeys.length === 0 || isSyncing">
<close-outlined />
批量删除
</a-button>
</a-space>
</a-form-item>
<!-- 按钮代码 -->
<a-form-item class="form-item delete-btn-2-wrapper">
<a-button danger @click="handShowDeleteVideos" class="delete-button-2">
<ClearOutlined /> <!-- 注意首字母大写Antd图标命名规范 -->
<a-button type="primary" danger @click="handShowDeleteVideos" class="delete-button-2">
<!-- <ClearOutlined /> -->
<!-- 注意首字母大写Antd图标命名规范 -->
<delete-outlined />
已删除
</a-button>
</a-form-item>
@@ -79,9 +85,9 @@
</span>
</div>
<a-button type="text" size="small" class="copy-delete-video-btn" @click="(e) => copyVideoPath(item.videoSavePath)">
<!-- <a-button type="text" size="small" class="copy-delete-video-btn" @click="(e) => copyVideoPath(item.videoSavePath)">
<CopyOutlined /> 复制
</a-button>
</a-button> -->
</a-list-item>
</template>
</a-list>
@@ -639,9 +645,9 @@ watch(
// -------------------------- 批量操作和操作列事件 --------------------------
/** 批量删除事件 */
const handleBatchDelete = () => {
const handleBatchSync = () => {
if (selectedRowKeys.value.length === 0) {
message.warning('请先选择要删除的视频');
message.warning('请先选择要重新下载的视频');
return;
}
@@ -657,6 +663,24 @@ const handleBatchDelete = () => {
});
};
const handleBatchDelete = () => {
if (selectedRowKeys.value.length === 0) {
message.warning('请先选择要彻底删除的视频');
return;
}
Modal.confirm({
title: '确认删除这些下载的视频吗',
content: `您确定要彻底下删除选中的 ${selectedRowKeys.value.length} 条视频数据吗?`,
okText: '确认彻底删除',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
deleteBatch({ ids: selectedRowKeys.value });
},
});
};
const deleteVideoShow = ref(false);
const handShowDeleteVideos = () => {
deleteVideoShow.value = true;
@@ -704,6 +728,35 @@ const reDownload = (param: object) => {
}
};
const deleteBatch = (param: object) => {
try {
loading.value = true;
console.log('执行批量删除,选中ID', selectedRowKeys.value);
useApiStore()
.BathRealDelete(param)
.then((res) => {
loading.value = false;
if (res.code === 0) {
message.success('删除成功,以后都不会下载了哦,你自己选的');
// 刷新数据并清空选中状态
GetRecords();
selectedRowKeys.value = [];
} else {
message.warning(res.message || '获取数据失败');
}
})
.catch((error) => {
loading.value = false;
});
} catch (error) {
console.error('批量删除失败:', error);
message.error('删除失败,请稍后重试');
} finally {
loading.value = false;
}
};
/** 重新下载事件 */
const handleReDownload = (record: DataItem) => {
if (!record.id) {
+33 -2
View File
@@ -156,7 +156,7 @@
<!-- 作者统计 -->
<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">
<div class="author-card" v-for="(author, index) in authors" :key="index" @dblclick="handleDeleteItem(author)">
<!-- 新增横向容器包裹头像和作者信息 -->
<div class="author-info-row">
<div class="author-avatar">
@@ -209,7 +209,7 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { useApiStore } from '@/store';
import { message, Spin, Empty, Tooltip, Modal, Form, FormInstance, Popconfirm } from 'ant-design-vue';
// 类型接口
interface Author {
name: string;
@@ -313,6 +313,37 @@ const getRandomElements = (arr: any[], n: number) => {
if (n >= arr.length) return [...arr];
return [...arr].sort(() => Math.random() - 0.5).slice(0, n);
};
const handleDeleteItem = (item: any) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除博主「${item.name}」所有视频吗?删除后将无法恢复。`,
okText: '确认删除',
cancelText: '取消',
okType: 'danger',
maskClosable: false,
onOk: () => {
return new Promise((resolve, reject) => {
useApiStore()
.DeleteByAuthor(item.uperId)
.then((res) => {
if (res.code === 0) {
message.success('根据视频数量,需要时常不确定,可以稍后去日志查看...');
resolve(true);
} else {
message.error('删除博主视频失败' + (res.message || '未知错误'));
reject(false);
}
})
.catch((err) => {
console.error('删除博主视频异常', err);
message.error('删除博主视频异常' + err);
reject(false);
});
});
},
});
};
</script>
<style scoped>
/* 基础样式 */
+46 -10
View File
@@ -31,18 +31,25 @@ import { Response } from '@/types';
export const useApiStore = defineStore('coreapi', () => {
//获取配置
async function apiGetConfig() {
return http
.request<any, Response<any>>('/api/config/GetConfig', 'GET')
.then((res) => {
return res;
})
.finally(() => {
return http.request<any, Response<any>>('/api/config/GetConfig', 'get').then(r => {
return r;
}).finally(() => {
});
});
}
// //获取配置
// async function apiGetConfig() {
// return http
// .request<any, Response<any>>('/api/config/GetConfig', 'GET')
// .then((res) => {
// return res;
// })
// .finally(() => {
// });
// }
//修改配置
async function apiUpdateConfig(request: object) {
return http
@@ -201,6 +208,14 @@ export const useApiStore = defineStore('coreapi', () => {
});
}
//批量删除
async function BathRealDelete(param: object) {
return http.request<any, Response<any>>('/api/video/vdelete/batch', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
//删除
async function DeleteVideo(param: string) {
return http.request<any, Response<any>>('/api/video/vdelete/' + param, 'get').then(r => {
@@ -209,7 +224,7 @@ export const useApiStore = defineStore('coreapi', () => {
});
}
//删除
//查询已删除
async function GetDeleteViedos() {
return http.request<any, Response<any>>('/api/video/vdelete/get', 'get').then(r => {
return r;
@@ -217,6 +232,14 @@ export const useApiStore = defineStore('coreapi', () => {
});
}
//删除博主全部视频
async function DeleteByAuthor(param: string) {
return http.request<any, Response<any>>('/api/video/vdelete/byauthor/' + param, 'get').then(r => {
return r;
}).finally(() => {
});
}
//检查版本
@@ -227,6 +250,16 @@ export const useApiStore = defineStore('coreapi', () => {
});
}
async function mp3List() {
return http.request<any, Response<any>>('/api/config/mp3List', 'get').then(r => {
return r;
}).finally(() => {
});
}
//快速停止或启动cookie配置
async function SwitchCookieStatus(param: object) {
return http.request<any, Response<any>>('/api/config/switch', 'post_json', param).then(r => {
@@ -331,6 +364,9 @@ export const useApiStore = defineStore('coreapi', () => {
}
return {
mp3List,
BathRealDelete,
DeleteByAuthor,
Renfo,
apiUploadAudio,
GetAppPort,
+14 -16
View File
@@ -48,23 +48,21 @@ http.interceptors.response.use(
accountStore.setLogged(false);
message.warning('登录状态已过期,请重新登录');
setTimeout(() => {
const redirectPath = router.currentRoute.value.fullPath;
if (redirectPath !== '/login') {
router.push({
path: '/login',
query: { redirect: redirectPath }
}).then(() => {
console.log('跳转登录页成功');
}).catch((err) => {
console.error('跳转登录页失败:', err);
}).finally(() => {
isRedirecting = false;
});
} else {
const redirectPath = router.currentRoute.value.fullPath;
if (redirectPath !== '/login') {
router.push({
path: '/login',
query: { redirect: redirectPath }
}).then(() => {
console.log('跳转登录页成功');
}).catch((err) => {
console.error('跳转登录页失败:', err);
}).finally(() => {
isRedirecting = false;
}
}, 100);
});
} else {
isRedirecting = false;
}
}
// 新增结束
} else {
+7 -23
View File
@@ -1,4 +1,4 @@
import axios, { AxiosInstance, AxiosRequestConfig, Method as _Method, AxiosResponse } from 'axios'; // 移除 AxiosProgressEvent
import axios, { AxiosInstance, AxiosRequestConfig, Method as _Method, AxiosResponse } from 'axios';
import qs from 'qs';
import Cookie from 'js-cookie';
@@ -13,8 +13,8 @@ declare interface _AxiosExtend {
request<T = any, R = AxiosResponse<T>>(
url: string,
method: Method,
params?: Record<string | number, any> | FormData, // 支持 FormData 类型
config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void } // 改用原生 ProgressEvent
params?: Record<string | number, any> | FormData,
config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void }
): Promise<R>;
/**
* 设置token
@@ -40,7 +40,6 @@ declare interface _AxiosExtend {
export interface AxiosHttp extends Omit<AxiosInstance, 'request'>, _AxiosExtend { }
// 新增 post_form / POST_FORM 类型
export type Method = _Method | 'POST_JSON' | 'post_json' | 'PUT_JSON' | 'put_json' | 'POST_FORM' | 'post_form';
/**
@@ -83,22 +82,10 @@ function toUrlencoded(params?: Record<string | number, any>) {
function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
const _axios = axios.create(config);
// 添加响应拦截器处理401状态
// 移除 401 处理,仅保留基础的响应拦截器(可选,也可直接删除)
_axios.interceptors.response.use(
// 成功响应直接返回
response => response,
// 错误响应处理
error => {
// 检查是否是401未授权错误
if (error.response && error.response.status === 401) {
// 调用removeAuthorization方法清除token
http.removeAuthorization();
// 这里可以添加额外的处理,比如跳转到登录页
// 示例: window.location.href = '/login';
}
return Promise.reject(error);
}
error => Promise.reject(error)
);
const http: AxiosHttp = {
@@ -107,13 +94,12 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
url: string,
method: Method,
params?: Record<string | number, any> | FormData,
config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void } // 改用原生 ProgressEvent
config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void }
): Promise<R> {
const _method = method.toUpperCase();
// 处理上传进度配置
const requestConfig: AxiosRequestConfig = {
...config,
onUploadProgress: config?.onUploadProgress, // 透传上传进度回调
onUploadProgress: config?.onUploadProgress,
};
switch (_method) {
@@ -132,11 +118,9 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
...requestConfig,
headers: { 'Content-Type': 'application/json', ...requestConfig.headers },
});
// 新增:POST_FORM 类型(适配文件上传的 FormData)
case 'POST_FORM':
return _axios.post(url, params, {
...requestConfig,
// FormData 不需要手动设置 Content-Typeaxios 会自动处理为 multipart/form-data
headers: { ...requestConfig.headers },
});
case 'PUT':