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:
46
CHANGELOG.md
46
CHANGELOG.md
@@ -1,6 +1,52 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
||||||
|
---
|
||||||
|
## 0.0.22(2026-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.js(WASM SQLite)渲染进程内存数据库,DDL 7 张表(task_records / order_records / param_history / media_cache / sync_cursor / app_settings / _schema_version)
|
||||||
|
- **加密**:safeStorage(OS 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.21(2026-06-15)
|
## 0.0.21(2026-06-15)
|
||||||
|
|
||||||
|
|||||||
11
TODO.md
11
TODO.md
@@ -1,6 +1,6 @@
|
|||||||
# TODO — 船长·HeiXiu 待办与优化清单
|
# TODO — 船长·HeiXiu 待办与优化清单
|
||||||
|
|
||||||
> 最后更新:2026-06-15
|
> 最后更新:2026-06-18
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,6 +21,15 @@
|
|||||||
- [ ] 项目管理页面(`/projects`,企业版)
|
- [ ] 项目管理页面(`/projects`,企业版)
|
||||||
- [ ] 会员页面(`/vip`)
|
- [ ] 会员页面(`/vip`)
|
||||||
- [ ] 消费记录页面
|
- [ ] 消费记录页面
|
||||||
|
- [x] 本地数据管理(加载历史/保存到磁盘/清理缓存) — `DataManageSetting` + sql.js 存储
|
||||||
|
|
||||||
|
### 媒体与文件
|
||||||
|
|
||||||
|
- [ ] 媒体文件本地下载+缓存(主进程 HTTP 无 CORS 限制,替代渲染进程 axios)
|
||||||
|
- [ ] CDN 签名过期检测(主进程发 HEAD 请求读 `SignatureExpired` 响应体)
|
||||||
|
- [ ] 列表页"下一页"懒加载集成(TaskHistory / OrdersPage 调用 `fetchAndSync*`)
|
||||||
|
- [ ] 参数回填 UI(选择模型时显示历史参数下拉)
|
||||||
|
- [ ] 右键"在文件夹中查看"功能(媒体预览右键菜单)
|
||||||
|
|
||||||
### 导航栏
|
### 导航栏
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {setupAppMenu} from './main/menu';
|
|||||||
import {initLogger, flushLogger, logger} from './main/logger';
|
import {initLogger, flushLogger, logger} from './main/logger';
|
||||||
import {registerLogIpcHandlers} from './main/log-ipc';
|
import {registerLogIpcHandlers} from './main/log-ipc';
|
||||||
import {registerSafeStorageIpcHandlers} from './main/safe-storage-ipc';
|
import {registerSafeStorageIpcHandlers} from './main/safe-storage-ipc';
|
||||||
|
import {registerStorageIpcHandlers} from './main/storage-ipc';
|
||||||
import {BIDIRECTIONAL} from '../shared/constants/ipc-channels';
|
import {BIDIRECTIONAL} from '../shared/constants/ipc-channels';
|
||||||
|
|
||||||
createRequire(import.meta.url);
|
createRequire(import.meta.url);
|
||||||
@@ -148,6 +149,7 @@ app.whenReady().then(() => {
|
|||||||
initLogger();
|
initLogger();
|
||||||
registerLogIpcHandlers();
|
registerLogIpcHandlers();
|
||||||
registerSafeStorageIpcHandlers();
|
registerSafeStorageIpcHandlers();
|
||||||
|
registerStorageIpcHandlers();
|
||||||
setupAppMenu();
|
setupAppMenu();
|
||||||
app.setName(WINDOW_TITLE);
|
app.setName(WINDOW_TITLE);
|
||||||
registerUpdateIpcHandlers();
|
registerUpdateIpcHandlers();
|
||||||
|
|||||||
161
electron/main/storage-ipc.ts
Normal file
161
electron/main/storage-ipc.ts
Normal 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 处理器已注册');
|
||||||
|
}
|
||||||
@@ -43,3 +43,47 @@ contextBridge.exposeInMainWorld('safeStorage', {
|
|||||||
return result?.success ? result.data : null;
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />-->
|
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />-->
|
||||||
<meta http-equiv="Content-Security-Policy" content="
|
<meta http-equiv="Content-Security-Policy" content="
|
||||||
default-src 'self';
|
default-src 'self';
|
||||||
script-src 'self' 'unsafe-inline';
|
script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval';
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
img-src 'self' data: https://rh-images-1252422369.cos.ap-beijing.myqcloud.com https://www.heixiu.net ; ;
|
img-src 'self' data: https: ;
|
||||||
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net ;
|
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>
|
<title>船长·HeiXiu</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1,19 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "ele-heixiu",
|
"name": "ele-heixiu",
|
||||||
"version": "0.0.13",
|
"version": "0.0.18",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ele-heixiu",
|
"name": "ele-heixiu",
|
||||||
"version": "0.0.13",
|
"version": "0.0.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-buddy/ide-toolbox": "^2.5.0",
|
"@react-buddy/ide-toolbox": "^2.5.0",
|
||||||
"antd": "^6.4.3",
|
"antd": "^6.4.3",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.7",
|
||||||
|
"sql.js": "^1.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"@types/react": "^19.2.16",
|
"@types/react": "^19.2.16",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/sql.js": "^1.4.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||||
"@typescript-eslint/parser": "^8.60.1",
|
"@typescript-eslint/parser": "^8.60.1",
|
||||||
"@vitejs/plugin-react": "^6.0.0",
|
"@vitejs/plugin-react": "^6.0.0",
|
||||||
@@ -2594,6 +2596,13 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
@@ -2701,6 +2710,17 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/verror": {
|
||||||
"version": "1.10.11",
|
"version": "1.10.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
|
||||||
@@ -7599,6 +7619,12 @@
|
|||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"optional": true
|
"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": {
|
"node_modules/stat-mode": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",
|
||||||
|
|||||||
@@ -31,7 +31,8 @@
|
|||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.7",
|
||||||
|
"sql.js": "^1.14.1"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.heixiu.electron",
|
"appId": "com.heixiu.electron",
|
||||||
@@ -94,6 +95,7 @@
|
|||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"@types/react": "^19.2.16",
|
"@types/react": "^19.2.16",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/sql.js": "^1.4.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||||
"@typescript-eslint/parser": "^8.60.1",
|
"@typescript-eslint/parser": "^8.60.1",
|
||||||
"@vitejs/plugin-react": "^6.0.0",
|
"@vitejs/plugin-react": "^6.0.0",
|
||||||
|
|||||||
BIN
public/sql-wasm-browser.wasm
Normal file
BIN
public/sql-wasm-browser.wasm
Normal file
Binary file not shown.
BIN
public/sql-wasm.wasm
Normal file
BIN
public/sql-wasm.wasm
Normal file
Binary file not shown.
@@ -46,4 +46,20 @@ export const BIDIRECTIONAL = {
|
|||||||
SAFESTORAGE_ENCRYPT: 'safe-storage:encrypt',
|
SAFESTORAGE_ENCRYPT: 'safe-storage:encrypt',
|
||||||
/** safeStorage 解密(Base64 密文 → 明文) */
|
/** safeStorage 解密(Base64 密文 → 明文) */
|
||||||
SAFESTORAGE_DECRYPT: 'safe-storage:decrypt',
|
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;
|
} as const;
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export type LogCategory =
|
|||||||
| 'app' // 应用生命周期
|
| 'app' // 应用生命周期
|
||||||
| 'ui' // UI 层错误 / 警告
|
| 'ui' // UI 层错误 / 警告
|
||||||
| 'ipc' // IPC 通信
|
| 'ipc' // IPC 通信
|
||||||
| 'safe-storage'; // 安全存储(safeStorage 加密/解密)
|
| 'safe-storage' // 安全存储(safeStorage 加密/解密)
|
||||||
|
| 'storage'; // 本地存储(IndexedDB / sql.js / 文件缓存)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 结构化错误快照
|
* 结构化错误快照
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
clearProactiveRefresh,
|
clearProactiveRefresh,
|
||||||
} from '@/services/auth-token';
|
} from '@/services/auth-token';
|
||||||
import {initModelCache} from '@/services/cache/model-cache';
|
import {initModelCache} from '@/services/cache/model-cache';
|
||||||
|
import {initStorage, clearUserStorage} from '@/infrastructure/storage';
|
||||||
|
|
||||||
// ---------- Context 类型 ----------
|
// ---------- Context 类型 ----------
|
||||||
|
|
||||||
@@ -110,6 +111,8 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
setIsLoggedIn(false);
|
setIsLoggedIn(false);
|
||||||
emit(EVENTS.LOGOUT);
|
emit(EVENTS.LOGOUT);
|
||||||
|
// 异步清理本地存储(不阻塞 UI)
|
||||||
|
void clearUserStorage();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
|
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
|
||||||
@@ -121,7 +124,7 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
// 从加密存储恢复 token + 模型缓存 到内存缓存
|
// 从加密存储恢复 token + 模型缓存 到内存缓存
|
||||||
Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache()]).then(() => {
|
Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache(), initStorage()]).then(() => {
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
|
|
||||||
// 初始化 401 刷新回调(token 缓存就绪后才注册)
|
// 初始化 401 刷新回调(token 缓存就绪后才注册)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||||
|
import { setOutputPath as saveOutputPath, setTeamRepoPath as saveTeamRepoPath } from '@/infrastructure/storage';
|
||||||
|
|
||||||
// ---------- localStorage 键 ----------
|
// ---------- localStorage 键 ----------
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
|
|||||||
const setOutputPath = useCallback((path: string) => {
|
const setOutputPath = useCallback((path: string) => {
|
||||||
setOutputPathState(path);
|
setOutputPathState(path);
|
||||||
writeStoredOutputPath(path);
|
writeStoredOutputPath(path);
|
||||||
|
try { saveOutputPath(path); } catch { /* db 未初始化则跳过 */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearOutputPath = useCallback(() => {
|
const clearOutputPath = useCallback(() => {
|
||||||
@@ -120,6 +122,7 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
|
|||||||
const setTeamRepoPath = useCallback((path: string) => {
|
const setTeamRepoPath = useCallback((path: string) => {
|
||||||
setTeamRepoPathState(path);
|
setTeamRepoPathState(path);
|
||||||
writeStoredTeamRepoPath(path);
|
writeStoredTeamRepoPath(path);
|
||||||
|
try { saveTeamRepoPath(path); } catch { /* db 未初始化则跳过 */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearTeamRepoPath = useCallback(() => {
|
const clearTeamRepoPath = useCallback(() => {
|
||||||
|
|||||||
176
src/infrastructure/storage/db-core.ts
Normal file
176
src/infrastructure/storage/db-core.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行一条写操作 SQL(INSERT / 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/infrastructure/storage/db-encrypt.ts
Normal file
123
src/infrastructure/storage/db-encrypt.ts
Normal 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] saveDatabase:exportDatabase 返回 null(数据库未打开?)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log(`[storage] saveDatabase:导出 ${data.byteLength} bytes`);
|
||||||
|
|
||||||
|
const base64 = uint8ArrayToBase64(data);
|
||||||
|
console.log(`[storage] saveDatabase:Base64 编码后 ${base64.length} chars`);
|
||||||
|
|
||||||
|
// Electron 环境:加密后写磁盘
|
||||||
|
if (hasIpc() && window.fileStorage && window.safeStorage) {
|
||||||
|
console.log('[storage] saveDatabase:Electron 环境,准备加密...');
|
||||||
|
try {
|
||||||
|
const encrypted = await window.safeStorage.encrypt(base64);
|
||||||
|
if (!encrypted) {
|
||||||
|
console.warn('[storage] saveDatabase:safeStorage.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] loadDatabase:Electron 环境,读取磁盘...');
|
||||||
|
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] loadDatabase:localStorage 无数据');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
console.log(`[storage] loadDatabase:localStorage 有数据 ${raw.length} chars`);
|
||||||
|
return base64ToUint8Array(raw);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/infrastructure/storage/index.ts
Normal file
128
src/infrastructure/storage/index.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
76
src/infrastructure/storage/ipc-file.ts
Normal file
76
src/infrastructure/storage/ipc-file.ts
Normal 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();
|
||||||
|
}
|
||||||
267
src/infrastructure/storage/media-loader.ts
Normal file
267
src/infrastructure/storage/media-loader.ts
Normal 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: URI(readFileAsBlobUrl 的做法),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) → 返回 MediaLoadFailure(reason: 'signature_expired')
|
||||||
|
* - 其他网络错误 → 返回 MediaLoadFailure(reason: 'network_error')
|
||||||
|
* - 请求取消 → 返回 MediaLoadFailure(reason: '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/infrastructure/storage/media-store.ts
Normal file
80
src/infrastructure/storage/media-store.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// ============================================================
|
||||||
|
// infrastructure/storage/media-store.ts — 媒体缓存存储
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 媒体缓存元数据 CRUD(Blob 实体由主进程管理)
|
||||||
|
// - 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()]);
|
||||||
|
}
|
||||||
104
src/infrastructure/storage/order-store.ts
Normal file
104
src/infrastructure/storage/order-store.ts
Normal 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()]);
|
||||||
|
}
|
||||||
80
src/infrastructure/storage/param-store.ts
Normal file
80
src/infrastructure/storage/param-store.ts
Normal 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 */ }
|
||||||
|
}
|
||||||
82
src/infrastructure/storage/schema.ts
Normal file
82
src/infrastructure/storage/schema.ts
Normal 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})`;
|
||||||
52
src/infrastructure/storage/settings-store.ts
Normal file
52
src/infrastructure/storage/settings-store.ts
Normal 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);
|
||||||
|
}
|
||||||
60
src/infrastructure/storage/sync-manager.ts
Normal file
60
src/infrastructure/storage/sync-manager.ts
Normal 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;
|
||||||
|
}
|
||||||
97
src/infrastructure/storage/task-store.ts
Normal file
97
src/infrastructure/storage/task-store.ts
Normal 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]);
|
||||||
|
}
|
||||||
94
src/infrastructure/storage/types.ts
Normal file
94
src/infrastructure/storage/types.ts
Normal 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;
|
||||||
80
src/infrastructure/storage/user-scope.ts
Normal file
80
src/infrastructure/storage/user-scope.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户 ID(JWT 解码 + 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`;
|
||||||
|
}
|
||||||
@@ -108,6 +108,8 @@ export function HomeContent() {
|
|||||||
const leftWidthRef = useRef(leftWidth);
|
const leftWidthRef = useRef(leftWidth);
|
||||||
const rightWidthRef = useRef(rightWidth);
|
const rightWidthRef = useRef(rightWidth);
|
||||||
const draggingRef = useRef(dragging);
|
const draggingRef = useRef(dragging);
|
||||||
|
// 标记拖拽期间被跳过的重算,拖拽结束后补算
|
||||||
|
const needsRecalcRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
leftWidthRef.current = leftWidth;
|
leftWidthRef.current = leftWidth;
|
||||||
@@ -147,6 +149,7 @@ export function HomeContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
|
draggingRef.current = null; // 即时清除 —— 优先级高于 React 状态,避免 ResizeObserver 被永久阻塞
|
||||||
setDragging(null);
|
setDragging(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,41 +162,97 @@ export function HomeContent() {
|
|||||||
};
|
};
|
||||||
}, [dragging]);
|
}, [dragging]);
|
||||||
|
|
||||||
// ======== 视口缩小自动收窄面板 ========
|
// ======== 视口缩放时按比例自动调整面板宽度 ========
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
// 拖拽中由 mousemove handler 处理约束,避免冲突
|
// 拖拽中由 mousemove handler 处理约束,避免冲突;
|
||||||
if (draggingRef.current) return;
|
// 若跳过则标记 needsRecalcRef,拖拽结束后补算
|
||||||
|
if (draggingRef.current) {
|
||||||
|
needsRecalcRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每次回调从 ref 读取当前 DOM 元素(避免闭包捕获 mount 时的 el)
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
const containerWidth = el.clientWidth;
|
const containerWidth = el.clientWidth;
|
||||||
|
|
||||||
const currentLeft = leftWidthRef.current;
|
const currentLeft = leftWidthRef.current;
|
||||||
const currentRight = rightWidthRef.current;
|
const currentRight = rightWidthRef.current;
|
||||||
|
|
||||||
// 检查当前总宽度是否超出容器
|
// 左右面板可用的总空间(中列至少保留 CENTER_MIN)
|
||||||
const totalNeeded = currentLeft + LAYOUT_OVERHEAD + CENTER_MIN + currentRight;
|
const sideAvailable = containerWidth - LAYOUT_OVERHEAD - CENTER_MIN;
|
||||||
if (totalNeeded <= containerWidth) return;
|
const sideTotal = currentLeft + currentRight;
|
||||||
|
|
||||||
// 超出容器 → 优先收缩右侧面板,再收左侧
|
if (sideAvailable <= 0 || sideTotal <= 0) return;
|
||||||
const deficit = totalNeeded - containerWidth;
|
|
||||||
const rightSlack = currentRight - RIGHT_MIN;
|
|
||||||
|
|
||||||
if (rightSlack >= deficit) {
|
// ---- 按当前比例分配空间 ----
|
||||||
setRightWidth(currentRight - deficit);
|
const ratio = currentLeft / sideTotal;
|
||||||
} else {
|
let newLeft = Math.round(sideAvailable * ratio);
|
||||||
setRightWidth(RIGHT_MIN);
|
let newRight = sideAvailable - newLeft;
|
||||||
const remainingDeficit = deficit - rightSlack;
|
|
||||||
setLeftWidth(Math.max(LEFT_MIN, currentLeft - remainingDeficit));
|
// ---- 最小值约束(保持比例的前提下不跌破下限) ----
|
||||||
|
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);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
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 => {
|
const dividerStyle = (side: 'left' | 'right'): React.CSSProperties => {
|
||||||
|
|||||||
@@ -14,45 +14,22 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
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 { 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 type { WidgetProps } from './types';
|
||||||
import { useFileUpload } from '@/hooks/use-api';
|
import { useFileUpload } from '@/hooks/use-api';
|
||||||
|
import { buildAccept, createMediaBeforeUpload } from './media-upload-utils';
|
||||||
|
|
||||||
const { Text } = Typography;
|
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 渲染时引用变化 */
|
/** 标记"已启用但空"的空数组,避免 react 渲染时引用变化 */
|
||||||
const ENABLED_EMPTY: UploadFile[] = [];
|
const ENABLED_EMPTY: UploadFile[] = [];
|
||||||
|
|
||||||
export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
const fileList = (value as UploadFile[]) || [];
|
const fileList = (value as UploadFile[]) || [];
|
||||||
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||||
|
const filters = config.fileFilter as string[] | undefined;
|
||||||
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
|
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
|
||||||
|
|
||||||
// 文件上传 Hook(提供 customRequest → antd Upload 集成)
|
// 文件上传 Hook(提供 customRequest → antd Upload 集成)
|
||||||
@@ -95,7 +72,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
|||||||
<Upload
|
<Upload
|
||||||
listType="picture-card"
|
listType="picture-card"
|
||||||
maxCount={1}
|
maxCount={1}
|
||||||
accept={buildAccept(config.fileFilter as string[] | undefined)}
|
accept={buildAccept(filters)}
|
||||||
fileList={fileList}
|
fileList={fileList}
|
||||||
customRequest={customRequest}
|
customRequest={customRequest}
|
||||||
onChange={({ fileList: newList }) => {
|
onChange={({ fileList: newList }) => {
|
||||||
@@ -105,7 +82,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isUploadDisabled}
|
disabled={isUploadDisabled}
|
||||||
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
beforeUpload={createMediaBeforeUpload(maxSizeMb, filters)}
|
||||||
>
|
>
|
||||||
{fileList.length < 1 && (
|
{fileList.length < 1 && (
|
||||||
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ImageListInput — imageList → Dragger(多张图片拖拽上传)
|
// ImageListInput — imageList → Dragger(多张图片/视频拖拽上传)
|
||||||
// 用于图生图模型的多图输入场景
|
// 用于图生图/图生视频模型的多图输入场景
|
||||||
|
// 当 file_filter 包含视频扩展名时,自动支持视频文件上传
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { Upload } from 'antd';
|
import { Upload } from 'antd';
|
||||||
import { InboxOutlined } from '@ant-design/icons';
|
import { InboxOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
import type { WidgetProps } from './types';
|
import type { WidgetProps } from './types';
|
||||||
import { createImageBeforeUpload } from './ImageInput';
|
|
||||||
import { useFileUpload } from '@/hooks/use-api';
|
import { useFileUpload } from '@/hooks/use-api';
|
||||||
|
import { buildAccept, createMediaBeforeUpload, VIDEO_EXTENSIONS } from './media-upload-utils';
|
||||||
|
|
||||||
const { Dragger } = Upload;
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
@@ -17,32 +18,31 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp
|
|||||||
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||||
const maxCount = (config.maxFiles as number) || 10;
|
const maxCount = (config.maxFiles as number) || 10;
|
||||||
const filters = config.fileFilter as string[] | undefined;
|
const filters = config.fileFilter as string[] | undefined;
|
||||||
|
const hasVideo = filters?.some(f => VIDEO_EXTENSIONS.has(f.toLowerCase()));
|
||||||
|
|
||||||
const { customRequest } = useFileUpload();
|
const { customRequest } = useFileUpload();
|
||||||
|
|
||||||
|
const mediaLabel = hasVideo ? '图片/视频' : '图片';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dragger
|
<Dragger
|
||||||
multiple
|
multiple
|
||||||
listType="picture"
|
listType="picture"
|
||||||
accept={
|
accept={buildAccept(filters)}
|
||||||
filters
|
|
||||||
? filters.map((ext) => `image/${ext}`).join(',')
|
|
||||||
: 'image/png,image/jpg,image/jpeg,image/webp'
|
|
||||||
}
|
|
||||||
fileList={fileList}
|
fileList={fileList}
|
||||||
customRequest={customRequest}
|
customRequest={customRequest}
|
||||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
beforeUpload={createMediaBeforeUpload(maxSizeMb, filters)}
|
||||||
maxCount={maxCount}
|
maxCount={maxCount}
|
||||||
>
|
>
|
||||||
<p className="ant-upload-drag-icon">
|
<p className="ant-upload-drag-icon">
|
||||||
<InboxOutlined />
|
<InboxOutlined />
|
||||||
</p>
|
</p>
|
||||||
<p className="ant-upload-text">点击或拖拽图片到此区域上传</p>
|
<p className="ant-upload-text">点击或拖拽{mediaLabel}到此区域上传</p>
|
||||||
<p className="ant-upload-hint">
|
<p className="ant-upload-hint">
|
||||||
支持 {filters?.join(' / ').toUpperCase() || 'PNG / JPG / WebP'}
|
支持 {filters?.join(' / ').toUpperCase() || 'PNG / JPG / WebP'}
|
||||||
,单张不超过 {maxSizeMb}MB
|
,单文件不超过 {maxSizeMb}MB
|
||||||
</p>
|
</p>
|
||||||
</Dragger>
|
</Dragger>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,172 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// 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 { useCallback, useMemo } from 'react';
|
||||||
import { PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
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 type { WidgetProps } from './types';
|
||||||
|
import { useFileUpload } from '@/hooks/use-api';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
const { Dragger } = Upload;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ---------- 内部值类型 ----------
|
||||||
|
|
||||||
|
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) {
|
export function SeedanceContent({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
const { token } = antTheme.useToken();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -27,8 +178,9 @@ export function SeedanceContent({ value, onChange, disabled, config }: WidgetPro
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
<PictureOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
{(imageFiles > 0) && <PictureOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />}
|
||||||
<VideoCameraOutlined 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 type="secondary" style={{ fontSize: 12 }}>
|
||||||
内容编排 — 描述动作 + 参考素材
|
内容编排 — 描述动作 + 参考素材
|
||||||
</Text>
|
</Text>
|
||||||
@@ -36,20 +188,61 @@ export function SeedanceContent({ value, onChange, disabled, config }: WidgetPro
|
|||||||
|
|
||||||
<TextArea
|
<TextArea
|
||||||
rows={4}
|
rows={4}
|
||||||
value={value as string}
|
value={cv.text}
|
||||||
onChange={(e) => onChange?.(e.target.value)}
|
onChange={handleTextChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={
|
placeholder={
|
||||||
(config.placeholder as string) ||
|
(config.placeholder as string) ||
|
||||||
'输入视频内容的文字描述(后续版本将支持拖拽编排图片/视频参考素材)'
|
'输入视频内容的文字描述'
|
||||||
}
|
}
|
||||||
maxLength={(config.maxLength as number) || 5000}
|
maxLength={(config.maxLength as number) || 5000}
|
||||||
showCount
|
showCount
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text type="secondary" style={{ display: 'block', marginTop: 6, fontSize: 11 }}>
|
{/* 参考图片上传 */}
|
||||||
💡 更多内容编排能力即将上线:支持文本 + 参考图 + 参考视频自由组合
|
{imageFiles > 0 && (
|
||||||
</Text>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
119
src/pages/home/controls/VideoInput.tsx
Normal file
119
src/pages/home/controls/VideoInput.tsx
Normal 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) {
|
||||||
|
// 关闭开关 → 表单值置为 undefined(isValueEmpty = 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { DoubleSpinBox } from './DoubleSpinBox';
|
|||||||
import { ImageInput } from './ImageInput';
|
import { ImageInput } from './ImageInput';
|
||||||
import { ImageListInput } from './ImageListInput';
|
import { ImageListInput } from './ImageListInput';
|
||||||
import { AudioListInput } from './AudioListInput';
|
import { AudioListInput } from './AudioListInput';
|
||||||
|
import { VideoInput } from './VideoInput';
|
||||||
import { SeedanceContent } from './SeedanceContent';
|
import { SeedanceContent } from './SeedanceContent';
|
||||||
|
|
||||||
export type { WidgetProps } from './types';
|
export type { WidgetProps } from './types';
|
||||||
@@ -45,6 +46,7 @@ export const WIDGET_MAP: Record<string, ComponentType<WidgetProps>> = {
|
|||||||
imageInput: ImageInput,
|
imageInput: ImageInput,
|
||||||
imageList: ImageListInput,
|
imageList: ImageListInput,
|
||||||
audioList: AudioListInput,
|
audioList: AudioListInput,
|
||||||
|
videoInput: VideoInput,
|
||||||
seedance2Content: SeedanceContent,
|
seedance2Content: SeedanceContent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
62
src/pages/home/controls/media-upload-utils.ts
Normal file
62
src/pages/home/controls/media-upload-utils.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
// - 本组件只负责:布局、Form 容器、提交逻辑、依赖禁用
|
// - 本组件只负责:布局、Form 容器、提交逻辑、依赖禁用
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useLayoutEffect, useMemo, useCallback, useRef } from 'react';
|
import {useState, useLayoutEffect, useMemo, useCallback, useRef, useEffect} from 'react';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
@@ -156,7 +156,9 @@ export function ModelInputForm() {
|
|||||||
const submitCountRef = useRef(0);
|
const submitCountRef = useRef(0);
|
||||||
/** 最新错误信息的 ref(避免 doSubmit useCallback 闭包过期) */
|
/** 最新错误信息的 ref(避免 doSubmit useCallback 闭包过期) */
|
||||||
const errorMessageRef = useRef(errorMessage);
|
const errorMessageRef = useRef(errorMessage);
|
||||||
|
useEffect(() => {
|
||||||
errorMessageRef.current = errorMessage;
|
errorMessageRef.current = errorMessage;
|
||||||
|
}, [errorMessage]);
|
||||||
|
|
||||||
// ======== useModelUI:param_schema → UIFieldConfig[] ========
|
// ======== useModelUI:param_schema → UIFieldConfig[] ========
|
||||||
|
|
||||||
@@ -234,6 +236,35 @@ export function ModelInputForm() {
|
|||||||
continue;
|
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') {
|
if (typeof value === 'boolean') {
|
||||||
params[field.name] = String(value);
|
params[field.name] = String(value);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// OutputPreview — 任务输出预览(图片 / 视频)
|
// OutputPreview — 任务输出预览(图片 / 视频)
|
||||||
// 使用 useTaskDetail() Hook 获取任务详情,组件仅负责渲染
|
// 使用 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';
|
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 {
|
function isVideoUrl(url: string): boolean {
|
||||||
return /\.(mp4|webm|mov|avi|mkv)(\?|$)/i.test(url);
|
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 images: string[] = [];
|
||||||
const videos: string[] = [];
|
const videos: string[] = [];
|
||||||
|
|
||||||
assets.forEach(({ url }) => {
|
assets.forEach(({ url }) => {
|
||||||
if (isVideoUrl(url)) videos.push(url);
|
if (isVideoUrl(url)) {
|
||||||
else images.push(url);
|
videos.push(url);
|
||||||
|
} else if (outputType === 'video' && videos.length === 0) {
|
||||||
|
// CDN 无扩展名链接,由 output_type 提示首个为视频
|
||||||
|
videos.push(url);
|
||||||
|
} else {
|
||||||
|
images.push(url);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { images, videos };
|
return { images, videos };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,16 +86,12 @@ export function OutputPreview() {
|
|||||||
const { data: detail, loading, refetch } = useTaskDetail(selectedTask?.id);
|
const { data: detail, loading, refetch } = useTaskDetail(selectedTask?.id);
|
||||||
|
|
||||||
// ======== 状态转换监听:非终态→终态时触发详情刷新 ========
|
// ======== 状态转换监听:非终态→终态时触发详情刷新 ========
|
||||||
// 不再独立轮询 getTaskStatus(避免与 TaskHistory 的批量轮询重复请求),
|
|
||||||
// 而是依赖批量轮询通过 Context 同步过来的 selectedTask.status 变化。
|
|
||||||
|
|
||||||
const prevStatusRef = useRef<TaskStatusString | undefined>(undefined);
|
const prevStatusRef = useRef<TaskStatusString | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const status = selectedTask?.status as TaskStatusString | undefined;
|
const status = selectedTask?.status as TaskStatusString | undefined;
|
||||||
const prev = prevStatusRef.current;
|
const prev = prevStatusRef.current;
|
||||||
|
|
||||||
// 从非终态变为终态 → 刷新详情获取完整数据(output_assets 等)
|
|
||||||
if (
|
if (
|
||||||
prev &&
|
prev &&
|
||||||
POLLING_STATUSES.includes(prev) &&
|
POLLING_STATUSES.includes(prev) &&
|
||||||
@@ -75,12 +104,12 @@ export function OutputPreview() {
|
|||||||
prevStatusRef.current = status;
|
prevStatusRef.current = status;
|
||||||
}, [selectedTask?.status, refetch]);
|
}, [selectedTask?.status, refetch]);
|
||||||
|
|
||||||
// 从 output_assets 提取图片/视频
|
// 按类型分类(URL 正则 + output_type 兜底)
|
||||||
const { images, videos } = useMemo(() => {
|
const { images, videos } = useMemo(() => {
|
||||||
if (!detail?.output_assets || detail.output_assets.length === 0) {
|
if (!detail?.output_assets || detail.output_assets.length === 0) {
|
||||||
return { images: [], videos: [] };
|
return { images: [], videos: [] };
|
||||||
}
|
}
|
||||||
return splitAssets(detail.output_assets);
|
return splitAssets(detail.output_assets, detail.output_type);
|
||||||
}, [detail]);
|
}, [detail]);
|
||||||
|
|
||||||
const status = selectedTask?.status as TaskStatusString | undefined;
|
const status = selectedTask?.status as TaskStatusString | undefined;
|
||||||
@@ -123,7 +152,7 @@ export function OutputPreview() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 加载中 ----
|
// ---- 加载中(任务详情) ----
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
<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 (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||||
<Empty description="暂无输出结果" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
<Empty description="暂无输出结果" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
@@ -167,18 +196,24 @@ export function OutputPreview() {
|
|||||||
<div style={{ flex: 1, overflow: 'auto', padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ flex: 1, overflow: 'auto', padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
{videos.map((url, i) => (
|
{videos.map((url, i) => (
|
||||||
<div key={i} style={{ borderRadius: 6, overflow: 'hidden', background: '#000' }}>
|
<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>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{images.length === 1 ? (
|
{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>
|
<Image.PreviewGroup>
|
||||||
{images.map((url, i) => (
|
{images.map((url, i) => (
|
||||||
<Image key={i} src={url} alt={`输出结果 ${i + 1}`}
|
<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>
|
</Image.PreviewGroup>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface UIFieldConfig {
|
|||||||
index: number;
|
index: number;
|
||||||
/** 表单字段名(map_to),对应 Form.Item name */
|
/** 表单字段名(map_to),对应 Form.Item name */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** API 声明的 widget 名称(如 "seedance2Content"),供提交时做特殊序列化 */
|
||||||
|
widgetName: string;
|
||||||
/** 要渲染的 React 组件 */
|
/** 要渲染的 React 组件 */
|
||||||
component: ComponentType<WidgetProps>;
|
component: ComponentType<WidgetProps>;
|
||||||
/** 显示标签(ui.label → Form.Item label) */
|
/** 显示标签(ui.label → Form.Item label) */
|
||||||
@@ -58,7 +60,7 @@ export interface UIFieldConfig {
|
|||||||
|
|
||||||
// ---------- 文件类型 widget 集合 ----------
|
// ---------- 文件类型 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,
|
minFiles: spec.min_files,
|
||||||
maxFiles: spec.max_files,
|
maxFiles: spec.max_files,
|
||||||
enableSwitch: ui.enable_switch,
|
enableSwitch: ui.enable_switch,
|
||||||
|
// seedance2Content:spec 中的媒体文件数量 + 上传供应商
|
||||||
|
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);
|
const isFile = FILE_WIDGETS.has(widget);
|
||||||
@@ -148,6 +155,7 @@ export function useModelUI(
|
|||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
name,
|
name,
|
||||||
|
widgetName: widget,
|
||||||
component,
|
component,
|
||||||
label: (ui.label as string) || name,
|
label: (ui.label as string) || name,
|
||||||
must: (spec.required as boolean) || false,
|
must: (spec.required as boolean) || false,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { StoragePathSetting } from './sections/StoragePathSetting';
|
|||||||
import { SystemInfoSetting } from './sections/SystemInfoSetting';
|
import { SystemInfoSetting } from './sections/SystemInfoSetting';
|
||||||
import { UpdateSetting } from './sections/UpdateSetting';
|
import { UpdateSetting } from './sections/UpdateSetting';
|
||||||
import { PasswordSetting } from './sections/PasswordSetting';
|
import { PasswordSetting } from './sections/PasswordSetting';
|
||||||
|
import { DataManageSetting } from './sections/DataManageSetting';
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -65,6 +66,7 @@ export function SettingsPage({ open, onClose }: SettingsPageProps) {
|
|||||||
<SystemInfoSetting />
|
<SystemInfoSetting />
|
||||||
<UpdateSetting />
|
<UpdateSetting />
|
||||||
<StoragePathSetting edition={edition} />
|
<StoragePathSetting edition={edition} />
|
||||||
|
<DataManageSetting />
|
||||||
<PasswordSetting />
|
<PasswordSetting />
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
112
src/pages/settings/sections/DataManageSetting.tsx
Normal file
112
src/pages/settings/sections/DataManageSetting.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/pages/settings/sections/data-sync.ts
Normal file
90
src/pages/settings/sections/data-sync.ts
Normal 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
11
src/vite-env.d.ts
vendored
@@ -13,6 +13,17 @@ declare global {
|
|||||||
encrypt(plaintext: string): Promise<string | null>;
|
encrypt(plaintext: string): Promise<string | null>;
|
||||||
decrypt(encryptedBase64: 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>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user