import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { PageHeader } from "../components/layout/PageHeader"; import { Badge, Button, Card, EmptyState, Input, Pager, Select, Textarea } from "../components/ui"; import { pageSlice } from "../lib/pagination"; import { useData, useRepos } from "../data/DataContext"; import type { Brand, BrandProduct, Persona, ScoutHomeworkRecord, ScoutPost, ScoutPurpose, ScoutResearchNote, ScoutResearchTier, ScoutRunBrief, ThreadsAccount, } from "../domain/types"; import { groupNotesByTier, noteLearnPoints, noteReplyHooks, formatResearchLink, } from "../lib/mockResearch"; import { isPersonaReady, personaOptionLabel } from "../lib/personaPrompt"; import { newId } from "../lib/id"; import { bumpScoutTodayDone, loadScoutToday, saveScoutToday } from "../lib/scoutToday"; import { nowUnixNano } from "../lib/time"; import { useI18n } from "../i18n/I18nContext"; function isPending(p: ScoutPost): boolean { return p.outreach_status === "new" || p.outreach_status === "drafted"; } function stanceOf(p: ScoutPost, t: (k: string, p?: Record) => string): string { if (p.scout_mode === "activity") return t("scout.stanceActivity"); if (p.scout_mode === "product" || p.matched_product_label) return t("scout.stanceProduct"); return t("scout.stanceRelation"); } /** 每次海巡批次的分組 key(與 theme_key 對齊) */ function postRunKey(p: ScoutPost): string { if (p.theme_key) return p.theme_key; if (p.matched_product_id) return `product|${p.matched_product_id}`; if (p.intent_snippet) return `intent|${p.intent_snippet}`; if (p.scout_mode === "activity") return `activity|${p.search_tag || "x"}`; return `tag|${p.search_tag || "other"}`; } function postRunLabel(p: ScoutPost, t: (k: string, p?: Record) => string): string { return ( p.theme_label || p.matched_product_label || p.intent_snippet || p.search_tag || t("scout.unnamedRun") ); } function runTimeSuffix(): string { const d = new Date(); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); return `${hh}:${mm}`; } /** 批次名過長時截斷(chip/標題用) */ function shortRunLabel(label: string, max = 20): string { const t = (label || "").trim(); if (t.length <= max) return t; return `${t.slice(0, Math.max(1, max - 1))}…`; } /** 知識圖譜節點卡:學習重點 + 回帖鉤子 + 來源 */ function KnowledgeNoteCard({ note, badge, compact, }: { note: ScoutResearchNote; badge?: string; compact?: boolean; }) { const { t } = useI18n(); const link = formatResearchLink(note.url, note.source_label); const points = noteLearnPoints(note); const hooks = noteReplyHooks(note); const relationLabel = note.relation ? t(`scout.relation.${note.relation}`) : null; const showPoints = compact ? points.slice(0, 3) : points; const showHooks = compact ? hooks.slice(0, 1) : hooks; return (
  • {note.title} {badge ? {badge} : null}
    {relationLabel ? ( {relationLabel} ) : null} {showPoints.length > 0 ? (

    {t("scout.learnPoints")}

      {showPoints.map((pt) => (
    • {pt}
    • ))}
    ) : note.summary ? (

    {note.summary}

    ) : null} {showHooks.length > 0 ? (

    {t("scout.replyHooks")}

      {showHooks.map((h) => (
    • 「{h}」
    • ))}
    ) : null} {link.label} {link.host}
  • ); } /** * 海巡:今日目標 + 現在這一則 + 收合功課/佇列 */ export function ScoutPage() { const repos = useRepos(); const { refresh, tick } = useData(); const { t } = useI18n(); const [brands, setBrands] = useState([]); const [allProducts, setAllProducts] = useState([]); const [personas, setPersonas] = useState([]); const [accounts, setAccounts] = useState([]); const [posts, setPosts] = useState([]); const [purpose, setPurpose] = useState("value"); const [intent, setIntent] = useState(""); const [productId, setProductId] = useState(""); const [personaId, setPersonaId] = useState(""); const [accountId, setAccountId] = useState(""); const [goal, setGoal] = useState(8); const [todayDone, setTodayDone] = useState(0); const [currentId, setCurrentId] = useState(null); const [draftText, setDraftText] = useState(""); const [report, setReport] = useState(null); const [reportOpen, setReportOpen] = useState(false); const [queueOpen, setQueueOpen] = useState(false); const [queuePage, setQueuePage] = useState(1); const [researchTier, setResearchTier] = useState<"all" | ScoutResearchTier>("all"); const QUEUE_PAGE = 8; const [homeworkList, setHomeworkList] = useState([]); /** 背景補完整功課(不擋主流程);綁定批次 key */ const [homeworkLoadingKey, setHomeworkLoadingKey] = useState(null); /** 目前檢視的海巡批次(每次按開始 = 一筆) */ const [activeRunKey, setActiveRunKey] = useState(null); const [busy, setBusy] = useState(""); const [message, setMessage] = useState(""); useEffect(() => { const t = loadScoutToday(); setTodayDone(t.done); setGoal(purpose === "activity" ? t.goalActivity : t.goalValue); }, [purpose]); useEffect(() => { void (async () => { const [b, p, hw, prods, postList, acc] = await Promise.all([ repos.scout.listBrands(), repos.personas.list(), repos.scout.listHomework(), repos.scout.listAllProducts(), repos.scout.listPosts(), repos.accounts.list(), ]); setBrands(b); setPersonas(p); setHomeworkList(hw); setAllProducts(prods); setPosts(postList); const usable = acc.filter((a) => a.is_usable); setAccounts(usable); if (!accountId && usable[0]) setAccountId(usable[0].id); const pending = postList.filter(isPending).sort((a, b) => (b.score || 0) - (a.score || 0)); if (pending[0]) { setCurrentId((cur) => cur || pending[0]!.id); setActiveRunKey((cur) => cur || postRunKey(pending[0]!)); } else if (postList[0]) { setActiveRunKey((cur) => cur || postRunKey(postList[0]!)); } const lastValue = hw.find((h) => h.purpose === "value"); if (lastValue && !report) { setReport(lastValue.brief); setActiveRunKey((cur) => cur || lastValue.theme_key); if (lastValue.brief.intent) setIntent((prev) => prev || lastValue.brief.intent); const pid = lastValue.brief.product_id || ""; if (pid && prods.some((x) => x.id === pid)) setProductId(pid); } })(); }, [repos, tick]); // eslint-disable-line react-hooks/exhaustive-deps const productOptions = useMemo(() => { return allProducts.map((p) => { const brand = brands.find((b) => b.id === p.brand_id); return { id: p.id, label: `${brand?.display_name || t("scout.brandFallback")} · ${p.label}`, }; }); }, [allProducts, brands, t]); const selectedProduct = useMemo( () => allProducts.find((p) => p.id === productId) || null, [allProducts, productId], ); /** 每次搜尋一筆:依 posts 出現順序(新掃在前) */ const runGroups = useMemo(() => { const order: string[] = []; const map = new Map< string, { key: string; label: string; pending: number; total: number; mode?: string } >(); for (const p of posts) { const key = postRunKey(p); let g = map.get(key); if (!g) { g = { key, label: postRunLabel(p, t), pending: 0, total: 0, mode: p.scout_mode, }; map.set(key, g); order.push(key); } g.total += 1; if (isPending(p)) g.pending += 1; if (!g.label || g.label === t("scout.unnamedRun")) g.label = postRunLabel(p, t); } // 有功課但命中已清空的批次也列出來(可刪) for (const h of homeworkList) { if (map.has(h.theme_key)) continue; order.push(h.theme_key); map.set(h.theme_key, { key: h.theme_key, label: h.theme_label, pending: 0, total: 0, mode: h.brief.mode, }); } return order.map((k) => map.get(k)!).filter(Boolean); }, [posts, homeworkList, t]); const activeRunMeta = useMemo( () => runGroups.find((g) => g.key === activeRunKey) || null, [runGroups, activeRunKey], ); const pendingQueue = useMemo(() => { return posts .filter(isPending) .filter((p) => !activeRunKey || postRunKey(p) === activeRunKey) .slice() .sort((a, b) => (b.score || 0) - (a.score || 0)); }, [posts, activeRunKey]); const current = useMemo( () => posts.find((p) => p.id === currentId) || null, [posts, currentId], ); // 切換當前則時帶入草稿;若跳出目前批次則跟著切批次 useEffect(() => { if (!current) { setDraftText(""); return; } setDraftText(current.draft_text || ""); const rk = postRunKey(current); if (rk && rk !== activeRunKey) setActiveRunKey(rk); }, [current?.id]); // eslint-disable-line react-hooks/exhaustive-deps function pickNext(afterId?: string | null, list?: ScoutPost[], runKey?: string | null) { const key = runKey === undefined ? activeRunKey : runKey; let src = (list || posts).filter(isPending); if (key) src = src.filter((p) => postRunKey(p) === key); src = src.slice().sort((a, b) => (b.score || 0) - (a.score || 0)); if (!src.length) { setCurrentId(null); return; } if (!afterId) { setCurrentId(src[0]!.id); return; } const idx = src.findIndex((p) => p.id === afterId); const next = src[idx + 1] || src.find((p) => p.id !== afterId) || null; setCurrentId(next?.id || null); } function selectRun(key: string) { setActiveRunKey(key); setQueuePage(1); const hw = homeworkList.find((h) => h.theme_key === key); if (hw) { setReport(hw.brief); setPurpose(hw.purpose); if (hw.brief.intent) setIntent(hw.brief.intent); if (hw.brief.product_id && allProducts.some((x) => x.id === hw.brief.product_id)) { setProductId(hw.brief.product_id); } else if (hw.purpose === "activity") { setProductId(""); } setResearchTier( hw.brief.research_notes?.some((n) => (n.tier || "core") === "core") ? "core" : "all", ); } else { const sample = posts.find((p) => postRunKey(p) === key); if (sample?.intent_snippet) setIntent((prev) => prev || sample.intent_snippet || ""); if (sample?.matched_product_id) setProductId(sample.matched_product_id); if (sample?.scout_mode === "activity") setPurpose("activity"); // 無功課時不硬清 report(避免 thrash);若 key 不同再清 if (report?.theme_key && report.theme_key !== key) setReport(null); } const pending = posts .filter((p) => postRunKey(p) === key && isPending(p)) .sort((a, b) => (b.score || 0) - (a.score || 0)); setCurrentId(pending[0]?.id || posts.find((p) => postRunKey(p) === key)?.id || null); } async function removeRun(key: string) { const g = runGroups.find((x) => x.key === key); const label = g?.label || t("scout.thisRun"); if ( !window.confirm(t("scout.confirmDeleteRun", { label })) ) { return; } setBusy("del-run"); try { await repos.scout.removeTheme(key); const list = await reloadPosts(); const hw = await repos.scout.listHomework(); setHomeworkList(hw); if (activeRunKey === key) { const nextFromList = list.find((p) => postRunKey(p) !== key); const chosen = (nextFromList ? postRunKey(nextFromList) : null) || hw.find((h) => h.theme_key !== key)?.theme_key || null; setActiveRunKey(chosen); if (chosen) { const nextHw = hw.find((h) => h.theme_key === chosen); setReport(nextHw?.brief || null); const pending = list .filter((p) => postRunKey(p) === chosen && isPending(p)) .sort((a, b) => (b.score || 0) - (a.score || 0)); setCurrentId(pending[0]?.id || null); } else { setReport(null); setCurrentId(null); } } else if (currentId && !list.some((p) => p.id === currentId)) { pickNext(currentId, list, activeRunKey); } setMessage(t("scout.deletedRun", { label })); refresh(); } catch (e) { setMessage(e instanceof Error ? e.message : t("scout.deleteRunFail")); } finally { setBusy(""); } } function setGoalPersist(n: number) { const g = Math.max(1, Math.min(99, n)); setGoal(g); const t = loadScoutToday(); if (purpose === "activity") { saveScoutToday({ ...t, goalActivity: g }); } else { saveScoutToday({ ...t, goalValue: g }); } } async function startPatrol() { const text = intent.trim(); if (!text) { setMessage(purpose === "activity" ? t("scout.needKeyword") : t("scout.needIntent")); return; } setBusy("run"); setMessage(""); setHomeworkLoadingKey(null); try { const freshProducts = await repos.scout.listAllProducts(); setAllProducts(freshProducts); const selected = purpose === "activity" ? null : freshProducts.find((p) => p.id === productId) || null; if (purpose === "value" && productId && !selected) { throw new Error(t("scout.productMissing")); } // ① 輕量 brief → 立刻海巡;每次按開始 = 獨立批次(unique theme_key) const brief = await repos.scout.prepareBrief({ intent: text, brandId: purpose === "activity" ? null : selected?.brand_id || null, productId: purpose === "activity" ? null : selected?.id || null, purpose, deep: false, }); const baseLabel = brief.theme_label || brief.product_label || brief.intent.slice(0, 36) || t("scout.defaultLabel"); const theme_key = newId("run"); const theme_label = `${baseLabel} · ${runTimeSuffix()}`; const briefSaved: ScoutRunBrief = { ...brief, theme_key, theme_label }; await repos.scout.saveHomework({ theme_key, theme_label, purpose, brief: briefSaved, created_at: nowUnixNano(), }); setHomeworkList(await repos.scout.listHomework()); setReport(briefSaved); setReportOpen(false); setActiveRunKey(theme_key); await repos.scout.runScanFromBrief({ ...briefSaved, scan_terms: briefSaved.scan_terms, }); const list = await repos.scout.listPosts(); setPosts(list); const pending = list .filter((p) => postRunKey(p) === theme_key && isPending(p)) .sort((a, b) => (b.score || 0) - (a.score || 0)); setCurrentId(pending[0]?.id || null); setMessage( purpose === "activity" ? t("scout.newRunActivity", { label: theme_label, n: pending.length }) : t("scout.newRunValue", { label: theme_label, n: pending.length }), ); refresh(); setBusy(""); // ② 痛點模式:背景補周邊知識(綁同一批次 theme_key) if (purpose === "value") { setHomeworkLoadingKey(theme_key); void (async () => { try { const deep = await repos.scout.prepareBrief({ intent: text, brandId: selected?.brand_id || null, productId: selected?.id || null, purpose: "value", deep: true, }); const deepSaved: ScoutRunBrief = { ...deep, theme_key, theme_label, }; await repos.scout.saveHomework({ theme_key, theme_label, purpose: "value", brief: deepSaved, created_at: nowUnixNano(), }); setHomeworkList(await repos.scout.listHomework()); // 僅在使用者仍看此批次時更新畫面 setReport((prev) => !prev || prev.theme_key === theme_key ? deepSaved : prev, ); const noteCount = deepSaved.research_notes?.length || 0; if (noteCount > 0) { setMessage(t("scout.knowledgeReady", { label: theme_label, n: noteCount })); // 勿在 setState updater 內再 setState(Strict Mode 可能雙重觸發) setActiveRunKey((cur) => { if (cur === theme_key) { queueMicrotask(() => { setResearchTier("core"); setReportOpen(true); }); } return cur; }); } } catch { // 背景失敗不擋主流程 } finally { setHomeworkLoadingKey((k) => (k === theme_key ? null : k)); } })(); } } catch (e) { setMessage(e instanceof Error ? e.message : t("scout.patrolFail")); setBusy(""); } } async function reloadPosts(): Promise { const list = await repos.scout.listPosts(); setPosts(list); return list; } async function regenDraft() { if (!current) return; setBusy("draft"); try { const next = await repos.scout.draftOutreach(current.id, personaId || undefined); await reloadPosts(); setDraftText(next.draft_text || ""); refresh(); } catch (e) { setMessage(e instanceof Error ? e.message : t("scout.draftFail")); } finally { setBusy(""); } } async function skipCurrent() { if (!current) return; const id = current.id; setBusy("skip"); try { await repos.scout.skipOutreach(id); const list = await reloadPosts(); pickNext(id, list); setMessage(t("scout.skipped")); refresh(); } finally { setBusy(""); } } async function sendCurrent() { if (!current) return; setBusy("send"); setMessage(""); try { let text = draftText.trim(); if (!text) { const d = await repos.scout.draftOutreach(current.id, personaId || undefined); text = (d.draft_text || "").trim(); setDraftText(text); } if (!text) throw new Error(t("scout.noDraft")); const id = current.id; await repos.scout.sendOutreach({ postId: id, text, accountId: accountId || undefined, personaId: personaId || undefined, }); const today = bumpScoutTodayDone(1); setTodayDone(today.done); const list = await reloadPosts(); pickNext(id, list); const who = accounts.find((a) => a.id === accountId)?.username || t("scout.accountFallback"); setMessage(t("scout.sent", { who, done: today.done, goal })); refresh(); } catch (e) { setMessage(e instanceof Error ? e.message : t("scout.sendFail")); } finally { setBusy(""); } } async function removeCurrent() { if (!current) return; if (!window.confirm(t("scout.confirmDeletePost"))) return; const id = current.id; setBusy("del"); try { await repos.scout.removePost(id); const list = await reloadPosts(); pickNext(id, list); refresh(); } finally { setBusy(""); } } const progressPct = Math.min(100, Math.round((todayDone / Math.max(1, goal)) * 100)); const queueRest = pendingQueue.filter((p) => p.id !== currentId); return ( <> {message ? (

    {message}

    ) : null} {/* ① 今日設定 */}
    setGoalPersist(Number(e.target.value) || 1)} />

    {t("scout.progress", { done: todayDone, goal })}