ow
This commit is contained in:
1 parent
4f57b65451
commit
eae4296980
6 files changed
+722
-259
No files matched your search
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user