This commit is contained in:
itcsx committed 2026-09-20 12:25:37 +08:00
1 parent 4f57b65451
commit eae4296980
6 files changed
+722 -259

No files matched your search

+247 -38
View File
@@ -75,6 +75,10 @@ const props = withDefaults(
chunkSize?: number;
/** 请求超时时间(毫秒) */
timeout?: number;
/** 单片失败后的自动重试次数,0 表示不重试 */
retryCount?: number;
/** 重试的初始等待毫秒数,之后按 1x、2x、4x 递增 */
retryDelay?: number;
}>(),
{
action: "",
@@ -87,6 +91,8 @@ const props = withDefaults(
buttonText: "上传文件",
chunkSize: 1024 * 1024,
timeout: 60000,
retryCount: 3,
retryDelay: 1000,
}
);
@@ -94,6 +100,10 @@ const emit = defineEmits<{
(e: "start", file: File): void;
(e: "progress", payload: FileUploadProgress): void;
(e: "success", payload: FileUploadResult): void;
/** 一批文件全部传完(多选时用它统一刷新一次,避免每个文件都刷) */
(e: "complete", payload: { count: number }): void;
/** 上传中断:断点已保留,由外部决定续传还是放弃 */
(e: "interrupted", payload: { fileName: string; uploaded: number; total: number }): void;
(e: "error", error: unknown): void;
}>();
@@ -148,64 +158,205 @@ const buildChunkName = (
};
/** 分片上传(顺序上传) */
/** 计算进度需要的上下文:多文件时要能算到「整体」而不是单个文件内 */
interface ChunkProgress {
/** 当前第几个文件,从 1 开始 */
fileNo: number;
/** 本次共几个文件 */
fileCount: number;
/** 本文件之前已经传完的字节数 */
baseBytes: number;
/** 本次所有文件的总字节数,0 表示未知 */
totalBytes: number;
/** 当前文件的大小 */
fileSize: number;
/** 每成功一片回调,用来记续传位置 */
onChunkDone?: (index: number) => void;
}
/** 等待指定毫秒 */
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* 发单个分片,失败自动重试(等待时间 1x → 2x → 4x 递增)。
* 网络抖动、短暂断网在这一层就扛过去了,
* 只有全部重试都失败,才交给上层去弹「是否续传」。
*/
const requestChunk = async (
chunks: Blob[],
fileName: string,
uid: string,
index: number,
path: string,
/** 这片还没开始传时的百分比,重试时用它,避免进度条往回跳 */
percentBefore: number
): Promise<any> => {
const retries = props.retryCount > 0 ? props.retryCount : 0;
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
// 每次重新构造:FormData 发出去一次之后就不能再用了
const formData = new FormData();
const chunkName = buildChunkName(uid, index, chunks.length, fileName);
formData.append(fileName, chunks[index - 1], chunkName);
try {
return await axios.create({ timeout: props.timeout }).request({
url: props.action || defaultAction(),
method: "post",
data: formData,
headers: {
...(props.headers || {}),
Authorization: useLoginProfileStore().getToken(),
"Content-Type": "multipart/form-data",
FilePath: path,
},
});
} catch (error) {
lastError = error;
if (attempt < retries) {
emit("progress", {
percent: percentBefore,
current: index,
total: chunks.length,
text: `网络异常,正在重试(${attempt + 1}/${retries})...`,
});
await sleep(props.retryDelay * Math.pow(2, attempt));
}
}
}
throw lastError;
};
const uploadChunk = async (
chunks: Blob[],
fileName: string,
uid: string,
index: number,
path: string
path: string,
progress: ChunkProgress
): Promise<any> => {
const formData = new FormData();
const chunkName = buildChunkName(uid, index, chunks.length, fileName);
formData.append(fileName, chunks[index - 1], chunkName);
const chunkSize = props.chunkSize > 0 ? props.chunkSize : 1024 * 1024;
const response = await axios.create({ timeout: props.timeout }).request({
url: props.action || defaultAction(),
method: "post",
data: formData,
headers: {
...(props.headers || {}),
Authorization: useLoginProfileStore().getToken(),
"Content-Type": "multipart/form-data",
FilePath: path,
},
});
// 这片还没传时的进度:重试期间保持不动,不让进度条倒退
const sentBefore = progress.baseBytes + Math.min((index - 1) * chunkSize, progress.fileSize);
const percentBefore =
progress.totalBytes > 0
? Math.min(100, Math.round((sentBefore / progress.totalBytes) * 100))
: Math.round(((index - 1) / chunks.length) * 100);
const response = await requestChunk(chunks, fileName, uid, index, path, percentBefore);
// 整体进度按字节算:多文件时进度条一路向前,不会每个文件都退回 0
const sentBytes = progress.baseBytes + Math.min(index * chunkSize, progress.fileSize);
const percent =
progress.totalBytes > 0
? Math.min(100, Math.round((sentBytes / progress.totalBytes) * 100))
: Math.round((index / chunks.length) * 100);
// 这一片已经落到服务器上了,记下位置,中断后从这里之后接着传
progress.onChunkDone?.(index);
emit("progress", {
percent: Math.round((index / chunks.length) * 100),
percent,
current: index,
total: chunks.length,
text: `上传中(${index}/${chunks.length})...`,
text:
progress.fileCount > 1
? `上传中(第 ${progress.fileNo}/${progress.fileCount} 个,分片 ${index}/${chunks.length}) ${percent}%`
: `上传中(${index}/${chunks.length})...`,
});
if (index < chunks.length) {
return await uploadChunk(chunks, fileName, uid, index + 1, path);
return await uploadChunk(chunks, fileName, uid, index + 1, path, progress);
}
return response.data;
};
/** 上传入口 */
const upload = async (file: File) => {
if (!file || typeof file.size !== "number") {
return;
}
if (uploading.value) {
return;
}
/**
* 一次上传的断点状态。
*
* 只要不刷新页面,文件、切好的分片和 uid 都还在内存里,
* 中断时记住「下一个要传第几片」,续传就能接着发,不必从头再来。
* 刷新页面就丢了——这是浏览器的限制,文件对象无法持久化。
*/
interface UploadSession {
files: File[];
path: string;
totalBytes: number;
/** 已完成文件的累计字节数 */
doneBytes: number;
/** 正在传第几个文件(从 0 开始) */
fileIndex: number;
/** 当前文件已切好的分片,续传时沿用 */
chunks?: Blob[];
/** 当前文件的 uid,续传时必须沿用,否则对不上服务器上的分片 */
uid?: string;
/** 当前文件下一个要传的分片序号 */
nextChunk: number;
}
/** 未完成的上传会话,null 表示没有待续传的任务 */
const session = ref<UploadSession | null>(null);
/** 是否还有待续传的上传 */
const hasPendingUpload = computed(() => !!session.value);
/** 执行或继续一次上传会话 */
const runSession = async (s: UploadSession): Promise<void> => {
session.value = s;
uploading.value = true;
emit("start", file);
try {
const path = await resolvePath();
const chunks = splitFile(file);
if (chunks.length === 0) {
throw new Error("无法读取文件内容");
while (s.fileIndex < s.files.length) {
const file = s.files[s.fileIndex];
// 只有新文件才切片、生成 uid;续传沿用原来的,保证和服务器上的分片对得上
if (!s.chunks || !s.uid) {
const chunks = splitFile(file);
if (chunks.length === 0) throw new Error("无法读取文件内容");
s.chunks = chunks;
s.uid = uuidv4();
s.nextChunk = 1;
}
const chunks = s.chunks;
const uid = s.uid;
const response = await uploadChunk(chunks, file.name, uid, s.nextChunk, s.path, {
fileNo: s.fileIndex + 1,
fileCount: s.files.length,
baseBytes: s.doneBytes,
totalBytes: s.totalBytes,
fileSize: file.size || 0,
onChunkDone: (index) => {
s.nextChunk = index + 1;
},
});
// 返回分割后的最后一个(序号最大的)分片文件名
const fileName = buildChunkName(uid, chunks.length, chunks.length, file.name);
emit("success", { file, path: s.path, total: chunks.length, fileName, response });
// 这个文件传完,进入下一个
s.doneBytes += file.size || 0;
s.fileIndex += 1;
s.chunks = undefined;
s.uid = undefined;
s.nextChunk = 1;
}
const uid = uuidv4();
const response = await uploadChunk(chunks, file.name, uid, 1, path);
// 返回分割后的最后一个(序号最大的)分片文件名
const fileName = buildChunkName(uid, chunks.length, chunks.length, file.name);
emit("success", { file, path, total: chunks.length, fileName, response });
session.value = null;
emit("complete", { count: s.files.length });
} catch (error) {
// 故意不清 session:留给外部决定续传还是放弃
const file = s.files[s.fileIndex];
emit("interrupted", {
fileName: file?.name || "",
uploaded: Math.max(0, s.nextChunk - 1),
total: s.chunks?.length ?? 0,
});
emit("error", error);
} finally {
uploading.value = false;
@@ -213,8 +364,66 @@ const upload = async (file: File) => {
}
};
/**
* 上传一批文件:按顺序一个一个传,不做并发,
* 免得后端同时收到多个分片流。多选时所有文件进同一个目录。
*/
const uploadFiles = async (files: File[]): Promise<void> => {
const list = (files || []).filter((f) => f && typeof f.size === "number");
if (list.length === 0 || uploading.value) return;
emit("start", list[0]);
try {
const path = await resolvePath();
await runSession({
files: list,
path,
totalBytes: list.reduce((sum, f) => sum + (f.size || 0), 0),
doneBytes: 0,
fileIndex: 0,
nextChunk: 1,
});
} catch (error) {
emit("error", error);
}
};
/** 从断点继续上传:由外部在中断后调用 */
const resume = async (): Promise<void> => {
const s = session.value;
if (!s || uploading.value) return;
await runSession(s);
};
/** 放弃这次上传,清掉断点状态 */
const discard = (): void => {
session.value = null;
};
/** 上传入口(供外部直接调用,单文件) */
const upload = async (file: File) => {
await uploadFiles([file]);
};
/**
* antd 对多选的文件会连续同步调用 beforeUpload。
* 这里先把文件攒起来,本轮结束后整批处理——
* 否则每个文件各自起一轮上传,后面的会被 uploading 挡掉。
*/
let pending: File[] = [];
let scheduled = false;
const handleBeforeUpload: UploadProps["beforeUpload"] = (file) => {
upload(file as unknown as File);
pending.push(file as unknown as File);
if (!scheduled) {
scheduled = true;
Promise.resolve().then(() => {
const files = pending;
pending = [];
scheduled = false;
uploadFiles(files);
});
}
return false;
};
@@ -226,7 +435,7 @@ const trigger = () => {
uploadRef.value?.$el?.querySelector("input")?.click();
};
defineExpose({ trigger, upload });
defineExpose({ trigger, upload, resume, discard, hasPendingUpload });
</script>
<style scoped>
@@ -85,8 +85,9 @@
</a-col>
</a-row>
<FileUpload v-if="canCreate" ref="fileUploadRef" :action="uploadUrl" :headers="headers"
:resolve-file-path="resolveFilePath" @start="onUploadStart" @progress="onUploadProgress"
@success="onUploadSuccess" @error="onUploadError" />
:resolve-file-path="resolveFilePath" :multiple="multiple" @start="onUploadStart"
@progress="onUploadProgress" @success="onUploadSuccess" @complete="onUploadComplete"
@interrupted="onUploadInterrupted" @error="onUploadError" />
</template>
<script setup lang="ts">
@@ -116,6 +117,8 @@ const props = defineProps<{
uploadUrl: string;
headers: any;
resolveFilePath?: () => string | Promise<string>;
/** 上传时是否允许一次选多个文件,默认只允许选一个 */
multiple?: boolean;
canCreate: boolean;
canAmend: boolean;
canDelete: boolean;
@@ -127,6 +130,8 @@ const emit = defineEmits([
"upload-start",
"upload-progress",
"upload-success",
"upload-complete",
"upload-interrupted",
"upload-error",
"download",
"share",
@@ -234,6 +239,28 @@ const onUploadSuccess = (payload: any) => {
emit("upload-success", payload);
};
/** 一批文件全部传完(多选时用它统一刷新一次,避免每个文件都刷一遍列表) */
const onUploadComplete = (payload: any) => {
emit("upload-complete", payload);
};
/** 上传中断:断点还在,交给页面去问用户要不要续传 */
const onUploadInterrupted = (payload: any) => {
emit("upload-interrupted", payload);
};
/** 从断点继续上传 */
const resumeUpload = () => {
fileUploadRef.value?.resume();
};
/** 放弃这次上传,清掉断点 */
const discardUpload = () => {
fileUploadRef.value?.discard();
};
defineExpose({ resumeUpload, discardUpload });
const onUploadError = (error: unknown) => {
emit("upload-error", error);
};
+208 -45
View File
@@ -1,8 +1,16 @@
<template>
<div class="vap" :class="{ 'vap--glow': effectOn }">
<audio ref="audioRef" :src="current?.url" preload="metadata" @timeupdate="onTimeUpdate"
@loadedmetadata="onLoadedMetadata" @durationchange="onLoadedMetadata" @ended="onEnded" @play="isPlaying = true"
@pause="isPlaying = false" @error="onError"></audio>
<!-- 两个 audio 轮换:一个在播,另一个在后台预载下一首。
切歌时若空闲的那个已经下好目标,直接交换角色,不换 src 就不会重新缓冲,
切歌因此不会卡。预载仅在 WiFi / 以太网下开启,避免悄悄跑掉手机流量。 -->
<audio ref="audioARef" :src="audioASrc" :preload="audioAPreload" @timeupdate="onTimeUpdate($event)"
@loadedmetadata="onLoadedMetadata($event)" @durationchange="onLoadedMetadata($event)"
@ended="onEnded($event)" @play="onPlayState($event, true)" @pause="onPlayState($event, false)"
@error="onError($event)"></audio>
<audio ref="audioBRef" :src="audioBSrc" :preload="audioBPreload" @timeupdate="onTimeUpdate($event)"
@loadedmetadata="onLoadedMetadata($event)" @durationchange="onLoadedMetadata($event)"
@ended="onEnded($event)" @play="onPlayState($event, true)" @pause="onPlayState($event, false)"
@error="onError($event)"></audio>
<!-- 空列表 -->
<div v-if="!current" class="vap-empty">
@@ -198,7 +206,12 @@ const props = withDefaults(defineProps<{ tracks?: VinylTrack[] }>(), { tracks: (
const emit = defineEmits<{ (e: "close"): void }>();
/* ---------------- 基础状态 ---------------- */
const audioRef = ref<HTMLAudioElement | null>(null);
const audioARef = ref<HTMLAudioElement | null>(null);
const audioBRef = ref<HTMLAudioElement | null>(null);
/** 当前由哪个 audio 在播,另一个空闲、负责后台预载下一首 */
const activeSlot = ref<"a" | "b">("a");
const activeAudio = computed(() => (activeSlot.value === "a" ? audioARef.value : audioBRef.value));
const idleAudio = computed(() => (activeSlot.value === "a" ? audioBRef.value : audioARef.value));
const index = ref(0);
const isPlaying = ref(false);
const currentTime = ref(0);
@@ -221,30 +234,133 @@ const isMuted = computed(() => muted.value || volume.value === 0);
const modeTitle = computed(() => ({ list: "列表循环", one: "单曲循环", random: "随机播放" }[mode.value]));
const rateOptions = [0.5, 0.75, 1, 1.25, 1.5, 2];
/* ---------------- 预载下一首(仅 WiFi / 以太网) ---------------- */
/**
* 下一首的下标。随机模式下下一首不确定,预载没有意义,返回 null。
*/
const nextIndex = computed<number | null>(() => {
const n = tracks.value.length;
if (n <= 1 || mode.value === "random") return null;
return (index.value + 1) % n;
});
const nextTrack = computed<VinylTrack | null>(() =>
nextIndex.value === null ? null : tracks.value[nextIndex.value] ?? null,
);
/**
* 是否允许后台预载:只在 WiFi / 以太网下开启。
* 蜂窝网络下预载会悄悄吃掉用户流量,因此关掉;
* 拿不到网络类型时(例如 Safari)保守关闭。
*/
const preloadEnabled = ref(false);
const detectPreloadNetwork = (): boolean => {
const conn: any =
(navigator as any).connection ||
(navigator as any).mozConnection ||
(navigator as any).webkitConnection;
if (!conn || !conn.type) return false;
return conn.type === "wifi" || conn.type === "ethernet";
};
const refreshPreloadNetwork = () => {
preloadEnabled.value = detectPreloadNetwork();
};
/**
* 空闲 audio 要装的地址;不允许预载时为 undefined,也就是不输出 src 属性。
* 这里不能给空字符串:浏览器会把空 src 解析成当前页面地址,
* 白白把 HTML 当音频加载一次并抛错。
*/
const idleSrc = computed(() => (preloadEnabled.value ? nextTrack.value?.url || undefined : undefined));
const audioASrc = computed(() => (activeSlot.value === "a" ? current.value?.url || undefined : idleSrc.value));
const audioBSrc = computed(() => (activeSlot.value === "b" ? current.value?.url || undefined : idleSrc.value));
const audioAPreload = computed(() => (activeSlot.value === "a" ? "auto" : idleSrc.value ? "auto" : "none"));
const audioBPreload = computed(() => (activeSlot.value === "b" ? "auto" : idleSrc.value ? "auto" : "none"));
/**
* 空闲的 audio 是否已经「真的能播」指定地址。
*
* 只比对 src 是不够的:预载失败(error)或还没缓冲出数据(readyState 太低)时,
* src 同样是那个地址,换过去 play() 会立刻失败并被静默吞掉,
* 表现就是切到某一首后播放器停住不动。
* 这种元素宁可不用——让当前 audio 重新加载,慢一点但一定能播。
*/
const idleHas = (url?: string): boolean => {
if (!url) return false;
const a = idleAudio.value;
// readyState >= 2 表示至少已有当前帧的数据,play() 才能出声
if (!a || !a.src || a.error || a.readyState < 2) return false;
try {
return a.src === new URL(url, window.location.href).href;
} catch {
return a.src === url;
}
};
/* ---------------- 播放控制 ---------------- */
const tryPlay = () => {
// 自动播放被浏览器拦截时静默失败,等用户点播放键
audioRef.value?.play().catch(() => { });
/** 连续播放失败次数,用来在整张列表都播不了时停止自动跳歌,避免打转 */
let playFailCount = 0;
/** 这首播不出来(加载失败或 play 被拒):自动跳下一首 */
const onPlayFailed = () => {
playFailCount += 1;
const n = tracks.value.length;
// 只有一首、或整张列表都试过了还失败,就停下,否则会无限跳歌
if (n <= 1 || playFailCount > n) return;
goNext(true);
};
/**
* 播放当前这首。
* 切歌后紧接着 play 常被浏览器以 AbortError 打断(新的加载打断了上一次播放请求),
* 所以失败后稍等重试一次;仍然失败就自动跳下一首,
* 绝不让播放器停在半路又不给任何提示。
*/
const tryPlay = async () => {
const a = activeAudio.value;
if (!a) return;
try {
await a.play();
} catch {
// 自动播放被拦截时不需要重试,等用户点播放键即可
window.setTimeout(() => {
const cur = activeAudio.value;
if (!cur || !cur.paused) return;
cur.play().catch(() => onPlayFailed());
}, 250);
}
};
const toggle = () => {
const a = audioRef.value;
const a = activeAudio.value;
if (!a || !current.value) return;
if (a.paused) tryPlay();
else a.pause();
};
/**
* 切到第 i 首。
* 空闲的 audio 若已经预载好这一首,就交换角色——
* src 不变就不会重新缓冲,切歌瞬时完成;否则只能换 src 重新加载。
*/
const goToIndex = (i: number) => {
const n = tracks.value.length;
if (!n) return;
const target = ((i % n) + n) % n;
if (idleHas(tracks.value[target]?.url)) {
activeSlot.value = activeSlot.value === "a" ? "b" : "a";
}
index.value = target;
};
const playAt = (i: number) => {
if (i === index.value) {
toggle();
return;
}
resumeAfterSwitch = true;
index.value = i;
goToIndex(i);
};
const goPrev = () => {
const n = tracks.value.length;
if (!n) return;
if (!tracks.value.length) return;
resumeAfterSwitch = true;
index.value = (index.value - 1 + n) % n;
goToIndex(index.value - 1);
};
const goNext = (auto: boolean) => {
const n = tracks.value.length;
@@ -253,38 +369,49 @@ const goNext = (auto: boolean) => {
if (auto && mode.value === "random" && n > 1) {
let r = index.value;
while (r === index.value) r = Math.floor(Math.random() * n);
index.value = r;
goToIndex(r);
} else {
index.value = (index.value + 1) % n;
goToIndex(index.value + 1);
}
};
const cycleMode = () => {
mode.value = mode.value === "list" ? "one" : mode.value === "one" ? "random" : "list";
};
const seekTo = (t: number) => {
const a = audioRef.value;
const a = activeAudio.value;
if (a && isFinite(t)) a.currentTime = Math.max(0, t);
};
/* ---------------- audio 事件 ---------------- */
const onTimeUpdate = () => {
const a = audioRef.value;
if (!a || dragRatio.value !== null) return;
/** 两个 audio 都会触发事件,只有当前在播的那个才需要响应 */
const isActive = (el: EventTarget | null) => !!el && el === activeAudio.value;
const onTimeUpdate = (e: Event) => {
if (!isActive(e.target) || dragRatio.value !== null) return;
const a = e.target as HTMLAudioElement;
currentTime.value = a.currentTime;
syncLyric();
};
const onLoadedMetadata = () => {
const a = audioRef.value;
if (a && isFinite(a.duration)) duration.value = a.duration;
const onLoadedMetadata = (e: Event) => {
if (!isActive(e.target)) return;
const a = e.target as HTMLAudioElement;
if (isFinite(a.duration)) duration.value = a.duration;
};
const onEnded = () => {
const onPlayState = (e: Event, playing: boolean) => {
if (!isActive(e.target)) return;
isPlaying.value = playing;
// 真的播起来了就清零,失败计数只在连续失败时累加
if (playing) playFailCount = 0;
};
const onEnded = (e: Event) => {
if (!isActive(e.target)) return;
if (stopAfterEnd.value) {
stopAfterEnd.value = false;
sleepMinutes.value = 0;
return;
}
if (mode.value === "one") {
const a = audioRef.value;
const a = activeAudio.value;
if (a) {
a.currentTime = 0;
tryPlay();
@@ -293,8 +420,13 @@ const onEnded = () => {
}
goNext(true);
};
const onError = () => {
// 音频地址拉不起来时只静默处理,避免打断页面
const onError = (e: Event) => {
// 预载中的 audio 报错不影响正在播的那首,静默处理即可
if (!isActive(e.target)) return;
// 当前这首加载不出来时必须往下走:
// 不处理的话播放器会一直停在这一首,用户只看到「突然不播了」却没有任何提示
message.error("音频加载失败,自动播放下一首");
onPlayFailed();
};
/* ---------------- 歌词 ---------------- */
@@ -303,7 +435,8 @@ interface LyricLine {
text: string;
}
const LYRIC_TAG = /\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g;
const LYRIC_LINE_H = 26;
/** 单句歌词的行高:当前句放大到 24px(与标题同号),行高要跟着放宽才不挤 */
const LYRIC_LINE_H = 34;
const lyricLines = ref<LyricLine[]>([]);
const lyricIndex = ref(-1);
@@ -349,7 +482,8 @@ const syncLyric = () => {
lyricIndex.value = i;
};
const lyricStyle = computed(() => ({
transform: `translateY(${39 - (lyricIndex.value + 0.5) * LYRIC_LINE_H}px)`,
// 51 = 歌词可视区高度的一半,让当前句停在正中
transform: `translateY(${51 - (lyricIndex.value + 0.5) * LYRIC_LINE_H}px)`,
}));
/* ---------------- 进度条(支持鼠标/触摸拖动) ---------------- */
@@ -375,7 +509,7 @@ const onBarUp = (e: PointerEvent) => {
if (!draggingBar) return;
draggingBar = false;
const r = ratioFromEvent(e);
if (audioRef.value && duration.value > 0) audioRef.value.currentTime = r * duration.value;
if (activeAudio.value && duration.value > 0) activeAudio.value.currentTime = r * duration.value;
dragRatio.value = null;
};
const displayRatio = computed(
@@ -517,32 +651,54 @@ watch(
}
);
watch([volume, muted], () => {
const a = audioRef.value;
/** 把音量 / 静音 / 倍速应用到当前在播的 audio */
const applyAudioSettings = () => {
const a = activeAudio.value;
if (!a) return;
a.volume = volume.value;
a.muted = muted.value || volume.value === 0;
});
watch(rate, () => {
if (audioRef.value) audioRef.value.playbackRate = rate.value;
a.playbackRate = rate.value;
};
watch([volume, muted, rate], () => applyAudioSettings());
// 交换角色后,新的 active 是刚才空闲的那个,设置要重新应用一次
watch(activeSlot, async () => {
await nextTick();
applyAudioSettings();
});
let timerId = 0;
let networkConn: any = null;
onMounted(() => {
document.addEventListener("click", onDocClick);
// 首次挂载时把音量 / 静音 / 倍速落到当前 audio 上(默认 0.9 而非浏览器的 1)
applyAudioSettings();
// 先看当前网络,只有 WiFi / 以太网才后台预载下一首
refreshPreloadNetwork();
networkConn =
(navigator as any).connection ||
(navigator as any).mozConnection ||
(navigator as any).webkitConnection;
networkConn?.addEventListener?.("change", refreshPreloadNetwork);
timerId = window.setInterval(() => {
nowTick.value = Date.now();
if (sleepAt.value && nowTick.value >= sleepAt.value) {
sleepAt.value = 0;
sleepMinutes.value = 0;
audioRef.value?.pause();
activeAudio.value?.pause();
}
}, 1000);
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocClick);
networkConn?.removeEventListener?.("change", refreshPreloadNetwork);
if (timerId) clearInterval(timerId);
audioRef.value?.pause();
// 两个都停:预载的那个可能正在后台下载
audioARef.value?.pause();
audioBRef.value?.pause();
});
</script>
@@ -652,8 +808,9 @@ onBeforeUnmount(() => {
left: 50%;
top: 50%;
/* img 是替换元素,width/height 不能用 auto + inset,否则按图片原始尺寸撑开 */
width: 38%;
height: 38%;
/* 在黑胶盘(尺寸不变)中心放封面,比原来的 38% 略放大一点 */
width: 46%;
height: 46%;
transform: translate(-50%, -50%);
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.55);
@@ -667,7 +824,7 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
justify-content: center;
font-size: 44px;
font-size: 48px;
color: rgba(255, 255, 255, 0.85);
background: linear-gradient(140deg, #1e6f78, #0d5560);
}
@@ -759,15 +916,20 @@ onBeforeUnmount(() => {
/* ---------- 歌词 ---------- */
.vap-lyric {
height: 78px;
margin-top: 10px;
/* 在唱片与标题之间略微上提,视觉上落在页面中部:
比自然位置高一点,又不至于像之前 -160px 那样贴着唱片下沿。
用 relative + top 只挪视觉位置,不影响下面标题、按钮的排布 */
position: relative;
top: -60px;
height: 102px;
margin-top: 30px;
overflow: hidden;
text-align: center;
}
.vap-lyric-empty {
margin: 0;
line-height: 78px;
line-height: 102px;
font-size: 15px;
color: rgba(255, 255, 255, 0.55);
}
@@ -778,8 +940,8 @@ onBeforeUnmount(() => {
.vap-lyric-line {
margin: 0;
height: 26px;
line-height: 26px;
height: 34px;
line-height: 34px;
font-size: 14px;
color: rgba(255, 255, 255, 0.4);
white-space: nowrap;
@@ -788,9 +950,10 @@ onBeforeUnmount(() => {
cursor: pointer;
}
/* 当前句:字号与歌名标题一致 */
.vap-lyric-line.is-active {
font-size: 16px;
font-weight: 600;
font-size: 24px;
font-weight: 700;
color: #fff;
}
+155 -71
View File
@@ -4,10 +4,11 @@
<Toolbar :path-stack="pathStack" :root-path="rootPath" :loading="loading" @go-top="onGoTop" @go-back="onGoBack"
@refresh="onReSearch" @search="onSearchByName" @breadcrumb-click="onBreadcrumbClick" />
<OperatorStation :selected-items="keys" :upload-url="uploadUrl" :headers="headers" :can-create="canCreate"
:can-amend="canAmend" :can-delete="canDelete" :can-view="canView" :resolve-file-path="resolveUploadFilePath"
@create-folder="onCreate" @upload-start="onUploadStart" @upload-progress="onUploadProgress"
@upload-success="onUploadSuccess" @upload-error="onUploadError" @download="onDownloadSelected"
<OperatorStation ref="operatorStationRef" :selected-items="keys" :upload-url="uploadUrl" :headers="headers"
:can-create="canCreate" :can-amend="canAmend" :can-delete="canDelete" :can-view="canView"
:resolve-file-path="resolveUploadFilePath" multiple @create-folder="onCreate" @upload-start="onUploadStart"
@upload-progress="onUploadProgress" @upload-success="onUploadSuccess" @upload-complete="onUploadComplete"
@upload-interrupted="onUploadInterrupted" @upload-error="onUploadError" @download="onDownloadSelected"
@share="onShareSelected" @publish="onPublishSelected" @view-preview="onViewPreviewSelected"
@play-video="onUrlVideoPlayItem" @play-audio="onPlayAudioItem" @pause-audio="onPauseAudio"
@continue-audio="onContinueAudio" @preview="onPreviewSelected" @rename="onAmendSelected" @move="onMoveSelected"
@@ -39,6 +40,16 @@
:fullscreen="width < 640" @ok="handleSelectPublishVideo">
<PublishVideoEnquiry ref="publishVideoEnquiry"></PublishVideoEnquiry>
</dragModal>
<!-- 上传中断:问用户要不要从断点续传 -->
<a-modal v-model:open="showResumeModal" title="上传中断" :mask-closable="false" :closable="false" ok-text="继续上传"
cancel-text="放弃" @ok="onResumeUpload" @cancel="onDiscardUpload">
<p>「{{ interruptedInfo.fileName }}」已上传 {{ interruptedInfo.uploaded }}/{{ interruptedInfo.total }} 片。</p>
<p>网络似乎中断了,是否从断点继续上传?</p>
<p style="color: #8a919f; font-size: 12px">
提示:刷新页面会丢失已上传的分片,那之后只能重新选择文件从头上传。
</p>
</a-modal>
</a-spin>
</template>
@@ -297,16 +308,62 @@ const onUploadProgress = (payload: { text?: string }) => {
spinTip.value = payload?.text || "上传中...";
};
const onUploadSuccess = (payload: { total?: number }) => {
const total = payload?.total ?? 1;
let sec = 2500;
if (total > 25) sec = total * 100;
/** 单个文件传完:这里不刷新,等一批都传完再统一刷,避免选了多个文件反复刷列表 */
const onUploadSuccess = () => {
spinTip.value = "上传完成,等待服务端处理...";
};
/** 一批文件全部传完:给后端留点时间合并分片,文件越多等得越久 */
const onUploadComplete = (payload: { count?: number }) => {
const count = payload?.count ?? 1;
const sec = 2500 + (count - 1) * 1000;
spinTip.value = "加载中...";
setTimeout(() => {
onReSearch();
loading.value = false;
}, sec);
};
/** 中断时的断点信息,弹窗里展示用 */
const interruptedInfo = ref<{ fileName: string; uploaded: number; total: number }>({
fileName: "",
uploaded: 0,
total: 0,
});
const showResumeModal = ref<boolean>(false);
const operatorStationRef = ref<any>(null);
/** 上传中断:断点还在内存里,弹窗问用户要不要接着传 */
const onUploadInterrupted = (payload: {
fileName?: string;
uploaded?: number;
total?: number;
}) => {
interruptedInfo.value = {
fileName: payload?.fileName || "",
uploaded: payload?.uploaded ?? 0,
total: payload?.total ?? 0,
};
loading.value = false;
spinTip.value = "加载中...";
showResumeModal.value = true;
};
/** 继续上传:从断点那一批接着发 */
const onResumeUpload = () => {
showResumeModal.value = false;
loading.value = true;
spinTip.value = "继续上传中...";
operatorStationRef.value?.resumeUpload();
};
/** 放弃:清掉断点,已传的分片留在服务器上不会被合并 */
const onDiscardUpload = () => {
showResumeModal.value = false;
operatorStationRef.value?.discardUpload();
loading.value = false;
};
const onUploadError = () => {
loading.value = false;
spinTip.value = "加载中...";
@@ -829,86 +886,113 @@ const handleSelectShare = async () => {
};
const handleSelectPublishPhoto = async () => {
if (publishPhotoEnquiry.value) {
if (publishPhotoEnquiry.value.keys.length > 0) {
let data: PublishData;
for (let i = 0; i < keys.value.length; i++) {
data = new PublishData();
data.FileId = keys.value[i].FileId;
data.PublishName = publishPhotoEnquiry.value.keys[0].PublishName;
try {
loading.value = true;
if (publishPhotoEnquiry.value) {
if (publishPhotoEnquiry.value.keys.length > 0) {
let data: PublishData;
for (let i = 0; i < keys.value.length; i++) {
data = new PublishData();
data.FileId = keys.value[i].FileId;
data.PublishName = publishPhotoEnquiry.value.keys[0].PublishName;
await publishPhotoService.InsertPublishPhotoDetail(data);
await publishPhotoService.InsertPublishPhotoDetail(data);
}
showModalPublishPhoto.value = false;
notification.info({
message: "系统提示:",
description: "作品发布成功",
placement: "topRight",
});
} else {
notification.info({
message: "系统提示:",
description: "请选择一条记录",
placement: "topRight",
});
}
showModalPublishPhoto.value = false;
notification.info({
message: "系统提示:",
description: "作品发布成功",
placement: "topRight",
});
} else {
notification.info({
message: "系统提示:",
description: "请选择一条记录",
placement: "topRight",
});
}
} catch (error) {
}
finally {
loading.value = false;
}
};
const handleSelectPublishAudio = async () => {
if (publishAudioEnquiry.value) {
if (publishAudioEnquiry.value.keys.length > 0) {
let data: PublishData;
for (let i = 0; i < keys.value.length; i++) {
data = new PublishData();
data.FileId = keys.value[i].FileId;
data.PublishName = publishAudioEnquiry.value.keys[0].PublishName;
try {
loading.value = true;
if (publishAudioEnquiry.value) {
if (publishAudioEnquiry.value.keys.length > 0) {
let data: PublishData;
for (let i = 0; i < keys.value.length; i++) {
data = new PublishData();
data.FileId = keys.value[i].FileId;
data.PublishName = publishAudioEnquiry.value.keys[0].PublishName;
await publishAudioService.InsertPublishAudioDetail(data);
await publishAudioService.InsertPublishAudioDetail(data);
}
showModalPublishAudio.value = false;
notification.info({
message: "系统提示:",
description: "专辑发布成功",
placement: "topRight",
});
} else {
notification.info({
message: "系统提示:",
description: "请选择一条记录",
placement: "topRight",
});
}
showModalPublishAudio.value = false;
notification.info({
message: "系统提示:",
description: "专辑发布成功",
placement: "topRight",
});
} else {
notification.info({
message: "系统提示:",
description: "请选择一条记录",
placement: "topRight",
});
}
} catch (error) {
}
finally {
loading.value = false;
}
};
const handleSelectPublishVideo = async () => {
if (publishVideoEnquiry.value) {
if (publishVideoEnquiry.value.keys.length > 0) {
let data: PublishData;
for (let i = 0; i < keys.value.length; i++) {
data = new PublishData();
data.FileId = keys.value[i].FileId;
data.PublishName = publishVideoEnquiry.value.keys[0].PublishName;
try {
loading.value = true;
await publishVideoService.InsertPublishVideoDetail(data);
if (publishVideoEnquiry.value) {
if (publishVideoEnquiry.value.keys.length > 0) {
let data: PublishData;
for (let i = 0; i < keys.value.length; i++) {
data = new PublishData();
data.FileId = keys.value[i].FileId;
data.PublishName = publishVideoEnquiry.value.keys[0].PublishName;
await publishVideoService.InsertPublishVideoDetail(data);
}
showModalPublishVideo.value = false;
notification.info({
message: "系统提示:",
description: "专辑发布成功",
placement: "topRight",
});
} else {
notification.info({
message: "系统提示:",
description: "请选择一条记录",
placement: "topRight",
});
}
showModalPublishVideo.value = false;
notification.info({
message: "系统提示:",
description: "专辑发布成功",
placement: "topRight",
});
} else {
notification.info({
message: "系统提示:",
description: "请选择一条记录",
placement: "topRight",
});
}
} catch (error) {
}
finally {
loading.value = false;
}
};
@@ -1,103 +0,0 @@
// vite.config.ts
import { resolve } from "node:path";
import { loadEnv } from "file:///D:/NET8/Webmaster/node_modules/vite/dist/node/index.js";
import vue from "file:///D:/NET8/Webmaster/node_modules/@vitejs/plugin-vue/dist/index.mjs";
import vueJsx from "file:///D:/NET8/Webmaster/node_modules/@vitejs/plugin-vue-jsx/dist/index.mjs";
import topLevelAwait from "file:///D:/NET8/Webmaster/node_modules/vite-plugin-top-level-await/exports/import.mjs";
import dayjs from "file:///D:/NET8/Webmaster/node_modules/dayjs/dayjs.min.js";
// package.json
var package_default = {
name: "webmaster",
private: true,
version: "0.0.0",
type: "module",
scripts: {
dev: "vite",
build: "vue-tsc && vite build",
preview: "vite preview"
},
dependencies: {
"@ant-design/icons-vue": "^7.0.1",
"@types/crypto-js": "^4.2.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.12.11",
"@types/uuid": "^9.0.8",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vueuse/core": "^10.9.0",
"@worstone/vue-aplayer": "^1.0.6",
"animate.css": "^4.1.1",
"ant-design-vue": "^4.2.1",
axios: "^1.6.8",
cors: "^2.8.5",
"crypto-js": "^4.2.0",
gsap: "^3.12.5",
howler: "^2.2.4",
jsencrypt: "^3.3.2",
"lodash-es": "^4.17.21",
pinia: "^2.1.7",
sortablejs: "^1.15.2",
uuid: "^9.0.1",
"vite-plugin-top-level-await": "^1.4.1",
vue: "^3.4.27",
"vue-i18n": "^9.13.1",
"vue-request": "^2.0.4",
"vue-router": "^4.0.13",
"vue3-photo-preview": "^0.3.0",
"vue3-video-play": "^1.3.1",
"wow.js": "^1.2.2"
},
devDependencies: {
"@types/howler": "^2.2.11",
"@types/sortablejs": "^1.15.8",
"@vitejs/plugin-vue": "^5.0.4",
less: "^4.2.0",
typescript: "^5.2.2",
vite: "^5.2.0",
"vue-tsc": "^2.0.6"
}
};
// vite.config.ts
var __vite_injected_original_dirname = "D:\\NET8\\Webmaster";
var CWD = process.cwd();
var __APP_INFO__ = {
pkg: package_default,
lastBuildTime: dayjs().format("YYYY-MM-DD HH:mm:ss")
};
var vite_config_default = ({ command, mode }) => {
const { VITE_BASE_URL, VITE_DROP_CONSOLE } = loadEnv(mode, CWD);
const isBuild = command === "build";
return {
base: VITE_BASE_URL,
define: {
__APP_INFO__: JSON.stringify(__APP_INFO__)
},
resolve: {
alias: [
{
find: "@",
replacement: resolve(__vite_injected_original_dirname, "./src")
}
]
},
plugins: [
vue(),
vueJsx(),
topLevelAwait({
// The export name of top-level await promise for each chunk module
promiseExportName: "__tla",
// The function to generate import names of top-level await promise in each chunk module
promiseImportName: (i) => `__tla_${i}`
})
],
server: {
host: "0.0.0.0",
port: 8088
}
};
};
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiLCAicGFja2FnZS5qc29uIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSA9IFwiRDpcXFxcTkVUOFxcXFxXZWJtYXN0ZXJcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIkQ6XFxcXE5FVDhcXFxcV2VibWFzdGVyXFxcXHZpdGUuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9EOi9ORVQ4L1dlYm1hc3Rlci92aXRlLmNvbmZpZy50c1wiO2ltcG9ydCB7IHJlc29sdmUgfSBmcm9tIFwibm9kZTpwYXRoXCI7XG5pbXBvcnQgeyBsb2FkRW52IH0gZnJvbSBcInZpdGVcIjtcblxuaW1wb3J0IHZ1ZSBmcm9tIFwiQHZpdGVqcy9wbHVnaW4tdnVlXCI7XG5pbXBvcnQgdnVlSnN4IGZyb20gXCJAdml0ZWpzL3BsdWdpbi12dWUtanN4XCI7XG5cbmltcG9ydCB0b3BMZXZlbEF3YWl0IGZyb20gXCJ2aXRlLXBsdWdpbi10b3AtbGV2ZWwtYXdhaXRcIjtcblxuaW1wb3J0IGRheWpzIGZyb20gXCJkYXlqc1wiO1xuaW1wb3J0IHBrZyBmcm9tIFwiLi9wYWNrYWdlLmpzb25cIjtcbmltcG9ydCB0eXBlIHsgVXNlckNvbmZpZywgQ29uZmlnRW52IH0gZnJvbSBcInZpdGVcIjtcblxuY29uc3QgQ1dEID0gcHJvY2Vzcy5jd2QoKTtcbmNvbnN0IF9fQVBQX0lORk9fXyA9IHtcbiAgcGtnLFxuICBsYXN0QnVpbGRUaW1lOiBkYXlqcygpLmZvcm1hdChcIllZWVktTU0tREQgSEg6bW06c3NcIiksXG59O1xuXG4vLyBodHRwczovL3ZpdGVqcy5kZXYvY29uZmlnL1xuZXhwb3J0IGRlZmF1bHQgKHsgY29tbWFuZCwgbW9kZSB9OiBDb25maWdFbnYpOiBVc2VyQ29uZmlnID0+IHtcbiAgLy8gXHU3M0FGXHU1ODgzXHU1M0Q4XHU5MUNGXG4gIGNvbnN0IHsgVklURV9CQVNFX1VSTCwgVklURV9EUk9QX0NPTlNPTEUgfSA9IGxvYWRFbnYobW9kZSwgQ1dEKTtcblxuICBjb25zdCBpc0J1aWxkID0gY29tbWFuZCA9PT0gXCJidWlsZFwiO1xuXG4gIHJldHVybiB7XG4gICAgYmFzZTogVklURV9CQVNFX1VSTCxcbiAgICBkZWZpbmU6IHtcbiAgICAgIF9fQVBQX0lORk9fXzogSlNPTi5zdHJpbmdpZnkoX19BUFBfSU5GT19fKSxcbiAgICB9LFxuICAgIHJlc29sdmU6IHtcbiAgICAgIGFsaWFzOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBmaW5kOiBcIkBcIixcbiAgICAgICAgICByZXBsYWNlbWVudDogcmVzb2x2ZShfX2Rpcm5hbWUsIFwiLi9zcmNcIiksXG4gICAgICAgIH0sXG4gICAgICBdLFxuICAgIH0sXG4gICAgcGx1Z2luczogW1xuICAgICAgdnVlKCksXG4gICAgICB2dWVKc3goKSxcbiAgICAgIHRvcExldmVsQXdhaXQoe1xuICAgICAgICAvLyBUaGUgZXhwb3J0IG5hbWUgb2YgdG9wLWxldmVsIGF3YWl0IHByb21pc2UgZm9yIGVhY2ggY2h1bmsgbW9kdWxlXG4gICAgICAgIHByb21pc2VFeHBvcnROYW1lOiBcIl9fdGxhXCIsXG4gICAgICAgIC8vIFRoZSBmdW5jdGlvbiB0byBnZW5lcmF0ZSBpbXBvcnQgbmFtZXMgb2YgdG9wLWxldmVsIGF3YWl0IHByb21pc2UgaW4gZWFjaCBjaHVuayBtb2R1bGVcbiAgICAgICAgcHJvbWlzZUltcG9ydE5hbWU6IChpKSA9PiBgX190bGFfJHtpfWAsXG4gICAgICB9KSxcbiAgICBdLFxuICAgIHNlcnZlcjoge1xuICAgICAgaG9zdDogXCIwLjAuMC4wXCIsXG4gICAgICBwb3J0OiA4MDg4LFxuICAgIH0sXG4gIH07XG59O1xuIiwgIntcclxuICBcIm5hbWVcIjogXCJ3ZWJtYXN0ZXJcIixcclxuICBcInByaXZhdGVcIjogdHJ1ZSxcclxuICBcInZlcnNpb25cIjogXCIwLjAuMFwiLFxyXG4gIFwidHlwZVwiOiBcIm1vZHVsZVwiLFxyXG4gIFwic2NyaXB0c1wiOiB7XHJcbiAgICBcImRldlwiOiBcInZpdGVcIixcclxuICAgIFwiYnVpbGRcIjogXCJ2dWUtdHNjICYmIHZpdGUgYnVpbGRcIixcclxuICAgIFwicHJldmlld1wiOiBcInZpdGUgcHJldmlld1wiXHJcbiAgfSxcclxuICBcImRlcGVuZGVuY2llc1wiOiB7XHJcbiAgICBcIkBhbnQtZGVzaWduL2ljb25zLXZ1ZVwiOiBcIl43LjAuMVwiLFxyXG4gICAgXCJAdHlwZXMvY3J5cHRvLWpzXCI6IFwiXjQuMi4yXCIsXHJcbiAgICBcIkB0eXBlcy9sb2Rhc2gtZXNcIjogXCJeNC4xNy4xMlwiLFxyXG4gICAgXCJAdHlwZXMvbm9kZVwiOiBcIl4yMC4xMi4xMVwiLFxyXG4gICAgXCJAdHlwZXMvdXVpZFwiOiBcIl45LjAuOFwiLFxyXG4gICAgXCJAdml0ZWpzL3BsdWdpbi12dWUtanN4XCI6IFwiXjMuMS4wXCIsXHJcbiAgICBcIkB2dWV1c2UvY29yZVwiOiBcIl4xMC45LjBcIixcclxuICAgIFwiQHdvcnN0b25lL3Z1ZS1hcGxheWVyXCI6IFwiXjEuMC42XCIsXHJcbiAgICBcImFuaW1hdGUuY3NzXCI6IFwiXjQuMS4xXCIsXHJcbiAgICBcImFudC1kZXNpZ24tdnVlXCI6IFwiXjQuMi4xXCIsXHJcbiAgICBcImF4aW9zXCI6IFwiXjEuNi44XCIsXHJcbiAgICBcImNvcnNcIjogXCJeMi44LjVcIixcclxuICAgIFwiY3J5cHRvLWpzXCI6IFwiXjQuMi4wXCIsXHJcbiAgICBcImdzYXBcIjogXCJeMy4xMi41XCIsXHJcbiAgICBcImhvd2xlclwiOiBcIl4yLjIuNFwiLFxyXG4gICAgXCJqc2VuY3J5cHRcIjogXCJeMy4zLjJcIixcclxuICAgIFwibG9kYXNoLWVzXCI6IFwiXjQuMTcuMjFcIixcclxuICAgIFwicGluaWFcIjogXCJeMi4xLjdcIixcclxuICAgIFwic29ydGFibGVqc1wiOiBcIl4xLjE1LjJcIixcclxuICAgIFwidXVpZFwiOiBcIl45LjAuMVwiLFxyXG4gICAgXCJ2aXRlLXBsdWdpbi10b3AtbGV2ZWwtYXdhaXRcIjogXCJeMS40LjFcIixcclxuICAgIFwidnVlXCI6IFwiXjMuNC4yN1wiLFxyXG4gICAgXCJ2dWUtaTE4blwiOiBcIl45LjEzLjFcIixcclxuICAgIFwidnVlLXJlcXVlc3RcIjogXCJeMi4wLjRcIixcclxuICAgIFwidnVlLXJvdXRlclwiOiBcIl40LjAuMTNcIixcclxuICAgIFwidnVlMy1waG90by1wcmV2aWV3XCI6IFwiXjAuMy4wXCIsXHJcbiAgICBcInZ1ZTMtdmlkZW8tcGxheVwiOiBcIl4xLjMuMVwiLFxyXG4gICAgXCJ3b3cuanNcIjogXCJeMS4yLjJcIlxyXG4gIH0sXHJcbiAgXCJkZXZEZXBlbmRlbmNpZXNcIjoge1xyXG4gICAgXCJAdHlwZXMvaG93bGVyXCI6IFwiXjIuMi4xMVwiLFxyXG4gICAgXCJAdHlwZXMvc29ydGFibGVqc1wiOiBcIl4xLjE1LjhcIixcclxuICAgIFwiQHZpdGVqcy9wbHVnaW4tdnVlXCI6IFwiXjUuMC40XCIsXHJcbiAgICBcImxlc3NcIjogXCJeNC4yLjBcIixcclxuICAgIFwidHlwZXNjcmlwdFwiOiBcIl41LjIuMlwiLFxyXG4gICAgXCJ2aXRlXCI6IFwiXjUuMi4wXCIsXHJcbiAgICBcInZ1ZS10c2NcIjogXCJeMi4wLjZcIlxyXG4gIH1cclxufVxyXG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQTJPLFNBQVMsZUFBZTtBQUNuUSxTQUFTLGVBQWU7QUFFeEIsT0FBTyxTQUFTO0FBQ2hCLE9BQU8sWUFBWTtBQUVuQixPQUFPLG1CQUFtQjtBQUUxQixPQUFPLFdBQVc7OztBQ1JsQjtBQUFBLEVBQ0UsTUFBUTtBQUFBLEVBQ1IsU0FBVztBQUFBLEVBQ1gsU0FBVztBQUFBLEVBQ1gsTUFBUTtBQUFBLEVBQ1IsU0FBVztBQUFBLElBQ1QsS0FBTztBQUFBLElBQ1AsT0FBUztBQUFBLElBQ1QsU0FBVztBQUFBLEVBQ2I7QUFBQSxFQUNBLGNBQWdCO0FBQUEsSUFDZCx5QkFBeUI7QUFBQSxJQUN6QixvQkFBb0I7QUFBQSxJQUNwQixvQkFBb0I7QUFBQSxJQUNwQixlQUFlO0FBQUEsSUFDZixlQ Line truncated