<add>新增将弹幕数据保存到本地文件的功能

This commit is contained in:
skmcj committed 2025-05-28 17:46:53 +08:00
1 parent 5159103ffe
commit 8ac77a8377
6 files changed
+406 -5

No files matched your search

Vendored
+36
View File
@@ -1 +1,37 @@
/// <reference types="vite/client" />
// global.d.ts 或 index.d.ts 中
interface FilePickerAcceptType {
description?: string;
accept: Record<string, string[]>;
}
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<void>;
close(): Promise<void>;
}
interface FileSystemFileHandle {
kind: 'file';
name: string;
getFile(): Promise<File>;
createWritable(): Promise<FileSystemWritableFileStream>;
}
interface Window {
showOpenFilePicker(options?: OpenFilePickerOptions): Promise<FileSystemFileHandle[]>;
showSaveFilePicker(options?: SaveFilePickerOptions): Promise<FileSystemFileHandle>;
}
+1 -1
View File
@@ -321,7 +321,7 @@ $lrMargin: 12px;
@media (max-width: 768px) {
.sk-message-content {
text-align: center;
width: min-content;
// width: min-content;
}
}
</style>
+4 -4
View File
@@ -29,10 +29,10 @@ import { getSignature } from './signature';
/**
* 连接状态
* 0 - 未连接
* 1 - 连接中(连接完成)
* 2 - 连接失败
* 3 - 已断开
* - 0 - 未连接
* - 1 - 连接中(连接完成)
* - 2 - 连接失败
* - 3 - 已断开
*/
export type ConnectStatus = 0 | 1 | 2 | 3;
+41
View File
@@ -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<string, number | string> = {
'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;
};
+250
View File
@@ -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<string, string> = {
'.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<FileSaveResult> {
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<FileSaveResult> {
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);
// 创建 <a> 标签
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;
+74
View File
@@ -11,6 +11,11 @@
:user-count="userCount"
:like-count="likeCount" />
<div class="view-left-bottom">
<div class="view-left-tools">
<div class="view-left-tool" title="保存弹幕" @click.stop="saveCastToFile">
<i class="ice-save"></i>
</div>
</div>
<hr class="hr" />
<LiveStatusPanel ref="panel" :status="connectStatus" />
</div>
@@ -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<ConnectStatus>(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);
});
};
</script>
<style lang="scss" scoped>
$bg: #f7f6f5;
$bd: #b2bfc3;
$theme: #68be8d;
$tool: #8b968d;
.index-view {
position: relative;
@@ -426,6 +470,36 @@ $bd: #b2bfc3;
margin: 5px 0;
}
}
.view-left-tools {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
box-sizing: border-box;
padding: 0 12px;
}
.view-left-tool {
font-size: 21px;
width: 1.2em;
height: 1.2em;
display: inline-flex;
align-items: center;
justify-content: center;
color: $tool;
transition: color 0.2s ease-in-out, background-color 0.3s ease-in-out, opacity 0.2s ease;
background-color: transparent;
border-radius: 0.4em;
i {
font-size: 1em;
}
&:hover {
color: #fff;
background-color: $theme;
}
&:active {
opacity: 0.8;
}
}
.view-center {
flex-grow: 4.5;
padding: 18px 12px;