fix swagger

This commit is contained in:
王性驊 2026-06-30 17:30:04 +08:00
parent 1c85024f6b
commit fb28b5a7e5
5 changed files with 593 additions and 152 deletions

View File

@ -434,6 +434,7 @@ export const api = {
personas: () => apiRequest<{ list: Persona[] }>("/api/v1/personas/", { auth: true }),
createPersona: (body: { display_name?: string }) => apiRequest<Persona>("/api/v1/personas/", { method: "POST", body, auth: true }),
updatePersona: (id: string, body: Record<string, unknown>) => apiRequest<Persona>(`/api/v1/personas/${id}`, { method: "PATCH", body, auth: true }),
deletePersona: (id: string) => apiRequest<null>(`/api/v1/personas/${id}`, { method: "DELETE", auth: true }),
startStyleAnalysis: (id: string, benchmark_username: string) =>
apiRequest<{ job_id: string; status: string }>(`/api/v1/personas/${id}/style-analysis`, { method: "POST", body: { benchmark_username }, auth: true }),
personaDrafts: (id: string) => apiRequest<{ list: CopyDraft[]; total: number }>(`/api/v1/personas/${id}/copy-drafts`, { auth: true }),

View File

@ -0,0 +1,182 @@
import { useEffect, useState } from "react";
import { api, JobRun } from "../api/haixun";
import { Badge, Button } from "./ui";
import { formatNano } from "../lib/format";
import { jobStatusBadgeClass, jobStatusLabel } from "../lib/jobStatus";
import { AcIcon } from "./AcIcon";
const ACTIVE_STATUSES = ["running", "queued", "pending", "waiting_worker", "cancel_requested"];
export function JobMonitor() {
const [jobs, setJobs] = useState<JobRun[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const activeJobs = jobs.filter((j) => ACTIVE_STATUSES.includes(j.status));
async function load() {
try {
const data = await api.jobs(1);
setJobs(data.list || []);
} catch {
// silent fail for background monitor
}
}
useEffect(() => {
void load();
const timer = window.setInterval(() => void load(), 4000);
return () => window.clearInterval(timer);
}, []);
async function cancel(job: JobRun) {
setLoading(true);
try {
await api.cancelJob(job.id);
await load();
} finally {
setLoading(false);
}
}
const count = activeJobs.length;
return (
<>
{/* Floating trigger button (always visible) */}
<button
onClick={() => setOpen(!open)}
className="job-fab"
style={{
position: "fixed",
bottom: "calc(4.5rem + env(safe-area-inset-bottom, 0px))",
right: "1rem",
zIndex: 110,
background: count > 0 ? "var(--hx-brand)" : "var(--hx-surface-muted)",
color: count > 0 ? "white" : "var(--hx-muted)",
border: count > 0 ? "none" : "1px solid var(--hx-line)",
borderRadius: "999px",
padding: "0.4rem 0.85rem",
fontSize: "0.78rem",
fontWeight: 700,
display: "flex",
alignItems: "center",
gap: "0.4rem",
boxShadow: "var(--shadow-card)",
cursor: "pointer",
}}
title={count > 0 ? `${count} 個任務進行中` : "查看任務狀態"}
>
<AcIcon name="job" size={17} />
<span>
{count > 0 ? `${count} 進行中` : "任務"}
</span>
</button>
{/* Floating panel */}
{open && (
<div
style={{
position: "fixed",
bottom: "calc(6.5rem + env(safe-area-inset-bottom, 0px))",
right: "1rem",
zIndex: 120,
width: "min(340px, calc(100vw - 2rem))",
maxHeight: "58vh",
background: "var(--hx-surface)",
border: "1px solid var(--hx-line)",
borderRadius: "var(--radius-lg)",
boxShadow: "var(--shadow-card)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.6rem 0.85rem",
borderBottom: "1px solid var(--hx-line)",
background: "var(--hx-surface-muted)",
}}
>
<div style={{ fontWeight: 700, display: "flex", alignItems: "center", gap: "0.4rem" }}>
<AcIcon name="job" size={16} />
{count > 0 ? `(${count})` : ""}
</div>
<button
onClick={() => setOpen(false)}
style={{ background: "transparent", border: "none", fontSize: "1.1rem", lineHeight: 1, cursor: "pointer", color: "var(--hx-muted)" }}
aria-label="關閉"
>
×
</button>
</div>
<div style={{ overflowY: "auto", padding: "0.5rem", flex: 1 }}>
{activeJobs.length === 0 ? (
<p className="muted" style={{ padding: "0.5rem 0.3rem" }}></p>
) : (
activeJobs.map((job) => (
<div
key={job.id}
style={{
border: "1px solid var(--hx-line)",
borderRadius: "var(--radius-md)",
padding: "0.55rem 0.7rem",
marginBottom: "0.5rem",
background: "var(--hx-surface)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.25rem" }}>
<strong style={{ fontSize: "0.9rem" }}>{job.template_type}</strong>
<Badge variant={jobStatusBadgeClass(job.status)}>{jobStatusLabel(job.status)}</Badge>
</div>
{job.progress && (
<div style={{ fontSize: "0.8rem", color: "var(--hx-ink-secondary)", marginBottom: "0.2rem" }}>
{job.progress.percentage ?? 0}% {job.progress.summary ? `· ${job.progress.summary}` : ""}
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.72rem" }}>
<span className="muted">{formatNano(job.update_at)}</span>
{["running", "queued", "pending"].includes(job.status) && (
<Button
variant="danger"
onClick={() => void cancel(job)}
disabled={loading}
style={{ fontSize: "0.7rem", padding: "0.15rem 0.5rem", minHeight: "1.6rem" }}
>
</Button>
)}
</div>
<div style={{ fontSize: "0.65rem", color: "var(--hx-subtle)", marginTop: "0.2rem", wordBreak: "break-all" }}>
{job.id}
</div>
</div>
))
)}
</div>
<div style={{ padding: "0.4rem 0.7rem", borderTop: "1px solid var(--hx-line)", background: "var(--hx-surface-muted)" }}>
<Button
onClick={() => {
window.location.hash = "jobs";
setOpen(false);
}}
style={{ width: "100%", fontSize: "0.75rem" }}
>
</Button>
</div>
</div>
)}
</>
);
}

View File

@ -6,6 +6,7 @@ import { AcIcon } from "./AcIcon";
import { AuthTicketIcon, SceneDecor } from "./AuthDecor";
import { ThemeToggle } from "./ThemeToggle";
import { Button } from "./ui";
import { JobMonitor } from "./JobMonitor";
export function Layout({
active,
@ -148,6 +149,7 @@ export function Layout({
</main>
</div>
<MobileBottomNav active={active} onNavigate={navigateApp} hasAccounts={accounts.length > 0} />
<JobMonitor />
</div>
);
}

View File

@ -305,6 +305,138 @@ a {
box-shadow: var(--shadow-card);
}
/* Persona page: beautiful 8D + voice + drafts */
.persona-item {
text-align: left;
background: var(--hx-surface);
border: 1px solid var(--hx-line);
border-radius: var(--radius-md);
padding: 0.85rem 0.95rem;
cursor: pointer;
transition: border-color 0.1s ease, background 0.1s ease, box-shadow 0.1s ease;
}
.persona-item:hover {
border-color: var(--hx-brand);
}
.persona-item.active {
border-color: var(--hx-brand);
background: var(--hx-brand-soft);
box-shadow: 0 0 0 1px var(--hx-brand);
}
.persona-item .title-row {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: space-between;
font-weight: 700;
}
.persona-item .brief {
margin: 0.35rem 0 0;
font-size: 0.9rem;
line-height: 1.5;
}
.dim-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
@media (min-width: 860px) {
.dim-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
.dim-card {
background: var(--hx-surface-muted);
border: 1px solid var(--hx-line);
border-radius: var(--radius-md);
padding: 0.75rem 0.85rem;
}
.dim-head {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.35rem;
}
.dim-badge {
font-size: 0.72rem;
font-weight: 900;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--hx-brand);
color: white;
letter-spacing: 0.02em;
flex: 0 0 auto;
}
.dim-title {
font-weight: 800;
font-size: 0.9rem;
color: var(--hx-ink-secondary);
}
.dim-summary {
font-size: 0.95rem;
line-height: 1.6;
color: var(--hx-ink);
margin: 0.2rem 0 0;
}
.evidence {
margin: 0.3rem 0 0;
padding-left: 0.95rem;
font-size: 0.76rem;
color: var(--hx-muted);
line-height: 1.4;
}
.evidence li {
margin-bottom: 0.1rem;
}
.voice-card {
background: color-mix(in srgb, var(--hx-brand-soft) 70%, var(--hx-surface));
border: 1px solid var(--hx-brand);
border-radius: var(--radius-lg);
padding: 0.95rem 1.1rem;
}
.voice-card h4 {
margin: 0 0 0.4rem;
color: var(--hx-brand);
font-size: 0.92rem;
font-weight: 800;
letter-spacing: 0.05em;
}
.voice-section {
margin-top: 0.5rem;
}
.voice-section label {
font-size: 0.72rem;
font-weight: 700;
color: var(--hx-brand);
display: block;
margin-bottom: 0.08rem;
}
.voice-section p {
margin: 0;
font-size: 0.95rem;
white-space: pre-wrap;
color: var(--hx-ink);
line-height: 1.65;
}
.draft-card {
background: var(--hx-surface);
border: 1px solid var(--hx-line);
border-radius: var(--radius-md);
padding: 0.8rem;
}
.draft-text {
background: var(--hx-surface-muted);
border: 1px solid var(--hx-line);
border-radius: var(--radius-sm);
padding: 0.7rem 0.75rem;
font-size: 0.95rem;
line-height: 1.6;
white-space: pre-wrap;
min-height: 5.2rem;
}
.field {
display: grid;
gap: 0.35rem;
@ -488,6 +620,24 @@ th {
opacity: 0.58;
}
/* Floating job monitor */
.job-fab {
transition: all 0.15s ease;
}
.job-fab:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-soft);
}
.job-fab:active {
transform: scale(0.98);
}
@media (min-width: 1024px) {
.job-fab {
bottom: 1.25rem !important;
}
}
.auth-page {
position: relative;
z-index: 1;

View File

@ -1,22 +1,15 @@
import { useEffect, useState } from "react";
import { api, CopyDraft, Persona, StylePreset, ThreadsAccount } from "../api/haixun";
import { Badge, Button, Card, ErrorText, Field, Input, PageTitle, Select, Textarea } from "../components/ui";
import { inputDateTimeToNano, shortText } from "../lib/format";
import { api, Persona, StylePreset } from "../api/haixun";
import { Badge, Button, Card, ErrorText, Field, Input, PageTitle, Textarea } from "../components/ui";
import { shortText } from "../lib/format";
import { AcIcon } from "../components/AcIcon";
export function PersonasPage() {
const [personas, setPersonas] = useState<Persona[]>([]);
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [selected, setSelected] = useState<Persona>();
const [drafts, setDrafts] = useState<CopyDraft[]>([]);
const [presets, setPresets] = useState<StylePreset[]>([]);
const [selectedDraftIds, setSelectedDraftIds] = useState<string[]>([]);
const [scheduleAccountId, setScheduleAccountId] = useState("");
const [scheduleStartAt, setScheduleStartAt] = useState("");
const [presetName, setPresetName] = useState("");
const [presetTone, setPresetTone] = useState("");
const [presetCTA, setPresetCTA] = useState("");
const [presetBannedWords, setPresetBannedWords] = useState("");
const [name, setName] = useState("");
const [displayName, setDisplayName] = useState("");
const [benchmark, setBenchmark] = useState("");
const [brief, setBrief] = useState("");
const [error, setError] = useState<unknown>();
@ -25,18 +18,15 @@ export function PersonasPage() {
async function load(nextId?: string) {
setError(undefined);
try {
const [data, accountData] = await Promise.all([api.personas(), api.threadsAccounts()]);
const data = await api.personas();
setPersonas(data.list || []);
setAccounts(accountData.list || []);
setScheduleAccountId((current) => current || accountData.active_account_id || accountData.list?.[0]?.id || "");
const persona = data.list.find((item) => item.id === (nextId || selected?.id)) || data.list[0];
setSelected(persona);
setDisplayName(persona?.display_name || "");
setBrief(persona?.brief || "");
if (persona) {
const [draftData, presetData] = await Promise.all([api.personaDrafts(persona.id), api.stylePresets(persona.id)]);
setDrafts(draftData.list || []);
const presetData = await api.stylePresets(persona.id);
setPresets(presetData.list || []);
setSelectedDraftIds([]);
}
} catch (err) {
setError(err);
@ -47,6 +37,14 @@ export function PersonasPage() {
void load();
}, []);
// 切換人設時同步名稱與摘要
useEffect(() => {
if (selected) {
setDisplayName(selected.display_name || "");
setBrief(selected.brief || "");
}
}, [selected?.id]);
async function createPersona() {
const created = await api.createPersona({ display_name: name || "新島民人設" });
setName("");
@ -55,192 +53,300 @@ export function PersonasPage() {
async function savePersona() {
if (!selected) return;
const updated = await api.updatePersona(selected.id, { brief });
const updated = await api.updatePersona(selected.id, {
display_name: displayName || undefined,
brief
});
setSelected(updated);
setMessage("人設摘要已儲存");
setDisplayName(updated.display_name || "");
setBrief(updated.brief || "");
setMessage("人設已更新");
// 樂觀更新列表中的名稱
setPersonas((prev) => prev.map((p) => p.id === updated.id ? { ...p, display_name: updated.display_name, brief: updated.brief } : p));
await load(updated.id);
}
async function deletePersona(id: string) {
if (!window.confirm("確定要刪除此人設嗎?\n刪除後將從列表消失相關草稿與設定會保留但無法再以此人設發文。")) return;
try {
await api.deletePersona(id);
const wasCurrent = selected?.id === id;
setMessage("人設已刪除");
// 重新載入若刪的是目前選取的load 會自動挑第一個
await load(wasCurrent ? undefined : selected?.id);
} catch (err) {
setError(err);
}
}
async function startStyle() {
if (!selected || !benchmark) return;
const data = await api.startStyleAnalysis(selected.id, benchmark);
setMessage(`8D 任務已建立:${data.job_id}`);
}
async function saveDraft(draft: CopyDraft, text: string) {
async function deletePreset(presetId: string) {
if (!selected) return;
await api.updateDraft(selected.id, draft.id, { text });
await load(selected.id);
if (!window.confirm("確定要刪除此語調預設嗎?")) return;
try {
await api.deleteStylePreset(selected.id, presetId);
setMessage("語調預設已刪除");
await load(selected.id);
} catch (err) {
setError(err);
}
}
async function scheduleDrafts() {
if (!selected || !scheduleAccountId || selectedDraftIds.length === 0) return;
const data = await api.schedulePersonaDrafts(selected.id, {
account_id: scheduleAccountId,
draft_ids: selectedDraftIds,
start_at: inputDateTimeToNano(scheduleStartAt),
timezone: "Asia/Taipei",
slots: [
{ weekday: 1, time: "09:30" },
{ weekday: 3, time: "12:30" },
{ weekday: 5, time: "18:30" }
],
mode: "recommended"
});
setMessage(data.message);
await load(selected.id);
}
async function savePreset(apply = false) {
if (!selected || !presetName.trim()) return;
const saved = await api.upsertStylePreset(selected.id, "new", {
name: presetName,
tone: presetTone,
cta: splitLines(presetCTA),
banned_words: splitLines(presetBannedWords),
apply
});
setMessage(apply ? `已儲存並套用語調:${saved.name}` : `已儲存語調:${saved.name}`);
setPresetName("");
setPresetTone("");
setPresetCTA("");
setPresetBannedWords("");
await load(selected.id);
}
return (
<div className="page-grid">
<PageTitle title="人設與文案" subtitle="建立口吻、人設 8D、查看與微調 AI 產出的文案草稿。" action={<Button onClick={() => void load()}></Button>} />
<div className="page-grid" style={{ gap: "1.15rem" }}>
<PageTitle
title="人設"
subtitle="管理人設與 8D 風格分析。主要語調與口吻來自 8D 分析的人設語音摘要。"
action={<Button onClick={() => void load()}></Button>}
/>
<ErrorText error={error} />
{message ? <Card><p>{message}</p></Card> : null}
<div className="grid-2">
<div className="grid-2" style={{ gap: "1.25rem" }}>
<Card title="人設列表">
<div className="page-grid">
<div className="page-grid" style={{ gap: "0.9rem" }}>
<div className="button-row">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="人設名稱" />
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="人設名稱" style={{ fontSize: "15px" }} />
<Button variant="primary" onClick={() => void createPersona()}></Button>
</div>
{personas.map((persona) => (
<button key={persona.id} className="card" style={{ textAlign: "left", cursor: "pointer" }} onClick={() => { setSelected(persona); setBrief(persona.brief || ""); void load(persona.id); }}>
<div className="button-row" style={{ justifyContent: "space-between" }}>
<strong>{persona.display_name || persona.id}</strong>
<Badge variant={persona.id === selected?.id ? "brand" : "neutral"}>{persona.style_profile ? "已有 8D" : "待分析"}</Badge>
{personas.length === 0 && <p className="muted" style={{ fontSize: "14px" }}></p>}
{personas.map((persona) => {
const isActive = persona.id === selected?.id;
return (
<div
key={persona.id}
className={`persona-item ${isActive ? "active" : ""}`}
style={{ padding: "0.85rem 0.95rem" }}
onClick={() => { setSelected(persona); setDisplayName(persona.display_name || ""); setBrief(persona.brief || ""); void load(persona.id); }}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { setSelected(persona); setDisplayName(persona.display_name || ""); setBrief(persona.brief || ""); void load(persona.id); } }}
>
<div className="title-row" style={{ alignItems: "center" }}>
<span style={{ display: "flex", alignItems: "center", gap: "0.5rem", flex: 1, fontSize: "15px", fontWeight: 700 }}>
<AcIcon name="persona" size={18} />
{persona.display_name || persona.id}
</span>
<Badge variant={isActive ? "brand" : (persona.style_profile ? "success" : "neutral")}>
{persona.style_profile ? "8D 已就緒" : "待分析"}
</Badge>
<button
type="button"
onClick={(e) => { e.stopPropagation(); void deletePersona(persona.id); }}
className="ac-btn ac-btn-danger"
style={{ padding: "0.25rem 0.6rem", fontSize: "0.75rem", minHeight: "auto", lineHeight: 1 }}
title="刪除人設"
>
</button>
</div>
<p className="brief muted" style={{ fontSize: "13.5px", marginTop: "0.35rem" }}>{shortText(persona.brief || persona.persona || "尚未填寫人設摘要", 90)}</p>
</div>
<p className="muted">{shortText(persona.brief || persona.persona || "尚未填寫人設摘要", 90)}</p>
</button>
))}
);
})}
</div>
</Card>
<Card title="人設設定">
{selected ? (
<div className="page-grid">
<Field label="人設摘要">
<Textarea value={brief} onChange={(e) => setBrief(e.target.value)} />
<div className="page-grid" style={{ gap: "1.1rem" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
{selected.style_profile ? <Badge variant="success">8D </Badge> : <Badge variant="warning"></Badge>}
</div>
<Field label="人設名稱">
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="例如:溫柔島民"
style={{ fontSize: "15px" }}
/>
</Field>
<Button variant="primary" onClick={() => void savePersona()}></Button>
<Field label="對標 Threads username">
<Input value={benchmark} onChange={(e) => setBenchmark(e.target.value)} placeholder="例如 ultralab_tw" />
<Field label="人設摘要brief">
<Textarea
value={brief}
onChange={(e) => setBrief(e.target.value)}
placeholder="描述這個人設的定位、產品、目標受眾與核心訊息..."
style={{ minHeight: "11rem", fontSize: "15px" }}
/>
</Field>
<Button onClick={() => void startStyle()} disabled={!benchmark}> 8D </Button>
{selected.style_profile ? <pre style={{ whiteSpace: "pre-wrap" }}>{selected.style_profile}</pre> : null}
<div className="button-row">
<Button variant="primary" onClick={() => void savePersona()}></Button>
</div>
<div style={{ height: 4 }} />
<Field label="對標 Threads username抓樣本做 8D">
<div className="button-row">
<Input value={benchmark} onChange={(e) => setBenchmark(e.target.value)} placeholder="例如 ultralab_tw" style={{ flex: 1, maxWidth: 280, fontSize: "15px" }} />
<Button onClick={() => void startStyle()} disabled={!benchmark.trim()}> 8D </Button>
</div>
<p className="subtle" style={{ fontSize: "0.85rem", marginTop: "0.3rem", lineHeight: 1.5 }}>
8D 調8D 調
</p>
</Field>
<div style={{ borderTop: "1px solid var(--hx-line)", paddingTop: "0.75rem", marginTop: "0.3rem" }}>
<div className="button-row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<span className="subtle" style={{ fontSize: "0.85rem" }}></span>
<Button
variant="danger"
onClick={() => void deletePersona(selected.id)}
>
</Button>
</div>
</div>
</div>
) : (
<p className="muted"></p>
<p className="muted"></p>
)}
</Card>
</div>
<div className="grid-2">
<Card title="語調庫">
<div className="page-grid">
<Field label="Preset 名稱">
<Input value={presetName} onChange={(e) => setPresetName(e.target.value)} placeholder="例如:年輕直接版" />
</Field>
<Field label="口吻">
<Textarea value={presetTone} onChange={(e) => setPresetTone(e.target.value)} placeholder="直接、短句、帶巡樓觀察..." />
</Field>
<Field label="CTA每行一個">
<Textarea value={presetCTA} onChange={(e) => setPresetCTA(e.target.value)} />
</Field>
<Field label="禁用詞(每行一個)">
<Textarea value={presetBannedWords} onChange={(e) => setPresetBannedWords(e.target.value)} />
</Field>
<div className="button-row">
<Button variant="primary" disabled={!presetName.trim()} onClick={() => void savePreset(false)}>調</Button>
<Button variant="soft" disabled={!presetName.trim()} onClick={() => void savePreset(true)}></Button>
</div>
{selected && selected.style_profile && (
<Card title="8D 風格檔案與語調(來自分析)">
<div style={{ fontSize: "14.5px" }}>
<Style8DView raw={selected.style_profile} />
</div>
</Card>
)}
{/* 其他語調預設(選用) - 主要語調已整合在 8D 分析的「人設語音摘要」中,可在此刪除 */}
{presets.length > 0 && (
<Card title={`其他語調預設(${selected ? (selected.display_name || '目前人設') : ''}`}>
<div style={{ fontSize: "14px", marginBottom: "0.5rem", color: "var(--hx-muted)" }}>
調 8D
</div>
<div style={{ display: "grid", gap: "0.7rem" }}>
{presets.map((preset) => (
<div key={preset.id} className="card">
<strong>{preset.name}</strong>
<p className="muted">{shortText(preset.tone || preset.notes || "尚未填寫口吻", 90)}</p>
<div key={preset.id} className="card" style={{ padding: "0.95rem 1.1rem", background: "var(--hx-surface)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "0.3rem" }}>
<div style={{ fontWeight: 800, fontSize: "15px" }}>{preset.name}</div>
<Button
variant="danger"
onClick={() => void deletePreset(preset.id)}
style={{ fontSize: "0.7rem", padding: "0.15rem 0.45rem", minHeight: "1.7rem" }}
>
</Button>
</div>
<p className="muted" style={{ margin: "0.4rem 0 0.55rem", fontSize: "14.5px", lineHeight: 1.6 }}>
{shortText(preset.tone || preset.notes || "(無口吻描述)", 160)}
</p>
<div className="button-row">
{(preset.cta || []).slice(0, 3).map((item) => <Badge key={item} variant="brand">{item}</Badge>)}
{(preset.banned_words || []).slice(0, 3).map((item) => <Badge key={item} variant="warning">{item}</Badge>)}
{(preset.cta || []).slice(0, 5).map((item) => <Badge key={item} variant="brand">{item}</Badge>)}
{(preset.banned_words || []).slice(0, 5).map((item) => <Badge key={item} variant="warning">{item}</Badge>)}
</div>
</div>
))}
</div>
</Card>
<Card title="批量排程草稿">
<div className="page-grid">
<Field label="Threads 帳號">
<Select value={scheduleAccountId} onChange={(e) => setScheduleAccountId(e.target.value)}>
{accounts.map((account) => (
<option key={account.id} value={account.id}>{account.display_name || account.username || account.id}</option>
))}
</Select>
</Field>
<Field label="起始時間">
<Input type="datetime-local" value={scheduleStartAt} onChange={(e) => setScheduleStartAt(e.target.value)} />
</Field>
<p className="muted"> {selectedDraftIds.length} queue</p>
<Button variant="primary" disabled={!scheduleAccountId || selectedDraftIds.length === 0} onClick={() => void scheduleDrafts()}>稿</Button>
</div>
</Card>
</div>
<Card title="文案草稿">
<div className="page-grid">
{drafts.map((draft) => (
<DraftEditor
key={draft.id}
draft={draft}
selected={selectedDraftIds.includes(draft.id)}
onToggle={(checked) => setSelectedDraftIds((ids) => checked ? [...ids, draft.id] : ids.filter((id) => id !== draft.id))}
onSave={(text) => void saveDraft(draft, text)}
/>
))}
{drafts.length === 0 ? <p className="muted">稿 viral scan </p> : null}
</div>
</Card>
)}
</div>
);
}
function DraftEditor({ draft, selected, onToggle, onSave }: { draft: CopyDraft; selected: boolean; onToggle: (checked: boolean) => void; onSave: (text: string) => void }) {
const [text, setText] = useState(draft.text);
// 8D 結構解析與美觀呈現
interface Dim {
summary: string;
evidence?: string[];
}
interface Stored8D {
username?: string;
analyzedAt?: string;
postCount?: number;
analysis?: Record<string, Dim>;
personaDraft?: string;
engagement?: { medianInteractions?: number; verdict?: string };
}
const DIM_ORDER = ["d1Tone","d2Structure","d3Interaction","d4Topics","d5Rhythm","d6Visual","d7Conversion","d8Risk"] as const;
const DIM_LABELS: Record<string, string> = {
d1Tone: "D1 語氣人格",
d2Structure: "D2 結構模板",
d3Interaction: "D3 互動方式",
d4Topics: "D4 主題分布",
d5Rhythm: "D5 發文節奏",
d6Visual: "D6 視覺語法",
d7Conversion: "D7 轉換方式",
d8Risk: "D8 風險紅線",
};
function parse8D(raw?: string): Stored8D | null {
if (!raw) return null;
try {
const p = JSON.parse(raw);
if (!p || (Object.keys(p.analysis || {}).length === 0 && !p.personaDraft)) return null;
return p as Stored8D;
} catch {
return null;
}
}
function Style8DView({ raw }: { raw: string }) {
const profile = parse8D(raw);
if (!profile) return <pre style={{ whiteSpace: "pre-wrap", fontSize: "12px", opacity: 0.7 }}>{raw}</pre>;
const analysis = profile.analysis || {};
const draft = (profile.personaDraft || "").trim();
return (
<div className="card">
<div className="button-row" style={{ justifyContent: "space-between" }}>
<label className="button-row">
<input type="checkbox" checked={selected} onChange={(e) => onToggle(e.target.checked)} />
<Badge variant="neutral">{draft.draft_type}</Badge>
</label>
<Badge variant={draft.status === "published" ? "success" : "brand"}>{draft.status || "draft"}</Badge>
<div className="page-grid" style={{ gap: "0.85rem" }}>
{/* meta row */}
<div className="button-row" style={{ justifyContent: "space-between", flexWrap: "wrap" }}>
<div>
{profile.username ? <Badge variant="brand">@{profile.username}</Badge> : null}
{profile.postCount != null ? <Badge variant="neutral"> {profile.postCount} </Badge> : null}
{profile.analyzedAt ? <span className="subtle" style={{ fontSize: "0.82rem", marginLeft: 6 }}>{profile.analyzedAt.slice(0,10)}</span> : null}
</div>
{profile.engagement?.verdict ? <Badge variant={profile.engagement.verdict === "strong" ? "success" : "warning"}> {profile.engagement.verdict}</Badge> : null}
</div>
<Textarea value={text} onChange={(e) => setText(e.target.value)} />
<div className="button-row">
<Button onClick={() => onSave(text)}>稿</Button>
{draft.publish_queue_id ? <Badge variant="warning"></Badge> : null}
{draft.published_permalink ? <a href={draft.published_permalink} target="_blank" rel="noreferrer"></a> : null}
{/* 8 維度卡片 */}
<div className="dim-grid">
{DIM_ORDER.map((key) => {
const d = analysis[key] || { summary: "" };
if (!d.summary) return null;
return (
<div key={key} className="dim-card" style={{ padding: "0.75rem 0.85rem" }}>
<div className="dim-head">
<span className="dim-badge">{key.slice(0,2).toUpperCase()}</span>
<span className="dim-title" style={{ fontSize: "0.88rem" }}>{DIM_LABELS[key]}</span>
</div>
<div className="dim-summary" style={{ fontSize: "0.95rem", lineHeight: 1.6 }}>{d.summary}</div>
{d.evidence && d.evidence.length > 0 && (
<ul className="evidence" style={{ fontSize: "0.82rem" }}>
{d.evidence.slice(0, 3).map((e, i) => <li key={i}>{e}</li>)}
</ul>
)}
</div>
);
})}
</div>
{/* 人設語音卡PersonaDraft */}
{draft && (
<div className="voice-card" style={{ padding: "0.95rem 1.1rem" }}>
<h4 style={{ fontSize: "0.9rem" }}><AcIcon name="persona" size={17} /> </h4>
<div className="voice-section">
<p style={{ whiteSpace: "pre-wrap", fontSize: "0.95rem", lineHeight: 1.65 }}>{draft}</p>
</div>
</div>
)}
</div>
);
}
function splitLines(value: string) {
return value.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
}