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

1100 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, 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 | 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();
const { refresh, tick } = useData();
const { t } = useI18n();
const [brands, setBrands] = useState<Brand[]>([]);
const [allProducts, setAllProducts] = useState<BrandProduct[]>([]);
const [personas, setPersonas] = useState<Persona[]>([]);
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 [personaId, setPersonaId] = 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);
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<ScoutHomeworkRecord[]>([]);
/** 背景補完整功課(不擋主流程);綁定批次 key */
const [homeworkLoadingKey, setHomeworkLoadingKey] = useState<string | null>(null);
/** 目前檢視的海巡批次(每次按開始 = 一筆) */
const [activeRunKey, setActiveRunKey] = useState<string | null>(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 內再 setStateStrict 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<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, 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 (
<>
<PageHeader title={t("scout.title")} />
{message ? (
<p className="hb-banner-ok" role="status">
{message}
</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>
{/* ② 海巡批次:單一 spar 選單 + 刪除 */}
{runGroups.length > 0 ? (
<Card title={t("scout.runs")}>
<div className="hb-stack">
<div className="hb-scout-run-row">
<Select
label={t("scout.runCount", { n: runGroups.length })}
aria-label={t("scout.runSelectAria")}
value={activeRunKey || ""}
onChange={(e) => {
const k = e.target.value;
if (k) selectRun(k);
}}
>
{runGroups.map((g) => (
<option key={g.key} value={g.key}>
{g.pending > 0 ? t("scout.runPending", { n: g.pending }) : t("scout.runDone")}
{g.label}
{g.total ? t("scout.runTotal", { n: g.total }) : ""}
</option>
))}
</Select>
{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>
</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.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={purpose === "activity" ? 3 : 5}
placeholder={purpose === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")}
/>
<div className="hb-grid-2">
<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>
<Select
label={t("scout.personaForRegen")}
value={personaId}
onChange={(e) => setPersonaId(e.target.value)}
>
<option value=""></option>
{personas.map((p) => (
<option key={p.id} value={p.id}>
{personaOptionLabel(p)}
{!isPersonaReady(p) ? t("scout.notReady") : ""}
</option>
))}
</Select>
</div>
<div className="hb-wizard-actions">
<Button
type="button"
variant="ghost"
disabled={Boolean(busy)}
onClick={() => void skipCurrent()}
>
{busy === "skip" ? "…" : t("scout.skip")}
</Button>
<Button
type="button"
variant="ghost"
disabled={Boolean(busy)}
onClick={() => void regenDraft()}
>
{busy === "draft" ? "…" : t("scout.regen")}
</Button>
<Button
type="button"
disabled={Boolean(busy) || !accountId}
onClick={() => void sendCurrent()}
>
{busy === "send" ? t("scout.sending") : t("scout.send")}
</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>
{/* ④ 周邊知識 · 綁目前海巡批次 */}
{purpose === "value" &&
((report && report.theme_key === activeRunKey) ||
(homeworkLoadingKey && homeworkLoadingKey === activeRunKey)) ? (
<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>
{homeworkLoadingKey === activeRunKey ? (
<div className="hb-scout-learn-banner is-loading">
<p style={{ fontSize: "0.9rem", fontWeight: 600 }}>{t("scout.loadingKnowledge")}</p>
</div>
) : null}
{!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>
) : homeworkLoadingKey === activeRunKey ? null : (
<p className="hb-scout-learn__intro">{t("scout.noWebSummary")}</p>
)}
</>
)}
</div>
</Card>
) : null}
{/* ⑤ 佇列(目前批次其餘待回) */}
<Card title={t("scout.queue", { n: queueRest.length })}>
<div className="hb-wizard-actions" style={{ marginBottom: queueOpen ? "0.65rem" : 0 }}>
<Button type="button" variant="ghost" onClick={() => setQueueOpen((v) => !v)}>
{queueOpen ? t("scout.collapseQueue") : t("scout.expandQueue")}
</Button>
</div>
{!queueOpen ? null : queueRest.length === 0 ? (
<EmptyState title={t("scout.noOtherPending")} />
) : (
<div className="hb-stack">
{pageSlice(queueRest, queuePage, QUEUE_PAGE).map((p) => (
<button
key={p.id}
type="button"
className="hb-scout-queue-item"
onClick={() => setCurrentId(p.id)}
>
<span className="hb-scout-queue-item__meta">
@{p.author} · {p.search_tag} · {Math.round(p.score)}
</span>
<span className="hb-scout-queue-item__text">
{p.text.slice(0, 72)}
{p.text.length > 72 ? "…" : ""}
</span>
</button>
))}
<Pager
total={queueRest.length}
page={queuePage}
pageSize={QUEUE_PAGE}
onPageChange={setQueuePage}
/>
</div>
)}
</Card>
</>
);
}