import { useCallback, useEffect, useState, type ReactNode } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { PageHeader } from "../components/layout/PageHeader"; import { OpportunityInboxCard } from "../components/radar/OpportunityInboxCard"; import { OpportunityDetailDrawer } from "../components/radar/OpportunityDetailDrawer"; import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary"; import { Button, EmptyState, Select } from "../components/ui"; import { useRepos } from "../data/DataContext"; import type { Brand, BrandProduct, JobStatus, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope, RadarSweep, RadarToday, RadarWatch } from "../domain/types"; import { useFormatApiError } from "../lib/apiErrors"; import { formatLocalDateTime } from "../lib/time"; import "../styles/radar.css"; const PAGE_SIZE = 20; function normalizeSort(value: string | null): string { if (value === "posted") return "newest"; if (value === "score") return "recommended"; return value || "recommended"; } export function RadarOpportunitiesPage() { const repos = useRepos(); const formatError = useFormatApiError(); const [params, setParams] = useSearchParams(); const [list, setList] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(() => Math.max(1, Number(params.get("page")) || 1)); const [band, setBand] = useState(params.get("band") || ""); const [state, setState] = useState(params.get("match_state") || ""); const [brandId, setBrandId] = useState(params.get("brand_id") || ""); const [productId, setProductId] = useState(params.get("product_id") || ""); const [sort, setSort] = useState(() => normalizeSort(params.get("sort"))); const [reviewState, setReviewState] = useState((params.get("review_state") as OpportunityReviewState) || "pending"); const [timeScope, setTimeScope] = useState((params.get("time_scope") as OpportunityTimeScope) || "today"); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [notice, setNotice] = useState<{ text: string; contactId?: string } | null>(null); const [brands, setBrands] = useState([]); const [products, setProducts] = useState([]); const [selected, setSelected] = useState(null); const [busyId, setBusyId] = useState(null); const [sweeping, setSweeping] = useState(false); const [lastSweep, setLastSweep] = useState(null); const [watches, setWatches] = useState([]); const [activeCount, setActiveCount] = useState(0); const [watchTotal, setWatchTotal] = useState(0); const [todayMeta, setTodayMeta] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(() => Boolean(params.get("band") || params.get("match_state") || params.get("brand_id") || params.get("product_id"))); const scout = (repos as unknown as { scout?: { listBrands: () => Promise; listProducts: (id: string) => Promise } }).scout; useEffect(() => { if (scout) void scout.listBrands().then(setBrands).catch(() => setBrands([])); }, [scout]); useEffect(() => { if (!scout || !brandId) { setProducts([]); return; } void scout.listProducts(brandId).then(setProducts).catch(() => setProducts([])); }, [scout, brandId]); const loadPatrol = useCallback(async () => { const [watchRes, today] = await Promise.all([ repos.radar.listWatches(1, 50).catch(() => null), repos.radar.getToday().catch(() => null), ]); if (watchRes) { setWatches(watchRes.list); setActiveCount(watchRes.active_count); setWatchTotal(watchRes.total); } setTodayMeta(today); if (typeof repos.radar.listSweeps === "function") { const sweeps = await repos.radar.listSweeps(1, 1).catch(() => null); setLastSweep(sweeps?.list[0] ?? null); } }, [repos.radar]); const load = useCallback(async () => { setLoading(true); const query = (scope: OpportunityTimeScope, review: OpportunityReviewState = reviewState) => repos.radar.listOpportunities({ page: scope === timeScope ? page : 1, pageSize: PAGE_SIZE, band: band || undefined, match_state: state || undefined, brand_id: brandId || undefined, product_id: productId || undefined, review_state: review, time_scope: scope, sort, }); try { let result = await query(timeScope); const canWiden = reviewState === "pending" && timeScope === "today" && !band && !state && !brandId && !productId; if (canWiden && result.total === 0) { const week = await query("7d"); if (week.total > 0) { setTimeScope("7d"); setPage(1); const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search); next.set("time_scope", "7d"); next.delete("page"); setParams(next, { replace: true }); result = week; setNotice({ text: `巡邏結果不是都在「今天發的文」。已改看近 7 天(${week.total} 筆)。任務上的判定/新建數字包含同一篇再命中,不一定全是新卡片。` }); } else { const all = await query("all"); if (all.total > 0) { setTimeScope("all"); setPage(1); const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search); next.set("time_scope", "all"); next.delete("page"); setParams(next, { replace: true }); result = all; setNotice({ text: `近 7 天沒有待處理結果,已改看全部(${all.total} 筆)。` }); } else { const seen = await query("7d", "completed"); if (seen.total > 0) { setNotice({ text: `這輪判定到的 ${seen.total} 筆已在「已看過」,所以「新找到」是空的。任務數字含再次命中的舊文。` }); } } } } setList(result.list); setTotal(result.total); setError(""); } catch (e) { setError(formatError(e)); } finally { setLoading(false); } }, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError, setParams]); useEffect(() => { void load(); }, [load]); useEffect(() => { void loadPatrol(); }, [loadPatrol]); async function updateReviewState( opportunity: Opportunity, patch: { state: OpportunityReviewState; removal_reason?: OpportunityRemovalReason; removal_note?: string }, successText: string, ) { setBusyId(opportunity.id); setNotice(null); try { const updated = await repos.radar.updateOpportunityReviewState(opportunity.id, patch); setSelected((current) => current?.id === updated.id ? null : current); await load(); setError(""); setNotice({ text: successText }); } catch (e) { setError(formatError(e)); } finally { setBusyId(null); } } async function acceptOpportunity(opportunity: Opportunity) { setBusyId(opportunity.id); setNotice(null); try { const result = await repos.radar.acceptOpportunity(opportunity.id); setSelected((current) => current?.id === opportunity.id ? null : current); await load(); setError(""); setNotice({ text: "已加入名單。這步是可選的,之後要追蹤再去名單即可。", contactId: result.contact_id, }); } catch (e) { setError(formatError(e)); } finally { setBusyId(null); } } async function waitForJob(jobId: string): Promise<{ status: JobStatus; progress_summary: string; error: string; timedOut: boolean }> { const deadline = Date.now() + 120_000; let last: { status: JobStatus; progress_summary: string; error: string } = { status: "queued", progress_summary: "立即巡邏已排程 · 等待 worker", error: "", }; while (Date.now() < deadline) { const job = await repos.jobs.get(jobId); if (job) { last = { status: job.status, progress_summary: job.progress_summary, error: job.error || "" }; if (job.status === "succeeded" || job.status === "failed" || job.status === "cancelled") { return { ...last, timedOut: false }; } } await new Promise((resolve) => window.setTimeout(resolve, 1500)); } return { ...last, timedOut: true }; } async function runNow() { const active = watches.filter((watch) => watch.status === "active"); if (!active.length) { setError("沒有開著的每日巡邏。先設定要巡的產品與關鍵字,或恢復一組訂閱。"); return; } setSweeping(true); setError(""); setNotice(null); try { const jobIds: string[] = []; for (const watch of active) { const res = await repos.radar.triggerWatchSweep(watch.id); if (res.job_id) jobIds.push(res.job_id); } if (!jobIds.length) { setError("沒有排到巡邏任務。"); return; } setNotice({ text: "立即巡邏進行中… 跑完才會把痛點列在下面。" }); // All active watches were queued together, so observe them together too. // Waiting serially made the page look frozen for up to 120s per watch. const finished = await Promise.all(jobIds.map((id) => waitForJob(id))); const failed = finished.find((job) => job.status === "failed"); const cancelled = finished.find((job) => job.status === "cancelled"); const running = finished.filter((job) => job.timedOut || job.status === "pending" || job.status === "queued" || job.status === "running" || job.status === "cancel_requested"); const terminalError = failed ? failed.progress_summary || failed.error || "巡邏失敗。" : cancelled ? "巡邏已取消,不會誤顯示為已完成。可再按一次立即巡邏。" : ""; if (typeof repos.radar.listSweeps === "function") { const sweeps = await repos.radar.listSweeps(1, 1).catch(() => null); setLastSweep(sweeps?.list[0] ?? null); } await Promise.all([load(), loadPatrol()]); const summary = finished.map((job) => job.progress_summary).filter(Boolean).join(" "); // load() clears stale list errors on a successful refresh. Re-apply the // job outcome afterwards so the actual patrol failure stays visible. if (terminalError) { setError(terminalError); setNotice(null); } else if (running.length > 0) { setNotice({ text: `已排入 ${jobIds.length} 組巡邏,目前仍在後台執行。可先離開這頁,完成後結果會留在這裡。` }); } else { setNotice({ text: summary.includes("新建") ? `${summary} 不是今天發的文也會留在下面。` : "這一輪巡邏跑完了。找到的痛點會留在下面。", }); } } catch (e) { setError(formatError(e)); } finally { setSweeping(false); } } function writeParams(changes: Record) { const next = new URLSearchParams(params); for (const [name, value] of Object.entries(changes)) { if (value) next.set(name, value); else next.delete(name); } setParams(next, { replace: true }); } function resetFilters() { setBand(""); setState(""); setBrandId(""); setProductId(""); setSort("recommended"); setTimeScope("today"); setAdvancedOpen(false); setPage(1); const next = new URLSearchParams(); if (reviewState !== "pending") next.set("review_state", reviewState); setParams(next, { replace: true }); } const lastSweptAt = Math.max( todayMeta?.last_swept_at ?? 0, ...watches.map((watch) => watch.last_swept_at ?? 0), ); const scheduledOn = activeCount > 0; const advancedFilterCount = [band, state, brandId, productId].filter(Boolean).length; const filtered = Boolean(advancedFilterCount || sort !== "recommended" || timeScope !== "today"); const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); function emptyCopy(): { title: string; description: string; action?: ReactNode } { if (filtered) { return { title: reviewState === "pending" ? "這個篩選下沒有結果" : reviewState === "completed" ? "目前沒有已看過的結果" : "目前沒有已丟掉的結果", description: "清除篩選或改看其他時間範圍。巡邏剛跑完的結果也可能在「近 7 天」或「全部」。", action: , }; } if (reviewState === "completed") { return { title: "還沒有已看過的結果", description: "切回「新找到」繼續看巡邏到的痛點。" }; } if (reviewState === "removed") { return { title: "還沒有丟掉的結果", description: "切回「新找到」繼續看巡邏到的痛點。" }; } if (watchTotal === 0) { return { title: "還沒設定巡邏", description: "先選產品與客人會搜的關鍵字。設好後可立即巡邏,每日定時巡邏也會接著跑。", action: 設定巡邏, }; } if (!scheduledOn) { return { title: "每日定時巡邏關著", description: "立即巡邏與每日定時都還在這個頁面。恢復至少一組訂閱後,兩個都能用;關掉其中一個不會藏掉另一個。", action: 打開每日定時巡邏, }; } if (!lastSweptAt) { return { title: "還沒巡邏過", description: "每日定時巡邏已開著,也可現在按「立即巡邏」。不是空白收件匣,只是第一輪還沒跑完。", action: , }; } if (todayMeta?.empty_reason === "sweep_failed") { const crawlerDead = /crawler session|Chrome crawler|Chrome 登入已過期/i.test( `${todayMeta.empty_hint || ""} ${lastSweep?.failed_reason || ""}`, ); return { title: "上一輪巡邏沒跑完", description: todayMeta.empty_hint || "巡邏失敗。可再按立即巡邏,或改看近 7 天/全部。", action: ( <> {crawlerDead ? 重新同步 Chrome : null} ), }; } if (lastSweep && lastSweep.hit_count === 0) { return { title: "搜尋沒撈到貼文", description: "門檻前就空了:關鍵字太長、太產品名、或 Threads 查無結果。改成客人會打的 2–4 字痛點詞再巡。", action: 改關鍵字, }; } return { title: "這輪有巡,但沒找到符合的痛點", description: lastSweep ? `搜尋命中 ${lastSweep.hit_count}、判定 ${lastSweep.judged_count}、新建 ${lastSweep.created_count}。不是今天發的文可改看近 7 天/全部。` : "新文章或產品對得上的需求會出現在這裡。也可改看「近 7 天」或「全部」,或調整要巡的關鍵字。", action: , }; } const empty = emptyCopy(); return ( <>

{scheduledOn ? "每日定時巡邏:開著" : "每日定時巡邏:關著"} 每天台北 06:00 自動巡一輪。關掉立即巡邏不會停每日定時。

{lastSweptAt ? `上次巡邏:${formatLocalDateTime(lastSweptAt)}` : "還沒巡邏過"} {scheduledOn ? `啟用中 ${activeCount} 組` : watchTotal ? "訂閱都暫停了,立即巡邏也需要至少一組開著" : "還沒設定要巡的產品與關鍵字"}

設定巡邏 Chrome 失效時會備援改用 API;只有 provider 成功回傳才計點。
{lastSweep ? : null}
巡邏到痛點就看這裡

先讀「為什麼推薦」,留下或丟掉即可。加入名單是可選的,不是看結果的必要步驟。

{(["pending", "completed", "removed"] as OpportunityReviewState[]).map((value) => ( ))} 共 {total} 筆
{filtered ? : 預設先看今天剛巡到的結果}
{advancedOpen ?
{brands.length ? ( ) : null} {products.length ? ( ) : null}
: null}
{error ?

{error}

: null} {notice ?
{notice.text}{notice.contactId ? 前往名單 : null}
: null} {loading ?

正在整理巡邏結果…

: null} {!loading && !error && !list.length ? ( ) : null} {!error ? (
{list.map((o) => void acceptOpportunity(item)} onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已留下。沒有建立名單。")} onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, "已丟掉。可從「已丟掉」還原。")} onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, "已還原。")} />)} {total > PAGE_SIZE ? (
第 {page} 頁/共 {pageCount} 頁
) : null}
) : null} {selected ? setSelected(null)} onAccept={(item) => void acceptOpportunity(item)} onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已留下。沒有建立名單。")} /> : null} ); }