1100 lines
39 KiB
TypeScript
1100 lines
39 KiB
TypeScript
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 內再 setState(Strict 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>
|
||
</>
|
||
);
|
||
}
|