From 679ea197eb6cc3acc1047453fd9b28707f857bb4 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Mon, 31 Aug 2026 16:14:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20AI=20=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=A4=9A=E7=BE=A4=E9=A3=8E=E6=A0=BC=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GROUPBRIEF_AI_IMAGE_STYLE_BATCH_TASK.md | 83 + app/api/groups.py | 83 +- app/db/repository.py | 24 +- frontend/e2e/ai-images.spec.ts | 148 +- frontend/src/api.test.ts | 31 +- frontend/src/api.ts | 23 + frontend/src/components/ImageThemePicker.tsx | 234 +- .../pages/v2/ai-images/ImageStylePanel.tsx | 302 +-- frontend/src/pages/v2/ai-images/model.tsx | 7 +- .../src/pages/v2/ai-images/useAIImageRuns.ts | 3 +- frontend/src/styles.css | 2110 +++++++++-------- tests/test_group_image_theme_batch_api.py | 234 ++ 12 files changed, 2072 insertions(+), 1210 deletions(-) create mode 100644 GROUPBRIEF_AI_IMAGE_STYLE_BATCH_TASK.md create mode 100644 tests/test_group_image_theme_batch_api.py diff --git a/GROUPBRIEF_AI_IMAGE_STYLE_BATCH_TASK.md b/GROUPBRIEF_AI_IMAGE_STYLE_BATCH_TASK.md new file mode 100644 index 0000000..5ff1d45 --- /dev/null +++ b/GROUPBRIEF_AI_IMAGE_STYLE_BATCH_TASK.md @@ -0,0 +1,83 @@ +# AI 图片风格中心、多群同步与当天筛选任务 + +## 背景 + +现有风格中心在桌面端占用过大并存在明显空白,触发器在无色板模式下文字粘连;AI 图片运行记录首次进入未默认筛选上海当天;群聊生图风格只能逐群保存。 + +## 目标 + +- 将共享 `ImageThemePicker` 改为桌面紧凑工作台(约 `960×720` 内)和 `390×844` 可用的移动全屏面板。 +- AI 图片运行记录首次请求默认携带上海当天日期,清空日期后允许查看全部历史。 +- 支持先确认一个风格,再搜索并勾选多个未删除群,通过单个批量接口同步配置。 +- 部分失败时保留失败群并允许一键重试,不回滚已经成功的群。 + +## 允许修改范围 + +- `frontend/src/components/ImageThemePicker.tsx` +- `frontend/src/pages/v2/ai-images/**` +- `frontend/src/api.ts` +- `frontend/src/styles.css` +- 与上述交互直接相关的前端单测和 `frontend/e2e/ai-images.spec.ts` +- `app/api/groups.py` +- `app/db/repository.py`(仅新增窄范围的主题保存事务能力) +- 与批量群主题接口直接相关的后端测试 +- 本执行说明文件 + +## 禁止修改范围 + +- 不重建或修改历史 Prompt、manifest、运行记录和历史图片。 +- 不触发生图,不发送微信或邮件,不启动真实 Provider。 +- 不改变群的 Prompt override、发送时间、发送开关或其他群配置。 +- 不改变“每日随机”按群标识和日期独立随机的现有规则。 +- 不连接生产数据库,不提交缓存、日志、构建产物或敏感配置。 + +## 已确定实现要求 + +### 风格中心 + +- 桌面端左侧四种模式导航,右侧显示设置与预览;移动端全屏、内容和操作区均可达。 +- AI 自由发挥和每日随机显示效果、适用场景与随机规则。 +- 预设风格提供搜索、分类、缩略图网格与紧凑详情。 +- 自定义描述限制 80 字、拒绝空值和多行,保留计数与示例并缩短输入区。 +- 底部取消/确认固定可见,保留草稿、Esc、焦点闭环和 reduced-motion。 +- 触发器始终有模式图标或色板,标题/副标题分行,右侧显示“更换”和箭头;不依赖 `nth-child`。 +- 只使用项目已声明的颜色变量,清理同区域无效旧 CSS。 + +### 多群同步 + +- 风格必须先在弹窗中明确确认;至少选择一个群后才能提交。 +- 群列表支持搜索、复选框、全选当前列表、清空和已选数量。 +- 每项显示群名、ID、当前风格及启用/停用状态;停用群仍允许预配置。 +- 只发送一次批量请求;部分失败显示成功/失败清单,仅保留失败群为选中并切换为“重试失败群”。 +- 多群场景展示“共享风格注入预览”,明确不会覆盖各群原有 Prompt 模板。 + +### API 与事务 + +- 新增 `PUT /api/groups/batch/image-theme`。 +- 请求:`group_ids`、`image_theme`、`image_theme_custom`。 +- 响应:`status`(`success|partial|failed`)、请求数量、成功群列表和带错误代码/可读原因的失败群列表。 +- 主题键和自定义文本先做全局校验;非法主题、空自定义、超长或多行自定义返回 422 且零写入。 +- 合法目标按请求顺序逐群使用独立事务;不存在、已删除或保存失败只记录该群失败,其他群继续。 +- 只更新 `image_theme`、`image_theme_custom`、`updated_at`,不走会连带修改其他配置的通用更新路径。 + +## 验收标准 + +- 首次 AI 图片 runs 请求带上海当天日期;清空后请求不带日期。 +- 无色板模式的触发器标题、副标题分行。 +- 多选、全选、清空、单次批量请求、部分失败和只重试失败项有自动化覆盖。 +- 批量接口覆盖全成功、部分成功、全局 422 零写入,并证明 Prompt override、发送配置和历史数据未变。 +- 原有草稿、取消不应用、确认一次提交行为继续通过。 +- 桌面与 `390×844` E2E 不溢出、列表可滚动、操作按钮可达。 + +## 测试命令 + +- 隔离 Python 环境运行相关后端测试及完整 `pytest` 两种顺序。 +- `npm ci` +- `npm run build` +- `npm test` +- `npm run test:e2e` +- `python -m compileall -q app scripts tests` + +## 返回格式 + +报告实际修改、测试结果、任务提交/远端 SHA、新 PR 地址和 CI 状态;不自动合并新 PR。 diff --git a/app/api/groups.py b/app/api/groups.py index 2cf61a9..4e3afe1 100644 --- a/app/api/groups.py +++ b/app/api/groups.py @@ -3,7 +3,8 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field, field_validator +from sqlalchemy.exc import SQLAlchemyError from sqlmodel import Session, select from app.ai.image_themes import ( @@ -98,6 +99,21 @@ class GroupImagePromptUpdate(BaseModel): expected_revision: str = "" +class BatchImageThemeUpdate(BaseModel): + group_ids: list[int] = Field(min_length=1) + image_theme: str + image_theme_custom: str = "" + + @field_validator("group_ids") + @classmethod + def validate_group_ids(cls, values: list[int]) -> list[int]: + if any(group_id <= 0 for group_id in values): + raise ValueError("群 ID 必须是正整数") + if len(set(values)) != len(values): + raise ValueError("群 ID 不能重复") + return values + + def _validate_group_theme(theme: object, custom: object = "") -> tuple[str, str]: """将主题配置统一交给后端目录校验,并转换成明确的 422。""" try: @@ -274,6 +290,71 @@ def create_group( return {"id": group.id, "restored": False} +@router.put("/batch/image-theme") +def batch_update_group_image_theme( + payload: BatchImageThemeUpdate, + session: Session = Depends(repo.get_session), +): + """按请求顺序独立保存群主题;单群失败不回滚已经成功的群。""" + + theme, custom = _validate_group_theme(payload.image_theme, payload.image_theme_custom) + successes: list[dict] = [] + failures: list[dict] = [] + + for group_id in payload.group_ids: + group = repo.get_group(session, group_id) + if group is None: + failures.append({ + "group_id": group_id, + "code": "GROUP_NOT_FOUND", + "reason": "群不存在", + }) + continue + if group.deleted_at is not None: + failures.append({ + "group_id": group_id, + "code": "GROUP_DELETED", + "reason": "群已移入回收站", + }) + continue + + group_name = group.display_name or group.wechat_group_name or f"群 {group_id}" + try: + updated = repo.update_group_image_theme( + session, + group_id, + image_theme=theme, + image_theme_custom=custom, + ) + except SQLAlchemyError: + session.rollback() + failures.append({ + "group_id": group_id, + "code": "DATABASE_SAVE_FAILED", + "reason": "数据库保存失败,请重试", + }) + continue + if not updated: + failures.append({ + "group_id": group_id, + "code": "GROUP_DELETED", + "reason": "群已不存在或移入回收站", + }) + continue + successes.append({ + "group_id": group_id, + "group_name": group_name, + }) + + status = "success" if not failures else "partial" if successes else "failed" + return { + "status": status, + "requested_count": len(payload.group_ids), + "success": successes, + "failed": failures, + } + + @router.put("/{group_id}") def update_group( group_id: int, diff --git a/app/db/repository.py b/app/db/repository.py index bebc2e0..26b18c6 100644 --- a/app/db/repository.py +++ b/app/db/repository.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any -from sqlalchemy import event +from sqlalchemy import event, update from sqlmodel import Session, SQLModel, create_engine, select from app.config.settings import Settings @@ -523,6 +523,28 @@ def save_group(session: Session, group: Group) -> Group: return group +def update_group_image_theme( + session: Session, + group_id: int, + *, + image_theme: str, + image_theme_custom: str, +) -> bool: + """独立事务只更新群生图主题字段,避免通用保存路径带入其他配置。""" + + result = session.execute( + update(Group) + .where(Group.id == group_id, Group.deleted_at.is_(None)) + .values( + image_theme=image_theme, + image_theme_custom=image_theme_custom, + updated_at=_now(), + ) + ) + session.commit() + return result.rowcount == 1 + + def delete_group(session: Session, group_id: int) -> Group | None: group = session.get(Group, group_id) if group is None: diff --git a/frontend/e2e/ai-images.spec.ts b/frontend/e2e/ai-images.spec.ts index c8e1e4e..3eeade1 100644 --- a/frontend/e2e/ai-images.spec.ts +++ b/frontend/e2e/ai-images.spec.ts @@ -46,6 +46,35 @@ async function json(route: Route, body: unknown) { async function installFakeApi(page: Page) { const calls: string[] = []; + const groups = [ + { id: 7, display_name: "测试群", enabled: true, image_theme: "ai_free", image_theme_custom: "" }, + { id: 9, display_name: "停用群", enabled: false, image_theme: "random_preset", image_theme_custom: "" }, + { id: 10, display_name: "失败群", enabled: true, image_theme: "ai_free", image_theme_custom: "" }, + ].map((group) => ({ + ...group, + wechat_group_id: `wx-${group.id}`, + wechat_group_name: group.display_name, + provider_preference: "default", + created_at: "", + updated_at: "", + schedule_rule: "daily", + send_time: "08:30", + summary_provider: "", + prompt_provider: "", + summary_model: "default", + prompt_model: "default", + image_enabled: true, + send_target: group.display_name, + effective_send_target: group.display_name, + send_target_mode: "manual" as const, + ranking_template: "default", + ranking_count_policy: "all_messages" as const, + sender_name_policy: "resolved" as const, + image_prompt_template: "default", + has_image_prompt_override: false, + wechat_send_enabled: false, + })); + let batchAttempt = 0; await page.route("**/api/**", async (route) => { const request = route.request(); const url = new URL(request.url()); @@ -53,30 +82,25 @@ async function installFakeApi(page: Page) { calls.push(`${request.method()} ${path}`); if (path === "/api/groups") { - return json(route, [{ - id: 7, - display_name: "测试群", - wechat_group_id: "wx-test", - wechat_group_name: "测试群", - enabled: true, - provider_preference: "default", - created_at: "", - updated_at: "", - schedule_rule: "daily", - send_time: "08:30", - summary_model: "default", - prompt_model: "default", - image_enabled: true, - send_target: "测试群", - effective_send_target: "测试群", - send_target_mode: "manual", - ranking_template: "default", - image_prompt_template: "default", - image_theme: "ai_free", - image_theme_custom: "", - has_image_prompt_override: false, - wechat_send_enabled: false, - }]); + return json(route, groups); + } + if (path === "/api/groups/batch/image-theme") { + batchAttempt += 1; + const body = request.postDataJSON() as { group_ids: number[]; image_theme: string; image_theme_custom: string }; + const failedIds = batchAttempt === 1 && body.group_ids.includes(10) ? [10] : []; + const successIds = body.group_ids.filter((groupId) => !failedIds.includes(groupId)); + for (const group of groups) { + if (successIds.includes(group.id)) { + group.image_theme = body.image_theme; + group.image_theme_custom = body.image_theme === "custom" ? body.image_theme_custom : ""; + } + } + return json(route, { + status: failedIds.length ? (successIds.length ? "partial" : "failed") : "success", + requested_count: body.group_ids.length, + success: successIds.map((groupId) => ({ group_id: groupId, group_name: groups.find((group) => group.id === groupId)?.display_name || `群 ${groupId}` })), + failed: failedIds.map((groupId) => ({ group_id: groupId, code: "DATABASE_SAVE_FAILED", reason: "数据库保存失败,请重试" })), + }); } if (path === "/api/v2/image-themes") { return json(route, { themes: [ @@ -94,6 +118,7 @@ async function installFakeApi(page: Page) { return json(route, { name: "default", content: "{{group_name}} {{image_theme}}" }); } if (path === "/api/v2/runs") { + calls.push(`RUN_QUERY ${url.search}`); return json(route, { runs: [ { group_name: "测试群", group_id: 7, run_date: runDate, status: "PROMPT_READY", updated_at: `${runDate}T08:00:00`, image_regen_status: "idle" }, { group_name: secondaryGroup, group_id: 8, run_date: runDate, status: "PROMPT_READY", updated_at: `${runDate}T07:30:00`, image_regen_status: "idle" }, @@ -123,7 +148,7 @@ test("AI 图片工作台通过 Fake API 加载目录、运行与详情", async ( await expect(page.getByText("测试 Prompt", { exact: true })).toBeVisible(); await expect(page.getByText("显示 2 / 2 条")).toBeVisible(); - await page.getByPlaceholder("搜索群名").fill("不存在"); + await page.getByPlaceholder("搜索群名", { exact: true }).fill("不存在"); await expect(page.getByText("显示 0 / 2 条")).toBeVisible(); expect(calls).toEqual(expect.arrayContaining([ "GET /api/groups", @@ -133,6 +158,25 @@ test("AI 图片工作台通过 Fake API 加载目录、运行与详情", async ( ])); }); +test("AI 图片默认筛选上海当天,清空日期后请求全部历史", async ({ page }) => { + const calls = await installFakeApi(page); + const today = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()); + await page.goto("/#/images"); + + await expect.poll(() => calls.filter((call) => call.startsWith("RUN_QUERY ")).length).toBeGreaterThan(0); + expect(calls.find((call) => call.startsWith("RUN_QUERY "))).toBe(`RUN_QUERY ?run_date=${today}`); + + const before = calls.filter((call) => call.startsWith("RUN_QUERY ")).length; + await page.getByLabel("运行日期").fill(""); + await expect.poll(() => calls.filter((call) => call.startsWith("RUN_QUERY ")).length).toBeGreaterThan(before); + expect(calls.filter((call) => call.startsWith("RUN_QUERY ")).at(-1)).toBe("RUN_QUERY "); +}); + test("选题评分默认显示前两项,可展开收起并在切换运行时重置", async ({ page }) => { await installFakeApi(page); await page.goto("/#/images"); @@ -179,6 +223,48 @@ test("风格中心保留草稿,取消不应用,确认后一次提交", async await dialog.getByRole("button", { name: "使用这个风格" }).click(); await expect(page.getByRole("button", { name: /指定风格/ }).first()).toBeVisible(); expect(calls.filter((call) => call === "POST /api/v2/image-themes/resolve")).toHaveLength(1); + + const triggerCopy = page.locator(".image-theme-picker-trigger .image-theme-trigger-copy").first(); + await expect(triggerCopy).toHaveCSS("flex-direction", "column"); + const titleBox = await triggerCopy.locator("b").boundingBox(); + const subtitleBox = await triggerCopy.locator("small").boundingBox(); + expect(titleBox).not.toBeNull(); + expect(subtitleBox).not.toBeNull(); + expect(subtitleBox!.y).toBeGreaterThan(titleBox!.y); +}); + +test("多群风格支持全选清空、部分失败并只重试失败群", async ({ page }) => { + const calls = await installFakeApi(page); + await page.goto("/#/images"); + + await page.getByRole("button", { name: /AI 自由发挥/ }).first().click(); + const dialog = page.getByRole("dialog", { name: "风格中心" }); + await dialog.getByRole("tab", { name: /预设风格/ }).click(); + await dialog.getByRole("button", { name: /分层纸艺插画/ }).first().click(); + await dialog.getByRole("button", { name: "使用这个风格" }).click(); + await expect(page.getByText("已确认:分层纸艺插画")).toBeVisible(); + + await page.getByRole("button", { name: "全选当前列表" }).click(); + await expect(page.getByText("已选 3 个")).toBeVisible(); + await page.getByRole("button", { name: "清空" }).click(); + await expect(page.getByText("已选 0 个")).toBeVisible(); + + await page.getByRole("checkbox", { name: /测试群/ }).check(); + await page.getByRole("checkbox", { name: /停用群/ }).check(); + await page.getByRole("checkbox", { name: /失败群/ }).check(); + await page.getByRole("button", { name: "应用到 3 个群" }).click(); + + await expect(page.getByText("部分群保存成功")).toBeVisible(); + await expect(page.getByText("失败群已保留为选中状态")).toBeVisible(); + await expect(page.getByRole("checkbox", { name: /测试群/ })).not.toBeChecked(); + await expect(page.getByRole("checkbox", { name: /停用群/ })).not.toBeChecked(); + await expect(page.getByRole("checkbox", { name: /失败群/ })).toBeChecked(); + expect(calls.filter((call) => call === "PUT /api/groups/batch/image-theme")).toHaveLength(1); + + await page.getByRole("button", { name: "重试失败群" }).click(); + await expect(page.getByText("全部保存成功")).toBeVisible(); + await expect(page.getByText("已选 0 个")).toBeVisible(); + expect(calls.filter((call) => call === "PUT /api/groups/batch/image-theme")).toHaveLength(2); }); test("风格中心在窄屏保持可滚动且操作按钮可达", async ({ page }) => { @@ -199,3 +285,15 @@ test("风格中心在窄屏保持可滚动且操作按钮可达", async ({ page expect(box!.width).toBeLessThanOrEqual(390); expect(box!.height).toBeLessThanOrEqual(844); }); + +test("风格中心桌面尺寸保持在 960×720 内", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await installFakeApi(page); + await page.goto("/#/images"); + + await page.getByRole("button", { name: /AI 自由发挥/ }).first().click(); + const box = await page.getByRole("dialog", { name: "风格中心" }).boundingBox(); + expect(box).not.toBeNull(); + expect(box!.width).toBeLessThanOrEqual(960); + expect(box!.height).toBeLessThanOrEqual(720); +}); diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index 840e0dc..360d70e 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { confirmRecovery, get, getDashboard, getRuns, getRuntimeLogs, pipelineGenerate, pipelineSend, readV2JsonFile, resetSendFailure, resolveManualSend, resolvePromptUnknown, resolveSendUnknown } from "./api"; +import { batchUpdateGroupImageTheme, confirmRecovery, get, getDashboard, getRuns, getRuntimeLogs, pipelineGenerate, pipelineSend, readV2JsonFile, resetSendFailure, resolveManualSend, resolvePromptUnknown, resolveSendUnknown } from "./api"; function response(body: unknown, options: { ok?: boolean; status?: number; raw?: string } = {}): Response { return { @@ -55,6 +55,35 @@ describe("frontend API contract", () => { }); }); + it("sends one batch request for multiple group image themes", async () => { + const fetchMock = vi.fn(async () => response({ + status: "success", + requested_count: 2, + success: [], + failed: [], + })); + vi.stubGlobal("fetch", fetchMock); + + await batchUpdateGroupImageTheme({ + group_ids: [7, 8], + image_theme: "custom", + image_theme_custom: "低饱和黏土摄影", + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + "/api/groups/batch/image-theme", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ + group_ids: [7, 8], + image_theme: "custom", + image_theme_custom: "低饱和黏土摄影", + }), + }), + ); + }); + it("confirms historical generation with CAS and no send field", async () => { const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => response({ status: "complete", send_invoked: false })); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 289885c..34ec3ab 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -403,6 +403,24 @@ export interface GroupImagePromptConfig { preview: string; } +export interface BatchImageThemeSuccess { + group_id: number; + group_name: string; +} + +export interface BatchImageThemeFailure { + group_id: number; + code: "GROUP_NOT_FOUND" | "GROUP_DELETED" | "DATABASE_SAVE_FAILED"; + reason: string; +} + +export interface BatchImageThemeResponse { + status: "success" | "partial" | "failed"; + requested_count: number; + success: BatchImageThemeSuccess[]; + failed: BatchImageThemeFailure[]; +} + export interface RunPromptConfig { group_name: string; run_date: string; @@ -513,6 +531,11 @@ export const syncWechatGroupNames = () => post("/groups/syn export const createGroup = (body: GroupPayload) => post<{ id: number; restored?: boolean }>("/groups", body); export const updateGroup = (groupId: number, body: Partial) => put<{ id: number }>(`/groups/${groupId}`, body); +export const batchUpdateGroupImageTheme = (body: { + group_ids: number[]; + image_theme: string; + image_theme_custom: string; +}) => put("/groups/batch/image-theme", body); export const verifyGroupSendTarget = (groupId: number) => post<{ ok: boolean; target: string; detail: string }>(`/groups/${groupId}/verify-send-target`); export const deleteGroup = (groupId: number) => del<{ ok: boolean; deleted_at: string | null }>(`/groups/${groupId}`); diff --git a/frontend/src/components/ImageThemePicker.tsx b/frontend/src/components/ImageThemePicker.tsx index 4281fd5..6172d50 100644 --- a/frontend/src/components/ImageThemePicker.tsx +++ b/frontend/src/components/ImageThemePicker.tsx @@ -1,5 +1,15 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Check, ImageSquare, MagnifyingGlass, X } from "@phosphor-icons/react"; +import { + CaretRight, + Check, + ImageSquare, + MagnifyingGlass, + Palette, + PencilSimple, + Shuffle, + Sparkle, + X, +} from "@phosphor-icons/react"; import type { ImageThemeOption } from "../api"; import { Button } from "./common"; @@ -11,6 +21,18 @@ const THEME_CATEGORIES = [ type ThemeTab = "ai_free" | "random_preset" | "preset" | "custom"; +const MODE_ITEMS: Array<{ + key: ThemeTab; + label: string; + short: string; + icon: typeof Sparkle; +}> = [ + { key: "ai_free", label: "AI 自由发挥", short: "按当天内容决定画面", icon: Sparkle }, + { key: "random_preset", label: "每日随机", short: "同群同日稳定复现", icon: Shuffle }, + { key: "preset", label: "预设风格", short: "从风格目录中选择", icon: Palette }, + { key: "custom", label: "自定义描述", short: "用一句话定义视觉语言", icon: PencilSimple }, +]; + interface ImageThemePickerProps { themes: ImageThemeOption[]; value: string; @@ -24,7 +46,11 @@ interface ImageThemePickerProps { function ThemeSwatches({ colors }: { colors: string[] }) { if (!colors.length) return null; - return ; + return ( + + ); } function tabForKey(key: string): ThemeTab { @@ -32,7 +58,24 @@ function tabForKey(key: string): ThemeTab { return "preset"; } -export function ImageThemePicker({ themes, value, customValue = "", onConfirm, label, loading = false, error = "", disabled = false }: ImageThemePickerProps) { +function ThemeVisual({ theme, tab }: { theme?: ImageThemeOption; tab: ThemeTab }) { + if (theme?.swatches.length) { + return ; + } + const Icon = MODE_ITEMS.find((item) => item.key === tab)?.icon || Palette; + return ; +} + +export function ImageThemePicker({ + themes, + value, + customValue = "", + onConfirm, + label, + loading = false, + error = "", + disabled = false, +}: ImageThemePickerProps) { const [open, setOpen] = useState(false); const [activeTab, setActiveTab] = useState(() => tabForKey(value)); const [draftKey, setDraftKey] = useState(value); @@ -40,8 +83,10 @@ export function ImageThemePicker({ themes, value, customValue = "", onConfirm, l const [query, setQuery] = useState(""); const [category, setCategory] = useState(""); const [previewBroken, setPreviewBroken] = useState(false); + const [confirming, setConfirming] = useState(false); const triggerRef = useRef(null); const modalRef = useRef(null); + const confirmingRef = useRef(false); const current = themes.find((theme) => theme.key === value); const selectedDraft = themes.find((theme) => theme.key === draftKey); const presets = useMemo(() => { @@ -52,23 +97,28 @@ export function ImageThemePicker({ themes, value, customValue = "", onConfirm, l }, [category, query, themes]); useEffect(() => setPreviewBroken(false), [draftKey]); + useEffect(() => { + confirmingRef.current = confirming; + }, [confirming]); useEffect(() => { if (!open) return; const previous = document.activeElement instanceof HTMLElement ? document.activeElement : triggerRef.current; const frame = window.requestAnimationFrame(() => modalRef.current?.focus()); const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { + if (event.key === "Escape" && !confirmingRef.current) { event.preventDefault(); setOpen(false); return; } if (event.key !== "Tab" || !modalRef.current) return; - const focusable = Array.from(modalRef.current.querySelectorAll("button:not(:disabled), input:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex='-1'])")); + const focusable = Array.from(modalRef.current.querySelectorAll( + "button:not(:disabled), input:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex='-1'])", + )); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; - if (event.shiftKey && document.activeElement === first) { + if (event.shiftKey && (document.activeElement === first || document.activeElement === modalRef.current)) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { @@ -95,71 +145,125 @@ export function ImageThemePicker({ themes, value, customValue = "", onConfirm, l const selectTab = (tab: ThemeTab) => { setActiveTab(tab); - if (tab !== "preset") setDraftKey(tab); - else if (!themes.some((theme) => theme.kind === "preset" && theme.key === draftKey)) setDraftKey(themes.find((theme) => theme.kind === "preset")?.key || ""); + if (tab !== "preset") { + setDraftKey(tab); + return; + } + if (!themes.some((theme) => theme.kind === "preset" && theme.key === draftKey)) { + setDraftKey(themes.find((theme) => theme.kind === "preset")?.key || ""); + } + }; + + const confirmDraft = async () => { + if (!draftKey || (draftKey === "custom" && !draftCustom.trim())) return; + setConfirming(true); + try { + await onConfirm(draftKey, draftKey === "custom" ? draftCustom.trim() : ""); + setOpen(false); + } finally { + setConfirming(false); + } }; + const currentTab = tabForKey(value); const status = current?.key === "ai_free" ? "不注入预设风格" : current?.key === "random_preset" ? `每日随机 · ${current.variation_count} 种组合` : current?.key === "custom" ? customValue.trim() || "自定义描述" : current ? `${current.variation_count} 种微变化` : "兼容主题"; + const unchanged = draftKey === value + && (draftKey !== "custom" || draftCustom.trim() === customValue.trim()); - return
- {label} - - - - {open && setOpen(false)} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.18 }}> - event.stopPropagation()} initial={{ opacity: 0, scale: 0.985, y: 6 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.985, y: 5 }} transition={{ duration: 0.22, ease: MOTION_EASE }}> -
-

风格中心

先在窗口中预览和调整,确认后才应用到当前编辑内容。

- -
- -
- {([["ai_free", "AI 自由发挥"], ["random_preset", "每日随机"], ["preset", "预设风格"], ["custom", "自定义描述"]] as const).map(([tab, tabLabel]) => )} -
- -
- {loading ?
正在读取风格目录…
: error ?
{error}
: activeTab === "preset" ?
- -
-
- - {selectedDraft?.preview_url && !previewBroken ? setPreviewBroken(true)} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.18 }} /> : 示例图暂不可用,仍可根据色板和说明选择} - + return ( +
+ {label} + + + + {open && ( + !confirming && setOpen(false)} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.18 }}> + event.stopPropagation()} initial={{ opacity: 0, scale: 0.985, y: 6 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.985, y: 5 }} transition={{ duration: 0.22, ease: MOTION_EASE }}> +
+

风格中心

选择和预览只保存在窗口草稿中,确认后才应用。

+ +
+ +
+ + +
+ {loading ?
正在读取风格目录…
+ : error ?
{error}
+ : activeTab === "preset" ? ( +
+
+ +
+ + {THEME_CATEGORIES.map((item) => )} +
+
+ {presets.map((theme) => ( + + ))} + {!presets.length &&
没有匹配的预设风格。
} +
+
+
+
+ + {selectedDraft?.preview_url && !previewBroken ? setPreviewBroken(true)} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.18 }} /> + : 示例图暂不可用,仍可按色板和说明选择} + +
+
{selectedDraft?.category || "预设风格"}

{selectedDraft?.label || "请选择一种风格"}

{selectedDraft?.description || "从风格目录中选择后查看画材、配色、纹理和光影说明。"}

{selectedDraft && 每天保留 {selectedDraft.variation_count} 种可复现微变化;示例不代表真实群聊内容。}
+
+
+ ) : activeTab === "custom" ? ( +
+ 自定义描述

用一句话定义视觉语言

仅注入共享视觉风格,不会覆盖群原有 Prompt 模板。

+