This commit is contained in:
itcsx committed 2026-09-22 10:06:41 +08:00
1 parent 9b9f07dea5
commit 2e1974c35d
15 files changed
+406 -169

No files matched your search

@@ -89,12 +89,13 @@ namespace CloudBuilder.Master.Service
//bool isAttached = Debugger.IsAttached;
foreach (var item in list)
{
item.Url = FileTypePolicy.PackUrl(service, item.CreatedBy, item.OrderKey, item.TranscodeIndc);
item.Url = FileTypePolicy.PackUrl(service, item.CreatedBy, item.OrderKey);
}
data.Datas = list.Select(x => new DmsFileUploadViewDisplayData(x)
{
FormattedPic = FileTypePolicy.PackPicUrl(service, x.CreatedBy, x.OrderKey)
FormattedPic = FileTypePolicy.PackPicUrl(service, x.CreatedBy, x.OrderKey),
FormattedM3u8 = FileTypePolicy.PackM3u8Url(service, x.CreatedBy, x.OrderKey)
}).ToArray();
}
@@ -157,12 +158,13 @@ namespace CloudBuilder.Master.Service
//bool isAttached = Debugger.IsAttached;
foreach (var item in list)
{
item.Url = FileTypePolicy.PackUrl(service, item.CreatedBy, item.OrderKey, item.TranscodeIndc);
item.Url = FileTypePolicy.PackUrl(service, item.CreatedBy, item.OrderKey);
}
data.Datas = list.Select(x => new DmsFileUploadViewDisplayData(x)
{
FormattedPic = FileTypePolicy.PackPicUrl(service, x.CreatedBy, x.OrderKey)
FormattedPic = FileTypePolicy.PackPicUrl(service, x.CreatedBy, x.OrderKey),
FormattedM3u8 = FileTypePolicy.PackM3u8Url(service, x.CreatedBy, x.OrderKey)
}).ToArray();
}
@@ -221,12 +223,13 @@ namespace CloudBuilder.Master.Service
foreach (var item in list)
{
item.Url = FileTypePolicy.PackUrl(service, item.CreatedBy, item.OrderKey, item.TranscodeIndc);
item.Url = FileTypePolicy.PackUrl(service, item.CreatedBy, item.OrderKey);
}
data.Logs = list.Select(x => new DmsFileUploadDeleteLogViewDisplayData(x)
{
FormattedPic = FileTypePolicy.PackPicUrl(service, x.CreatedBy, x.OrderKey)
FormattedPic = FileTypePolicy.PackPicUrl(service, x.CreatedBy, x.OrderKey),
FormattedM3u8 = FileTypePolicy.PackM3u8Url(service, x.CreatedBy, x.OrderKey)
}).ToArray();
}
@@ -321,13 +324,14 @@ namespace CloudBuilder.Master.Service
foreach (var item in list)
{
item.Url = FileTypePolicy.PackUrl(service, item.CreatedBy, item.OrderKey, item.TranscodeIndc);
item.Url = FileTypePolicy.PackUrl(service, item.CreatedBy, item.OrderKey);
}
data.Families = list.Select(x => new FamilyDmsFileViewDisplayData(x)
{
FileType = FileTypePolicy.GetType(x.OrderKey),
FormattedPic = FileTypePolicy.PackPicUrl(service, x.CreatedBy, x.OrderKey)
FormattedPic = FileTypePolicy.PackPicUrl(service, x.CreatedBy, x.OrderKey),
FormattedM3u8 = FileTypePolicy.PackM3u8Url(service, x.CreatedBy, x.OrderKey)
}).ToArray();
}
@@ -84,11 +84,11 @@ namespace CloudBuilder.Master.Service
}
if (!string.IsNullOrEmpty(item.Url) && FileTypePolicy.GetType(item.Url!) == FileTypePolicy.AUDIO)
{
item.FormattedUrl = FileTypePolicy.PackUrl(service, item.CreatedBy, item.Url, YesNoPolicy.NO);
item.FormattedUrl = FileTypePolicy.PackUrl(service, item.CreatedBy, item.Url);
}
if (!string.IsNullOrEmpty(item.Lrc) && FileTypePolicy.GetType(item.Lrc!) == FileTypePolicy.LYRIC)
{
item.FormattedLrc = FileTypePolicy.PackUrl(service, item.CreatedBy, item.Lrc, YesNoPolicy.NO);
item.FormattedLrc = FileTypePolicy.PackUrl(service, item.CreatedBy, item.Lrc);
}
}
@@ -91,7 +91,8 @@ namespace CloudBuilder.Master.Service
//暂时不支持转码
DmsFileEntity dmsFileEntity = repositoryDmsFile.DetachedEntities.Where(x => x.OrderKey == item.Url).FirstOrDefault();
item.FormattedPic = FileTypePolicy.PackPicUrl(service, item.CreatedBy, item.Url);
item.FormattedUrl = FileTypePolicy.PackUrl(service, item.CreatedBy, item.Url, dmsFileEntity.TranscodeIndc);
item.FormattedUrl = FileTypePolicy.PackUrl(service, item.CreatedBy, item.Url);
item.FormattedM3u8 = FileTypePolicy.PackM3u8Url(service, item.CreatedBy, item.Url);
}
}
}
@@ -33,5 +33,6 @@ namespace CloudBuilder.Master.Data
public string? IsPause { get; set; }
public string? FormattedPic { get; set; }
public string? FormattedM3u8 { get; set; }
}
}
@@ -32,5 +32,7 @@ namespace CloudBuilder.Master.Data
public string? IsPause { get; set; }
public string? FormattedPic { get; set; }
public string? FormattedM3u8 { get; set; }
}
}
@@ -34,5 +34,6 @@ namespace CloudBuilder.Master.Data
public string? IsPause { get; set; }
public string? FormattedPic { get; set; }
public string? FormattedM3u8 { get; set; }
}
}
@@ -25,5 +25,7 @@ namespace CloudBuilder.Master.Data
public string? FormattedUrl { get; set; }
public string? FormattedPic { get; set; }
public string? FormattedM3u8 { get; set; }
}
}
@@ -78,14 +78,18 @@ namespace CloudBuilder.Security.Policy
{
return GetType(value) == LYRIC;
}
public static string PackM3u8Url(IApplicationService service, string createdBy, string orderKey)
{
if (!IsVideo(orderKey)) return null;
return PackUrl(service, createdBy, orderKey, YesNoPolicy.YES, false);
}
public static string PackPicUrl(IApplicationService service, string createdBy, string orderKey)
{
return PackUrl(service, createdBy, orderKey, YesNoPolicy.YES, true);
}
public static string PackUrl(IApplicationService service, string createdBy, string orderKey, string transcodeIndc)
public static string PackUrl(IApplicationService service, string createdBy, string orderKey)
{
return PackUrl(service, createdBy, orderKey, transcodeIndc, false);
return PackUrl(service, createdBy, orderKey, YesNoPolicy.NO, false);
}
public static string PackUrl(IApplicationService service, string createdBy, string orderKey, string transcodeIndc, bool isPic)
@@ -17,6 +17,8 @@
<template #closeIcon>
<slot name="closeIcon">
<Space class="ant-modal-operate" @click.stop>
<!-- 可选插槽:放在「全屏」左边,供调用方追加自己的按钮。不传则什么都不渲染 -->
<slot name="operate" />
<FullscreenOutlined v-if="!fullscreenModel" @click="fullscreenModel = true" />
<FullscreenExitOutlined v-else @click="restore" />
<CloseOutlined @click="closeModal" />
+58 -1
View File
@@ -1,14 +1,26 @@
<template>
<dragModal v-model:open="visible" :width="width" :title="title" :footer="null" :mask-closable="false"
:fullscreen="fullscreen" wrap-class-name="vpm-wrap">
<!-- 弹窗右上角追加「页面全屏」按钮,位置在 DragModal 自带的「全屏」左边。
它的作用是把底部控制区隐藏、让画面占满整个弹窗,再点一次恢复 -->
<template #operate>
<span class="vpm-page-full" :class="{ 'is-on': !controlsVisible }"
:title="controlsVisible ? '页面全屏(隐藏控制区)' : '退出页面全屏(显示控制区)'"
@click.stop="controlsVisible = !controlsVisible">
<EyeOutlined v-if="!controlsVisible" />
<EyeInvisibleOutlined v-else />
</span>
</template>
<div class="vpm-body">
<VideoPlayer :videos="videos" :start-index="startIndex" :auto-play="true" />
<VideoPlayer v-model:controls="controlsVisible" :videos="videos" :start-index="startIndex" :auto-play="true" />
</div>
</dragModal>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { useVModel } from "@vueuse/core";
import { EyeInvisibleOutlined, EyeOutlined } from "@ant-design/icons-vue";
import dragModal from "@/components/basic/DragModal.vue";
import VideoPlayer from "@/components/basic/VideoPlayer.vue";
@@ -37,6 +49,13 @@ const props = withDefaults(
const emit = defineEmits(["update:open", "close"]);
const visible = useVModel(props, "open", emit);
/**
* 底部控制区显隐(右上角「页面全屏」按钮):
* 隐藏后播放器的画面区会吃掉控制区占的高度,画面铺满弹窗;再点一次恢复。
* 单击画面区、播放器控制栏里的眼睛图标也是同一个动作,状态互通。
*/
const controlsVisible = ref(true);
</script>
<style scoped>
@@ -45,6 +64,32 @@ const visible = useVModel(props, "open", emit);
height: 100%;
min-height: 240px;
}
/* 右上角的「页面全屏」图标。
DragModal 的关闭按钮(.ant-modal-close)宽度固定(close-x 50px),
把这个图标塞进它的图标排布里会把「全屏」「关闭」挤变形甚至挤出边界,
所以用绝对定位挂到关闭按钮左侧:不占它的布局空间。
尺寸/垂直对齐按关闭按钮的盒子(高 50px、图标 16px)写死,保证和旁边两个一致 */
.vpm-page-full {
position: absolute;
top: 0;
right: calc(100% + 4px);
width: 32px;
height: 50px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 1;
cursor: pointer;
margin-right: -10px;
margin-top: 5px;
}
/* 已经进入页面全屏(控制区隐藏)时高亮,和播放器里的图标状态保持一致 */
.vpm-page-full.is-on {
color: #13c2c2;
}
</style>
<style>
@@ -62,4 +107,16 @@ const visible = useVModel(props, "open", emit);
height: auto;
overflow: hidden;
}
/* 文件名可能很长:单行显示,超出用 ... 截断,不再折行把标题栏撑高。
右侧留出「页面全屏 + 全屏 + 关闭」三个按钮的宽度,避免长标题被按钮压住 */
.ant-modal-wrap.vpm-wrap .ant-modal .ant-modal-header {
padding-right: 128px;
}
.ant-modal-wrap.vpm-wrap .ant-modal .ant-modal-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>
+103 -39
View File
@@ -7,6 +7,7 @@
</div>
<!-- 画面区:原生 video 只负责解码播放,控制栏全部自绘(与 VinylAudioPlayer 一致) -->
<!-- 单击画面区 = 播放/暂停(收起控制区改用「页面全屏」/眼睛按钮) -->
<div v-else class="vp-stage" ref="stageRef" @click="toggle">
<!-- 不绑 :src:m3u8 要交给 hls.js 解码,播放源统一由 attachSource 挂接 -->
<video ref="videoRef" class="vp-media" :class="{ 'is-rot': rotated }" :style="rotStyle"
@@ -25,13 +26,15 @@
</button>
</div>
<!-- 控制区 -->
<div v-if="current" class="vp-body">
<!-- 控制区:单击画面区可隐藏,隐藏后画面区吃掉这部分空间 -->
<div v-if="current && controlsVisible" class="vp-body">
<!-- 功能图标行 -->
<div class="vp-actions">
<button class="vp-act" :class="{ 'is-fav': isFaved }" type="button" title="喜欢" @click="onFav">
<HeartFilled v-if="isFaved" />
<HeartOutlined v-else />
<!-- 原码/转码切换:默认播转码(FormattedM3u8),点这里切到原码(Url) -->
<button class="vp-act" :class="{ 'is-on': useOriginal }" type="button" :title="sourceTitle"
@click="toggleSource">
<VideoCameraOutlined />
<span class="vp-badge" :class="{ 'is-on': useOriginal }">{{ useOriginal ? "原码" : "转码" }}</span>
</button>
<button class="vp-act" type="button" title="下载" @click="onDownload">
<DownloadOutlined />
@@ -41,7 +44,13 @@
<FullscreenExitOutlined v-if="isFullscreen" />
<FullscreenOutlined v-else />
</button>
<button class="vp-act" :class="{ 'is-on': rotated }" type="button" :title="rotTitle"
<!-- 隐藏/显示控制区:收起后画面区吃掉这部分空间(弹窗右上角的「页面全屏」也是同一个动作) -->
<button class="vp-act" :class="{ 'is-on': !controlsVisible }" type="button"
:title="controlsVisible ? '隐藏控制区' : '显示控制区'" @click="toggleControls">
<EyeInvisibleOutlined v-if="controlsVisible" />
<EyeOutlined v-else />
</button>
<button class="vp-act" :class="{ 'is-on': rotated }" type="button" title="横竖屏切换"
@click="cycleRot">
<RotateRightOutlined />
<span v-if="rotMode === 'auto'" class="vp-rot-auto">A</span>
@@ -147,16 +156,18 @@
<script lang="ts" setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import { useVModel } from "@vueuse/core";
import { message } from "ant-design-vue";
import Hls from "hls.js";
import { request } from "@/utils/request";
import type { PublishVideoDetailDisplayData } from "@/data/auto/publishVideoDetailDisplayData";
import {
HeartOutlined, HeartFilled, DownloadOutlined, FieldTimeOutlined,
DownloadOutlined, FieldTimeOutlined,
AudioOutlined, AudioMutedOutlined, EllipsisOutlined, RotateRightOutlined,
RetweetOutlined, SwapOutlined, StepBackwardFilled, StepForwardFilled,
CaretRightFilled, PauseOutlined, UnorderedListOutlined, CloseOutlined,
FullscreenOutlined, FullscreenExitOutlined, VideoCameraOutlined,
EyeOutlined, EyeInvisibleOutlined,
} from "@ant-design/icons-vue";
/** 一个视频 */
@@ -169,10 +180,14 @@ const props = withDefaults(
startIndex?: number;
/** 挂上播放源后是否立即播放(弹窗里默认自动播) */
autoPlay?: boolean;
/** 底部控制区是否显示,可用 v-model:controls 从外部控制(单击画面区也会切换) */
controls?: boolean;
}>(),
{ videos: () => [], startIndex: 0, autoPlay: false },
{ videos: () => [], startIndex: 0, autoPlay: false, controls: true },
);
const emit = defineEmits<{ (e: "update:controls", value: boolean): void }>();
/* ---------------- 基础状态 ---------------- */
const videoRef = ref<HTMLVideoElement | null>(null);
const index = ref(props.startIndex);
@@ -184,6 +199,15 @@ const muted = ref(false);
const rate = ref(1);
const mode = ref<"list" | "one" | "random">("list");
const showList = ref(false);
/**
* 底部控制区是否显示:由控制栏的眼睛图标 / 弹窗右上角的「页面全屏」按钮切换。
*
* passive: true 是关键:不传的话 useVModel 直接读写 props,
* 父组件没绑 v-model:controls 时(例如影视厅 PreviewVideo 页面)
* 内部改了值也只会 emit 出去、props 不变,按钮就"点了没反应"。
* passive 会保留本地状态,父组件绑了 v-model 时依然双向同步。
*/
const controlsVisible = useVModel(props, "controls", emit, { passive: true });
const menu = ref<"" | "timer" | "volume" | "more">("");
const downloaded = ref(false);
/** 是否正在缓冲:数据不够时会卡住画面,必须让用户看得见 */
@@ -280,6 +304,15 @@ const toggle = () => {
if (v.paused) tryPlay();
else v.pause();
};
/**
* 切换底部控制区(功能图标行/进度条/主控制)的显示。
* 收起后 .vp-stage 的 flex:1 会吃掉这部分高度,画面最大化。
* 入口:控制栏里的眼睛图标、弹窗右上角的「页面全屏」按钮。
*/
const toggleControls = () => {
menu.value = ""; // 顺手收起可能打开的弹层(定时/音量/倍速)
controlsVisible.value = !controlsVisible.value;
};
const playAt = (i: number) => {
if (i === index.value) {
toggle();
@@ -320,6 +353,11 @@ const onLoadedMetadata = () => {
const v = videoRef.value;
if (!v) return;
if (isFinite(v.duration)) duration.value = v.duration;
// 切换原码/转码后恢复播放位置
if (pendingSeek != null) {
v.currentTime = pendingSeek;
pendingSeek = null;
}
// 自动识别横竖屏:宽 > 高 视为横屏,auto 模式下旋转90°播放
landscape.value = v.videoWidth > v.videoHeight;
};
@@ -358,7 +396,8 @@ const clearStallTimer = () => {
* 第 2 个用 ffmpeg 重封装(不重新编码)即可解决,几乎不吃 CPU。
*/
const diagnoseStall = async () => {
const url = current.value?.FormattedUrl;
// 诊断当前真正在播的那个源(转码或原码),不是写死 FormattedUrl
const url = sourceUrl.value;
if (!url || isHlsSource(url)) return;
try {
const res = await fetch(url, { headers: { Range: "bytes=0-1" }, credentials: "include" });
@@ -487,39 +526,60 @@ const onSleep = (v: number) => {
menu.value = "";
};
/* ---------------- 喜欢 / 下载 / 信息 ---------------- */
const FAV_KEY = "vp-favs";
const loadFavs = (): Set<string> => {
try {
return new Set<string>(JSON.parse(localStorage.getItem(FAV_KEY) || "[]"));
} catch {
return new Set<string>();
}
};
const favs = ref<Set<string>>(loadFavs());
const isFaved = computed(() => !!current.value && favs.value.has(current.value.FormattedUrl));
const onFav = () => {
/* ---------------- 原码 / 转码切换 ---------------- */
/** false = 播转码(FormattedM3u8,10 秒预览,默认);true = 播原码(Url) */
const useOriginal = ref(false);
const sourceTitle = computed(() =>
useOriginal.value ? "正在播放原码,点击切回转码" : "正在播放转码,点击切换原码"
);
/** 只有像地址(http(s)://、//、/)的才拿去播,避免把 OrderKey 这类文件名当 URL 请求 */
const isPlayableUrl = (u?: string) => !!u && /^(https?:)?\/\//i.test(u);
/** 当前应使用的播放地址:默认转码;切原码但该视频没有可用原码时回退转码 */
const sourceUrl = computed(() => {
const t = current.value;
if (!t) return "";
if (useOriginal.value && isPlayableUrl(t.Url)) return t.Url;
return t.FormattedM3u8 || t.FormattedUrl || "";
});
/** 切换播放源后要恢复到的播放位置(秒) */
let pendingSeek: number | null = null;
const toggleSource = () => {
const v = videoRef.value;
if (!current.value) return;
const s = new Set(favs.value);
if (s.has(current.value.FormattedUrl)) s.delete(current.value.FormattedUrl);
else s.add(current.value.FormattedUrl);
favs.value = s;
localStorage.setItem(FAV_KEY, JSON.stringify([...s]));
if (useOriginal.value && !current.value.Url) {
message.info("该视频没有原码");
return;
}
// 记住播放位置,新源的元数据加载完后再跳回去(见 onLoadedMetadata)
pendingSeek = v?.currentTime ?? 0;
const wasPlaying = isPlaying.value;
useOriginal.value = !useOriginal.value;
// 重新挂接播放源:m3u8 走 hls.js,原码 mp4 走 src(内部会销毁上一个 hls 实例)
attachSource(sourceUrl.value);
if (wasPlaying) tryPlay();
};
const onDownload = async () => {
if (!current.value) return;
// 优先下载原码;m3u8 是切片清单,下载下来没法直接看
const downloadUrl = current.value.Url || current.value.FormattedUrl || sourceUrl.value;
// 参考 DmsFileEnquiry.vue:先取回字节流,再用 Blob + <a download> 落盘
try {
const res = await request<ArrayBuffer>({
url: current.value.FormattedUrl,
url: downloadUrl,
method: "get",
responseType: "arraybuffer",
});
const blobUrl = window.URL.createObjectURL(new Blob([res]));
const ext = (current.value.FormattedUrl.match(/\.(\w{2,5})(?:\?|#|$)/) || [])[1];
const ext = (downloadUrl.match(/\.(\w{2,5})(?:\?|#|$)/) || [])[1];
// Title 通常是原始文件名、自带扩展名,先把已有扩展名剥掉再拼,
// 否则会得到「xxx.mp4.mp4」这样的双后缀
const base = (current.value.Title || "video").replace(/\.[^.]+$/, "");
const a = document.createElement("a");
a.href = blobUrl;
a.download = `${current.value.Title || "video"}.${ext || "mp4"}`;
a.download = ext ? `${base}.${ext}` : base;
document.body.appendChild(a);
a.click();
a.remove();
@@ -537,13 +597,12 @@ const landscape = ref(false);
const rotated = computed(
() => rotMode.value === "on" ? true : rotMode.value === "off" ? false : landscape.value
);
const rotTitle = computed(() => ({
auto: "自动旋转(横屏视频转90°)",
on: "强制旋转90°",
off: "关闭旋转",
}[rotMode.value]));
const cycleRot = () => {
rotMode.value = rotMode.value === "auto" ? "on" : rotMode.value === "on" ? "off" : "auto";
// 单击立即翻转画面方向。
// 之前是 auto→on→off 轮转,但横屏视频在 auto 和 on 下画面完全相同,
// 第一次点击看不出任何变化,用户以为失灵、要按两次才生效。
// 这里直接按「当前画面是否已旋转」取反:转着 → 关,没转 → 转。
rotMode.value = rotated.value ? "off" : "on";
};
/** 旋转后 video 宽高对调,横屏视频即可铺满竖版画面区 */
const stageRef = ref<HTMLElement | null>(null);
@@ -573,14 +632,16 @@ const onFullscreen = async () => {
const v = videoRef.value;
if (!v) return;
try {
if (document.fullscreenElement) await document.exitFullscreen();
// 只在「video 元素自己在全屏」时才退出;页面全屏(documentElement)时直接切到 video 全屏
if (document.fullscreenElement === v) await document.exitFullscreen();
else await v.requestFullscreen();
} catch {
message.error("当前浏览器不支持全屏");
}
};
const onFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement;
// 只认 video 元素自己的全屏,否则弹窗的「页面全屏」会让这里的图标状态错乱
isFullscreen.value = document.fullscreenElement === videoRef.value;
};
/* ---------------- 弹层开合 ---------------- */
@@ -608,8 +669,9 @@ const prepare = async (t: VideoItem | null, play: boolean) => {
if (!t) return;
await nextTick();
ensureStageRO();
// 重新挂接播放源:m3u8 走 hls.js,普通文件走 src(内部会销毁上一个 hls 实例)
attachSource(t.FormattedUrl);
// 重新挂接播放源:m3u8 走 hls.js,原码 mp4 走 src(内部会销毁上一个 hls 实例)
// 播放源按「转码优先」取:默认 FormattedM3u8,切了原码用 Url
attachSource(sourceUrl.value);
if (play || props.autoPlay) {
resumeAfterSwitch = false;
tryPlay();
@@ -799,6 +861,8 @@ onBeforeUnmount(() => {
cursor: pointer;
}
/* ---------- 功能图标行 ---------- */
.vp-actions {
position: relative;
@@ -82,6 +82,7 @@ import { Howl } from "howler";
import { PublishVideoEntity } from "@/data/auto/publishVideoEntity";
import publishVideoService from "@/service/master/publishVideoService";
import { useWindowSize } from "@vueuse/core";
import { request } from "@/utils/request";
import fileTypePolicy from "@/policy/custom/fileTypePolicy";
import { PublishAudioEntity } from "@/data/auto/publishAudioEntity";
import { FamilyFolderFavoriteViewEntity } from "@/data/auto/familyFolderFavoriteViewEntity";
@@ -292,18 +293,20 @@ const onChangeFolder = async (fvalue: string) => {
onSearchByFilePath(fvalue);
};
/** DMS 记录 → 播放列表项(m3u8 的封面用同名 png 兜底) */
/** DMS 记录 → 播放列表项:Url=原码、FormattedM3u8=转码(10秒),封面用同名 png 兜底 */
const toVideoItem = (
record: DmsFileUploadDeleteLogViewDisplayData,
): VideoItem => {
const item = new PublishVideoDetailDisplayData();
item.Title = record.OrgFileName;
item.FormattedUrl = record.Url;
item.Url = record.Url;
item.FormattedM3u8 = record.FormattedM3u8;
item.FormattedUrl = record.FormattedM3u8 || record.Url;
item.FormattedPic = (record.FormattedPic || "").replace(/\.m3u8$/, ".png");
return item;
};
/** 当前目录里已转码(有 Url)的视频,作为弹窗播放器的播放列表 */
/** 当前目录里的视频,作为弹窗播放器的播放列表 */
const buildVideoList = (
records: DmsFileUploadDeleteLogViewDisplayData[],
): VideoItem[] =>
@@ -312,7 +315,7 @@ const buildVideoList = (
(x) =>
x.IsDirectoryIndc != "Y" &&
fileTypePolicy.isVideo(x.OrderKey || x.OrgFileName) &&
!!x.Url,
!!(x.FormattedM3u8 || x.Url),
)
.map(toVideoItem);
@@ -328,27 +331,6 @@ const openVideoPlay = (list: VideoItem[], index: number, title: string) => {
videoPlayModal.value = true;
};
/** 未转码的视频没有 Url,直接取源片路径播放 */
const onSourceVideoPlay = async (
record: DmsFileUploadDeleteLogViewDisplayData,
) => {
try {
loading.value = true;
const url = await dmsFileService.FindDeleteLogM3u8Path(record.FileId);
const item = toVideoItem(record);
item.FormattedUrl = url;
openVideoPlay([item], 0, record.OrgFileName);
} catch (error) {
notification.info({
message: "系统提示:",
description: "视频加载失败",
placement: "topRight",
});
} finally {
loading.value = false;
}
};
const onPreviewFile = async (
usernameOrRecord: any,
orderKey?: string,
@@ -483,8 +465,57 @@ const onShareFamily = async (record: Record<string, any>) => {
showModalShare.value = true;
};
const onDownloadFile = async (fid: number, fname: string) => {
GetOrgPictureFile(fid, fname, false);
/** 按 URL 直接下载(视频走这条:Url 就是原码文件) */
const downloadByUrl = async (url: string, fileName: string) => {
if (!url) {
notification.info({
message: "系统提示:",
description: "该文件没有可下载的地址",
placement: "topRight",
});
return;
}
try {
loading.value = true;
spinTip.value = "下载中...";
// 与 VideoPlayer 一致:先取回字节流,再用 Blob + <a download> 落盘
const res = await request<ArrayBuffer>({
url,
method: "get",
responseType: "arraybuffer",
});
const blobUrl = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement("a");
link.href = blobUrl;
link.setAttribute("download", fileName);
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
} catch (error) {
notification.info({
message: "系统提示:",
description: "下载失败",
placement: "topRight",
});
} finally {
loading.value = false;
spinTip.value = "加载中...";
}
};
/** 下载一条记录:视频下原码,其它文件走原来的分片合并下载 */
const onDownloadFile = async (
record: DmsFileUploadDeleteLogViewDisplayData,
) => {
if (
fileTypePolicy.isVideo(record.OrgFileName) ||
fileTypePolicy.isVideo(record.OrderKey)
) {
await downloadByUrl(record.Url, record.OrgFileName);
return;
}
GetOrgPictureFile(record.FileId, record.OrgFileName, false);
};
const GetOrgPictureFile = async (
@@ -975,16 +1006,19 @@ const onOpenFile = (record: DmsFileUploadDeleteLogViewDisplayData) => {
} else if (fileTypePolicy.isAudio(record.OrgFileName)) {
onPlayAudio(toUploadView(record));
} else {
onDownloadFile(record.FileId, record.OrgFileName);
onDownloadFile(record);
}
};
const onDownloadSelected = (items: DmsFileUploadDeleteLogViewDisplayData[]) => {
items.forEach((item) => {
const onDownloadSelected = async (
items: DmsFileUploadDeleteLogViewDisplayData[],
) => {
// 逐个串行下载:并发触发多个 <a download> 浏览器会拦截后面的
for (const item of items) {
if (item.IsDirectoryIndc != "Y") {
onDownloadFile(item.FileId, item.OrgFileName);
await onDownloadFile(item);
}
});
}
};
const onShareSelected = (items: DmsFileUploadDeleteLogViewDisplayData[]) => {
@@ -1018,15 +1052,9 @@ const onViewPreviewSelected = (items: DmsFileUploadDeleteLogViewDisplayData[]) =
};
/** 播放视频:把当前目录的视频串成播放列表,从点中的那一个开始 */
const onUrlVideoPlayItem = async (
record: DmsFileUploadDeleteLogViewDisplayData,
) => {
if (!record.Url) {
await onSourceVideoPlay(record);
return;
}
const onUrlVideoPlayItem = (record: DmsFileUploadDeleteLogViewDisplayData) => {
const list = buildVideoList(datas.value);
let index = list.findIndex((x) => x.FormattedUrl === record.Url);
let index = list.findIndex((x) => x.Url === record.Url);
if (index < 0) {
list.unshift(toVideoItem(record));
index = 0;
+62 -36
View File
@@ -88,6 +88,7 @@ import { Howl } from "howler";
import { PublishVideoEntity } from "@/data/auto/publishVideoEntity";
import publishVideoService from "@/service/master/publishVideoService";
import { useWindowSize } from "@vueuse/core";
import { request } from "@/utils/request";
import fileTypePolicy from "@/policy/custom/fileTypePolicy";
import { PublishAudioEntity } from "@/data/auto/publishAudioEntity";
import { FamilyFolderFavoriteViewEntity } from "@/data/auto/familyFolderFavoriteViewEntity";
@@ -462,16 +463,18 @@ const onChangeFolder = async (fvalue: string) => {
onSearchByFilePath(fvalue);
};
/** DMS 记录 → 播放列表项(m3u8 的封面用同名 png 兜底) */
/** DMS 记录 → 播放列表项:Url=原码、FormattedM3u8=转码(10秒),封面用同名 png 兜底 */
const toVideoItem = (record: DmsFileUploadViewDisplayData): VideoItem => {
const item = new PublishVideoDetailDisplayData();
item.Title = record.OrgFileName;
item.FormattedUrl = record.Url;
item.Url = record.Url;
item.FormattedM3u8 = record.FormattedM3u8;
item.FormattedUrl = record.FormattedM3u8 || record.Url;
item.FormattedPic = (record.FormattedPic || "").replace(/\.m3u8$/, ".png");
return item;
};
/** 当前目录里已转码(有 Url)的视频,作为弹窗播放器的播放列表 */
/** 当前目录里的视频,作为弹窗播放器的播放列表 */
const buildVideoList = (
records: DmsFileUploadViewDisplayData[],
): VideoItem[] =>
@@ -480,7 +483,7 @@ const buildVideoList = (
(x) =>
x.IsDirectoryIndc != "Y" &&
fileTypePolicy.isVideo(x.OrderKey || x.OrgFileName) &&
!!x.Url,
!!(x.FormattedM3u8 || x.Url),
)
.map(toVideoItem);
@@ -496,25 +499,6 @@ const openVideoPlay = (list: VideoItem[], index: number, title: string) => {
videoPlayModal.value = true;
};
/** 未转码的视频没有 Url,直接取源片路径播放 */
const onSourceVideoPlay = async (record: DmsFileUploadViewDisplayData) => {
try {
loading.value = true;
const url = await dmsFileService.FindSourceVideoPath(record.FileId);
const item = toVideoItem(record);
item.FormattedUrl = url;
openVideoPlay([item], 0, record.OrgFileName);
} catch (error) {
notification.info({
message: "系统提示:",
description: "视频加载失败",
placement: "topRight",
});
} finally {
loading.value = false;
}
};
const onPreviewFile = async (usernameOrRecord: any, orderKey?: string) => {
let username: string;
let fname: string;
@@ -643,8 +627,53 @@ const onShareFamily = async (records: DmsFileUploadViewDisplayData[]) => {
showModalShare.value = true;
};
const onDownloadFile = async (fid: number, fname: string) => {
GetOrgPictureFile(fid, fname, false);
/** 按 URL 直接下载(视频走这条:Url 就是原码文件) */
const downloadByUrl = async (url: string, fileName: string) => {
if (!url) {
notification.info({
message: "系统提示:",
description: "该文件没有可下载的地址",
placement: "topRight",
});
return;
}
try {
loading.value = true;
spinTip.value = "下载中...";
// 与 VideoPlayer 一致:先取回字节流,再用 Blob + <a download> 落盘
const res = await request<ArrayBuffer>({
url,
method: "get",
responseType: "arraybuffer",
});
const blobUrl = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement("a");
link.href = blobUrl;
link.setAttribute("download", fileName);
document.body.appendChild(link);
link.click();
link.remove();
// 立刻 revoke 部分浏览器会来不及触发下载,延后释放
setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
} catch (error) {
notification.info({
message: "系统提示:",
description: "下载失败",
placement: "topRight",
});
} finally {
loading.value = false;
spinTip.value = "加载中...";
}
};
/** 下载一条记录:视频下原码,其它文件走原来的分片合并下载 */
const onDownloadFile = async (record: DmsFileUploadViewDisplayData) => {
if (fileTypePolicy.isVideo(record.OrgFileName) || fileTypePolicy.isVideo(record.OrderKey)) {
await downloadByUrl(record.Url, record.OrgFileName);
return;
}
GetOrgPictureFile(record.FileId, record.OrgFileName, false);
};
const GetOrgPictureFile = async (
@@ -1164,16 +1193,17 @@ const onOpenFile = (record: DmsFileUploadViewDisplayData) => {
} else if (fileTypePolicy.isAudio(record.OrgFileName)) {
onPlayAudio(toUploadView(record));
} else {
onDownloadFile(record.FileId, record.OrgFileName);
onDownloadFile(record);
}
};
const onDownloadSelected = (items: DmsFileUploadViewDisplayData[]) => {
items.forEach((item) => {
const onDownloadSelected = async (items: DmsFileUploadViewDisplayData[]) => {
// 逐个串行下载:并发触发多个 <a download> 浏览器会拦截后面的
for (const item of items) {
if (item.IsDirectoryIndc != "Y") {
onDownloadFile(item.FileId, item.OrgFileName);
await onDownloadFile(item);
}
});
}
};
const onShareSelected = (items: DmsFileUploadViewDisplayData[]) => {
@@ -1231,13 +1261,9 @@ const onViewPreviewSelected = (items: DmsFileUploadViewDisplayData[]) => {
};
/** 播放视频:把当前目录的视频串成播放列表,从点中的那一个开始 */
const onUrlVideoPlayItem = async (record: DmsFileUploadViewDisplayData) => {
if (!record.Url) {
await onSourceVideoPlay(record);
return;
}
const onUrlVideoPlayItem = (record: DmsFileUploadViewDisplayData) => {
const list = buildVideoList(datas.value);
let index = list.findIndex((x) => x.FormattedUrl === record.Url);
let index = list.findIndex((x) => x.Url === record.Url);
if (index < 0) {
list.unshift(toVideoItem(record));
index = 0;
+64 -36
View File
@@ -74,6 +74,7 @@ import { Howl } from "howler";
import { PublishVideoEntity } from "@/data/auto/publishVideoEntity";
import publishVideoService from "@/service/master/publishVideoService";
import { useWindowSize } from "@vueuse/core";
import { request } from "@/utils/request";
import fileTypePolicy from "@/policy/custom/fileTypePolicy";
import { PublishAudioEntity } from "@/data/auto/publishAudioEntity";
import { FamilyFolderFavoriteViewEntity } from "@/data/auto/familyFolderFavoriteViewEntity";
@@ -292,16 +293,18 @@ const onChangeFolder = async (fvalue: string) => {
onSearchByFilePath(fvalue);
};
/** DMS 记录 → 播放列表项(m3u8 的封面用同名 png 兜底) */
/** DMS 记录 → 播放列表项:Url=原码、FormattedM3u8=转码(10秒),封面用同名 png 兜底 */
const toVideoItem = (record: FamilyDmsFileViewDisplayData): VideoItem => {
const item = new PublishVideoDetailDisplayData();
item.Title = record.OrgFileName;
item.FormattedUrl = record.Url;
item.Url = record.Url;
item.FormattedM3u8 = record.FormattedM3u8;
item.FormattedUrl = record.FormattedM3u8 || record.Url;
item.FormattedPic = (record.FormattedPic || "").replace(/\.m3u8$/, ".png");
return item;
};
/** 当前目录里已转码(有 Url)的视频,作为弹窗播放器的播放列表 */
/** 当前目录里的视频,作为弹窗播放器的播放列表 */
const buildVideoList = (
records: FamilyDmsFileViewDisplayData[],
): VideoItem[] =>
@@ -310,7 +313,7 @@ const buildVideoList = (
(x) =>
x.IsDirectoryIndc != "Y" &&
fileTypePolicy.isVideo(x.OrderKey || x.OrgFileName) &&
!!x.Url,
!!(x.FormattedM3u8 || x.Url),
)
.map(toVideoItem);
@@ -326,25 +329,6 @@ const openVideoPlay = (list: VideoItem[], index: number, title: string) => {
videoPlayModal.value = true;
};
/** 未转码的视频没有 Url,直接取源片路径播放 */
const onSourceVideoPlay = async (record: FamilyDmsFileViewDisplayData) => {
try {
loading.value = true;
const url = await dmsFileService.FindSourceVideoPath(record.FileId);
const item = toVideoItem(record);
item.FormattedUrl = url;
openVideoPlay([item], 0, record.OrgFileName);
} catch (error) {
notification.info({
message: "系统提示:",
description: "视频加载失败",
placement: "topRight",
});
} finally {
loading.value = false;
}
};
const onPreviewFile = async (usernameOrRecord: any, orderKey?: string) => {
let username: string;
let fname: string;
@@ -473,8 +457,55 @@ const onShareFamily = async (record: Record<string, any>) => {
showModalShare.value = true;
};
const onDownloadFile = async (fid: number, fname: string) => {
GetOrgPictureFile(fid, fname, false);
/** 按 URL 直接下载(视频走这条:Url 就是原码文件) */
const downloadByUrl = async (url: string, fileName: string) => {
if (!url) {
notification.info({
message: "系统提示:",
description: "该文件没有可下载的地址",
placement: "topRight",
});
return;
}
try {
loading.value = true;
spinTip.value = "下载中...";
// 与 VideoPlayer 一致:先取回字节流,再用 Blob + <a download> 落盘
const res = await request<ArrayBuffer>({
url,
method: "get",
responseType: "arraybuffer",
});
const blobUrl = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement("a");
link.href = blobUrl;
link.setAttribute("download", fileName);
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
} catch (error) {
notification.info({
message: "系统提示:",
description: "下载失败",
placement: "topRight",
});
} finally {
loading.value = false;
spinTip.value = "加载中...";
}
};
/** 下载一条记录:视频下原码,其它文件走原来的分片合并下载 */
const onDownloadFile = async (record: FamilyDmsFileViewDisplayData) => {
if (
fileTypePolicy.isVideo(record.OrgFileName) ||
fileTypePolicy.isVideo(record.OrderKey)
) {
await downloadByUrl(record.Url, record.OrgFileName);
return;
}
GetOrgPictureFile(record.FileId, record.OrgFileName, false);
};
const GetOrgPictureFile = async (
@@ -1076,16 +1107,17 @@ const onOpenFile = (record: FamilyDmsFileViewDisplayData) => {
} else if (fileTypePolicy.isAudio(record.OrgFileName)) {
onPlayAudio(toUploadView(record));
} else {
onDownloadFile(record.FileId, record.OrgFileName);
onDownloadFile(record);
}
};
const onDownloadSelected = (items: FamilyDmsFileViewDisplayData[]) => {
items.forEach((item) => {
const onDownloadSelected = async (items: FamilyDmsFileViewDisplayData[]) => {
// 逐个串行下载:并发触发多个 <a download> 浏览器会拦截后面的
for (const item of items) {
if (item.IsDirectoryIndc != "Y") {
onDownloadFile(item.FileId, item.OrgFileName);
await onDownloadFile(item);
}
});
}
};
const onShareSelected = (items: FamilyDmsFileViewDisplayData[]) => {
@@ -1137,13 +1169,9 @@ const onViewPreviewSelected = (items: FamilyDmsFileViewDisplayData[]) => {
};
/** 播放视频:把当前目录的视频串成播放列表,从点中的那一个开始 */
const onUrlVideoPlayItem = async (record: FamilyDmsFileViewDisplayData) => {
if (!record.Url) {
await onSourceVideoPlay(record);
return;
}
const onUrlVideoPlayItem = (record: FamilyDmsFileViewDisplayData) => {
const list = buildVideoList(datas.value);
let index = list.findIndex((x) => x.FormattedUrl === record.Url);
let index = list.findIndex((x) => x.Url === record.Url);
if (index < 0) {
list.unshift(toVideoItem(record));
index = 0;
+20 -3
View File
@@ -36,6 +36,21 @@ onMounted(async () => {
await loadVideos();
});
/**
* 发布专辑详情(FindPublishVideoDefault / FindByKey)里,Url 存的是
* OrderKey(形如 xxxx-3-3.mp4 的文件名),不是可播放地址;
* 真正的原码地址在 FormattedUrl 上(后端 3 参 PackUrl 现在固定返回原码)。
* 这里把 Url 补成地址,否则播放器切「原码」时会拿文件名当地址请求、直接 404。
* 网盘列表模式(mode=list)的 Url 本身就是完整地址,不受影响。
*/
const normalizeDetails = (details: PublishVideoDetailDisplayData[]) =>
(details ?? []).map((d) => {
if (d.Url && !/^(https?:)?\/\//i.test(d.Url)) {
d.Url = d.FormattedUrl || "";
}
return d;
});
const loadVideos = async () => {
try {
// 模式一:从网盘中心加载某个路径下的所有视频
@@ -65,7 +80,9 @@ const loadVideos = async () => {
for (const item of dr.Datas) {
const detail = new PublishVideoDetailDisplayData();
detail.Title = item.OrgFileName;
detail.FormattedUrl = item.Url;
detail.Url = item.Url; // 原码
detail.FormattedM3u8 = item.FormattedM3u8; // 转码(10秒)
detail.FormattedUrl = item.FormattedM3u8 || item.Url;
// m3u8 通常配套同名 jpg/png 封面,这里把后缀替换成 .png 兜底
detail.FormattedPic = (item.FormattedPic || "").replace(/\.m3u8$/, ".png");
videos.value.push(detail);
@@ -75,12 +92,12 @@ const loadVideos = async () => {
// 默认发布视频
else if (!publishName.value || publishName.value.length === 0) {
const data: PublishVideoData = await publishVideoService.FindPublishVideoDefault();
videos.value = data?.Details ?? [];
videos.value = normalizeDetails(data?.Details ?? []);
}
// 按 key 加载
else {
const data: PublishVideoData = await publishVideoService.FindByKey(publishName.value);
videos.value = data?.Details ?? [];
videos.value = normalizeDetails(data?.Details ?? []);
}
} catch (error) {
console.log(error);