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);
+ });
+};