fix swagger
This commit is contained in:
parent
1c85024f6b
commit
fb28b5a7e5
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
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>
|
||||
</div>
|
||||
<p className="muted">{shortText(persona.brief || persona.persona || "尚未填寫人設摘要", 90)}</p>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
{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>
|
||||
<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}
|
||||
{profile.engagement?.verdict ? <Badge variant={profile.engagement.verdict === "strong" ? "success" : "warning"}>互動 {profile.engagement.verdict}</Badge> : null}
|
||||
</div>
|
||||
|
||||
{/* 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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue