haixunMaster/app/(dashboard)/accounts/page.tsx

647 lines
25 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
"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>
);
}