import { useEffect, useEffectEvent, 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, ScoutHomeworkRecord, ScoutPost, ScoutPurpose, ScoutResearchNote, ScoutResearchTier, ScoutRunBrief, ThreadsAccount, } from "../domain/types"; import { groupNotesByTier, noteLearnPoints, noteReplyHooks, formatResearchLink, } from "../lib/mockResearch"; import { newId } from "../lib/id"; import { bumpScoutTodayDone, loadScoutToday, saveScoutToday } from "../lib/scoutToday"; import { nowUnixNano } from "../lib/time"; import { useI18n } from "../i18n/I18nContext"; import { useJobLive } from "../data/JobLiveContext"; import { jobStatusLabel, jobStatusTone } from "../lib/jobLabels"; import { formatLocalDateTime } from "../lib/time"; import type { Job } from "../domain/types"; function isPending(p: ScoutPost): boolean { return p.outreach_status === "new" || p.outreach_status === "drafted"; } function canSend(p: ScoutPost): boolean { return isPending(p) || p.outreach_status === "queued"; } function outreachStatusLabel( p: ScoutPost, t: (k: string, p?: Record) => string, ): string { return t(`scout.status.${p.outreach_status}`); } function MatchQueue({ title, posts, page, onPageChange, currentId, onSelect, t, }: { title: string; posts: ScoutPost[]; page: number; onPageChange: (page: number) => void; currentId: string | null; onSelect: (id: string) => void; t: (k: string, p?: Record) => string; }) { const pageSize = 8; return ( {posts.length === 0 ? ( ) : (
{pageSlice(posts, page, pageSize).map((p) => ( ))}
)}
); } 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 { tick } = useData(); const { t } = useI18n(); const { jobs: liveJobs, reload: reloadJobs } = useJobLive(); const [brands, setBrands] = useState([]); const [allProducts, setAllProducts] = useState([]); const [accounts, setAccounts] = useState([]); const [posts, setPosts] = useState([]); const [purpose, setPurpose] = useState("value"); const [intent, setIntent] = useState(""); const [productId, setProductId] = 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 [valueQueuePage, setValueQueuePage] = useState(1); const [activityQueuePage, setActivityQueuePage] = useState(1); const [researchTier, setResearchTier] = useState<"all" | ScoutResearchTier>("all"); const [homeworkList, setHomeworkList] = useState([]); /** 目前檢視的海巡批次(每次按開始 = 一筆) */ const [activeRunKey, setActiveRunKey] = useState(null); const [busy, setBusy] = useState(""); const [message, setMessage] = useState(""); const [scanJob, setScanJob] = useState(null); const [scanJobThemeKey, setScanJobThemeKey] = useState(null); const [crawlerSessionRequired, setCrawlerSessionRequired] = useState(false); const [loadError, setLoadError] = useState(""); useEffect(() => { const t = loadScoutToday(); setTodayDone(t.done); setGoal(purpose === "activity" ? t.goalActivity : t.goalValue); }, [purpose]); useEffect(() => { void (async () => { try { const [b, hw, prods, postList, acc] = await Promise.all([ repos.scout.listBrands(), repos.scout.listHomework(), repos.scout.listAllProducts(), repos.scout.listPosts(), repos.accounts.list(), ]); setBrands(b); 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]) setActiveRunKey((cur) => cur || postRunKey(pending[0]!)); setLoadError(""); } catch (e) { setLoadError(e instanceof Error ? e.message : t("scout.loadFail")); } })(); }, [repos, t, tick]); 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 runQueue = useMemo(() => { return posts .filter((p) => !activeRunKey || postRunKey(p) === activeRunKey) .slice() .sort((a, b) => Number(isPending(b)) - Number(isPending(a)) || (b.score || 0) - (a.score || 0)); }, [posts, activeRunKey]); const pendingQueue = useMemo(() => runQueue.filter(isPending), [runQueue]); const valueQueue = useMemo(() => runQueue.filter((p) => p.scout_mode !== "activity"), [runQueue]); const activityQueue = useMemo(() => runQueue.filter((p) => p.scout_mode === "activity"), [runQueue]); const current = useMemo( () => posts.find((p) => p.id === currentId) || null, [posts, currentId], ); const visibleScanJob = useMemo( () => (scanJob ? liveJobs.find((job) => job.id === scanJob.id) || scanJob : null), [liveJobs, scanJob], ); const pollScanJob = useEffectEvent(async (jobId: string, cancelled: () => boolean) => { try { await reloadJobs(); const job = await repos.jobs.get(jobId); if (cancelled() || !job) return; setScanJob((previous) => previous?.id === job.id && previous.status === job.status && previous.updated_at === job.updated_at ? previous : job, ); if (job.status === "succeeded") { const list = await repos.scout.listPosts(); if (cancelled()) return; setPosts(list); const completedThemeKey = scanJobThemeKey || job.ref_id || activeRunKey; const pending = list .filter((p) => postRunKey(p) === completedThemeKey && isPending(p)) .sort((a, b) => (b.score || 0) - (a.score || 0)); setMessage(t("scout.scanReady", { n: pending.length })); } else if (job.status === "failed" || job.status === "cancelled") { setMessage(job.error || t("scout.patrolFail")); setCrawlerSessionRequired(/crawler.?session|chrome session/i.test(job.error || "")); } else if ( job.status === "queued" && Number(job.created_at || 0) > 0 && Date.now() - Number(job.created_at) / 1_000_000 > 12_000 ) { setMessage(t("scout.workerWaiting")); } } catch { // The shared JobLiveContext keeps refreshing; leave the last known status visible. } }); useEffect(() => { if (!scanJob || ["succeeded", "failed", "cancelled"].includes(scanJob.status)) return; let cancelled = false; const poll = () => pollScanJob(scanJob.id, () => cancelled); void poll(); const id = window.setInterval(() => void poll(), 1500); return () => { cancelled = true; window.clearInterval(id); }; }, [scanJob?.id, scanJob?.status]); // 選取貼文只帶入草稿;批次由使用者明確選取,不能反向切換。 useEffect(() => { if (!current) { setDraftText(""); return; } setDraftText(current.draft_text || ""); }, [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); setValueQueuePage(1); setActivityQueuePage(1); const hw = homeworkList.find((h) => h.theme_key === key); if (hw) { setReport(hw.brief); setResearchTier( hw.brief.research_notes?.some((n) => (n.tier || "core") === "core") ? "core" : "all", ); } else { 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 || 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) { setActiveRunKey(null); setReport(null); setCurrentId(null); } else if (currentId && !list.some((p) => p.id === currentId)) { pickNext(currentId, list, activeRunKey); } setMessage(t("scout.deletedRun", { label })); } 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(""); setCrawlerSessionRequired(false); 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 }; const { job } = await repos.scout.runScanFromBrief({ ...briefSaved, scan_terms: briefSaved.scan_terms, }); await repos.scout.saveHomework({ theme_key, theme_label, purpose, brief: briefSaved, created_at: nowUnixNano(), }); setHomeworkList(await repos.scout.listHomework()); setScanJob(job); setScanJobThemeKey(theme_key); void reloadJobs(); setMessage(t("scout.scanQueued", { label: theme_label })); } catch (e) { setMessage(e instanceof Error ? e.message : t("scout.patrolFail")); setCrawlerSessionRequired( typeof e === "object" && e !== null && "code" in e && (e as { code?: number }).code === 400061, ); setBusy(""); } finally { 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); await reloadPosts(); setDraftText(next.draft_text || ""); } 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); setTodayDone(bumpScoutTodayDone().done); setMessage(t("scout.skipped")); } catch (e) { setMessage(e instanceof Error ? e.message : t("scout.patrolFail")); } 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); 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, }); const list = await reloadPosts(); pickNext(id, list); const today = bumpScoutTodayDone(); setTodayDone(today.done); const who = accounts.find((a) => a.id === accountId)?.username || t("scout.accountFallback"); setMessage(t("scout.queued", { who, done: today.done, goal })); } 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); setMessage(t("scout.deletedPost")); } catch (e) { setMessage(e instanceof Error ? e.message : t("common.error")); } finally { setBusy(""); } } const progressPct = Math.min(100, Math.round((todayDone / Math.max(1, goal)) * 100)); return ( <> {loadError ? : null} {message ? (

    {message}

    ) : null} {crawlerSessionRequired ? (

    {t("scout.crawlerSessionRequired")} {t("scout.openSettings")}

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

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