feat: 联动PSD品类标签并支持批量上传字体
This commit is contained in:
parent
366e0e3e5c
commit
3ee9701a50
@ -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 />
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -183,7 +183,7 @@ function matchesTemplateSelectedCategories(
|
||||
template.categoryTags
|
||||
);
|
||||
if (rawTemplateCategoryTags.length === 0) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const templateCategoryTags = getEffectiveTemplateCategoryTags(
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -23,6 +23,7 @@ export interface AgentPsdFontListResponse {
|
||||
export interface AgentPsdFontMutationResponse {
|
||||
success: boolean;
|
||||
font?: AgentPsdFont;
|
||||
fonts?: AgentPsdFont[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user