thread-master/apps/web/src/pages/ScoutPage.tsx

1127 lines
40 KiB
TypeScript
Raw Normal View History

2026-07-13 08:59:13 +00:00
import { useEffect, useEffectEvent, useMemo, useState } from "react";
2026-07-10 05:10:31 +00:00
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";
2026-07-13 08:59:13 +00:00
import { useJobLive } from "../data/JobLiveContext";
import { jobStatusLabel, jobStatusTone } from "../lib/jobLabels";
import { formatLocalDateTime } from "../lib/time";
import type { Job } from "../domain/types";
2026-07-10 05:10:31 +00:00
function isPending(p: ScoutPost): boolean {
return p.outreach_status === "new" || p.outreach_status === "drafted";
}
2026-07-13 08:59:13 +00:00
function canSend(p: ScoutPost): boolean {
return isPending(p) || p.outreach_status === "queued";
}
function outreachStatusLabel(
p: ScoutPost,
t: (k: string, p?: Record<string, string | number>) => 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, string | number>) => string;
}) {
const pageSize = 8;
return (
<Card title={title}>
{posts.length === 0 ? (
<EmptyState title={t("scout.noMatchesInQueue")} />
) : (
<div className="hb-stack">
{pageSlice(posts, page, pageSize).map((p) => (
<button
key={p.id}
type="button"
className={`hb-scout-queue-item${currentId === p.id ? " is-active" : ""}`}
onClick={() => onSelect(p.id)}
>
<span className="hb-scout-queue-item__meta">
@{p.author} · {p.search_tag} · {Math.round(p.score)}
<Badge tone={isPending(p) ? "brand" : "neutral"}>{outreachStatusLabel(p, t)}</Badge>
</span>
<span className="hb-scout-queue-item__text">
{p.text.slice(0, 72)}
{p.text.length > 72 ? "…" : ""}
</span>
</button>
))}
<Pager total={posts.length} page={page} pageSize={pageSize} onPageChange={onPageChange} />
</div>
)}
</Card>
);
}
2026-07-10 05:10:31 +00:00
function stanceOf(p: ScoutPost, t: (k: string, p?: Record<string, string | number>) => 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 | number>) => 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 (
<li className={`hb-scout-research__card${compact ? " is-preview" : ""}`}>
<div className="hb-scout-research__head">
<strong>{note.title}</strong>
{badge ? <Badge tone="success">{badge}</Badge> : null}
</div>
{relationLabel ? (
<span className="hb-scout-research__relation">{relationLabel}</span>
) : null}
{showPoints.length > 0 ? (
<div className="hb-scout-research__block">
<p className="hb-scout-research__block-label">{t("scout.learnPoints")}</p>
<ul className="hb-scout-research__points">
{showPoints.map((pt) => (
<li key={pt}>{pt}</li>
))}
</ul>
</div>
) : note.summary ? (
<p
className={`hb-scout-research__summary${compact ? " hb-scout-research__summary--clamp" : ""}`}
>
{note.summary}
</p>
) : null}
{showHooks.length > 0 ? (
<div className="hb-scout-research__block">
<p className="hb-scout-research__block-label">{t("scout.replyHooks")}</p>
<ul className="hb-scout-research__hooks">
{showHooks.map((h) => (
<li key={h}>{h}</li>
))}
</ul>
</div>
) : null}
<a
className="hb-scout-research__link"
href={note.url}
target="_blank"
rel="noreferrer"
title={note.url}
>
<span className="hb-scout-research__link-label">{link.label}</span>
<span className="hb-scout-research__link-host">{link.host}</span>
</a>
</li>
);
}
/**
* + +
*/
export function ScoutPage() {
const repos = useRepos();
2026-07-13 08:59:13 +00:00
const { tick } = useData();
2026-07-10 05:10:31 +00:00
const { t } = useI18n();
2026-07-13 08:59:13 +00:00
const { jobs: liveJobs, reload: reloadJobs } = useJobLive();
2026-07-10 05:10:31 +00:00
const [brands, setBrands] = useState<Brand[]>([]);
const [allProducts, setAllProducts] = useState<BrandProduct[]>([]);
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [posts, setPosts] = useState<ScoutPost[]>([]);
const [purpose, setPurpose] = useState<ScoutPurpose>("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<string | null>(null);
const [draftText, setDraftText] = useState("");
const [report, setReport] = useState<ScoutRunBrief | null>(null);
const [reportOpen, setReportOpen] = useState(false);
2026-07-13 08:59:13 +00:00
const [valueQueuePage, setValueQueuePage] = useState(1);
const [activityQueuePage, setActivityQueuePage] = useState(1);
2026-07-10 05:10:31 +00:00
const [researchTier, setResearchTier] = useState<"all" | ScoutResearchTier>("all");
const [homeworkList, setHomeworkList] = useState<ScoutHomeworkRecord[]>([]);
/** 目前檢視的海巡批次(每次按開始 = 一筆) */
const [activeRunKey, setActiveRunKey] = useState<string | null>(null);
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
2026-07-13 08:59:13 +00:00
const [scanJob, setScanJob] = useState<Job | null>(null);
const [scanJobThemeKey, setScanJobThemeKey] = useState<string | null>(null);
const [crawlerSessionRequired, setCrawlerSessionRequired] = useState(false);
const [loadError, setLoadError] = useState("");
2026-07-10 05:10:31 +00:00
useEffect(() => {
const t = loadScoutToday();
setTodayDone(t.done);
setGoal(purpose === "activity" ? t.goalActivity : t.goalValue);
}, [purpose]);
useEffect(() => {
void (async () => {
2026-07-13 08:59:13 +00:00
try {
const [b, hw, prods, postList, acc] = await Promise.all([
repos.scout.listBrands(),
2026-07-10 05:10:31 +00:00
repos.scout.listHomework(),
repos.scout.listAllProducts(),
repos.scout.listPosts(),
repos.accounts.list(),
2026-07-13 08:59:13 +00:00
]);
2026-07-10 05:10:31 +00:00
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);
2026-07-13 08:59:13 +00:00
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"));
2026-07-10 05:10:31 +00:00
}
})();
2026-07-13 08:59:13 +00:00
}, [repos, t, tick]);
2026-07-10 05:10:31 +00:00
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],
);
2026-07-13 08:59:13 +00:00
const runQueue = useMemo(() => {
2026-07-10 05:10:31 +00:00
return posts
.filter((p) => !activeRunKey || postRunKey(p) === activeRunKey)
.slice()
2026-07-13 08:59:13 +00:00
.sort((a, b) => Number(isPending(b)) - Number(isPending(a)) || (b.score || 0) - (a.score || 0));
2026-07-10 05:10:31 +00:00
}, [posts, activeRunKey]);
2026-07-13 08:59:13 +00:00
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]);
2026-07-10 05:10:31 +00:00
const current = useMemo(
() => posts.find((p) => p.id === currentId) || null,
[posts, currentId],
);
2026-07-13 08:59:13 +00:00
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]);
// 選取貼文只帶入草稿;批次由使用者明確選取,不能反向切換。
2026-07-10 05:10:31 +00:00
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);
2026-07-13 08:59:13 +00:00
setValueQueuePage(1);
setActivityQueuePage(1);
2026-07-10 05:10:31 +00:00
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 {
2026-07-13 08:59:13 +00:00
setReport(null);
2026-07-10 05:10:31 +00:00
}
const pending = posts
.filter((p) => postRunKey(p) === key && isPending(p))
.sort((a, b) => (b.score || 0) - (a.score || 0));
2026-07-13 08:59:13 +00:00
setCurrentId(pending[0]?.id || null);
2026-07-10 05:10:31 +00:00
}
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) {
2026-07-13 08:59:13 +00:00
setActiveRunKey(null);
setReport(null);
setCurrentId(null);
2026-07-10 05:10:31 +00:00
} 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("");
2026-07-13 08:59:13 +00:00
setCrawlerSessionRequired(false);
2026-07-10 05:10:31 +00:00
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 };
2026-07-13 08:59:13 +00:00
const { job } = await repos.scout.runScanFromBrief({
...briefSaved,
scan_terms: briefSaved.scan_terms,
});
2026-07-10 05:10:31 +00:00
await repos.scout.saveHomework({
theme_key,
theme_label,
purpose,
brief: briefSaved,
created_at: nowUnixNano(),
});
setHomeworkList(await repos.scout.listHomework());
2026-07-13 08:59:13 +00:00
setScanJob(job);
setScanJobThemeKey(theme_key);
void reloadJobs();
setMessage(t("scout.scanQueued", { label: theme_label }));
2026-07-10 05:10:31 +00:00
} catch (e) {
setMessage(e instanceof Error ? e.message : t("scout.patrolFail"));
2026-07-13 08:59:13 +00:00
setCrawlerSessionRequired(
typeof e === "object" && e !== null && "code" in e && (e as { code?: number }).code === 400061,
);
setBusy("");
} finally {
2026-07-10 05:10:31 +00:00
setBusy("");
}
}
async function reloadPosts(): Promise<ScoutPost[]> {
const list = await repos.scout.listPosts();
setPosts(list);
return list;
}
async function regenDraft() {
if (!current) return;
setBusy("draft");
try {
2026-07-13 08:59:13 +00:00
const next = await repos.scout.draftOutreach(current.id);
2026-07-10 05:10:31 +00:00
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);
2026-07-13 08:59:13 +00:00
setTodayDone(bumpScoutTodayDone().done);
2026-07-10 05:10:31 +00:00
setMessage(t("scout.skipped"));
2026-07-13 08:59:13 +00:00
} catch (e) {
setMessage(e instanceof Error ? e.message : t("scout.patrolFail"));
2026-07-10 05:10:31 +00:00
} finally {
setBusy("");
}
}
async function sendCurrent() {
if (!current) return;
setBusy("send");
setMessage("");
try {
let text = draftText.trim();
if (!text) {
2026-07-13 08:59:13 +00:00
const d = await repos.scout.draftOutreach(current.id);
2026-07-10 05:10:31 +00:00
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);
2026-07-13 08:59:13 +00:00
const today = bumpScoutTodayDone();
setTodayDone(today.done);
2026-07-10 05:10:31 +00:00
const who = accounts.find((a) => a.id === accountId)?.username || t("scout.accountFallback");
2026-07-13 08:59:13 +00:00
setMessage(t("scout.queued", { who, done: today.done, goal }));
2026-07-10 05:10:31 +00:00
} 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);
2026-07-13 08:59:13 +00:00
setMessage(t("scout.deletedPost"));
} catch (e) {
setMessage(e instanceof Error ? e.message : t("common.error"));
2026-07-10 05:10:31 +00:00
} finally {
setBusy("");
}
}
const progressPct = Math.min(100, Math.round((todayDone / Math.max(1, goal)) * 100));
return (
<>
<PageHeader title={t("scout.title")} />
2026-07-13 08:59:13 +00:00
{loadError ? <EmptyState title={loadError} /> : null}
2026-07-10 05:10:31 +00:00
{message ? (
<p className="hb-banner-ok" role="status">
{message}
</p>
) : null}
2026-07-13 08:59:13 +00:00
{crawlerSessionRequired ? (
<p className="hb-banner-ok" role="alert">
{t("scout.crawlerSessionRequired")} <Link to="/app/settings">{t("scout.openSettings")}</Link>
</p>
) : null}
2026-07-10 05:10:31 +00:00
{/* ① 今日設定 */}
<Card title={t("scout.today")}>
<div className="hb-stack">
<div className="hb-tabs hb-tabs--sm" role="tablist">
<button
type="button"
className={`hb-tab ${purpose === "value" ? "is-active" : ""}`}
onClick={() => {
setPurpose("value");
const t = loadScoutToday();
setGoal(t.goalValue);
}}
>
{t("scout.purposeValue")}
</button>
<button
type="button"
className={`hb-tab ${purpose === "activity" ? "is-active" : ""}`}
onClick={() => {
setPurpose("activity");
setProductId("");
const t = loadScoutToday();
setGoal(t.goalActivity);
}}
>
{t("scout.purposeActivity")}
</button>
</div>
<div className="hb-scout-today-row">
<Input
label={t("scout.goal")}
type="number"
min={1}
max={99}
value={String(goal)}
onChange={(e) => setGoalPersist(Number(e.target.value) || 1)}
/>
<div className="hb-scout-progress">
<p className="hb-field__label" style={{ margin: 0 }}>
{t("scout.progress", { done: todayDone, goal })}
</p>
<div className="hb-progress" aria-hidden>
<div className="hb-progress__bar" style={{ width: `${progressPct}%` }} />
</div>
</div>
</div>
<Textarea
label={purpose === "activity" ? t("scout.keyword") : t("scout.intent")}
value={intent}
onChange={(e) => setIntent(e.target.value)}
rows={purpose === "activity" ? 2 : 3}
placeholder={
purpose === "activity"
? t("scout.keywordPh")
: t("scout.intentPh")
}
/>
{purpose === "value" ? (
<>
<Select
label={t("scout.productOptional")}
value={
productId && productOptions.some((o) => o.id === productId) ? productId : ""
}
onChange={(e) => setProductId(e.target.value)}
>
<option value="">{t("scout.noProduct")}</option>
{productOptions.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</Select>
{selectedProduct ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
{t("scout.placement", { label: selectedProduct.label })}
{selectedProduct.pain_points?.[0]
? t("scout.painPart", { pain: selectedProduct.pain_points[0] })
: ""}
</p>
) : null}
{allProducts.length === 0 ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
{t("scout.noProductsBefore")}{" "}
<Link to="/app/brands">{t("nav.brands")}</Link>{" "}
{t("scout.noProductsAfter")}
</p>
) : null}
</>
) : null}
<div className="hb-wizard-actions">
<Button
type="button"
onClick={() => void startPatrol()}
disabled={busy === "run" || !intent.trim()}
>
{busy === "run" ? t("scout.fetching") : pendingQueue.length ? t("scout.startMore") : t("scout.start")}
</Button>
</div>
</div>
</Card>
2026-07-13 08:59:13 +00:00
{visibleScanJob ? (
<Card title={t("scout.scanJob")}>
<div className="hb-inline-badges" role="status" aria-live="polite">
<Badge tone={jobStatusTone(visibleScanJob.status)}>
{jobStatusLabel(visibleScanJob.status, t)}
</Badge>
<span className="text-muted">
{visibleScanJob.progress_summary || t("scout.scanInProgress")}
{visibleScanJob.progress_percent ? ` · ${visibleScanJob.progress_percent}%` : ""}
</span>
</div>
</Card>
) : null}
{/* ② 海巡批次:只由使用者選取,不會因 Job 或貼文更新跳動。 */}
2026-07-10 05:10:31 +00:00
{runGroups.length > 0 ? (
<Card title={t("scout.runs")}>
<div className="hb-stack">
2026-07-13 08:59:13 +00:00
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{t("scout.runCount", { n: runGroups.length })}
</p>
<div className="hb-scout-run-list">
{runGroups.map((g) => (
<button
key={g.key}
type="button"
className={`hb-scout-run-item${activeRunKey === g.key ? " is-active" : ""}`}
onClick={() => selectRun(g.key)}
>
<span>{g.label}</span>
<span className="hb-scout-run-item__meta">
2026-07-10 05:10:31 +00:00
{g.pending > 0 ? t("scout.runPending", { n: g.pending }) : t("scout.runDone")}
{g.total ? t("scout.runTotal", { n: g.total }) : ""}
2026-07-13 08:59:13 +00:00
</span>
</button>
))}
</div>
<div className="hb-scout-run-row">
2026-07-10 05:10:31 +00:00
{activeRunKey ? (
<div className="hb-scout-run-row__action">
<Button
type="button"
variant="danger"
disabled={Boolean(busy)}
onClick={() => void removeRun(activeRunKey)}
>
{busy === "del-run" ? t("scout.deleting") : t("scout.deleteRun")}
</Button>
</div>
) : null}
</div>
</div>
</Card>
) : null}
{/* ③ 現在這一則(僅目前批次) */}
<Card title={t("scout.now")}>
{!current || (activeRunKey && postRunKey(current) !== activeRunKey) ? (
<EmptyState title={t("scout.emptyBatch")} />
) : (
<div className="hb-stack hb-scout-now">
<div className="hb-scout-now__meta">
<div className="hb-inline-badges">
<Badge tone="neutral">@{current.author}</Badge>
<Badge tone="brand">{current.search_tag}</Badge>
<Badge tone="neutral">{Math.round(current.score)}</Badge>
2026-07-13 08:59:13 +00:00
{current.scan_path ? <Badge tone="neutral">{t("scout.source", { source: current.scan_path })}</Badge> : null}
{current.classification ? (
<Badge tone="neutral">{t("scout.classification", { classification: current.classification })}</Badge>
) : null}
2026-07-10 05:10:31 +00:00
</div>
<p className="hb-scout-now__stance text-muted">
{stanceOf(current, t)}
{current.matched_product_label ? ` · ${current.matched_product_label}` : ""}
{activeRunMeta ? ` · ${shortRunLabel(activeRunMeta.label, 18)}` : ""}
</p>
</div>
<p className="hb-scout-now__text">{current.text}</p>
2026-07-13 08:59:13 +00:00
{current.created_at || current.permalink ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
{current.created_at ? t("scout.createdAt", { time: formatLocalDateTime(current.created_at) }) : ""}
{current.created_at && current.permalink ? " · " : ""}
{current.permalink ? (
<a href={current.permalink} target="_blank" rel="noreferrer">
{t("scout.openPermalink")}
</a>
) : null}
</p>
) : null}
2026-07-10 05:10:31 +00:00
{current.match_reason ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
{current.match_reason}
</p>
) : null}
{current.opportunity ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
{current.opportunity}
</p>
) : null}
<Textarea
label={t("scout.draft")}
value={draftText}
onChange={(e) => setDraftText(e.target.value)}
2026-07-13 08:59:13 +00:00
rows={current.scout_mode === "activity" ? 3 : 5}
placeholder={current.scout_mode === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")}
2026-07-10 05:10:31 +00:00
/>
2026-07-13 08:59:13 +00:00
<div>
2026-07-10 05:10:31 +00:00
<Select
label={t("scout.sendAccount")}
value={accountId}
onChange={(e) => setAccountId(e.target.value)}
>
{accounts.length === 0 ? (
<option value="">{t("scout.noAccount")}</option>
) : (
accounts.map((a) => (
<option key={a.id} value={a.id}>
{a.display_name} · @{a.username}
</option>
))
)}
</Select>
</div>
<div className="hb-wizard-actions">
<Button
type="button"
variant="ghost"
2026-07-13 08:59:13 +00:00
disabled={Boolean(busy) || !isPending(current)}
2026-07-10 05:10:31 +00:00
onClick={() => void skipCurrent()}
>
{busy === "skip" ? "…" : t("scout.skip")}
</Button>
<Button
type="button"
variant="ghost"
2026-07-13 08:59:13 +00:00
disabled={Boolean(busy) || !isPending(current)}
2026-07-10 05:10:31 +00:00
onClick={() => void regenDraft()}
>
{busy === "draft" ? "…" : t("scout.regen")}
</Button>
<Button
type="button"
2026-07-13 08:59:13 +00:00
disabled={Boolean(busy) || !accountId || !canSend(current)}
2026-07-10 05:10:31 +00:00
onClick={() => void sendCurrent()}
>
2026-07-13 08:59:13 +00:00
{busy === "send"
? t("scout.sending")
: current.outreach_status === "queued"
? t("scout.resend")
: t("scout.send")}
2026-07-10 05:10:31 +00:00
</Button>
<Button
type="button"
variant="ghost"
disabled={Boolean(busy)}
onClick={() => void removeCurrent()}
>
{t("common.delete")}
</Button>
</div>
{accounts.length === 0 ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
{t("scout.needAccountBefore")}{" "}
<Link to="/app/crew">{t("nav.crew")}</Link>{" "}
{t("scout.needAccountAfter")}
</p>
) : null}
</div>
)}
</Card>
{/* ④ 周邊知識 · 綁目前海巡批次 */}
2026-07-13 08:59:13 +00:00
{report?.mode !== "activity" &&
report?.theme_key === activeRunKey &&
report.research_notes?.length ? (
2026-07-10 05:10:31 +00:00
<Card
title={
report?.theme_label
? t("scout.knowledgeWithLabel", { label: report.theme_label })
: t("scout.knowledgeLearn")
}
>
<div className="hb-scout-learn">
<div className="hb-scout-learn__toolbar">
<Button
type="button"
variant={report?.research_notes?.length && !reportOpen ? "primary" : "ghost"}
onClick={() => setReportOpen((v) => !v)}
disabled={!report}
>
{reportOpen
? t("scout.collapseKnowledge")
: report?.research_notes?.length
? t("scout.expandLearn", { n: report.research_notes.length })
: t("scout.expandKnowledge")}
</Button>
{report?.theme_key ? (
<Button
type="button"
variant="ghost"
disabled={Boolean(busy)}
onClick={() => void removeRun(report.theme_key!)}
>
{t("scout.deleteKnowledgeRun")}
</Button>
) : null}
</div>
{!reportOpen || !report ? (
report ? (
report.research_notes && report.research_notes.length > 0 ? (
<>
<div className="hb-scout-learn-banner is-ready">
<p style={{ fontSize: "0.9rem", fontWeight: 600 }}>
{t("scout.notesCount", { n: report.research_notes.length })}
{report.product_label ? ` · ${report.product_label}` : ""}
</p>
</div>
<ul className="hb-scout-research hb-scout-research--preview">
{report.research_notes
.filter((n) => (n.tier || "core") === "core")
.slice(0, 2)
.map((n) => (
<KnowledgeNoteCard key={n.id} note={n} badge={t("scout.badgeCore")} compact />
))}
</ul>
</>
) : (
<p className="hb-scout-learn__intro">{t("scout.noKnowledge")}</p>
)
) : null
) : (
<>
{report.product_label ? (
<div className="hb-scout-context">
<p className="hb-field__label">{t("scout.product")}</p>
<p style={{ fontWeight: 600, margin: 0 }}>{report.product_label}</p>
{report.product_context ? (
<p style={{ margin: 0, fontSize: "0.9rem", lineHeight: 1.55 }}>
{report.product_context}
</p>
) : null}
</div>
) : null}
{report.pains.length > 0 ? (
<div className="hb-scout-context">
<p className="hb-field__label">
{report.product_label ? t("scout.painsSolved") : t("scout.focus")}
</p>
<div className="hb-chip-row">
{report.pains.map((p) => (
<span key={p} className="hb-chip is-static">
{p}
</span>
))}
</div>
</div>
) : null}
{report.research_notes && report.research_notes.length > 0 ? (
<div className="hb-scout-context hb-scout-learn">
<div className="hb-scout-research-tier__head">
<p className="hb-field__label" style={{ margin: 0 }}>
{t("scout.knowledge")}
</p>
<Badge tone="brand">{report.research_notes.length}</Badge>
</div>
<div className="hb-tabs hb-tabs--sm hb-scout-learn__tabs" role="tablist">
<button
type="button"
className={`hb-tab ${researchTier === "all" ? "is-active" : ""}`}
onClick={() => setResearchTier("all")}
>
{t("scout.all")}
</button>
{(["core", "adjacent", "broad"] as ScoutResearchTier[]).map((tier) => {
const n = report.research_notes!.filter(
(x) => (x.tier || "adjacent") === tier,
).length;
if (!n) return null;
return (
<button
key={tier}
type="button"
className={`hb-tab ${researchTier === tier ? "is-active" : ""}`}
onClick={() => setResearchTier(tier)}
title={t(`scout.tier.${tier}Hint`)}
>
{t(`scout.tier.${tier}`)}
<span className="hb-tab__count"> {n}</span>
</button>
);
})}
</div>
<div className="hb-scout-learn__tiers">
{(researchTier === "all"
? groupNotesByTier(report.research_notes)
: [
{
tier: researchTier,
notes: report.research_notes.filter(
(x) => (x.tier || "adjacent") === researchTier,
),
},
]
).map((group) =>
group.notes.length === 0 ? null : (
<div key={group.tier} className="hb-scout-research-tier">
{researchTier === "all" ? (
<div className="hb-scout-research-tier__head">
<Badge
tone={
group.tier === "core"
? "success"
: group.tier === "adjacent"
? "brand"
: "neutral"
}
>
{t(`scout.tier.${group.tier}`)}
</Badge>
</div>
) : null}
<ul className="hb-scout-research">
{group.notes.map((n) => (
<KnowledgeNoteCard key={n.id} note={n} />
))}
</ul>
</div>
),
)}
</div>
</div>
2026-07-13 08:59:13 +00:00
) : (
2026-07-10 05:10:31 +00:00
<p className="hb-scout-learn__intro">{t("scout.noWebSummary")}</p>
)}
</>
)}
</div>
</Card>
) : null}
2026-07-13 08:59:13 +00:00
{/* ⑤ 不同回覆策略不能混排,避免將短回誤當成痛點接話。 */}
<MatchQueue
title={t("scout.valueQueue", { n: valueQueue.length })}
posts={valueQueue}
page={valueQueuePage}
onPageChange={setValueQueuePage}
currentId={currentId}
onSelect={setCurrentId}
t={t}
/>
<MatchQueue
title={t("scout.activityQueue", { n: activityQueue.length })}
posts={activityQueue}
page={activityQueuePage}
onPageChange={setActivityQueuePage}
currentId={currentId}
onSelect={setCurrentId}
t={t}
/>
2026-07-10 05:10:31 +00:00
</>
);
}