647 lines
25 KiB
TypeScript
647 lines
25 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|||
|
|
import { flushSync } from "react-dom";
|
|||
|
|
import {
|
|||
|
|
Loader2,
|
|||
|
|
ScanText,
|
|||
|
|
MessageCircle,
|
|||
|
|
Plus,
|
|||
|
|
Send,
|
|||
|
|
Sparkles,
|
|||
|
|
UserRound,
|
|||
|
|
WandSparkles,
|
|||
|
|
X,
|
|||
|
|
} from "lucide-react";
|
|||
|
|
import { Badge } from "@/components/ui/badge";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|||
|
|
import { EmptyState } from "@/components/layout/empty-state";
|
|||
|
|
import { Input } from "@/components/ui/input";
|
|||
|
|
import { Label } from "@/components/ui/label";
|
|||
|
|
import { PageHeader } from "@/components/layout/page-header";
|
|||
|
|
import { useJobs } from "@/components/layout/jobs-provider";
|
|||
|
|
import { JobProgressPanel } from "@/components/job-progress-panel";
|
|||
|
|
import { InlineAlert } from "@/components/ui/inline-alert";
|
|||
|
|
import { Textarea } from "@/components/ui/textarea";
|
|||
|
|
import { useActionFeedback } from "@/lib/use-action-feedback";
|
|||
|
|
import { parseFetchJson } from "@/lib/utils";
|
|||
|
|
import {
|
|||
|
|
createEmptyStyle8DProfile,
|
|||
|
|
parseStyle8DProfile,
|
|||
|
|
STYLE_8D_KEYS,
|
|||
|
|
STYLE_8D_LABELS,
|
|||
|
|
type StoredStyle8DProfile,
|
|||
|
|
type Style8DKey,
|
|||
|
|
} from "@/lib/types/style-profile";
|
|||
|
|
|
|||
|
|
interface Account {
|
|||
|
|
id: string;
|
|||
|
|
username?: string | null;
|
|||
|
|
displayName?: string | null;
|
|||
|
|
persona?: string | null;
|
|||
|
|
styleProfile?: string | null;
|
|||
|
|
styleBenchmark?: string | null;
|
|||
|
|
brief?: string | null;
|
|||
|
|
productBrief?: string | null;
|
|||
|
|
targetAudience?: string | null;
|
|||
|
|
goals?: string | null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface AssistantMessage {
|
|||
|
|
role: "user" | "assistant";
|
|||
|
|
content: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type Style8DResult = StoredStyle8DProfile;
|
|||
|
|
|
|||
|
|
export default function AccountsPage() {
|
|||
|
|
const [account, setAccount] = useState<Account | null>(null);
|
|||
|
|
const [draft, setDraft] = useState<Account | null>(null);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|||
|
|
const [newName, setNewName] = useState("");
|
|||
|
|
const [assistantOpen, setAssistantOpen] = useState(true);
|
|||
|
|
const [assistantInput, setAssistantInput] = useState("");
|
|||
|
|
const [assistantBusy, setAssistantBusy] = useState(false);
|
|||
|
|
const [benchmarkUsername, setBenchmarkUsername] = useState("");
|
|||
|
|
const [styleResult, setStyleResult] = useState<Style8DResult | null>(null);
|
|||
|
|
const assistantInputRef = useRef<HTMLInputElement>(null);
|
|||
|
|
const { activeJobs } = useJobs();
|
|||
|
|
const { feedback, clearFeedback, showError, showSuccess } = useActionFeedback();
|
|||
|
|
const [assistantMessages, setAssistantMessages] = useState<AssistantMessage[]>([
|
|||
|
|
{
|
|||
|
|
role: "assistant",
|
|||
|
|
content: "跟我說你想經營的方向,我會把這頁策略欄位先幫你補好。",
|
|||
|
|
},
|
|||
|
|
]);
|
|||
|
|
const styleJob = activeJobs.find(
|
|||
|
|
(job) => job.type === "style-8d" && (!draft?.id || job.accountId === draft.id)
|
|||
|
|
);
|
|||
|
|
const visibleStyle =
|
|||
|
|
styleResult ?? createEmptyStyle8DProfile(benchmarkUsername.replace(/^@/, "").trim());
|
|||
|
|
|
|||
|
|
const load = useCallback(async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
const res = await fetch("/api/accounts");
|
|||
|
|
const data = await parseFetchJson<{ accounts?: Account[]; activeAccountId?: string }>(res);
|
|||
|
|
const rows = (data.accounts ?? []) as Account[];
|
|||
|
|
const active = rows.find((row) => row.id === data.activeAccountId) ?? rows[0] ?? null;
|
|||
|
|
setAccount(active);
|
|||
|
|
setDraft(active);
|
|||
|
|
setStyleResult(parseStyle8DProfile(active?.styleProfile));
|
|||
|
|
setBenchmarkUsername(active?.styleBenchmark ?? "");
|
|||
|
|
setLoading(false);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
load();
|
|||
|
|
}, [load]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
function onJobCompleted(event: Event) {
|
|||
|
|
const { job } = (event as CustomEvent).detail as {
|
|||
|
|
job: { type: string; accountId?: string | null; status: string };
|
|||
|
|
};
|
|||
|
|
if (job.type === "style-8d" && job.accountId === account?.id && job.status === "completed") {
|
|||
|
|
void load();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
window.addEventListener("job-completed", onJobCompleted);
|
|||
|
|
return () => window.removeEventListener("job-completed", onJobCompleted);
|
|||
|
|
}, [account?.id, load]);
|
|||
|
|
|
|||
|
|
async function createAccount() {
|
|||
|
|
if (!newName.trim()) return;
|
|||
|
|
clearFeedback();
|
|||
|
|
setBusy("create");
|
|||
|
|
const res = await fetch("/api/accounts", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ displayName: newName.trim() }),
|
|||
|
|
});
|
|||
|
|
let data: { error?: string } = {};
|
|||
|
|
try {
|
|||
|
|
data = await parseFetchJson(res);
|
|||
|
|
} catch (err) {
|
|||
|
|
setBusy(null);
|
|||
|
|
showError(err instanceof Error ? err.message : "伺服器回應異常", "建立失敗");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setBusy(null);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
showError(data.error ?? "無法建立帳號", "建立失敗");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setNewName("");
|
|||
|
|
showSuccess("帳號策略已建立");
|
|||
|
|
load();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function save() {
|
|||
|
|
if (!draft) return;
|
|||
|
|
clearFeedback();
|
|||
|
|
setBusy("save");
|
|||
|
|
const res = await fetch(`/api/accounts/${draft.id}`, {
|
|||
|
|
method: "PATCH",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
displayName: draft.displayName,
|
|||
|
|
username: draft.username,
|
|||
|
|
persona: draft.persona,
|
|||
|
|
styleProfile: draft.styleProfile,
|
|||
|
|
styleBenchmark: draft.styleBenchmark,
|
|||
|
|
brief: draft.brief,
|
|||
|
|
productBrief: draft.productBrief,
|
|||
|
|
targetAudience: draft.targetAudience,
|
|||
|
|
goals: draft.goals,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
let data: { error?: string } = {};
|
|||
|
|
try {
|
|||
|
|
data = await parseFetchJson(res);
|
|||
|
|
} catch (err) {
|
|||
|
|
setBusy(null);
|
|||
|
|
showError(err instanceof Error ? err.message : "伺服器回應異常", "儲存失敗");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setBusy(null);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
showError(data.error ?? "無法儲存策略", "儲存失敗");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
showSuccess("帳號策略已儲存");
|
|||
|
|
load();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateDraft(patch: Partial<Account>) {
|
|||
|
|
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function analyzeBenchmark() {
|
|||
|
|
if (!draft || !benchmarkUsername.trim()) return;
|
|||
|
|
clearFeedback();
|
|||
|
|
setBusy("8d");
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/accounts/${draft.id}/style-analysis`, {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ benchmarkUsername }),
|
|||
|
|
});
|
|||
|
|
const data = await parseFetchJson<{ error?: string; jobId?: string; message?: string }>(res);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
showError(data.error ?? "無法完成 8D 分析", "8D 分析失敗");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
showSuccess(data.message ?? "8D 分析已在背景執行,可自由切換頁面");
|
|||
|
|
window.dispatchEvent(new Event("haixun:jobs-updated"));
|
|||
|
|
} catch (error) {
|
|||
|
|
showError(error instanceof Error ? error.message : "8D 分析失敗", "8D 分析失敗");
|
|||
|
|
} finally {
|
|||
|
|
setBusy(null);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateStyleDimension(key: Style8DKey, summary: string) {
|
|||
|
|
const base = styleResult ?? createEmptyStyle8DProfile(benchmarkUsername.replace(/^@/, "").trim());
|
|||
|
|
const next: Style8DResult = {
|
|||
|
|
...base,
|
|||
|
|
analysis: {
|
|||
|
|
...base.analysis,
|
|||
|
|
[key]: { ...base.analysis[key], summary },
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
setStyleResult(next);
|
|||
|
|
updateDraft({ styleProfile: JSON.stringify(next) });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const clearAssistantInput = useCallback(() => {
|
|||
|
|
setAssistantInput("");
|
|||
|
|
if (assistantInputRef.current) {
|
|||
|
|
assistantInputRef.current.value = "";
|
|||
|
|
}
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
async function askAssistant(preset?: string) {
|
|||
|
|
if (!draft) return;
|
|||
|
|
const instruction = (preset ?? assistantInput).trim();
|
|||
|
|
if (!instruction || assistantBusy) return;
|
|||
|
|
|
|||
|
|
flushSync(() => {
|
|||
|
|
if (!preset) clearAssistantInput();
|
|||
|
|
setAssistantMessages((prev) => [...prev, { role: "user", content: instruction }]);
|
|||
|
|
});
|
|||
|
|
setAssistantBusy(true);
|
|||
|
|
|
|||
|
|
const res = await fetch("/api/accounts/strategy-assistant", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
instruction,
|
|||
|
|
current: {
|
|||
|
|
displayName: draft.displayName,
|
|||
|
|
username: draft.username,
|
|||
|
|
brief: draft.brief,
|
|||
|
|
persona: draft.persona,
|
|||
|
|
targetAudience: draft.targetAudience,
|
|||
|
|
productBrief: draft.productBrief,
|
|||
|
|
goals: draft.goals,
|
|||
|
|
style8D: Object.fromEntries(
|
|||
|
|
STYLE_8D_KEYS.map((key) => [key, visibleStyle.analysis[key]?.summary ?? null])
|
|||
|
|
),
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
let data: {
|
|||
|
|
error?: string;
|
|||
|
|
fields?: Partial<Account> & { style8D?: Partial<Record<Style8DKey, string | null>> };
|
|||
|
|
message?: string;
|
|||
|
|
} = {};
|
|||
|
|
try {
|
|||
|
|
data = await parseFetchJson(res);
|
|||
|
|
} catch (err) {
|
|||
|
|
setAssistantBusy(false);
|
|||
|
|
const message = err instanceof Error ? err.message : "伺服器回應異常";
|
|||
|
|
setAssistantMessages((prev) => [...prev, { role: "assistant", content: message }]);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setAssistantBusy(false);
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const message = data.error ?? "小幫手暫時無法產生內容";
|
|||
|
|
setAssistantMessages((prev) => [...prev, { role: "assistant", content: message }]);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fields = data.fields ?? {};
|
|||
|
|
const { style8D, ...accountFields } = fields;
|
|||
|
|
let nextStyleResult = styleResult;
|
|||
|
|
if (style8D && Object.values(style8D).some((value) => value?.trim())) {
|
|||
|
|
const base = styleResult ?? createEmptyStyle8DProfile(benchmarkUsername.replace(/^@/, "").trim());
|
|||
|
|
nextStyleResult = {
|
|||
|
|
...base,
|
|||
|
|
analysis: { ...base.analysis },
|
|||
|
|
};
|
|||
|
|
for (const key of STYLE_8D_KEYS) {
|
|||
|
|
const summary = style8D[key]?.trim();
|
|||
|
|
if (summary) {
|
|||
|
|
nextStyleResult.analysis[key] = { ...base.analysis[key], summary };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
setStyleResult(nextStyleResult);
|
|||
|
|
}
|
|||
|
|
setDraft((prev) =>
|
|||
|
|
prev
|
|||
|
|
? {
|
|||
|
|
...prev,
|
|||
|
|
...Object.fromEntries(
|
|||
|
|
Object.entries(accountFields).filter(([, value]) => value !== null && value !== undefined)
|
|||
|
|
),
|
|||
|
|
...(nextStyleResult ? { styleProfile: JSON.stringify(nextStyleResult) } : {}),
|
|||
|
|
}
|
|||
|
|
: prev
|
|||
|
|
);
|
|||
|
|
setAssistantMessages((prev) => [
|
|||
|
|
...prev,
|
|||
|
|
{
|
|||
|
|
role: "assistant",
|
|||
|
|
content: data.message ?? "我已經依照你的方向補上這頁欄位,你可以再微調後儲存。",
|
|||
|
|
},
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<PageHeader
|
|||
|
|
title="帳號策略"
|
|||
|
|
description="人設與受眾定位"
|
|||
|
|
action={
|
|||
|
|
draft && (
|
|||
|
|
<Button onClick={save} disabled={busy === "save"}>
|
|||
|
|
{busy === "save" && <Loader2 className="h-4 w-4 animate-spin" />}
|
|||
|
|
儲存策略
|
|||
|
|
</Button>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{feedback && (
|
|||
|
|
<InlineAlert
|
|||
|
|
type={feedback.type}
|
|||
|
|
title={feedback.title}
|
|||
|
|
message={feedback.message}
|
|||
|
|
onDismiss={clearFeedback}
|
|||
|
|
className="mb-4"
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loading ? (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<div className="skeleton h-72 animate-pulse" />
|
|||
|
|
<div className="skeleton h-48 animate-pulse" />
|
|||
|
|
</div>
|
|||
|
|
) : !draft ? (
|
|||
|
|
<EmptyState
|
|||
|
|
icon={UserRound}
|
|||
|
|
title="先建立一個經營帳號"
|
|||
|
|
description="側欄新增帳號後設定策略"
|
|||
|
|
action={
|
|||
|
|
<div className="flex w-full max-w-md flex-col gap-2 sm:flex-row">
|
|||
|
|
<Input
|
|||
|
|
value={newName}
|
|||
|
|
onChange={(e) => setNewName(e.target.value)}
|
|||
|
|
placeholder="例如:個人品牌、產品號"
|
|||
|
|
/>
|
|||
|
|
<Button onClick={createAccount} disabled={busy === "create"}>
|
|||
|
|
<Plus className="h-4 w-4" />
|
|||
|
|
建立
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<div className="space-y-5">
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader>
|
|||
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
|||
|
|
<div>
|
|||
|
|
<CardTitle>{account?.displayName ?? account?.username ?? "未命名帳號"}</CardTitle>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
<Badge variant="success">目前帳號</Badge>
|
|||
|
|
</div>
|
|||
|
|
</CardHeader>
|
|||
|
|
<CardContent className="space-y-4">
|
|||
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label>品牌 / 帳號名稱</Label>
|
|||
|
|
<Input
|
|||
|
|
value={draft.displayName ?? ""}
|
|||
|
|
onChange={(e) => updateDraft({ displayName: e.target.value })}
|
|||
|
|
placeholder="給自己看的名稱"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label>公開帳號名稱(選填)</Label>
|
|||
|
|
<Input
|
|||
|
|
value={draft.username ?? ""}
|
|||
|
|
onChange={(e) => updateDraft({ username: e.target.value })}
|
|||
|
|
placeholder="@username"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label>一句話定位</Label>
|
|||
|
|
<Textarea
|
|||
|
|
rows={3}
|
|||
|
|
value={draft.brief ?? ""}
|
|||
|
|
onChange={(e) => updateDraft({ brief: e.target.value })}
|
|||
|
|
placeholder="這個帳號幫誰,用什麼觀點,解決什麼問題"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label>人設與語氣</Label>
|
|||
|
|
<Textarea
|
|||
|
|
rows={5}
|
|||
|
|
value={draft.persona ?? ""}
|
|||
|
|
onChange={(e) => updateDraft({ persona: e.target.value })}
|
|||
|
|
placeholder="像誰、怎麼說話、常用語氣、哪些話不要說"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
<Card id="style-8d" className="scroll-mt-6">
|
|||
|
|
<CardHeader>
|
|||
|
|
<CardTitle className="flex items-center gap-2"><ScanText className="h-4 w-4 text-primary" />對標帳號 8D 分析</CardTitle>
|
|||
|
|
<CardDescription>分析後會直接存成這個帳號的風格策略;之後產貼文、優化與回覆都會自動套用。</CardDescription>
|
|||
|
|
</CardHeader>
|
|||
|
|
<CardContent className="space-y-4">
|
|||
|
|
<div className="flex flex-col gap-2 sm:flex-row">
|
|||
|
|
<Input value={benchmarkUsername} onChange={(event) => setBenchmarkUsername(event.target.value)} placeholder="@對標帳號" />
|
|||
|
|
<Button onClick={analyzeBenchmark} disabled={busy === "8d" || !!styleJob || !benchmarkUsername.trim()} className="sm:shrink-0">
|
|||
|
|
{busy === "8d" || styleJob ? <Loader2 className="h-4 w-4 animate-spin" /> : <ScanText className="h-4 w-4" />}
|
|||
|
|
{styleJob ? "8D 任務進行中" : busy === "8d" ? "建立任務中…" : "開始 8D 分析"}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
{styleJob && (
|
|||
|
|
<div className="rounded-xl border border-primary/20 bg-primary/[0.035] p-3">
|
|||
|
|
<p className="mb-2 text-xs font-medium">可自由切換頁面,任務不會中斷</p>
|
|||
|
|
<JobProgressPanel
|
|||
|
|
summary={styleJob.progress}
|
|||
|
|
progressDetailRaw={styleJob.progressDetail}
|
|||
|
|
jobType="style-8d"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{!styleResult && !styleJob && (
|
|||
|
|
<InlineAlert
|
|||
|
|
type="info"
|
|||
|
|
title="尚未建立 8D 風格策略"
|
|||
|
|
message="八個欄位已列在下方。你可以先手動填寫,或輸入對標帳號交給 AI 分析。"
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
{styleResult && (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<div className="grid gap-3 sm:grid-cols-4">
|
|||
|
|
<div className="rounded-xl bg-muted/70 p-3"><p className="text-xs text-muted-foreground">近期樣本</p><p className="mt-1 text-lg font-semibold">{styleResult.postCount} 篇</p></div>
|
|||
|
|
<div className="rounded-xl bg-muted/70 p-3"><p className="text-xs text-muted-foreground">互動中位數</p><p className="mt-1 text-lg font-semibold">{styleResult.engagement.medianInteractions}</p></div>
|
|||
|
|
<div className="rounded-xl bg-muted/70 p-3"><p className="text-xs text-muted-foreground">平均互動</p><p className="mt-1 text-lg font-semibold">{styleResult.engagement.averageInteractions}</p></div>
|
|||
|
|
<div className="rounded-xl bg-muted/70 p-3"><p className="text-xs text-muted-foreground">達 {styleResult.engagement.threshold} 互動</p><p className="mt-1 text-lg font-semibold">{styleResult.engagement.postsAboveThreshold} 篇</p></div>
|
|||
|
|
</div>
|
|||
|
|
<InlineAlert
|
|||
|
|
type={styleResult.engagement.verdict === "strong" ? "success" : "warning"}
|
|||
|
|
title={styleResult.engagement.verdict === "strong" ? "近期互動穩定,適合當對標" : styleResult.engagement.verdict === "unknown" ? "公開互動資料不足" : "可參考風格,但互動門檻不高"}
|
|||
|
|
message="判斷採用近期貼文的讚 + 回覆×2;公開頁沒有可靠瀏覽數時不會捏造。"
|
|||
|
|
/>
|
|||
|
|
<div className="flex flex-wrap items-center gap-2">
|
|||
|
|
<Badge variant="success">已套用到帳號生成流程</Badge>
|
|||
|
|
<span className="text-xs text-muted-foreground">對標 @{styleResult.username} · 每個欄位都可再調整</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|||
|
|
{STYLE_8D_KEYS.map((key) => {
|
|||
|
|
const value = visibleStyle.analysis[key];
|
|||
|
|
return (
|
|||
|
|
<div key={key} className="rounded-xl border border-border bg-muted/40 p-4">
|
|||
|
|
<Label className="text-xs font-semibold tracking-wider text-primary">{STYLE_8D_LABELS[key]}</Label>
|
|||
|
|
<Textarea
|
|||
|
|
rows={3}
|
|||
|
|
className="mt-2 bg-card"
|
|||
|
|
value={value?.summary ?? ""}
|
|||
|
|
onChange={(event) => updateStyleDimension(key, event.target.value)}
|
|||
|
|
placeholder={`等待 AI 產生${STYLE_8D_LABELS[key]},或先手動填寫`}
|
|||
|
|
/>
|
|||
|
|
{value?.evidence?.length ? <p className="mt-2 text-xs leading-5 text-muted-foreground">證據:{value.evidence.join("、")}</p> : null}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
{styleResult && (
|
|||
|
|
<div className="flex flex-wrap items-center gap-2">
|
|||
|
|
<Button variant="outline" onClick={() => updateDraft({ persona: styleResult.personaDraft })}>同步成上方人設文字</Button>
|
|||
|
|
<p className="text-xs text-muted-foreground">8D 已經會自動生效;這顆按鈕只在你想把摘要同步到人設文字時使用。</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader>
|
|||
|
|
<CardTitle>受眾與轉換</CardTitle>
|
|||
|
|
|
|||
|
|
</CardHeader>
|
|||
|
|
<CardContent className="space-y-4">
|
|||
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label>目標受眾</Label>
|
|||
|
|
<Textarea
|
|||
|
|
rows={5}
|
|||
|
|
value={draft.targetAudience ?? ""}
|
|||
|
|
onChange={(e) => updateDraft({ targetAudience: e.target.value })}
|
|||
|
|
placeholder="想吸引誰、他們的痛點、渴望、常用語言"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label>產品 / 服務敘事</Label>
|
|||
|
|
<Textarea
|
|||
|
|
rows={5}
|
|||
|
|
value={draft.productBrief ?? ""}
|
|||
|
|
onChange={(e) => updateDraft({ productBrief: e.target.value })}
|
|||
|
|
placeholder="你提供什麼、解決什麼、自然導向哪個行動"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label>近期經營目標</Label>
|
|||
|
|
<Textarea
|
|||
|
|
rows={3}
|
|||
|
|
value={draft.goals ?? ""}
|
|||
|
|
onChange={(e) => updateDraft({ goals: e.target.value })}
|
|||
|
|
placeholder="例如:提高留言、導流私訊、測試內容市場、建立專家感"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{draft && (
|
|||
|
|
<div className="mobile-floating fixed right-4 z-30 flex max-w-[calc(100vw-2rem)] flex-col items-end gap-3 lg:right-5">
|
|||
|
|
{assistantOpen && (
|
|||
|
|
<Card className="w-[360px] max-w-full border-foreground/10 shadow-2xl">
|
|||
|
|
<CardHeader className="border-b border-border pb-3">
|
|||
|
|
<div className="flex items-start justify-between gap-3">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<div className="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-foreground text-background shadow-sm">
|
|||
|
|
<Sparkles className="h-5 w-5" />
|
|||
|
|
<span className="absolute -right-0.5 -top-0.5 h-3 w-3 rounded-full border-2 border-card bg-success" />
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<CardTitle className="text-[15px]">策略小幫手</CardTitle>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<Button
|
|||
|
|
variant="ghost"
|
|||
|
|
size="icon"
|
|||
|
|
className="h-8 w-8"
|
|||
|
|
onClick={() => setAssistantOpen(false)}
|
|||
|
|
>
|
|||
|
|
<X className="h-4 w-4" />
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</CardHeader>
|
|||
|
|
<CardContent className="space-y-3 p-3">
|
|||
|
|
<div className="max-h-64 space-y-2 overflow-y-auto pr-1">
|
|||
|
|
{assistantMessages.map((message, index) => (
|
|||
|
|
<div
|
|||
|
|
key={`${message.role}-${index}`}
|
|||
|
|
className={
|
|||
|
|
message.role === "user"
|
|||
|
|
? "ml-8 rounded-lg bg-foreground px-3 py-2 text-xs leading-relaxed text-background"
|
|||
|
|
: "mr-8 rounded-lg bg-muted px-3 py-2 text-xs leading-relaxed text-foreground"
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
{message.content}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
{assistantBusy && (
|
|||
|
|
<div className="mr-8 flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-xs text-muted-foreground">
|
|||
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|||
|
|
正在整理成可用策略…
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
size="sm"
|
|||
|
|
disabled={assistantBusy}
|
|||
|
|
onClick={() => askAssistant("我想經營醫療或健康相關帳號,請幫我補成合規、專業但親切的人設策略。")}
|
|||
|
|
>
|
|||
|
|
醫療健康
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
size="sm"
|
|||
|
|
disabled={assistantBusy}
|
|||
|
|
onClick={() => askAssistant("請根據目前欄位,把語氣變得更像真人、更適合 Threads。")}
|
|||
|
|
>
|
|||
|
|
變自然
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<Input
|
|||
|
|
ref={assistantInputRef}
|
|||
|
|
value={assistantInput}
|
|||
|
|
onChange={(event) => setAssistantInput(event.target.value)}
|
|||
|
|
onKeyDown={(event) => {
|
|||
|
|
if (event.nativeEvent.isComposing) return;
|
|||
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|||
|
|
event.preventDefault();
|
|||
|
|
askAssistant();
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
placeholder="例如:我想經營醫療衛教,受眾是上班族"
|
|||
|
|
disabled={assistantBusy}
|
|||
|
|
/>
|
|||
|
|
<Button
|
|||
|
|
type="button"
|
|||
|
|
size="icon"
|
|||
|
|
onClick={() => askAssistant()}
|
|||
|
|
disabled={assistantBusy}
|
|||
|
|
>
|
|||
|
|
{assistantBusy ? (
|
|||
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|||
|
|
) : (
|
|||
|
|
<Send className="h-4 w-4" />
|
|||
|
|
)}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
|||
|
|
小幫手可修改一般策略與 8D 欄位;套用後仍由你審核並按「儲存策略」。
|
|||
|
|
</p>
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<Button
|
|||
|
|
className="h-12 gap-2 rounded-full px-5 shadow-xl"
|
|||
|
|
onClick={() => setAssistantOpen((open) => !open)}
|
|||
|
|
>
|
|||
|
|
{assistantOpen ? <MessageCircle className="h-4 w-4" /> : <WandSparkles className="h-4 w-4" />}
|
|||
|
|
{assistantOpen ? "收起小幫手" : "策略小幫手"}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|