Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions docs/plans/2026-06-16_b1-image-gen-settings-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Phase B1:ImageGenerationSettings 拆分

> **创建日期**:2026-06-16
> **作者**:planner
> **关联**:[v3 路线图 Phase B](../plans/2026-06-16_project-optimization-v3.md)
> **目标**:把 `ImageGenerationSettings.tsx`(2205 行)拆为多个 panel 子组件,主文件 < 800 行

---

## 一、背景

`ImageGenerationSettings.tsx` 是 v3 路线图 Phase B 中最小、风险最低的拆分候选(仅被 `SettingsPanel.tsx` 引用 1 次)。文件内部已存在 6 个 `renderXxxPage()` 子函数(行 831-2200),结构天然适合按子函数拆分。

## 二、现状

**文件**:`src/components/features/Settings/Image/ImageGenerationSettings.tsx`(2205 行)

| 区段 | 行号 | 内容 |
|---|---|---|
| imports + 公共工具 | 1-185 | 8 个纯函数(`初始化模型列表`、`创建文生图配置模板` 等) |
| 主组件声明 | 187-380 | 17 useState + 4 useRef + useEffect + 10+ useMemo |
| `renderBasicPage` | 831-856 | 后端选择 + 基础连接测试 |
| `renderProviderPage` | 857-1277 | 模型列表、API key、NovelAI 自定义参数 |
| `renderTransformerPage` | 1278-1451 | 词组转化器(NAI/Custom) |
| `renderPresetsPage` | 1452-1606 | 画师串预设(npc/player/all) |
| `renderAutomationPage` | 1607-1877 | 重试、自动化、workflow 管理 |
| `renderPlayerPage` | 1878-2200 | 主角独立生图配置 |
| 主 return | 2200-2205 | `export default` |

**消费者**(仅 1 个):
- `src/components/features/Settings/SettingsPanel.tsx`(props: `settings`, `onSave`)

## 三、目标结构

```
src/components/features/Settings/Image/
├── ImageGenerationSettings.tsx # <800 行:state + 路由 + Provider
├── types.ts # ~80 行:Props、SettingsContext 类型
├── helpers.ts # ~150 行:原 1-185 行的纯函数
├── useImageGenState.ts # ~250 行:原 state 管理(含 useState/useMemo 提取)
├── useImageGenHandlers.ts # ~200 行:原 onChange/onSave handlers
└── panels/
├── BasicPage.tsx # 原 renderBasicPage
├── ProviderPage.tsx # 原 renderProviderPage
├── TransformerPage.tsx # 原 renderTransformerPage
├── PresetsPage.tsx # 原 renderPresetsPage
├── AutomationPage.tsx # 原 renderAutomationPage
└── PlayerPage.tsx # 原 renderPlayerPage
```

## 四、技术方案

### 4.1 状态管理:React Context(避免 prop drilling)

6 个 panel 共用主组件的 17 个 useState + 10+ useMemo + handlers。直接 props drilling 会出现长 prop 列表。

**方案**:
- 抽 `ImageGenContext` 包含 `{ form, setForm, modelOptions, ...all state }`
- 顶层 `<ImageGenProvider value={...}>` 包裹
- panel 用 `useImageGen()` hook 访问

### 4.2 拆分顺序(按依赖深度从浅到深)

1. **helpers.ts**:纯函数,无依赖(最先拆)
2. **types.ts**:类型定义,无运行时
3. **useImageGenState.ts**:state 初始化逻辑(含 useMemo 派生)
4. **useImageGenHandlers.ts**:onChange、onSave、test connection 等
5. **panels/BasicPage.tsx**:依赖最少,最先拆
6. **panels/PlayerPage.tsx**:内部 useMemo 最多(行 1831-1877),最复杂
7. **主文件改写**:route + Provider 包裹

### 4.3 公共类型提取

- `Props`:原 Props 接口
- `SettingsContextValue`:Context 类型
- `PanelProps`:每个 panel 的 props(统一从 Context 取值)
- 关键子类型:`WorkflowItem`、`TestResultModal`、`ModelOptions` 等

## 五、实施步骤

按 v3 路线图 Phase B 要求"3 次以上小 PR":

### PR1:抽出公共层(helpers + types)— 不改 UI
- [ ] 创建 `types.ts`、`helpers.ts`
- [ ] 移动纯函数(行 31-185)
- [ ] 移动 type/interface 声明
- [ ] 主文件 import 改为从 helpers/types 取
- [ ] smoke test:组件 mount、save 不报错

### PR2:抽出 state 与 handlers
- [ ] 创建 `useImageGenState.ts`、`useImageGenHandlers.ts`
- [ ] 移动 state 初始化、useMemo 计算、onChange 处理
- [ ] 主文件用 hook 替代内联 state
- [ ] smoke test:state 变化正确传播

### PR3:拆分 panel(先 Basic + Presets)
- [ ] 创建 `panels/BasicPage.tsx`、`panels/PresetsPage.tsx`
- [ ] 引入 `ImageGenContext`
- [ ] 主文件改用 `<BasicPage />` 替代 `renderBasicPage()`
- [ ] smoke test:UI 渲染一致

### PR4:拆分剩余 panel
- [ ] `panels/ProviderPage.tsx`
- [ ] `panels/TransformerPage.tsx`
- [ ] `panels/AutomationPage.tsx`
- [ ] `panels/PlayerPage.tsx`(最复杂,最后做)
- [ ] 主文件 < 800 行
- [ ] smoke test + 视觉回归(用 Playwright snapshot 对比)

## 六、风险与缓解

| 风险 | 等级 | 缓解 |
|---|---|---|
| 状态丢失 / Context 值未传播 | HIGH | 每个 PR 跑 smoke test + Playwright 快照 |
| 主组件 useMemo 依赖循环 | MEDIUM | 用 useCallback 稳定 handler 引用 |
| 移动 useEffect 副作用丢失 | MEDIUM | PR1 跑全量测试套件 |
| `renderPlayerPage` 内部 useMemo(行 1831-1877)位置异常 | MEDIUM | 单独 commit 提取到独立 hook |
| 行 31-185 的纯函数被外部 import | LOW | 提前 `grep -r "from.*ImageGenerationSettings" src/` 验证 |

## 七、验收标准

- [ ] 主文件 < 800 行
- [ ] 6 个 panel 各自 < 500 行
- [ ] `npm run lint` 0 error
- [ ] `npm run typecheck` 0 error
- [ ] `npm run test` 现有用例全过
- [ ] Playwright 截图对比:UI 无视觉回归
- [ ] `SettingsPanel.tsx` 消费 API 不变(仍是默认导出 React.FC<Props>)

## 八、不在本 Phase 范围

- ❌ 重写为 useReducer(保留 useState,保留简单性)
- ❌ 拆 `Settings/Image/` 下其他 7 个 *NSFWSettings.tsx(等 B2)
- ❌ 重构 `SettingsPanel.tsx` 主入口
- ❌ 改 props 接口(保持向后兼容)

## 九、关联文件

- `src/components/features/Settings/SettingsPanel.tsx`(消费者)
- `src/components/features/Settings/Image/*.tsx`(同目录 7 个 NSFW settings,可参考命名约定)
- `docs/technical/13b-performance-modularization.md`(已有 chunk 拆分经验可参考)
192 changes: 23 additions & 169 deletions src/components/features/Settings/Image/ImageGenerationSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
import type {
接口设置结构,
功能模型占位配置结构,
单接口配置结构,
画师串预设结构,
画师串预设适用范围类型,
词组转化器提示词预设结构,
词组转化器提示词预设类型,
PNG画风预设结构,
文生图接口配置结构,
文生图后端类型,
Expand All @@ -17,172 +15,28 @@ import ToggleSwitch from '../../../ui/ToggleSwitch';
import InlineSelect from '../../../ui/InlineSelect';
import { 规范化接口设置 } from '../../../../utils/apiConfig';
import { 自动场景横屏尺寸选项, 自动场景竖屏尺寸选项 } from '../../../../utils/imageSizeOptions';

interface Props {
settings: 接口设置结构;
onSave: (settings: 接口设置结构) => void;
}

type 生图模型字段 = '文生图模型使用模型' | '场景生图模型使用模型' | '主角生图模型使用模型' | '词组转化器使用模型' | 'PNG提炼使用模型';
type 设置分页 = 'basic' | 'provider' | 'transformer' | 'presets' | 'automation' | 'retry' | 'player';
type 画师串适用页签 = 'npc' | 'scene' | 'player';
type 词组预设页签 = 'nai' | 'npc' | 'scene' | 'player';

const 初始化模型列表 = (): Record<生图模型字段, string[]> => ({
文生图模型使用模型: [],
场景生图模型使用模型: [],
主角生图模型使用模型: [],
词组转化器使用模型: [],
PNG提炼使用模型: []
});

const 初始化加载状态 = (): Record<生图模型字段, boolean> => ({
文生图模型使用模型: false,
场景生图模型使用模型: false,
主角生图模型使用模型: false,
词组转化器使用模型: false,
PNG提炼使用模型: false
});

const 基础页面选项: Array<{ value: 设置分页; label: string }> = [
{ value: 'basic', label: '基础' },
{ value: 'provider', label: '接口设置' },
{ value: 'transformer', label: '转化器' },
{ value: 'presets', label: '预设管理' },
{ value: 'automation', label: '自动任务' },
{ value: 'retry', label: '重试设置' },
{ value: 'player', label: '主角' }
];

const 文生图后端选项: Array<{ value: 功能模型占位配置结构['文生图后端类型']; label: string }> = [
{ value: 'openai', label: 'OpenAI 兼容' },
{ value: 'grok', label: 'Grok (xAI)' },
{ value: 'novelai', label: 'NovelAI 官方' },
{ value: 'sd_webui', label: 'Stable Diffusion WebUI' },
{ value: 'comfyui', label: 'ComfyUI' }
];

const 接口路径模式选项: Array<{ value: 功能模型占位配置结构['文生图接口路径模式']; label: string }> = [
{ value: 'preset', label: '预设路径' },
{ value: 'custom', label: '自定义路径' }
];

const 预设路径选项映射: Record<功能模型占位配置结构['文生图后端类型'], Array<{
value: 功能模型占位配置结构['文生图预设接口路径'];
label: string;
}>> = {
openai: [
{ value: 'openai_images', label: '/v1/images/generations' },
{ value: 'openai_chat', label: '/v1/chat/completions' }
],
grok: [
{ value: 'openai_chat', label: '/v1/chat/completions' }
],
novelai: [
{ value: 'novelai_generate', label: '/ai/generate-image' }
],
sd_webui: [
{ value: 'sd_txt2img', label: '/sdapi/v1/txt2img' }
],
comfyui: [
{ value: 'comfyui_prompt', label: '/prompt' }
]
};

const NovelAI模型建议 = ['nai-diffusion-4-5-full', 'nai-diffusion-4-5-curated', 'nai-diffusion-4-full'];
const NovelAI采样器选项: Array<{ value: 功能模型占位配置结构['NovelAI采样器']; label: string }> = [
{ value: 'k_euler_ancestral', label: 'Euler Ancestral' },
{ value: 'k_euler', label: 'Euler' },
{ value: 'k_dpmpp_2m', label: 'DPM++ 2M' },
{ value: 'k_dpmpp_2s_ancestral', label: 'DPM++ 2S Ancestral' },
{ value: 'k_dpmpp_sde', label: 'DPM++ SDE' },
{ value: 'k_dpmpp_2m_sde', label: 'DPM++ 2M SDE' }
];
const NovelAI噪点表选项: Array<{ value: 功能模型占位配置结构['NovelAI噪点表']; label: string }> = [
{ value: 'karras', label: 'Karras' },
{ value: 'native', label: 'Native' },
{ value: 'exponential', label: 'Exponential' },
{ value: 'polyexponential', label: 'Polyexponential' }
];

const 获取后端设置标签 = (backend: 功能模型占位配置结构['文生图后端类型']): string => {
switch (backend) {
case 'sd_webui':
return 'WebUI 设置';
case 'comfyui':
return 'ComfyUI 设置';
case 'novelai':
return 'NovelAI 设置';
case 'openai':
default:
return '后端设置';
}
};

const 图片后端需要模型选择 = (backend: 功能模型占位配置结构['文生图后端类型']): boolean => {
return backend === 'openai' || backend === 'grok' || backend === 'novelai';
};

const 图片后端需要鉴权 = (backend: 功能模型占位配置结构['文生图后端类型']): boolean => {
return backend === 'openai' || backend === 'grok' || backend === 'novelai';
};

const ComfyUI工作流占位提示 = '__PROMPT__ / {{prompt}},__NEGATIVE_PROMPT__ / {{negative_prompt}},__WIDTH__ / {{width}},__HEIGHT__ / {{height}},__STEPS__ / {{steps}},__CFG__ / {{cfg}},__CFG_RESCALE__ / {{cfg_rescale}},__SAMPLER__ / {{sampler}},__SCHEDULER__ / {{scheduler}},__SEED__ / {{seed}},__SMEA__ / {{smea}},__SMEA_DYN__ / {{smea_dyn}}';

const 页面容器样式 = 'rounded-2xl border border-fuchsia-500/20 bg-black/25 p-5 space-y-5';
const 卡片样式 = 'rounded-xl border border-white/10 bg-black/20 p-4 space-y-4';
const 标签样式 = 'text-sm font-bold text-fuchsia-200';

const 生成预设ID = (prefix: string) => `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;

const 创建文生图配置模板 = (backend: 文生图后端类型): 文生图接口配置结构 => {
const now = Date.now();
return {
id: 生成预设ID('img_api'),
名称: `文生图配置 ${new Date(now).toLocaleTimeString()}`,
后端类型: backend,
模型: '',
API地址: backend === 'novelai' ? 'https://image.novelai.net' : backend === 'grok' ? 'https://api.x.ai/v1' : '',
API密钥: '',
接口路径模式: 'preset',
预设接口路径: 预设路径选项映射[backend][0]?.value || 'openai_images',
自定义接口路径: '',
响应格式: 'url',
OpenAI自定义格式: false,
ComfyUI工作流JSON: '',
NovelAI启用自定义参数: false,
NovelAI采样器: 'k_euler_ancestral',
NovelAI噪点表: 'karras',
NovelAI步数: 28,
NovelAI负面提示词: '',
createdAt: now,
updatedAt: now
};
};
const 创建空画师串预设 = (scope: 画师串适用页签): 画师串预设结构 => {
const now = Date.now();
return {
id: 生成预设ID('artist_preset'),
名称: scope === 'scene' ? '新建场景画师串' : scope === 'player' ? '新建主角画师串' : '新建NPC画师串',
适用范围: scope as 画师串预设适用范围类型 || 'npc',
画师串: '',
正面提示词: '',
负面提示词: '',
createdAt: now,
updatedAt: now
};
};
const 创建空词组预设 = (scope: 词组预设页签): 词组转化器提示词预设结构 => {
const now = Date.now();
return {
id: 生成预设ID('transformer_preset'),
名称: scope === 'nai' ? '新建NAI提示词' : scope === 'scene' ? '新建场景提示词' : scope === 'player' ? '新建主角提示词' : '新建NPC提示词',
类型: scope as 词组转化器提示词预设类型,
提示词: '',
createdAt: now,
updatedAt: now
};
};
import type { Props, 生图模型字段, 设置分页, 画师串适用页签, 词组预设页签 } from './types';
import {
初始化模型列表,
初始化加载状态,
基础页面选项,
文生图后端选项,
接口路径模式选项,
预设路径选项映射,
NovelAI模型建议,
NovelAI采样器选项,
NovelAI噪点表选项,
获取后端设置标签,
图片后端需要模型选择,
图片后端需要鉴权,
ComfyUI工作流占位提示,
页面容器样式,
卡片样式,
标签样式,
创建文生图配置模板,
创建空画师串预设,
创建空词组预设
} from './helpers';

const ImageGenerationSettings: React.FC<Props> = ({ settings, onSave }) => {
const [form, setForm] = useState<接口设置结构>(() => 规范化接口设置(settings));
Expand Down
Loading
Loading