Files
ele-HeiXiu/electron/main/net-request.ts
YoungestSongMo 136a17c0bf feat: 任务列表右键菜单回调 + 主进程下载 + sharp 缩略图 + IPC 精简重构
- 任务右键菜单:实现打开输出目录(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 行
2026-07-03 11:13:36 +08:00

231 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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;
/** 拼接 URLbaseURL + 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' });
},
};