feat: 联动PSD品类标签并支持批量上传字体
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-03 19:41:59 +08:00
parent 366e0e3e5c
commit 3ee9701a50
9 changed files with 459 additions and 193 deletions

View File

@ -257,7 +257,7 @@ export function PsdTemplatesSettingsClient({
const [fontUploadName, setFontUploadName] = useState("");
const [fontFamily, setFontFamily] = useState("");
const [fontUploadBrandName, setFontUploadBrandName] = useState("");
const [fontUploadFile, setFontUploadFile] = useState<File | null>(null);
const [fontUploadFiles, setFontUploadFiles] = useState<File[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [isCreatingAndParsing, setIsCreatingAndParsing] = useState(false);
const [templateUploadPercent, setTemplateUploadPercent] = useState(0);
@ -486,15 +486,15 @@ export function PsdTemplatesSettingsClient({
}
function handleFontFileChange(event: ChangeEvent<HTMLInputElement>) {
const nextFile = event.target.files?.[0] || null;
setFontUploadFile(nextFile);
const nextFiles = Array.from(event.target.files || []);
setFontUploadFiles(nextFiles);
if (nextFile) {
const normalizedName = nextFile.name.replace(/\.[^.]+$/, "");
if (!fontUploadName.trim()) {
if (nextFiles.length === 1) {
const normalizedName = nextFiles[0]?.name.replace(/\.[^.]+$/, "") || "";
if (normalizedName && !fontUploadName.trim()) {
setFontUploadName(normalizedName);
}
if (!fontFamily.trim()) {
if (normalizedName && !fontFamily.trim()) {
setFontFamily(normalizedName);
}
}
@ -693,16 +693,25 @@ export function PsdTemplatesSettingsClient({
}
async function handleUploadFont() {
if (!fontUploadFile) {
if (fontUploadFiles.length === 0) {
toast.error("请先选择字体文件");
return;
}
const formData = new FormData();
formData.set("name", fontUploadName.trim() || fontUploadFile.name.replace(/\.[^.]+$/, ""));
formData.set("family", fontFamily.trim() || fontUploadName.trim() || "CustomFont");
if (fontUploadFiles.length === 1) {
formData.set(
"name",
fontUploadName.trim() ||
fontUploadFiles[0]?.name.replace(/\.[^.]+$/, "") ||
"CustomFont"
);
}
formData.set("family", fontFamily.trim());
formData.set("brandName", fontUploadBrandName.trim());
formData.set("file", fontUploadFile);
for (const file of fontUploadFiles) {
formData.append("files", file);
}
try {
setIsUploadingFont(true);
@ -711,15 +720,20 @@ export function PsdTemplatesSettingsClient({
body: formData,
});
const payload = (await response.json()) as AgentPsdFontMutationResponse;
if (!response.ok || !payload.success || !payload.font) {
const uploadedFonts = payload.fonts || (payload.font ? [payload.font] : []);
if (!response.ok || !payload.success || uploadedFonts.length === 0) {
throw new Error(payload.error || "字体上传失败");
}
toast.success("字体已上传到字体库");
toast.success(
uploadedFonts.length > 1
? `已批量上传 ${uploadedFonts.length} 个字体`
: "字体已上传到字体库"
);
setFontUploadName("");
setFontFamily("");
setFontUploadBrandName("");
setFontUploadFile(null);
setFontUploadFiles([]);
await mutateFonts();
} catch (error) {
toast.error(error instanceof Error ? error.message : "字体上传失败");
@ -1371,8 +1385,14 @@ export function PsdTemplatesSettingsClient({
id="psdFontName"
value={fontUploadName}
onChange={(event) => setFontUploadName(event.target.value)}
disabled={fontUploadFiles.length > 1}
placeholder="例如:思源黑体-中黑"
/>
{fontUploadFiles.length > 1 ? (
<p className="text-xs text-muted-foreground">
批量上传时会自动使用每个文件名作为字体名称,这一项不再单独生效。
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="psdFontFamily">CSS 字体族</Label>
@ -1418,12 +1438,22 @@ export function PsdTemplatesSettingsClient({
id="psdFontFile"
type="file"
accept=".ttf,.otf,.woff,.woff2"
multiple
onChange={handleFontFileChange}
/>
<p className="text-xs text-muted-foreground">
{fontUploadFile
? `已选择:${fontUploadFile.name}`
: "建议按实际字重分别上传多个字体文件"}
{fontUploadFiles.length > 0
? fontUploadFiles.length === 1
? `已选择:${fontUploadFiles[0]?.name || ""}`
: `已选择 ${fontUploadFiles.length} 个字体文件:${fontUploadFiles
.map((file) => file.name)
.join("、")}`
: "支持一次选择多个字体文件批量上传,建议按实际字重分别整理后再导入"}
</p>
<p className="text-xs text-muted-foreground">
{fontUploadFiles.length > 1
? "批量上传时会使用每个文件名作为字体名称;如果填写了 CSS 字体族,会批量应用到这一批字体。"
: "单个上传时可自定义字体名称和 CSS 字体族。"}
</p>
</div>
<Button onClick={handleUploadFont} disabled={isUploadingFont} className="w-full" variant="outline">
@ -1432,7 +1462,7 @@ export function PsdTemplatesSettingsClient({
) : (
<Type className="mr-2 h-4 w-4" />
)}
上传到字体库
{fontUploadFiles.length > 1 ? "批量上传到字体库" : "上传到字体库"}
</Button>
<Separator />

View File

@ -42,6 +42,16 @@ function getDefaultFontName(fileName: string) {
return fileName.replace(/\.[^.]+$/, "").trim() || "未命名字体";
}
function getUploadFontFiles(formData: FormData) {
const multipleFiles = formData.getAll("files").filter((item): item is File => item instanceof File);
if (multipleFiles.length > 0) {
return multipleFiles;
}
const singleFile = formData.get("file");
return singleFile instanceof File ? [singleFile] : [];
}
export async function GET() {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session?.user?.id) {
@ -73,14 +83,12 @@ export async function POST(request: NextRequest) {
return jsonMutationError(400, "上传表单解析失败");
}
const file = formData.get("file");
const name =
String(formData.get("name") || "").trim() ||
(file instanceof File ? getDefaultFontName(file.name) : "");
const family = String(formData.get("family") || "").trim() || name;
const files = getUploadFontFiles(formData);
const customName = String(formData.get("name") || "").trim();
const customFamily = String(formData.get("family") || "").trim();
const brandName = String(formData.get("brandName") || "").trim();
if (!(file instanceof File)) {
if (files.length === 0) {
return jsonMutationError(400, "缺少字体文件");
}
@ -88,39 +96,57 @@ export async function POST(request: NextRequest) {
return jsonMutationError(400, "字体品牌不能超过 64 个字符");
}
const extension = getFileExtension(file.name);
if (!ALLOWED_FONT_EXTENSIONS.has(extension)) {
return jsonMutationError(400, "字体文件仅支持 ttf / otf / woff / woff2");
}
for (const file of files) {
const extension = getFileExtension(file.name);
if (!ALLOWED_FONT_EXTENSIONS.has(extension)) {
return jsonMutationError(400, "字体文件仅支持 ttf / otf / woff / woff2");
}
if (file.size <= 0) {
return jsonMutationError(400, "字体文件不能为空");
}
if (file.size <= 0) {
return jsonMutationError(400, "字体文件不能为空");
}
if (file.size > MAX_FONT_FILE_SIZE) {
return jsonMutationError(400, "字体文件不能超过 20MB");
if (file.size > MAX_FONT_FILE_SIZE) {
return jsonMutationError(400, "字体文件不能超过 20MB");
}
}
try {
const fileBuffer = await file.arrayBuffer();
const uploaded = await uploadFile(file.name, fileBuffer);
const now = new Date().toISOString();
const nextFont: AgentPsdFont = {
id: randomUUID(),
name,
family,
brandName: brandName || undefined,
fileName: file.name,
fileUrl: uploaded.url,
filePath: uploaded.pathname,
contentType: uploaded.contentType || file.type || undefined,
createdAt: now,
updatedAt: now,
uploadedById: session.user.id,
};
const currentFonts = await readAgentPsdFonts();
const fonts = upsertAgentPsdFont(currentFonts, nextFont);
const createdFonts = await Promise.all(
files.map(async (file, index) => {
const fileBuffer = await file.arrayBuffer();
const uploaded = await uploadFile(file.name, fileBuffer);
const defaultName = getDefaultFontName(file.name);
const resolvedName =
files.length === 1 ? customName || defaultName : defaultName;
const resolvedFamily =
customFamily ||
(files.length === 1 ? customName || defaultName : defaultName);
const nextFont: AgentPsdFont = {
id: randomUUID(),
name: resolvedName,
family: resolvedFamily,
brandName: brandName || undefined,
fileName: file.name,
fileUrl: uploaded.url,
filePath: uploaded.pathname,
contentType: uploaded.contentType || file.type || undefined,
createdAt: now,
updatedAt: new Date(Date.parse(now) + index).toISOString(),
uploadedById: session.user.id,
};
return nextFont;
})
);
const fonts = createdFonts.reduce(
(collection, font) => upsertAgentPsdFont(collection, font),
currentFonts
);
await upsertSystemSetting({
key: AGENT_PSD_FONTS_KEY,
@ -130,7 +156,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
font: nextFont,
font: createdFonts[0],
fonts: createdFonts,
} satisfies AgentPsdFontMutationResponse);
} catch (error) {
console.error("[PSD Fonts][Admin] create failed:", error);

View File

@ -69,9 +69,12 @@ import {
} from "@/lib/agents/psd-template-secondary-category-config";
import {
AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY,
buildAgentPsdTemplateCategoryTagIndex,
defaultAgentPsdTemplateCategoryTagConfig,
defaultAgentPsdTemplateCategoryTagIndex,
normalizeAgentPsdTemplateCategoryTagConfig,
} from "@/lib/agents/psd-template-category-tag-config";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import {
AGENT_PRESET_LIBRARY_CONFIG_KEY,
defaultAgentPresetLibraryConfig,
@ -284,6 +287,7 @@ export async function GET() {
agentPsdTextColorConfigSetting,
agentPsdTemplateSecondaryCategoryConfigSetting,
agentPsdTemplateCategoryTagConfigSetting,
agentPsdTemplates,
] = await Promise.all([
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
db
@ -454,6 +458,7 @@ export async function GET() {
.where(eq(systemSettings.key, AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
readAgentPsdTemplates().catch(() => []),
]);
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
@ -531,6 +536,10 @@ export async function GET() {
normalizeAgentPsdTemplateCategoryTagConfig(
agentPsdTemplateCategoryTagConfigSetting?.value
);
const agentPsdTemplateCategoryTagIndex = buildAgentPsdTemplateCategoryTagIndex({
templates: agentPsdTemplates,
config: agentPsdTemplateCategoryTagConfig,
});
// 如果没有设置记录,返回默认值
const baseSettings = settings || {
@ -571,6 +580,8 @@ export async function GET() {
defaultAgentPsdTemplateSecondaryCategoryConfig,
agentPsdTemplateCategoryTagConfig:
defaultAgentPsdTemplateCategoryTagConfig,
agentPsdTemplateCategoryTagIndex:
defaultAgentPsdTemplateCategoryTagIndex,
realtimeCanvasEnabled: true,
realtimeCanvasServiceEnabled: true,
storageFailoverConfig,
@ -624,6 +635,7 @@ export async function GET() {
agentPsdTextColorConfig,
agentPsdTemplateSecondaryCategoryConfig,
agentPsdTemplateCategoryTagConfig,
agentPsdTemplateCategoryTagIndex,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,

View File

@ -112,6 +112,7 @@ import {
normalizeAgentGenerationProjectPersistedState,
} from "@/lib/agents/generation-project-state";
import { normalizeAgentPresetModeConfig } from "@/lib/agents/preset-mode-config";
import type { AgentPsdTemplateCategoryTagIndex } from "@/lib/agents/psd-template-category-tag-config";
import { agentProjectService } from "@/services/agent-projects";
import type {
AgentPreset,
@ -384,6 +385,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
realtimeCanvasServiceEnabled?: boolean;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
agentWorkflowBrandModelConfig?: unknown;
agentPsdTemplateCategoryTagIndex?: AgentPsdTemplateCategoryTagIndex;
agentWorkflowUiConfig?: {
steps?: Array<{
key: AgentWorkflowStepKey;
@ -2130,6 +2132,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
config={resolvedWorkflowUiConfig}
inputs={workflowInputs}
onInputsChange={handleWorkflowInputsChange}
psdTemplateCategoryTagIndex={siteSettingsData?.agentPsdTemplateCategoryTagIndex}
theme="dark"
/>
}
@ -2139,6 +2142,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
config={resolvedWorkflowUiConfig}
inputs={workflowInputs}
onInputsChange={handleWorkflowInputsChange}
psdTemplateCategoryTagIndex={siteSettingsData?.agentPsdTemplateCategoryTagIndex}
materials={workflowMaterials}
onMaterialsChange={handleWorkflowMaterialsChange}
selections={sanitizedWorkflowSelections}

View File

@ -5992,8 +5992,6 @@ export function AgentGenerationWorkspaceDarkV2({
resetKeys={[
psdTemplateDialogSourceUrl || "",
workflowContext?.brand || "",
workflowContext?.thirdCategory || "",
workflowContext?.fourthCategory || "",
workflowContext?.fifthCategory || "",
psdTemplateDialogMode,
]}
@ -6035,11 +6033,7 @@ export function AgentGenerationWorkspaceDarkV2({
}}
sourceImageUrl={psdTemplateDialogSourceUrl}
brandName={workflowContext?.brand || null}
selectedCategoryNames={[
workflowContext?.fifthCategory || "",
workflowContext?.fourthCategory || "",
workflowContext?.thirdCategory || "",
].filter(Boolean)}
selectedCategoryNames={[workflowContext?.fifthCategory || ""].filter(Boolean)}
sizeInfoText={workflowContext?.summaryText || null}
templateMode={psdTemplateDialogMode}
/>

View File

@ -183,7 +183,7 @@ function matchesTemplateSelectedCategories(
template.categoryTags
);
if (rawTemplateCategoryTags.length === 0) {
return true;
return false;
}
const templateCategoryTags = getEffectiveTemplateCategoryTags(

View File

@ -15,7 +15,6 @@ import {
flattenVisibleAgentWorkflowSteps,
getAgentWorkflowBlockedOptionKeysForSelections,
getAgentWorkflowBrandStoreEntries,
getAgentWorkflowFifthCategoryOptionsByBrandStoreThirdFourth,
getAgentWorkflowFourthCategoryOptionsByBrandStoreThirdCategory,
getAgentWorkflowPriceBandOptions,
getAgentWorkflowThirdCategoryOptionsByBrandStore,
@ -34,6 +33,11 @@ import {
buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
} from "@/lib/agents/workflow-composer";
import {
getAgentPsdTemplateCategoryTagsByBrand,
normalizeAgentPsdTemplateCategoryTagIndex,
type AgentPsdTemplateCategoryTagIndex,
} from "@/lib/agents/psd-template-category-tag-config";
import type {
AgentWorkflowComposerSelection,
AgentWorkflowMaterialSelections,
@ -57,6 +61,7 @@ interface AgentWorkflowPanelDarkV2Props {
config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputs;
onInputsChange: (next: AgentWorkflowInputs) => void;
psdTemplateCategoryTagIndex?: AgentPsdTemplateCategoryTagIndex | null;
materials: AgentWorkflowMaterialSelections;
onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
@ -77,6 +82,7 @@ interface AgentWorkflowBrandStorePanelProps {
config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputs;
onInputsChange: (next: AgentWorkflowInputs) => void;
psdTemplateCategoryTagIndex?: AgentPsdTemplateCategoryTagIndex | null;
theme?: "light" | "dark";
title?: string;
description?: string;
@ -281,6 +287,7 @@ export function AgentWorkflowBrandStorePanel({
config,
inputs,
onInputsChange,
psdTemplateCategoryTagIndex,
theme = "dark",
title = "品牌层级选择",
description = "先选择品牌、店铺、分类层级和价格带,后续品牌模特与风格步骤会按这里的选择联动。",
@ -331,16 +338,18 @@ export function AgentWorkflowBrandStorePanel({
}),
[config, inputs.brand, inputs.store, inputs.thirdCategory]
);
const normalizedPsdTemplateCategoryTagIndex = useMemo(
() =>
normalizeAgentPsdTemplateCategoryTagIndex(psdTemplateCategoryTagIndex),
[psdTemplateCategoryTagIndex]
);
const fifthCategoryOptions = useMemo(
() =>
getAgentWorkflowFifthCategoryOptionsByBrandStoreThirdFourth({
config,
brand: inputs.brand,
store: inputs.store,
thirdCategory: inputs.thirdCategory,
fourthCategory: inputs.fourthCategory,
}),
[config, inputs.brand, inputs.store, inputs.thirdCategory, inputs.fourthCategory]
getAgentPsdTemplateCategoryTagsByBrand(
normalizedPsdTemplateCategoryTagIndex,
inputs.brand
),
[inputs.brand, normalizedPsdTemplateCategoryTagIndex]
);
const priceBandOptions = useMemo(
() => getAgentWorkflowPriceBandOptions(config),
@ -371,56 +380,52 @@ export function AgentWorkflowBrandStorePanel({
) || Boolean(config?.inputOptions?.fourthCategories?.length)),
[brandStoreEntries, config?.inputOptions?.fourthCategories, hasThirdCategoryLevel]
);
const hasFifthCategoryLevel = useMemo(
() =>
hasFourthCategoryLevel &&
(brandStoreEntries.some((entry) =>
(entry.storeEntries || []).some((storeEntry) =>
(storeEntry.thirdCategoryEntries || []).some((thirdEntry) =>
(thirdEntry.fourthCategoryEntries || []).some(
(fourthEntry) => (fourthEntry.fifthCategories || []).length > 0
)
)
)
) || Boolean(config?.inputOptions?.fifthCategories?.length)),
[brandStoreEntries, config?.inputOptions?.fifthCategories, hasFourthCategoryLevel]
);
const isDark = theme === "dark";
useEffect(() => {
let nextInputs = inputs;
let changed = false;
if (!hasThirdCategoryLevel) {
if (inputs.thirdCategory || inputs.fourthCategory || inputs.fifthCategory) {
onInputsChange({
...inputs,
if (nextInputs.thirdCategory || nextInputs.fourthCategory) {
nextInputs = {
...nextInputs,
thirdCategory: "",
fourthCategory: "",
fifthCategory: "",
});
};
changed = true;
}
return;
}
if (!hasFourthCategoryLevel) {
if (inputs.fourthCategory || inputs.fifthCategory) {
onInputsChange({
...inputs,
} else if (!hasFourthCategoryLevel) {
if (nextInputs.fourthCategory) {
nextInputs = {
...nextInputs,
fourthCategory: "",
fifthCategory: "",
});
};
changed = true;
}
return;
}
if (!hasFifthCategoryLevel && inputs.fifthCategory) {
onInputsChange({
...inputs,
if (
nextInputs.fifthCategory &&
!fifthCategoryOptions.some(
(option) =>
option.trim().toLowerCase() === nextInputs.fifthCategory.trim().toLowerCase()
)
) {
nextInputs = {
...nextInputs,
fifthCategory: "",
});
};
changed = true;
}
if (changed) {
onInputsChange(nextInputs);
}
}, [
hasFifthCategoryLevel,
hasFourthCategoryLevel,
fifthCategoryOptions,
hasThirdCategoryLevel,
hasFourthCategoryLevel,
inputs,
onInputsChange,
]);
@ -486,7 +491,6 @@ export function AgentWorkflowBrandStorePanel({
? inputs.thirdCategory
: "",
fourthCategory: "",
fifthCategory: "",
})
}
/>
@ -511,7 +515,6 @@ export function AgentWorkflowBrandStorePanel({
value.trim().toLowerCase() === inputs.thirdCategory.trim().toLowerCase()
? inputs.fourthCategory
: "",
fifthCategory: "",
})
}
/>
@ -533,30 +536,24 @@ export function AgentWorkflowBrandStorePanel({
onInputsChange({
...inputs,
fourthCategory: value,
fifthCategory:
value.trim().toLowerCase() === inputs.fourthCategory.trim().toLowerCase()
? inputs.fifthCategory
: "",
})
}
/>
) : null}
{hasFifthCategoryLevel ? (
<SearchableSelect
value={inputs.fifthCategory}
options={fifthCategoryOptions}
placeholder={`选择${fifthCategoryLabel}`}
searchPlaceholder={`输入${fifthCategoryLabel}筛选`}
emptyText={
inputs.fourthCategory
? `当前${fourthCategoryLabel}下暂无${fifthCategoryLabel}`
: `请先选择${fourthCategoryLabel}`
}
theme={theme}
disabled={!inputs.fourthCategory || fifthCategoryOptions.length === 0}
onValueChange={(value) => onInputsChange({ ...inputs, fifthCategory: value })}
/>
) : null}
<SearchableSelect
value={inputs.fifthCategory}
options={fifthCategoryOptions}
placeholder={`选择${fifthCategoryLabel}`}
searchPlaceholder={`输入${fifthCategoryLabel}筛选`}
emptyText={
inputs.brand
? `当前${firstCategoryLabel}下暂无${fifthCategoryLabel}`
: `请先选择${firstCategoryLabel}`
}
theme={theme}
disabled={!inputs.brand || fifthCategoryOptions.length === 0}
onValueChange={(value) => onInputsChange({ ...inputs, fifthCategory: value })}
/>
<SearchableSelect
value={inputs.priceBand}
options={priceBandOptions}
@ -576,6 +573,7 @@ export function AgentWorkflowPanelDarkV2({
config,
inputs,
onInputsChange,
psdTemplateCategoryTagIndex,
materials,
onMaterialsChange,
selections,
@ -682,6 +680,16 @@ export function AgentWorkflowPanelDarkV2({
: [],
[activeEmbeddedStepEntry, selections]
);
const activeEmbeddedSelectedPath = useMemo(
() =>
activeEmbeddedStepEntry
? getAgentWorkflowSelectedOptionPath({
step: activeEmbeddedStepEntry.step,
selectionValue: selections[activeEmbeddedStepEntry.step.key],
})
: [],
[activeEmbeddedStepEntry, selections]
);
const activeEmbeddedOptionLevel =
activeEmbeddedOptionLevels.find((level) => level.depth === activeEmbeddedOptionDepth) ||
activeEmbeddedOptionLevels[activeEmbeddedOptionLevels.length - 1] ||
@ -938,6 +946,7 @@ export function AgentWorkflowPanelDarkV2({
config={config}
inputs={inputs}
onInputsChange={onInputsChange}
psdTemplateCategoryTagIndex={psdTemplateCategoryTagIndex}
theme="dark"
/>
</motion.section>
@ -958,7 +967,7 @@ export function AgentWorkflowPanelDarkV2({
>
<div className="mb-5 space-y-4">
<div className="mt-3">
<div className="flex flex-wrap items-stretch gap-3 pb-1">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
{flattenedResolvedSteps.map(({ step }, stepIndex) => {
const completed = isAgentWorkflowStepSelectionComplete({
step,
@ -969,16 +978,17 @@ export function AgentWorkflowPanelDarkV2({
step,
selectionValue: selections[step.key],
});
const stepOptionLevels = getWorkflowStepOptionLevels({
step,
selectionValue: selections[step.key],
});
const primarySelectedOption = stepSelectedPath[0] || null;
const selectedCountLabel =
stepSelectedPath.length > 0
? `${stepSelectedPath.length} 项已选`
: "待选择";
return (
<div
key={step.key}
className={cn(
"group flex min-w-[220px] max-w-full flex-1 basis-[280px] flex-col gap-3 rounded-[20px] border px-3 py-3 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
"group flex min-h-[102px] flex-col justify-between rounded-[20px] border px-3 py-3 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
active
? "border-emerald-300/28 bg-emerald-400/[0.08]"
: completed
@ -986,7 +996,7 @@ export function AgentWorkflowPanelDarkV2({
: "border-white/10 bg-white/[0.025]"
)}
>
<div className="flex flex-wrap items-start gap-3 text-left">
<div className="flex items-start gap-3 text-left">
<button
type="button"
onClick={() => {
@ -1010,75 +1020,119 @@ export function AgentWorkflowPanelDarkV2({
{stepIndex + 1}
</span>
<div className="min-w-0 flex-1 space-y-1">
<div
className={cn(
"text-[12px] font-semibold leading-5 transition-colors",
active
? "text-white"
: completed
? "text-white/82"
: "text-white/52 group-hover:text-white/76"
)}
>
{step.title}
<div className="flex flex-wrap items-center gap-2">
<div
className={cn(
"text-[12px] font-semibold leading-5 transition-colors",
active
? "text-white"
: completed
? "text-white/82"
: "text-white/52 group-hover:text-white/76"
)}
>
{step.title}
</div>
{active ? (
<span className="inline-flex min-h-[22px] items-center rounded-full border border-emerald-300/30 bg-emerald-400/10 px-2.5 py-1 text-[10px] font-medium leading-none text-emerald-200">
当前步骤
</span>
) : null}
</div>
<div className="flex flex-wrap gap-1.5 pt-1">
{primarySelectedOption ? (
<span
className={cn(
"inline-flex min-h-[24px] max-w-full items-center rounded-full border px-2 py-1 text-[10px] leading-[1.15]",
active
? "border-emerald-300/28 bg-emerald-400/10 text-emerald-100"
: completed
? "border-white/14 bg-white/[0.06] text-white/72"
: "border-white/10 bg-white/[0.03] text-white/48"
)}
>
<span className="mr-1 shrink-0 text-[9px] opacity-70">主</span>
<span className="break-words whitespace-normal">
{primarySelectedOption.buttonName}
</span>
</span>
) : (
<span className="inline-flex min-h-[24px] items-center rounded-full border border-dashed border-white/10 px-2 py-1 text-[10px] leading-none text-white/32">
待选择
</span>
)}
{stepSelectedPath.length > 1 ? (
<span className="inline-flex min-h-[24px] items-center rounded-full border border-white/12 bg-white/[0.04] px-2 py-1 text-[10px] leading-none text-white/48">
{selectedCountLabel}
</span>
) : null}
</div>
{active ? (
<span className="inline-flex min-h-[24px] items-center rounded-full border border-emerald-300/30 bg-emerald-400/10 px-2.5 py-1 text-[10px] font-medium leading-none text-emerald-200">
当前步骤
</span>
) : null}
</div>
</button>
<div className="flex min-w-0 flex-1 items-start">
{stepSelectedPath.length > 0 ? (
<div className="flex min-w-0 flex-wrap items-start gap-1.5">
{stepSelectedPath.map((option, optionIndex) => {
const optionDepth = optionIndex + 1;
const optionLevelActive =
active &&
(activeEmbeddedOptionLevel?.depth || 1) === optionDepth;
return (
<button
key={`${step.key}-${option.key}-${optionDepth}`}
type="button"
onClick={() => {
setPendingAutoAdvanceStepKey(null);
setAutoAdvanceNotice(null);
setActiveEmbeddedStepKey(step.key);
setActiveEmbeddedOptionDepth(optionDepth);
}}
className={cn(
"inline-flex min-h-[24px] max-w-full items-center gap-1 rounded-full border px-2 py-1 text-[10px] leading-[1.15] transition-colors",
optionLevelActive
? "border-emerald-300/35 bg-emerald-400/12 text-emerald-100"
: active || completed
? "border-white/14 bg-white/[0.06] text-white/72 hover:border-white/22 hover:bg-white/[0.09] hover:text-white"
: "border-white/10 bg-white/[0.03] text-white/48 hover:border-white/18 hover:text-white/72"
)}
title={`返回${getWorkflowOptionDepthLabel(optionDepth)}:${option.buttonName}`}
>
<span className="text-[9px] opacity-70">
{getWorkflowOptionDepthShortLabel(optionDepth)}
</span>
<span className="max-w-[11rem] break-words whitespace-normal">
{option.buttonName}
</span>
</button>
);
})}
</div>
) : (
<span className="text-[10px] leading-none text-white/32">
待选择
</span>
)}
</div>
</div>
</div>
);
})}
</div>
<div className="rounded-[18px] border border-white/10 bg-white/[0.025] px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-white/12 px-2.5 py-1 text-[11px] font-medium text-white/55">
当前步骤路径
</span>
<span className="text-xs text-white/45">
{activeEmbeddedStepEntry.step.title}
</span>
</div>
<span className="text-[11px] text-white/35">
{activeEmbeddedSelectedPath.length > 0
? `已选 ${activeEmbeddedSelectedPath.length} 项`
: "还未选择主卡片"}
</span>
</div>
{activeEmbeddedSelectedPath.length > 0 ? (
<div className="mt-3 flex flex-wrap items-start gap-1.5">
{activeEmbeddedSelectedPath.map((option, optionIndex) => {
const optionDepth = optionIndex + 1;
const optionLevelActive =
(activeEmbeddedOptionLevel?.depth || 1) === optionDepth;
return (
<button
key={`${activeEmbeddedStepEntry.step.key}-${option.key}-${optionDepth}`}
type="button"
onClick={() => {
setPendingAutoAdvanceStepKey(null);
setAutoAdvanceNotice(null);
setActiveEmbeddedStepKey(activeEmbeddedStepEntry.step.key);
setActiveEmbeddedOptionDepth(optionDepth);
}}
className={cn(
"inline-flex min-h-[26px] max-w-full items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] leading-[1.15] transition-colors",
optionLevelActive
? "border-emerald-300/35 bg-emerald-400/12 text-emerald-100"
: "border-white/14 bg-white/[0.06] text-white/72 hover:border-white/22 hover:bg-white/[0.09] hover:text-white"
)}
title={`返回${getWorkflowOptionDepthLabel(optionDepth)}:${option.buttonName}`}
>
<span className="text-[9px] opacity-70">
{getWorkflowOptionDepthShortLabel(optionDepth)}
</span>
<span className="max-w-[14rem] break-words whitespace-normal">
{option.buttonName}
</span>
</button>
);
})}
</div>
) : (
<div className="mt-3 text-xs leading-5 text-white/35">
先选择当前步骤的主卡片,完整子节点路径会显示在这里,顶部步骤条只保留紧凑摘要。
</div>
)}
</div>
</div>
</div>

View File

@ -23,6 +23,7 @@ export interface AgentPsdFontListResponse {
export interface AgentPsdFontMutationResponse {
success: boolean;
font?: AgentPsdFont;
fonts?: AgentPsdFont[];
error?: string;
}

View File

@ -1,7 +1,18 @@
import type { AgentPsdTemplate } from "@/lib/agents/psd-template-config";
export interface AgentPsdTemplateCategoryTagConfig {
tags: string[];
}
export interface AgentPsdTemplateCategoryTagIndexEntry {
brandName: string;
tags: string[];
}
export interface AgentPsdTemplateCategoryTagIndex {
entries: AgentPsdTemplateCategoryTagIndexEntry[];
}
export const AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY =
"agent_psd_template_category_tag_config";
export const AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG = "适合所有";
@ -57,6 +68,14 @@ export const defaultAgentPsdTemplateCategoryTagConfig: AgentPsdTemplateCategoryT
tags: [],
};
export const defaultAgentPsdTemplateCategoryTagIndex: AgentPsdTemplateCategoryTagIndex = {
entries: [],
};
function normalizeLookupText(value: unknown) {
return String(value || "").trim().toLowerCase();
}
export function normalizeAgentPsdTemplateCategoryTagConfig(
value: unknown
): AgentPsdTemplateCategoryTagConfig {
@ -72,6 +91,131 @@ export function normalizeAgentPsdTemplateCategoryTagConfig(
};
}
export function normalizeAgentPsdTemplateCategoryTagIndex(
value: unknown
): AgentPsdTemplateCategoryTagIndex {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return defaultAgentPsdTemplateCategoryTagIndex;
}
const rawEntries = Array.isArray((value as Record<string, unknown>).entries)
? ((value as Record<string, unknown>).entries as unknown[])
: [];
const entries: AgentPsdTemplateCategoryTagIndexEntry[] = [];
const seenBrands = new Set<string>();
for (const item of rawEntries) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
continue;
}
const record = item as Record<string, unknown>;
const brandName = String(record.brandName || "").trim();
const tags = normalizeAgentPsdTemplateCategoryTags(record.tags).filter(
(tag) => tag !== AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG
);
const brandKey = normalizeLookupText(brandName);
if (!brandName || !brandKey || seenBrands.has(brandKey)) {
continue;
}
seenBrands.add(brandKey);
entries.push({
brandName,
tags,
});
}
return { entries };
}
export function buildAgentPsdTemplateCategoryTagIndex(params: {
templates: AgentPsdTemplate[];
config?: AgentPsdTemplateCategoryTagConfig | null;
}) {
const normalizedConfig = normalizeAgentPsdTemplateCategoryTagConfig(params.config);
const activeTags = normalizedConfig.tags;
const activeTagKeySet = new Set(activeTags.map((tag) => normalizeLookupText(tag)));
const builders = new Map<
string,
{
brandName: string;
tagKeys: Set<string>;
hasUniversalTemplate: boolean;
}
>();
for (const template of params.templates || []) {
if (
template.status !== "active" ||
template.parseStatus !== "completed" ||
!template.layers.some((layer) => layer.isEditable)
) {
continue;
}
const brandName = String(template.brandName || "").trim();
const brandKey = normalizeLookupText(brandName);
if (!brandName || !brandKey) {
continue;
}
const categoryTags = normalizeAgentPsdTemplateCategoryTags(template.categoryTags);
if (categoryTags.length === 0) {
continue;
}
const currentBuilder =
builders.get(brandKey) ||
{
brandName,
tagKeys: new Set<string>(),
hasUniversalTemplate: false,
};
if (categoryTags.includes(AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG)) {
currentBuilder.hasUniversalTemplate = true;
}
for (const tag of categoryTags) {
const tagKey = normalizeLookupText(tag);
if (!tagKey || !activeTagKeySet.has(tagKey)) {
continue;
}
currentBuilder.tagKeys.add(tagKey);
}
builders.set(brandKey, currentBuilder);
}
return {
entries: Array.from(builders.values()).map((entry) => ({
brandName: entry.brandName,
tags: entry.hasUniversalTemplate
? activeTags
: activeTags.filter((tag) => entry.tagKeys.has(normalizeLookupText(tag))),
})),
} satisfies AgentPsdTemplateCategoryTagIndex;
}
export function getAgentPsdTemplateCategoryTagsByBrand(
index?: AgentPsdTemplateCategoryTagIndex | null,
brandName?: string | null
) {
const normalizedIndex = normalizeAgentPsdTemplateCategoryTagIndex(index);
const brandKey = normalizeLookupText(brandName);
if (!brandKey) {
return [];
}
return (
normalizedIndex.entries.find(
(entry) => normalizeLookupText(entry.brandName) === brandKey
)?.tags || []
);
}
export function getAgentPsdTemplateCategoryTagOptions(
config?: AgentPsdTemplateCategoryTagConfig | null,
extraTags?: unknown