From fb28b5a7e578ac1305d22a106473fb10c58a1439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Tue, 30 Jun 2026 17:30:04 +0800 Subject: [PATCH] fix swagger --- backend/web/src/api/haixun.ts | 1 + backend/web/src/components/JobMonitor.tsx | 182 ++++++++++ backend/web/src/components/Layout.tsx | 2 + backend/web/src/index.css | 150 ++++++++ backend/web/src/pages/PersonasPage.tsx | 410 ++++++++++++++-------- 5 files changed, 593 insertions(+), 152 deletions(-) create mode 100644 backend/web/src/components/JobMonitor.tsx diff --git a/backend/web/src/api/haixun.ts b/backend/web/src/api/haixun.ts index 96307a2..09ac545 100644 --- a/backend/web/src/api/haixun.ts +++ b/backend/web/src/api/haixun.ts @@ -434,6 +434,7 @@ export const api = { personas: () => apiRequest<{ list: Persona[] }>("/api/v1/personas/", { auth: true }), createPersona: (body: { display_name?: string }) => apiRequest("/api/v1/personas/", { method: "POST", body, auth: true }), updatePersona: (id: string, body: Record) => apiRequest(`/api/v1/personas/${id}`, { method: "PATCH", body, auth: true }), + deletePersona: (id: string) => apiRequest(`/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 }), diff --git a/backend/web/src/components/JobMonitor.tsx b/backend/web/src/components/JobMonitor.tsx new file mode 100644 index 0000000..2f2095c --- /dev/null +++ b/backend/web/src/components/JobMonitor.tsx @@ -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([]); + 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) */} + + + {/* Floating panel */} + {open && ( +
+
+
+ + 進行中的任務 {count > 0 ? `(${count})` : ""} +
+ +
+ +
+ {activeJobs.length === 0 ? ( +

目前沒有進行中的任務。

+ ) : ( + activeJobs.map((job) => ( +
+
+ {job.template_type} + {jobStatusLabel(job.status)} +
+ + {job.progress && ( +
+ {job.progress.percentage ?? 0}% {job.progress.summary ? `· ${job.progress.summary}` : ""} +
+ )} + +
+ {formatNano(job.update_at)} + + {["running", "queued", "pending"].includes(job.status) && ( + + )} +
+ +
+ {job.id} +
+
+ )) + )} +
+ +
+ +
+
+ )} + + ); +} diff --git a/backend/web/src/components/Layout.tsx b/backend/web/src/components/Layout.tsx index 89eb348..85cd900 100644 --- a/backend/web/src/components/Layout.tsx +++ b/backend/web/src/components/Layout.tsx @@ -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({ 0} /> + ); } diff --git a/backend/web/src/index.css b/backend/web/src/index.css index 14bcbab..a99cc02 100644 --- a/backend/web/src/index.css +++ b/backend/web/src/index.css @@ -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; diff --git a/backend/web/src/pages/PersonasPage.tsx b/backend/web/src/pages/PersonasPage.tsx index 541ca35..59f3e04 100644 --- a/backend/web/src/pages/PersonasPage.tsx +++ b/backend/web/src/pages/PersonasPage.tsx @@ -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([]); - const [accounts, setAccounts] = useState([]); const [selected, setSelected] = useState(); - const [drafts, setDrafts] = useState([]); const [presets, setPresets] = useState([]); - const [selectedDraftIds, setSelectedDraftIds] = useState([]); - 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(); @@ -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 ( -
- void load()}>重新載入} /> +
+ void load()}>重新載入} + /> {message ?

{message}

: null} -
+
-
+
- setName(e.target.value)} placeholder="人設名稱" /> + setName(e.target.value)} placeholder="人設名稱" style={{ fontSize: "15px" }} />
- {personas.map((persona) => ( - +
+

{shortText(persona.brief || persona.persona || "尚未填寫人設摘要", 90)}

-

{shortText(persona.brief || persona.persona || "尚未填寫人設摘要", 90)}

- - ))} + ); + })}
{selected ? ( -
- -