- 任务右键菜单:实现打开输出目录(inputs/outputs/.cache 结构)、
复制资源链接、重新编辑、再次生成、拉取结果、重新下载、收藏、删除记录
- 主进程下载:通过 Electron net 模块绕过 CORS,支持 CDN 媒体文件下载
- sharp 缩略图:替换 nativeImage,支持 4K/8K+ 大尺寸素材的流式处理
- IPC 精简:40+ 冗余通道 → 15 个分组通道,移除未使用的 preload 桥接方法
- 任务存储:新增收藏字段、schema 迁移、task-store 持久化
- 清理废弃模块:canvas-ipc / updater / request / pricing
refactor: IPC/Preload/Services 模块化拆分 — 单一职责 + 向后兼容
主要变更:
- shared/constants/ipc-channels.ts → ipc/ 模块 (base/storage/canvas/updater)
- electron/preload.ts → preload/ 模块 (base/safe-storage/file-storage/canvas)
- electron/main/ipc/canvas-ipc.ts → canvas/ 模块 (types/walk/sidecar/mime/thumbnail)
- electron/main/ipc/storage-ipc.ts → 提取 utils/ (path/download/thumbnail)
- electron/main/updater.ts → updater/ 模块 (provider/platform-updaters)
- src/services/request.ts → request/ 模块 (token/interceptors/methods)
- src/shared/utils/pricing.ts → pricing/ 模块 (types/helpers/format/calculate/fields)
- src/modules/home/center/views/ModelInputForm.tsx → 提取 utils + DependentFormItem
架构优化:
- 所有拆分均保持向后兼容(旧导入路径仍可用)
- TypeScript 编译通过 ✓
- 每个模块单一职责,平均 ~130 行
231 lines
7.2 KiB
TypeScript
231 lines
7.2 KiB
TypeScript
// ============================================================
|
||
// NetRequest — Electron net.request 的 axios 风格封装
|
||
//
|
||
// 用法:
|
||
// import { request, get, post } from './net-request';
|
||
// const { data } = await get<UpdateCheckResult>('/api/v1/update/check', {
|
||
// baseURL: 'https://www.heixiu.com',
|
||
// params: { platform: 'win32', version: '1.0.0' },
|
||
// });
|
||
//
|
||
// 设计:
|
||
// - 底层使用 Electron net.request(不引入额外依赖)
|
||
// - Promise 封装,支持 async/await
|
||
// - JSON 自动解析、查询参数自动拼接
|
||
// - 流式下载场景仍直接用 net.request(本模块不封装 stream)
|
||
// ============================================================
|
||
|
||
import { net } from 'electron';
|
||
import { logger } from './logger';
|
||
|
||
// ---------- 类型 ----------
|
||
|
||
export interface RequestConfig {
|
||
/** 请求方法(默认 GET) */
|
||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||
/** 基础 URL(可选,方便切换环境) */
|
||
baseURL?: string;
|
||
/** 查询参数(自动拼接 ?key=value&...) */
|
||
params?: Record<string, string | number | undefined>;
|
||
/** 请求头 */
|
||
headers?: Record<string, string>;
|
||
/** 请求体(对象自动 JSON.stringify,字符串原样发送) */
|
||
data?: unknown;
|
||
/** 响应类型(默认 json,设置 text 跳过解析) */
|
||
responseType?: 'json' | 'text';
|
||
/** 超时时间(毫秒,默认 30s) */
|
||
timeout?: number;
|
||
}
|
||
|
||
export interface RequestResponse<T = unknown> {
|
||
/** HTTP 状态码 */
|
||
status: number;
|
||
/** 状态文本(如 "OK") */
|
||
statusText: string;
|
||
/** 响应头 */
|
||
headers: Record<string, string | string[]>;
|
||
/** 响应体 */
|
||
data: T;
|
||
}
|
||
|
||
/** 网络请求错误 */
|
||
export class RequestError extends Error {
|
||
status?: number;
|
||
constructor(message: string, status?: number) {
|
||
super(message);
|
||
this.name = 'NetRequestError';
|
||
this.status = status;
|
||
}
|
||
}
|
||
|
||
// ---------- 工具 ----------
|
||
|
||
const DEFAULT_TIMEOUT = 30_000;
|
||
|
||
/** 拼接 URL(baseURL + path + query) */
|
||
function buildUrl(url: string, config: RequestConfig): string {
|
||
// 如果没有 baseURL 且 url 不是绝对地址,直接报错(便于定位问题)
|
||
if (!config.baseURL && !/^https?:\/\//i.test(url)) {
|
||
throw new RequestError(
|
||
`URL 缺少 baseURL,无法构造绝对地址。url="${url}", config=${JSON.stringify({ ...config, params: config.params ? '[object]' : undefined })}`,
|
||
);
|
||
}
|
||
|
||
const base = (config.baseURL || '').trim().replace(/\/+$/, '');
|
||
const path = url.startsWith('/') ? url : `/${url}`;
|
||
let fullUrl = base ? `${base}${path}` : url;
|
||
|
||
if (config.params) {
|
||
const search = new URLSearchParams();
|
||
for (const [key, value] of Object.entries(config.params)) {
|
||
if (value !== undefined && value !== null) {
|
||
search.append(key, String(value));
|
||
}
|
||
}
|
||
const qs = search.toString();
|
||
if (qs) {
|
||
fullUrl += (fullUrl.includes('?') ? '&' : '?') + qs;
|
||
}
|
||
}
|
||
|
||
// 调试:在 net.request 调用之前验证 URL 合法性
|
||
try {
|
||
void new URL(fullUrl);
|
||
} catch {
|
||
throw new RequestError(
|
||
`构造的 URL 无法通过 Node.js 校验: "${fullUrl}"`,
|
||
);
|
||
}
|
||
|
||
console.log('[net-request] buildUrl:', fullUrl);
|
||
return fullUrl;
|
||
}
|
||
|
||
/** 序列化请求体 */
|
||
function serializeBody(data: unknown): { body: string; contentType: string } {
|
||
if (typeof data === 'string') {
|
||
return { body: data, contentType: 'text/plain' };
|
||
}
|
||
return { body: JSON.stringify(data), contentType: 'application/json' };
|
||
}
|
||
|
||
// ---------- 核心 ----------
|
||
|
||
/**
|
||
* 发起请求(axios 风格 API)
|
||
*
|
||
* 流式下载场景请直接使用 Electron net.request(本模块不封装)
|
||
*/
|
||
export async function request<T = unknown>(
|
||
url: string,
|
||
config: RequestConfig = {},
|
||
): Promise<RequestResponse<T>> {
|
||
const fullUrl = buildUrl(url, config);
|
||
const method = (config.method || 'GET').toUpperCase();
|
||
const timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
// 调试:验证 URL 是否合法
|
||
try {
|
||
void new URL(fullUrl);
|
||
} catch (urlErr) {
|
||
logger.warn('request', `非法 URL: ${fullUrl}`, urlErr instanceof Error ? urlErr : undefined);
|
||
reject(new RequestError(`非法 URL: ${(urlErr as Error).message}`));
|
||
return;
|
||
}
|
||
|
||
const req = net.request({
|
||
method,
|
||
url: fullUrl,
|
||
});
|
||
|
||
// 超时处理
|
||
let timedOut = false;
|
||
const timer = setTimeout(() => {
|
||
timedOut = true;
|
||
req.abort();
|
||
reject(new RequestError(`请求超时 (${timeout}ms)`));
|
||
}, timeout);
|
||
|
||
// 请求头
|
||
if (config.headers) {
|
||
for (const [key, value] of Object.entries(config.headers)) {
|
||
req.setHeader(key, value);
|
||
}
|
||
}
|
||
|
||
// 请求体
|
||
if (config.data !== undefined) {
|
||
const { body, contentType } = serializeBody(config.data);
|
||
req.setHeader('Content-Type', contentType);
|
||
req.write(body);
|
||
}
|
||
|
||
req.on('response', (response) => {
|
||
clearTimeout(timer);
|
||
|
||
const status = response.statusCode;
|
||
const statusText = response.statusMessage ?? '';
|
||
|
||
// 读取响应头
|
||
const headers: Record<string, string | string[]> = {};
|
||
// Electron IncomingMessage.headers 是 Record<string, string[]>
|
||
const rawHeaders = response.headers;
|
||
if (rawHeaders) {
|
||
for (const [key, value] of Object.entries(rawHeaders)) {
|
||
headers[key] = value.length === 1 ? value[0] : value;
|
||
}
|
||
}
|
||
|
||
// 读取响应体
|
||
let body = '';
|
||
response.on('data', (chunk: Buffer) => (body += chunk.toString()));
|
||
response.on('end', () => {
|
||
try {
|
||
const asJson = config.responseType !== 'text';
|
||
const data = asJson ? JSON.parse(body) : body;
|
||
resolve({ status, statusText, headers, data: data as T });
|
||
} catch {
|
||
// JSON 解析失败 → 返回原始文本
|
||
resolve({ status, statusText, headers, data: body as T });
|
||
}
|
||
});
|
||
|
||
response.on('error', (err) => {
|
||
clearTimeout(timer);
|
||
reject(new RequestError(`响应读取失败: ${err.message}`));
|
||
});
|
||
});
|
||
|
||
req.on('error', (err) => {
|
||
clearTimeout(timer);
|
||
if (timedOut) return;
|
||
reject(new RequestError(`网络请求失败: ${err.message}`));
|
||
});
|
||
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
// ---------- 默认导出:类 axios 命名空间 ----------
|
||
|
||
export const netRequest = {
|
||
request,
|
||
|
||
async get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||
return await request<T>(url, { ...config, method: 'GET' });
|
||
},
|
||
|
||
async post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||
return request<T>(url, { ...config, method: 'POST', data });
|
||
},
|
||
|
||
async put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||
return request<T>(url, { ...config, method: 'PUT', data });
|
||
},
|
||
|
||
async del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||
return request<T>(url, { ...config, method: 'DELETE' });
|
||
},
|
||
};
|