feat: update workflow categories and admin notifications
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-05-07 19:31:14 +08:00
parent c843661da3
commit 2561c66cb8
33 changed files with 2006 additions and 255 deletions

View File

@ -93,6 +93,12 @@ import {
normalizeDingTalkRobotNotificationConfig,
type DingTalkRobotNotificationConfig,
} from "@/lib/notifications/dingtalk-robot-config";
import {
DINGTALK_NOTIFICATION_DRAFT_CONFIG_KEY,
defaultDingTalkNotificationDraftConfig,
normalizeDingTalkNotificationDraftConfig,
type DingTalkNotificationDraftConfig,
} from "@/lib/notifications/dingtalk-notification-draft-config";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
@ -371,6 +377,61 @@ function createEmptyEnhanceProPreset(): EnhanceProPreset {
};
}
type AdminDingTalkNotificationTaskStatus =
| "pending"
| "sending"
| "sent"
| "failed"
| "cancelled";
type AdminDingTalkNotificationTask = {
id: string;
title: string;
content: string;
status: AdminDingTalkNotificationTaskStatus;
scheduledAt: string;
attempts: number;
sentAt?: string | null;
cancelledAt?: string | null;
lastError?: string | null;
createdAt: string;
updatedAt: string;
};
function formatDingTalkNotificationTaskStatus(
status: AdminDingTalkNotificationTaskStatus,
) {
if (status === "pending") return "待发送";
if (status === "sending") return "发送中";
if (status === "sent") return "已发送";
if (status === "failed") return "发送失败";
if (status === "cancelled") return "已取消";
return status;
}
function getDingTalkNotificationTaskStatusClassName(
status: AdminDingTalkNotificationTaskStatus,
) {
if (status === "sent") {
return "border border-emerald-200 bg-emerald-50 text-emerald-700";
}
if (status === "failed") {
return "border border-red-200 bg-red-50 text-red-600";
}
if (status === "cancelled") {
return "border border-slate-200 bg-slate-100 text-slate-500";
}
if (status === "sending") {
return "border border-blue-200 bg-blue-50 text-blue-600";
}
return "border border-amber-200 bg-amber-50 text-amber-700";
}
function toDateTimeLocalInputValue(value: Date) {
const offsetMs = value.getTimezoneOffset() * 60 * 1000;
return new Date(value.getTime() - offsetMs).toISOString().slice(0, 16);
}
function LocalAiSectionToggleButton({
expanded,
onClick,
@ -1526,6 +1587,20 @@ export function AdminSettingsClient() {
`/api/admin/settings?key=${DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY}`,
fetcher
);
const {
data: dingTalkNotificationDraftData,
mutate: mutateDingTalkNotificationDraft,
} = useSWR<{ setting?: { value?: unknown } }>(
`/api/admin/settings?key=${DINGTALK_NOTIFICATION_DRAFT_CONFIG_KEY}`,
fetcher
);
const {
data: dingTalkNotificationTasksData,
mutate: mutateDingTalkNotificationTasks,
} = useSWR<{ tasks?: AdminDingTalkNotificationTask[] }>(
"/api/admin/dingtalk-robot/notifications?limit=30",
fetcher
);
const {
data: threeDModelAliasConfigData,
mutate: mutateThreeDModelAliasConfig,
@ -2833,34 +2908,26 @@ export function AdminSettingsClient() {
tags?: CanvasSkillTag[];
}>("/api/canvas-skill-tags", fetcher);
// 系统通知状态
const [notification, setNotification] = useState<{
enabled: boolean;
title: string;
content: string;
}>({
enabled: false,
title: "",
content: "",
});
const [dingTalkNotificationDraft, setDingTalkNotificationDraft] =
useState<DingTalkNotificationDraftConfig>(
defaultDingTalkNotificationDraftConfig,
);
const [dingTalkRobotNotificationConfig, setDingTalkRobotNotificationConfig] =
useState<DingTalkRobotNotificationConfig>(
defaultDingTalkRobotNotificationConfig,
);
const [dingTalkNotificationScheduleAt, setDingTalkNotificationScheduleAt] =
useState("");
const [isSavingDingTalkNotificationDraft, setIsSavingDingTalkNotificationDraft] =
useState(false);
const [isSavingDingTalkRobotNotificationConfig, setIsSavingDingTalkRobotNotificationConfig] =
useState(false);
const [isSendingDingTalkNotification, setIsSendingDingTalkNotification] =
useState(false);
useEffect(() => {
fetch("/api/admin/settings?key=login_notification")
.then((res) => res.json())
.then((data) => {
if (data.setting?.value) {
setNotification(data.setting.value);
}
});
}, []);
const [isSchedulingDingTalkNotification, setIsSchedulingDingTalkNotification] =
useState(false);
const [cancellingDingTalkNotificationTaskId, setCancellingDingTalkNotificationTaskId] =
useState<string | null>(null);
useEffect(() => {
const value = dingTalkRobotNotificationConfigData?.setting?.value;
@ -2869,19 +2936,35 @@ export function AdminSettingsClient() {
);
}, [dingTalkRobotNotificationConfigData]);
const handleSaveNotification = async () => {
useEffect(() => {
const value = dingTalkNotificationDraftData?.setting?.value;
setDingTalkNotificationDraft(
normalizeDingTalkNotificationDraftConfig(value),
);
}, [dingTalkNotificationDraftData]);
const handleSaveDingTalkNotificationDraft = async () => {
setIsSavingDingTalkNotificationDraft(true);
try {
await fetch("/api/admin/settings", {
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "login_notification",
value: notification,
key: DINGTALK_NOTIFICATION_DRAFT_CONFIG_KEY,
value: dingTalkNotificationDraft,
}),
});
toast.success("系统通知已保存");
if (!response.ok) {
throw new Error("保存失败");
}
toast.success("钉钉通知草稿已保存");
mutateDingTalkNotificationDraft();
} catch (error) {
toast.error("保存失败");
toast.error("保存钉钉通知草稿失败");
} finally {
setIsSavingDingTalkNotificationDraft(false);
}
};
@ -2911,16 +2994,16 @@ export function AdminSettingsClient() {
};
const handleSendNotificationToDingTalk = async () => {
const title = String(notification.title || "").trim();
const content = String(notification.content || "").trim();
const title = String(dingTalkNotificationDraft.title || "").trim();
const content = String(dingTalkNotificationDraft.content || "").trim();
if (!title) {
toast.error("请先填写系统通知标题");
toast.error("请先填写钉钉通知标题");
return;
}
if (!content) {
toast.error("请先填写系统通知内容");
toast.error("请先填写钉钉通知正文");
return;
}
@ -2937,7 +3020,7 @@ export function AdminSettingsClient() {
throw new Error(payload?.error || "发送失败");
}
toast.success("当前系统通知已发送到钉钉群");
toast.success("钉钉通知已发送");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "发送钉钉群通知失败",
@ -2947,6 +3030,86 @@ export function AdminSettingsClient() {
}
};
const handleScheduleDingTalkNotification = async () => {
const title = String(dingTalkNotificationDraft.title || "").trim();
const content = String(dingTalkNotificationDraft.content || "").trim();
if (!title) {
toast.error("请先填写钉钉通知标题");
return;
}
if (!content) {
toast.error("请先填写钉钉通知正文");
return;
}
if (!dingTalkNotificationScheduleAt) {
toast.error("请选择定时发送时间");
return;
}
const scheduledAt = new Date(dingTalkNotificationScheduleAt);
if (Number.isNaN(scheduledAt.getTime())) {
toast.error("定时发送时间格式不正确");
return;
}
setIsSchedulingDingTalkNotification(true);
try {
const response = await fetch("/api/admin/dingtalk-robot/notifications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
content,
scheduledAt: scheduledAt.toISOString(),
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.success === false) {
throw new Error(payload?.error || "创建定时通知失败");
}
toast.success("钉钉定时通知已创建");
setDingTalkNotificationScheduleAt("");
mutateDingTalkNotificationTasks();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "创建钉钉定时通知失败",
);
} finally {
setIsSchedulingDingTalkNotification(false);
}
};
const handleCancelDingTalkNotificationTask = async (taskId: string) => {
setCancellingDingTalkNotificationTaskId(taskId);
try {
const response = await fetch(
`/api/admin/dingtalk-robot/notifications/${taskId}`,
{
method: "DELETE",
},
);
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.success === false) {
throw new Error(payload?.error || "取消定时通知失败");
}
toast.success("定时通知已取消");
mutateDingTalkNotificationTasks();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "取消钉钉通知任务失败",
);
} finally {
setCancellingDingTalkNotificationTaskId(null);
}
};
useEffect(() => {
if (questionsData) {
setQuestions(questionsData);
@ -7397,7 +7560,7 @@ export function AdminSettingsClient() {
</TabsTrigger>
<TabsTrigger value="notification">
<Bell className="mr-2 h-4 w-4" />
系统通知
钉钉通知
</TabsTrigger>
<TabsTrigger value="ai">
<Bot className="mr-2 h-4 w-4" />
@ -13510,55 +13673,117 @@ export function AdminSettingsClient() {
</Card>
</TabsContent>
{/* 系统通知设置 */}
{/* 钉钉通知设置 */}
<TabsContent value="notification" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-xl">系统通知</CardTitle>
<CardTitle className="text-xl">钉钉通知中心</CardTitle>
<CardDescription>
发布全站公告通知,用户进入系统时可见
直接粘贴 Markdown 公告内容,支持立即发送和定时发送,仅推送到钉钉群,不再同步站内弹窗。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center gap-2">
<Switch
id="notification-enabled"
checked={notification.enabled}
onCheckedChange={(checked) =>
setNotification({ ...notification, enabled: checked })
}
/>
<Label htmlFor="notification-enabled">启用通知弹窗</Label>
</div>
<div className="space-y-2">
<Label htmlFor="notification-title">通知标题</Label>
<Label htmlFor="dingtalk-notification-title">通知标题</Label>
<Input
id="notification-title"
value={notification.title}
id="dingtalk-notification-title"
value={dingTalkNotificationDraft.title}
onChange={(e) =>
setNotification({ ...notification, title: e.target.value })
setDingTalkNotificationDraft((current) => ({
...current,
title: e.target.value,
}))
}
placeholder="例如:系统维护公告"
placeholder="例如:系统更新公告|生图引擎性能升级完成"
/>
</div>
<div className="space-y-2">
<Label htmlFor="notification-content">通知内容</Label>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="dingtalk-notification-content">
Markdown 正文
</Label>
<span className="text-xs text-muted-foreground">
支持标题、粗体、列表、引用、链接
</span>
</div>
<Textarea
id="notification-content"
value={notification.content}
id="dingtalk-notification-content"
value={dingTalkNotificationDraft.content}
onChange={(e) =>
setNotification({
...notification,
setDingTalkNotificationDraft((current) => ({
...current,
content: e.target.value,
})
}))
}
placeholder="请输入通知详细内容..."
rows={5}
placeholder={"### 系统更新公告\n\n尊敬的用户:\n\n我们已完成本轮生图引擎性能升级,并已正式上线。"}
rows={12}
/>
</div>
<Button onClick={handleSaveNotification}>
<Save className="mr-2 h-4 w-4" /> 保存通知设置
</Button>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
<div className="space-y-2">
<Label htmlFor="dingtalk-notification-schedule">
定时发送时间
</Label>
<Input
id="dingtalk-notification-schedule"
type="datetime-local"
value={dingTalkNotificationScheduleAt}
min={toDateTimeLocalInputValue(new Date())}
onChange={(e) =>
setDingTalkNotificationScheduleAt(e.target.value)
}
/>
<p className="text-xs text-muted-foreground">
到点后由服务器定时作业自动发送到钉钉群。
</p>
</div>
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground">发送说明</p>
<p className="mt-2">
建议先保存草稿,再根据需要选择立即发送或创建定时通知。
</p>
<p className="mt-2">
定时通知创建后会进入任务列表,可在发送前取消。
</p>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
onClick={handleSaveDingTalkNotificationDraft}
disabled={isSavingDingTalkNotificationDraft}
>
{isSavingDingTalkNotificationDraft ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
保存通知草稿
</Button>
<Button
onClick={handleSendNotificationToDingTalk}
disabled={isSendingDingTalkNotification}
>
{isSendingDingTalkNotification ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Bell className="mr-2 h-4 w-4" />
)}
立即发送到钉钉群
</Button>
<Button
variant="secondary"
onClick={handleScheduleDingTalkNotification}
disabled={isSchedulingDingTalkNotification}
>
{isSchedulingDingTalkNotification ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Clock className="mr-2 h-4 w-4" />
)}
创建定时通知
</Button>
</div>
</CardContent>
</Card>
@ -13566,7 +13791,7 @@ export function AdminSettingsClient() {
<CardHeader>
<CardTitle className="text-xl">钉钉群机器人通知</CardTitle>
<CardDescription>
配置钉钉自定义机器人后,可将当前系统通知一键发送到钉钉群
配置钉钉自定义机器人后,通知中心中的 Markdown 公告即可直接推送到钉钉群。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@ -13617,33 +13842,101 @@ export function AdminSettingsClient() {
/>
</div>
<p className="text-xs text-muted-foreground">
建议在钉钉群中创建“自定义机器人”,将 Webhook 与加签密钥配置到这里。发送时会复用当前系统通知的标题与正文。
建议在钉钉群中创建“自定义机器人”,将 Webhook 与加签密钥配置到这里。通知内容将以 Markdown 消息的形式发送。
</p>
<div className="flex flex-wrap gap-3">
<Button
onClick={handleSaveDingTalkRobotNotificationConfig}
disabled={isSavingDingTalkRobotNotificationConfig}
>
{isSavingDingTalkRobotNotificationConfig ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
保存机器人配置
</Button>
<Button
variant="outline"
onClick={handleSendNotificationToDingTalk}
disabled={isSendingDingTalkNotification}
>
{isSendingDingTalkNotification ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Bell className="mr-2 h-4 w-4" />
)}
发送当前系统通知到钉钉群
</Button>
</div>
<Button
onClick={handleSaveDingTalkRobotNotificationConfig}
disabled={isSavingDingTalkRobotNotificationConfig}
>
{isSavingDingTalkRobotNotificationConfig ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
保存机器人配置
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl">定时通知任务</CardTitle>
<CardDescription>
查看最近创建的钉钉通知任务,待发送任务可在触发前取消。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{(dingTalkNotificationTasksData?.tasks || []).length === 0 ? (
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
暂无定时通知任务,创建后会显示在这里。
</div>
) : (
(dingTalkNotificationTasksData?.tasks || []).map((task) => (
<div
key={task.id}
className="space-y-3 rounded-lg border p-4"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<p className="font-medium text-foreground">
{task.title}
</p>
<p className="text-xs text-muted-foreground">
计划发送:{formatBeijingTime(task.scheduledAt)}
</p>
<p className="text-xs text-muted-foreground">
创建时间:{formatBeijingTime(task.createdAt)}
</p>
{task.sentAt ? (
<p className="text-xs text-muted-foreground">
实际发送:{formatBeijingTime(task.sentAt)}
</p>
) : null}
</div>
<span
className={cn(
"inline-flex w-fit items-center rounded-full px-2.5 py-1 text-xs font-medium",
getDingTalkNotificationTaskStatusClassName(
task.status,
),
)}
>
{formatDingTalkNotificationTaskStatus(task.status)}
</span>
</div>
<div className="rounded-md bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
<div className="max-h-28 overflow-hidden whitespace-pre-wrap break-words">
{task.content}
</div>
</div>
{task.lastError ? (
<p className="text-xs text-red-500">
失败原因:{task.lastError}
</p>
) : null}
{task.status === "pending" ? (
<div className="flex justify-end">
<Button
variant="outline"
onClick={() =>
handleCancelDingTalkNotificationTask(task.id)
}
disabled={
cancellingDingTalkNotificationTaskId === task.id
}
>
{cancellingDingTalkNotificationTaskId === task.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<X className="mr-2 h-4 w-4" />
)}
取消定时
</Button>
</div>
) : null}
</div>
))
)}
</CardContent>
</Card>
</TabsContent>

View File

@ -60,6 +60,10 @@ import {
getAgentWorkflowCategoryLabels,
getAgentWorkflowBusinessModeSettings,
getAgentWorkflowConstraintSettings,
getAgentWorkflowOfficialCategoryEntries,
getAgentWorkflowOfficialCategoryKeyLabels,
getAgentWorkflowOfficialSecondCategoryKey,
getAgentWorkflowOfficialThirdCategoryKey,
getAgentWorkflowPageFeatureSettings,
normalizeAgentWorkflowBusinessModeSettings,
normalizeAgentWorkflowOptionOrdering,
@ -2093,6 +2097,34 @@ export function AgentWorkflowSettingsClient({
() => getAgentWorkflowBusinessModeSettings(config),
[config]
);
const officialCategoryEntries = useMemo(
() => getAgentWorkflowOfficialCategoryEntries(config),
[config]
);
const officialCategoryKeyLabelMap = useMemo(
() => getAgentWorkflowOfficialCategoryKeyLabels(config),
[config]
);
const officialCategoryChoices = useMemo(
() =>
officialCategoryEntries.flatMap((secondEntry) => {
const secondKey = getAgentWorkflowOfficialSecondCategoryKey(secondEntry.categoryName);
return [
{
key: secondKey,
label: `二级:${secondEntry.categoryName}`,
},
...(secondEntry.thirdCategories || []).map((thirdCategory) => ({
key: getAgentWorkflowOfficialThirdCategoryKey({
secondCategory: secondEntry.categoryName,
thirdCategory,
}),
label: `三级:${secondEntry.categoryName} / ${thirdCategory}`,
})),
].filter((item) => item.key);
}),
[officialCategoryEntries]
);
const workflowImageModelOptions = useMemo(
() =>
(aiModelsData?.models || [])
@ -4202,6 +4234,7 @@ export function AgentWorkflowSettingsClient({
(item) => item.option.key !== option.key && (item.option.children || []).length > 0
);
const specialAllowedKeys = new Set(option.specialModuleAllowedOptionKeys || []);
const availableOfficialCategoryKeys = new Set(option.availableOfficialCategoryKeys || []);
const isExpanded = isWorkflowOptionExpanded(option.key, depth);
const blockedSearchValue = blockedOptionSearches[refKey] || "";
const normalizedBlockedSearchValue = blockedSearchValue.trim().toLowerCase();
@ -4528,6 +4561,91 @@ export function AgentWorkflowSettingsClient({
className="mt-1 scale-90"
/>
</div>
<div className="space-y-3 rounded-lg border border-dashed bg-muted/20 p-3">
<div className="space-y-1">
<Label className="text-xs">品类可用范围</Label>
<p className="text-[11px] leading-5 text-muted-foreground">
可不选;不选代表所有官方品类都可用。选择后,前台用户选中对应二级或三级品类时,当前层级只会展示匹配的卡片。
</p>
</div>
{availableOfficialCategoryKeys.size > 0 ? (
<div className="flex flex-wrap gap-2">
{Array.from(availableOfficialCategoryKeys).map((categoryKey) => (
<span
key={categoryKey}
className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-xs"
>
<span className="max-w-[260px] truncate">
{officialCategoryKeyLabelMap.get(categoryKey) || categoryKey}
</span>
<button
type="button"
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
onClick={() =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
availableOfficialCategoryKeys: (
currentOption.availableOfficialCategoryKeys || []
).filter((item) => item !== categoryKey),
}))
}
aria-label={`移除品类可用范围 ${categoryKey}`}
>
<Trash2 className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="rounded-md border border-dashed bg-background/70 px-3 py-2 text-xs text-muted-foreground">
当前不限制品类,所有官方二级/三级品类都可见。
</div>
)}
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border bg-background/70 p-2">
{officialCategoryChoices.length > 0 ? (
officialCategoryChoices.map((choice) => {
const active = availableOfficialCategoryKeys.has(choice.key);
return (
<button
key={choice.key}
type="button"
className={cn(
"flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2 text-left text-xs transition-colors",
active
? "border-sky-500/50 bg-sky-500/10 text-foreground"
: "border-border/70 bg-background hover:border-foreground/20 hover:bg-muted/60"
)}
onClick={() =>
updateOption(step.key, option.key, (currentOption) => {
const nextKeys = new Set(
currentOption.availableOfficialCategoryKeys || []
);
if (nextKeys.has(choice.key)) {
nextKeys.delete(choice.key);
} else {
nextKeys.add(choice.key);
}
return {
...currentOption,
availableOfficialCategoryKeys: Array.from(nextKeys),
};
})
}
>
<span className="break-words leading-5">{choice.label}</span>
<Badge variant={active ? "default" : "outline"}>
{active ? "已限定" : "可选择"}
</Badge>
</button>
);
})
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
暂无官方品类,请先在基础下拉选项里配置二级/三级品类。
</div>
)}
</div>
</div>
{depth === 1 ? (
<div className="flex items-start justify-between gap-4 rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="space-y-1">
@ -6594,6 +6712,42 @@ export function AgentWorkflowSettingsClient({
/>
</div>
</div>
<div className="rounded-xl border border-sky-500/20 bg-sky-500/5 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<Label className="text-xs">官方二级 / 三级品类</Label>
<p className="text-xs leading-5 text-muted-foreground">
前台“选择品类”会使用这里的官方品类;工作流任意层级卡片的“品类可用范围”也会从这里选择。
</p>
</div>
<Badge variant="outline">{officialCategoryChoices.length} 个可用范围</Badge>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-2">
{officialCategoryEntries.map((entry) => (
<div
key={entry.categoryName}
className="rounded-lg border bg-background/80 p-3"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold">{entry.categoryName}</span>
<Badge variant="secondary">
{(entry.thirdCategories || []).length} 个三级品类
</Badge>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{(entry.thirdCategories || []).map((thirdCategory) => (
<span
key={`${entry.categoryName}-${thirdCategory}`}
className="rounded-full border bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground"
>
{thirdCategory}
</span>
))}
</div>
</div>
))}
</div>
</div>
<div className="rounded-xl border bg-muted/10 p-3">
<div className="flex flex-col gap-2 sm:flex-row">
<Input

View File

@ -9,6 +9,8 @@ import { Input } from "@/components/ui/input";
import Image from "next/image";
import { toast } from "sonner";
import { requestLongTaskResult } from "@/lib/client/long-task";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatBeijingTime } from "@/lib/utils";
type GalleryImage = {
@ -30,6 +32,35 @@ type Pagination = {
totalPages: number;
};
function buildGalleryDownloadFilename(url: string, alt: string) {
const rawUrl = String(url || "").trim();
const rawAlt = String(alt || "").trim();
const safeBase =
rawAlt
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80) || "gallery-image";
let extension = "png";
try {
const pathname = new URL(rawUrl).pathname;
const candidate = pathname.split("/").pop() || "";
const inferred = candidate.split(".").pop()?.trim().toLowerCase();
if (inferred && /^[a-z0-9]{2,5}$/.test(inferred)) {
extension = inferred;
}
} catch {
const candidate = rawUrl.split("/").pop()?.split("?")[0] || "";
const inferred = candidate.split(".").pop()?.trim().toLowerCase();
if (inferred && /^[a-z0-9]{2,5}$/.test(inferred)) {
extension = inferred;
}
}
return `${safeBase}-${Date.now()}.${extension}`;
}
export default function GalleryPage() {
const router = useRouter();
const [images, setImages] = useState<GalleryImage[]>([]);
@ -72,19 +103,41 @@ export default function GalleryPage() {
};
const handleDownload = async (url: string, alt: string) => {
const originalUrl = inferOriginalImageUrl(String(url || "").trim());
if (!originalUrl) {
toast.error("图片地址无效");
return;
}
try {
const response = await fetch(url);
toast.info("正在准备下载...");
const response = await fetch(
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = `${alt || "image"}-${Date.now()}.png`;
link.download = buildGalleryDownloadFilename(originalUrl, alt);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
toast.success("图片已开始下载");
} catch (error) {
console.error("Download failed:", error);
const fallbackLink = document.createElement("a");
fallbackLink.href = originalUrl;
fallbackLink.target = "_blank";
fallbackLink.rel = "noopener noreferrer";
document.body.appendChild(fallbackLink);
fallbackLink.click();
document.body.removeChild(fallbackLink);
toast.error("下载失败,已为你打开原图,可直接另存为");
}
};

View File

@ -0,0 +1,63 @@
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/lib/db";
import { dingTalkNotificationTask } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import { requireAdmin } from "@/lib/middleware/admin";
const paramsSchema = z.object({
id: z.string().uuid("通知任务 ID 不合法"),
});
export async function DELETE(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
try {
await requireAdmin();
const params = paramsSchema.parse(await context.params);
const [task] = await db
.update(dingTalkNotificationTask)
.set({
status: "cancelled",
cancelledAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(dingTalkNotificationTask.id, params.id),
eq(dingTalkNotificationTask.status, "pending"),
),
)
.returning();
if (!task) {
return Response.json(
{ success: false, error: "该任务不存在,或已无法取消" },
{ status: 409 },
);
}
return Response.json({
success: true,
task,
});
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
return Response.json(
{ success: false, error: error.issues[0]?.message || "参数校验失败" },
{ status: 400 },
);
}
return Response.json(
{ success: false, error: "取消钉钉通知任务失败" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,101 @@
import { z } from "zod";
import { ChatSDKError } from "@/lib/errors";
import { listRecentDingTalkNotificationTasks } from "@/lib/jobs/dingtalk-notification-job";
import { requireAdmin } from "@/lib/middleware/admin";
import { db } from "@/lib/db";
import { dingTalkNotificationTask } from "@/lib/db/schema";
const createTaskSchema = z.object({
title: z.string().trim().min(1, "通知标题不能为空").max(100, "通知标题过长"),
content: z
.string()
.trim()
.min(1, "通知内容不能为空")
.max(20000, "通知内容过长"),
scheduledAt: z.string().trim().min(1, "请选择发送时间"),
});
function parseFutureSchedule(value: string) {
const scheduledAt = new Date(value);
if (Number.isNaN(scheduledAt.getTime())) {
throw new Error("定时发送时间格式不正确");
}
if (scheduledAt.getTime() <= Date.now()) {
throw new Error("定时发送时间必须晚于当前时间");
}
return scheduledAt;
}
export async function GET(request: Request) {
try {
await requireAdmin();
const { searchParams } = new URL(request.url);
const limitParam = Number.parseInt(searchParams.get("limit") || "30", 10);
const limit = Number.isFinite(limitParam)
? Math.max(1, Math.min(100, limitParam))
: 30;
const tasks = await listRecentDingTalkNotificationTasks(limit);
return Response.json({ tasks });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
return Response.json(
{ error: "获取钉钉通知任务失败" },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
try {
const session = await requireAdmin();
const body = createTaskSchema.parse(await request.json());
const scheduledAt = parseFutureSchedule(body.scheduledAt);
const [task] = await db
.insert(dingTalkNotificationTask)
.values({
title: body.title,
content: body.content,
status: "pending",
scheduledAt,
createdBy: session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
return Response.json({
success: true,
task,
});
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
return Response.json(
{ success: false, error: error.issues[0]?.message || "参数校验失败" },
{ status: 400 },
);
}
return Response.json(
{
success: false,
error:
error instanceof Error ? error.message : "创建钉钉通知任务失败",
},
{ status: 500 },
);
}
}

View File

@ -15,7 +15,7 @@ const bodySchema = z.object({
.string()
.trim()
.min(1, "通知内容不能为空")
.max(5000, "通知内容过长"),
.max(20000, "通知内容过长"),
});
export async function POST(request: Request) {

View File

@ -55,6 +55,10 @@ import {
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
normalizeDingTalkRobotNotificationConfig,
} from "@/lib/notifications/dingtalk-robot-config";
import {
DINGTALK_NOTIFICATION_DRAFT_CONFIG_KEY,
normalizeDingTalkNotificationDraftConfig,
} from "@/lib/notifications/dingtalk-notification-draft-config";
const settingSchema = z.object({
key: z.string(),
@ -108,6 +112,12 @@ export async function GET(request: Request) {
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === DINGTALK_NOTIFICATION_DRAFT_CONFIG_KEY) {
const value = normalizeDingTalkNotificationDraftConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
return Response.json({ setting });
}
@ -246,6 +256,10 @@ export async function POST(request: Request) {
nextValue = normalizeDingTalkRobotNotificationConfig(value);
}
if (key === DINGTALK_NOTIFICATION_DRAFT_CONFIG_KEY) {
nextValue = normalizeDingTalkNotificationDraftConfig(value);
}
if (key === THIRD_PARTY_SQLSERVER_CONFIG_KEY) {
const existingSetting = await getSystemSetting({
key: THIRD_PARTY_SQLSERVER_CONFIG_KEY,

View File

@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import {
getDueDingTalkNotificationTasks,
runDingTalkNotificationJob,
} from "@/lib/jobs/dingtalk-notification-job";
function verifyAuthToken(request: Request): boolean {
const authHeader = request.headers.get("authorization");
const token = authHeader?.replace("Bearer ", "");
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret) {
console.warn(
"[DingTalkNotificationJob] CRON_SECRET 未配置,当前允许所有请求",
);
return true;
}
return token === cronSecret;
}
export async function GET(request: Request) {
try {
if (!verifyAuthToken(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const tasks = await getDueDingTalkNotificationTasks(20);
return NextResponse.json({
dueCount: tasks.length,
tasks: tasks.map((task) => ({
id: task.id,
title: task.title,
status: task.status,
scheduledAt: task.scheduledAt,
createdAt: task.createdAt,
})),
timestamp: new Date().toISOString(),
});
} catch (error) {
return NextResponse.json(
{
success: false,
error:
error instanceof Error ? error.message : "查询钉钉通知任务失败",
timestamp: new Date().toISOString(),
},
{ status: 500 },
);
}
}
export async function POST(request: Request) {
try {
if (!verifyAuthToken(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const result = await runDingTalkNotificationJob();
return NextResponse.json({
success: true,
result,
timestamp: new Date().toISOString(),
});
} catch (error) {
return NextResponse.json(
{
success: false,
error:
error instanceof Error ? error.message : "执行钉钉通知任务失败",
timestamp: new Date().toISOString(),
},
{ status: 500 },
);
}
}

View File

@ -1298,6 +1298,10 @@ const agentWorkflowOptionSchema: z.ZodTypeAny = z.lazy(() =>
.array(z.string().trim().min(1).max(64))
.max(AGENT_WORKFLOW_BLOCKED_OPTION_KEYS_MAX)
.optional(),
availableOfficialCategoryKeys: z
.array(z.string().trim().min(1).max(160))
.max(AGENT_WORKFLOW_INPUT_OPTIONS_MAX_ITEMS)
.optional(),
specialModuleEnabled: z.boolean().optional(),
specialModuleExclusionEnabled: z.boolean().optional(),
specialModuleAllowedOptionKeys: z
@ -1453,6 +1457,28 @@ const updateSchema = z.object({
.array(z.string().trim().min(1).max(64))
.max(AGENT_WORKFLOW_INPUT_OPTIONS_MAX_ITEMS)
.optional(),
officialCategoryEntries: z
.array(
z.object({
categoryName: z.string().trim().min(1).max(64),
thirdCategories: z
.array(z.string().trim().min(1).max(64))
.max(AGENT_WORKFLOW_INPUT_OPTIONS_MAX_ITEMS)
.optional(),
thirdCategoryEntries: z
.array(
z.object({
categoryName: z.string().trim().min(1).max(64),
sortOrder: z.number().min(0).max(999).optional(),
})
)
.max(AGENT_WORKFLOW_INPUT_OPTIONS_MAX_ITEMS)
.optional(),
sortOrder: z.number().min(0).max(999).optional(),
})
)
.max(AGENT_WORKFLOW_INPUT_OPTIONS_MAX_ITEMS)
.optional(),
priceBands: z
.array(z.string().trim().min(1).max(64))
.max(AGENT_WORKFLOW_INPUT_OPTIONS_MAX_ITEMS)

View File

@ -962,6 +962,8 @@ function hasMeaningfulWorkflowState(
String(inputs?.secondarySku || "").trim() ||
String(inputs?.brand || "").trim() ||
String(inputs?.store || "").trim() ||
String(inputs?.officialSecondCategory || "").trim() ||
String(inputs?.officialThirdCategory || "").trim() ||
String(inputs?.thirdCategory || "").trim() ||
String(inputs?.fourthCategory || "").trim() ||
String(inputs?.fifthCategory || "").trim() ||
@ -1664,6 +1666,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
secondarySku: "",
brand: "",
store: "",
officialSecondCategory: "",
officialThirdCategory: "",
thirdCategory: "",
fourthCategory: "",
fifthCategory: "",
@ -2716,6 +2720,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
secondarySku: workflowInputs.secondarySku.trim(),
brand: workflowInputs.brand.trim(),
store: workflowInputs.store.trim(),
officialSecondCategory: workflowInputs.officialSecondCategory.trim(),
officialThirdCategory: workflowInputs.officialThirdCategory.trim(),
thirdCategory: workflowInputs.thirdCategory.trim(),
fourthCategory: workflowInputs.fourthCategory.trim(),
fifthCategory: workflowInputs.fifthCategory.trim(),
@ -2794,6 +2800,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
secondarySku: "",
brand: "",
store: "",
officialSecondCategory: "",
officialThirdCategory: "",
thirdCategory: "",
fourthCategory: "",
fifthCategory: "",
@ -2843,6 +2851,12 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
).trim(),
brand: String(persistedWorkflow.inputs?.brand || "").trim(),
store: String(persistedWorkflow.inputs?.store || "").trim(),
officialSecondCategory: String(
persistedWorkflow.inputs?.officialSecondCategory || ""
).trim(),
officialThirdCategory: String(
persistedWorkflow.inputs?.officialThirdCategory || ""
).trim(),
thirdCategory: String(
persistedWorkflow.inputs?.thirdCategory || ""
).trim(),
@ -3215,6 +3229,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
secondarySku: workflowInputs.secondarySku.trim(),
brand: workflowInputs.brand.trim(),
store: workflowInputs.store.trim(),
officialSecondCategory: workflowInputs.officialSecondCategory.trim(),
officialThirdCategory: workflowInputs.officialThirdCategory.trim(),
thirdCategory: workflowInputs.thirdCategory.trim(),
fourthCategory: workflowInputs.fourthCategory.trim(),
fifthCategory: workflowInputs.fifthCategory.trim(),
@ -3283,6 +3299,12 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
).trim(),
brand: String(persistedWorkflow.inputs?.brand || "").trim(),
store: String(persistedWorkflow.inputs?.store || "").trim(),
officialSecondCategory: String(
persistedWorkflow.inputs?.officialSecondCategory || ""
).trim(),
officialThirdCategory: String(
persistedWorkflow.inputs?.officialThirdCategory || ""
).trim(),
thirdCategory: String(
persistedWorkflow.inputs?.thirdCategory || ""
).trim(),
@ -3362,6 +3384,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
secondarySku: String(parsed.inputs?.secondarySku || "").trim(),
brand: String(parsed.inputs?.brand || "").trim(),
store: String(parsed.inputs?.store || "").trim(),
officialSecondCategory: String(parsed.inputs?.officialSecondCategory || "").trim(),
officialThirdCategory: String(parsed.inputs?.officialThirdCategory || "").trim(),
thirdCategory: String(parsed.inputs?.thirdCategory || "").trim(),
fourthCategory: String(parsed.inputs?.fourthCategory || "").trim(),
fifthCategory: String(parsed.inputs?.fifthCategory || "").trim(),
@ -8790,9 +8814,6 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
onInputsChange={handleWorkflowInputsChange}
onMaterialsChange={handleWorkflowMaterialsChange}
onSelectionsChange={handleWorkflowSelectionsChange}
psdTemplateCategoryTagIndex={
siteSettingsData?.agentPsdTemplateCategoryTagIndex
}
selections={sanitizedWorkflowSelections}
showBrandStoreSection={false}
/>
@ -8803,7 +8824,6 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
handleWorkflowSelectionsChange,
resolvedWorkflowUiConfig,
sanitizedWorkflowSelections,
siteSettingsData?.agentPsdTemplateCategoryTagIndex,
workflowInputs,
workflowMaterials,
]
@ -8837,9 +8857,6 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
config={resolvedWorkflowUiConfig}
inputs={workflowInputs}
onInputsChange={handleWorkflowInputsChange}
psdTemplateCategoryTagIndex={
siteSettingsData?.agentPsdTemplateCategoryTagIndex
}
theme="dark"
/>
}

View File

@ -95,6 +95,7 @@ import {
import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId,
resolveThreeDModelBillingId,
resolveThreeDModelDisplayName,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
@ -821,6 +822,8 @@ export default function CanvasModel3DPage() {
);
const pointsEnabled = pointsSystemConfig.enabled === true;
const isHighVip = membershipSummary?.isHighVip === true;
const publicModelAliasConfig =
modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG;
const selectedBatchImageCount = useMemo(
() => batchImageSlots.filter((slot) => Boolean(slot.file)).length,
[batchImageSlots]
@ -868,9 +871,17 @@ export default function CanvasModel3DPage() {
}
return "提交成功后扣除";
})();
const worldBillingModelId = useMemo(
() =>
resolveThreeDModelBillingId(
worldModel,
publicModelAliasConfig.worldModels
),
[publicModelAliasConfig.worldModels, worldModel]
);
const worldFeatureCost = useMemo(
() => get3DWorldCreditCost(pointsSystemConfig, worldModel),
[pointsSystemConfig, worldModel]
() => get3DWorldCreditCost(pointsSystemConfig, worldBillingModelId),
[pointsSystemConfig, worldBillingModelId]
);
const worldFeatureCostLabel = formatFeatureCostLabel({
cost: worldFeatureCost,
@ -882,15 +893,6 @@ export default function CanvasModel3DPage() {
: isHighVip
? "VIP 用户本次不扣积分"
: "按当前生成质量扣除";
const getWorldModelCostLabel = useCallback(
(targetWorldModel: string) =>
formatFeatureCostLabel({
cost: get3DWorldCreditCost(pointsSystemConfig, targetWorldModel),
isHighVip,
pointsEnabled,
}),
[isHighVip, pointsEnabled, pointsSystemConfig]
);
const refineCostLabel = formatFeatureCostLabel({
cost: get3DTaskCreditCost(pointsSystemConfig, { taskType: "refine_model" }),
isHighVip,
@ -937,8 +939,6 @@ export default function CanvasModel3DPage() {
// Display logo logic (prefer dark mode logo since we are in dark theme)
const displayLogo =
siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo;
const publicModelAliasConfig =
modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG;
const tripoModelOptions = useMemo(
() => {
const enabledEntries = publicModelAliasConfig.tripoModels.filter(
@ -3319,14 +3319,10 @@ export default function CanvasModel3DPage() {
<SelectItem
className="focus:bg-zinc-800 focus:text-white"
key={item.id}
textValue={item.displayName}
value={item.id}
>
<span className="flex w-full items-center justify-between gap-3">
<span>{item.displayName}</span>
<span className="text-[10px] text-yellow-300">
{getWorldModelCostLabel(item.id)}
</span>
</span>
{item.displayName}
</SelectItem>
))}
</SelectContent>

View File

@ -37,6 +37,8 @@ export function AgentPageDark({
secondarySku: "",
brand: "",
store: "",
officialSecondCategory: "",
officialThirdCategory: "",
thirdCategory: "",
fourthCategory: "",
fifthCategory: "",

View File

@ -35,6 +35,8 @@ export interface AgentWorkflowInputs {
secondarySku: string;
brand: string;
store: string;
officialSecondCategory: string;
officialThirdCategory: string;
thirdCategory: string;
fourthCategory: string;
fifthCategory: string;
@ -232,6 +234,8 @@ export function AgentWorkflowPanel({
secondarySku: "",
brand: "",
store: "",
officialSecondCategory: "",
officialThirdCategory: "",
thirdCategory: "",
fourthCategory: "",
fifthCategory: "",

View File

@ -36,6 +36,8 @@ export interface AgentWorkflowInputs {
secondarySku: string;
brand: string;
store: string;
officialSecondCategory: string;
officialThirdCategory: string;
thirdCategory: string;
fourthCategory: string;
fifthCategory: string;
@ -319,7 +321,7 @@ export function AgentWorkflowPanelDark({
onReset();
return;
}
onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "", thirdCategory: "", fourthCategory: "", fifthCategory: "", priceBand: "" });
onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "", officialSecondCategory: "", officialThirdCategory: "", thirdCategory: "", fourthCategory: "", fifthCategory: "", priceBand: "" });
onMaterialsChange({
primary: createEmptyAgentWorkflowMaterialSelection(),
secondary: createEmptyAgentWorkflowMaterialSelection(),

View File

@ -13,11 +13,9 @@ import {
} from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { motion } from "framer-motion";
import { ArrowRight, Check, Copy, Sparkles, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { ArrowRight, Check, Sparkles, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
getAgentWorkflowCategoryLabels,
@ -27,6 +25,8 @@ import {
getAgentWorkflowBlockedOptionKeysForSelections,
getAgentWorkflowBrandStoreEntries,
getAgentWorkflowPageFeatureSettings,
getAgentWorkflowOfficialSecondCategoryOptions,
getAgentWorkflowOfficialThirdCategoryOptions,
getAgentWorkflowFourthCategoryOptionsByBrandStoreThirdCategory,
getAgentWorkflowPriceBandOptions,
getAgentWorkflowThirdCategoryOptionsByBrandStore,
@ -36,20 +36,15 @@ import {
getEnabledAgentWorkflowStepTree,
getRenderableAgentWorkflowOptionTree,
getRenderableAgentWorkflowStepTree,
filterAgentWorkflowStepTreeByOfficialCategory,
isAgentWorkflowStepSelectionComplete,
serializeAgentWorkflowSelectionPath,
} from "@/lib/agents/workflow-ui-config";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
import {
buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
} from "@/lib/agents/workflow-composer";
import {
getAgentPsdTemplateCategoryTagsByBrand,
normalizeAgentPsdTemplateCategoryTagIndex,
type AgentPsdTemplateCategoryTagIndex,
} from "@/lib/agents/psd-template-category-tag-config";
import type {
AgentWorkflowMaterialSelections,
AgentWorkflowShowcaseConfig,
@ -62,6 +57,8 @@ export interface AgentWorkflowInputs {
secondarySku: string;
brand: string;
store: string;
officialSecondCategory: string;
officialThirdCategory: string;
thirdCategory: string;
fourthCategory: string;
fifthCategory: string;
@ -72,7 +69,6 @@ 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>>;
@ -93,7 +89,6 @@ interface AgentWorkflowBrandStorePanelProps {
config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputs;
onInputsChange: (next: AgentWorkflowInputs) => void;
psdTemplateCategoryTagIndex?: AgentPsdTemplateCategoryTagIndex | null;
theme?: "light" | "dark";
title?: string;
description?: string;
@ -109,7 +104,6 @@ const VIRTUAL_OPTION_GRID_MIN_CARD_HEIGHT = 122;
const VIRTUAL_OPTION_GRID_THRESHOLD = 18;
interface WorkflowPanelDerivedState {
composerContext: ReturnType<typeof buildAgentWorkflowComposerContext>;
progress: ReturnType<typeof getAgentWorkflowProgress>;
}
@ -138,17 +132,9 @@ function scheduleWorkflowIdleTask(callback: () => void, timeout = 450) {
function buildWorkflowPanelDerivedState(params: {
config: AgentWorkflowUiConfig;
inputs: AgentWorkflowInputs;
materials: AgentWorkflowMaterialSelections;
selections: WorkflowSelectionState;
}): WorkflowPanelDerivedState {
return {
composerContext: buildAgentWorkflowComposerContext({
config: params.config,
inputs: params.inputs,
materials: params.materials,
selections: params.selections,
}),
progress: getAgentWorkflowProgress({
config: params.config,
selections: params.selections,
@ -484,7 +470,6 @@ export function AgentWorkflowBrandStorePanel({
config,
inputs,
onInputsChange,
psdTemplateCategoryTagIndex,
theme = "dark",
title = "品牌层级选择",
description = "先选择品牌、店铺、分类层级和价格带,后续品牌模特与风格步骤会按这里的选择联动。",
@ -497,7 +482,6 @@ export function AgentWorkflowBrandStorePanel({
const secondCategoryLabel = categoryLabels.secondCategory;
const thirdCategoryLabel = categoryLabels.thirdCategory;
const fourthCategoryLabel = categoryLabels.fourthCategory;
const fifthCategoryLabel = categoryLabels.fifthCategory;
const resolvedTitle =
title === "品牌层级选择" ? `${firstCategoryLabel}层级选择` : title;
const resolvedDescription =
@ -535,18 +519,17 @@ export function AgentWorkflowBrandStorePanel({
}),
[config, inputs.brand, inputs.store, inputs.thirdCategory]
);
const normalizedPsdTemplateCategoryTagIndex = useMemo(
() =>
normalizeAgentPsdTemplateCategoryTagIndex(psdTemplateCategoryTagIndex),
[psdTemplateCategoryTagIndex]
const officialSecondCategoryOptions = useMemo(
() => getAgentWorkflowOfficialSecondCategoryOptions(config),
[config]
);
const fifthCategoryOptions = useMemo(
const officialThirdCategoryOptions = useMemo(
() =>
getAgentPsdTemplateCategoryTagsByBrand(
normalizedPsdTemplateCategoryTagIndex,
inputs.brand
),
[inputs.brand, normalizedPsdTemplateCategoryTagIndex]
getAgentWorkflowOfficialThirdCategoryOptions({
config,
secondCategory: inputs.officialSecondCategory,
}),
[config, inputs.officialSecondCategory]
);
const priceBandOptions = useMemo(
() => getAgentWorkflowPriceBandOptions(config),
@ -603,28 +586,55 @@ export function AgentWorkflowBrandStorePanel({
}
if (
nextInputs.fifthCategory &&
!fifthCategoryOptions.some(
nextInputs.officialSecondCategory &&
!officialSecondCategoryOptions.some(
(option) =>
option.trim().toLowerCase() === nextInputs.fifthCategory.trim().toLowerCase()
option.trim().toLowerCase() === nextInputs.officialSecondCategory.trim().toLowerCase()
)
) {
nextInputs = {
...nextInputs,
officialSecondCategory: "",
officialThirdCategory: "",
fifthCategory: "",
};
changed = true;
}
if (
nextInputs.officialThirdCategory &&
!officialThirdCategoryOptions.some(
(option) =>
option.trim().toLowerCase() === nextInputs.officialThirdCategory.trim().toLowerCase()
)
) {
nextInputs = {
...nextInputs,
officialThirdCategory: "",
fifthCategory: "",
};
changed = true;
} else if (
nextInputs.officialThirdCategory &&
nextInputs.fifthCategory !== nextInputs.officialThirdCategory
) {
nextInputs = {
...nextInputs,
fifthCategory: nextInputs.officialThirdCategory,
};
changed = true;
}
if (changed) {
onInputsChange(nextInputs);
}
}, [
fifthCategoryOptions,
hasThirdCategoryLevel,
hasFourthCategoryLevel,
inputs,
onInputsChange,
officialSecondCategoryOptions,
officialThirdCategoryOptions,
]);
return (
@ -663,7 +673,7 @@ export function AgentWorkflowBrandStorePanel({
? inputs.thirdCategory
: "",
fourthCategory: "",
fifthCategory: "",
fifthCategory: inputs.officialThirdCategory || "",
})
}
/>
@ -738,18 +748,41 @@ export function AgentWorkflowBrandStorePanel({
/>
) : null}
<SearchableSelect
value={inputs.fifthCategory}
options={fifthCategoryOptions}
placeholder={`选择${fifthCategoryLabel}`}
searchPlaceholder={`输入${fifthCategoryLabel}筛选`}
value={inputs.officialSecondCategory}
options={officialSecondCategoryOptions}
placeholder="选择二级品类"
searchPlaceholder="输入二级品类筛选"
emptyText="暂无官方二级品类"
theme={theme}
disabled={officialSecondCategoryOptions.length === 0}
onValueChange={(value) =>
onInputsChange({
...inputs,
officialSecondCategory: value,
officialThirdCategory: "",
fifthCategory: "",
})
}
/>
<SearchableSelect
value={inputs.officialThirdCategory}
options={officialThirdCategoryOptions}
placeholder="选择三级品类"
searchPlaceholder="输入三级品类筛选"
emptyText={
inputs.brand
? `当前${firstCategoryLabel}下暂无${fifthCategoryLabel}`
: `请先选择${firstCategoryLabel}`
inputs.officialSecondCategory
? "当前二级品类下暂无三级品类"
: "请先选择二级品类"
}
theme={theme}
disabled={!inputs.brand || fifthCategoryOptions.length === 0}
onValueChange={(value) => onInputsChange({ ...inputs, fifthCategory: value })}
disabled={!inputs.officialSecondCategory || officialThirdCategoryOptions.length === 0}
onValueChange={(value) =>
onInputsChange({
...inputs,
officialThirdCategory: value,
fifthCategory: value,
})
}
/>
<SearchableSelect
value={inputs.priceBand}
@ -770,9 +803,6 @@ function AgentWorkflowPanelDarkV2Component({
config,
inputs,
onInputsChange,
psdTemplateCategoryTagIndex,
materials,
onMaterialsChange,
selections,
onSelectionsChange,
onNextStep,
@ -793,18 +823,28 @@ function AgentWorkflowPanelDarkV2Component({
const secondCategoryLabel = categoryLabels.secondCategory;
const resolvedConfig = useMemo(() => {
const source = config || defaultAgentWorkflowUiConfig;
const filteredSteps = filterAgentWorkflowStepTreeByOfficialCategory({
steps: source.steps || [],
officialSecondCategory: inputs.officialSecondCategory,
officialThirdCategory: inputs.officialThirdCategory,
});
return {
...source,
steps: getEnabledAgentWorkflowStepTree(source.steps || []),
steps: getEnabledAgentWorkflowStepTree(filteredSteps),
};
}, [config]);
}, [config, inputs.officialSecondCategory, inputs.officialThirdCategory]);
const displayConfig = useMemo(() => {
const source = config || defaultAgentWorkflowUiConfig;
const filteredSteps = filterAgentWorkflowStepTreeByOfficialCategory({
steps: source.steps || [],
officialSecondCategory: inputs.officialSecondCategory,
officialThirdCategory: inputs.officialThirdCategory,
});
return {
...source,
steps: getRenderableAgentWorkflowStepTree(source.steps || []),
steps: getRenderableAgentWorkflowStepTree(filteredSteps),
};
}, [config]);
}, [config, inputs.officialSecondCategory, inputs.officialThirdCategory]);
const pageFeatureSettings = useMemo(
() => getAgentWorkflowPageFeatureSettings(config),
[config]
@ -882,8 +922,6 @@ function AgentWorkflowPanelDarkV2Component({
useState<WorkflowPanelDerivedState>(() =>
buildWorkflowPanelDerivedState({
config: resolvedConfig,
inputs,
materials,
selections: deferredSelections,
})
);
@ -897,8 +935,6 @@ function AgentWorkflowPanelDarkV2Component({
return scheduleWorkflowIdleTask(() => {
const nextDerivedState = buildWorkflowPanelDerivedState({
config: resolvedConfig,
inputs,
materials,
selections: deferredSelections,
});
@ -911,9 +947,8 @@ function AgentWorkflowPanelDarkV2Component({
});
});
},
[deferredSelections, inputs, materials, resolvedConfig]
[deferredSelections, resolvedConfig]
);
const composerContext = workflowDerivedState.composerContext;
const progress = workflowDerivedState.progress;
const stepSelectionSummaryMap = useMemo(() => {
const entries = flattenAgentWorkflowSteps(resolvedConfig.steps).map(({ step }) => [
@ -1187,19 +1222,6 @@ function AgentWorkflowPanelDarkV2Component({
);
};
const handleCopyPrompt = async () => {
if (!composerContext?.promptText) {
toast.error("当前还没有可复制的 Prompt");
return;
}
try {
await navigator.clipboard.writeText(composerContext.promptText);
toast.success("Prompt 已复制");
} catch {
toast.error("复制失败");
}
};
const handleEmbeddedOptionSelect = useCallback(
(optionKey: string) => {
if (!activeEmbeddedOptionLevel || !activeEmbeddedStepEntry) {
@ -1275,7 +1297,6 @@ function AgentWorkflowPanelDarkV2Component({
config={config}
inputs={inputs}
onInputsChange={onInputsChange}
psdTemplateCategoryTagIndex={psdTemplateCategoryTagIndex}
theme="dark"
/>
</motion.section>
@ -1549,33 +1570,6 @@ function AgentWorkflowPanelDarkV2Component({
</motion.section>
))
)}
{/* Prompt 预览 */}
<motion.section
className="rounded-2xl border border-white/10 bg-white/[0.035] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/80">Prompt 预览</h3>
<Button
variant="ghost"
size="sm"
onClick={handleCopyPrompt}
className="h-8 gap-1.5 text-xs text-white/60 hover:bg-white/10 hover:text-white"
>
<Copy className="h-3.5 w-3.5" />
复制
</Button>
</div>
<Textarea
value={composerContext?.promptText || ""}
readOnly
rows={6}
placeholder="选择工作流后,这里会自动生成结构化 Prompt"
className="resize-none rounded-xl border-white/12 bg-black/20 text-sm text-white/80 placeholder:text-white/20 focus:border-white/15"
/>
</motion.section>
</div>
);

View File

@ -36,6 +36,8 @@ export interface AgentWorkflowInputs {
secondarySku: string;
brand: string;
store: string;
officialSecondCategory: string;
officialThirdCategory: string;
thirdCategory: string;
fourthCategory: string;
fifthCategory: string;
@ -340,7 +342,7 @@ export function AgentWorkflowPanelNew({
onReset();
return;
}
onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "", thirdCategory: "", fourthCategory: "", fifthCategory: "", priceBand: "" });
onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "", officialSecondCategory: "", officialThirdCategory: "", thirdCategory: "", fourthCategory: "", fifthCategory: "", priceBand: "" });
onMaterialsChange({
primary: createEmptyAgentWorkflowMaterialSelection(),
secondary: createEmptyAgentWorkflowMaterialSelection(),

View File

@ -2084,6 +2084,16 @@ export function CanvasChatPanel({
url: string;
alt?: string;
} | null>(null);
const fullscreenPreviewSrc = useMemo(() => {
if (!fullscreenPreview?.url) return "";
const originalUrl = inferOriginalImageUrl(fullscreenPreview.url);
return buildImageProxyUrl(originalUrl || fullscreenPreview.url, {
delivery: "proxy",
width: 2400,
quality: 90,
format: "webp",
});
}, [fullscreenPreview]);
// 🆕 图片参数状态
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
@ -2461,9 +2471,12 @@ export function CanvasChatPanel({
return;
}
const filename = buildDownloadFilename(raw);
const originalUrl = inferOriginalImageUrl(raw) || raw;
const filename = buildDownloadFilename(originalUrl);
try {
const response = await fetch(raw);
const response = await fetch(
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
@ -2475,11 +2488,11 @@ export function CanvasChatPanel({
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
return;
} catch {
const link = document.createElement("a");
link.href = raw;
link.href = originalUrl;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
@ -10120,9 +10133,11 @@ export function CanvasChatPanel({
</button>
<img
src={fullscreenPreview.url}
src={fullscreenPreviewSrc}
alt={fullscreenPreview.alt || "图片预览"}
className="max-w-[95vw] max-h-[95vh] object-contain"
decoding="async"
draggable={false}
onClick={(e) => e.stopPropagation()}
/>

View File

@ -3,6 +3,7 @@
import {
CheckCircle2,
ChevronDown,
History as HistoryIcon,
ImageIcon,
Loader2,
ScanFace,
@ -10,9 +11,11 @@ import {
Settings2,
SlidersHorizontal,
Sparkles,
X,
} from "lucide-react";
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
@ -195,11 +198,140 @@ const SECTION_EYEBROW_CLASS =
"inline-flex items-center gap-1.5 rounded-full border border-[#ebebeb] bg-[#fafafa] px-2.5 py-1 font-semibold text-[10px] tracking-[0.14em] text-[#666666] uppercase";
const INNER_PANEL_CLASS =
"rounded-[22px] border border-[#efefef] bg-[#fbfbfb] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.92)]";
const ENHANCE_PRO_HISTORY_STORAGE_KEY = "ai-chatbot:enhance-pro-history:v1";
const ENHANCE_PRO_HISTORY_LIMIT = 4;
const ENHANCE_PRO_HISTORY_TOTAL_LIMIT = 48;
type EnhanceProHistoryEntry = {
id: string;
scopeKey: string;
createdAt: number;
prompt: string;
model: string;
image: EnhanceProImageResult;
};
function clampNumber(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function hashEnhanceHistoryValue(value: string) {
let hash = 2_166_136_261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return (hash >>> 0).toString(36);
}
function createEnhanceProHistoryScopeKey(value?: string | null) {
const raw = String(value || "").trim();
return raw
? `enhance-scope-${hashEnhanceHistoryValue(raw)}`
: "enhance-scope-global";
}
function normalizeEnhanceProHistoryEntry(
value: unknown
): EnhanceProHistoryEntry | null {
if (!value || typeof value !== "object") return null;
const raw = value as Partial<EnhanceProHistoryEntry> & {
image?: Partial<EnhanceProImageResult>;
};
const originalUrl = String(raw.image?.originalUrl || "").trim();
if (!originalUrl) return null;
return {
id:
String(raw.id || "").trim() ||
`enhance-history-${hashEnhanceHistoryValue(originalUrl)}`,
scopeKey:
String(raw.scopeKey || "").trim() || createEnhanceProHistoryScopeKey(),
createdAt: Number.isFinite(Number(raw.createdAt))
? Number(raw.createdAt)
: Date.now(),
prompt: String(raw.prompt || "").trim(),
model: String(raw.model || "").trim(),
image: {
originalUrl,
thumbnailUrl: String(raw.image?.thumbnailUrl || originalUrl).trim(),
width: Number.isFinite(Number(raw.image?.width))
? Number(raw.image?.width)
: undefined,
height: Number.isFinite(Number(raw.image?.height))
? Number(raw.image?.height)
: undefined,
},
};
}
function sortEnhanceHistoryEntries(entries: EnhanceProHistoryEntry[]) {
return [...entries].sort((left, right) => right.createdAt - left.createdAt);
}
function dedupeEnhanceHistoryEntries(entries: EnhanceProHistoryEntry[]) {
const seen = new Set<string>();
const nextEntries: EnhanceProHistoryEntry[] = [];
for (const entry of sortEnhanceHistoryEntries(entries)) {
const originalUrl = String(entry.image.originalUrl || "").trim();
const dedupeKey = `${entry.scopeKey}::${originalUrl}`;
if (!originalUrl || seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
nextEntries.push(entry);
}
return nextEntries;
}
function readPersistedEnhanceProHistory() {
if (typeof window === "undefined") return [] as EnhanceProHistoryEntry[];
try {
const raw = window.localStorage.getItem(ENHANCE_PRO_HISTORY_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return dedupeEnhanceHistoryEntries(
parsed
.map((entry) => normalizeEnhanceProHistoryEntry(entry))
.filter((entry): entry is EnhanceProHistoryEntry => Boolean(entry))
).slice(0, ENHANCE_PRO_HISTORY_TOTAL_LIMIT);
} catch {
return [];
}
}
function writePersistedEnhanceProHistory(entries: EnhanceProHistoryEntry[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(
ENHANCE_PRO_HISTORY_STORAGE_KEY,
JSON.stringify(entries)
);
}
function mergePersistedEnhanceProHistory(
currentEntries: EnhanceProHistoryEntry[],
scopeKey: string,
nextEntries: EnhanceProHistoryEntry[]
) {
const currentScopeEntries = dedupeEnhanceHistoryEntries([
...nextEntries.filter((entry) => entry.scopeKey === scopeKey),
...currentEntries.filter((entry) => entry.scopeKey === scopeKey),
]).slice(0, ENHANCE_PRO_HISTORY_LIMIT);
const otherScopeEntries = dedupeEnhanceHistoryEntries(
currentEntries.filter((entry) => entry.scopeKey !== scopeKey)
);
return sortEnhanceHistoryEntries([
...currentScopeEntries,
...otherScopeEntries,
]).slice(0, ENHANCE_PRO_HISTORY_TOTAL_LIMIT);
}
function fitDimensions(width: number, height: number, maxOutputEdge: number) {
const safeWidth = Math.max(1, Math.round(width || 1024));
const safeHeight = Math.max(1, Math.round(height || 1024));
@ -405,6 +537,7 @@ function buildEnhanceSourcePayload(
interface EnhanceProWorkspaceProps {
sourceImageUrl?: string | null;
historyScopeKey?: string | null;
onRequestAddImage?: () => void;
onApplyResult: (payload: {
imageUrl: string;
@ -419,6 +552,7 @@ interface EnhanceProWorkspaceProps {
export function EnhanceProWorkspace({
sourceImageUrl,
historyScopeKey,
onRequestAddImage,
onApplyResult,
}: EnhanceProWorkspaceProps) {
@ -437,6 +571,9 @@ export function EnhanceProWorkspace({
const [adjustmentsOpen, setAdjustmentsOpen] = useState(true);
const [faceOpen, setFaceOpen] = useState(true);
const [comparePosition, setComparePosition] = useState(50);
const [persistedHistoryEntries, setPersistedHistoryEntries] = useState<
EnhanceProHistoryEntry[]
>(() => readPersistedEnhanceProHistory());
const compareDragPointerIdRef = useRef<number | null>(null);
const requestIdRef = useRef<string | null>(null);
const requestSignatureRef = useRef<string | null>(null);
@ -467,6 +604,27 @@ export function EnhanceProWorkspace({
selectedResultIndex >= 0
? generatedImages[selectedResultIndex] || null
: null;
const resolvedHistoryScopeInput = useMemo(
() => String(historyScopeKey || sourceImageUrl || "").trim(),
[historyScopeKey, sourceImageUrl]
);
const resolvedHistoryScopeKey = useMemo(
() => createEnhanceProHistoryScopeKey(resolvedHistoryScopeInput),
[resolvedHistoryScopeInput]
);
const visibleHistoryEntries = useMemo(() => {
const orderedEntries = sortEnhanceHistoryEntries(persistedHistoryEntries);
if (!resolvedHistoryScopeInput) {
return orderedEntries.slice(0, ENHANCE_PRO_HISTORY_LIMIT);
}
return orderedEntries
.filter((entry) => entry.scopeKey === resolvedHistoryScopeKey)
.slice(0, ENHANCE_PRO_HISTORY_LIMIT);
}, [
persistedHistoryEntries,
resolvedHistoryScopeInput,
resolvedHistoryScopeKey,
]);
const activePreviewUrl =
selectedGeneratedImage?.originalUrl || sourceImageUrl || "";
const showComparisonPreview = Boolean(
@ -560,6 +718,19 @@ export function EnhanceProWorkspace({
}
}, [formState.face_enhancement]);
const persistHistoryEntries = useCallback(
(updater: (entries: EnhanceProHistoryEntry[]) => EnhanceProHistoryEntry[]) => {
setPersistedHistoryEntries((currentEntries) => {
const nextEntries = dedupeEnhanceHistoryEntries(
updater(currentEntries)
).slice(0, ENHANCE_PRO_HISTORY_TOTAL_LIMIT);
writePersistedEnhanceProHistory(nextEntries);
return nextEntries;
});
},
[]
);
const handleNumericField = <K extends keyof FormState>(
key: K,
nextValue: number
@ -655,12 +826,34 @@ export function EnhanceProWorkspace({
throw new Error(result.error || "图像增强 Pro 失败");
}
setGeneratedImages(result.data.images);
const nextImages = result.data.images;
const historyCreatedAt = Date.now();
setGeneratedImages(nextImages);
setSelectedResultIndex(0);
persistHistoryEntries((currentEntries) =>
mergePersistedEnhanceProHistory(
currentEntries,
resolvedHistoryScopeKey,
nextImages.map((image, index) => ({
id: [
resolvedHistoryScopeKey,
historyCreatedAt.toString(36),
String(index),
hashEnhanceHistoryValue(image.originalUrl || image.thumbnailUrl),
].join("-"),
scopeKey: resolvedHistoryScopeKey,
createdAt: historyCreatedAt + index,
prompt: requestParams.prompt || "",
model: requestParams.model || config.defaultModel,
image,
}))
)
);
requestIdRef.current = null;
requestSignatureRef.current = null;
toast.success(
`图像增强 Pro 完成,已生成 ${result.data.images.length} 张结果`
`图像增强 Pro 完成,已生成 ${nextImages.length} 张结果`
);
} catch (error) {
const message =
@ -676,6 +869,25 @@ export function EnhanceProWorkspace({
}
};
const handleLoadHistoryEntry = useCallback((entry: EnhanceProHistoryEntry) => {
requestIdRef.current = null;
requestSignatureRef.current = null;
setGeneratedImages([entry.image]);
setSelectedResultIndex(0);
setLastErrorMessage("");
toast.success("已加载历史增强图片");
}, []);
const handleDeleteHistoryEntry = useCallback(
(entryId: string) => {
persistHistoryEntries((currentEntries) =>
currentEntries.filter((entry) => entry.id !== entryId)
);
toast.success("已删除历史增强图片");
},
[persistHistoryEntries]
);
const handleApply = () => {
if (!selectedGeneratedImage?.originalUrl) {
toast.error("请先生成结果并选择一张增强图");
@ -967,6 +1179,116 @@ export function EnhanceProWorkspace({
</div>
) : null}
{visibleHistoryEntries.length > 0 ? (
<div
className={cn(
PANEL_SHELL_CLASS,
SIDEBAR_SECTION_CARD_CLASS,
"space-y-3 px-3.5 py-3.5"
)}
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[18px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
<HistoryIcon className="h-5 w-5" />
</div>
<div className="min-w-0 space-y-1">
<div className={SECTION_EYEBROW_CLASS}>Recent Enhancements</div>
<div className={PANEL_SECTION_TITLE_CLASS}>最近增强</div>
</div>
</div>
<div className="rounded-full border border-[#ebebeb] bg-white px-2.5 py-1 text-[11px] text-[#666666]">
{visibleHistoryEntries.length}/{ENHANCE_PRO_HISTORY_LIMIT}
</div>
</div>
<div className="space-y-2.5">
{visibleHistoryEntries.map((entry, index) => {
const isActive =
selectedGeneratedImage?.originalUrl === entry.image.originalUrl;
const createdAtLabel = new Date(entry.createdAt).toLocaleString(
"zh-CN",
{
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}
);
return (
<div
className={cn(
"group flex items-stretch gap-2 rounded-[22px] border border-[#efefef] bg-[#fafafa] p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.92)] transition",
isActive &&
"border-[#111111] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_12px_26px_rgba(0,0,0,0.06)]"
)}
key={entry.id}
>
<button
className="flex min-w-0 flex-1 items-stretch gap-3 text-left"
onClick={() => handleLoadHistoryEntry(entry)}
type="button"
>
<div className="relative h-24 w-24 shrink-0 overflow-hidden rounded-[18px] border border-[#ebebeb] bg-white">
<img
alt={`历史增强 ${index + 1}`}
className="h-full w-full object-cover"
src={getDisplayImageUrl(
entry.image.thumbnailUrl || entry.image.originalUrl,
320
)}
/>
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-0.5 font-medium text-[10px] text-white backdrop-blur-sm">
最近第 {index + 1} 张
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col justify-center">
<div className="flex items-center gap-2">
<div className="truncate font-medium text-[#111111] text-sm tracking-[-0.02em]">
增强结果
</div>
{isActive ? (
<span className="rounded-full border border-[#e5e5e5] bg-white px-2 py-0.5 text-[10px] text-[#111111]">
当前
</span>
) : null}
</div>
<div className="mt-1 text-[#666666] text-[11px]">
{createdAtLabel}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[#666666] text-[11px]">
{entry.model ? (
<span className="rounded-full border border-[#ebebeb] bg-white px-2 py-0.5">
{entry.model}
</span>
) : null}
{entry.image.width && entry.image.height ? (
<span className="rounded-full border border-[#ebebeb] bg-white px-2 py-0.5">
{entry.image.width} x {entry.image.height}
</span>
) : null}
</div>
</div>
</button>
<button
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#666666] transition hover:border-[#111111] hover:text-[#111111]"
onClick={(event) => {
event.stopPropagation();
handleDeleteHistoryEntry(entry.id);
}}
title="删除历史图片"
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
);
})}
</div>
</div>
) : null}
<Collapsible
className={cn(
PANEL_SHELL_CLASS,

View File

@ -910,6 +910,20 @@ export function RealtimeCanvasDialog({
() => imageLayers.find((layer) => layer.id === selectedLayerId) || null,
[imageLayers, selectedLayerId]
);
const enhanceHistoryScopeKey = useMemo(() => {
return (
initialImageUrl ||
enhanceUploadedSourceImageUrl ||
imageLayers[0]?.src ||
previewUrl ||
"realtime-canvas-enhance"
);
}, [
enhanceUploadedSourceImageUrl,
imageLayers,
initialImageUrl,
previewUrl,
]);
const enhanceSourceImageUrl = useMemo(() => {
return (
selectedImageLayer?.src ||
@ -3507,6 +3521,7 @@ export function RealtimeCanvasDialog({
{workspaceMode === "enhance-pro" ? (
<EnhanceProWorkspace
historyScopeKey={enhanceHistoryScopeKey}
onApplyResult={handleApplyEnhanceResult}
onRequestAddImage={() => uploadInputRef.current?.click()}
sourceImageUrl={enhanceSourceImageUrl}

View File

@ -886,6 +886,15 @@ export function ImageNode({
}),
[data.src, displayImageWidth]
);
const fullscreenPreviewSrc = useMemo(() => {
const originalUrl = inferOriginalImageUrl(data.src || "");
return buildImageProxyUrl(originalUrl || data.src || "", {
delivery: "proxy",
width: 2400,
quality: 90,
format: "webp",
});
}, [data.src]);
const displayCompareSrc = useMemo(
() =>
buildImageProxyUrl(compareSource, {
@ -4118,12 +4127,17 @@ export function ImageNode({
// 下载图片 - 通过 fetch 获取图片数据实现真正的下载(避免跨域问题导致跳转)
const handleDownload = async () => {
if (!data.src) return;
const originalUrl = inferOriginalImageUrl(data.src) || data.src;
try {
toast.info("正在准备下载...");
// 通过 fetch 获取图片数据
const response = await fetch(data.src);
const response = await fetch(
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
);
if (!response.ok) {
throw new Error(`下载失败(${response.status})`);
}
const blob = await response.blob();
// 创建 Blob URL
@ -4134,7 +4148,7 @@ export function ImageNode({
link.href = blobUrl;
// 从 URL 中提取文件扩展名,默认使用 png
const urlParts = data.src.split(".");
const urlParts = originalUrl.split(".");
const ext = urlParts[urlParts.length - 1]?.split("?")[0] || "png";
link.download = `${sanitizeFileName(displayTitle)}.${ext}`;
@ -4144,7 +4158,7 @@ export function ImageNode({
document.body.removeChild(link);
// 释放 Blob URL
URL.revokeObjectURL(blobUrl);
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
toast.success("下载成功");
} catch (error) {
@ -4152,7 +4166,7 @@ export function ImageNode({
toast.error("下载失败,请重试");
// 降级方案:直接打开图片
window.open(data.src, "_blank");
window.open(originalUrl, "_blank", "noopener,noreferrer");
}
};
@ -5503,8 +5517,10 @@ export function ImageNode({
<img
alt={data.prompt || "图片预览"}
className="max-h-[95vh] max-w-[95vw] object-contain"
decoding="async"
draggable={false}
onClick={(e) => e.stopPropagation()}
src={data.src}
src={fullscreenPreviewSrc}
/>
{/* 底部提示 */}

View File

@ -34,6 +34,8 @@ export function createEmptyAgentWorkflowPersistedState(): AgentWorkflowPersisted
secondarySku: "",
brand: "",
store: "",
officialSecondCategory: "",
officialThirdCategory: "",
thirdCategory: "",
fourthCategory: "",
fifthCategory: "",
@ -233,6 +235,8 @@ function normalizeWorkflowState(value: unknown): AgentWorkflowPersistedState {
secondarySku: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record<string, unknown>).secondarySku || "" : "").trim(),
brand: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record<string, unknown>).brand || "" : "").trim(),
store: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record<string, unknown>).store || "" : "").trim(),
officialSecondCategory: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record<string, unknown>).officialSecondCategory || "" : "").trim(),
officialThirdCategory: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record<string, unknown>).officialThirdCategory || "" : "").trim(),
thirdCategory: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record<string, unknown>).thirdCategory || "" : "").trim(),
fourthCategory: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record<string, unknown>).fourthCategory || "" : "").trim(),
fifthCategory: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record<string, unknown>).fifthCategory || "" : "").trim(),

View File

@ -20,6 +20,8 @@ export interface AgentWorkflowInputsValue {
secondarySku: string;
brand: string;
store: string;
officialSecondCategory: string;
officialThirdCategory: string;
thirdCategory: string;
fourthCategory: string;
fifthCategory: string;
@ -65,6 +67,8 @@ function sanitizePromptBusinessMetadata(params: {
const metadataTerms = [
params.inputs.brand,
params.inputs.store,
params.inputs.officialSecondCategory,
params.inputs.officialThirdCategory,
params.inputs.thirdCategory,
params.inputs.fourthCategory,
params.inputs.fifthCategory,
@ -426,6 +430,12 @@ export function buildAgentWorkflowComposerContext(params: {
if (params.inputs.store.trim()) {
summaryParts.push(`店铺:${params.inputs.store.trim()}`);
}
if (params.inputs.officialSecondCategory.trim()) {
summaryParts.push(`二级品类:${params.inputs.officialSecondCategory.trim()}`);
}
if (params.inputs.officialThirdCategory.trim()) {
summaryParts.push(`三级品类:${params.inputs.officialThirdCategory.trim()}`);
}
if (params.inputs.thirdCategory.trim()) {
summaryParts.push(`三级分类:${params.inputs.thirdCategory.trim()}`);
}
@ -585,6 +595,8 @@ export function buildAgentWorkflowComposerContext(params: {
secondarySku: params.inputs.secondarySku.trim() || undefined,
brand: params.inputs.brand.trim() || undefined,
store: params.inputs.store.trim() || undefined,
officialSecondCategory: params.inputs.officialSecondCategory.trim() || undefined,
officialThirdCategory: params.inputs.officialThirdCategory.trim() || undefined,
thirdCategory: params.inputs.thirdCategory.trim() || undefined,
fourthCategory: params.inputs.fourthCategory.trim() || undefined,
fifthCategory: params.inputs.fifthCategory.trim() || undefined,

View File

@ -8,6 +8,8 @@ import type {
AgentWorkflowConstraintSettings,
AgentWorkflowFourthCategoryEntry,
AgentWorkflowOptionUiConfig,
AgentWorkflowOfficialSecondCategoryEntry,
AgentWorkflowOfficialThirdCategoryEntry,
AgentWorkflowPageFeatureSettings,
AgentWorkflowStoreEntry,
AgentWorkflowThirdCategoryEntry,
@ -21,6 +23,7 @@ export const AGENT_WORKFLOW_MAX_TREE_DEPTH = 8;
export const AGENT_WORKFLOW_MAX_OPTION_DEPTH = 100;
export const AGENT_WORKFLOW_PROMPT_FRAGMENT_MAX_LENGTH = 20_000;
export const AGENT_WORKFLOW_BLOCKED_OPTION_KEYS_MAX = 5000;
export const AGENT_WORKFLOW_INPUT_OPTIONS_MAX_ITEMS = 5000;
const AGENT_WORKFLOW_SPECIAL_MODULE_ALLOWED_KEYS_MAX = 5000;
export const defaultAgentWorkflowCategoryLabels: AgentWorkflowCategoryLabels = {
firstCategory: "品牌",
@ -29,6 +32,58 @@ export const defaultAgentWorkflowCategoryLabels: AgentWorkflowCategoryLabels = {
fourthCategory: "四级分类",
fifthCategory: "五级分类",
};
export const defaultAgentWorkflowOfficialCategoryEntries: AgentWorkflowOfficialSecondCategoryEntry[] = [
{
categoryName: "住宅家具",
thirdCategories: [
"柜类",
"床类",
"床垫类",
"沙发类",
"坐具类",
"几类",
"桌类",
"架类",
"箱类",
"镜子类",
"榻榻米空间",
"根雕类",
"屏风/花窗",
"案/台类",
"户外/庭院家具",
"宜家IKEA",
"家具辅料",
"二手/闲置专区",
"成套家具",
"情趣家具",
"设计师家具",
],
thirdCategoryEntries: [
"柜类",
"床类",
"床垫类",
"沙发类",
"坐具类",
"几类",
"桌类",
"架类",
"箱类",
"镜子类",
"榻榻米空间",
"根雕类",
"屏风/花窗",
"案/台类",
"户外/庭院家具",
"宜家IKEA",
"家具辅料",
"二手/闲置专区",
"成套家具",
"情趣家具",
"设计师家具",
].map((categoryName, sortOrder) => ({ categoryName, sortOrder })),
sortOrder: 0,
},
];
export const defaultAgentWorkflowConstraintSettings: AgentWorkflowConstraintSettings = {
crossLevelMutualExclusionEnabled: false,
};
@ -99,6 +154,9 @@ function cloneOption(
0,
AGENT_WORKFLOW_BLOCKED_OPTION_KEYS_MAX
),
availableOfficialCategoryKeys: Array.from(
new Set((option.availableOfficialCategoryKeys || []).filter(Boolean))
).slice(0, AGENT_WORKFLOW_INPUT_OPTIONS_MAX_ITEMS),
specialModuleEnabled: Boolean(option.specialModuleEnabled),
specialModuleExclusionEnabled: Boolean(option.specialModuleExclusionEnabled),
specialModuleAllowedOptionKeys: Array.from(
@ -221,6 +279,7 @@ export const defaultAgentWorkflowUiConfig: AgentWorkflowUiConfig = {
brands: [],
stores: [],
categoryLabels: defaultAgentWorkflowCategoryLabels,
officialCategoryEntries: defaultAgentWorkflowOfficialCategoryEntries,
thirdCategories: [],
fourthCategories: [],
fifthCategories: [],
@ -620,6 +679,75 @@ export function getRenderableAgentWorkflowStepTree(
return nextSteps;
}
function optionMatchesOfficialCategory(
option: AgentWorkflowOptionUiConfig,
selectedCategoryKeys: Set<string>
) {
const availableKeys = normalizeOfficialCategoryKeyList(option.availableOfficialCategoryKeys);
if (availableKeys.length === 0 || selectedCategoryKeys.size === 0) {
return true;
}
return availableKeys.some((key) => selectedCategoryKeys.has(key));
}
function filterAgentWorkflowOptionTreeByOfficialCategory(
options: AgentWorkflowOptionUiConfig[],
selectedCategoryKeys: Set<string>
): AgentWorkflowOptionUiConfig[] {
const filteredOptions: AgentWorkflowOptionUiConfig[] = [];
for (const option of [...options].sort((left, right) => left.sortOrder - right.sortOrder)) {
const children = filterAgentWorkflowOptionTreeByOfficialCategory(
option.children || [],
selectedCategoryKeys
);
const matches = optionMatchesOfficialCategory(option, selectedCategoryKeys);
if (!matches && children.length === 0) {
continue;
}
filteredOptions.push({
...option,
children,
});
}
return filteredOptions;
}
export function filterAgentWorkflowStepTreeByOfficialCategory(params: {
steps: AgentWorkflowStepUiConfig[];
officialSecondCategory?: string | null;
officialThirdCategory?: string | null;
}): AgentWorkflowStepUiConfig[] {
const secondKey = getAgentWorkflowOfficialSecondCategoryKey(
params.officialSecondCategory || ""
);
const thirdKey = getAgentWorkflowOfficialThirdCategoryKey({
secondCategory: params.officialSecondCategory,
thirdCategory: params.officialThirdCategory,
});
const selectedCategoryKeys = new Set([secondKey, thirdKey].filter(Boolean));
if (selectedCategoryKeys.size === 0) {
return params.steps;
}
return [...params.steps]
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((step) => ({
...step,
options: filterAgentWorkflowOptionTreeByOfficialCategory(
step.options || [],
selectedCategoryKeys
),
children: filterAgentWorkflowStepTreeByOfficialCategory({
steps: step.children || [],
officialSecondCategory: params.officialSecondCategory,
officialThirdCategory: params.officialThirdCategory,
}),
}))
.filter((step) => step.options.length > 0 || (step.children || []).length > 0);
}
export function flattenAgentWorkflowOptionTree(
options: AgentWorkflowOptionUiConfig[],
depth = 1,
@ -750,6 +878,89 @@ function normalizeKeyList(value: unknown, max = 100) {
);
}
function normalizeOfficialCategoryKey(value: unknown) {
return String(value || "").trim().slice(0, 160);
}
function normalizeOfficialCategoryKeyList(value: unknown, max = AGENT_WORKFLOW_INPUT_OPTIONS_MAX_ITEMS) {
if (!Array.isArray(value)) return [] as string[];
return Array.from(
new Set(
value
.map(normalizeOfficialCategoryKey)
.filter(Boolean)
.slice(0, max)
)
);
}
export function getAgentWorkflowOfficialSecondCategoryKey(secondCategory: string) {
const normalizedSecond = String(secondCategory || "").trim();
return normalizedSecond ? `official-second:${normalizedSecond}` : "";
}
export function getAgentWorkflowOfficialThirdCategoryKey(params: {
secondCategory?: string | null;
thirdCategory?: string | null;
}) {
const normalizedSecond = String(params.secondCategory || "").trim();
const normalizedThird = String(params.thirdCategory || "").trim();
return normalizedSecond && normalizedThird
? `official-third:${normalizedSecond}>${normalizedThird}`
: "";
}
export function getAgentWorkflowOfficialCategoryEntries(
config?: AgentWorkflowUiConfig | null
) {
return config?.inputOptions?.officialCategoryEntries &&
config.inputOptions.officialCategoryEntries.length > 0
? config.inputOptions.officialCategoryEntries
: defaultAgentWorkflowOfficialCategoryEntries;
}
export function getAgentWorkflowOfficialSecondCategoryOptions(
config?: AgentWorkflowUiConfig | null
) {
return getAgentWorkflowOfficialCategoryEntries(config).map((entry) => entry.categoryName);
}
export function getAgentWorkflowOfficialThirdCategoryOptions(params: {
config?: AgentWorkflowUiConfig | null;
secondCategory?: string | null;
}) {
const normalizedSecond = String(params.secondCategory || "").trim().toLowerCase();
if (!normalizedSecond) {
return [];
}
const matchedEntry = getAgentWorkflowOfficialCategoryEntries(params.config).find(
(entry) => entry.categoryName.trim().toLowerCase() === normalizedSecond
);
return matchedEntry?.thirdCategories || [];
}
export function getAgentWorkflowOfficialCategoryKeyLabels(
config?: AgentWorkflowUiConfig | null
) {
const labels = new Map<string, string>();
for (const secondEntry of getAgentWorkflowOfficialCategoryEntries(config)) {
const secondKey = getAgentWorkflowOfficialSecondCategoryKey(secondEntry.categoryName);
if (secondKey) {
labels.set(secondKey, `二级:${secondEntry.categoryName}`);
}
for (const thirdCategory of secondEntry.thirdCategories || []) {
const thirdKey = getAgentWorkflowOfficialThirdCategoryKey({
secondCategory: secondEntry.categoryName,
thirdCategory,
});
if (thirdKey) {
labels.set(thirdKey, `三级:${secondEntry.categoryName} / ${thirdCategory}`);
}
}
}
return labels;
}
function sanitizeBlockedOptionTree(
options: AgentWorkflowOptionUiConfig[],
validKeys = new Set(
@ -835,6 +1046,71 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
raw.inputOptions && typeof raw.inputOptions === "object" && !Array.isArray(raw.inputOptions)
? (raw.inputOptions as Record<string, unknown>)
: {};
const normalizeOfficialCategoryEntries = (input: unknown) => {
const source = Array.isArray(input) ? input : defaultAgentWorkflowOfficialCategoryEntries;
const seenSecondCategories = new Set<string>();
const normalizedEntries: AgentWorkflowOfficialSecondCategoryEntry[] = [];
source.forEach((item, index) => {
const record =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const categoryName = normalizeText(record.categoryName ?? record.name, 64);
const secondKey = categoryName.toLowerCase();
if (!categoryName || seenSecondCategories.has(secondKey)) {
return;
}
seenSecondCategories.add(secondKey);
const thirdCategoryEntriesSource = Array.isArray(record.thirdCategoryEntries)
? (record.thirdCategoryEntries as unknown[])
: normalizeStringList(record.thirdCategories).map((thirdCategory, thirdIndex) => ({
categoryName: thirdCategory,
sortOrder: thirdIndex,
}));
const seenThirdCategories = new Set<string>();
const thirdCategoryEntries = thirdCategoryEntriesSource
.map((thirdItem, thirdIndex): AgentWorkflowOfficialThirdCategoryEntry | null => {
const thirdRecord =
thirdItem && typeof thirdItem === "object" && !Array.isArray(thirdItem)
? (thirdItem as Record<string, unknown>)
: {};
const thirdName = normalizeText(thirdRecord.categoryName ?? thirdRecord.name ?? thirdItem, 64);
const thirdKey = thirdName.toLowerCase();
if (!thirdName || seenThirdCategories.has(thirdKey)) {
return null;
}
seenThirdCategories.add(thirdKey);
return {
categoryName: thirdName,
sortOrder: normalizeNumber(thirdRecord.sortOrder, thirdIndex),
};
})
.filter((entry): entry is AgentWorkflowOfficialThirdCategoryEntry => Boolean(entry))
.sort((left, right) => (left.sortOrder || 0) - (right.sortOrder || 0))
.map((entry, thirdIndex) => ({
...entry,
sortOrder: thirdIndex,
}));
normalizedEntries.push({
categoryName,
thirdCategories: thirdCategoryEntries.map((entry) => entry.categoryName),
thirdCategoryEntries,
sortOrder: normalizeNumber(record.sortOrder, index),
});
});
return normalizedEntries.length > 0
? normalizedEntries
.sort((left, right) => (left.sortOrder || 0) - (right.sortOrder || 0))
.map((entry, index) => ({
...entry,
sortOrder: index,
}))
: defaultAgentWorkflowOfficialCategoryEntries;
};
const normalizeBrandStoreEntries = (input: unknown) => {
if (!Array.isArray(input)) return [] as AgentWorkflowBrandStoreEntry[];
@ -1095,6 +1371,9 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
const normalizedFourthCategories = normalizeStringList(rawInputOptions.fourthCategories);
const normalizedFifthCategories = normalizeStringList(rawInputOptions.fifthCategories);
const normalizedPriceBands = normalizeStringList(rawInputOptions.priceBands);
const normalizedOfficialCategoryEntries = normalizeOfficialCategoryEntries(
rawInputOptions.officialCategoryEntries
);
const fallbackBrandStoreEntries: AgentWorkflowBrandStoreEntry[] =
normalizedBrandStoreEntries.length > 0
? normalizedBrandStoreEntries
@ -1157,6 +1436,9 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
defaultOption?.promptFragment ||
buttonName;
const hasRawBlockedOptionKeys = Array.isArray(optionRecord.blockedOptionKeys);
const hasRawAvailableOfficialCategoryKeys = Array.isArray(
optionRecord.availableOfficialCategoryKeys
);
const hasRawSpecialModuleAllowedOptionKeys = Array.isArray(
optionRecord.specialModuleAllowedOptionKeys
);
@ -1186,6 +1468,9 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
blockedOptionKeys: hasRawBlockedOptionKeys
? normalizeKeyList(optionRecord.blockedOptionKeys, AGENT_WORKFLOW_BLOCKED_OPTION_KEYS_MAX)
: normalizeKeyList(defaultOption?.blockedOptionKeys, AGENT_WORKFLOW_BLOCKED_OPTION_KEYS_MAX),
availableOfficialCategoryKeys: hasRawAvailableOfficialCategoryKeys
? normalizeOfficialCategoryKeyList(optionRecord.availableOfficialCategoryKeys)
: normalizeOfficialCategoryKeyList(defaultOption?.availableOfficialCategoryKeys),
specialModuleEnabled: normalizeBoolean(
optionRecord.specialModuleEnabled,
defaultOption?.specialModuleEnabled ?? false
@ -1349,6 +1634,7 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
)
: normalizedFifthCategories,
categoryLabels: normalizedCategoryLabels,
officialCategoryEntries: normalizedOfficialCategoryEntries,
priceBands: normalizedPriceBands,
brandStoreEntries: fallbackBrandStoreEntries,
},

View File

@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS "DingTalkNotificationTask" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" varchar(100) NOT NULL,
"content" text NOT NULL,
"status" varchar(24) NOT NULL DEFAULT 'pending',
"scheduledAt" timestamp NOT NULL,
"attempts" integer NOT NULL DEFAULT 0,
"lastAttemptAt" timestamp,
"sentAt" timestamp,
"cancelledAt" timestamp,
"lastError" text,
"createdBy" uuid REFERENCES "User"("id"),
"createdAt" timestamp NOT NULL DEFAULT now(),
"updatedAt" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "DingTalkNotificationTask_status_scheduledAt_idx"
ON "DingTalkNotificationTask" ("status", "scheduledAt");
CREATE INDEX IF NOT EXISTS "DingTalkNotificationTask_createdAt_idx"
ON "DingTalkNotificationTask" ("createdAt");

View File

@ -449,6 +449,13 @@
"when": 1777392000000,
"tag": "0077_add_stepfun_image_provider_type",
"breakpoints": true
},
{
"idx": 64,
"version": "7",
"when": 1777569600000,
"tag": "0078_add_dingtalk_notification_tasks",
"breakpoints": true
}
]
}

View File

@ -260,6 +260,26 @@ export const systemSettingHistory = pgTable("SystemSettingHistory", {
export type SystemSettingHistory = InferSelectModel<typeof systemSettingHistory>;
export const dingTalkNotificationTask = pgTable("DingTalkNotificationTask", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
title: varchar("title", { length: 100 }).notNull(),
content: text("content").notNull(),
status: varchar("status", { length: 24 }).notNull().default("pending"),
scheduledAt: timestamp("scheduledAt").notNull(),
attempts: integer("attempts").notNull().default(0),
lastAttemptAt: timestamp("lastAttemptAt"),
sentAt: timestamp("sentAt"),
cancelledAt: timestamp("cancelledAt"),
lastError: text("lastError"),
createdBy: uuid("createdBy").references(() => user.id),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type DingTalkNotificationTask = InferSelectModel<
typeof dingTalkNotificationTask
>;
export const toolConfig = pgTable("ToolConfig", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
toolName: varchar("toolName", { length: 128 }).notNull().unique(),

View File

@ -0,0 +1,138 @@
import "server-only";
import { and, asc, desc, eq, lte, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { getSystemSetting } from "@/lib/db/queries";
import { dingTalkNotificationTask } from "@/lib/db/schema";
import {
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
defaultDingTalkRobotNotificationConfig,
normalizeDingTalkRobotNotificationConfig,
} from "@/lib/notifications/dingtalk-robot-config";
import {
assertDingTalkRobotNotificationConfigReady,
sendDingTalkRobotNotification,
} from "@/lib/notifications/dingtalk-robot";
const DUE_STATUSES = ["pending"] as const;
export type DingTalkNotificationTaskStatus =
| "pending"
| "sending"
| "sent"
| "failed"
| "cancelled";
export async function listRecentDingTalkNotificationTasks(limit = 30) {
return db
.select()
.from(dingTalkNotificationTask)
.orderBy(desc(dingTalkNotificationTask.createdAt))
.limit(Math.max(1, Math.min(100, Math.floor(limit))));
}
export async function getDueDingTalkNotificationTasks(limit = 20) {
return db
.select()
.from(dingTalkNotificationTask)
.where(
and(
eq(dingTalkNotificationTask.status, DUE_STATUSES[0]),
lte(dingTalkNotificationTask.scheduledAt, new Date()),
),
)
.orderBy(asc(dingTalkNotificationTask.scheduledAt))
.limit(Math.max(1, Math.min(100, Math.floor(limit))));
}
async function claimPendingTask(taskId: string) {
const [claimedTask] = await db
.update(dingTalkNotificationTask)
.set({
status: "sending",
attempts: sql`${dingTalkNotificationTask.attempts} + 1`,
lastAttemptAt: new Date(),
updatedAt: new Date(),
lastError: null,
})
.where(
and(
eq(dingTalkNotificationTask.id, taskId),
eq(dingTalkNotificationTask.status, "pending"),
),
)
.returning();
return claimedTask ?? null;
}
export async function runDingTalkNotificationJob(options?: { limit?: number }) {
const pendingTasks = await getDueDingTalkNotificationTasks(options?.limit ?? 20);
const setting = await getSystemSetting({
key: DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
}).catch(() => null);
const config = setting?.value
? normalizeDingTalkRobotNotificationConfig(setting.value)
: defaultDingTalkRobotNotificationConfig;
assertDingTalkRobotNotificationConfigReady(config);
const result = {
pending: pendingTasks.length,
attempted: 0,
sent: 0,
failed: 0,
skipped: 0,
errors: [] as Array<{ id: string; error: string }>,
};
for (const task of pendingTasks) {
const claimedTask = await claimPendingTask(task.id);
if (!claimedTask) {
result.skipped += 1;
continue;
}
result.attempted += 1;
try {
await sendDingTalkRobotNotification({
config,
title: claimedTask.title,
content: claimedTask.content,
});
await db
.update(dingTalkNotificationTask)
.set({
status: "sent",
sentAt: new Date(),
updatedAt: new Date(),
lastError: null,
})
.where(eq(dingTalkNotificationTask.id, claimedTask.id));
result.sent += 1;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "发送钉钉通知失败";
await db
.update(dingTalkNotificationTask)
.set({
status: "failed",
updatedAt: new Date(),
lastError: errorMessage,
})
.where(eq(dingTalkNotificationTask.id, claimedTask.id));
result.failed += 1;
result.errors.push({
id: claimedTask.id,
error: errorMessage,
});
}
}
return result;
}

View File

@ -17,7 +17,9 @@ export type ThreeDModelAliasConfig = {
export type PublicThreeDModelAliasEntry = Omit<
ThreeDModelAliasEntry,
"realModelId"
>;
> & {
billingModelId?: string;
};
export type PublicThreeDModelAliasConfig = {
tripoModels: PublicThreeDModelAliasEntry[];
@ -230,8 +232,9 @@ export function toPublicThreeDModelAliasConfig(
tripoModels: config.tripoModels.map(({ realModelId: _realModelId, ...entry }) => ({
...entry,
})),
worldModels: config.worldModels.map(({ realModelId: _realModelId, ...entry }) => ({
worldModels: config.worldModels.map(({ realModelId, ...entry }) => ({
...entry,
billingModelId: realModelId,
})),
};
}
@ -240,7 +243,12 @@ export const DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG =
toPublicThreeDModelAliasConfig(defaultThreeDModelAliasConfig());
function findEntryByValue<
T extends { id: string; displayName: string; realModelId?: string },
T extends {
id: string;
displayName: string;
realModelId?: string;
billingModelId?: string;
},
>(entries: T[], value: unknown) {
const normalized = sanitizeText(value);
if (!normalized) return null;
@ -251,6 +259,11 @@ function findEntryByValue<
(entry) =>
"realModelId" in entry && sanitizeText(entry.realModelId) === normalized,
) ||
entries.find(
(entry) =>
"billingModelId" in entry &&
sanitizeText(entry.billingModelId) === normalized,
) ||
entries.find((entry) => sanitizeText(entry.displayName) === normalized) ||
null
);
@ -298,8 +311,25 @@ export function resolveThreeDModelAliasId(
return sanitizeText(value);
}
export function resolveThreeDModelBillingId<
T extends {
id: string;
displayName: string;
realModelId?: string;
billingModelId?: string;
},
>(value: unknown, entries: T[]) {
const match = findEntryByValue(entries, value);
return sanitizeText(match?.billingModelId || match?.realModelId || value);
}
export function resolveThreeDModelDisplayName<
T extends { id: string; displayName: string; realModelId?: string },
T extends {
id: string;
displayName: string;
realModelId?: string;
billingModelId?: string;
},
>(
value: unknown,
entries: T[],

View File

@ -0,0 +1,42 @@
export const DINGTALK_NOTIFICATION_DRAFT_CONFIG_KEY =
"dingtalk_notification_draft_config";
export type DingTalkNotificationDraftConfig = {
title: string;
content: string;
};
export const defaultDingTalkNotificationDraftConfig: DingTalkNotificationDraftConfig =
{
title: "",
content: "",
};
function normalizeDraftTitle(value: unknown) {
return String(value || "")
.replace(/[\u2028\u2029]/g, "")
.replace(/[\u0000-\u001F\u007F]/g, "")
.trim();
}
function normalizeDraftContent(value: unknown) {
return String(value || "")
.replace(/[\u2028\u2029]/g, "")
.replace(/\u0000/g, "")
.replace(/\r\n?/g, "\n")
.trim();
}
export function normalizeDingTalkNotificationDraftConfig(
value: unknown,
): DingTalkNotificationDraftConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return defaultDingTalkNotificationDraftConfig;
}
const raw = value as Partial<DingTalkNotificationDraftConfig>;
return {
title: normalizeDraftTitle(raw.title),
content: normalizeDraftContent(raw.content),
};
}

View File

@ -29,7 +29,9 @@ function buildSignedWebhookUrl(webhookUrl: string, secret: string) {
function buildMarkdownText(params: { title: string; content: string }) {
const normalizedTitle = String(params.title || "").trim();
const normalizedContent = String(params.content || "").trim();
const normalizedContent = String(params.content || "")
.replace(/\r\n?/g, "\n")
.trim();
const sentAt = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
@ -41,14 +43,25 @@ function buildMarkdownText(params: { title: string; content: string }) {
hour12: false,
}).format(new Date());
const body = normalizedContent
.split(/\r?\n/)
.map((line) => line.trim())
.join("\n\n");
return [
`### ${normalizedTitle}`,
"",
normalizedContent,
"",
`> 发送时间:${sentAt}`,
].join("\n");
}
return [`### ${normalizedTitle}`, "", body, "", `> 发送时间:${sentAt}`].join(
"\n",
);
export function assertDingTalkRobotNotificationConfigReady(
config: DingTalkRobotNotificationConfig,
) {
if (!config.enabled) {
throw new Error("钉钉群机器人通知未启用");
}
if (!config.webhookUrl) {
throw new Error("钉钉群机器人 Webhook 未配置");
}
}
export async function sendDingTalkRobotNotification(params: {
@ -57,14 +70,7 @@ export async function sendDingTalkRobotNotification(params: {
content: string;
}) {
const config = normalizeDingTalkRobotNotificationConfig(params.config);
if (!config.enabled) {
throw new Error("钉钉群机器人通知未启用");
}
if (!config.webhookUrl) {
throw new Error("钉钉群机器人 Webhook 未配置");
}
assertDingTalkRobotNotificationConfigReady(config);
const title = String(params.title || "").trim();
const content = String(params.content || "").trim();

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

File diff suppressed because one or more lines are too long

View File

@ -87,6 +87,7 @@ export interface AgentWorkflowOptionUiConfig {
enabled: boolean;
sortOrder: number;
promptFragment?: string | null;
availableOfficialCategoryKeys?: string[];
blockedOptionKeys?: string[];
specialModuleEnabled?: boolean;
specialModuleExclusionEnabled?: boolean;
@ -144,6 +145,7 @@ export interface AgentWorkflowUiConfig {
brands: string[];
stores: string[];
categoryLabels?: AgentWorkflowCategoryLabels;
officialCategoryEntries?: AgentWorkflowOfficialSecondCategoryEntry[];
thirdCategories?: string[];
fourthCategories?: string[];
fifthCategories?: string[];
@ -164,6 +166,18 @@ export interface AgentWorkflowCategoryLabels {
fifthCategory: string;
}
export interface AgentWorkflowOfficialThirdCategoryEntry {
categoryName: string;
sortOrder?: number;
}
export interface AgentWorkflowOfficialSecondCategoryEntry {
categoryName: string;
thirdCategories: string[];
thirdCategoryEntries?: AgentWorkflowOfficialThirdCategoryEntry[];
sortOrder?: number;
}
export interface AgentWorkflowFourthCategoryEntry {
categoryName: string;
fifthCategories: string[];
@ -325,6 +339,8 @@ export interface AgentWorkflowComposerContext {
secondarySku?: string;
brand?: string;
store?: string;
officialSecondCategory?: string;
officialThirdCategory?: string;
thirdCategory?: string;
fourthCategory?: string;
fifthCategory?: string;
@ -463,6 +479,8 @@ export interface AgentWorkflowPersistedState {
secondarySku: string;
brand: string;
store: string;
officialSecondCategory?: string;
officialThirdCategory?: string;
thirdCategory: string;
fourthCategory: string;
fifthCategory: string;