diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index 064bc167a..6f8500519 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -9,12 +9,13 @@ * - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一 */ import React, { useMemo, useState } from "react" -import { AutoComplete, Input, message } from "antd" +import { Input, message } from "antd" import { LoadingOutlined } from "@ant-design/icons" import type { TitleSettings } from "../types" import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants" import { useStep4Title } from "../hooks/useStep4Title" import AiTitleGenerator from "./title/AiTitleGenerator" +import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete" import TitleStylePanel from "./title/TitleStylePanel" import { AI_TITLE_TEMPLATES } from "../constants" @@ -201,21 +202,14 @@ const Step4TitleSettings: React.FC = (props) => {
- { t.updateTitle(val || "") onPreviewTitlesChange?.([val || ""]) }} options={titleOptions} - filterOption={(inputValue, option) => { - const title = (option?.label || option?.value || "") as string - return title.toLowerCase().includes((inputValue || "").toLowerCase()) - }} />
@@ -269,17 +263,11 @@ const Step4TitleSettings: React.FC = (props) => { {Array.from({ length: previewCount }, (_, i) => (
- updateVariantTitle(i, val || "")} + updateVariantTitle(i, val)} options={titleOptions} - filterOption={(inputValue, option) => { - const title = (option?.label || option?.value || "") as string - return title.toLowerCase().includes((inputValue || "").toLowerCase()) - }} />
))} diff --git a/apps/web/src/pages/generate/components/title/TitleLibraryAutoComplete.tsx b/apps/web/src/pages/generate/components/title/TitleLibraryAutoComplete.tsx new file mode 100644 index 000000000..72cdd8ed6 --- /dev/null +++ b/apps/web/src/pages/generate/components/title/TitleLibraryAutoComplete.tsx @@ -0,0 +1,75 @@ +/** + * 标题库 AutoComplete(Issue #1737) + * + * 原生 antd AutoComplete(combobox 模式)的两个行为不符合产品预期: + * 1. combobox 默认 showAction=[],输入框聚焦时下拉不展开——用户必须先打字才能看到标题库, + * 且组件无下拉箭头,视觉上是"纯输入框",不知道标题库里已有标题可选。 + * 2. 空态聚焦不展示任何标题库内容。 + * + * 本组件封装修复: + * - 受控 open:聚焦(且标题库非空)即展开,展示全部标题;失焦/选中/Esc 关闭 + * (rc-select 失焦会主动 onToggleOpen(false),onOpenChange 同步状态即可,不会死循环) + * - suffixIcon 加下拉三角,视觉提示"可选择";有值时 allowClear 的清除按钮照常出现 + * - 输入文字时由 filterOption 过滤(空串展示全部) + * - 保留 combobox 自由输入能力:用户可输入标题库之外的自定义标题 + */ +import React, { useState } from "react" +import { AutoComplete } from "antd" +import { DownOutlined } from "@ant-design/icons" +import type { AutoCompleteProps } from "antd" + +export interface TitleOption { + label: string + value: string +} + +interface TitleLibraryAutoCompleteProps { + value: string + onChange: (val: string) => void + options: TitleOption[] + placeholder?: string + allowClear?: boolean + maxLength?: number + style?: React.CSSProperties +} + +const TitleLibraryAutoComplete: React.FC = ({ + value, + onChange, + options, + placeholder = "输入或从标题库选择", + allowClear = true, + maxLength = 50, + style, +}) => { + const [open, setOpen] = useState(false) + const hasTitles = options.length > 0 + + const filterOption: AutoCompleteProps["filterOption"] = (inputValue, option) => { + const title = (option?.label || option?.value || "") as string + return title.toLowerCase().includes((inputValue || "").toLowerCase()) + } + + return ( + onChange(val || "")} + options={options} + filterOption={filterOption} + open={open} + onOpenChange={setOpen} + onFocus={() => { + // 标题库为空时不展开(避免弹出"暂无数据"空壳) + if (hasTitles) setOpen(true) + }} + onSelect={() => setOpen(false)} + suffixIcon={} + placeholder={placeholder} + allowClear={allowClear} + maxLength={maxLength} + style={{ width: "100%", ...style }} + /> + ) +} + +export default TitleLibraryAutoComplete diff --git a/apps/web/src/test/pages/generate/title-library-autocomplete.test.tsx b/apps/web/src/test/pages/generate/title-library-autocomplete.test.tsx new file mode 100644 index 000000000..b8a05188d --- /dev/null +++ b/apps/web/src/test/pages/generate/title-library-autocomplete.test.tsx @@ -0,0 +1,126 @@ +/** + * TitleLibraryAutoComplete 单测(Issue #1737) + * + * 覆盖: + * - 聚焦空输入框 → 下拉立即展开,展示标题库全部标题(原生 AutoComplete 聚焦不展开,此为本工单核心修复) + * - 输入关键词 → 下拉只显示匹配项 + * - 点击下拉项 → onChange 回填所选标题 + * - 自由输入自定义标题 → onChange 正常透传,不被下拉干扰 + * - 标题库为空 → 聚焦不展开(不出"暂无数据"空壳) + * - 选中后下拉关闭 + */ +import { describe, it, expect, vi } from "vitest" +import { render, screen, waitFor, fireEvent } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete" + +const OPTIONS = [ + { label: "永康这家面馆绝了", value: "永康这家面馆绝了" }, + { label: "永康美食探店vlog", value: "永康美食探店vlog" }, + { label: "萌宠日常第一天", value: "萌宠日常第一天" }, +] + +function renderBox(initialValue = "", opts = OPTIONS) { + const onChange = vi.fn() + const result = render( + , + ) + return { onChange, ...result } +} + +/** 聚焦输入框(combobox role) */ +function focusInput() { + const input = screen.getByRole("combobox") as HTMLInputElement + fireEvent.focus(input) + return input +} + +/** 取下拉中实际可见的选项(rc-virtual-list 渲染为 .ant-select-item-option;role=option 的 listbox 是 a11y 哨兵) */ +function getVisibleOptions(): HTMLElement[] { + const dropdown = document.querySelector(".ant-select-dropdown:not(.ant-select-dropdown-hidden)") + if (!dropdown) return [] + return Array.from(dropdown.querySelectorAll(".ant-select-item-option")) as HTMLElement[] +} + +describe("TitleLibraryAutoComplete (#1737)", () => { + it("聚焦空输入框时下拉展开并展示标题库全部标题", async () => { + renderBox() + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + + focusInput() + + await screen.findByRole("listbox") + await waitFor(() => expect(getVisibleOptions()).toHaveLength(3)) + const options = getVisibleOptions() + expect(options[0]).toHaveTextContent("永康这家面馆绝了") + expect(options[2]).toHaveTextContent("萌宠日常第一天") + }) + + it("输入关键词时下拉只显示匹配项", async () => { + const user = userEvent.setup() + renderBox() + const input = screen.getByRole("combobox") + await user.click(input) + await screen.findByRole("listbox") + + await user.type(input, "永康") + await waitFor(() => expect(getVisibleOptions()).toHaveLength(2)) + const options = getVisibleOptions() + expect(options.every((o) => o.textContent?.includes("永康"))).toBe(true) + }) + + it("点击下拉项后 onChange 回填标题且下拉关闭", async () => { + const user = userEvent.setup() + const { onChange } = renderBox() + const input = screen.getByRole("combobox") as HTMLInputElement + await user.click(input) + await screen.findByRole("listbox") + + await user.click(screen.getByText("萌宠日常第一天")) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith("萌宠日常第一天") + }) + await waitFor(() => { + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + }) + }) + + it("自由输入自定义标题时 onChange 正常透传(不被下拉干扰)", async () => { + const user = userEvent.setup() + const { onChange } = renderBox() + const input = screen.getByRole("combobox") + await user.click(input) + + await user.type(input, "我自己编的标题XYZ") + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith("我自己编的标题XYZ") + }) + // 输入无匹配关键词,下拉无 option 时不阻塞输入 + expect(input).toHaveValue("我自己编的标题XYZ") + }) + + it("标题库为空时聚焦不展开下拉", async () => { + renderBox("", []) + focusInput() + // 等一帧确认没有 listbox + await new Promise((r) => setTimeout(r, 50)) + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + }) + + it("渲染下拉箭头图标作为可选择提示", () => { + const { container } = renderBox() + // antd 后缀图标在 .ant-select-arrow 内 + expect(container.querySelector(".ant-select-arrow")).toBeInTheDocument() + }) + + it("有初始值时输入框正常展示", () => { + renderBox("已有标题") + expect(screen.getByRole("combobox")).toHaveValue("已有标题") + }) +})