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

647 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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