feat: 三栏比例缩放 + SeedanceContent视频上传 + 本地存储基建 (0.0.22)

三栏布局
- ResizeObserver 等比例分配替代固定默认值恢复
- 修复拖拽+窗口调整竞态卡死(needsRecalcRef 延迟补算)
- containerRef.current 动态读取 DOM 替代闭包 el

Seedance2Content
- 从占位文本重构为内容编排控件(文本+图片/视频/音频上传)
- 根据 spec.image_files/video_files/audio_files 动态渲染上传区域
- 值 JSON 序列化 → 提交时转 ContentItem[](text/image_url/video_url/audio_url)

图片上传兼容视频文件
- buildAccept 根据 file_filter 区分 image/video 前缀
- createMediaBeforeUpload 动态放行 video/* MIME
- 公共模块 media-upload-utils.ts

本地存储基建
- sql.js (WASM SQLite) + safeStorage 加密持久化
- 8个IPC文件操作通道 + resolveSafe 路径穿越防护
- <userData>/heixiu-data/ 沙箱 + JWT用户隔离
- 7表DDL + 游标分页 + LRU媒体淘汰

其他
- 修复 errorMessageRef 渲染期写入警告
- 媒体预览回退直接URL(<img>无CORS限制)+ output_type类型补充
- CSP wasm-unsafe-eval + https: CDN媒体源
This commit is contained in:
2026-06-18 19:22:28 +08:00
parent e1c8eff946
commit e4c9ae8fc6
42 changed files with 2640 additions and 126 deletions

View File

@@ -1,6 +1,52 @@
# 更新日志
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
---
## 0.0.222026-06-18
**三栏布局比例缩放 + SeedanceContent 视频参考上传 + 本地存储基建**
### 三栏布局:比例自适应缩放
- ResizeObserver 改为**等比例分配**策略(替代固定默认值恢复):窗口 Snap/最大化/手动缩放时左-中-右三栏按当前比例同步缩放
- 修复拖拽期间窗口缩放竞态:`handleMouseUp` 即时清除 `draggingRef` + `needsRecalcRef` 延迟补算,解决拖拽中最大化后放开鼠标布局卡死
- 使用 `containerRef.current` 动态读取 DOM替代闭包捕获 mount 时 `el`),避免 Provider 重渲染后读到旧 DOM
### Seedance2Content 内容编排
- `SeedanceContent` 从占位文本重构为完整内容编排控件:文本描述 + 参考图片/视频/音频三条上传通道
- 根据 `param_schema[].spec.image_files / video_files / audio_files` 动态渲染对应上传区域,**不依赖 widget 名称的隐式约定**
- 内部值类型 `{ text, imageUrls[], videoUrls[], audioUrls[] }` → 表单 JSON 字符串 → 提交时序列化为 API `ContentItem[]``text` / `image_url` / `video_url` / `audio_url` 判别联合)
- `useModelUI` 新增 `widgetName` 字段 + `spec` 媒体文件计数传参(`imageFiles`/`videoFiles`/`audioFiles`/`uploadProvider`
- `ModelInputForm.doSubmit` 新增 `seedance2Content` 分支序列化逻辑
### 图片上传兼容视频文件
- `ImageInput` / `ImageListInput``buildAccept` 根据 `file_filter` 自动区分 `image/` vs `video/` 前缀(原硬编码 `image/` 导致视频文件不可选)
- `createMediaBeforeUpload` 根据 `filters` 动态放行 `video/*` MIME
- 公共模块 `media-upload-utils.ts``VIDEO_EXTENSIONS` + `buildAccept()` + `createMediaBeforeUpload()` 三组件共享
### 本地存储基建sql.js + safeStorage
- **数据库**sql.jsWASM SQLite渲染进程内存数据库DDL 7 张表task_records / order_records / param_history / media_cache / sync_cursor / app_settings / _schema_version
- **加密**safeStorageOS DPAPI/Keychain加密持久化base64 编解码管线
- **IPC 文件操作**8 个双向 IPC 通道FILE_WRITE/READ/DELETE/MAKE_DIR/SHOW_ITEM_IN_FOLDER/GET_DATA_DIR/FILE_SIZE/FILE_EXISTS`resolveSafe()` 路径穿越防护
- **数据目录**`<userData>/heixiu-data/`(沙箱内),含 `heixiu.db`(加密镜像)+ `media-cache/<userId>/<date>/`
- **用户隔离**JWT `user_id` 区分数据,`getTodayKey()` 日期分桶
- **同步策略**:本地优先 + 手动触发("加载历史数据"按钮),游标分页(`sync_cursor` 表),每批 100 条
- **设置集成**`DataManageSetting` 卡片(缓存统计 + 加载历史数据 + 保存到磁盘 + 清理缓存)
### 媒体输出预览优化
- `<img>/<video>` 直接加载远程 URL无 CORS 限制),移除阻塞式 axios 下载
- 类型检测URL 扩展名正则 + `output_type` 补充CDN 无扩展名链接)
- `<Image fallback>` 加载失败占位图
### 其他
- 修复 `errorMessageRef` 在渲染期间写入react-hooks/refs 警告)
- CSP 添加 `wasm-unsafe-eval` 支持 WebAssembly + `https:` CDN 媒体源
---
## 0.0.212026-06-15

11
TODO.md
View File

@@ -1,6 +1,6 @@
# TODO — 船长·HeiXiu 待办与优化清单
> 最后更新2026-06-15
> 最后更新2026-06-18
---
@@ -21,6 +21,15 @@
- [ ] 项目管理页面(`/projects`,企业版)
- [ ] 会员页面(`/vip`
- [ ] 消费记录页面
- [x] 本地数据管理(加载历史/保存到磁盘/清理缓存) — `DataManageSetting` + sql.js 存储
### 媒体与文件
- [ ] 媒体文件本地下载+缓存(主进程 HTTP 无 CORS 限制,替代渲染进程 axios
- [ ] CDN 签名过期检测(主进程发 HEAD 请求读 `SignatureExpired` 响应体)
- [ ] 列表页"下一页"懒加载集成TaskHistory / OrdersPage 调用 `fetchAndSync*`
- [ ] 参数回填 UI选择模型时显示历史参数下拉
- [ ] 右键"在文件夹中查看"功能(媒体预览右键菜单)
### 导航栏

View File

@@ -12,6 +12,7 @@ import {setupAppMenu} from './main/menu';
import {initLogger, flushLogger, logger} from './main/logger';
import {registerLogIpcHandlers} from './main/log-ipc';
import {registerSafeStorageIpcHandlers} from './main/safe-storage-ipc';
import {registerStorageIpcHandlers} from './main/storage-ipc';
import {BIDIRECTIONAL} from '../shared/constants/ipc-channels';
createRequire(import.meta.url);
@@ -148,6 +149,7 @@ app.whenReady().then(() => {
initLogger();
registerLogIpcHandlers();
registerSafeStorageIpcHandlers();
registerStorageIpcHandlers();
setupAppMenu();
app.setName(WINDOW_TITLE);
registerUpdateIpcHandlers();

View File

@@ -0,0 +1,161 @@
// ============================================================
// storage-ipc.ts — 本地存储 IPC 处理器(主进程)
//
// 职责:
// - 文件写入/读取/删除Buffer 基础操作)
// - 递归创建目录
// - shell.showItemInFolder右键"在文件夹中查看"
// - 获取应用数据目录路径
// - 所有操作在 heixiu-data/ 沙箱内进行
//
// 安全:
// - 路径校验:拒绝相对路径中的 ../ 穿越
// - 所有写入操作 base 于 getDataDir()
// ============================================================
import { ipcMain, shell, app } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import { BIDIRECTIONAL } from '../../shared/constants/ipc-channels';
import { logger } from './logger';
// ---------- 内部工具 ----------
/** 获取 heixiu-data 根目录 */
function getDataDir(): string {
return path.join(app.getPath('userData'), 'heixiu-data');
}
/**
* 将相对路径解析为绝对路径,并校验是否在 dataDir 内。
* 防止路径穿越攻击。
*/
function resolveSafe(dataDir: string, relativePath: string): string {
const resolved = path.resolve(dataDir, relativePath);
if (!resolved.startsWith(dataDir + path.sep) && resolved !== dataDir) {
throw new Error(`路径越界:${relativePath}`);
}
return resolved;
}
/** 确保目录存在(递归创建) */
function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
// ---------- 注册 IPC 处理器 ----------
export function registerStorageIpcHandlers(): void {
const dataDir = getDataDir();
ensureDir(dataDir);
logger.info('app', `本地存储目录:${dataDir}`);
// ---- 获取数据目录 ----
ipcMain.handle(BIDIRECTIONAL.GET_DATA_DIR, () => {
return { success: true, data: dataDir };
});
// ---- 文件写入 ----
ipcMain.handle(
BIDIRECTIONAL.FILE_WRITE,
async (
_event,
params: { relativePath: string; data: string; encoding?: 'base64' | 'utf8' },
) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
ensureDir(path.dirname(filePath));
const buffer = Buffer.from(params.data, params.encoding || 'base64');
fs.writeFileSync(filePath, buffer);
console.log(`[storage IPC] 文件写入成功:${filePath}${buffer.length} bytes`);
return { success: true, data: filePath };
} catch (err) {
const msg = (err as Error).message;
console.error(`[storage IPC] 文件写入失败:${params.relativePath}${msg}`);
logger.warn('storage', `文件写入失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 文件读取(返回 Base64 ----
ipcMain.handle(BIDIRECTIONAL.FILE_READ, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
if (!fs.existsSync(filePath)) {
return { success: false, error: '文件不存在' };
}
const buffer = fs.readFileSync(filePath);
return { success: true, data: buffer.toString('base64') };
} catch (err) {
logger.warn('storage', `文件读取失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 文件删除 ----
ipcMain.handle(BIDIRECTIONAL.FILE_DELETE, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
return { success: true };
} catch (err) {
logger.warn('storage', `文件删除失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 创建目录 ----
ipcMain.handle(BIDIRECTIONAL.MAKE_DIR, async (_event, params: { relativePath: string }) => {
try {
const dirPath = resolveSafe(dataDir, params.relativePath);
ensureDir(dirPath);
return { success: true, data: dirPath };
} catch (err) {
logger.warn('storage', `目录创建失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 在文件管理器中显示 ----
ipcMain.handle(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, async (_event, params: { filePath: string }) => {
try {
shell.showItemInFolder(params.filePath);
return { success: true };
} catch (err) {
logger.warn('storage', `showItemInFolder 失败:${params.filePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 文件大小 ----
ipcMain.handle(BIDIRECTIONAL.FILE_SIZE, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
if (!fs.existsSync(filePath)) {
return { success: false, error: '文件不存在' };
}
const stat = fs.statSync(filePath);
return { success: true, data: stat.size };
} catch (err) {
return { success: false, error: (err as Error).message };
}
});
// ---- 文件/目录是否存在 ----
ipcMain.handle(BIDIRECTIONAL.FILE_EXISTS, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
return { success: true, data: fs.existsSync(filePath) };
} catch {
return { success: true, data: false };
}
});
logger.info('app', '存储 IPC 处理器已注册');
}

View File

@@ -43,3 +43,47 @@ contextBridge.exposeInMainWorld('safeStorage', {
return result?.success ? result.data : null;
},
});
// --------- 本地存储文件 API主进程 fs 桥接)---------
contextBridge.exposeInMainWorld('fileStorage', {
/** 获取 heixiu-data 根目录路径 */
getDataDir(): Promise<string | null> {
return ipcRenderer.invoke(BIDIRECTIONAL.GET_DATA_DIR)
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
},
/** 写入文件Base64 → 磁盘),返回写入的绝对路径 */
writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE, { relativePath, data, encoding })
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
},
/** 读取文件(磁盘 → Base64 */
readFile(relativePath: string): Promise<string | null> {
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_READ, { relativePath })
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
},
/** 删除文件 */
deleteFile(relativePath: string): Promise<boolean> {
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_DELETE, { relativePath })
.then((r: { success: boolean }) => r?.success ?? false);
},
/** 递归创建目录 */
makeDir(relativePath: string): Promise<string | null> {
return ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR, { relativePath })
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
},
/** 在文件管理器中显示文件(绝对路径) */
showItemInFolder(filePath: string): Promise<boolean> {
return ipcRenderer.invoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath })
.then((r: { success: boolean }) => r?.success ?? false);
},
/** 获取文件大小(字节) */
fileSize(relativePath: string): Promise<number | null> {
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_SIZE, { relativePath })
.then((r: { success: boolean; data: number }) => r?.success ? r.data : null);
},
/** 检查文件/目录是否存在 */
fileExists(relativePath: string): Promise<boolean> {
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_EXISTS, { relativePath })
.then((r: { success: boolean; data: boolean }) => r?.success ? r.data : false);
},
});

View File

@@ -7,10 +7,11 @@
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />-->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'unsafe-inline';
script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://rh-images-1252422369.cos.ap-beijing.myqcloud.com https://www.heixiu.net ; ;
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net ;
img-src 'self' data: https: ;
media-src 'self' https: ;
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net https: ;
">
<title>船长·HeiXiu</title>
</head>

32
package-lock.json generated
View File

@@ -1,19 +1,20 @@
{
"name": "ele-heixiu",
"version": "0.0.13",
"version": "0.0.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ele-heixiu",
"version": "0.0.13",
"version": "0.0.18",
"dependencies": {
"@react-buddy/ide-toolbox": "^2.5.0",
"antd": "^6.4.3",
"axios": "^1.16.1",
"electron-updater": "^6.8.3",
"react": "^19.2.7",
"react-dom": "^19.2.7"
"react-dom": "^19.2.7",
"sql.js": "^1.14.1"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
@@ -22,6 +23,7 @@
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"@types/sql.js": "^1.4.11",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"@vitejs/plugin-react": "^6.0.0",
@@ -2594,6 +2596,13 @@
"@types/node": "*"
}
},
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -2701,6 +2710,17 @@
"@types/node": "*"
}
},
"node_modules/@types/sql.js": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz",
"integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/emscripten": "*",
"@types/node": "*"
}
},
"node_modules/@types/verror": {
"version": "1.10.11",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
@@ -7599,6 +7619,12 @@
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT"
},
"node_modules/stat-mode": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",

View File

@@ -31,7 +31,8 @@
"axios": "^1.16.1",
"electron-updater": "^6.8.3",
"react": "^19.2.7",
"react-dom": "^19.2.7"
"react-dom": "^19.2.7",
"sql.js": "^1.14.1"
},
"build": {
"appId": "com.heixiu.electron",
@@ -94,6 +95,7 @@
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"@types/sql.js": "^1.4.11",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"@vitejs/plugin-react": "^6.0.0",

Binary file not shown.

BIN
public/sql-wasm.wasm Normal file

Binary file not shown.

View File

@@ -46,4 +46,20 @@ export const BIDIRECTIONAL = {
SAFESTORAGE_ENCRYPT: 'safe-storage:encrypt',
/** safeStorage 解密Base64 密文 → 明文) */
SAFESTORAGE_DECRYPT: 'safe-storage:decrypt',
/** 文件写入Blob Buffer → 磁盘) */
FILE_WRITE: 'storage:file-write',
/** 文件读取(磁盘 → Base64 */
FILE_READ: 'storage:file-read',
/** 文件删除 */
FILE_DELETE: 'storage:file-delete',
/** 创建目录(递归) */
MAKE_DIR: 'storage:make-dir',
/** 在文件管理器中显示文件 */
SHOW_ITEM_IN_FOLDER: 'storage:show-item-in-folder',
/** 获取应用数据目录路径 */
GET_DATA_DIR: 'storage:get-data-dir',
/** 获取文件大小 */
FILE_SIZE: 'storage:file-size',
/** 文件/目录是否存在 */
FILE_EXISTS: 'storage:file-exists',
} as const;

View File

@@ -14,7 +14,8 @@ export type LogCategory =
| 'app' // 应用生命周期
| 'ui' // UI 层错误 / 警告
| 'ipc' // IPC 通信
| 'safe-storage'; // 安全存储safeStorage 加密/解密)
| 'safe-storage' // 安全存储safeStorage 加密/解密)
| 'storage'; // 本地存储IndexedDB / sql.js / 文件缓存)
/**
* 结构化错误快照

View File

@@ -27,6 +27,7 @@ import {
clearProactiveRefresh,
} from '@/services/auth-token';
import {initModelCache} from '@/services/cache/model-cache';
import {initStorage, clearUserStorage} from '@/infrastructure/storage';
// ---------- Context 类型 ----------
@@ -110,6 +111,8 @@ export function AppProvider({ children }: AppProviderProps) {
setUser(null);
setIsLoggedIn(false);
emit(EVENTS.LOGOUT);
// 异步清理本地存储(不阻塞 UI
void clearUserStorage();
}, []);
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
@@ -121,7 +124,7 @@ export function AppProvider({ children }: AppProviderProps) {
let aborted = false;
// 从加密存储恢复 token + 模型缓存 到内存缓存
Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache()]).then(() => {
Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache(), initStorage()]).then(() => {
if (aborted) return;
// 初始化 401 刷新回调token 缓存就绪后才注册)

View File

@@ -8,6 +8,7 @@
// ============================================================
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { setOutputPath as saveOutputPath, setTeamRepoPath as saveTeamRepoPath } from '@/infrastructure/storage';
// ---------- localStorage 键 ----------
@@ -108,6 +109,7 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
const setOutputPath = useCallback((path: string) => {
setOutputPathState(path);
writeStoredOutputPath(path);
try { saveOutputPath(path); } catch { /* db 未初始化则跳过 */ }
}, []);
const clearOutputPath = useCallback(() => {
@@ -120,6 +122,7 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
const setTeamRepoPath = useCallback((path: string) => {
setTeamRepoPathState(path);
writeStoredTeamRepoPath(path);
try { saveTeamRepoPath(path); } catch { /* db 未初始化则跳过 */ }
}, []);
const clearTeamRepoPath = useCallback(() => {

View File

@@ -0,0 +1,176 @@
// ============================================================
// infrastructure/storage/db-core.ts — sql.js 数据库核心封装
//
// 职责:
// - 初始化 sql.js WASM 运行时 + 创建/加载 Database
// - 执行 DDL 建表 / 迁移
// - 提供通用 CRUD 操作execute / query / run / get
// - 导出数据库为 Uint8Array供加密落盘
//
// 安全:
// - 数据库完全在内存中运行,不直接操作磁盘
// - 数据导出由 db-encrypt.ts 加密后写入磁盘
// ============================================================
import initSqlJs, { type Database, type SqlJsStatic, type SqlValue } from 'sql.js';
import { DDL_STATEMENTS, INIT_VERSION_SQL } from './schema';
import { logger } from '@/utils/logger';
// ---------- 内部状态 ----------
let SQL: SqlJsStatic | null = null;
let db: Database | null = null;
// ---------- 初始化 ----------
/**
* 初始化 sql.js 运行时WASM 加载),仅需调用一次。
*/
async function ensureRuntime(): Promise<SqlJsStatic> {
if (SQL) return SQL;
try {
SQL = await initSqlJs({
locateFile: (file: string) => {
const url = import.meta.env.DEV ? `/${file}` : `./${file}`;
console.log(`[storage] 加载 sql.js WASM${url}`);
return url;
},
});
console.log('[storage] sql.js 运行时初始化成功');
return SQL;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[storage] sql.js WASM 加载失败:${msg}`);
logger.error('storage', `sql.js WASM 加载失败:${msg}`, err instanceof Error ? err : undefined);
throw err;
}
}
/**
* 打开或创建数据库。
*
* @param encryptedData - 从磁盘读取的加密数据(由 db-encrypt.ts 解密后提供),
* 未提供则创建空库。
* @returns true 表示成功
*/
export async function openDatabase(encryptedData?: Uint8Array): Promise<boolean> {
try {
const sql = await ensureRuntime();
db = new sql.Database(encryptedData ?? undefined);
// 执行建表 DDL
db.run('PRAGMA journal_mode=OFF');
db.run('PRAGMA synchronous=0');
for (const stmt of DDL_STATEMENTS) {
db.run(stmt);
}
db.run(INIT_VERSION_SQL);
const size = (db.export()?.byteLength ?? 0);
console.log(`[storage] 数据库已打开(${encryptedData ? '从磁盘恢复' : '新建'}${DDL_STATEMENTS.length} 条 DDL${size} bytes`);
logger.info('storage', `数据库已打开(${encryptedData ? '从磁盘恢复' : '新建'}`);
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[storage] 数据库打开失败:${msg}`);
logger.error('storage', `数据库打开失败:${msg}`, err instanceof Error ? err : undefined);
return false;
}
}
// ---------- 导出 ----------
/**
* 导出当前数据库为 Uint8Array供加密后写入磁盘
*/
export function exportDatabase(): Uint8Array | null {
if (!db) return null;
try {
return db.export();
} catch (err) {
logger.error('storage', '数据库导出失败', err instanceof Error ? err : undefined);
return null;
}
}
// ---------- 关闭 ----------
export function closeDatabase(): void {
if (db) {
db.close();
db = null;
logger.info('storage', '数据库已关闭');
}
}
export function isOpen(): boolean {
return db !== null;
}
// ---------- 查询 API ----------
/** 确保数据库已打开 */
function guard(): Database {
if (!db) throw new Error('数据库未初始化 — 请确认已调用 initStorage() 并等待其完成');
return db;
}
/** 执行一条写操作 SQLINSERT / UPDATE / DELETE返回受影响行数 */
export function execute(sql: string, params?: SqlValue[]): number {
try {
guard().run(sql, params);
return guard().getRowsModified();
} catch (err) {
logger.error('storage', `SQL 执行失败:${sql.substring(0, 80)}`, err instanceof Error ? err : undefined);
throw err;
}
}
/** 查询多条记录 */
export function queryAll<T = Record<string, unknown>>(sql: string, params?: SqlValue[]): T[] {
try {
const stmt = guard().prepare(sql);
if (params) stmt.bind(params);
const rows: T[] = [];
while (stmt.step()) {
rows.push(stmt.getAsObject() as T);
}
stmt.free();
return rows;
} catch (err) {
logger.error('storage', `SQL 查询失败:${sql.substring(0, 80)}`, err instanceof Error ? err : undefined);
return [];
}
}
/** 查询单条记录 */
export function queryOne<T = Record<string, unknown>>(sql: string, params?: SqlValue[]): T | null {
try {
const stmt = guard().prepare(sql);
if (params) stmt.bind(params);
let row: T | null = null;
if (stmt.step()) {
row = stmt.getAsObject() as T;
}
stmt.free();
return row;
} catch (err) {
logger.error('storage', `SQL 查询失败:${sql.substring(0, 80)}`, err instanceof Error ? err : undefined);
return null;
}
}
/** 执行多条 SQL事务包裹 */
export function executeBatch(statements: string[]): void {
const d = guard();
try {
d.run('BEGIN');
for (const stmt of statements) {
d.run(stmt);
}
d.run('COMMIT');
} catch (err) {
try { d.run('ROLLBACK'); } catch { /* ignore */ }
throw err;
}
}

View File

@@ -0,0 +1,123 @@
// ============================================================
// infrastructure/storage/db-encrypt.ts — 数据库加密持久化
//
// 职责:
// - 将 sql.js 数据库序列化 → safeStorage 加密 → IPC 写入磁盘
// - 从磁盘读取 → safeStorage 解密 → 反序列化为 sql.js 可用的 Uint8Array
// - 降级方案:非 Electron 环境回退 localStorage仅开发用
//
// 流程:
// 保存DB.export() → Uint8Array → base64 → safeStorage.encrypt() → IPC writeFile
// 加载IPC readFile → base64 → safeStorage.decrypt() → Uint8Array → DB
// ============================================================
import { hasIpc } from '@/utils/ipc';
import { exportDatabase } from './db-core';
import { logger } from '@/utils/logger';
/** 数据库文件相对路径(相对于 heixiu-data/ */
const DB_RELATIVE_PATH = 'heixiu.db';
// ---------- 数据编解码 ----------
function uint8ArrayToBase64(data: Uint8Array): string {
let binary = '';
for (let i = 0; i < data.byteLength; i++) {
binary += String.fromCharCode(data[i]);
}
return btoa(binary);
}
function base64ToUint8Array(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
// ---------- 公开 API ----------
/** 保存数据库到磁盘(加密 + IPC 写文件) */
export async function saveDatabase(): Promise<boolean> {
const data = exportDatabase();
if (!data) {
console.warn('[storage] saveDatabaseexportDatabase 返回 null数据库未打开');
return false;
}
console.log(`[storage] saveDatabase导出 ${data.byteLength} bytes`);
const base64 = uint8ArrayToBase64(data);
console.log(`[storage] saveDatabaseBase64 编码后 ${base64.length} chars`);
// Electron 环境:加密后写磁盘
if (hasIpc() && window.fileStorage && window.safeStorage) {
console.log('[storage] saveDatabaseElectron 环境,准备加密...');
try {
const encrypted = await window.safeStorage.encrypt(base64);
if (!encrypted) {
console.warn('[storage] saveDatabasesafeStorage.encrypt 返回 null');
return false;
}
console.log(`[storage] saveDatabase加密后 ${encrypted.length} chars准备写入磁盘...`);
const result = await window.fileStorage.writeFile(DB_RELATIVE_PATH, encrypted, 'base64');
console.log(`[storage] saveDatabase写入结果 = ${result}`);
return result !== null;
} catch (err) {
console.error('[storage] saveDatabase 异常:', err);
logger.error('storage', '数据库加密保存失败', err instanceof Error ? err : undefined);
return false;
}
}
// 降级localStorage 明文(仅开发用)
console.log('[storage] saveDatabase非 Electron 环境,使用 localStorage 降级');
try {
localStorage.setItem('ele-heixiu-db', base64);
return true;
} catch {
return false;
}
}
/** 从磁盘加载数据库IPC 读文件 + 解密) */
export async function loadDatabase(): Promise<Uint8Array | undefined> {
// Electron 环境:读文件 + 解密
if (hasIpc() && window.fileStorage && window.safeStorage) {
console.log('[storage] loadDatabaseElectron 环境,读取磁盘...');
try {
const encrypted = await window.fileStorage.readFile(DB_RELATIVE_PATH);
if (!encrypted) {
console.log('[storage] loadDatabase磁盘文件不存在首次启动');
return undefined;
}
console.log(`[storage] loadDatabase读取加密数据 ${encrypted.length} chars准备解密...`);
const base64 = await window.safeStorage.decrypt(encrypted);
if (!base64) {
console.warn('[storage] loadDatabase解密失败');
return undefined;
}
console.log(`[storage] loadDatabase解密后 ${base64.length} chars`);
return base64ToUint8Array(base64);
} catch (err) {
console.warn('[storage] loadDatabase 异常:', err);
logger.warn('storage', '数据库解密加载失败', err instanceof Error ? err : undefined);
return undefined;
}
}
// 降级localStorage 明文
console.log('[storage] loadDatabase非 Electron 环境,尝试 localStorage...');
try {
const raw = localStorage.getItem('ele-heixiu-db');
if (!raw) {
console.log('[storage] loadDatabaselocalStorage 无数据');
return undefined;
}
console.log(`[storage] loadDatabaselocalStorage 有数据 ${raw.length} chars`);
return base64ToUint8Array(raw);
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,128 @@
// ============================================================
// infrastructure/storage/index.ts — 本地存储模块统一入口
//
// 初始化流程:
// await initStorage() // AppProvider 挂载后调用一次
// await clearUserStorage() // 退出登录时调用
// await saveDatabase() // 定期或退出前调用(保存到磁盘)
//
// 目录结构(自动管理):
// <userData>/heixiu-data/
// ├── heixiu.db ← 加密的 sql.js 持久化镜像
// └── media-cache/<userId>/<date>/ ← 媒体文件缓存
//
// 使用示例:
// import { initStorage, getTasks, syncTasks, saveParamSnapshot } from '@/infrastructure/storage';
// ============================================================
import { openDatabase, closeDatabase, isOpen } from './db-core';
import { loadDatabase, saveDatabase } from './db-encrypt';
import { clearUserIdCache } from './user-scope';
import { clearUserTasks, getTaskCount as _getTaskCount } from './task-store';
import { clearUserOrders, getOrderCount as _getOrderCount } from './order-store';
import { clearUserParams } from './param-store';
import { clearUserMedia, getMediaCount as _getMediaCount, getMediaTotalSize as _getMediaTotalSize } from './media-store';
import { logger } from '@/utils/logger';
// ---------- 重新导出 ----------
export { openDatabase, closeDatabase, isOpen, exportDatabase, execute, queryAll, queryOne } from './db-core';
export { loadDatabase, saveDatabase } from './db-encrypt';
export { getCurrentUserId, clearUserIdCache, getTodayKey, hashURL, formatBytes } from './user-scope';
export { upsertTask, syncTasks, getTasks, getTaskById, deleteTask, getTaskCount, clearUserTasks, rowToTask } from './task-store';
export { upsertOrder, syncRechargeOrders, syncVipOrders, getOrders, getOrderById, deleteOrder, getOrderCount, clearUserOrders } from './order-store';
export { saveParamSnapshot, fillOutputData, getParamByTaskId, getParamHistory, parseParams, deleteParamHistory, clearUserParams } from './param-store';
export { upsertMediaCache, getMediaByUrl, hasMedia, getMediaCount, getMediaTotalSize, deleteMediaByUrl, clearExpiredMedia, clearUserMedia } from './media-store';
export { setSetting, getSetting, getAllSettings, deleteSetting, getOutputPath, setOutputPath, getTeamRepoPath, setTeamRepoPath } from './settings-store';
export { getCursor, updateCursor, hasMore, nextPage, syncedCount, totalCount, getPageSize } from './sync-manager';
export { writeFile, readFile, deleteFile, makeDir, showItemInFolder, fileExists, saveBlobToFile, readFileAsBlobUrl, getDataDir } from './ipc-file';
export { loadMedia, preloadMedia, isVideoMime, isImageMime, isAudioMime, revokeMediaUrl, isMediaSuccess, isMediaFailure } from './media-loader';
export type { MediaLoadResult, MediaLoadSuccess, MediaLoadFailure } from './media-loader';
export type {
TaskRecordEntry, TaskQueryFilter,
OrderRecordEntry, OrderType, OrderQueryFilter,
ParamHistoryEntry, MediaCacheEntry,
SyncCursor, SyncDataType, AppSetting,
} from './types';
// ---------- 初始化 ----------
/**
* 初始化本地存储模块。
*
* 流程:从磁盘加载加密数据 → 解密 → sql.js 打开 → 建表
*
* @returns true 表示就绪
*/
export async function initStorage(): Promise<boolean> {
// 幂等:已经打开就跳过
if (isOpen()) {
console.log('[storage] 数据库已打开,跳过重复初始化');
return true;
}
console.log('[storage] 开始初始化...');
// 1. 尝试从磁盘加载已有数据
let data: Uint8Array | undefined;
try {
data = await loadDatabase();
console.log(`[storage] 加载磁盘数据:${data ? `${data.byteLength} bytes` : '无(首次启动)'}`);
} catch (err) {
console.warn('[storage] 加载持久化数据失败,将创建新数据库', err);
logger.warn('storage', '加载持久化数据失败,将创建新数据库', err instanceof Error ? err : undefined);
}
// 2. 打开数据库
const ok = await openDatabase(data);
if (!ok) {
console.error('[storage] 数据库打开失败,本地存储不可用');
logger.warn('storage', '数据库打开失败,本地存储不可用');
return false;
}
// 3. 清理过期媒体缓存
try {
const { clearExpiredMedia } = await import('./media-store');
clearExpiredMedia();
} catch { /* ignore */ }
console.log('[storage] 本地存储模块初始化完成');
logger.info('storage', '本地存储模块初始化完成');
return true;
}
// ---------- 清理 ----------
/** 清除当前用户的所有本地数据 + 关闭数据库 */
export async function clearUserStorage(): Promise<void> {
// 1. 清空所有表数据
try {
await Promise.allSettled([
Promise.resolve(clearUserTasks()),
Promise.resolve(clearUserOrders()),
Promise.resolve(clearUserParams()),
Promise.resolve(clearUserMedia()),
]);
} catch { /* ignore */ }
// 2. 保存空库到磁盘(覆盖旧数据)
await saveDatabase();
// 3. 关闭数据库
closeDatabase();
clearUserIdCache();
logger.info('storage', '用户存储数据已清理');
}
// ---------- 统计 ----------
/** 获取存储统计信息 */
export function getStorageStats() {
return {
taskCount: _getTaskCount(),
orderCount: _getOrderCount(),
mediaCount: _getMediaCount(),
mediaSize: _getMediaTotalSize(),
dbReady: isOpen(),
};
}

View File

@@ -0,0 +1,76 @@
// ============================================================
// infrastructure/storage/ipc-file.ts — 渲染进程侧文件操作桥接
//
// 职责:
// - 封装 window.fileStorage 调用(由 preload 注入)
// - 降级方案:非 Electron 环境使用 localStorage 模拟
// - 提供统一的文件操作 API 供上层 Store 使用
//
// 使用模式:
// 本模块是唯一直接访问 window.fileStorage 的地方,
// media-store / db-encrypt 等均通过本模块操作文件。
// ============================================================
import { hasIpc } from '@/utils/ipc';
import { logger } from '@/utils/logger';
// ---------- 文件 API ----------
export async function writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
if (!hasIpc() || !window.fileStorage) {
logger.warn('storage', 'fileStorage 不可用,文件写入跳过');
return null;
}
return window.fileStorage.writeFile(relativePath, data, encoding);
}
export async function readFile(relativePath: string): Promise<string | null> {
if (!hasIpc() || !window.fileStorage) return null;
return window.fileStorage.readFile(relativePath);
}
export async function deleteFile(relativePath: string): Promise<boolean> {
if (!hasIpc() || !window.fileStorage) return false;
return window.fileStorage.deleteFile(relativePath);
}
export async function makeDir(relativePath: string): Promise<string | null> {
if (!hasIpc() || !window.fileStorage) return null;
return window.fileStorage.makeDir(relativePath);
}
export async function showItemInFolder(filePath: string): Promise<boolean> {
if (!hasIpc() || !window.fileStorage) return false;
return window.fileStorage.showItemInFolder(filePath);
}
export async function fileExists(relativePath: string): Promise<boolean> {
if (!hasIpc() || !window.fileStorage) return false;
return window.fileStorage.fileExists(relativePath);
}
/** Blob → Base64 → IPC 写入磁盘。返回写入的绝对路径 */
export async function saveBlobToFile(relativePath: string, blob: Blob): Promise<string | null> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1]; // 去掉 data:mime;base64, 前缀
writeFile(relativePath, base64, 'base64').then(resolve);
};
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
}
/** 读取文件并返回 Blob URL */
export async function readFileAsBlobUrl(relativePath: string, mimeType: string): Promise<string | null> {
const base64 = await readFile(relativePath);
if (!base64) return null;
return `data:${mimeType};base64,${base64}`;
}
/** 获取 heixiu-data 根目录绝对路径 */
export async function getDataDir(): Promise<string | null> {
if (!hasIpc() || !window.fileStorage) return null;
return window.fileStorage.getDataDir();
}

View File

@@ -0,0 +1,267 @@
// ============================================================
// infrastructure/storage/media-loader.ts — 媒体加载与缓存
//
// 职责:
// - 通过认证 axios 下载媒体文件(携带 Authorization 头)
// - 从 HTTP Content-Type 检测真实 MIME 类型
// → 解决 CDN 直连链接无文件扩展名导致 isVideoUrl() 失效的问题
// - 本地磁盘缓存 + 数据库元数据记录
// - 返回 blob URL 供 <img>/<video> 标签使用
//
// 流程:
// 加载:检查本地缓存 → 命中 → 读文件 → blob URL
// → 未命中 → axios 下载 → 保存磁盘 → 记录 DB → blob URL
// 显示blob URL高效支持视频 seek无需 data: URI 编码开销)
// ============================================================
import axios from 'axios';
import { axiosInstance } from '@/services/request';
import { logger } from '@/utils/logger';
import { hasIpc } from '@/utils/ipc';
import { getCurrentUserId, hashURL, getTodayKey } from './user-scope';
import { getMediaByUrl, upsertMediaCache, deleteMediaByUrl } from './media-store';
import { saveBlobToFile, readFile, fileExists } from './ipc-file';
// ---------- 类型 ----------
/** 加载成功 */
export interface MediaLoadSuccess {
ok: true;
/** 可直传 <img src> / <video src> 的 blob URL */
blobUrl: string;
/** HTTP 响应 Content-Type如 "image/png"、"video/mp4" */
mimeType: string;
/** 是否来自本地缓存false = 本次网络下载) */
fromCache: boolean;
}
/** 加载失败(含可区分的原因,供 UI 展示不同提示) */
export interface MediaLoadFailure {
ok: false;
reason: 'signature_expired' | 'network_error' | 'cancelled';
message: string;
}
/** 判别联合:调用方通过 result.ok 收窄类型 */
export type MediaLoadResult = MediaLoadSuccess | MediaLoadFailure;
// ---------- 类型守卫 ----------
export function isMediaSuccess(r: MediaLoadResult): r is MediaLoadSuccess {
return r.ok;
}
export function isMediaFailure(r: MediaLoadResult): r is MediaLoadFailure {
return !r.ok;
}
// ---------- 内部工具 ----------
/**
* base64 → Blob → blob URL
*
* 相比 data: URIreadFileAsBlobUrl 的做法blob URL 优势:
* - 无 base64 33% 体积膨胀(内存友好)
* - <video> 可正常 seek/stream
* - 通过 URL.revokeObjectURL() 显式释放
*/
function base64ToBlobUrl(base64: string, mimeType: string): string {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
return URL.createObjectURL(blob);
}
/**
* 从本地磁盘缓存加载媒体,返回 blob URL。
* 若文件不存在或 IPC 不可用则返回 null。
*/
async function loadFromDisk(localPath: string, mimeType: string): Promise<string | null> {
if (!hasIpc()) return null;
try {
const exists = await fileExists(localPath);
if (!exists) return null;
const base64 = await readFile(localPath);
if (!base64) return null;
return base64ToBlobUrl(base64, mimeType);
} catch {
return null;
}
}
/**
* 从远程下载媒体 → 保存到本地磁盘缓存 → 返回 blob URL。
*
* 降级策略:
* - 磁盘写入失败 → 仍返回 blob URL本次会话可用不持久化
* - CDN 签名过期 (403 SignatureExpired) → 返回 MediaLoadFailurereason: 'signature_expired'
* - 其他网络错误 → 返回 MediaLoadFailurereason: 'network_error'
* - 请求取消 → 返回 MediaLoadFailurereason: 'cancelled'
*/
async function downloadAndCache(
url: string,
signal?: AbortSignal,
): Promise<MediaLoadSuccess | MediaLoadFailure> {
try {
// ---- 下载axiosInstance 自动携带 Authorization ----
const response = await axiosInstance.get(url, {
responseType: 'arraybuffer',
signal,
timeout: 120_000, // 媒体文件可能较大
});
// ---- 从 Content-Type 提取 MIME ----
const rawContentType = String(response.headers['content-type'] || '');
const mimeType = rawContentType.split(';')[0]?.trim() || 'application/octet-stream';
const buffer = response.data as ArrayBuffer;
const blob = new Blob([buffer], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
// ---- 写入磁盘缓存 ----
if (hasIpc() && window.fileStorage) {
const uid = getCurrentUserId();
const ext = mimeType.split('/')[1] || 'bin';
const urlHash = hashURL(url);
const localPath = `media-cache/${uid}/${getTodayKey()}/${urlHash}.${ext}`;
try {
const savedPath = await saveBlobToFile(localPath, blob);
if (savedPath) {
upsertMediaCache(url, mimeType, buffer.byteLength, localPath);
logger.debug('storage', `媒体已缓存:${mimeType} ${buffer.byteLength} bytes → ${localPath}`);
}
} catch (err) {
logger.warn('storage', '媒体磁盘缓存写入失败(降级为内存模式)', err instanceof Error ? err : undefined);
}
}
return { ok: true, blobUrl, mimeType, fromCache: false };
} catch (err) {
// 请求取消
if (axios.isCancel(err) || (err as Record<string, unknown>)?.code === 'ERR_CANCELED') {
return { ok: false, reason: 'cancelled', message: '请求已取消' };
}
// CDN 签名过期 — 403 + JSON body { code: "SignatureExpired", message: "..." }
if (axios.isAxiosError(err) && err.response?.status === 403) {
try {
const raw = err.response.data;
// responseType 为 arraybuffer错误响应体也是 ArrayBuffer需解码
let text: string;
if (raw instanceof ArrayBuffer) {
text = new TextDecoder().decode(raw);
} else if (typeof raw === 'string') {
text = raw;
} else {
text = JSON.stringify(raw);
}
const body = JSON.parse(text) as Record<string, unknown>;
if (body.code === 'SignatureExpired') {
logger.debug('storage', `CDN 签名已过期:${url}`);
return {
ok: false,
reason: 'signature_expired',
message: '媒体链接签名已过期有效期24小时请重新生成任务',
};
}
} catch {
// 解析失败 → 按普通 403 处理
}
logger.warn('storage', `CDN 访问拒绝 (403)${url}`);
return {
ok: false,
reason: 'network_error',
message: 'CDN 访问被拒绝403链接可能已失效',
};
}
logger.warn('storage', `媒体下载失败:${url}`, err instanceof Error ? err : undefined);
return {
ok: false,
reason: 'network_error',
message: '媒体文件下载失败,请检查网络连接',
};
}
}
// ---------- 公开 API ----------
/**
* 加载媒体文件,返回 blob URL 或可区分的错误信息。
*
* 策略:
* 1. 查本地 DB 缓存 → 命中则读磁盘返回 blob URL
* 2. 未命中则通过认证 HTTP 下载 → 写磁盘 + 记 DB → 返回 blob URL
* 3. CDN 签名过期(403 SignatureExpired) → 返回 reason='signature_expired'
* 4. 其他网络错误 → 返回 reason='network_error'
*
* @param url - 媒体文件的完整 URL可以是 CDN 直连链接)
* @param signal - 取消信号(组件卸载时中止下载)
*/
export async function loadMedia(url: string, signal?: AbortSignal): Promise<MediaLoadResult> {
// ---- 1. 检查本地数据库缓存 ----
const cached = getMediaByUrl(url);
if (cached) {
const blobUrl = await loadFromDisk(cached.local_path, cached.mime_type);
if (blobUrl) {
logger.debug('storage', `媒体缓存命中:${url}`);
return { ok: true, blobUrl, mimeType: cached.mime_type, fromCache: true };
}
// 数据库有记录但磁盘文件丢失 → 清理脏记录,走下载
deleteMediaByUrl(url);
logger.debug('storage', `媒体缓存文件丢失,重新下载:${url}`);
}
// ---- 2. 网络下载 + 缓存 ----
return downloadAndCache(url, signal);
}
/**
* 预加载一批媒体 URL并行下载不阻塞 UI
* 适用于任务列表打开时预加载缩略图等场景。
*/
export function preloadMedia(urls: string[], signal?: AbortSignal): void {
Promise.allSettled(urls.map(url => loadMedia(url, signal))).then(results => {
const loaded = results.filter(r => r.status === 'fulfilled' && r.value.ok).length;
if (loaded > 0) {
logger.debug('storage', `预加载完成:${loaded}/${urls.length} 个媒体文件`);
}
});
}
// ---------- MIME 类型判断 ----------
/** 判断 MIME 是否为视频 */
export function isVideoMime(mimeType: string): boolean {
return mimeType.startsWith('video/');
}
/** 判断 MIME 是否为图片 */
export function isImageMime(mimeType: string): boolean {
return mimeType.startsWith('image/');
}
/** 判断 MIME 是否为音频 */
export function isAudioMime(mimeType: string): boolean {
return mimeType.startsWith('audio/');
}
// ---------- 资源释放 ----------
/**
* 释放 blob URL 占用的内存。
* 应在组件卸载或 URL 不再使用时调用。
*/
export function revokeMediaUrl(blobUrl: string): void {
if (blobUrl.startsWith('blob:')) {
URL.revokeObjectURL(blobUrl);
}
}

View File

@@ -0,0 +1,80 @@
// ============================================================
// infrastructure/storage/media-store.ts — 媒体缓存存储
//
// 职责:
// - 媒体缓存元数据 CRUDBlob 实体由主进程管理)
// - URL → local_path 映射 + LRU 淘汰
// - 读取时更新访问时间LRU 排序依据)
// ============================================================
import { execute, queryAll, queryOne } from './db-core';
import { getCurrentUserId, getTodayKey, hashURL } from './user-scope';
import type { MediaCacheEntry } from './types';
import { logger } from '@/utils/logger';
// ---------- API ----------
export function upsertMediaCache(
url: string,
mimeType: string,
fileSize: number,
localPath: string,
): MediaCacheEntry | null {
const uid = getCurrentUserId();
const urlHash = hashURL(url);
const now = Date.now();
// 删除旧条目(同 URL 覆盖)
execute(`DELETE FROM media_cache WHERE url_hash = ?`, [urlHash]);
execute(
`INSERT INTO media_cache (user_id, url_hash, original_url, local_path, mime_type, file_size, created_date, accessed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[uid, urlHash, url, localPath, mimeType, fileSize, getTodayKey(), now],
);
const row = queryOne<MediaCacheEntry>(`SELECT * FROM media_cache WHERE url_hash = ?`, [urlHash]);
return row ?? null;
}
export function getMediaByUrl(url: string): MediaCacheEntry | null {
const row = queryOne<MediaCacheEntry>(`SELECT * FROM media_cache WHERE url_hash = ?`, [hashURL(url)]);
if (row) {
// 更新访问时间LRU
execute(`UPDATE media_cache SET accessed_at = ? WHERE id = ?`, [Date.now(), row.id!]);
}
return row;
}
export function hasMedia(url: string): boolean {
const row = queryOne<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM media_cache WHERE url_hash = ?`, [hashURL(url)]);
return (row?.cnt ?? 0) > 0;
}
export function getMediaCount(): number {
const row = queryOne<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM media_cache WHERE user_id = ?`, [getCurrentUserId()]);
return row?.cnt ?? 0;
}
export function getMediaTotalSize(): number {
const row = queryOne<{ total: number }>(`SELECT COALESCE(SUM(file_size), 0) as total FROM media_cache WHERE user_id = ?`, [getCurrentUserId()]);
return row?.total ?? 0;
}
export function deleteMediaByUrl(url: string): void {
execute(`DELETE FROM media_cache WHERE url_hash = ?`, [hashURL(url)]);
}
export function clearExpiredMedia(maxAgeDays = 30): number {
const cutoff = Date.now() - maxAgeDays * 86400000;
const rows = queryAll<MediaCacheEntry>(`SELECT id, local_path FROM media_cache WHERE accessed_at < ?`, [cutoff]);
for (const r of rows) {
if (r.id) execute(`DELETE FROM media_cache WHERE id = ?`, [r.id]);
}
if (rows.length > 0) logger.info('storage', `已清理 ${rows.length} 条过期媒体缓存`);
return rows.length;
}
export function clearUserMedia(): void {
execute(`DELETE FROM media_cache WHERE user_id = ?`, [getCurrentUserId()]);
}

View File

@@ -0,0 +1,104 @@
// ============================================================
// infrastructure/storage/order-store.ts — 订单记录本地存储
// ============================================================
import { execute, queryAll, queryOne } from './db-core';
import { getCurrentUserId } from './user-scope';
import type { OrderRecordEntry, OrderType, OrderQueryFilter } from './types';
import { logger } from '@/utils/logger';
// ---------- 通用接口 ----------
interface OrderLike {
id: string;
status: string;
created_at?: string | Date;
}
// ---------- API ----------
export function upsertOrder<T extends OrderLike>(orderType: OrderType, order: T): void {
const now = Date.now();
const row: OrderRecordEntry = {
id: order.id,
user_id: getCurrentUserId(),
order_type: orderType,
status: order.status,
data_json: JSON.stringify(order),
created_at: typeof order.created_at === 'string'
? Date.parse(order.created_at)
: (order.created_at instanceof Date ? order.created_at.getTime() : now),
};
execute(
`INSERT OR REPLACE INTO order_records (id, user_id, order_type, status, data_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[row.id, row.user_id, row.order_type, row.status, row.data_json, row.created_at],
);
}
export function syncRechargeOrders<T extends OrderLike>(orders: T[]): number {
let n = 0;
for (const o of orders) {
try { upsertOrder('recharge', o); n++; } catch { /* skip */ }
}
logger.debug('storage', `充值订单同步:${n}/${orders.length}`);
return n;
}
export function syncVipOrders<T extends OrderLike>(orders: T[]): number {
let n = 0;
for (const o of orders) {
try { upsertOrder('vip', o); n++; } catch { /* skip */ }
}
logger.debug('storage', `VIP 订单同步:${n}/${orders.length}`);
return n;
}
export function getOrders<T extends OrderLike>(filter: OrderQueryFilter = {}): T[] {
const uid = getCurrentUserId();
let rows: OrderRecordEntry[];
if (filter.order_type) {
rows = queryAll<OrderRecordEntry>(
`SELECT * FROM order_records WHERE user_id = ? AND order_type = ? ORDER BY created_at DESC LIMIT ?`,
[uid, filter.order_type, filter.limit || 100],
);
} else {
rows = queryAll<OrderRecordEntry>(
`SELECT * FROM order_records WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`,
[uid, filter.limit || 100],
);
}
return rows.map(r => {
try { return JSON.parse(r.data_json) as T; } catch { return { id: r.id, status: r.status } as unknown as T; }
});
}
export function getOrderById<T extends OrderLike>(orderId: string): T | null {
const row = queryOne<OrderRecordEntry>(`SELECT * FROM order_records WHERE id = ?`, [orderId]);
if (!row) return null;
try { return JSON.parse(row.data_json) as T; } catch { return null; }
}
export function deleteOrder(orderId: string): void {
execute(`DELETE FROM order_records WHERE id = ?`, [orderId]);
}
export function getOrderCount(orderType?: OrderType): number {
const uid = getCurrentUserId();
if (orderType) {
const row = queryOne<{ cnt: number }>(
`SELECT COUNT(*) as cnt FROM order_records WHERE user_id = ? AND order_type = ?`,
[uid, orderType],
);
return row?.cnt ?? 0;
}
const row = queryOne<{ cnt: number }>(
`SELECT COUNT(*) as cnt FROM order_records WHERE user_id = ?`,
[uid],
);
return row?.cnt ?? 0;
}
export function clearUserOrders(): void {
execute(`DELETE FROM order_records WHERE user_id = ?`, [getCurrentUserId()]);
}

View File

@@ -0,0 +1,80 @@
// ============================================================
// infrastructure/storage/param-store.ts — 参数历史本地存储
//
// 职责:
// - 任务提交后保存输入参数快照task_id 主键)
// - 任务完成后回填输出数据
// - 按模型查询历史参数列表(回填菜单数据源)
// ============================================================
import { execute, queryAll, queryOne } from './db-core';
import { getCurrentUserId } from './user-scope';
import type { ParamHistoryEntry } from './types';
const MAX_PER_MODEL = 20;
// ---------- API ----------
export function saveParamSnapshot(
taskId: string,
modelId: string,
modelName: string,
inputParams: Record<string, unknown>,
tag?: string,
): void {
const uid = getCurrentUserId();
execute(
`INSERT OR REPLACE INTO param_history (task_id, user_id, model_id, model_name, input_params_json, tag, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[taskId, uid, modelId, modelName, JSON.stringify(inputParams), tag || null, Date.now()],
);
// 裁剪旧记录
prune(modelId, uid);
}
export function fillOutputData(taskId: string, outputData: Record<string, unknown>): void {
execute(
`UPDATE param_history SET output_data_json = ? WHERE task_id = ?`,
[JSON.stringify(outputData), taskId],
);
}
export function getParamByTaskId(taskId: string): ParamHistoryEntry | null {
return queryOne<ParamHistoryEntry>(`SELECT * FROM param_history WHERE task_id = ?`, [taskId]);
}
export function getParamHistory(modelId: string, limit = 10): ParamHistoryEntry[] {
const uid = getCurrentUserId();
return queryAll<ParamHistoryEntry>(
`SELECT * FROM param_history WHERE user_id = ? AND model_id = ? ORDER BY created_at DESC LIMIT ?`,
[uid, modelId, limit],
);
}
export function parseParams(entry: ParamHistoryEntry): Record<string, unknown> {
try { return JSON.parse(entry.input_params_json) as Record<string, unknown>; } catch { return {}; }
}
export function deleteParamHistory(id: number): void {
execute(`DELETE FROM param_history WHERE id = ?`, [id]);
}
export function clearUserParams(): void {
execute(`DELETE FROM param_history WHERE user_id = ?`, [getCurrentUserId()]);
}
// ---------- 内部 ----------
function prune(modelId: string, userId: string): void {
try {
const rows = queryAll<ParamHistoryEntry>(
`SELECT id FROM param_history WHERE user_id = ? AND model_id = ? ORDER BY created_at DESC`,
[userId, modelId],
);
if (rows.length <= MAX_PER_MODEL) return;
const toDelete = rows.slice(MAX_PER_MODEL);
for (const r of toDelete) {
if (r.id) execute(`DELETE FROM param_history WHERE id = ?`, [r.id]);
}
} catch { /* ignore */ }
}

View File

@@ -0,0 +1,82 @@
// ============================================================
// infrastructure/storage/schema.ts — 数据库 Schema 定义
// ============================================================
/** 当前 schema 版本 */
export const SCHEMA_VERSION = 1;
/** 建表 DDL所有 CREATE TABLE IF NOT EXISTS */
export const DDL_STATEMENTS = [
// ---- 任务记录 ----
`CREATE TABLE IF NOT EXISTS task_records (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
model_id TEXT NOT NULL,
status TEXT NOT NULL,
data_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_task_user_status ON task_records(user_id, status)`,
`CREATE INDEX IF NOT EXISTS idx_task_user_model ON task_records(user_id, model_id)`,
// ---- 订单记录 ----
`CREATE TABLE IF NOT EXISTS order_records (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
order_type TEXT NOT NULL,
status TEXT NOT NULL,
data_json TEXT NOT NULL,
created_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_order_user_type ON order_records(user_id, order_type)`,
// ---- 参数历史 ----
`CREATE TABLE IF NOT EXISTS param_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT UNIQUE NOT NULL,
user_id TEXT NOT NULL,
model_id TEXT NOT NULL,
model_name TEXT NOT NULL,
input_params_json TEXT NOT NULL,
output_data_json TEXT,
tag TEXT,
created_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_param_user_model ON param_history(user_id, model_id, created_at)`,
// ---- 媒体缓存 ----
`CREATE TABLE IF NOT EXISTS media_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
url_hash TEXT UNIQUE NOT NULL,
original_url TEXT NOT NULL,
local_path TEXT NOT NULL,
mime_type TEXT NOT NULL,
file_size INTEGER NOT NULL,
created_date TEXT NOT NULL,
accessed_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_media_user_hash ON media_cache(user_id, url_hash)`,
// ---- 同步游标 ----
`CREATE TABLE IF NOT EXISTS sync_cursor (
data_type TEXT PRIMARY KEY,
last_page INTEGER NOT NULL,
total_count INTEGER NOT NULL,
synced_at INTEGER NOT NULL
)`,
// ---- 应用设置 ----
`CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)`,
// ---- Schema 版本 ----
`CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER PRIMARY KEY)`,
];
/** 初始化 schema 版本记录 */
export const INIT_VERSION_SQL = `INSERT OR IGNORE INTO _schema_version (version) VALUES (${SCHEMA_VERSION})`;

View File

@@ -0,0 +1,52 @@
// ============================================================
// infrastructure/storage/settings-store.ts — 应用设置持久化
//
// 职责:
// - 读写 key-value 格式的应用设置(如 outputPath
// - 提供同步读取方法(无 async overhead
// - 兼容现有的 localStorage 设置逻辑
// ============================================================
import { execute, queryAll, queryOne } from './db-core';
import type { AppSetting } from './types';
import { SETTING_KEYS } from './types';
// ---------- API ----------
export function setSetting(key: string, value: string): void {
execute(
`INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)`,
[key, value, Date.now()],
);
}
export function getSetting(key: string): string | null {
const row = queryOne<AppSetting>(`SELECT value FROM app_settings WHERE key = ?`, [key]);
return row?.value ?? null;
}
export function getAllSettings(): AppSetting[] {
return queryAll<AppSetting>(`SELECT * FROM app_settings ORDER BY key`);
}
export function deleteSetting(key: string): void {
execute(`DELETE FROM app_settings WHERE key = ?`, [key]);
}
// ---------- 便捷方法(兼容 SettingsProvider ----------
export function getOutputPath(): string {
return getSetting(SETTING_KEYS.OUTPUT_PATH) || '';
}
export function setOutputPath(path: string): void {
setSetting(SETTING_KEYS.OUTPUT_PATH, path);
}
export function getTeamRepoPath(): string {
return getSetting(SETTING_KEYS.TEAM_REPO_PATH) || '';
}
export function setTeamRepoPath(path: string): void {
setSetting(SETTING_KEYS.TEAM_REPO_PATH, path);
}

View File

@@ -0,0 +1,60 @@
// ============================================================
// infrastructure/storage/sync-manager.ts — 数据同步控制
//
// 职责:
// - 管理分页加载游标(每类数据拉到第几页)
// - 判断是否需要"下一页"按钮
// - "加载历史数据"按钮的逻辑入口
//
// 策略:
// - 每次加载 100 条
// - last_page * 100 < total_count 时显示"继续加载更多"
// - 不自动拉取,完全由用户手动触发
// ============================================================
import { execute, queryOne } from './db-core';
import type { SyncCursor, SyncDataType } from './types';
const PAGE_SIZE = 100;
// ---------- API ----------
export function getPageSize(): number {
return PAGE_SIZE;
}
export function getCursor(dataType: SyncDataType): SyncCursor | null {
return queryOne<SyncCursor>(`SELECT * FROM sync_cursor WHERE data_type = ?`, [dataType]);
}
export function updateCursor(dataType: SyncDataType, lastPage: number, totalCount: number): void {
execute(
`INSERT OR REPLACE INTO sync_cursor (data_type, last_page, total_count, synced_at) VALUES (?, ?, ?, ?)`,
[dataType, lastPage, totalCount, Date.now()],
);
}
/** 判断是否还有更多数据可加载 */
export function hasMore(dataType: SyncDataType): boolean {
const c = getCursor(dataType);
if (!c) return true; // 从未加载过 → 显示按钮
return c.last_page * PAGE_SIZE < c.total_count;
}
/** 下次应加载的页码 */
export function nextPage(dataType: SyncDataType): number {
const c = getCursor(dataType);
return c ? c.last_page + 1 : 1;
}
/** 已缓存的总条数 */
export function syncedCount(dataType: SyncDataType): number {
const c = getCursor(dataType);
return c ? Math.min(c.last_page * PAGE_SIZE, c.total_count) : 0;
}
/** 服务端返回的总数 */
export function totalCount(dataType: SyncDataType): number {
const c = getCursor(dataType);
return c?.total_count ?? 0;
}

View File

@@ -0,0 +1,97 @@
// ============================================================
// infrastructure/storage/task-store.ts — 任务记录本地存储
//
// 职责:
// - 插入/更新/查询任务记录
// - 批量同步 API 数据
// - 按用户 + 状态/模型筛选
// ============================================================
import { execute, queryAll, queryOne } from './db-core';
import { getCurrentUserId } from './user-scope';
import type { TaskRecordEntry, TaskQueryFilter } from './types';
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
import { logger } from '@/utils/logger';
// ---------- 序列化 ----------
function taskToRow(task: TaskInfoItemResponseBody): TaskRecordEntry {
return {
id: task.id,
user_id: getCurrentUserId(),
model_id: task.model_id,
status: task.status,
data_json: JSON.stringify(task),
created_at: typeof task.created_at === 'string' ? Date.parse(task.created_at) : Date.now(),
updated_at: Date.now(),
};
}
export function rowToTask(row: TaskRecordEntry): TaskInfoItemResponseBody {
try {
return JSON.parse(row.data_json) as TaskInfoItemResponseBody;
} catch {
return { id: row.id, model_id: row.model_id, status: row.status, created_at: new Date(row.created_at).toISOString() } as unknown as TaskInfoItemResponseBody;
}
}
// ---------- API ----------
export function upsertTask(task: TaskInfoItemResponseBody): void {
const r = taskToRow(task);
execute(
`INSERT OR REPLACE INTO task_records (id, user_id, model_id, status, data_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[r.id, r.user_id, r.model_id, r.status, r.data_json, r.created_at, r.updated_at],
);
}
export function syncTasks(tasks: TaskInfoItemResponseBody[]): number {
let n = 0;
for (const t of tasks) {
try { upsertTask(t); n++; } catch { /* skip */ }
}
logger.debug('storage', `任务同步:${n}/${tasks.length}`);
return n;
}
export function getTasks(filter: TaskQueryFilter = {}): TaskRecordEntry[] {
const uid = getCurrentUserId();
if (filter.status) {
return queryAll<TaskRecordEntry>(
`SELECT * FROM task_records WHERE user_id = ? AND status = ? ORDER BY created_at DESC LIMIT ?`,
[uid, filter.status, filter.limit || 100],
);
}
if (filter.model_id) {
return queryAll<TaskRecordEntry>(
`SELECT * FROM task_records WHERE user_id = ? AND model_id = ? ORDER BY created_at DESC LIMIT ?`,
[uid, filter.model_id, filter.limit || 100],
);
}
return queryAll<TaskRecordEntry>(
`SELECT * FROM task_records WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`,
[uid, filter.limit || 100],
);
}
export function getTaskById(taskId: string): TaskRecordEntry | null {
return queryOne<TaskRecordEntry>(`SELECT * FROM task_records WHERE id = ?`, [taskId]);
}
export function deleteTask(taskId: string): void {
execute(`DELETE FROM task_records WHERE id = ?`, [taskId]);
}
export function getTaskCount(userId?: string): number {
const row = queryOne<{ cnt: number }>(
`SELECT COUNT(*) as cnt FROM task_records WHERE user_id = ?`,
[userId || getCurrentUserId()],
);
return row?.cnt ?? 0;
}
export function clearUserTasks(): void {
const uid = getCurrentUserId();
execute(`DELETE FROM task_records WHERE user_id = ?`, [uid]);
}

View File

@@ -0,0 +1,94 @@
// ============================================================
// infrastructure/storage/types.ts — 本地存储模块共享类型
// ============================================================
// ---------- 任务记录 ----------
export interface TaskRecordEntry {
id: string;
user_id: string;
model_id: string;
status: string;
data_json: string;
created_at: number;
updated_at: number;
}
export interface TaskQueryFilter {
status?: string;
model_id?: string;
limit?: number;
offset?: number;
}
// ---------- 订单记录 ----------
export type OrderType = 'recharge' | 'vip';
export interface OrderRecordEntry {
id: string;
user_id: string;
order_type: OrderType;
status: string;
data_json: string;
created_at: number;
}
export interface OrderQueryFilter {
order_type?: OrderType;
status?: string;
limit?: number;
offset?: number;
}
// ---------- 参数历史 ----------
export interface ParamHistoryEntry {
id?: number;
task_id: string;
user_id: string;
model_id: string;
model_name: string;
input_params_json: string;
output_data_json?: string;
tag?: string;
created_at: number;
}
// ---------- 媒体缓存 ----------
export interface MediaCacheEntry {
id?: number;
user_id: string;
url_hash: string;
original_url: string;
local_path: string;
mime_type: string;
file_size: number;
created_date: string;
accessed_at: number;
}
// ---------- 同步游标 ----------
export type SyncDataType = 'tasks' | 'recharge_orders' | 'vip_orders';
export interface SyncCursor {
data_type: SyncDataType;
last_page: number;
total_count: number;
synced_at: number;
}
// ---------- 应用设置 ----------
export interface AppSetting {
key: string;
value: string;
updated_at: number;
}
export const SETTING_KEYS = {
OUTPUT_PATH: 'output_path',
TEAM_REPO_PATH: 'team_repo_path',
} as const;

View File

@@ -0,0 +1,80 @@
// ============================================================
// infrastructure/storage/user-scope.ts — 用户作用域工具
//
// 职责:
// - 从 JWT refresh_token 提取当前用户 ID
// - 生成日期键YYYY-MM-DD用于按日期分区
// - URL 哈希djb2用于去重查询
// ============================================================
import { getSafeStore } from '@/utils/safe-storage';
const REFRESH_KEY = 'ele-heixiu-refresh-token';
// ---------- 用户 ID ----------
let cachedUserId: string | null = null;
let cacheTs = 0;
const CACHE_MS = 30_000;
function extractIdFromJWT(token: string): string | null {
try {
const p = JSON.parse(atob(token.split('.')[1]));
return (p.user_id as string) || (p.sub as string) || null;
} catch {
return null;
}
}
/** 获取当前用户 IDJWT 解码 + 30s 缓存),未登录返回 'anonymous' */
export function getCurrentUserId(): string {
const now = Date.now();
if (cachedUserId !== null && now - cacheTs < CACHE_MS) return cachedUserId;
const token = getSafeStore(REFRESH_KEY);
if (token) {
const id = extractIdFromJWT(token);
if (id) { cachedUserId = id; cacheTs = now; return id; }
}
cachedUserId = 'anonymous';
cacheTs = now;
return 'anonymous';
}
export function clearUserIdCache(): void {
cachedUserId = null;
cacheTs = 0;
}
// ---------- 日期键 ----------
export function getTodayKey(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export function dateKeyFromTs(ts: number): string {
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
// ---------- URL 哈希 ----------
export function hashURL(url: string): string {
let h = 5381;
for (let i = 0; i < url.length; i++) {
h = ((h << 5) + h) ^ url.charCodeAt(i);
h = h >>> 0;
}
return h.toString(16).padStart(8, '0');
}
// ---------- 格式化 ----------
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
return `${(bytes / 1073741824).toFixed(2)} GB`;
}

View File

@@ -108,6 +108,8 @@ export function HomeContent() {
const leftWidthRef = useRef(leftWidth);
const rightWidthRef = useRef(rightWidth);
const draggingRef = useRef(dragging);
// 标记拖拽期间被跳过的重算,拖拽结束后补算
const needsRecalcRef = useRef(false);
useEffect(() => {
leftWidthRef.current = leftWidth;
@@ -147,6 +149,7 @@ export function HomeContent() {
};
const handleMouseUp = () => {
draggingRef.current = null; // 即时清除 —— 优先级高于 React 状态,避免 ResizeObserver 被永久阻塞
setDragging(null);
};
@@ -159,41 +162,97 @@ export function HomeContent() {
};
}, [dragging]);
// ======== 视口缩小自动收窄面板 ========
// ======== 视口缩放时按比例自动调整面板宽度 ========
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
// 拖拽中由 mousemove handler 处理约束,避免冲突
if (draggingRef.current) return;
// 拖拽中由 mousemove handler 处理约束,避免冲突
// 若跳过则标记 needsRecalcRef拖拽结束后补算
if (draggingRef.current) {
needsRecalcRef.current = true;
return;
}
// 每次回调从 ref 读取当前 DOM 元素(避免闭包捕获 mount 时的 el
const el = containerRef.current;
if (!el) return;
const containerWidth = el.clientWidth;
const currentLeft = leftWidthRef.current;
const currentRight = rightWidthRef.current;
// 检查当前总宽度是否超出容器
const totalNeeded = currentLeft + LAYOUT_OVERHEAD + CENTER_MIN + currentRight;
if (totalNeeded <= containerWidth) return;
// 左右面板可用的总空间(中列至少保留 CENTER_MIN
const sideAvailable = containerWidth - LAYOUT_OVERHEAD - CENTER_MIN;
const sideTotal = currentLeft + currentRight;
// 超出容器 → 优先收缩右侧面板,再收左侧
const deficit = totalNeeded - containerWidth;
const rightSlack = currentRight - RIGHT_MIN;
if (sideAvailable <= 0 || sideTotal <= 0) return;
if (rightSlack >= deficit) {
setRightWidth(currentRight - deficit);
} else {
setRightWidth(RIGHT_MIN);
const remainingDeficit = deficit - rightSlack;
setLeftWidth(Math.max(LEFT_MIN, currentLeft - remainingDeficit));
// ---- 按当前比例分配空间 ----
const ratio = currentLeft / sideTotal;
let newLeft = Math.round(sideAvailable * ratio);
let newRight = sideAvailable - newLeft;
// ---- 最小值约束(保持比例的前提下不跌破下限) ----
if (newLeft < LEFT_MIN) {
newLeft = LEFT_MIN;
newRight = sideAvailable - newLeft;
}
if (newRight < RIGHT_MIN) {
newRight = RIGHT_MIN;
newLeft = sideAvailable - newRight;
}
// 两侧同时触底 → 保持最小值,中列由 CSS min-width 兜底
if (newLeft < LEFT_MIN) newLeft = LEFT_MIN;
if (newRight < RIGHT_MIN) newRight = RIGHT_MIN;
// 即时更新 ref避免 ResizeObserver 连续触发时读到过期值
leftWidthRef.current = newLeft;
rightWidthRef.current = newRight;
if (newLeft !== currentLeft) setLeftWidth(newLeft);
if (newRight !== currentRight) setRightWidth(newRight);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// 拖拽结束后,若 ResizeObserver 曾因拖拽跳过重算,立即补算
useEffect(() => {
if (dragging !== null) return; // 仍在拖拽中
if (!needsRecalcRef.current) return;
needsRecalcRef.current = false;
const el = containerRef.current;
if (!el) return;
const containerWidth = el.clientWidth;
const currentLeft = leftWidthRef.current;
const currentRight = rightWidthRef.current;
const sideAvailable = containerWidth - LAYOUT_OVERHEAD - CENTER_MIN;
const sideTotal = currentLeft + currentRight;
if (sideAvailable <= 0 || sideTotal <= 0) return;
const ratio = currentLeft / sideTotal;
let newLeft = Math.round(sideAvailable * ratio);
let newRight = sideAvailable - newLeft;
if (newLeft < LEFT_MIN) { newLeft = LEFT_MIN; newRight = sideAvailable - newLeft; }
if (newRight < RIGHT_MIN) { newRight = RIGHT_MIN; newLeft = sideAvailable - newRight; }
if (newLeft < LEFT_MIN) newLeft = LEFT_MIN;
if (newRight < RIGHT_MIN) newRight = RIGHT_MIN;
leftWidthRef.current = newLeft;
rightWidthRef.current = newRight;
if (newLeft !== currentLeft) setLeftWidth(newLeft);
if (newRight !== currentRight) setRightWidth(newRight);
}, [dragging]);
// ======== 分隔条样式工厂 ========
const dividerStyle = (side: 'left' | 'right'): React.CSSProperties => {

View File

@@ -14,45 +14,22 @@
// ============================================================
import { useState, useCallback } from 'react';
import { Upload, Switch, Space, Typography, message } from 'antd';
import { Upload, Switch, Space, Typography } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile, RcFile } from 'antd/es/upload';
import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
import { useFileUpload } from '@/hooks/use-api';
import { buildAccept, createMediaBeforeUpload } from './media-upload-utils';
const { Text } = Typography;
/**
* 创建图片上传前的类型 & 大小校验函数ImageInput / ImageListInput 共用)
* @param maxSizeMb 最大文件大小MB默认 10
*/
export function createImageBeforeUpload(maxSizeMb: number = 10) {
return (file: RcFile) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('只能上传图片文件');
return Upload.LIST_IGNORE;
}
if (file.size / 1024 / 1024 >= maxSizeMb) {
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
return Upload.LIST_IGNORE;
}
return true;
};
}
/** 构建图片 accept 字符串 */
function buildAccept(filters?: string[]): string {
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
return filters.map((ext) => `image/${ext}`).join(',');
}
/** 标记"已启用但空"的空数组,避免 react 渲染时引用变化 */
const ENABLED_EMPTY: UploadFile[] = [];
export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
const fileList = (value as UploadFile[]) || [];
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
const filters = config.fileFilter as string[] | undefined;
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
// 文件上传 Hook提供 customRequest → antd Upload 集成)
@@ -95,7 +72,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
<Upload
listType="picture-card"
maxCount={1}
accept={buildAccept(config.fileFilter as string[] | undefined)}
accept={buildAccept(filters)}
fileList={fileList}
customRequest={customRequest}
onChange={({ fileList: newList }) => {
@@ -105,7 +82,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
}
}}
disabled={isUploadDisabled}
beforeUpload={createImageBeforeUpload(maxSizeMb)}
beforeUpload={createMediaBeforeUpload(maxSizeMb, filters)}
>
{fileList.length < 1 && (
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>

View File

@@ -1,14 +1,15 @@
// ============================================================
// ImageListInput — imageList → Dragger多张图片拖拽上传
// 用于图生图模型的多图输入场景
// ImageListInput — imageList → Dragger多张图片/视频拖拽上传)
// 用于图生图/图生视频模型的多图输入场景
// 当 file_filter 包含视频扩展名时,自动支持视频文件上传
// ============================================================
import { Upload } from 'antd';
import { InboxOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
import { createImageBeforeUpload } from './ImageInput';
import { useFileUpload } from '@/hooks/use-api';
import { buildAccept, createMediaBeforeUpload, VIDEO_EXTENSIONS } from './media-upload-utils';
const { Dragger } = Upload;
@@ -17,32 +18,31 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
const maxCount = (config.maxFiles as number) || 10;
const filters = config.fileFilter as string[] | undefined;
const hasVideo = filters?.some(f => VIDEO_EXTENSIONS.has(f.toLowerCase()));
const { customRequest } = useFileUpload();
const mediaLabel = hasVideo ? '图片/视频' : '图片';
return (
<Dragger
multiple
listType="picture"
accept={
filters
? filters.map((ext) => `image/${ext}`).join(',')
: 'image/png,image/jpg,image/jpeg,image/webp'
}
accept={buildAccept(filters)}
fileList={fileList}
customRequest={customRequest}
onChange={({ fileList: newList }) => onChange?.(newList)}
disabled={disabled}
beforeUpload={createImageBeforeUpload(maxSizeMb)}
beforeUpload={createMediaBeforeUpload(maxSizeMb, filters)}
maxCount={maxCount}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-text">{mediaLabel}</p>
<p className="ant-upload-hint">
{filters?.join(' / ').toUpperCase() || 'PNG / JPG / WebP'}
{maxSizeMb}MB
{maxSizeMb}MB
</p>
</Dragger>
);

View File

@@ -1,55 +1,248 @@
// ============================================================
// SeedanceContent — seedance2Content → 自定义内容编排控件
// SeedanceContent — seedance2Content → 内容编排控件
//
// 对应 Seedance 2.0 视频生成模型的 content 参数json_array
// 最终形态:文本 + 参考图 + 参考视频自由组合编排
// 对应 Seedance 2.0 视频生成模型的 content 参数json_array
// 内部值结构JSON 字符串 ⟷ 表单字段值):
// { text: string; imageUrls: string[]; videoUrls: string[]; audioUrls: string[] }
//
// TODO: 当前为占位实现,后续需完善拖拽编排能力
// 从 param_schema.spec 读取 image_files / video_files / audio_files
// 决定各媒体类型的上传数量上限和是否显示对应上传区域。
// ============================================================
import { Input, Typography, theme as antTheme } from 'antd';
import { PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
import { useCallback, useMemo } from 'react';
import { Upload, Input, Typography, message, theme as antTheme } from 'antd';
import {
PictureOutlined,
VideoCameraOutlined,
AudioOutlined,
InboxOutlined,
} from '@ant-design/icons';
import type { UploadFile, RcFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
import { useFileUpload } from '@/hooks/use-api';
const { TextArea } = Input;
const { Dragger } = Upload;
const { Text } = Typography;
export function SeedanceContent({ value, onChange, disabled, config }: WidgetProps) {
const { token } = antTheme.useToken();
// ---------- 内部值类型 ----------
return (
<div
style={{
border: `1px dashed ${token.colorBorder}`,
borderRadius: 6,
padding: 12,
background: token.colorFillQuaternary,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<PictureOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
<VideoCameraOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
<Text type="secondary" style={{ fontSize: 12 }}>
+
</Text>
</div>
<TextArea
rows={4}
value={value as string}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
placeholder={
(config.placeholder as string) ||
'输入视频内容的文字描述(后续版本将支持拖拽编排图片/视频参考素材)'
}
maxLength={(config.maxLength as number) || 5000}
showCount
/>
<Text type="secondary" style={{ display: 'block', marginTop: 6, fontSize: 11 }}>
💡 线 + +
</Text>
</div>
);
interface ContentValue {
text: string;
imageUrls: string[];
videoUrls: string[];
audioUrls: string[];
}
// ---------- 辅助函数 ----------
function parseValue(raw: unknown): ContentValue {
if (typeof raw === 'string' && raw.startsWith('{')) {
try {
const obj = JSON.parse(raw) as Partial<ContentValue>;
return {
text: obj.text || '',
imageUrls: Array.isArray(obj.imageUrls) ? obj.imageUrls : [],
videoUrls: Array.isArray(obj.videoUrls) ? obj.videoUrls : [],
audioUrls: Array.isArray(obj.audioUrls) ? obj.audioUrls : [],
};
} catch { /* ignore */ }
}
return { text: typeof raw === 'string' ? raw : '', imageUrls: [], videoUrls: [], audioUrls: [] };
}
function serializeValue(cv: ContentValue): string {
return JSON.stringify(cv);
}
/** URL 数组 → UploadFile 数组(全部标记为 done */
function urlsToFileList(urls: string[], type: string): UploadFile[] {
return urls.map((url, i) => ({
uid: `${type}-${i}-${url.slice(-20)}`,
name: url.split('/').pop() || `${type}_${i + 1}`,
status: 'done' as const,
url,
}));
}
/** 从 UploadFile 数组中提取已上传成功的 URL */
function extractUrls(fileList: UploadFile[]): string[] {
return fileList
.filter(f => f.status === 'done')
.map(f => (f.response as { url?: string })?.url || f.url || '')
.filter(Boolean);
}
// ---------- 上传区域子组件 ----------
interface UploadSectionProps {
type: 'image' | 'video' | 'audio';
urls: string[];
maxCount: number;
maxSizeMb: number;
disabled: boolean;
icon: React.ReactNode;
label: string;
customRequest: ReturnType<typeof useFileUpload>['customRequest'];
onChange: (urls: string[]) => void;
}
function UploadSection({
type, urls, maxCount, maxSizeMb, disabled, icon, label, customRequest, onChange,
}: UploadSectionProps) {
const fileList = useMemo(() => urlsToFileList(urls, type), [urls, type]);
const beforeUpload = useCallback((file: RcFile) => {
const mimePrefix = type === 'image' ? 'image/' : type === 'video' ? 'video/' : 'audio/';
if (!file.type.startsWith(mimePrefix)) {
message.error(`只能上传${label}文件`);
return Upload.LIST_IGNORE;
}
if (file.size / 1024 / 1024 >= maxSizeMb) {
message.error(`${label}大小不能超过 ${maxSizeMb}MB`);
return Upload.LIST_IGNORE;
}
return true;
}, [type, label, maxSizeMb]);
const handleChange = useCallback(
(info: { fileList: UploadFile[] }) => {
onChange(extractUrls(info.fileList));
},
[onChange],
);
if (maxCount <= 0) return null;
return (
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
{icon} {label} {maxCount}
</Text>
<Dragger
multiple={maxCount > 1}
listType="picture"
accept={type === 'image' ? 'image/*' : type === 'video' ? 'video/*' : 'audio/*'}
fileList={fileList}
customRequest={customRequest}
onChange={handleChange}
disabled={disabled}
beforeUpload={beforeUpload}
maxCount={maxCount}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">{label}</p>
<p className="ant-upload-hint"> {maxSizeMb}MB</p>
</Dragger>
</div>
);
}
// ---------- 主组件 ----------
export function SeedanceContent({ value, onChange, disabled, config }: WidgetProps) {
const { token } = antTheme.useToken();
const { customRequest } = useFileUpload();
const cv = useMemo(() => parseValue(value), [value]);
const imageFiles = (config.imageFiles as number) || 0;
const videoFiles = (config.videoFiles as number) || 0;
const audioFiles = (config.audioFiles as number) || 0;
const maxSizeMb = (config.maxFileSizeMb as number) || 50;
const emit = useCallback(
(patch: Partial<ContentValue>) => {
const next = { ...cv, ...patch };
onChange?.(serializeValue(next));
},
[cv, onChange],
);
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
emit({ text: e.target.value });
},
[emit],
);
return (
<div
style={{
border: `1px dashed ${token.colorBorder}`,
borderRadius: 6,
padding: 12,
background: token.colorFillQuaternary,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
{(imageFiles > 0) && <PictureOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />}
{(videoFiles > 0) && <VideoCameraOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />}
{(audioFiles > 0) && <AudioOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />}
<Text type="secondary" style={{ fontSize: 12 }}>
+
</Text>
</div>
<TextArea
rows={4}
value={cv.text}
onChange={handleTextChange}
disabled={disabled}
placeholder={
(config.placeholder as string) ||
'输入视频内容的文字描述'
}
maxLength={(config.maxLength as number) || 5000}
showCount
/>
{/* 参考图片上传 */}
{imageFiles > 0 && (
<UploadSection
type="image"
urls={cv.imageUrls}
maxCount={imageFiles}
maxSizeMb={maxSizeMb}
disabled={disabled ?? false}
icon={<PictureOutlined />}
label="参考图片"
customRequest={customRequest}
onChange={(urls) => emit({ imageUrls: urls })}
/>
)}
{/* 参考视频上传 */}
{videoFiles > 0 && (
<UploadSection
type="video"
urls={cv.videoUrls}
maxCount={videoFiles}
maxSizeMb={Math.max(maxSizeMb, 100)}
disabled={disabled ?? false}
icon={<VideoCameraOutlined />}
label="参考视频"
customRequest={customRequest}
onChange={(urls) => emit({ videoUrls: urls })}
/>
)}
{/* 参考音频上传 */}
{audioFiles > 0 && (
<UploadSection
type="audio"
urls={cv.audioUrls}
maxCount={audioFiles}
maxSizeMb={maxSizeMb}
disabled={disabled ?? false}
icon={<AudioOutlined />}
label="参考音频"
customRequest={customRequest}
onChange={(urls) => emit({ audioUrls: urls })}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,119 @@
// ============================================================
// VideoInput — videoInput → Upload单个视频
// 用于视频参考等单视频上传场景
//
// enable_switch 支持:
// 当 API ui.enable_switch === true 时,默认禁用(灰色),
// 需点击 Switch 开关后才能上传——匹配原 Qt 客户端行为。
// 开关关闭时清除已上传文件。
//
// 表单值语义(配合 constraints_config.requires 依赖禁用):
// undefined → 开关关闭 / 未启用 → isValueEmpty = true → 依赖字段禁用
// [] → 开关开启但未上传文件 → isValueEmpty = false → 依赖字段启用
// [{...}] → 已上传文件 → isValueEmpty = false → 依赖字段启用
// ============================================================
import { useState, useCallback } from 'react';
import { Upload, Switch, Space, Typography, message } from 'antd';
import { VideoCameraOutlined } from '@ant-design/icons';
import type { UploadFile, RcFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
import { useFileUpload } from '@/hooks/use-api';
const { Text } = Typography;
/**
* 创建视频上传前的类型 & 大小校验函数。
* @param maxSizeMb 最大文件大小MB默认 50
*/
export function createVideoBeforeUpload(maxSizeMb: number = 50) {
return (file: RcFile) => {
const isVideo = file.type.startsWith('video/');
if (!isVideo) {
message.error('只能上传视频文件');
return Upload.LIST_IGNORE;
}
if (file.size / 1024 / 1024 >= maxSizeMb) {
message.error(`视频大小不能超过 ${maxSizeMb}MB`);
return Upload.LIST_IGNORE;
}
return true;
};
}
/** 构建视频 accept 字符串 */
function buildVideoAccept(filters?: string[]): string {
if (!filters || filters.length === 0) return 'video/mp4,video/webm,video/quicktime,video/x-msvideo';
return filters.map((ext) => `video/${ext}`).join(',');
}
/** 标记"已启用但空"的空数组,避免 react 渲染时引用变化 */
const ENABLED_EMPTY: UploadFile[] = [];
export function VideoInput({ value, onChange, disabled, config }: WidgetProps) {
const fileList = (value as UploadFile[]) || [];
const maxSizeMb = (config.maxFileSizeMb as number) || 50;
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
// 文件上传 Hook提供 customRequest → antd Upload 集成)
const { customRequest } = useFileUpload();
// 有 enable_switch 时默认禁用,需手动开启
const [switchOn, setSwitchOn] = useState(!hasEnableSwitch);
const isUploadDisabled = disabled || !switchOn;
const handleSwitchChange = useCallback(
(on: boolean) => {
setSwitchOn(on);
if (!on) {
// 关闭开关 → 表单值置为 undefinedisValueEmpty = true依赖字段禁用
onChange?.(undefined);
} else {
// 开启开关 → 空数组 = "已启用但未上传"isValueEmpty = false依赖字段启用
onChange?.(ENABLED_EMPTY as unknown as UploadFile[]);
}
},
[onChange],
);
return (
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
{hasEnableSwitch && (
<Space size={8}>
<Switch
size="small"
checked={switchOn}
onChange={handleSwitchChange}
disabled={disabled}
/>
<Text type={switchOn ? 'success' : 'secondary'} style={{ fontSize: 12 }}>
{switchOn ? '已启用' : '点击开关启用上传'}
</Text>
</Space>
)}
<Upload
listType="picture-card"
maxCount={1}
accept={buildVideoAccept(config.fileFilter as string[] | undefined)}
fileList={fileList}
customRequest={customRequest}
onChange={({ fileList: newList }) => {
// 仅当开关开启时上报文件变化
if (switchOn || !hasEnableSwitch) {
onChange?.(newList as unknown as UploadFile[]);
}
}}
disabled={isUploadDisabled}
beforeUpload={createVideoBeforeUpload(maxSizeMb)}
>
{fileList.length < 1 && (
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
<VideoCameraOutlined />
<div style={{ marginTop: 4, fontSize: 12 }}></div>
</div>
)}
</Upload>
</Space>
);
}

View File

@@ -23,6 +23,7 @@ import { DoubleSpinBox } from './DoubleSpinBox';
import { ImageInput } from './ImageInput';
import { ImageListInput } from './ImageListInput';
import { AudioListInput } from './AudioListInput';
import { VideoInput } from './VideoInput';
import { SeedanceContent } from './SeedanceContent';
export type { WidgetProps } from './types';
@@ -45,6 +46,7 @@ export const WIDGET_MAP: Record<string, ComponentType<WidgetProps>> = {
imageInput: ImageInput,
imageList: ImageListInput,
audioList: AudioListInput,
videoInput: VideoInput,
seedance2Content: SeedanceContent,
};

View File

@@ -0,0 +1,62 @@
// ============================================================
// media-upload-utils — ImageInput / ImageListInput / VideoInput 共享工具
// ============================================================
import { message, Upload } from 'antd';
import type { RcFile } from 'antd/es/upload';
/** 常见视频扩展名,用于 buildAccept / createMediaBeforeUpload 区分图片/视频 */
export const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv']);
/**
* 构建媒体文件 accept 字符串,自动区分 image/ video/ 前缀。
*
* @example
* buildAccept(['png', 'jpg']) → "image/png,image/jpg"
* buildAccept(['mp4', 'webm']) → "video/mp4,video/webm"
* buildAccept([]) → "image/png,image/jpg,image/jpeg,image/webp"
*/
export function buildAccept(filters?: string[]): string {
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
return filters.map((ext) => {
if (VIDEO_EXTENSIONS.has(ext.toLowerCase())) return `video/${ext}`;
return `image/${ext}`;
}).join(',');
}
/**
* 创建上传前的类型 & 大小校验函数ImageInput / ImageListInput 共用)。
*
* 当 file_filter 包含视频扩展名时,自动放行 video/* MIME 文件。
* 不传入 filters 时仅接受 image/*(向后兼容)。
*
* @param maxSizeMb 最大文件大小MB默认 10
* @param filters API spec.file_filter 数组
*/
export function createMediaBeforeUpload(maxSizeMb: number = 10, filters?: string[]) {
const hasVideoFilters = filters?.some(f => VIDEO_EXTENSIONS.has(f.toLowerCase()));
return (file: RcFile) => {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
if (isVideo && hasVideoFilters) {
if (file.size / 1024 / 1024 >= maxSizeMb) {
message.error(`视频大小不能超过 ${maxSizeMb}MB`);
return Upload.LIST_IGNORE;
}
return true;
}
if (!isImage) {
message.error(hasVideoFilters ? '只能上传图片或视频文件' : '只能上传图片文件');
return Upload.LIST_IGNORE;
}
if (file.size / 1024 / 1024 >= maxSizeMb) {
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
return Upload.LIST_IGNORE;
}
return true;
};
}

View File

@@ -10,7 +10,7 @@
// - 本组件只负责布局、Form 容器、提交逻辑、依赖禁用
// ============================================================
import { useState, useLayoutEffect, useMemo, useCallback, useRef } from 'react';
import {useState, useLayoutEffect, useMemo, useCallback, useRef, useEffect} from 'react';
import {
Form,
Input,
@@ -156,7 +156,9 @@ export function ModelInputForm() {
const submitCountRef = useRef(0);
/** 最新错误信息的 ref避免 doSubmit useCallback 闭包过期) */
const errorMessageRef = useRef(errorMessage);
errorMessageRef.current = errorMessage;
useEffect(() => {
errorMessageRef.current = errorMessage;
}, [errorMessage]);
// ======== useModelUIparam_schema → UIFieldConfig[] ========
@@ -234,6 +236,35 @@ export function ModelInputForm() {
continue;
}
// seedance2Content将内部 JSON 值序列化为 API 要求的 ContentItem[]
if (field.widgetName === 'seedance2Content') {
try {
const cv = JSON.parse(value as string) as {
text?: string;
imageUrls?: string[];
videoUrls?: string[];
audioUrls?: string[];
};
const items: Record<string, unknown>[] = [];
if (cv.text) {
items.push({ type: 'text', text: cv.text });
}
for (const url of (cv.imageUrls || [])) {
items.push({ type: 'image_url', role: 'reference_image', image_url: { url } });
}
for (const url of (cv.videoUrls || [])) {
items.push({ type: 'video_url', role: 'reference_video', video_url: { url } });
}
for (const url of (cv.audioUrls || [])) {
items.push({ type: 'audio_url', role: 'reference_audio', audio_url: { url } });
}
if (items.length > 0) {
params[field.name] = JSON.stringify(items);
}
} catch { /* ignore parse error */ }
continue;
}
if (typeof value === 'boolean') {
params[field.name] = String(value);
continue;

View File

@@ -1,6 +1,15 @@
// ============================================================
// OutputPreview — 任务输出预览(图片 / 视频)
// 使用 useTaskDetail() Hook 获取任务详情,组件仅负责渲染
//
// 媒体加载策略:
// - <img>/<video> 直接加载远程 URL无 CORS 限制,与 axios/fetch 不同)
// - 类型检测URL 扩展名正则 + output_type 补充(无扩展名 CDN 链接)
// - 加载失败:<Image fallback> 显示占位图;<video onError> 静默(浏览器内置错误提示)
//
// 注意CDN 签名过期403 SignatureExpired的 HTTP 级检测需要 CORS
// 响应头支持,当前 CDN 不具备。后续可通过主进程 Node.js HTTP无 CORS
// 实现精确检测。
// ============================================================
import { useMemo, useRef, useEffect } from 'react';
@@ -29,17 +38,41 @@ const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }>
// ---------- 工具函数 ----------
/**
* 通过 URL 扩展名判断是否为视频。
* 对 CDN 直连链接(无扩展名)返回 false此时由 output_type 兜底。
*/
function isVideoUrl(url: string): boolean {
return /\.(mp4|webm|mov|avi|mkv)(\?|$)/i.test(url);
}
function splitAssets(assets: Array<{ url: string }>): { images: string[]; videos: string[] } {
/**
* 分离图片/视频 URL。
*
* 策略(优先级从高到低):
* 1. URL 正则匹配(对带扩展名的链接精确识别)
* 2. output_type 兜底CDN 无扩展名链接):
* - output_type='video' → 首个资产按视频处理,其余按图片
* - output_type='image' / 'audio' → 全部按图片处理
*/
function splitAssets(
assets: Array<{ url: string }>,
outputType?: string,
): { images: string[]; videos: string[] } {
const images: string[] = [];
const videos: string[] = [];
assets.forEach(({ url }) => {
if (isVideoUrl(url)) videos.push(url);
else images.push(url);
if (isVideoUrl(url)) {
videos.push(url);
} else if (outputType === 'video' && videos.length === 0) {
// CDN 无扩展名链接,由 output_type 提示首个为视频
videos.push(url);
} else {
images.push(url);
}
});
return { images, videos };
}
@@ -53,16 +86,12 @@ export function OutputPreview() {
const { data: detail, loading, refetch } = useTaskDetail(selectedTask?.id);
// ======== 状态转换监听:非终态→终态时触发详情刷新 ========
// 不再独立轮询 getTaskStatus避免与 TaskHistory 的批量轮询重复请求),
// 而是依赖批量轮询通过 Context 同步过来的 selectedTask.status 变化。
const prevStatusRef = useRef<TaskStatusString | undefined>(undefined);
useEffect(() => {
const status = selectedTask?.status as TaskStatusString | undefined;
const prev = prevStatusRef.current;
// 从非终态变为终态 → 刷新详情获取完整数据output_assets 等)
if (
prev &&
POLLING_STATUSES.includes(prev) &&
@@ -75,12 +104,12 @@ export function OutputPreview() {
prevStatusRef.current = status;
}, [selectedTask?.status, refetch]);
// 从 output_assets 提取图片/视频
// 按类型分类URL 正则 + output_type 兜底)
const { images, videos } = useMemo(() => {
if (!detail?.output_assets || detail.output_assets.length === 0) {
return { images: [], videos: [] };
}
return splitAssets(detail.output_assets);
return splitAssets(detail.output_assets, detail.output_type);
}, [detail]);
const status = selectedTask?.status as TaskStatusString | undefined;
@@ -123,7 +152,7 @@ export function OutputPreview() {
);
}
// ---- 加载中 ----
// ---- 加载中(任务详情) ----
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
@@ -133,7 +162,7 @@ export function OutputPreview() {
}
// ---- 无结果 ----
if (!detail || images.length + videos.length === 0) {
if (!detail || (images.length + videos.length === 0)) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Empty description="暂无输出结果" image={Empty.PRESENTED_IMAGE_SIMPLE} />
@@ -167,22 +196,28 @@ export function OutputPreview() {
<div style={{ flex: 1, overflow: 'auto', padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
{videos.map((url, i) => (
<div key={i} style={{ borderRadius: 6, overflow: 'hidden', background: '#000' }}>
<video controls style={{ width: '100%', maxHeight: 360, display: 'block' }} src={url}>
<video controls style={{ width: '100%', maxHeight: 360, display: 'block' }} src={url}
onError={() => console.debug('[OutputPreview] 视频加载失败:', url)}
>
</video>
</div>
))}
{images.length === 1 ? (
<Image src={images[0]} alt="输出结果" style={{ borderRadius: 6, width: '100%', objectFit: 'contain' }} />
<Image src={images[0]} alt="输出结果" style={{ borderRadius: 6, width: '100%', objectFit: 'contain' }}
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMGYwIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiNjMGMwYzAiIGZvbnQtc2l6ZT0iMTQiPuWbvueJh+WKoOi9veWksei0pTwvdGV4dD48L3N2Zz4="
/>
) : (
<Image.PreviewGroup>
{images.map((url, i) => (
<Image key={i} src={url} alt={`输出结果 ${i + 1}`}
style={{ borderRadius: 6, width: '100%', objectFit: 'contain', marginBottom: 8 }} />
style={{ borderRadius: 6, width: '100%', objectFit: 'contain', marginBottom: 8 }}
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMGYwIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiNjMGMwYzAiIGZvbnQtc2l6ZT0iMTQiPuWbvueJh+WKoOi9veWksei0pTwvdGV4dD48L3N2Zz4="
/>
))}
</Image.PreviewGroup>
)}
</div>
</div>
);
}
}

View File

@@ -30,6 +30,8 @@ export interface UIFieldConfig {
index: number;
/** 表单字段名map_to对应 Form.Item name */
name: string;
/** API 声明的 widget 名称(如 "seedance2Content"),供提交时做特殊序列化 */
widgetName: string;
/** 要渲染的 React 组件 */
component: ComponentType<WidgetProps>;
/** 显示标签ui.label → Form.Item label */
@@ -58,7 +60,7 @@ export interface UIFieldConfig {
// ---------- 文件类型 widget 集合 ----------
const FILE_WIDGETS = new Set(['imageInput', 'imageList', 'audioList']);
const FILE_WIDGETS = new Set(['imageInput', 'imageList', 'audioList', 'videoInput']);
// ---------- 约束解析 ----------
@@ -138,6 +140,11 @@ export function useModelUI(
minFiles: spec.min_files,
maxFiles: spec.max_files,
enableSwitch: ui.enable_switch,
// seedance2Contentspec 中的媒体文件数量 + 上传供应商
imageFiles: (spec.image_files as number) ?? 0,
videoFiles: (spec.video_files as number) ?? 0,
audioFiles: (spec.audio_files as number) ?? 0,
uploadProvider: spec.upload_provider as string | undefined,
};
const isFile = FILE_WIDGETS.has(widget);
@@ -148,6 +155,7 @@ export function useModelUI(
return {
index,
name,
widgetName: widget,
component,
label: (ui.label as string) || name,
must: (spec.required as boolean) || false,

View File

@@ -21,6 +21,7 @@ import { StoragePathSetting } from './sections/StoragePathSetting';
import { SystemInfoSetting } from './sections/SystemInfoSetting';
import { UpdateSetting } from './sections/UpdateSetting';
import { PasswordSetting } from './sections/PasswordSetting';
import { DataManageSetting } from './sections/DataManageSetting';
interface SettingsPageProps {
open: boolean;
@@ -65,6 +66,7 @@ export function SettingsPage({ open, onClose }: SettingsPageProps) {
<SystemInfoSetting />
<UpdateSetting />
<StoragePathSetting edition={edition} />
<DataManageSetting />
<PasswordSetting />
</div>
</Modal>

View File

@@ -0,0 +1,112 @@
// ============================================================
// DataManageSetting — 本地数据管理设置区块
//
// 功能:
// - 展示本地缓存统计(任务/订单/媒体数量)
// - [加载历史数据]各拉首批数据100 条任务、100 条充值订单、100 条 VIP 订单)
// - [清理缓存]:清空当前用户本地数据
// - [保存到磁盘]:手动触发加密落盘
//
// "下一页"逻辑在对应列表页组件中:
// - TaskHistory → 滚动到最后一页本地数据时调 fetchMoreTasks()
// - OrdersPage → 滚动到最后一页本地数据时调 fetchMoreOrders()
// ============================================================
import { useState, useCallback, useEffect } from 'react';
import { Card, Button, Typography, App, Divider } from 'antd';
import { CloudDownloadOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import {
initStorage, getStorageStats, clearUserStorage, saveDatabase,
} from '@/infrastructure/storage';
import { fetchAndSyncTasks, fetchAndSyncRechargeOrders, fetchAndSyncVipOrders } from './data-sync';
const { Text } = Typography;
// ---------- 组件 ----------
export function DataManageSetting() {
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState(() => getStorageStats());
const refreshStats = useCallback(() => setStats(getStorageStats()), []);
// 一键加载首批数据
const loadAll = useCallback(async () => {
setLoading(true);
try {
await Promise.allSettled([
fetchAndSyncTasks(),
fetchAndSyncRechargeOrders(),
fetchAndSyncVipOrders(),
]);
refreshStats();
} finally {
setLoading(false);
}
}, [refreshStats]);
// 清理缓存
const handleClear = useCallback(() => {
clearUserStorage().then(async () => {
await initStorage();
refreshStats();
message.success('本地缓存已清理');
});
}, [message, refreshStats]);
// 保存到磁盘
const handleSave = useCallback(() => {
saveDatabase().then(ok => {
message[ok ? 'success' : 'error'](ok ? '数据已保存到磁盘' : '保存失败');
});
}, [message]);
useEffect(() => { refreshStats(); }, [refreshStats]);
return (
<Card size="small" title="数据管理" className="rounded-md!">
<div className="flex flex-col gap-3">
{/* ---- 统计 ---- */}
<div className="flex flex-col gap-1">
<Text type="secondary" className="text-xs"></Text>
<div className="flex gap-4">
<Text className="text-xs">
<Text strong>{stats.taskCount}</Text>
</Text>
<Text className="text-xs">
<Text strong>{stats.orderCount}</Text>
</Text>
<Text className="text-xs">
<Text strong>{stats.mediaCount}</Text>
</Text>
</div>
</div>
<Divider style={{ margin: '4px 0' }} />
<Button
size="small"
type="primary"
icon={<CloudDownloadOutlined />}
onClick={loadAll}
loading={loading}
block
>
</Button>
<Divider style={{ margin: '4px 0' }} />
<div className="flex gap-2">
<Button size="small" icon={<ReloadOutlined />} onClick={handleSave}>
</Button>
<Button size="small" danger icon={<DeleteOutlined />} onClick={handleClear}>
</Button>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,90 @@
// ============================================================
// data-sync.ts — 数据同步辅助函数
//
// 供设置页 [加载历史数据] 和列表页 [下一页] 共用。
// 职责:
// - 调用 API 拉取分页数据
// - 同步到本地 sql.js 缓存
// - 更新分页游标
// - 返回是否还有更多数据
// ============================================================
import {
syncTasks, syncRechargeOrders, syncVipOrders,
hasMore, nextPage, updateCursor,
} from '@/infrastructure/storage';
import { TaskAPI } from '@/services/modules/task';
import { PaymentAPI } from '@/services/modules/payments';
import { VipAPI } from '@/services/modules/vip-api';
import { logger } from '@/utils/logger';
const PAGE_SIZE = 100;
// ======== 任务 ========
export async function fetchAndSyncTasks(): Promise<{ count: number; hasMore: boolean }> {
if (!hasMore('tasks')) return { count: 0, hasMore: false };
const page = nextPage('tasks');
try {
const res = await TaskAPI.getTaskList({ page, page_size: PAGE_SIZE });
const sorted = [...res.items].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
syncTasks(sorted);
updateCursor('tasks', page, res.total);
logger.info('storage', `任务同步:第 ${page} 页,${sorted.length} 条,共 ${res.total}`);
return { count: sorted.length, hasMore: page * PAGE_SIZE < res.total };
} catch (err) {
logger.error('storage', '任务同步失败', err instanceof Error ? err : undefined);
throw err;
}
}
export function hasMoreTasks(): boolean {
return hasMore('tasks');
}
// ======== 充值订单 ========
export async function fetchAndSyncRechargeOrders(): Promise<{ count: number; hasMore: boolean }> {
if (!hasMore('recharge_orders')) return { count: 0, hasMore: false };
const page = nextPage('recharge_orders');
try {
const res = await PaymentAPI.getOrdersListInfo({ page, page_size: PAGE_SIZE });
syncRechargeOrders(res);
// API 未返回 total → 用返回条数推算
const total = res.length >= PAGE_SIZE ? (page * PAGE_SIZE) + PAGE_SIZE : page * PAGE_SIZE;
updateCursor('recharge_orders', page, total);
logger.info('storage', `充值订单同步:第 ${page} 页,${res.length}`);
return { count: res.length, hasMore: res.length >= PAGE_SIZE };
} catch (err) {
logger.error('storage', '充值订单同步失败', err instanceof Error ? err : undefined);
throw err;
}
}
export function hasMoreRechargeOrders(): boolean {
return hasMore('recharge_orders');
}
// ======== VIP 订单 ========
export async function fetchAndSyncVipOrders(): Promise<{ count: number; hasMore: boolean }> {
if (!hasMore('vip_orders')) return { count: 0, hasMore: false };
const page = nextPage('vip_orders');
try {
const res = await VipAPI.getVipOrders({ page, page_size: PAGE_SIZE });
syncVipOrders(res);
const total = res.length >= PAGE_SIZE ? (page * PAGE_SIZE) + PAGE_SIZE : page * PAGE_SIZE;
updateCursor('vip_orders', page, total);
logger.info('storage', `VIP 订单同步:第 ${page} 页,${res.length}`);
return { count: res.length, hasMore: res.length >= PAGE_SIZE };
} catch (err) {
logger.error('storage', 'VIP 订单同步失败', err instanceof Error ? err : undefined);
throw err;
}
}
export function hasMoreVipOrders(): boolean {
return hasMore('vip_orders');
}

11
src/vite-env.d.ts vendored
View File

@@ -13,6 +13,17 @@ declare global {
encrypt(plaintext: string): Promise<string | null>;
decrypt(encryptedBase64: string): Promise<string | null>;
};
/** 本地存储文件 API由 preload 注入,主进程 fs 桥接) */
fileStorage: {
getDataDir(): Promise<string | null>;
writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null>;
readFile(relativePath: string): Promise<string | null>;
deleteFile(relativePath: string): Promise<boolean>;
makeDir(relativePath: string): Promise<string | null>;
showItemInFolder(filePath: string): Promise<boolean>;
fileSize(relativePath: string): Promise<number | null>;
fileExists(relativePath: string): Promise<boolean>;
};
}
}