feat: update workflow categories and admin notifications
This commit is contained in:
parent
c843661da3
commit
2561c66cb8
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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("下载失败,已为你打开原图,可直接另存为");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
63
app/api/admin/dingtalk-robot/notifications/[id]/route.ts
Normal file
63
app/api/admin/dingtalk-robot/notifications/[id]/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
101
app/api/admin/dingtalk-robot/notifications/route.ts
Normal file
101
app/api/admin/dingtalk-robot/notifications/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@ const bodySchema = z.object({
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "通知内容不能为空")
|
||||
.max(5000, "通知内容过长"),
|
||||
.max(20000, "通知内容过长"),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@ -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,
|
||||
|
||||
78
app/api/jobs/dingtalk-notifications/route.ts
Normal file
78
app/api/jobs/dingtalk-notifications/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -37,6 +37,8 @@ export function AgentPageDark({
|
||||
secondarySku: "",
|
||||
brand: "",
|
||||
store: "",
|
||||
officialSecondCategory: "",
|
||||
officialThirdCategory: "",
|
||||
thirdCategory: "",
|
||||
fourthCategory: "",
|
||||
fifthCategory: "",
|
||||
|
||||
@ -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: "",
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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()}
|
||||
/>
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
{/* 底部提示 */}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
21
lib/db/migrations/0078_add_dingtalk_notification_tasks.sql
Normal file
21
lib/db/migrations/0078_add_dingtalk_notification_tasks.sql
Normal 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");
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
138
lib/jobs/dingtalk-notification-job.ts
Normal file
138
lib/jobs/dingtalk-notification-job.ts
Normal 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;
|
||||
}
|
||||
@ -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[],
|
||||
|
||||
42
lib/notifications/dingtalk-notification-draft-config.ts
Normal file
42
lib/notifications/dingtalk-notification-draft-config.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@ -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
2
next-env.d.ts
vendored
@ -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
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user