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

1113 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
} from "../domain/types";
import {
groupNotesByTier,
noteLearnPoints,
noteReplyHooks,
formatResearchLink,
} from "../lib/mockResearch";
import { newId } from "../lib/id";
import { allowHttpUrl } from "../lib/externalUrl";
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 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>
);
}
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;
const safeUrl = allowHttpUrl(note.url);
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}
{safeUrl ? (
<a
className="hb-scout-research__link"
href={safeUrl}
target="_blank"
rel="noreferrer"
title={safeUrl}
>
<span className="hb-scout-research__link-label">{link.label}</span>
<span className="hb-scout-research__link-host">{link.host}</span>
</a>
) : null}
</li>
);
}
/**
* 海巡:今日目標 + 現在這一則 + 收合功課/佇列
*/
export function ScoutPage() {
const repos = useRepos();
const { tick } = useData();
const { t } = useI18n();
const { jobs: liveJobs, reload: reloadJobs } = useJobLive();
const [brands, setBrands] = useState<Brand[]>([]);
const [allProducts, setAllProducts] = useState<BrandProduct[]>([]);
const [posts, setPosts] = useState<ScoutPost[]>([]);
const [purpose, setPurpose] = useState<ScoutPurpose>("value");
const [intent, setIntent] = useState("");
const [productId, setProductId] = 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);
const [valueQueuePage, setValueQueuePage] = useState(1);
const [activityQueuePage, setActivityQueuePage] = useState(1);
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("");
const [scanJob, setScanJob] = useState<Job | null>(null);
const [scanJobThemeKey, setScanJobThemeKey] = useState<string | null>(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] = await Promise.all([
repos.scout.listBrands(),
repos.scout.listHomework(),
repos.scout.listAllProducts(),
repos.scout.listPosts(),
]);
setBrands(b);
setHomeworkList(hw);
setAllProducts(prods);
setPosts(postList);
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.
}
});
const scanJobId = scanJob?.id;
const scanJobStatus = scanJob?.status;
useEffect(() => {
if (!scanJobId || !scanJobStatus || ["succeeded", "failed", "cancelled"].includes(scanJobStatus)) return;
let cancelled = false;
const poll = () => pollScanJob(scanJobId, () => cancelled);
void poll();
const id = window.setInterval(() => void poll(), 1500);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [scanJobId, scanJobStatus]);
// 選取貼文只帶入草稿;批次由使用者明確選取,不能反向切換。
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<ScoutPost[]> {
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("");
}
}
function openThreadsReply() {
if (!current) return;
const url = allowHttpUrl(current.permalink);
if (!url) {
setMessage(t("scout.noPermalink"));
return;
}
window.open(url, "_blank", "noopener,noreferrer");
const text = draftText.trim();
if (text && navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(text).then(
() => setMessage(t("scout.openedAndCopied")),
() => setMessage(t("scout.openedManual")),
);
} else {
setMessage(t("scout.openedManual"));
}
}
async function markManualDone() {
if (!current) return;
const id = current.id;
setBusy("manual-done");
setMessage("");
try {
await repos.scout.markPublished(id);
const list = await reloadPosts();
pickNext(id, list);
const today = bumpScoutTodayDone();
setTodayDone(today.done);
setMessage(t("scout.manualDone", { done: today.done, goal }));
} catch (e) {
setMessage(e instanceof Error ? e.message : t("scout.manualDoneFail"));
} 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 (
<>
<PageHeader title={t("scout.title")} />
{loadError ? <EmptyState title={loadError} /> : null}
{message ? (
<p className="hb-banner-ok" role="status">
{message}
</p>
) : null}
{crawlerSessionRequired ? (
<p className="hb-banner-ok" role="alert">
{t("scout.crawlerSessionRequired")} <Link to="/app/settings">{t("scout.openSettings")}</Link>
</p>
) : null}
{/* ① 今日設定 */}
<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>
{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 或貼文更新跳動。 */}
{runGroups.length > 0 ? (
<Card title={t("scout.runs")}>
<div className="hb-stack">
<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">
{g.pending > 0 ? t("scout.runPending", { n: g.pending }) : t("scout.runDone")}
{g.total ? t("scout.runTotal", { n: g.total }) : ""}
</span>
</button>
))}
</div>
<div className="hb-scout-run-row">
{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>
{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}
</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>
{current.created_at || allowHttpUrl(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 && allowHttpUrl(current.permalink) ? " · " : ""}
{allowHttpUrl(current.permalink) ? (
<a href={allowHttpUrl(current.permalink) ?? undefined} target="_blank" rel="noreferrer">
{t("scout.openPermalink")}
</a>
) : null}
</p>
) : null}
{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)}
rows={current.scout_mode === "activity" ? 3 : 5}
placeholder={current.scout_mode === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")}
/>
<p className="text-muted" style={{ fontSize: "0.85rem", margin: 0 }}>
{t("scout.manualReplyHint")}
</p>
<div className="hb-wizard-actions">
<Button
type="button"
variant="ghost"
disabled={Boolean(busy) || !isPending(current)}
onClick={() => void skipCurrent()}
>
{busy === "skip" ? "…" : t("scout.skip")}
</Button>
<Button
type="button"
variant="ghost"
disabled={Boolean(busy) || !isPending(current)}
onClick={() => void regenDraft()}
>
{busy === "draft" ? "…" : t("scout.regen")}
</Button>
<Button
type="button"
disabled={Boolean(busy) || !allowHttpUrl(current.permalink)}
onClick={openThreadsReply}
>
{t("scout.openThreadsReply")}
</Button>
<Button
type="button"
variant="ghost"
disabled={Boolean(busy) || !isPending(current)}
onClick={() => void markManualDone()}
>
{busy === "manual-done" ? "…" : t("scout.markManualDone")}
</Button>
<Button
type="button"
variant="ghost"
disabled={Boolean(busy)}
onClick={() => void removeCurrent()}
>
{t("common.delete")}
</Button>
</div>
</div>
)}
</Card>
{/* ④ 周邊知識 · 綁目前海巡批次 */}
{report?.mode !== "activity" &&
report?.theme_key === activeRunKey &&
report.research_notes?.length ? (
<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>
) : (
<p className="hb-scout-learn__intro">{t("scout.noWebSummary")}</p>
)}
</>
)}
</div>
</Card>
) : null}
{/* ⑤ 不同回覆策略不能混排,避免將短回誤當成痛點接話。 */}
<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}
/>
</>
);
}