From 8ac77a837751cd9d7b967b29a1b4776b1d370eb9 Mon Sep 17 00:00:00 2001 From: skmcj <2604597248@qq.com> Date: Wed, 28 May 2025 17:46:53 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=B0=86=E5=BC=B9?= =?UTF-8?q?=E5=B9=95=E6=95=B0=E6=8D=AE=E4=BF=9D=E5=AD=98=E5=88=B0=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=96=87=E4=BB=B6=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- env.d.ts | 36 +++++ src/components/Message/message.vue | 2 +- src/core/dycast.ts | 8 +- src/utils/commonUtil.ts | 41 +++++ src/utils/fileUtil.ts | 250 +++++++++++++++++++++++++++++ src/views/IndexView.vue | 74 +++++++++ 6 files changed, 406 insertions(+), 5 deletions(-) create mode 100644 src/utils/commonUtil.ts create mode 100644 src/utils/fileUtil.ts diff --git a/env.d.ts b/env.d.ts index 11f02fe..c214ec1 100644 --- a/env.d.ts +++ b/env.d.ts @@ -1 +1,37 @@ /// + +// global.d.ts 或 index.d.ts 中 + +interface FilePickerAcceptType { + description?: string; + accept: Record; +} + +interface OpenFilePickerOptions { + multiple?: boolean; + types?: FilePickerAcceptType[]; + excludeAcceptAllOption?: boolean; +} + +interface SaveFilePickerOptions { + suggestedName?: string; + types?: FilePickerAcceptType[]; + excludeAcceptAllOption?: boolean; +} + +interface FileSystemWritableFileStream extends WritableStream { + write(data: Blob | BufferSource | string): Promise; + close(): Promise; +} + +interface FileSystemFileHandle { + kind: 'file'; + name: string; + getFile(): Promise; + createWritable(): Promise; +} + +interface Window { + showOpenFilePicker(options?: OpenFilePickerOptions): Promise; + showSaveFilePicker(options?: SaveFilePickerOptions): Promise; +} diff --git a/src/components/Message/message.vue b/src/components/Message/message.vue index 107c638..d8c994b 100644 --- a/src/components/Message/message.vue +++ b/src/components/Message/message.vue @@ -321,7 +321,7 @@ $lrMargin: 12px; @media (max-width: 768px) { .sk-message-content { text-align: center; - width: min-content; + // width: min-content; } } diff --git a/src/core/dycast.ts b/src/core/dycast.ts index dbf7964..5181dd2 100644 --- a/src/core/dycast.ts +++ b/src/core/dycast.ts @@ -29,10 +29,10 @@ import { getSignature } from './signature'; /** * 连接状态 - * 0 - 未连接 - * 1 - 连接中(连接完成) - * 2 - 连接失败 - * 3 - 已断开 + * - 0 - 未连接 + * - 1 - 连接中(连接完成) + * - 2 - 连接失败 + * - 3 - 已断开 */ export type ConnectStatus = 0 | 1 | 2 | 3; diff --git a/src/utils/commonUtil.ts b/src/utils/commonUtil.ts new file mode 100644 index 0000000..651047c --- /dev/null +++ b/src/utils/commonUtil.ts @@ -0,0 +1,41 @@ +/** + * 格式化日期 + * @param date {Date} 日期 + * @param format {string} 格式化字符串 + * - y:年,M:月,d:日 + * - h:时(12),H:时(24),m:分,s:秒 + * - q:季度,a:上午|下午,A:AM|PM + * - w:星期(EN),W:星期(CN) + * - 例:'yyyy-MM-dd W' = '1970-01-01 星期四' + */ +export const formatDate = function (date: Date, format: string = 'HH:mm') { + const re = /(y+)/; + if (re.test(format)) { + const t = re.exec(format)![1]; + format = format.replace(t, (date.getFullYear() + '').substring(4 - t.length)); + } + const CW = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; + const EW = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const o: Record = { + 'M+': date.getMonth() + 1, // 月 + 'd+': date.getDate(), // 日 + 'h+': date.getHours() % 12 === 0 ? 12 : date.getHours() % 12, // 小时[12] + 'H+': date.getHours(), // 小时[24] + 'm+': date.getMinutes(), // 分 + 's+': date.getSeconds(), // 秒 + 'q+': Math.floor((date.getMonth() + 3) / 3), // 季度 + 'S+': date.getMilliseconds(), // 毫秒 + a: date.getHours() < 12 ? '上午' : '下午', // 上午/下午 + A: date.getHours() < 12 ? 'AM' : 'PM', // AM/PM + w: EW[date.getDay()], + W: CW[date.getDay()] + }; + for (let k in o) { + const regx = new RegExp('(' + k + ')'); + if (regx.test(format)) { + const t = regx.exec(format)![1]; + format = format.replace(t, t.length === 1 ? `${o[k]}` : `00${o[k]}`.slice(t.length * -1)); + } + } + return format; +}; diff --git a/src/utils/fileUtil.ts b/src/utils/fileUtil.ts new file mode 100644 index 0000000..111f2f2 --- /dev/null +++ b/src/utils/fileUtil.ts @@ -0,0 +1,250 @@ +/** 文件保存的配置 */ +export interface FileSaveOptions { + name?: string; // 文件名称(不含扩展名) + ext?: string; // 文件扩展名(如 '.json') + mimeType?: string; // 指定 MIME 类型,优先使用 + description?: string; // 保存对话框中文件类型描述 + forceFallback?: boolean; // 是否强制使用传统下载方式 + existStrategy?: 'prompt' | 'overwrite' | 'new'; // 文件存在时的处理方式 +} + +export interface FileSaveResult { + success: boolean; // 是否保存成功 + fileHandle?: FileSystemFileHandle | null; // FSA 模式下的文件句柄 + error?: Error; // 错误对象 + message?: string; // 提示信息 +} + +// 常见扩展名与 MIME 类型的映射表 +const mimeMap: Record = { + '.txt': 'text/plain', + '.json': 'application/json', + '.html': 'text/html', + '.htm': 'text/html', + '.csv': 'text/csv', + '.xml': 'application/xml', + '.js': 'application/javascript', + '.ts': 'application/typescript', + '.css': 'text/css', + '.md': 'text/markdown', + '.pdf': 'application/pdf', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.zip': 'application/zip', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4' +}; + +/** + * FileSaver 文件保存工具 + * - 支持现代浏览器的 File System Access API (只能在安全上下文使用https|localhost) + * - 支持传统下载方式 + * - 支持自动根据扩展名识别 MIME 类型 + */ +class FileSaver { + static async save(content: string | Blob | ArrayBuffer, options: FileSaveOptions = {}): Promise { + const { + // 默认文件名为 'untitled' + name = 'untitled', + // 默认扩展名为空 + ext = '', + // 如果用户传入了 mimeType 就使用它 + mimeType, + // 文件类型描述默认是 'File' + description = 'File', + // 是否强制使用 fallback (即原始的保存方式,创建标签) + forceFallback = false, + // 默认提示用户是否覆盖 + existStrategy = 'prompt' + } = options; + + // 自动根据扩展名推断 MIME 类型(如果未提供) + const inferredMime = mimeType || mimeMap[ext.toLowerCase()] || 'application/octet-stream'; + + try { + // 如果支持 File System Access API 且不强制使用 fallback,则优先使用现代方式 + if (!forceFallback && this.isFSAvailable()) { + return await this.saveWithFSA(content, { + name, + ext, + mimeType: inferredMime, + description, + existStrategy + }); + } + + // 否则使用传统下载方式(a 标签方式) + return this.saveWithFallback(content, { + name: this.ensureExtension(name, ext), + mimeType: inferredMime + }); + } catch (error) { + return { + success: false, + error: error as Error, + message: '文件保存失败' + }; + } + } + + /** + * 使用 File System Access API 保存文件 + * @param content + * @param options + * @returns + */ + private static async saveWithFSA( + content: string | Blob | ArrayBuffer, + options: { + name: string; + ext: string; + mimeType: string; + description: string; + existStrategy: 'prompt' | 'overwrite' | 'new'; + } + ): Promise { + try { + // 确保文件名有扩展名 + const fileName = this.ensureExtension(options.name, options.ext); + let fileHandle: FileSystemFileHandle | null = null; + + // 如果不是强制创建新文件,可以选择已有文件 + if (options.existStrategy !== 'new') { + try { + [fileHandle] = await window.showOpenFilePicker({ + types: [ + { + description: options.description, + accept: { [options.mimeType]: [options.ext] } + } + ] + }); + + // 如果策略为提示且用户拒绝覆盖,则取消操作 + if (options.existStrategy === 'prompt' && !confirm(`文件 "${fileHandle.name}" 已存在,是否覆盖?`)) { + return { + success: false, + fileHandle: null, + message: '用户取消覆盖' + }; + } + } catch { + // 未选择文件或用户取消,继续新建流程 + } + } + + // 没有文件句柄时,新建文件 + if (!fileHandle) { + fileHandle = await window.showSaveFilePicker({ + suggestedName: fileName, + types: [ + { + description: options.description, + accept: { [options.mimeType]: [options.ext] } + } + ] + }); + } + // 获取可写流 + const writable = await fileHandle.createWritable(); + // 内容转换成 Blob + const blob = this.normalizeContentToBlob(content, options.mimeType); + // 写入内容 + await writable.write(blob); + // 关闭流 + await writable.close(); + + return { + success: true, + fileHandle, + message: '文件保存成功' + }; + } catch (error) { + // 用户取消操作的处理 + if ((error as Error).name === 'AbortError') { + return { + success: false, + fileHandle: null, + message: '用户取消操作' + }; + } + throw error; + } + } + + /** + * 传统方式保存文件 + * @param content + * @param options + * @returns + */ + private static saveWithFallback( + content: string | Blob | ArrayBuffer, + options: { name: string; mimeType: string } + ): FileSaveResult { + try { + // 内容转为 Blob + const blob = this.normalizeContentToBlob(content, options.mimeType); + // 创建临时 URL + const url = URL.createObjectURL(blob); + // 创建 标签 + const a = document.createElement('a'); + // 设置下载链接 + a.href = url; + // 设置下载文件名 + a.download = options.name; + // 隐藏标签 + a.style.display = 'none'; + // 添加到页面中 + document.body.appendChild(a); + // 触发下载 + a.click(); + // 清理标签 + document.body.removeChild(a); + // 释放 URL + URL.revokeObjectURL(url); + + return { + success: true, + fileHandle: null, + message: '文件下载开始' + }; + } catch (error) { + return { + success: false, + error: error as Error, + message: '传统下载方式失败' + }; + } + } + + // 判断当前浏览器是否支持 File System Access API + private static isFSAvailable(): boolean { + return 'showSaveFilePicker' in window && 'showOpenFilePicker' in window && 'FileSystemFileHandle' in window; + } + + // 确保文件名带有扩展名 + private static ensureExtension(filename: string, ext: string): string { + return filename.endsWith(ext) ? filename : `${filename}${ext}`; + } + + // 将内容转换为标准 Blob 对象,便于写入或下载 + private static normalizeContentToBlob(content: string | Blob | ArrayBuffer, mimeType: string): Blob { + if (typeof content === 'string') { + return new Blob([content], { type: mimeType }); // 文本转 Blob + } + if (content instanceof Blob) { + return content; // 已是 Blob,直接返回 + } + if (content instanceof ArrayBuffer) { + return new Blob([new Uint8Array(content)], { type: mimeType }); // 二进制转 Blob + } + throw new Error('Unsupported content type'); // 不支持的类型 + } +} + +export default FileSaver; diff --git a/src/views/IndexView.vue b/src/views/IndexView.vue index 9252960..44988e2 100644 --- a/src/views/IndexView.vue +++ b/src/views/IndexView.vue @@ -11,6 +11,11 @@ :user-count="userCount" :like-count="likeCount" />
+
+
+ +
+

@@ -69,6 +74,8 @@ import { CLog } from '@/utils/logUtil'; import { getId } from '@/utils/idUtil'; import { RelayCast } from '@/core/relay'; import SkMessage from '@/components/Message'; +import { formatDate } from '@/utils/commonUtil'; +import FileSaver from '@/utils/fileUtil'; // 连接状态 const connectStatus = ref(0); @@ -386,11 +393,48 @@ const relayCast = function () { const stopRelayCast = function () { if (relayWs) relayWs.close(1000); }; + +/** 将弹幕保存到本地文件 */ +const saveCastToFile = function () { + if (connectStatus.value === 1) { + SkMessage.warning('请断开连接后再保存'); + return; + } + const len = allCasts.length; + if (len <= 0) { + SkMessage.warning('暂无弹幕需要保存'); + return; + } + const date = formatDate(new Date(), 'yyyy-MM-dd_HHmmss'); + const fileName = `[${roomNum.value}]${date}(${len})`; + const data = JSON.stringify(allCasts, null, 2); + FileSaver.save(data, { + name: fileName, + ext: '.json', + mimeType: 'application/json', + description: '弹幕数据', + existStrategy: 'new' + }) + .then(res => { + if (res.success) { + SkMessage.success('弹幕保存成功'); + } else { + SkMessage.error('弹幕保存失败'); + CLog.error('弹幕保存失败 =>', res.message); + } + }) + .catch(err => { + SkMessage.error('弹幕保存出错了'); + CLog.error('弹幕保存出错了 =>', err); + }); +};