<add>新增将弹幕数据保存到本地文件的功能
This commit is contained in:
1 parent
5159103ffe
commit
8ac77a8377
6 files changed
+406
-5
No files matched your search
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -29,10 +29,10 @@ import { getSignature } from './signature';
|
||||
|
||||
/**
|
||||
* 连接状态
|
||||
* 0 - 未连接
|
||||
* 1 - 连接中(连接完成)
|
||||
* 2 - 连接失败
|
||||
* 3 - 已断开
|
||||
* - 0 - 未连接
|
||||
* - 1 - 连接中(连接完成)
|
||||
* - 2 - 连接失败
|
||||
* - 3 - 已断开
|
||||
*/
|
||||
export type ConnectStatus = 0 | 1 | 2 | 3;
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user