207 lines
6.7 KiB
TypeScript
207 lines
6.7 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useEffect, useState } from "react";
|
|||
|
|
import { useRouter } from "next/navigation";
|
|||
|
|
import { Plus } from "lucide-react";
|
|||
|
|
import { BrandProductPicker } from "@/components/product-profile/brand-product-picker";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import {
|
|||
|
|
Dialog,
|
|||
|
|
DialogContent,
|
|||
|
|
DialogDescription,
|
|||
|
|
DialogFooter,
|
|||
|
|
DialogHeader,
|
|||
|
|
DialogTitle,
|
|||
|
|
} from "@/components/ui/dialog";
|
|||
|
|
import { Input } from "@/components/ui/input";
|
|||
|
|
import { Label } from "@/components/ui/label";
|
|||
|
|
import { Textarea } from "@/components/ui/textarea";
|
|||
|
|
import { InlineAlert } from "@/components/ui/inline-alert";
|
|||
|
|
import type { ActionFeedback } from "@/lib/use-action-feedback";
|
|||
|
|
import type { TopicGoal } from "@/lib/types/topic-goal";
|
|||
|
|
import { parseFetchJson } from "@/lib/utils";
|
|||
|
|
|
|||
|
|
interface TopicFormDialogProps {
|
|||
|
|
open: boolean;
|
|||
|
|
onOpenChange: (open: boolean) => void;
|
|||
|
|
defaultGoal: TopicGoal;
|
|||
|
|
onCreated?: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function TopicFormDialog({
|
|||
|
|
open,
|
|||
|
|
onOpenChange,
|
|||
|
|
defaultGoal,
|
|||
|
|
onCreated,
|
|||
|
|
}: TopicFormDialogProps) {
|
|||
|
|
const router = useRouter();
|
|||
|
|
const [label, setLabel] = useState("");
|
|||
|
|
const [query, setQuery] = useState("");
|
|||
|
|
const [brief, setBrief] = useState("");
|
|||
|
|
const topicGoal = defaultGoal;
|
|||
|
|
const [brandProfileId, setBrandProfileId] = useState<string | null>(null);
|
|||
|
|
const [productProfileId, setProductProfileId] = useState<string | null>(null);
|
|||
|
|
const [productContext, setProductContext] = useState("");
|
|||
|
|
const [submitting, setSubmitting] = useState(false);
|
|||
|
|
const [formFeedback, setFormFeedback] = useState<ActionFeedback | null>(null);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!open) return;
|
|||
|
|
setLabel("");
|
|||
|
|
setQuery("");
|
|||
|
|
setBrief("");
|
|||
|
|
setBrandProfileId(null);
|
|||
|
|
setProductProfileId(null);
|
|||
|
|
setProductContext("");
|
|||
|
|
setFormFeedback(null);
|
|||
|
|
}, [open, defaultGoal]);
|
|||
|
|
|
|||
|
|
async function handleSubmit(e: React.FormEvent) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (!label.trim() || !query.trim()) {
|
|||
|
|
setFormFeedback({ type: "warning", message: "請填寫主題名稱與種子關鍵字。" });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (topicGoal === "placement" && !brandProfileId) {
|
|||
|
|
setFormFeedback({ type: "warning", title: "請選擇品牌", message: "置入模式需先選擇品牌。" });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setFormFeedback(null);
|
|||
|
|
setSubmitting(true);
|
|||
|
|
const res = await fetch("/api/topics", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
label: label.trim(),
|
|||
|
|
query: query.trim(),
|
|||
|
|
brief: brief.trim() || undefined,
|
|||
|
|
topicGoal,
|
|||
|
|
brandProfileId: topicGoal === "placement" ? brandProfileId : undefined,
|
|||
|
|
productProfileId: topicGoal === "placement" ? productProfileId : undefined,
|
|||
|
|
productContext: topicGoal === "placement" ? productContext : undefined,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
let data: { error?: string; topic?: { id: string } } = {};
|
|||
|
|
try {
|
|||
|
|
data = await parseFetchJson(res);
|
|||
|
|
} catch (err) {
|
|||
|
|
setSubmitting(false);
|
|||
|
|
setFormFeedback({
|
|||
|
|
type: "error",
|
|||
|
|
title: "建立失敗",
|
|||
|
|
message: err instanceof Error ? err.message : "伺服器回應異常",
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!res.ok) {
|
|||
|
|
setSubmitting(false);
|
|||
|
|
setFormFeedback({
|
|||
|
|
type: "error",
|
|||
|
|
title: "建立失敗",
|
|||
|
|
message: data.error ?? "無法建立主題",
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const topicId = data.topic?.id;
|
|||
|
|
if (!topicId) {
|
|||
|
|
setSubmitting(false);
|
|||
|
|
setFormFeedback({ type: "error", title: "建立失敗", message: "未取得主題編號" });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const analyzeRes = await fetch("/api/analyze-topic", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ topicId, brief: brief.trim() || undefined }),
|
|||
|
|
});
|
|||
|
|
setSubmitting(false);
|
|||
|
|
|
|||
|
|
onCreated?.();
|
|||
|
|
onOpenChange(false);
|
|||
|
|
if (analyzeRes.ok) window.dispatchEvent(new Event("haixun:jobs-updated"));
|
|||
|
|
router.push(`/scans/${topicId}?mode=${topicGoal}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|||
|
|
<DialogContent className="w-[min(100vw-2rem,36rem)]">
|
|||
|
|
<DialogHeader>
|
|||
|
|
<DialogTitle>{topicGoal === "placement" ? "新增找 TA 任務" : "新增拷貝忍者任務"}</DialogTitle>
|
|||
|
|
<DialogDescription>建立後會自動開始 AI 分析;完成後確認標籤,就能直接海巡。</DialogDescription>
|
|||
|
|
</DialogHeader>
|
|||
|
|
|
|||
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|||
|
|
{formFeedback && (
|
|||
|
|
<InlineAlert
|
|||
|
|
type={formFeedback.type}
|
|||
|
|
title={formFeedback.title}
|
|||
|
|
message={formFeedback.message}
|
|||
|
|
onDismiss={() => setFormFeedback(null)}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|||
|
|
<div className="space-y-1.5">
|
|||
|
|
<Label>名稱</Label>
|
|||
|
|
<Input
|
|||
|
|
value={label}
|
|||
|
|
onChange={(e) => setLabel(e.target.value)}
|
|||
|
|
placeholder="居家狗狗洗澡"
|
|||
|
|
autoFocus
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="space-y-1.5">
|
|||
|
|
<Label>種子關鍵字</Label>
|
|||
|
|
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="狗狗洗澡" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="space-y-1.5">
|
|||
|
|
<Label>Brief(建議填)</Label>
|
|||
|
|
<Textarea
|
|||
|
|
value={brief}
|
|||
|
|
onChange={(e) => setBrief(e.target.value)}
|
|||
|
|
rows={2}
|
|||
|
|
placeholder={
|
|||
|
|
topicGoal === "placement"
|
|||
|
|
? "想服務誰、什麼情境"
|
|||
|
|
: "你是誰、想服務誰、內容方向"
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{topicGoal === "placement" && (
|
|||
|
|
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
|||
|
|
<BrandProductPicker
|
|||
|
|
brandId={brandProfileId}
|
|||
|
|
productId={productProfileId}
|
|||
|
|
onChange={({ brandId, productId, context }) => {
|
|||
|
|
setBrandProfileId(brandId);
|
|||
|
|
setProductProfileId(productId);
|
|||
|
|
if (context) setProductContext(context);
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<DialogFooter>
|
|||
|
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|||
|
|
取消
|
|||
|
|
</Button>
|
|||
|
|
<Button type="submit" disabled={submitting}>
|
|||
|
|
{submitting ? "建立並分析中…" : (
|
|||
|
|
<>
|
|||
|
|
<Plus className="h-4 w-4" />
|
|||
|
|
建立並分析
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</Button>
|
|||
|
|
</DialogFooter>
|
|||
|
|
</form>
|
|||
|
|
</DialogContent>
|
|||
|
|
</Dialog>
|
|||
|
|
);
|
|||
|
|
}
|