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>
|
||
);
|
||
}
|